Compare commits
340 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fa69dec3e | |||
| 3d5f3303d1 | |||
| 10883e92b1 | |||
| e2d28333ec | |||
| 6d55d4832f | |||
| 46c563873f | |||
| 10c5d3c408 | |||
| 942080c460 | |||
| 6a0869ca0a | |||
| d9992dd2f2 | |||
| c8c9631518 | |||
| 943f29f227 | |||
| 80a069077c | |||
| e344e55e6a | |||
| a69947b69a | |||
| 926f593f50 | |||
| f13734a4e9 | |||
| 2107166926 | |||
| 60e5601690 | |||
| 0957d9ef88 | |||
| eb481c57ec | |||
| e0b1666f5d | |||
| f2afc088af | |||
| 971d59b2c6 | |||
| 83fe58056f | |||
| 5f107922d0 | |||
| a2ea6b7bf7 | |||
| 1e0e728840 | |||
| cea6ed3f95 | |||
| f15705ed47 | |||
| 0b9c13ae4b | |||
| d0c8ae02f0 | |||
| 4eda7401b9 | |||
| 0c2f3c9399 | |||
| 541ecae48c | |||
| 82a551f1d4 | |||
| fd2253135d | |||
| d0820aa47e | |||
| 09c5800f49 | |||
| 52aa2dc6a8 | |||
| 6418ee7735 | |||
| 25aef89b4f | |||
| 77d17f97d3 | |||
| d5c1ec3aea | |||
| 97a7384381 | |||
| 27b7bfccba | |||
| ab15624a93 | |||
| a9c5d8ed69 | |||
| 234b90796e | |||
| 5f018ba22f | |||
| e11cbbcc18 | |||
| ba4d30d8db | |||
| d11431769c | |||
| d1d1bbb3e4 | |||
| 823f30882a | |||
| d36f389469 | |||
| add686a4d5 | |||
| 59be2d8f02 | |||
| aa3db49f65 | |||
| c101cb3d3f | |||
| 63912af43b | |||
| d19263dd79 | |||
| f8c9c59a03 | |||
| 5595f7a954 | |||
| bac4527945 | |||
| 51da8bcb72 | |||
| 8aa6e7e245 | |||
| 5029b35d14 | |||
| 2412027d9b | |||
| ddff5219e5 | |||
| 346bc18b58 | |||
| c256ae3664 | |||
| 2b47358a9e | |||
| e0b041ce44 | |||
| 3f64dee927 | |||
| 67c5ebeabd | |||
| 84bc8e96cc | |||
| 2d30cec7c6 | |||
| 5f5f66178b | |||
| e9fadec0ac | |||
| 10008e51ff | |||
| 3098610d17 | |||
| 7a2566d320 | |||
| 01ba8ca53e | |||
| 2a0a2dccff | |||
| 66a6f32d4c | |||
| 02b3f61953 | |||
| dc08526255 | |||
| 2ca339610b | |||
| ffe65937c1 | |||
| 4b6f03eef5 | |||
| d2f530a7fe | |||
| 44ea4b6b13 | |||
| ebc8421a70 | |||
| 526f142dea | |||
| ca431d36e6 | |||
| 68a81bdd89 | |||
| 68a3d713ef | |||
| 50d3d08e38 | |||
| 9a2fec4d8f | |||
| e68b1d2092 | |||
| 6dfedef81e | |||
| 317e39c823 | |||
| 0072a3cb71 | |||
| 8d021c1530 | |||
| 23606dc2d4 | |||
| 0838c4196d | |||
| 65605a0de2 | |||
| a20ac80281 | |||
| 02e83fb0d0 | |||
| eddad0db6e | |||
| ffad0afb61 | |||
| 83eed8e1cd | |||
| 0c7c8abade | |||
| a064890ed8 | |||
| f310f4a35e | |||
| 0268e09882 | |||
| 449814d0df | |||
| a3d3577103 | |||
| a3086fdd2c | |||
| daafc6a41e | |||
| c27ee116f7 | |||
| 5167cde443 | |||
| 34542d3a9c | |||
| e73d1df4cc | |||
| f9a95c3d48 | |||
| 2dd2b5fdbf | |||
| 896f1db9d4 | |||
| a1bd2d775f | |||
| 8cbe23f9ed | |||
| edc891c93d | |||
| 2d706a4be0 | |||
| 32cb81ef34 | |||
| 88aa3c4a3d | |||
| e813d3ccb7 | |||
| a24d7a4e24 | |||
| 39987b9717 | |||
| c77a1ea4f8 | |||
| 64b5d90d66 | |||
| 5a1fb32e1d | |||
| c4d8108e8c | |||
| 1350d80083 | |||
| e0cc218007 | |||
| bcaf8f2247 | |||
| d1190ed9dc | |||
| 513d6037d4 | |||
| e86012b2ab | |||
| 214e381dc5 | |||
| aa79cb670b | |||
| f0106e15fa | |||
| 1d194c6be2 | |||
| 6917890007 | |||
| 1c69de7ecd | |||
| 62188793d9 | |||
| 585ee2d42b | |||
| 2a58cfd45c | |||
| 93003c5fe0 | |||
| 91b0787d72 | |||
| 315277d4b0 | |||
| 1821c0d89d | |||
| 5650da0c5f | |||
| 0ca8b73cef | |||
| 6bab26ab27 | |||
| a72243e7fa | |||
| 5bd86d9cf7 | |||
| 00fd3fbadb | |||
| b806f40797 | |||
| c82aa1d253 | |||
| 071919f0db | |||
| ab41e96ba5 | |||
| 95a470ba7b | |||
| 1e0653ed33 | |||
| 3f0df08031 | |||
| 8c3bc8a5ba | |||
| 3a5a8f48e4 | |||
| f4b99ffb09 | |||
| 0c6afdebc3 | |||
| 98b382ba2f | |||
| 54a24385b1 | |||
| 3eeb84cdae | |||
| 204a10ae14 | |||
| ba1dd7547e | |||
| 238585c257 | |||
| d8ebe1ad13 | |||
| 819df3e93a | |||
| 99e46feafa | |||
| a432c8ab54 | |||
| d740b7560c | |||
| 91c4c73d7e | |||
| 4ce2384f27 | |||
| 02a6289d56 | |||
| 52ae6eba81 | |||
| 080b6e74a2 | |||
| ad7d32023c | |||
| 1baf8fbe37 | |||
| 758ca6a373 | |||
| 65504d0a23 | |||
| 0409f4e2e2 | |||
| 8ca63e939a | |||
| 25a621fd51 | |||
| 3f71a8aff7 | |||
| 6ec81c37b9 | |||
| 8a9ff98da8 | |||
| bb7e29eeac | |||
| 27d8783104 | |||
| 9a01e6db43 | |||
| b341ddf9d9 | |||
| 3c8d0ac859 | |||
| 3f529c0dee | |||
| a6734e542d | |||
| 2287a67dfd | |||
| 31dbaa80d7 | |||
| b1846a3ea9 | |||
| e1a7e9e622 | |||
| 0ae48faedb | |||
| b8122222f8 | |||
| 5e5a39c22e | |||
| 1e7cf214e9 | |||
| d7e7a2df9a | |||
| 584aa35894 | |||
| 652ae8057a | |||
| 0bc0a1354a | |||
| 7d30838813 | |||
| 3da178ed02 | |||
| e6ac3e3812 | |||
| 62087e20b5 | |||
| b7a47c6eb6 | |||
| 9b14baca51 | |||
| eb1a75c9f5 | |||
| 5e79106134 | |||
| ee45ec356e | |||
| 9275bff828 | |||
| 9150c70618 | |||
| b792e749d6 | |||
| 9871572740 | |||
| 52c1cb3cbc | |||
| d5b2b371c8 | |||
| d5cb022b02 | |||
| 021f62fd30 | |||
| 684e398798 | |||
| 12b64e9978 | |||
| 273df23f20 | |||
| a9b0dbf3eb | |||
| 5f56e068d3 | |||
| 92b82132d8 | |||
| 79ae9d3a1c | |||
| 86714a93f9 | |||
| 553f6f8cf6 | |||
| b561f67c12 | |||
| d03f9b80c7 | |||
| 6d9a5fb578 | |||
| 44d19245c4 | |||
| ce36c268cc | |||
| 1357b07921 | |||
| d4bfdcb749 | |||
| 39621706c8 | |||
| 4684e7a1e5 | |||
| 16bb3c8ad3 | |||
| 16fbdcc655 | |||
| 416d268352 | |||
| 44fb88f368 | |||
| f050c12253 | |||
| 82486d121e | |||
| 2bf382dbac | |||
| ab562e0df0 | |||
| 02e8fba9d0 | |||
| 530df33ba4 | |||
| 8b2e8cb681 | |||
| 2cb8c937e1 | |||
| ea5c08fb58 | |||
| 2507168916 | |||
| ec6e08f0d3 | |||
| ceee7cc977 | |||
| ece05cd9e9 | |||
| 2345016aa3 | |||
| d349352812 | |||
| e002ca2cb2 | |||
| 849afb4aa4 | |||
| 943628e6fe | |||
| 6621de95c0 | |||
| 87797153be | |||
| 4e833f9b8c | |||
| 560a2c362f | |||
| 71e9e1ebe2 | |||
| 73ed24aecf | |||
| 09eff6cf8e | |||
| ae7a36523b | |||
| 48a111da76 | |||
| 0f94a7cdcd | |||
| 79761b16bf | |||
| 7235caae0b | |||
| 2f294cdd21 | |||
| 33a124c1d2 | |||
| 91ed42ff08 | |||
| a2ffb3396e | |||
| 6846d58d46 | |||
| 0177ae064f | |||
| 72caed0138 | |||
| 332ce9e54d | |||
| c1ec6ed9fd | |||
| 203d082317 | |||
| 80067a2e93 | |||
| 9807eb33d3 | |||
| 063d7517e7 | |||
| f3ea09ad4a | |||
| 9650eb07d1 | |||
| 63c049abce | |||
| 02d501175d | |||
| f49ca0f72c | |||
| f5613c6e5e | |||
| fce5790765 | |||
| 5a0a65e591 | |||
| 4b4de9528b | |||
| 88f3c239bf | |||
| 293c1bebba | |||
| f84273dd28 | |||
| 5430f36e24 | |||
| 97f0ffd294 | |||
| 5cf8ba3d15 | |||
| aaecee1e9b | |||
| 701f8b258d | |||
| a222e7aea1 | |||
| ef668bd6a8 | |||
| 5082ccba70 | |||
| 9d124310d1 | |||
| ba9d85333f | |||
| 3b40897809 | |||
| 4bbd7d3e4a | |||
| c0bc43050c | |||
| 6b1b600ac8 | |||
| 12ec36b6d1 | |||
| 3571f0b84f | |||
| f9f18be746 | |||
| 8b8d674acb | |||
| eeb417234b | |||
| 869f77fb8e | |||
| ea6ea561d2 | |||
| 9a8217cf3f | |||
| 1c0e26d9e8 | |||
| 1d3b21717e |
@@ -1,9 +1,15 @@
|
||||
# Bot Configuration
|
||||
BOT_TOKEN=
|
||||
|
||||
# настройки реф программы юзернейм без @!
|
||||
BOT_USERNAME=
|
||||
REFERRAL_FIRST_REWARD=100.0
|
||||
REFERRAL_REFERRED_BONUS=100.0
|
||||
REFERRAL_THRESHOLD=200.0
|
||||
REFERRAL_PERCENTAGE=0.2
|
||||
|
||||
# RemnaWave API Configuration
|
||||
REMNAWAVE_URL=https://example.com
|
||||
REMNAWAVE_MODE=remote
|
||||
REMNAWAVE_TOKEN=
|
||||
|
||||
# Admin Configuration (comma-separated list of Telegram user IDs)
|
||||
@@ -12,11 +18,54 @@ ADMIN_IDS=123445567,12342123
|
||||
# Support Configuration
|
||||
SUPPORT_USERNAME=support
|
||||
|
||||
# Database Configuration (optional, defaults to SQLite)
|
||||
# DATABASE_URL=sqlite+aiosqlite:///bot.db
|
||||
TRIAL_ENABLED=true # Включить триал
|
||||
TRIAL_DURATION_DAYS=3 # Дней триала
|
||||
TRIAL_TRAFFIC_GB=2 # Лимит трафика у триал подписки
|
||||
TRIAL_SQUAD_UUID= # UUID сквада из панели remnawave /dashboard/management/internal-squads
|
||||
TRIAL_PRICE=0.0 # Оставить 0!
|
||||
TRIAL_NOTIFICATION_ENABLED=true # Уведомление об истекшей триальной подписке
|
||||
TRIAL_NOTIFICATION_HOURS_AFTER=1 # Через сколько отсылать сообщение
|
||||
TRIAL_NOTIFICATION_HOURS_WINDOW=23 # Через сколько выслать повторно
|
||||
|
||||
TRIAL_ENABLED=true
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_GB=2
|
||||
TRIAL_SQUAD_UUID=
|
||||
TRIAL_PRICE=0.0
|
||||
# Monitor Service Settings (дополнительные настройки)
|
||||
MONITOR_CHECK_INTERVAL=21600 # Промежуток проверики (3600 - будет раз в час проверять и слать уведомления)
|
||||
MONITOR_DAILY_CHECK_HOUR=10 # Ежедневный чек в 10 утра
|
||||
MONITOR_WARNING_DAYS=2 # За сколько начинать до окончания подписки слать уведомления
|
||||
DELETE_EXPIRED_TRIAL_DAYS=1 # Через сколько дней после истечения удалять триал подписки
|
||||
DELETE_EXPIRED_REGULAR_DAYS=7 # Через сколько дней после истечения удалять обычные подписки
|
||||
AUTO_DELETE_ENABLED=true # Включить автоматическое удаление при ежедневной проверке
|
||||
|
||||
|
||||
LUCKY_GAME_ENABLED=true # Включить/выключить игру удачи
|
||||
LUCKY_GAME_REWARD=50.0 # Размер награды за выигрыш в рублях
|
||||
LUCKY_GAME_NUMBERS=30 # Общее количество чисел для выбора (1-30)
|
||||
LUCKY_GAME_WINNING_COUNT=25 # Количество выигрышных чисел из общего количества
|
||||
|
||||
# ================================
|
||||
# TELEGRAM STARS НАСТРОЙКИ
|
||||
# ================================
|
||||
# Включить/выключить пополнение через Telegram Stars
|
||||
STARS_ENABLED=true
|
||||
# Курсы обмена звезд на рубли
|
||||
# Формат: STARS_{количество_звезд}_RATE={рубли}
|
||||
# 100 звёзд
|
||||
STARS_100_RATE=110
|
||||
# 150 звёзд
|
||||
STARS_150_RATE=165
|
||||
# 250 звёзд
|
||||
STARS_250_RATE=280
|
||||
# 350 звёзд
|
||||
STARS_350_RATE=355
|
||||
# 500 звёзд
|
||||
STARS_500_RATE=550
|
||||
# 750 звёзд
|
||||
STARS_750_RATE=800
|
||||
# 1000 звёзд
|
||||
STARS_1000_RATE=1000
|
||||
|
||||
|
||||
TRIBUTE_ENABLED=true # true/false включить/выключить пополнение баланса с помощью доната на Tribute
|
||||
TRIBUTE_API_KEY= # API ключ из настроек Tribute
|
||||
TRIBUTE_WEBHOOK_PORT=8081
|
||||
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
|
||||
TRIBUTE_DONATE_LINK=https://t.me/tribute/app?startapp=XXXXXXXX # укажите ссылку на донат в Tribute
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: BedolagaBot
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: fr1ngg/remnawave-bedolaga-telegram-bot:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Build and Publish Docker Image
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Generate security report
|
||||
uses: docker/scout-action@v1
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
command: quickview,compare
|
||||
image: ${{ steps.meta.outputs.tags }}
|
||||
to: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
ignore-unchanged: true
|
||||
only-severities: critical,high
|
||||
write-comment: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,2 @@
|
||||
/screens
|
||||
/assets
|
||||
@@ -0,0 +1,46 @@
|
||||
# Use Python 3.11 slim image
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
g++ \
|
||||
libpq-dev \
|
||||
curl \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd -r botuser && useradd -r -g botuser botuser
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
|
||||
pip install --no-cache-dir -r requirements.txt --prefer-binary
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /app/logs /app/data && \
|
||||
chown -R botuser:botuser /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER botuser
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD python -c "import asyncio; import sys; sys.exit(0)"
|
||||
|
||||
# Default command
|
||||
CMD ["python3", "main.py"]
|
||||
@@ -1,155 +1,638 @@
|
||||
#Описание
|
||||
# 🤖 Remnawave Bedolaga Bot
|
||||
|
||||
RemnaWave Telegram Bot — это многофункциональный бот для управления подписками(Для каждой подписки возможно назначить свой сквад со своими инбаундами - нововведение Remnawave 2.0.0+), балансом, промокодами, тестовой подпиской и рассылками пользователям через Telegram.
|
||||
Бот интегрирован с системой RemnaWave версии 2.0.8
|
||||
<div align="center">
|
||||
|
||||
#Основные возможности
|
||||
[](https://hub.docker.com/r/fr1ngg/remnawave-bedolaga-telegram-bot)
|
||||
[](https://python.org)
|
||||
[](https://postgresql.org)
|
||||
[](LICENSE)
|
||||
[](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/stargazers)
|
||||
|
||||
Мультиязычный интерфейс (на данный момент русский и английский языки)
|
||||
Создание и покупка подписок с управлением трафиком, длительностью и ценой
|
||||
Бесплатная тестовая подписка с ограничениями
|
||||
Пополнение баланса: 1) Через саппорт в ручную 2) Отправка заявки с суммой админу (С возможность подтвердить/отклонить заявку)
|
||||
Управление балансом пользователей (пополнение, списание)
|
||||
Промокоды со скидками и ограничениями по использованию
|
||||
Полноценная админ-панель с контролем пользователей, созданием подписок(Подтягивает UUID сквада из панели), платежей и статистикой
|
||||
Рассылка сообщений отдельным пользователям и всем сразу
|
||||
Интеграция с RemnaWave API для управления подписками и пользователями RemnaWave
|
||||
История платежей(Не работает, в доработке) и управление платежами (подтверждение, отклонение)
|
||||

|
||||
|
||||
#Требования
|
||||
Python 3.8+
|
||||
PostgreSQL, SQLite или другая поддерживаемая SQL-база данных
|
||||
Токен Telegram-бота
|
||||
URL и токен RemnaWave API
|
||||
Список администраторов Telegram ID
|
||||
**Современный Telegram-бот для управления VPN подписками через Remnawave API**
|
||||
|
||||
#Установка
|
||||
*Полнофункциональное решение с управлением пользователями, платежами и администрированием*
|
||||
|
||||
1) Клонируйте репозиторий:
|
||||
[🚀 Быстрый старт](#-быстрый-старт) • [📖 Документация](#-документация) • [💬 Поддержка](#-поддержка) • [🤝 Вклад](#-вклад-в-проект)
|
||||
|
||||
git clone https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
cd remnawave-bedolaga-telegram-bot
|
||||
</div>
|
||||
|
||||
2) Настройте handlers.py! В нем механизм подмены поддомена адреса подписки обязательно заменить на свой! Строка 997+
|
||||
---
|
||||
|
||||
# Fallback Замена поддомена у ссылки подписки
|
||||
config = kwargs.get('config')
|
||||
if config and 'adminka.' in config.REMNAWAVE_URL:
|
||||
base_url = config.REMNAWAVE_URL.replace('adminka.', 'sub.')
|
||||
## ✨ Основные возможности
|
||||
|
||||
3) В remnawave_api строки 187-206 заменить поддомен панели на свой!
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
# Формируем правильную ссылку на основе base_url
|
||||
# Заменяем adminka на sub в URL
|
||||
if 'adminka.' in self.base_url:
|
||||
sub_url = self.base_url.replace('adminka.', 'sub.')
|
||||
else:
|
||||
sub_url = self.base_url
|
||||
|
||||
subscription_url = f"{sub_url}/sub/{short_uuid}"
|
||||
logger.info(f"Generated subscription URL: {subscription_url}")
|
||||
|
||||
return subscription_url
|
||||
### 👤 **Для пользователей**
|
||||
- 💰 **Управление балансом** - Telegram Stars + Telegram Tribute + P2P через поддержку
|
||||
- 🛒 **Покупка подписок** - различные тарифы с настройкой squad
|
||||
- 📱 **Управление подписками** - просмотр, продление, ссылки, настройка автопродления
|
||||
- 🎁 **Промокоды** - денежные бонусы
|
||||
- 👥 **Реферальная программа** - зарабатывай с друзей и их последующих пополнений
|
||||
- 🎰 **Игра удачи** - ежедневные бонусы
|
||||
- 🆓 **Тестовая подписка** - бесплатная пробная версия
|
||||
- ♾️ **Автопродление** - настраиваемое автообновление
|
||||
- 🌐 **Мультиязычность** - русский и английский
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get subscription URL: {e}")
|
||||
# Fallback с заменой домена
|
||||
if 'adminka.' in self.base_url:
|
||||
sub_url = self.base_url.replace('adminka.', 'sub.')
|
||||
else:
|
||||
sub_url = self.base_url
|
||||
return f"{sub_url}/sub/{short_uuid}"
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
4) Создайте виртуальное окружение и активируйте его:
|
||||
### ⚙️ **Для администраторов**
|
||||
- 📊 **Детальная статистика** - пользователи, платежи, подписки
|
||||
- 👥 **Управление пользователями** - поиск, редактирование, баланс
|
||||
- 💳 **Управление платежами** - одобрение, история, Telegram Stars
|
||||
- 🎫 **Управление промокодами** - создание, статистика, массовые операции
|
||||
- 🖥 **Мониторинг системы** - состояние нод, синхронизация с Remnawave
|
||||
- 📨 **Рассылки** - уведомления пользователям
|
||||
- 🔍 **Мониторинг подписок** - автоуведомления об истечении
|
||||
- 📋 **Правила сервиса** - настройка через админ-панель
|
||||
- ♾️ **Статистика автопродления** - полный контроль
|
||||
|
||||
suo apt install python3
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
5) Установите зависимости:
|
||||
# 🚀 Автоматический установщик
|
||||
|
||||
pip install -r requirements.txt
|
||||
(ТЕСТИРУЕТСЯ, ДРУЗЬЯ КАК БЫ ВАМ НЕ ХОТЕЛОСЬ, ЧТОБЫ ВСЕ САМО ПОСТАВИЛОСЬ, НО Я ПОКА НЕ МОГУ ЗАСТАВИТЬ СКРИПТ КОРРЕКТНО НАСТРАИВАТЬ ВАМ ВЕБХУК С ОБРАТНЫМ ПРОКСИ, ПОЭТОМУ СОБИРАЕМ РУЧКАМИ ДОКЕР БОТА, ПОДКЛЮЧАЕМ ЕГО К СЕТИ ВАШЕГО NGINX/CADDY ДОКЕРА. ДЕЛАЕМ РУЧКАМИ ОБРАТНО ПРОКСИ НА ХУК /tribute-webhook И КАЙФУЕМ - НЕЙРОНКА КЛАУД В ПОМОЩЬ)
|
||||
|
||||
6) Создайте файл .env в корне проекта и заполните его необходимыми переменными окружения. Пример:
|
||||
## Структура проекта
|
||||
|
||||
BOT_TOKEN=ваш_telegram_bot_token
|
||||
REMNAWAVE_URL=https://your-remnawave-url.ru
|
||||
REMNAWAVE_MODE=remote/local
|
||||
REMNAWAVE_TOKEN=ваш_remnawave_token
|
||||
DATABASE_URL=sqlite+aiosqlite:///bot.db
|
||||
ADMIN_IDS=123456789,987654321
|
||||
DEFAULT_LANGUAGE=ru
|
||||
SUPPORT_USERNAME=support
|
||||
TRIAL_ENABLED=true
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_GB=2
|
||||
TRIAL_SQUAD_UUID=19bd5bde-5eea-4368-809c-6ba1ffb93897
|
||||
TRIAL_PRICE=0.0
|
||||
После установки в `/opt/bedolaga-bot/`:
|
||||
|
||||
7) Запустите бота:
|
||||
```
|
||||
/opt/bedolaga-bot/
|
||||
├── docker-compose.yml # Конфигурация контейнеров
|
||||
├── .env # Переменные окружения
|
||||
├── logs/ # Логи бота
|
||||
├── data/ # Данные приложения
|
||||
└── backups/ # Резервные копии БД
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> 💡 **Совет:** После установки добавьте установщик в закладки для быстрого доступа к меню управления!
|
||||
|
||||
## 💡 Полезные команды
|
||||
|
||||
# Быстрый перезапуск
|
||||
docker compose restart
|
||||
|
||||
# Очистка логов
|
||||
docker compose logs --tail=100
|
||||
|
||||
# Проверка использования ресурсов
|
||||
docker stats
|
||||
|
||||
# Резервная копия одной командой
|
||||
docker compose exec postgres pg_dump -U remnawave_user remnawave_bot > backup.sql
|
||||
|
||||
<details>
|
||||
<summary>🎁 Реферальная программа</summary>
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|------------|----------|--------------|
|
||||
| `REFERRAL_FIRST_REWARD` | Награда за первого реферала | `100.0` |
|
||||
| `REFERRAL_REFERRED_BONUS` | Бонус приглашенному | `100.0` |
|
||||
| `REFERRAL_THRESHOLD` | Порог активации (руб.) | `200.0` |
|
||||
| `REFERRAL_PERCENTAGE` | % с последующих платежей | `0.2` (20%) |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⭐ Telegram Stars</summary>
|
||||
|
||||
| Переменная | Описание | Пример |
|
||||
|------------|----------|---------|
|
||||
| `STARS_ENABLED` | Включить оплату звездами | `true` |
|
||||
| `STARS_100_RATE` | Курс 100 звезд → рублей | `110` |
|
||||
| `STARS_250_RATE` | Курс 250 звезд → рублей | `280` |
|
||||
| `STARS_500_RATE` | Курс 500 звезд → рублей | `550` |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⭐ Telegram Tribute</summary>
|
||||
|
||||
| Переменная | Описание | Пример |
|
||||
|------------|----------|---------|
|
||||
| `TRIBUTE_ENABLED` | Включить оплату с помощью доната на Tribute | `false` |
|
||||
| `TRIBUTE_API_KEY` | API ключ из настроек Tribute | `XXXXXXXXXXX` |
|
||||
| `TRIBUTE_WEBHOOK_PORT` | Порт | `8081` |
|
||||
| `TRIBUTE_WEBHOOK_PATH` | URL Webhook Path | `/tribute-webhook` |
|
||||
| `TRIBUTE_DONATE_LINK` | Ссылка на ваш донат из Tribute | `https://t.me/tribute/app?startapp=XXXXXXXXXXXXX` |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🔍 Мониторинг подписок</summary>
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|------------|----------|--------------|
|
||||
| `MONITOR_ENABLED` | Включить мониторинг | `true` |
|
||||
| `MONITOR_CHECK_INTERVAL` | Интервал проверки (сек) | `1800` (30 мин) |
|
||||
| `MONITOR_WARNING_DAYS` | За сколько дней предупреждать | `3` |
|
||||
| `AUTO_DELETE_ENABLED` | Автоудаление истекших | `true` |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary> Триал</summary>
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|------------|----------|--------------|
|
||||
| `TRIAL_ENABLED` | Включить триал | `true` |
|
||||
| `TRIAL_DURATION_DAYS` | Дней триала | `3` |
|
||||
| `TRIAL_SQUAD_UUID` | Сквад триальной подписки | `` |
|
||||
| `TRIAL_NOTIFICATION_ENABLED` | Уведомление об истечении триал подписки | `true` |
|
||||
| `TRIAL_NOTIFICATION_HOURS_AFTER` | Через сколько отсылать сообщение | `1` |
|
||||
| `TRIAL_NOTIFICATION_HOURS_WINDOW` | Через сколько выслать повторно | `` |
|
||||
|
||||
</details>
|
||||
<details>
|
||||
<summary>⚙️ Настройка Telegram Tribute</summary>
|
||||
|
||||
0) Обновляемся до версии 1.4.0
|
||||
1) Регистрируемся и проходим верификацию в Tribute
|
||||
2) Создаем донат ссылку, копируем вставляем в .env бота, конфигурируем остальные параметры из .env.example для работы Tribute
|
||||
3) Настраиваем обратное прокси на /tribute-webhook
|
||||
|
||||
3.1 Пример для докера Caddy
|
||||
|
||||
https://test.example.com {
|
||||
# Tribute webhook endpoint
|
||||
handle /tribute-webhook* {
|
||||
reverse_proxy localhost:8081 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
# Health check для webhook сервиса
|
||||
handle /webhook-health {
|
||||
reverse_proxy localhost:8081/health {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
3.2 Пример для докера с Nginx
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name yourdomain.com;
|
||||
|
||||
location /tribute-webhook {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
client_max_body_size 10M;
|
||||
}
|
||||
|
||||
location /webhook-health {
|
||||
proxy_pass http://127.0.0.1:8081/health;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
3.3 Рестартим Бота, рестратим Caddy/Nginx
|
||||
|
||||
4) Указываем ссылку на наш хук в настройках Tribute: https://test.example.com/tribute-webhook, создаем API ключ вписываем в .env бота
|
||||
|
||||
5) Делаем тест - если успешно, значит все настроили правильно. Не успешный тест - проверяй доступность хука, где-то ты что-то не так настроил
|
||||
|
||||
1) Хлебный - создание службы автозапуска, проверка файлов, запуск бота
|
||||
6) Тестируем пополнение через бота
|
||||
|
||||
chmod +x run.sh
|
||||
./run.sh
|
||||
|
||||
2) Для мужчин (Службу там поднять самому, докерфайл собрать или под скрином развернуть - уже твое дело)
|
||||
</details>
|
||||
|
||||
python main.py
|
||||
|
||||
#Конфигурация
|
||||
🐳 Docker Compose примеры
|
||||
|
||||
BOT_TOKEN — токен Telegram бота от BotFather.
|
||||
REMNAWAVE_URL — URL API RemnaWave.
|
||||
REMNAWAVE_MODE=remote
|
||||
REMNAWAVE_TOKEN — токен доступа к API RemnaWave.
|
||||
DATABASE_URL — строка подключения к базе данных.
|
||||
ADMIN_IDS — через запятую Telegram ID администраторов.
|
||||
SUPPORT_USERNAME — ник поддержки, без @ указывать
|
||||
Параметры тестовой подписки (включение, длительность, трафик, UUID squad, цена).
|
||||
TRIAL_ENABLED=true/false
|
||||
TRIAL_DURATION_DAYS=3 (дни)
|
||||
TRIAL_TRAFFIC_GB=2
|
||||
TRIAL_SQUAD_UUID= (УКазать UUID сквада из панели!)
|
||||
TRIAL_PRICE=0.0 (не трогать)
|
||||
<details>
|
||||
<summary>🏠 Для локальной установки (панель + бот)</summary>
|
||||
|
||||
#Использование
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: remnawave_bot_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: remnawave_bot
|
||||
POSTGRES_USER: remnawave_user
|
||||
POSTGRES_PASSWORD: secure_password_123
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- remnawave-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U remnawave_user -d remnawave_bot"]
|
||||
|
||||
/start
|
||||
bot:
|
||||
image: fr1ngg/remnawave-bedolaga-telegram-bot:latest
|
||||
container_name: remnawave_bot
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
- "8081:8081"
|
||||
networks:
|
||||
- remnawave-network
|
||||
|
||||
#Структура проекта
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
main.py — главный файл запуска.
|
||||
handlers.py — основные обработчики команд и действий пользователя.
|
||||
admin_handlers.py — обработчики команд и действий администраторов.
|
||||
database.py — модели и методы работы с базой данных (SQLAlchemy).
|
||||
remnawave_api.py — интеграция с API RemnaWave.
|
||||
keyboards.py — генерация клавиатур Telegram.
|
||||
translations.py — локализация и переводы.
|
||||
utils.py — вспомогательные функции.
|
||||
middlewares.py — промежуточные слои для обработки сообщений и запросов.
|
||||
.env — файл конфигурации с переменными окружения.
|
||||
requirements.txt — список зависимостей Python.
|
||||
run.sh — скрипт установки и управления ботом (опционально).
|
||||
networks:
|
||||
bot_network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
```
|
||||
|
||||
#Администрирование
|
||||
</details>
|
||||
|
||||
Для входа в админ-панель используйте кнопку "⚙️ Админ панель" в главном меню, если вы указаны как администратор в ADMIN_IDS.
|
||||
<details>
|
||||
<summary>🌐 Для удаленной установки</summary>
|
||||
|
||||
В админ-панели доступны:
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: remnawave_bot_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: remnawave_bot
|
||||
POSTGRES_USER: remnawave_user
|
||||
POSTGRES_PASSWORD: secure_password_123
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U remnawave_user -d remnawave_bot"]
|
||||
|
||||
Управление подписками (создание, редактирование, список, удаление, включение/отключение)
|
||||
Управление пользователями (просмотр списка, баланс)
|
||||
Управление балансом (пополнение пользователей через tg id)
|
||||
Управление промокодами (создание, список)
|
||||
Одобрение или отклонение платежей (Приходит запрос администраторам)
|
||||
Отправка сообщений пользователям или массовая рассылка
|
||||
Просмотр краткой статистики
|
||||
bot:
|
||||
image: fr1ngg/remnawave-bedolaga-telegram-bot:latest
|
||||
container_name: remnawave_bot
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
- "8081:8081"
|
||||
networks:
|
||||
- bot_network
|
||||
|
||||
#ToDo
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
1) Код колхозный и не без вайбкодинга тут обошлось, но будет допиливаться, текущая реализация работает - уже хорошо
|
||||
2) Подключить различные шлюзы для пополнения баланса
|
||||
3) Дописать службу для оповещения об истечении срока подписки и контроля
|
||||
4) Синхранизацию с Remnawave между пользователями по тг id
|
||||
5) Полнофункциональную панель упарвления
|
||||
networks:
|
||||
bot_network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Использование
|
||||
|
||||
### 👤 Для пользователей
|
||||
|
||||
1. **Запуск** → Найдите бота и нажмите `/start`
|
||||
2. **Язык** → Выберите русский или английский
|
||||
3. **Баланс** → "💰 Баланс" → "💳 Пополнить" → Telegram Stars
|
||||
4. **Подписка** → "🛒 Купить подписку" → выбор тарифа → оплата
|
||||
5. **Управление** → "📋 Мои подписки" → выбор → получение ссылки
|
||||
6. **Рефералы** → "👥 Рефералы" → поделиться ссылкой
|
||||
|
||||
### ⚙️ Для администраторов
|
||||
|
||||
Доступ через кнопку **"⚙️ Админ панель"**:
|
||||
|
||||
- **📦 Управление подписками** → создание и настройка тарифов
|
||||
- **👥 Управление пользователями** → поиск, редактирование балансов
|
||||
- **💰 Управление платежами** → одобрение Telegram Stars платежей
|
||||
- **🎁 Промокоды** → создание денежных бонусов
|
||||
- **📨 Рассылки** → уведомления пользователям
|
||||
- **🖥 Система Remnawave** → мониторинг нод, синхронизация
|
||||
- **📊 Статистика** → подробная аналитика
|
||||
|
||||
---
|
||||
|
||||
## 📊 Производительность
|
||||
|
||||
### 💪 Рекомендуемые ресурсы
|
||||
|
||||
| Пользователей | RAM | CPU | Диск | Описание |
|
||||
|---------------|-----|-----|------|----------|
|
||||
| **До 500** | 1GB | 1 vCPU | 10GB | Начальная конфигурация |
|
||||
| **До 1,000** | 2GB | 1 vCPU | 20GB | Малый бизнес |
|
||||
| **До 10,000** | 4GB | 2 vCPU | 50GB | Средний бизнес |
|
||||
| **До 50,000** | 8GB | 4 vCPU | 100GB | Крупный бизнес |
|
||||
|
||||
### ⚡ Оптимизация
|
||||
|
||||
- **Redis** → включите для кэширования (`--profile with-redis`)
|
||||
- **PostgreSQL** → настройте для production нагрузок
|
||||
- **Nginx** → используйте как reverse proxy (`--profile with-nginx`)
|
||||
- **Мониторинг** → отслеживайте через `docker stats`
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Управление
|
||||
|
||||
### 📋 Основные команды
|
||||
|
||||
```bash
|
||||
# Переход в директорию
|
||||
cd /opt/bedolaga-bot
|
||||
|
||||
# Управление через Docker Compose
|
||||
docker compose up -d # Запуск
|
||||
docker compose down # Остановка
|
||||
docker compose restart bot # Перезапуск бота
|
||||
docker compose logs -f bot # Логи в реальном времени
|
||||
|
||||
# Управление через systemd (если настроено)
|
||||
sudo systemctl start bedolaga-bot
|
||||
sudo systemctl stop bedolaga-bot
|
||||
sudo systemctl restart bedolaga-bot
|
||||
```
|
||||
|
||||
### 🔄 Обновление
|
||||
|
||||
```bash
|
||||
# Автоматическое через меню
|
||||
sudo ./bedolaga.sh
|
||||
|
||||
# Ручное обновление
|
||||
docker compose down
|
||||
docker compose pull bot
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 💾 Резервное копирование
|
||||
|
||||
```bash
|
||||
# Создание backup
|
||||
docker exec remnawave_bot_db pg_dump -U remnawave_user remnawave_bot > backup.sql
|
||||
|
||||
# Восстановление
|
||||
docker exec -i remnawave_bot_db psql -U remnawave_user remnawave_bot < backup.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Устранение неполадок
|
||||
|
||||
### ❓ Частые проблемы
|
||||
|
||||
<details>
|
||||
<summary>🤖 Бот не отвечает</summary>
|
||||
|
||||
**Проверьте:**
|
||||
- ✅ Правильность `BOT_TOKEN`
|
||||
- ✅ Интернет соединение
|
||||
- ✅ Логи: `docker compose logs bot`
|
||||
|
||||
**Решение:**
|
||||
```bash
|
||||
# Перезапуск бота
|
||||
docker compose restart bot
|
||||
|
||||
# Проверка токена
|
||||
docker exec remnawave_bot env | grep BOT_TOKEN
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🗄️ Ошибки базы данных</summary>
|
||||
|
||||
**Симптомы:**
|
||||
- SQL ошибки в логах
|
||||
- Бот не сохраняет данные
|
||||
|
||||
**Решение:**
|
||||
```bash
|
||||
# Проверка PostgreSQL
|
||||
docker compose logs postgres
|
||||
|
||||
# Подключение к БД
|
||||
docker exec -it remnawave_bot_db psql -U remnawave_user remnawave_bot
|
||||
|
||||
# Экстренное восстановление через меню
|
||||
sudo ./bedolaga.sh → "10) Экстренное исправление БД"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🔌 Проблемы с Remnawave API</summary>
|
||||
|
||||
**Проверьте:**
|
||||
- ✅ Доступность `REMNAWAVE_URL`
|
||||
- ✅ Валидность `REMNAWAVE_TOKEN`
|
||||
- ✅ Сетевое подключение
|
||||
|
||||
**Диагностика:**
|
||||
```bash
|
||||
# Проверка URL
|
||||
curl -I https://your-panel.com
|
||||
|
||||
# Тест API из контейнера
|
||||
docker exec remnawave_bot curl -I http://remnawave:3000
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⚠️ Проблема обновления 1.3.3 → 1.3.4</summary>
|
||||
|
||||
**Симптом:** SQL ошибки после обновления связанные с автоплатежами
|
||||
|
||||
**Решение через меню:**
|
||||
```bash
|
||||
sudo ./bedolaga.sh
|
||||
# Выберите: "10) Экстренное исправление БД (Python)"
|
||||
# Или: "11) Экстренное исправление БД (SQL)"
|
||||
```
|
||||
|
||||
**Ручное решение:**
|
||||
1. Скачайте `emergency_fix.py`
|
||||
2. Добавьте в `docker-compose.yml`:
|
||||
```yaml
|
||||
emergency-fix:
|
||||
image: fr1ngg/remnawave-bedolaga-telegram-bot:latest
|
||||
volumes:
|
||||
- ./emergency_fix.py:/app/emergency_fix.py
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
networks:
|
||||
- bot_network
|
||||
profiles:
|
||||
- emergency
|
||||
command: python emergency_fix.py
|
||||
```
|
||||
3. Запустите: `docker compose run --rm emergency-fix`
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
### ✅ Реализовано
|
||||
|
||||
- ✅ **Мониторинг подписок** - автоуведомления и контроль
|
||||
- ✅ **Telegram Stars** - пополнение баланса звездами
|
||||
- ✅ **Синхронизация Remnawave** - импорт пользователей и статистика
|
||||
- ✅ **Реферальная система** - полнофункциональная программа
|
||||
- ✅ **Игра удачи** - ежедневные розыгрыши бонусов
|
||||
- ✅ **Управление промокодами** - создание, редактирование, статистика
|
||||
- ✅ **Правила сервиса** - настройка через админ-панель
|
||||
- ✅ **Автоплатежи** - настраиваемое автопродление подписок
|
||||
- ✅ **Просмотр подписок пользователей** - детальная статистика
|
||||
- - ✅ **Автоматическое пополнение лк с помощью доната Tribute**
|
||||
### 🎯 В планах
|
||||
|
||||
| Версия | Функция | Приоритет | Описание |
|
||||
|--------|---------|-----------|----------|
|
||||
| **v1.4.5** | Юкасса интеграция | 🔴 High | Автоматические платежи |
|
||||
| **v1.4.5** | Веб-панель управления | 🟡 Medium | Полный веб-интерфейс |
|
||||
| **v1.4.5** | Безопасное удаление | 🟡 Medium | Архивирование вместо удаления |
|
||||
| **v1.5.0** | Дополнительные платежи | 🟡 Medium | Сбербанк, Tinkoff, Crypto |
|
||||
| **v1.5.0** | Уведомления | 🟡 Medium | Webhook, Email, другие чаты |
|
||||
| **v1.6.0** | Система блокировок | 🟢 Low | Бан/разбан пользователей |
|
||||
|
||||
### 💡 Хотите добавить функцию?
|
||||
|
||||
- 🐛 [Сообщите о баге](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/issues)
|
||||
- ✨ [Предложите улучшение](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/discussions)
|
||||
- 🔧 [Создайте Pull Request](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/pulls)
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Вклад в проект
|
||||
|
||||
### 💪 Как помочь
|
||||
|
||||
**🔧 Разработчикам:**
|
||||
- Fork репозитория
|
||||
- Создайте feature branch: `git checkout -b feature/amazing-feature`
|
||||
- Внесите изменения и сделайте commit: `git commit -m 'Add amazing feature'`
|
||||
- Push в branch: `git push origin feature/amazing-feature`
|
||||
- Создайте Pull Request
|
||||
|
||||
**🐞 Пользователям:**
|
||||
- Сообщайте о багах в [Issues](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/issues)
|
||||
- Предлагайте идеи в [Discussions](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/discussions)
|
||||
- Ставьте ⭐ проекту
|
||||
- Рассказывайте друзьям
|
||||
|
||||
**💰 Спонсорам:**
|
||||
- Поддержите разработку
|
||||
- Закажите приоритетные функции
|
||||
- Получите корпоративную поддержку
|
||||
|
||||
---
|
||||
|
||||
## 💬 Поддержка
|
||||
|
||||
### 📞 Контакты
|
||||
|
||||
- **Telegram:** [@fringg](https://t.me/fringg) - Писать исключительно по делу, я бы конечно был рад всем помочь настроить remnawave, ваши ноды, настроить вам бота, настроить вебхуки, но ребят я один и бота пилю тоже в соло, помощь вам занимает крайне много времени, поэтому если надумаете просить помочь что-то настроить, готовьтесь дарить мне шпагу подарком в тг)(
|
||||
|
||||
- **Issues:** [GitHub Issues](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/issues)
|
||||
- **Discussions:** [GitHub Discussions](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/discussions)
|
||||
|
||||
### 📚 Полезные ссылки
|
||||
|
||||
- [📖 Документация Remnawave](https://docs.remna.st)
|
||||
- [🤖 Создание Telegram бота](https://t.me/BotFather)
|
||||
- [🐳 Docker документация](https://docs.docker.com)
|
||||
- [🐘 PostgreSQL документация](https://www.postgresql.org/docs)
|
||||
|
||||
---
|
||||
|
||||
## 📄 Лицензия
|
||||
|
||||
Проект распространяется под лицензией **MIT**. Подробности в файле [LICENSE](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
## ⭐ История проекта
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
[](https://star-history.com/#Fr1ngg/remnawave-bedolaga-telegram-bot&Date)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
### 💝 Донатеры
|
||||
|
||||
Спасибо всем, кто поддерживает проект!
|
||||
|
||||
| Донатер | Сумма |
|
||||
|---------|-------|
|
||||
| Илья (@ispanec_nn) | $15 |
|
||||
| @Legacyyy777 | 600 руб |
|
||||
|
||||
---
|
||||
|
||||
### Поддержать проект
|
||||
[](https://t.me/tribute/app?startapp=duUO)
|
||||
|
||||
---
|
||||
|
||||
**💝 Создано с любовью для Remnawave сообщества**
|
||||
|
||||
*Автор не является профессиональным разработчиком, но прикладывает все усилия для создания удобного бота для ваших сервисов* 💪
|
||||
|
||||
*The English Readme is currently being written, with revisions for various payment systems and languages. If you have the opportunity, please help me with local optimization and translations. This would greatly help me in writing an open source project that is accessible to different markets/countries.*
|
||||
|
||||
[🔝 Вернуться наверх](#-remnawave-bedolaga-bot)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Callable
|
||||
from aiogram.types import CallbackQuery
|
||||
from aiogram import Router
|
||||
from remnawave_api import RemnaWaveAPI
|
||||
from translations import t
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class APIErrorHandler:
|
||||
|
||||
@staticmethod
|
||||
async def handle_api_error(callback: CallbackQuery, error: Exception,
|
||||
operation: str, user_language: str = 'ru',
|
||||
fallback_keyboard=None) -> bool:
|
||||
error_message = str(error).lower()
|
||||
|
||||
if "timeout" in error_message or "connection" in error_message:
|
||||
text = "⏱ Таймаут подключения к API\n\n"
|
||||
text += "Возможные причины:\n"
|
||||
text += "• Медленный интернет\n"
|
||||
text += "• Перегрузка сервера RemnaWave\n"
|
||||
text += "• Временные проблемы с сетью\n\n"
|
||||
text += "🔄 Попробуйте повторить операцию через несколько секунд"
|
||||
|
||||
elif "401" in error_message or "unauthorized" in error_message:
|
||||
text = "🔐 Ошибка авторизации API\n\n"
|
||||
text += "Токен доступа недействителен или истек.\n"
|
||||
text += "Обратитесь к администратору для обновления токена."
|
||||
|
||||
elif "404" in error_message or "not found" in error_message:
|
||||
text = f"❌ Ресурс не найден\n\n"
|
||||
text += f"Операция: {operation}\n"
|
||||
text += "Возможно, запрашиваемый объект был удален или не существует."
|
||||
|
||||
elif "500" in error_message or "internal server error" in error_message:
|
||||
text = "🔥 Внутренняя ошибка сервера RemnaWave\n\n"
|
||||
text += "Сервер временно недоступен.\n"
|
||||
text += "Попробуйте повторить операцию позже."
|
||||
|
||||
else:
|
||||
text = f"❌ Ошибка API операции: {operation}\n\n"
|
||||
text += f"Детали: {str(error)[:100]}{'...' if len(str(error)) > 100 else ''}\n\n"
|
||||
text += "Обратитесь к администратору если проблема повторяется."
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=fallback_keyboard or error_recovery_keyboard(operation, user_language)
|
||||
)
|
||||
return True
|
||||
except Exception as edit_error:
|
||||
logger.error(f"Failed to edit message with error info: {edit_error}")
|
||||
try:
|
||||
await callback.answer(f"❌ Ошибка: {operation}", show_alert=True)
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def safe_api_call(api_method: Callable, *args, **kwargs) -> tuple[bool, Any]:
|
||||
try:
|
||||
result = await api_method(*args, **kwargs)
|
||||
return True, result
|
||||
except Exception as e:
|
||||
logger.error(f"API call failed: {api_method.__name__} - {e}")
|
||||
return False, str(e)
|
||||
|
||||
def create_error_recovery_keyboard(error_context: str, language: str = 'ru'):
|
||||
from keyboards import error_recovery_keyboard
|
||||
return error_recovery_keyboard(error_context, language)
|
||||
|
||||
async def safe_get_nodes(api: RemnaWaveAPI) -> tuple[bool, list]:
|
||||
try:
|
||||
logger.info("Attempting to fetch nodes from API...")
|
||||
nodes = await api.get_all_nodes()
|
||||
|
||||
if nodes is None:
|
||||
logger.warning("API returned None for nodes")
|
||||
return False, []
|
||||
|
||||
if not isinstance(nodes, list):
|
||||
logger.warning(f"API returned non-list for nodes: {type(nodes)}")
|
||||
return False, []
|
||||
|
||||
logger.info(f"Successfully fetched {len(nodes)} nodes")
|
||||
return True, nodes
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching nodes: {e}")
|
||||
return False, []
|
||||
|
||||
async def safe_get_system_users(api: RemnaWaveAPI) -> tuple[bool, list]:
|
||||
try:
|
||||
logger.info("Attempting to fetch system users from API...")
|
||||
users = await api.get_all_system_users_full()
|
||||
|
||||
if users is None:
|
||||
logger.warning("API returned None for users")
|
||||
return False, []
|
||||
|
||||
if not isinstance(users, list):
|
||||
logger.warning(f"API returned non-list for users: {type(users)}")
|
||||
return False, []
|
||||
|
||||
logger.info(f"Successfully fetched {len(users)} users")
|
||||
return True, users
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching system users: {e}")
|
||||
return False, []
|
||||
|
||||
async def safe_restart_nodes(api: RemnaWaveAPI, all_nodes: bool = True, node_id: str = None) -> tuple[bool, str]:
|
||||
try:
|
||||
if all_nodes:
|
||||
logger.info("Attempting to restart all nodes...")
|
||||
result = await api.restart_all_nodes()
|
||||
else:
|
||||
logger.info(f"Attempting to restart node {node_id}...")
|
||||
result = await api.restart_node(node_id)
|
||||
|
||||
if result:
|
||||
message = "Команда перезагрузки отправлена успешно"
|
||||
logger.info(f"Restart command sent successfully")
|
||||
return True, message
|
||||
else:
|
||||
message = "API вернул отрицательный результат"
|
||||
logger.warning("API returned negative result for restart")
|
||||
return False, message
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error restarting nodes: {e}")
|
||||
return False, str(e)
|
||||
|
||||
async def check_api_health(api: RemnaWaveAPI) -> Dict[str, Any]:
|
||||
health_info = {
|
||||
'api_available': False,
|
||||
'nodes_accessible': False,
|
||||
'users_accessible': False,
|
||||
'system_stats_accessible': False,
|
||||
'errors': []
|
||||
}
|
||||
|
||||
if api is None:
|
||||
health_info['errors'].append("API instance is None")
|
||||
return health_info
|
||||
|
||||
try:
|
||||
success, nodes = await safe_get_nodes(api)
|
||||
if success:
|
||||
health_info['api_available'] = True
|
||||
health_info['nodes_accessible'] = True
|
||||
else:
|
||||
health_info['errors'].append("Cannot fetch nodes")
|
||||
except Exception as e:
|
||||
health_info['errors'].append(f"Nodes check failed: {e}")
|
||||
|
||||
try:
|
||||
success, users = await safe_get_system_users(api)
|
||||
if success:
|
||||
health_info['users_accessible'] = True
|
||||
else:
|
||||
health_info['errors'].append("Cannot fetch users")
|
||||
except Exception as e:
|
||||
health_info['errors'].append(f"Users check failed: {e}")
|
||||
|
||||
try:
|
||||
stats = await api.get_system_stats()
|
||||
if stats:
|
||||
health_info['system_stats_accessible'] = True
|
||||
else:
|
||||
health_info['errors'].append("Cannot fetch system stats")
|
||||
except Exception as e:
|
||||
health_info['errors'].append(f"System stats check failed: {e}")
|
||||
|
||||
return health_info
|
||||
|
||||
def handle_api_errors(operation_name: str):
|
||||
def decorator(func):
|
||||
async def wrapper(callback: CallbackQuery, user, *args, **kwargs):
|
||||
try:
|
||||
return await func(callback, user, *args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in {func.__name__}: {e}")
|
||||
|
||||
api = kwargs.get('api')
|
||||
fallback_keyboard = None
|
||||
|
||||
if 'nodes' in operation_name.lower():
|
||||
from keyboards import admin_system_keyboard
|
||||
fallback_keyboard = admin_system_keyboard(user.language)
|
||||
elif 'users' in operation_name.lower():
|
||||
from keyboards import system_users_keyboard
|
||||
fallback_keyboard = system_users_keyboard(user.language)
|
||||
|
||||
await APIErrorHandler.handle_api_error(
|
||||
callback, e, operation_name, user.language, fallback_keyboard
|
||||
)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
@@ -0,0 +1,295 @@
|
||||
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Градиенты -->
|
||||
<linearGradient id="mainGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#764ba2;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f093fb;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="skinGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#d4a574;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#b8935f;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="redSkinGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#e8b598;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#d4a574;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="clothesGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3a3a3a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#595959;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="bottleGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2d5016;stop-opacity:0.8" />
|
||||
<stop offset="100%" style="stop-color:#1a2f0d;stop-opacity:0.9" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Фильтры -->
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<filter id="dropshadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="4"/>
|
||||
<feOffset dx="2" dy="4" result="offset" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.3"/>
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Фон -->
|
||||
<rect width="400" height="400" fill="#1a1a2e" rx="50"/>
|
||||
|
||||
<!-- Декоративные элементы фона -->
|
||||
<circle cx="50" cy="80" r="30" fill="url(#mainGradient)" opacity="0.1"/>
|
||||
<circle cx="350" cy="120" r="40" fill="#8B4513" opacity="0.1"/>
|
||||
|
||||
<!-- Волны RemnaWave в фоне -->
|
||||
<g transform="translate(200,200)" opacity="0.2">
|
||||
<path d="M-120,-100 Q-60,-130 0,-100 Q60,-70 120,-100"
|
||||
stroke="url(#mainGradient)"
|
||||
stroke-width="4"
|
||||
fill="none">
|
||||
<animate attributeName="d"
|
||||
values="M-120,-100 Q-60,-130 0,-100 Q60,-70 120,-100;M-120,-100 Q-60,-70 0,-100 Q60,-130 120,-100;M-120,-100 Q-60,-130 0,-100 Q60,-70 120,-100"
|
||||
dur="4s"
|
||||
repeatCount="indefinite"/>
|
||||
</path>
|
||||
</g>
|
||||
|
||||
<!-- Главный персонаж - Бомж-алкаш -->
|
||||
<g transform="translate(200,180)" filter="url(#dropshadow)">
|
||||
|
||||
<!-- Тело в рваной куртке -->
|
||||
<ellipse cx="0" cy="30" rx="45" ry="60" fill="url(#clothesGradient)"/>
|
||||
|
||||
<!-- Рваные края одежды -->
|
||||
<path d="M-45,70 L-40,75 L-35,70 L-30,75 L-25,70 L-20,75 L-15,70 L-10,75 L-5,70 L0,75 L5,70 L10,75 L15,70 L20,75 L25,70 L30,75 L35,70 L40,75 L45,70"
|
||||
stroke="none" fill="#2a2a2a"/>
|
||||
|
||||
<!-- Заплатки -->
|
||||
<rect x="-20" y="10" width="12" height="10" fill="#5a4a3a" opacity="0.7" rx="2"/>
|
||||
<rect x="15" y="35" width="10" height="8" fill="#4a4a4a" opacity="0.8" rx="1"/>
|
||||
<rect x="-10" y="45" width="8" height="12" fill="#6a5a4a" opacity="0.6" rx="1"/>
|
||||
|
||||
<!-- Шея -->
|
||||
<rect x="-12" y="-25" width="24" height="20" fill="url(#redSkinGradient)" rx="8"/>
|
||||
|
||||
<!-- Голова (более человечная форма) -->
|
||||
<ellipse cx="0" cy="-45" rx="28" ry="32" fill="url(#redSkinGradient)"/>
|
||||
|
||||
<!-- Сальные волосы -->
|
||||
<path d="M-25,-70 Q-20,-75 -15,-72 Q-10,-78 -5,-73 Q0,-80 5,-73 Q10,-78 15,-72 Q20,-75 25,-70"
|
||||
stroke="#3a3a3a" stroke-width="4" fill="#3a3a3a" stroke-linecap="round"/>
|
||||
<path d="M-28,-65 Q-25,-70 -20,-67" stroke="#3a3a3a" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M28,-65 Q25,-70 20,-67" stroke="#3a3a3a" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Левое ухо -->
|
||||
<ellipse cx="-28" cy="-45" rx="6" ry="10" fill="url(#skinGradient)"/>
|
||||
|
||||
<!-- Правое ухо -->
|
||||
<ellipse cx="28" cy="-45" rx="6" ry="10" fill="url(#skinGradient)"/>
|
||||
|
||||
<!-- Опухшие глаза -->
|
||||
<ellipse cx="-10" cy="-50" rx="8" ry="5" fill="white"/>
|
||||
<ellipse cx="10" cy="-50" rx="8" ry="5" fill="white"/>
|
||||
|
||||
<!-- Зрачки (косящие) -->
|
||||
<g id="eyes">
|
||||
<circle cx="-8" cy="-49" r="3" fill="#333">
|
||||
<animate attributeName="cx" values="-8;-10;-8" dur="3s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="12" cy="-49" r="3" fill="#333">
|
||||
<animate attributeName="cx" values="12;10;12" dur="3s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</g>
|
||||
|
||||
<!-- Красные глаза -->
|
||||
<path d="M-18,-50 L-15,-51" stroke="red" stroke-width="1" opacity="0.7"/>
|
||||
<path d="M-17,-48 L-14,-49" stroke="red" stroke-width="0.5" opacity="0.7"/>
|
||||
<path d="M18,-50 L15,-51" stroke="red" stroke-width="1" opacity="0.7"/>
|
||||
<path d="M17,-48 L14,-49" stroke="red" stroke-width="0.5" opacity="0.7"/>
|
||||
|
||||
<!-- Мешки под глазами -->
|
||||
<ellipse cx="-10" cy="-44" rx="8" ry="3" fill="#8a6a4a" opacity="0.5"/>
|
||||
<ellipse cx="10" cy="-44" rx="8" ry="3" fill="#8a6a4a" opacity="0.5"/>
|
||||
|
||||
<!-- Красный нос алкаша -->
|
||||
<ellipse cx="0" cy="-38" rx="6" ry="5" fill="#d67070"/>
|
||||
<circle cx="-2" cy="-39" r="1" fill="#e08080" opacity="0.8"/>
|
||||
|
||||
<!-- Небритость -->
|
||||
<g opacity="0.6">
|
||||
<circle cx="-15" cy="-30" r="1" fill="#555"/>
|
||||
<circle cx="-12" cy="-28" r="0.8" fill="#555"/>
|
||||
<circle cx="-8" cy="-30" r="0.8" fill="#555"/>
|
||||
<circle cx="15" cy="-30" r="1" fill="#555"/>
|
||||
<circle cx="12" cy="-28" r="0.8" fill="#555"/>
|
||||
<circle cx="8" cy="-30" r="0.8" fill="#555"/>
|
||||
<circle cx="0" cy="-28" r="0.8" fill="#555"/>
|
||||
<circle cx="-5" cy="-26" r="0.8" fill="#555"/>
|
||||
<circle cx="5" cy="-26" r="0.8" fill="#555"/>
|
||||
</g>
|
||||
|
||||
<!-- Рот с сигаретой/бутылкой (анимированный) -->
|
||||
<g id="mouth-group">
|
||||
<!-- Основной рот -->
|
||||
<path d="M-12,-20 Q0,-18 12,-20" stroke="#444" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Сигарета (появляется и исчезает) -->
|
||||
<g id="cigarette">
|
||||
<rect x="10" y="-22" width="25" height="3" fill="white" rx="1">
|
||||
<animate attributeName="opacity" values="0;0;0;1;1;1;0;0;0" dur="8s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<rect x="30" y="-22" width="5" height="3" fill="#ff6b35">
|
||||
<animate attributeName="opacity" values="0;0;0;1;1;1;0;0;0" dur="8s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<!-- Дым от сигареты -->
|
||||
<g opacity="0.4">
|
||||
<circle cx="35" cy="-22" r="2" fill="#999">
|
||||
<animate attributeName="opacity" values="0;0;0;0.6;0.3;0;0;0;0" dur="8s" repeatCount="indefinite"/>
|
||||
<animate attributeName="cy" values="-22;-22;-22;-28;-35;-40;-22;-22;-22" dur="8s" repeatCount="indefinite"/>
|
||||
<animate attributeName="r" values="2;2;2;4;6;8;2;2;2" dur="8s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Левая рука с бутылкой -->
|
||||
<g id="left-arm">
|
||||
<!-- Рука -->
|
||||
<line x1="-45" y1="10" x2="-55" y2="-5" stroke="url(#skinGradient)" stroke-width="8" stroke-linecap="round">
|
||||
<animate attributeName="x2" values="-55;-50;-55" dur="8s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y2" values="-5;-20;-5" dur="8s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
|
||||
<!-- Кисть -->
|
||||
<circle cx="-55" cy="-5" r="6" fill="url(#skinGradient)">
|
||||
<animate attributeName="cx" values="-55;-50;-55" dur="8s" repeatCount="indefinite"/>
|
||||
<animate attributeName="cy" values="-5;-20;-5" dur="8s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
|
||||
<!-- Бутылка -->
|
||||
<g id="bottle">
|
||||
<rect x="-62" y="-25" width="14" height="30" fill="url(#bottleGradient)" rx="2">
|
||||
<animate attributeName="x" values="-62;-57;-62" dur="8s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y" values="-25;-40;-25" dur="8s" repeatCount="indefinite"/>
|
||||
<animate attributeName="transform" values="rotate(0 -55 -10);rotate(-30 -50 -25);rotate(0 -55 -10)" dur="8s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<rect x="-58" y="-30" width="6" height="8" fill="#2d5016">
|
||||
<animate attributeName="x" values="-58;-53;-58" dur="8s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y" values="-30;-45;-30" dur="8s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<!-- Этикетка -->
|
||||
<rect x="-60" y="-15" width="10" height="8" fill="white" opacity="0.3">
|
||||
<animate attributeName="x" values="-60;-55;-60" dur="8s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y" values="-15;-30;-15" dur="8s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Правая рука (свободная, дрожащая) -->
|
||||
<g id="right-arm">
|
||||
<line x1="45" y1="10" x2="55" y2="20" stroke="url(#skinGradient)" stroke-width="8" stroke-linecap="round">
|
||||
<animate attributeName="x2" values="55;57;55;53;55" dur="2s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
<circle cx="55" cy="20" r="6" fill="url(#skinGradient)">
|
||||
<animate attributeName="cx" values="55;57;55;53;55" dur="2s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</g>
|
||||
|
||||
<!-- Ноги в рваных штанах -->
|
||||
<rect x="-15" y="85" width="12" height="35" fill="#4a4a5a" rx="2"/>
|
||||
<rect x="3" y="85" width="12" height="35" fill="#4a4a5a" rx="2"/>
|
||||
|
||||
<!-- Дырки в штанах -->
|
||||
<ellipse cx="-9" cy="95" rx="3" ry="4" fill="#1a1a2e"/>
|
||||
<ellipse cx="9" cy="105" rx="2" ry="3" fill="#1a1a2e"/>
|
||||
|
||||
<!-- Старые ботинки -->
|
||||
<ellipse cx="-9" cy="125" rx="10" ry="6" fill="#2a2a2a"/>
|
||||
<ellipse cx="9" cy="125" rx="10" ry="6" fill="#2a2a2a"/>
|
||||
<!-- Дырки в ботинках -->
|
||||
<circle cx="-6" cy="123" r="2" fill="#1a1a2e"/>
|
||||
<circle cx="12" cy="123" r="1.5" fill="#1a1a2e"/>
|
||||
|
||||
<!-- Значок "B" на груди -->
|
||||
<circle cx="0" cy="25" r="20" fill="url(#mainGradient)" opacity="0.8" stroke="white" stroke-width="1"/>
|
||||
<text x="0" y="35"
|
||||
font-family="Arial Black, sans-serif"
|
||||
font-size="28"
|
||||
font-weight="bold"
|
||||
text-anchor="middle"
|
||||
fill="white">
|
||||
B
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Упавшие бутылки вокруг -->
|
||||
<g opacity="0.6">
|
||||
<rect x="80" y="340" width="8" height="20" fill="#2d5016" rx="1" transform="rotate(75 84 350)"/>
|
||||
<rect x="310" y="330" width="7" height="18" fill="#3a5a1a" rx="1" transform="rotate(-60 313 339)"/>
|
||||
<rect x="150" y="350" width="6" height="16" fill="#2d5016" rx="1" transform="rotate(45 153 358)"/>
|
||||
</g>
|
||||
|
||||
<!-- Окурки -->
|
||||
<g opacity="0.7">
|
||||
<rect x="120" y="360" width="12" height="2" fill="white" rx="1"/>
|
||||
<rect x="130" y="360" width="2" height="2" fill="#ff6b35"/>
|
||||
|
||||
<rect x="250" y="355" width="10" height="2" fill="white" rx="1" transform="rotate(30 255 356)"/>
|
||||
<rect x="258" y="355" width="2" height="2" fill="#ff6b35" transform="rotate(30 259 356)"/>
|
||||
</g>
|
||||
|
||||
<!-- Текст логотипа -->
|
||||
<text x="200" y="320"
|
||||
font-family="Arial Black, sans-serif"
|
||||
font-size="20"
|
||||
font-weight="bold"
|
||||
text-anchor="middle"
|
||||
fill="url(#mainGradient)"
|
||||
filter="url(#glow)">
|
||||
Remnawave
|
||||
</text>
|
||||
|
||||
<text x="200" y="345"
|
||||
font-family="Arial Black, sans-serif"
|
||||
font-size="18"
|
||||
font-weight="bold"
|
||||
text-anchor="middle"
|
||||
fill="#d67070">
|
||||
Bedolaga Bot
|
||||
</text>
|
||||
|
||||
<!-- Подпись -->
|
||||
<text x="200" y="365"
|
||||
font-family="Arial, sans-serif"
|
||||
font-size="11"
|
||||
text-anchor="middle"
|
||||
fill="#8892b0"
|
||||
opacity="0.8">
|
||||
Telegram VPN Manager
|
||||
</text>
|
||||
|
||||
<!-- Мухи летающие вокруг (признак антисанитарии) -->
|
||||
<g opacity="0.5">
|
||||
<circle cx="150" cy="150" r="1" fill="#333">
|
||||
<animateTransform attributeName="transform" type="rotate" values="0 200 180;360 200 180" dur="6s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="250" cy="150" r="1" fill="#333">
|
||||
<animateTransform attributeName="transform" type="rotate" values="360 200 180;0 200 180" dur="5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,284 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from database import Database, UserSubscription, User, Subscription
|
||||
from remnawave_api import RemnaWaveAPI
|
||||
from referral_utils import process_referral_rewards
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AutoPayService:
|
||||
|
||||
def __init__(self, db: Database, api: Optional[RemnaWaveAPI] = None, bot=None):
|
||||
self.db = db
|
||||
self.api = api
|
||||
self.bot = bot
|
||||
self.is_running = False
|
||||
self.check_task = None
|
||||
|
||||
async def start(self):
|
||||
if self.is_running:
|
||||
logger.warning("AutoPay service is already running")
|
||||
return
|
||||
|
||||
self.is_running = True
|
||||
logger.info("🔄 Starting AutoPay service...")
|
||||
|
||||
self.check_task = asyncio.create_task(self._periodic_check())
|
||||
|
||||
async def stop(self):
|
||||
if not self.is_running:
|
||||
return
|
||||
|
||||
self.is_running = False
|
||||
logger.info("⏹ Stopping AutoPay service...")
|
||||
|
||||
if self.check_task:
|
||||
self.check_task.cancel()
|
||||
try:
|
||||
await self.check_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _periodic_check(self):
|
||||
while self.is_running:
|
||||
try:
|
||||
await self.process_autopayments()
|
||||
# Проверяем каждые 30 минут
|
||||
await asyncio.sleep(1800)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("AutoPay periodic check cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in AutoPay periodic check: {e}")
|
||||
await asyncio.sleep(300)
|
||||
|
||||
async def process_autopayments(self) -> dict:
|
||||
logger.info("🔄 Processing autopayments...")
|
||||
|
||||
try:
|
||||
subscriptions_to_pay = await self.db.get_subscriptions_for_autopay()
|
||||
|
||||
if not subscriptions_to_pay:
|
||||
logger.info("No subscriptions ready for autopay")
|
||||
return {
|
||||
'processed': 0,
|
||||
'successful': 0,
|
||||
'failed': 0,
|
||||
'insufficient_balance': 0,
|
||||
'errors': []
|
||||
}
|
||||
|
||||
logger.info(f"Found {len(subscriptions_to_pay)} subscriptions for autopay")
|
||||
|
||||
stats = {
|
||||
'processed': 0,
|
||||
'successful': 0,
|
||||
'failed': 0,
|
||||
'insufficient_balance': 0,
|
||||
'errors': []
|
||||
}
|
||||
|
||||
for user_sub in subscriptions_to_pay:
|
||||
try:
|
||||
result = await self._process_single_autopayment(user_sub)
|
||||
stats['processed'] += 1
|
||||
|
||||
if result['success']:
|
||||
stats['successful'] += 1
|
||||
logger.info(f"✅ Autopay successful for user {user_sub.user_id}, subscription {user_sub.id}")
|
||||
elif result['reason'] == 'insufficient_balance':
|
||||
stats['insufficient_balance'] += 1
|
||||
logger.info(f"💳 Insufficient balance for user {user_sub.user_id}, subscription {user_sub.id}")
|
||||
else:
|
||||
stats['failed'] += 1
|
||||
stats['errors'].append(f"User {user_sub.user_id}: {result['reason']}")
|
||||
logger.warning(f"❌ Autopay failed for user {user_sub.user_id}: {result['reason']}")
|
||||
|
||||
except Exception as e:
|
||||
stats['processed'] += 1
|
||||
stats['failed'] += 1
|
||||
error_msg = f"User {user_sub.user_id}: {str(e)}"
|
||||
stats['errors'].append(error_msg)
|
||||
logger.error(f"Error processing autopay for user {user_sub.user_id}: {e}")
|
||||
|
||||
logger.info(f"📊 Autopay processing complete: {stats['successful']} successful, "
|
||||
f"{stats['failed']} failed, {stats['insufficient_balance']} insufficient balance")
|
||||
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in process_autopayments: {e}")
|
||||
return {
|
||||
'processed': 0,
|
||||
'successful': 0,
|
||||
'failed': 0,
|
||||
'insufficient_balance': 0,
|
||||
'errors': [str(e)]
|
||||
}
|
||||
|
||||
async def _process_single_autopayment(self, user_sub: UserSubscription) -> dict:
|
||||
try:
|
||||
user = await self.db.get_user_by_telegram_id(user_sub.user_id)
|
||||
if not user:
|
||||
return {'success': False, 'reason': 'User not found'}
|
||||
|
||||
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
|
||||
if not subscription:
|
||||
return {'success': False, 'reason': 'Subscription plan not found'}
|
||||
|
||||
if subscription.is_trial:
|
||||
logger.info(f"Skipping autopay for trial subscription: user {user_sub.user_id}")
|
||||
return {'success': False, 'reason': 'Trial subscriptions are not eligible for autopay'}
|
||||
|
||||
if not user_sub.is_active or not user_sub.auto_pay_enabled:
|
||||
return {'success': False, 'reason': 'Subscription inactive or autopay disabled'}
|
||||
|
||||
if user.balance < subscription.price:
|
||||
await self._notify_insufficient_balance(user, subscription, user_sub)
|
||||
return {'success': False, 'reason': 'insufficient_balance'}
|
||||
|
||||
return await self._execute_autopayment(user, subscription, user_sub)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in _process_single_autopayment: {e}")
|
||||
return {'success': False, 'reason': str(e)}
|
||||
|
||||
async def _execute_autopayment(self, user: User, subscription: Subscription, user_sub: UserSubscription) -> dict:
|
||||
try:
|
||||
user.balance -= subscription.price
|
||||
await self.db.update_user(user)
|
||||
|
||||
now = datetime.utcnow()
|
||||
if user_sub.expires_at > now:
|
||||
new_expiry = user_sub.expires_at + timedelta(days=subscription.duration_days)
|
||||
else:
|
||||
new_expiry = now + timedelta(days=subscription.duration_days)
|
||||
|
||||
user_sub.expires_at = new_expiry
|
||||
user_sub.is_active = True
|
||||
await self.db.update_user_subscription(user_sub)
|
||||
|
||||
if self.api and user_sub.short_uuid:
|
||||
try:
|
||||
remna_user_details = await self.api.get_user_by_short_uuid(user_sub.short_uuid)
|
||||
if remna_user_details:
|
||||
user_uuid = remna_user_details.get('uuid')
|
||||
if user_uuid:
|
||||
expiry_str = new_expiry.isoformat() + 'Z'
|
||||
update_data = {
|
||||
'enable': True,
|
||||
'expireAt': expiry_str
|
||||
}
|
||||
await self.api.update_user(user_uuid, update_data)
|
||||
logger.info(f"Updated RemnaWave expiry for user {user_sub.user_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update RemnaWave expiry: {e}")
|
||||
|
||||
payment = await self.db.create_payment(
|
||||
user_id=user_sub.user_id,
|
||||
amount=-subscription.price,
|
||||
payment_type='autopay',
|
||||
description=f'Автоплатеж: {subscription.name}',
|
||||
status='completed'
|
||||
)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
await process_referral_rewards(
|
||||
user_sub.user_id,
|
||||
subscription.price,
|
||||
payment.id,
|
||||
self.db,
|
||||
self.bot,
|
||||
payment_type='autopay'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to process referral rewards for autopay: {e}")
|
||||
|
||||
await self._notify_successful_autopay(user, subscription, user_sub, new_expiry)
|
||||
|
||||
return {'success': True, 'reason': 'Payment processed successfully'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing autopayment: {e}")
|
||||
try:
|
||||
user.balance += subscription.price
|
||||
await self.db.update_user(user)
|
||||
except:
|
||||
pass
|
||||
return {'success': False, 'reason': str(e)}
|
||||
|
||||
async def _notify_successful_autopay(self, user: User, subscription: Subscription,
|
||||
user_sub: UserSubscription, new_expiry: datetime):
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
try:
|
||||
from utils import format_datetime
|
||||
|
||||
text = f"✅ Автоматическое продление подписки\n\n"
|
||||
text += f"📋 Подписка: {subscription.name}\n"
|
||||
text += f"💰 Списано: {subscription.price} руб.\n"
|
||||
text += f"📅 Продлено до: {format_datetime(new_expiry, user.language)}\n"
|
||||
text += f"💳 Остаток на балансе: {user.balance} руб.\n\n"
|
||||
text += f"🔄 Следующее продление произойдет автоматически за {user_sub.auto_pay_days_before} дн. до истечения.\n\n"
|
||||
text += f"ℹ️ Для отключения автоплатежа перейдите в 'Мои подписки' → выберите подписку"
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📋 Мои подписки", callback_data="my_subscriptions")],
|
||||
[InlineKeyboardButton(text="💰 Баланс", callback_data="balance")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
text,
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send autopay success notification: {e}")
|
||||
|
||||
async def _notify_insufficient_balance(self, user: User, subscription: Subscription, user_sub: UserSubscription):
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
try:
|
||||
from utils import format_datetime
|
||||
|
||||
needed = subscription.price - user.balance
|
||||
days_left = (user_sub.expires_at - datetime.utcnow()).days
|
||||
|
||||
text = f"⚠️ Не удалось автоматически продлить подписку\n\n"
|
||||
text += f"📋 Подписка: {subscription.name}\n"
|
||||
text += f"💰 Нужно для продления: {subscription.price} руб.\n"
|
||||
text += f"💳 Ваш баланс: {user.balance} руб.\n"
|
||||
text += f"💸 Недостает: {needed} руб.\n\n"
|
||||
text += f"📅 Подписка истекает: {format_datetime(user_sub.expires_at, user.language)}\n"
|
||||
text += f"⏰ Осталось дней: {days_left}\n\n"
|
||||
text += f"💡 Пополните баланс для автоматического продления"
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="topup_balance")],
|
||||
[InlineKeyboardButton(text="📋 Мои подписки", callback_data="my_subscriptions")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
text,
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send insufficient balance notification: {e}")
|
||||
|
||||
async def get_service_status(self) -> dict:
|
||||
return {
|
||||
'is_running': self.is_running,
|
||||
'check_interval': 1800,
|
||||
'has_api': self.api is not None,
|
||||
'has_bot': self.bot is not None
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Dict
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
BOT_TOKEN: str
|
||||
BOT_USERNAME: str
|
||||
DATABASE_URL: str
|
||||
REMNAWAVE_URL: str
|
||||
REMNAWAVE_TOKEN: str
|
||||
SUBSCRIPTION_BASE_URL: str
|
||||
ADMIN_IDS: List[int]
|
||||
SUPPORT_USERNAME: str
|
||||
DEFAULT_LANGUAGE: str
|
||||
TRIAL_ENABLED: bool
|
||||
TRIAL_DURATION_DAYS: int
|
||||
TRIAL_TRAFFIC_GB: int
|
||||
TRIAL_SQUAD_UUID: str
|
||||
REFERRAL_FIRST_REWARD: float
|
||||
REFERRAL_REFERRED_BONUS: float
|
||||
REFERRAL_THRESHOLD: float
|
||||
REFERRAL_PERCENTAGE: float
|
||||
MONITOR_ENABLED: bool = True
|
||||
MONITOR_CHECK_INTERVAL: int = 3600
|
||||
MONITOR_DAILY_CHECK_HOUR: int = 10
|
||||
MONITOR_WARNING_DAYS: int = 3
|
||||
DELETE_EXPIRED_TRIAL_DAYS: int = 1
|
||||
DELETE_EXPIRED_REGULAR_DAYS: int = 7
|
||||
AUTO_DELETE_ENABLED: bool = False
|
||||
TRIAL_NOTIFICATION_ENABLED: bool = True
|
||||
TRIAL_NOTIFICATION_HOURS_AFTER: int = 2
|
||||
TRIAL_NOTIFICATION_HOURS_WINDOW: int = 22
|
||||
TRIBUTE_ENABLED: bool = False
|
||||
TRIBUTE_API_KEY: str = ""
|
||||
TRIBUTE_WEBHOOK_PORT: int = 8081
|
||||
TRIBUTE_WEBHOOK_PATH: str = "/tribute-webhook"
|
||||
TRIBUTE_DONATE_URL: str = ""
|
||||
TRIBUTE_DONATE_LINK: str = ""
|
||||
|
||||
LUCKY_GAME_ENABLED: bool = True
|
||||
LUCKY_GAME_REWARD: float = 50.0
|
||||
LUCKY_GAME_NUMBERS: int = 30
|
||||
LUCKY_GAME_WINNING_COUNT: int = 3
|
||||
|
||||
STARS_ENABLED: bool = True
|
||||
STARS_RATES: Dict[int, float] = None
|
||||
|
||||
def load_config() -> Config:
|
||||
|
||||
def parse_admin_ids(admin_ids_str: str) -> List[int]:
|
||||
if not admin_ids_str:
|
||||
return []
|
||||
try:
|
||||
return [int(id_str.strip()) for id_str in admin_ids_str.split(',') if id_str.strip()]
|
||||
except (ValueError, AttributeError):
|
||||
return []
|
||||
|
||||
def get_bool(key: str, default: bool = False) -> bool:
|
||||
value = os.getenv(key, '').lower()
|
||||
return value in ('true', '1', 'yes', 'on')
|
||||
|
||||
def get_int(key: str, default: int = 0) -> int:
|
||||
try:
|
||||
return int(os.getenv(key, str(default)))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
def get_float(key: str, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(os.getenv(key, str(default)))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
def parse_stars_rates() -> Dict[int, float]:
|
||||
default_rates = {
|
||||
100: 150.0,
|
||||
150: 220.0,
|
||||
250: 400.0,
|
||||
350: 500.0,
|
||||
500: 800.0,
|
||||
750: 1150.0,
|
||||
1000: 1500.0
|
||||
}
|
||||
|
||||
custom_rates = {}
|
||||
|
||||
for stars in [100, 150, 250, 350, 500, 750, 1000]:
|
||||
env_key = f"STARS_{stars}_RATE"
|
||||
rate = get_float(env_key, 0.0)
|
||||
if rate > 0:
|
||||
custom_rates[stars] = rate
|
||||
|
||||
if custom_rates:
|
||||
return custom_rates
|
||||
|
||||
return default_rates
|
||||
|
||||
return Config(
|
||||
BOT_TOKEN=os.getenv('BOT_TOKEN', ''),
|
||||
BOT_USERNAME=os.getenv('BOT_USERNAME', ''),
|
||||
DATABASE_URL=os.getenv('DATABASE_URL', 'sqlite+aiosqlite:///bot.db'),
|
||||
REMNAWAVE_URL=os.getenv('REMNAWAVE_URL', ''),
|
||||
REMNAWAVE_TOKEN=os.getenv('REMNAWAVE_TOKEN', ''),
|
||||
SUBSCRIPTION_BASE_URL=os.getenv('SUBSCRIPTION_BASE_URL', ''),
|
||||
ADMIN_IDS=parse_admin_ids(os.getenv('ADMIN_IDS', '')),
|
||||
SUPPORT_USERNAME=os.getenv('SUPPORT_USERNAME', 'support'),
|
||||
DEFAULT_LANGUAGE=os.getenv('DEFAULT_LANGUAGE', 'ru'),
|
||||
TRIAL_ENABLED=get_bool('TRIAL_ENABLED', False),
|
||||
TRIAL_DURATION_DAYS=get_int('TRIAL_DURATION_DAYS', 3),
|
||||
TRIAL_TRAFFIC_GB=get_int('TRIAL_TRAFFIC_GB', 2),
|
||||
TRIAL_SQUAD_UUID=os.getenv('TRIAL_SQUAD_UUID', ''),
|
||||
REFERRAL_FIRST_REWARD=get_float('REFERRAL_FIRST_REWARD', 150.0),
|
||||
REFERRAL_REFERRED_BONUS=get_float('REFERRAL_REFERRED_BONUS', 150.0),
|
||||
REFERRAL_THRESHOLD=get_float('REFERRAL_THRESHOLD', 300.0),
|
||||
REFERRAL_PERCENTAGE=get_float('REFERRAL_PERCENTAGE', 0.25),
|
||||
MONITOR_ENABLED=get_bool('MONITOR_ENABLED', True),
|
||||
MONITOR_CHECK_INTERVAL=get_int('MONITOR_CHECK_INTERVAL', 3600),
|
||||
MONITOR_DAILY_CHECK_HOUR=get_int('MONITOR_DAILY_CHECK_HOUR', 10),
|
||||
MONITOR_WARNING_DAYS=get_int('MONITOR_WARNING_DAYS', 3),
|
||||
DELETE_EXPIRED_TRIAL_DAYS=get_int('DELETE_EXPIRED_TRIAL_DAYS', 1),
|
||||
DELETE_EXPIRED_REGULAR_DAYS=get_int('DELETE_EXPIRED_REGULAR_DAYS', 7),
|
||||
AUTO_DELETE_ENABLED=get_bool('AUTO_DELETE_ENABLED', False),
|
||||
TRIAL_NOTIFICATION_ENABLED=get_bool('TRIAL_NOTIFICATION_ENABLED', True),
|
||||
TRIAL_NOTIFICATION_HOURS_AFTER=get_int('TRIAL_NOTIFICATION_HOURS_AFTER', 2),
|
||||
TRIAL_NOTIFICATION_HOURS_WINDOW=get_int('TRIAL_NOTIFICATION_HOURS_WINDOW', 22),
|
||||
LUCKY_GAME_ENABLED=get_bool('LUCKY_GAME_ENABLED', True),
|
||||
LUCKY_GAME_REWARD=get_float('LUCKY_GAME_REWARD', 50.0),
|
||||
LUCKY_GAME_NUMBERS=get_int('LUCKY_GAME_NUMBERS', 30),
|
||||
LUCKY_GAME_WINNING_COUNT=get_int('LUCKY_GAME_WINNING_COUNT', 3),
|
||||
STARS_ENABLED=get_bool('STARS_ENABLED', True),
|
||||
STARS_RATES=parse_stars_rates(),
|
||||
TRIBUTE_ENABLED=get_bool('TRIBUTE_ENABLED', False),
|
||||
TRIBUTE_API_KEY=os.getenv('TRIBUTE_API_KEY', ''),
|
||||
TRIBUTE_WEBHOOK_PORT=get_int('TRIBUTE_WEBHOOK_PORT', 8081),
|
||||
TRIBUTE_WEBHOOK_PATH=os.getenv('TRIBUTE_WEBHOOK_PATH', '/tribute-webhook'),
|
||||
TRIBUTE_DONATE_URL=os.getenv('TRIBUTE_DONATE_URL', ''),
|
||||
TRIBUTE_DONATE_LINK=os.getenv('TRIBUTE_DONATE_LINK', '')
|
||||
)
|
||||
|
||||
def debug_environment():
|
||||
env_vars = [
|
||||
'BOT_TOKEN', 'BOT_USERNAME', 'DATABASE_URL',
|
||||
'REMNAWAVE_URL', 'REMNAWAVE_TOKEN', 'ADMIN_IDS',
|
||||
'REFERRAL_FIRST_REWARD', 'REFERRAL_THRESHOLD',
|
||||
'MONITOR_ENABLED', 'MONITOR_CHECK_INTERVAL',
|
||||
'DELETE_EXPIRED_TRIAL_DAYS', 'DELETE_EXPIRED_REGULAR_DAYS', 'AUTO_DELETE_ENABLED',
|
||||
'LUCKY_GAME_ENABLED', 'LUCKY_GAME_REWARD',
|
||||
'STARS_ENABLED', 'STARS_100_RATE', 'STARS_500_RATE', 'STARS_1000_RATE'
|
||||
]
|
||||
|
||||
print("📋 Environment variables:")
|
||||
for var in env_vars:
|
||||
value = os.getenv(var, 'NOT SET')
|
||||
if 'TOKEN' in var and value != 'NOT SET':
|
||||
value = value[:10] + "..." if len(value) > 10 else value
|
||||
print(f" {var}: {value}")
|
||||
@@ -1,10 +1,10 @@
|
||||
from database import Subscription
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Dict
|
||||
from translations import t
|
||||
from datetime import datetime
|
||||
|
||||
def language_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Language selection keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🇷🇺 Русский", callback_data="lang_ru"),
|
||||
@@ -13,42 +13,39 @@ def language_keyboard() -> InlineKeyboardMarkup:
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def main_menu_keyboard(lang: str = 'ru', is_admin: bool = False, show_trial: bool = False) -> InlineKeyboardMarkup:
|
||||
"""Beautiful main menu keyboard with emojis and better layout"""
|
||||
def main_menu_keyboard(lang: str = 'ru', is_admin: bool = False, show_trial: bool = False, show_lucky_game: bool = False) -> InlineKeyboardMarkup:
|
||||
buttons = [
|
||||
# Первый ряд - основные функции
|
||||
[
|
||||
InlineKeyboardButton(text="💰 " + t('balance', lang), callback_data="balance"),
|
||||
InlineKeyboardButton(text="📋 " + t('my_subscriptions', lang), callback_data="my_subscriptions")
|
||||
],
|
||||
# Второй ряд - покупка подписки (выделена отдельно как главная функция)
|
||||
[InlineKeyboardButton(text="🛒 " + t('buy_subscription', lang), callback_data="buy_subscription")],
|
||||
]
|
||||
|
||||
# Добавляем тестовую подписку если доступна
|
||||
if show_trial:
|
||||
buttons.insert(1, [InlineKeyboardButton(text="🆓 Тестовая подписка", callback_data="trial_subscription")])
|
||||
|
||||
# Добавляем остальные кнопки
|
||||
if show_lucky_game:
|
||||
buttons.append([InlineKeyboardButton(text="🎰 Проверь свою удачу!", callback_data="lucky_game")])
|
||||
|
||||
buttons.extend([
|
||||
# Дополнительные функции
|
||||
[
|
||||
InlineKeyboardButton(text="🎁 " + t('promocode', lang), callback_data="promocode"),
|
||||
InlineKeyboardButton(text="👥 Рефералы", callback_data="referral_program")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="💬 " + t('support', lang), callback_data="support")
|
||||
],
|
||||
# Последний ряд - настройки
|
||||
[InlineKeyboardButton(text="🌐 " + t('change_language', lang), callback_data="change_language")]
|
||||
[InlineKeyboardButton(text="🌐 " + t('change_language', lang), callback_data="change_language")],
|
||||
[InlineKeyboardButton(text="📜 Правила сервиса", callback_data="service_rules")]
|
||||
])
|
||||
|
||||
# Добавляем админ панель если пользователь админ
|
||||
if is_admin:
|
||||
buttons.append([InlineKeyboardButton(text="⚙️ " + t('admin_panel', lang), callback_data="admin_panel")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
# Остальные функции остаются прежними...
|
||||
def balance_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful balance menu keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 " + t('topup_balance', lang), callback_data="topup_balance")],
|
||||
[InlineKeyboardButton(text="📊 " + t('payment_history', lang), callback_data="payment_history")],
|
||||
@@ -56,27 +53,48 @@ def balance_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def topup_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful top up balance keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 " + t('topup_card', lang), callback_data="topup_card")],
|
||||
[InlineKeyboardButton(text="👨💼 " + t('topup_support', lang), callback_data="topup_support")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="balance")]
|
||||
def topup_keyboard(lang: str, tribute_enabled: bool = False, stars_enabled: bool = False) -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
if tribute_enabled:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="💳 Tribute (Карта)" if lang == 'ru' else "💳 Tribute (Card/SBP)",
|
||||
callback_data="topup_tribute"
|
||||
)
|
||||
])
|
||||
|
||||
if stars_enabled:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="⭐ Telegram Stars" if lang == 'ru' else "⭐ Telegram Stars",
|
||||
callback_data="topup_stars"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[InlineKeyboardButton(
|
||||
text="💬 Связаться с поддержкой" if lang == 'ru' else "💬 Contact Support",
|
||||
callback_data="topup_support"
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text="🔙 Назад" if lang == 'ru' else "🔙 Back",
|
||||
callback_data="balance"
|
||||
)]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def subscriptions_keyboard(subscriptions: List[dict], lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful available subscriptions keyboard"""
|
||||
buttons = []
|
||||
|
||||
# Группируем подписки по две в ряд для компактности
|
||||
for i in range(0, len(subscriptions), 2):
|
||||
row = []
|
||||
for j in range(2):
|
||||
if i + j < len(subscriptions):
|
||||
sub = subscriptions[i + j]
|
||||
price_text = f"{sub['price']:.0f}₽"
|
||||
# Используем разные эмодзи для разных ценовых категорий
|
||||
if sub['price'] <= 100:
|
||||
emoji = "🥉"
|
||||
elif sub['price'] <= 300:
|
||||
@@ -94,7 +112,6 @@ def subscriptions_keyboard(subscriptions: List[dict], lang: str = 'ru') -> Inlin
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def subscription_detail_keyboard(subscription_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful subscription detail keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="💎 " + t('buy_subscription_btn', lang, price=0),
|
||||
@@ -105,41 +122,67 @@ def subscription_detail_keyboard(subscription_id: int, lang: str = 'ru') -> Inli
|
||||
return keyboard
|
||||
|
||||
def user_subscriptions_keyboard(user_subscriptions: List[dict], lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful user's subscriptions keyboard"""
|
||||
buttons = []
|
||||
|
||||
for sub in user_subscriptions:
|
||||
# Добавляем эмодзи статуса для каждой подписки
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text=f"📱 {sub['name']}",
|
||||
callback_data=f"view_sub_{sub['id']}"
|
||||
)])
|
||||
|
||||
if not user_subscriptions:
|
||||
# Если нет подписок, показываем кнопку покупки
|
||||
buttons.append([InlineKeyboardButton(text="🛒 " + t('buy_subscription', lang), callback_data="buy_subscription")])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def user_subscription_detail_keyboard(subscription_id: int, lang: str = 'ru', show_extend: bool = False) -> InlineKeyboardMarkup:
|
||||
"""Beautiful user's subscription detail keyboard with connection and optional extend button"""
|
||||
def user_subscription_detail_keyboard(subscription_id: int, lang: str = 'ru',
|
||||
show_extend: bool = False, is_imported: bool = False,
|
||||
is_trial: bool = False, autopay_enabled: bool = False) -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
# Add extend button if subscription is expiring soon
|
||||
if show_extend:
|
||||
buttons.append([InlineKeyboardButton(text="⏰ " + t('extend_subscription', lang), callback_data=f"extend_sub_{subscription_id}")])
|
||||
if is_imported:
|
||||
buttons.append([InlineKeyboardButton(text="🔗 Получить ссылку подключения", callback_data=f"get_connection_{subscription_id}")])
|
||||
buttons.append([InlineKeyboardButton(text="🛒 Купить новую подписку", callback_data="buy_subscription")])
|
||||
elif is_trial:
|
||||
buttons.append([InlineKeyboardButton(text="🔗 Получить ссылку подключения", callback_data=f"get_connection_{subscription_id}")])
|
||||
buttons.append([InlineKeyboardButton(text="🛒 Купить полную подписку", callback_data="buy_subscription")])
|
||||
else:
|
||||
if show_extend:
|
||||
buttons.append([InlineKeyboardButton(text="⏰ " + t('extend_subscription', lang), callback_data=f"extend_sub_{subscription_id}")])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🔗 Получить ссылку подключения", callback_data=f"get_connection_{subscription_id}")])
|
||||
|
||||
if autopay_enabled:
|
||||
autopay_text = "🔄✅ Настроить автоплатеж"
|
||||
else:
|
||||
autopay_text = "🔄❌ Настроить автоплатеж"
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=autopay_text, callback_data=f"autopay_settings_{subscription_id}")])
|
||||
|
||||
# Connection button (главная кнопка)
|
||||
buttons.append([InlineKeyboardButton(text="🔗 Получить ссылку подключения", callback_data=f"get_connection_{subscription_id}")])
|
||||
|
||||
# Back button
|
||||
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="my_subscriptions")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def autopay_confirmation_keyboard(subscription_id: int, action: str, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да", callback_data=f"confirm_autopay_{action}_{subscription_id}"),
|
||||
InlineKeyboardButton(text="❌ Нет", callback_data=f"autopay_settings_{subscription_id}")
|
||||
]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def autopay_help_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Клавиатура помощи по автоплатежам"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="topup_balance")],
|
||||
[InlineKeyboardButton(text="📋 Мои подписки", callback_data="my_subscriptions")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="main_menu")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def extend_subscription_keyboard(subscription_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful extend subscription confirmation keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да, продлить", callback_data=f"confirm_extend_{subscription_id}"),
|
||||
@@ -149,46 +192,48 @@ def extend_subscription_keyboard(subscription_id: int, lang: str = 'ru') -> Inli
|
||||
return keyboard
|
||||
|
||||
def back_keyboard(callback_data: str, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful back button keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data=callback_data)]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def cancel_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful cancel button keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="❌ " + t('cancel', lang), callback_data="main_menu")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
# Admin keyboards
|
||||
|
||||
def admin_menu_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful admin menu keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
# Первый ряд - управление контентом
|
||||
[
|
||||
InlineKeyboardButton(text="📦 " + t('manage_subscriptions', lang), callback_data="admin_subscriptions"),
|
||||
InlineKeyboardButton(text="👥 " + t('manage_users', lang), callback_data="admin_users")
|
||||
],
|
||||
# Второй ряд - финансы
|
||||
[
|
||||
InlineKeyboardButton(text="💰 " + t('manage_balance', lang), callback_data="admin_balance"),
|
||||
InlineKeyboardButton(text="🎁 " + t('manage_promocodes', lang), callback_data="admin_promocodes")
|
||||
],
|
||||
# Третий ряд - коммуникации и аналитика
|
||||
[
|
||||
InlineKeyboardButton(text="📨 " + t('send_message', lang), callback_data="admin_messages"),
|
||||
InlineKeyboardButton(text="👥 Рефералы", callback_data="admin_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📜 Правила сервиса", callback_data="admin_rules"),
|
||||
InlineKeyboardButton(text="🔄 Автоплатежи", callback_data="admin_autopay")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🖥 Система RemnaWave", callback_data="admin_system"),
|
||||
InlineKeyboardButton(text="🔍 Мониторинг подписок", callback_data="admin_monitor")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 " + t('statistics', lang), callback_data="admin_stats")
|
||||
],
|
||||
# Назад
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_subscriptions_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful admin subscriptions management keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="➕ " + t('create_subscription', lang), callback_data="create_subscription")],
|
||||
[InlineKeyboardButton(text="📋 Список подписок", callback_data="list_admin_subscriptions")],
|
||||
@@ -197,34 +242,205 @@ def admin_subscriptions_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
return keyboard
|
||||
|
||||
def admin_users_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful admin users management keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="👥 Список пользователей", callback_data="list_users")],
|
||||
[InlineKeyboardButton(text="📋 Все подписки пользователей", callback_data="admin_user_subscriptions_all")],
|
||||
[InlineKeyboardButton(text="🔍 Поиск пользователя", callback_data="search_user")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_users_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="👥 Список пользователей", callback_data="list_users")],
|
||||
[InlineKeyboardButton(text="📋 Все подписки пользователей", callback_data="admin_user_subscriptions_all")],
|
||||
[InlineKeyboardButton(text="🔍 Поиск пользователя", callback_data="search_user")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_user_subscriptions_filters_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Клавиатура фильтров для подписок пользователей"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🟢 Активные", callback_data="filter_subs_active"),
|
||||
InlineKeyboardButton(text="🔴 Истекшие", callback_data="filter_subs_expired")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄✅ С автоплатежом", callback_data="filter_subs_autopay"),
|
||||
InlineKeyboardButton(text="⏰ Истекают скоро", callback_data="filter_subs_expiring")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🆓 Триальные", callback_data="filter_subs_trial"),
|
||||
InlineKeyboardButton(text="📦 Импортированные", callback_data="filter_subs_imported")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Все подписки", callback_data="admin_user_subscriptions_all"),
|
||||
InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")
|
||||
]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_user_subscription_detail_keyboard(subscription_id: int, user_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Клавиатура для детального просмотра подписки пользователя"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"edit_user_sub_{subscription_id}"),
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data=f"refresh_user_sub_{subscription_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_detail_{user_id}"),
|
||||
InlineKeyboardButton(text="📋 К списку подписок", callback_data="admin_user_subscriptions_all")
|
||||
],
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def user_subscriptions_pagination_keyboard(current_page: int, total_pages: int,
|
||||
filter_type: str = "all", lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
|
||||
if current_page > 0:
|
||||
nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"user_subs_page_{current_page - 1}_{filter_type}"))
|
||||
|
||||
nav_row.append(InlineKeyboardButton(text=f"{current_page + 1}/{total_pages}", callback_data="noop"))
|
||||
|
||||
if current_page < total_pages - 1:
|
||||
nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"user_subs_page_{current_page + 1}_{filter_type}"))
|
||||
|
||||
buttons.append(nav_row)
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🔍 Фильтры", callback_data="admin_user_subscriptions_filters"),
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data=f"refresh_user_subs_{filter_type}")
|
||||
])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def admin_balance_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful admin balance management keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💸 Пополнить баланс пользователю", callback_data="admin_add_balance")],
|
||||
[InlineKeyboardButton(text="📊 История платежей", callback_data="admin_payment_history")],
|
||||
[InlineKeyboardButton(text="⭐ Telegram Stars", callback_data="admin_stars_payments")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_promocodes_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful admin promocodes management keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="➕ " + t('create_promocode', lang), callback_data="create_promocode")],
|
||||
[InlineKeyboardButton(text="📋 Список промокодов", callback_data="list_promocodes")],
|
||||
[InlineKeyboardButton(text="📋 Управление промокодами", callback_data="list_promocodes")],
|
||||
[InlineKeyboardButton(text="📊 Статистика промокодов", callback_data="promocodes_stats")],
|
||||
[InlineKeyboardButton(text="🧹 Очистить истекшие", callback_data="cleanup_expired_promos")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def promocodes_management_keyboard(promocodes: List, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
for promo in promocodes[:15]:
|
||||
status_emoji = "🟢" if promo.is_active else "🔴"
|
||||
|
||||
if promo.expires_at and promo.expires_at < datetime.utcnow():
|
||||
status_emoji = "⏰"
|
||||
|
||||
usage_text = f"{promo.used_count}/{promo.usage_limit}"
|
||||
button_text = f"{status_emoji} {promo.code} ({promo.discount_amount}₽) [{usage_text}]"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"promo_info_{promo.id}"
|
||||
)
|
||||
])
|
||||
|
||||
control_buttons = []
|
||||
|
||||
if not promo.code.startswith('REF'):
|
||||
toggle_text = "🔴" if promo.is_active else "🟢"
|
||||
control_buttons.append(
|
||||
InlineKeyboardButton(text=toggle_text, callback_data=f"toggle_promo_{promo.id}")
|
||||
)
|
||||
|
||||
control_buttons.append(
|
||||
InlineKeyboardButton(text="✏️", callback_data=f"edit_promo_{promo.id}")
|
||||
)
|
||||
|
||||
control_buttons.append(
|
||||
InlineKeyboardButton(text="🗑", callback_data=f"delete_promo_{promo.id}")
|
||||
)
|
||||
else:
|
||||
control_buttons.append(
|
||||
InlineKeyboardButton(text="👥 Реферальный", callback_data="noop")
|
||||
)
|
||||
|
||||
if control_buttons:
|
||||
buttons.append(control_buttons)
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="➕ Создать промокод", callback_data="create_promocode")
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🧹 Очистить истекшие", callback_data="cleanup_expired_promos"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="promocodes_stats")
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_promocodes")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def promocode_edit_keyboard(promo_id: int, language: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="💰 Изменить скидку", callback_data=f"edit_promo_field_{promo_id}_discount")],
|
||||
[InlineKeyboardButton(text="📊 Изменить лимит", callback_data=f"edit_promo_field_{promo_id}_limit")],
|
||||
[InlineKeyboardButton(text="⏰ Изменить срок", callback_data=f"edit_promo_field_{promo_id}_expiry")],
|
||||
[InlineKeyboardButton(text="🗑 Удалить промокод", callback_data=f"delete_promo_{promo_id}")],
|
||||
[InlineKeyboardButton(text=t('back', language), callback_data="list_promocodes")]
|
||||
]
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def promocode_info_keyboard(promo_id: int, is_referral: bool = False, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
if not is_referral:
|
||||
buttons.extend([
|
||||
[InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"edit_promo_{promo_id}")],
|
||||
[
|
||||
InlineKeyboardButton(text="🟢/🔴 Переключить", callback_data=f"toggle_promo_{promo_id}"),
|
||||
InlineKeyboardButton(text="🗑 Удалить", callback_data=f"delete_promo_{promo_id}")
|
||||
]
|
||||
])
|
||||
else:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="👥 Реферальный код", callback_data="noop")
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🔙 К списку", callback_data="list_promocodes")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def bulk_promocodes_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="🧹 Удалить все истекшие", callback_data="confirm_cleanup_expired")],
|
||||
[InlineKeyboardButton(text="🔴 Деактивировать все", callback_data="confirm_deactivate_all")],
|
||||
[InlineKeyboardButton(text="📊 Экспорт статистики", callback_data="export_promo_stats")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="list_promocodes")]
|
||||
]
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def confirmation_keyboard(confirm_callback: str, cancel_callback: str, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful confirmation keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да", callback_data=confirm_callback),
|
||||
@@ -234,7 +450,6 @@ def confirmation_keyboard(confirm_callback: str, cancel_callback: str, lang: str
|
||||
return keyboard
|
||||
|
||||
def pagination_keyboard(page: int, total_pages: int, prefix: str, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful pagination keyboard"""
|
||||
buttons = []
|
||||
|
||||
if total_pages > 1:
|
||||
@@ -253,13 +468,11 @@ def pagination_keyboard(page: int, total_pages: int, prefix: str, lang: str = 'r
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def admin_subscriptions_list_keyboard(subs: List[Subscription], lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful keyboard for admin subscriptions list with enhanced controls"""
|
||||
buttons = []
|
||||
for sub in subs:
|
||||
status_emoji = "🟢" if sub.is_active else "🔴"
|
||||
price = f"{sub.price:.0f}₽"
|
||||
|
||||
# Основная кнопка с информацией о подписке
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{status_emoji} {sub.name} — {price}",
|
||||
@@ -267,7 +480,6 @@ def admin_subscriptions_list_keyboard(subs: List[Subscription], lang: str = 'ru'
|
||||
)
|
||||
])
|
||||
|
||||
# Кнопки управления для каждой подписки в одну строку
|
||||
control_buttons = [
|
||||
InlineKeyboardButton(text="✏️", callback_data=f"edit_sub_{sub.id}"),
|
||||
InlineKeyboardButton(
|
||||
@@ -279,13 +491,11 @@ def admin_subscriptions_list_keyboard(subs: List[Subscription], lang: str = 'ru'
|
||||
|
||||
buttons.append(control_buttons)
|
||||
|
||||
# Кнопка создания новой подписки
|
||||
buttons.append([InlineKeyboardButton(text="➕ Создать подписку", callback_data="create_subscription")])
|
||||
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_subscriptions")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def admin_payment_keyboard(payment_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful keyboard for admin payment approval"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Одобрить платеж", callback_data=f"approve_payment_{payment_id}"),
|
||||
@@ -295,7 +505,6 @@ def admin_payment_keyboard(payment_id: int, lang: str = 'ru') -> InlineKeyboardM
|
||||
return keyboard
|
||||
|
||||
def admin_messages_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful admin messages management keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="👤 " + t('send_to_user', lang), callback_data="admin_send_to_user")],
|
||||
[InlineKeyboardButton(text="📢 " + t('send_to_all', lang), callback_data="admin_send_to_all")],
|
||||
@@ -304,7 +513,6 @@ def admin_messages_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
return keyboard
|
||||
|
||||
def quick_topup_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Quick topup amounts keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="💳 100₽", callback_data="quick_topup_100"),
|
||||
@@ -320,7 +528,6 @@ def quick_topup_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
return keyboard
|
||||
|
||||
def connection_keyboard(subscription_url: str, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful connection keyboard with web app"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔗 Подключиться", url=subscription_url)],
|
||||
[InlineKeyboardButton(text="📱 Инструкция", callback_data="connection_guide")],
|
||||
@@ -329,9 +536,412 @@ def connection_keyboard(subscription_url: str, lang: str = 'ru') -> InlineKeyboa
|
||||
return keyboard
|
||||
|
||||
def trial_subscription_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Trial subscription confirmation keyboard"""
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="✅ Получить тестовую подписку", callback_data="confirm_trial")],
|
||||
[InlineKeyboardButton(text=t('back', lang), callback_data="main_menu")]
|
||||
]
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def admin_monitor_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📊 Статус сервиса", callback_data="monitor_status")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_system_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📊 Системная статистика", callback_data="system_stats")],
|
||||
[InlineKeyboardButton(text="🖥 Управление нодами", callback_data="nodes_management")],
|
||||
[InlineKeyboardButton(text="👥 Пользователи системы", callback_data="system_users")],
|
||||
[InlineKeyboardButton(text="🔄 Синхронизация с RemnaWave", callback_data="sync_remnawave")],
|
||||
[InlineKeyboardButton(text="🔍 Отладка API", callback_data="debug_api_comprehensive")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def system_stats_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Обновить статистику", callback_data="refresh_system_stats")],
|
||||
[InlineKeyboardButton(text="🖥 Ноды", callback_data="nodes_management")],
|
||||
[InlineKeyboardButton(text="👥 Системные пользователи", callback_data="system_users")],
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_system")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def nodes_management_keyboard(nodes: List[Dict], lang: str = 'ru', timestamp: int = None) -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
if nodes:
|
||||
online_count = len([n for n in nodes if n.get('status') == 'online'])
|
||||
total_count = len(nodes)
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📊 Ноды: {online_count}/{total_count} онлайн",
|
||||
callback_data="noop"
|
||||
)
|
||||
])
|
||||
|
||||
for i, node in enumerate(nodes[:5]):
|
||||
status = node.get('status', 'unknown')
|
||||
|
||||
if status == 'online':
|
||||
status_emoji = "🟢"
|
||||
elif status == 'disabled':
|
||||
status_emoji = "⚫"
|
||||
elif status == 'disconnected':
|
||||
status_emoji = "🔴"
|
||||
elif status == 'xray_stopped':
|
||||
status_emoji = "🟡"
|
||||
else:
|
||||
status_emoji = "⚪"
|
||||
|
||||
node_name = node.get('name', f'Node-{i+1}')
|
||||
node_id = node.get('id', node.get('uuid'))
|
||||
|
||||
if len(node_name) > 20:
|
||||
display_name = node_name[:17] + "..."
|
||||
else:
|
||||
display_name = node_name
|
||||
|
||||
usage_info = ""
|
||||
if node.get('cpuUsage'):
|
||||
usage_info += f" CPU:{node['cpuUsage']:.0f}%"
|
||||
if node.get('memUsage'):
|
||||
usage_info += f" MEM:{node['memUsage']:.0f}%"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{status_emoji} {display_name}{usage_info}",
|
||||
callback_data=f"node_details_{node_id}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🔄",
|
||||
callback_data=f"restart_node_{node_id}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="⚙️",
|
||||
callback_data=f"node_settings_{node_id}"
|
||||
)
|
||||
])
|
||||
|
||||
if len(nodes) > 5:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"... и еще {len(nodes) - 5} нод",
|
||||
callback_data="show_all_nodes"
|
||||
)
|
||||
])
|
||||
else:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text="❌ Ноды не найдены",
|
||||
callback_data="noop"
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🔄 Перезагрузить все", callback_data="restart_all_nodes"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="nodes_statistics")
|
||||
])
|
||||
|
||||
refresh_callback = f"refresh_nodes_stats_{timestamp}" if timestamp else "refresh_nodes_stats"
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data=refresh_callback)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🔙 Назад", callback_data="admin_system")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def system_users_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📊 Статистика пользователей", callback_data="users_statistics")],
|
||||
[InlineKeyboardButton(text="👥 Список всех пользователей", callback_data="list_all_system_users")],
|
||||
[InlineKeyboardButton(text="🔍 Поиск пользователя", callback_data="search_user_uuid")],
|
||||
[InlineKeyboardButton(text="🔍 Отладка API пользователей", callback_data="debug_users_api")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_system")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def bulk_operations_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="bulk_reset_traffic")],
|
||||
[InlineKeyboardButton(text="❌ Отключить пользователей", callback_data="bulk_disable_users")],
|
||||
[InlineKeyboardButton(text="✅ Включить пользователей", callback_data="bulk_enable_users")],
|
||||
[InlineKeyboardButton(text="🗑 Удалить пользователей", callback_data="bulk_delete_users")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="system_users")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def confirm_restart_keyboard(node_id: str = None, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
action = f"confirm_restart_node_{node_id}" if node_id else "confirm_restart_all_nodes"
|
||||
back_action = f"node_details_{node_id}" if node_id else "nodes_management"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да, перезагрузить", callback_data=action),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data=back_action)
|
||||
]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_referrals_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📊 Статистика рефералов", callback_data="referral_statistics")],
|
||||
[InlineKeyboardButton(text="👥 Список рефереров", callback_data="list_referrers")],
|
||||
[InlineKeyboardButton(text="💰 История выплат", callback_data="referral_payments")],
|
||||
[InlineKeyboardButton(text="⚙️ Настройки программы", callback_data="referral_settings")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def lucky_game_keyboard(can_play: bool, time_left_str: str = "", lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
if can_play:
|
||||
buttons.append([InlineKeyboardButton(text="🎲 Играть!", callback_data="start_lucky_game")])
|
||||
else:
|
||||
buttons.append([InlineKeyboardButton(text=f"⏰ Приходи через {time_left_str}", callback_data="noop")])
|
||||
|
||||
buttons.extend([
|
||||
[InlineKeyboardButton(text="📈 История игр", callback_data="lucky_game_history")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def lucky_numbers_keyboard(numbers_count: int) -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
for i in range(0, numbers_count, 5):
|
||||
row = []
|
||||
for j in range(5):
|
||||
if i + j + 1 <= numbers_count:
|
||||
number = i + j + 1
|
||||
row.append(InlineKeyboardButton(
|
||||
text=str(number),
|
||||
callback_data=f"choose_number_{number}"
|
||||
))
|
||||
buttons.append(row)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="❌ Отмена", callback_data="lucky_game")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def lucky_game_result_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📈 История игр", callback_data="lucky_game_history")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="main_menu")]
|
||||
])
|
||||
|
||||
def stars_topup_keyboard(stars_rates: Dict[int, float], lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
sorted_rates = sorted(stars_rates.items())
|
||||
|
||||
for i in range(0, len(sorted_rates), 2):
|
||||
row = []
|
||||
for j in range(2):
|
||||
if i + j < len(sorted_rates):
|
||||
stars, rubles = sorted_rates[i + j]
|
||||
if stars >= 500:
|
||||
emoji = "🔥" # Выгодное предложение
|
||||
elif stars >= 250:
|
||||
emoji = "💎" # Хорошее предложение
|
||||
else:
|
||||
emoji = "⭐" # Базовое предложение
|
||||
|
||||
button_text = f"{emoji} {stars} ⭐ → {rubles:.0f}₽"
|
||||
row.append(InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"buy_stars_{stars}"
|
||||
))
|
||||
buttons.append(row)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="topup_balance")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def stars_payment_keyboard(stars_amount: int, rub_amount: float, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="❌ Отмена", callback_data="topup_stars")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def service_rules_keyboard(current_page: int, total_pages: int, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
|
||||
if current_page > 0:
|
||||
nav_row.append(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"rules_page_{current_page - 1}"))
|
||||
|
||||
nav_row.append(InlineKeyboardButton(text=f"{current_page + 1}/{total_pages}", callback_data="noop"))
|
||||
|
||||
if current_page < total_pages - 1:
|
||||
nav_row.append(InlineKeyboardButton(text="Вперед ➡️", callback_data=f"rules_page_{current_page + 1}"))
|
||||
|
||||
buttons.append(nav_row)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🏠 Главное меню", callback_data="main_menu")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def admin_rules_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📋 Список правил", callback_data="admin_rules_list")],
|
||||
[InlineKeyboardButton(text="➕ Добавить страницу", callback_data="admin_rules_create")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_rules_list_keyboard(rules, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
for rule in rules:
|
||||
status_emoji = "🟢" if rule.is_active else "🔴"
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{status_emoji} {rule.page_order}. {rule.title}",
|
||||
callback_data=f"admin_rule_view_{rule.id}"
|
||||
)
|
||||
])
|
||||
|
||||
buttons.extend([
|
||||
[InlineKeyboardButton(text="➕ Добавить страницу", callback_data="admin_rules_create")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_rules")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def admin_rule_edit_keyboard(rule_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✏️ Редактировать заголовок", callback_data=f"admin_rule_edit_title_{rule_id}"),
|
||||
InlineKeyboardButton(text="📝 Редактировать содержимое", callback_data=f"admin_rule_edit_content_{rule_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Изменить порядок", callback_data=f"admin_rule_edit_order_{rule_id}"),
|
||||
InlineKeyboardButton(text="🟢/🔴 Вкл/Выкл", callback_data=f"admin_rule_toggle_{rule_id}")
|
||||
],
|
||||
[InlineKeyboardButton(text="🗑 Удалить", callback_data=f"admin_rule_delete_{rule_id}")],
|
||||
[InlineKeyboardButton(text="🔙 К списку", callback_data="admin_rules_list")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_rule_delete_confirm_keyboard(rule_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да, удалить", callback_data=f"admin_rule_confirm_delete_{rule_id}"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_rule_view_{rule_id}")
|
||||
]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def autopay_settings_keyboard(user_sub_id: int, user_sub, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
if user_sub.auto_pay_enabled:
|
||||
toggle_text = "❌ Отключить автоплатеж"
|
||||
toggle_callback = f"toggle_autopay_{user_sub_id}"
|
||||
else:
|
||||
toggle_text = "✅ Включить автоплатеж"
|
||||
toggle_callback = f"toggle_autopay_{user_sub_id}"
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=toggle_text, callback_data=toggle_callback)])
|
||||
|
||||
if user_sub.auto_pay_enabled:
|
||||
buttons.append([InlineKeyboardButton(text="📅 Настроить дни до продления", callback_data="noop")])
|
||||
|
||||
days_row = []
|
||||
for days in [1, 3, 5, 7]:
|
||||
emoji = "🔹" if user_sub.auto_pay_days_before == days else "⚪"
|
||||
days_row.append(InlineKeyboardButton(
|
||||
text=f"{emoji} {days}д",
|
||||
callback_data=f"autopay_days_{user_sub_id}_{days}"
|
||||
))
|
||||
buttons.append(days_row)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🔙 К подписке", callback_data=f"view_sub_{user_sub_id}")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def autopay_status_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Обновить", callback_data="autopay_status")],
|
||||
[InlineKeyboardButton(text="🚀 Принудительная проверка", callback_data="autopay_force_check")],
|
||||
[InlineKeyboardButton(text="📋 Список подписок", callback_data="autopay_subscriptions_list")],
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_autopay")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def autopay_subscriptions_keyboard(subscriptions_data: List[Dict], lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
|
||||
expired = [s for s in subscriptions_data if s['expires_in_days'] <= 0]
|
||||
due_soon = [s for s in subscriptions_data if 0 < s['expires_in_days'] <= s['auto_pay_days_before']]
|
||||
|
||||
critical_subs = expired + due_soon
|
||||
|
||||
for sub_data in critical_subs[:8]:
|
||||
username = sub_data['username'] if sub_data['username'] != 'N/A' else f"ID:{sub_data['user_id']}"
|
||||
days = sub_data['expires_in_days']
|
||||
|
||||
if days <= 0:
|
||||
status_emoji = "❌"
|
||||
status_text = f"Истекла"
|
||||
elif days <= sub_data['auto_pay_days_before']:
|
||||
status_emoji = "⚠️"
|
||||
status_text = f"Через {days}д"
|
||||
else:
|
||||
status_emoji = "✅"
|
||||
status_text = f"Через {days}д"
|
||||
|
||||
button_text = f"{status_emoji} @{username} ({status_text})"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"autopay_user_detail_{sub_data['user_id']}"
|
||||
)
|
||||
])
|
||||
|
||||
if len(subscriptions_data) > 8:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"... и еще {len(subscriptions_data) - 8}",
|
||||
callback_data="noop"
|
||||
)
|
||||
])
|
||||
|
||||
buttons.extend([
|
||||
[InlineKeyboardButton(text="🔄 Обновить", callback_data="autopay_subscriptions_list")],
|
||||
[InlineKeyboardButton(text="📊 Статистика", callback_data="autopay_statistics")],
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_autopay")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def autopay_user_detail_keyboard(user_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 Добавить баланс", callback_data=f"admin_add_balance_to_{user_id}")],
|
||||
[InlineKeyboardButton(text="📋 Управление подписками", callback_data=f"admin_user_subscriptions_{user_id}")],
|
||||
[InlineKeyboardButton(text="🔄 Обновить", callback_data=f"autopay_user_detail_{user_id}")],
|
||||
[InlineKeyboardButton(text="🔙 К списку", callback_data="autopay_subscriptions_list")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
|
||||
def autopay_statistics_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⚠️ Недостаточно средств", callback_data="autopay_insufficient_balance_users")],
|
||||
[InlineKeyboardButton(text="📋 Список подписок", callback_data="autopay_subscriptions_list")],
|
||||
[InlineKeyboardButton(text="🔄 Обновить", callback_data="autopay_statistics")],
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_autopay")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import logging
|
||||
import random
|
||||
from typing import Optional, List
|
||||
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
from database import Database, User
|
||||
from keyboards import back_keyboard
|
||||
from translations import t
|
||||
from utils import log_user_action, format_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class LuckyGameStates(StatesGroup):
|
||||
waiting_number_choice = State()
|
||||
|
||||
lucky_game_router = Router()
|
||||
|
||||
|
||||
@lucky_game_router.callback_query(F.data == "lucky_game")
|
||||
async def lucky_game_menu_callback(callback: CallbackQuery, db: Database, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
config = kwargs.get('config')
|
||||
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
try:
|
||||
reward_amount = getattr(config, 'LUCKY_GAME_REWARD', 50.0)
|
||||
numbers_count = getattr(config, 'LUCKY_GAME_NUMBERS', 30)
|
||||
winning_numbers = getattr(config, 'LUCKY_GAME_WINNING_COUNT', 3)
|
||||
|
||||
can_play, next_game_time = await check_can_play_today(db, user.telegram_id)
|
||||
|
||||
games_played, total_won, win_count = await get_user_game_stats(db, user.telegram_id)
|
||||
|
||||
text = "🎰 **Проверь свою удачу!**\n\n"
|
||||
text += "🎯 **Как играть:**\n"
|
||||
text += f"• Выберите число от 1 до {numbers_count}\n"
|
||||
text += f"• Из {numbers_count} чисел {winning_numbers} - выигрышные\n"
|
||||
text += f"• Угадали - получаете {reward_amount:.0f}₽ на баланс!\n"
|
||||
text += "• Играть можно 1 раз в сутки\n\n"
|
||||
|
||||
text += "📊 **Ваша статистика:**\n"
|
||||
text += f"• Игр сыграно: {games_played}\n"
|
||||
text += f"• Выигрышей: {win_count}\n"
|
||||
text += f"• Всего выиграно: {total_won:.0f}₽\n"
|
||||
|
||||
if games_played > 0:
|
||||
win_rate = (win_count / games_played) * 100
|
||||
text += f"• Процент побед: {win_rate:.1f}%\n"
|
||||
|
||||
buttons = []
|
||||
|
||||
if can_play:
|
||||
buttons.append([InlineKeyboardButton(text="🎲 Играть!", callback_data="start_lucky_game")])
|
||||
else:
|
||||
now = datetime.utcnow()
|
||||
time_left = next_game_time - now
|
||||
hours_left = int(time_left.total_seconds() // 3600)
|
||||
minutes_left = int((time_left.total_seconds() % 3600) // 60)
|
||||
|
||||
if hours_left > 0:
|
||||
time_text = f"{hours_left}ч {minutes_left}м"
|
||||
else:
|
||||
time_text = f"{minutes_left}м"
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=f"⏰ Приходи через {time_text}", callback_data="noop")])
|
||||
|
||||
buttons.extend([
|
||||
[InlineKeyboardButton(text="📈 История игр", callback_data="lucky_game_history")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', user.language), callback_data="main_menu")]
|
||||
])
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in lucky game menu: {e}")
|
||||
await callback.answer("❌ Ошибка загрузки игры")
|
||||
|
||||
@lucky_game_router.callback_query(F.data == "start_lucky_game")
|
||||
async def start_lucky_game_callback(callback: CallbackQuery, db: Database, state: FSMContext, **kwargs):
|
||||
"""Начать игру - показать числа для выбора"""
|
||||
user = kwargs.get('user')
|
||||
config = kwargs.get('config')
|
||||
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
try:
|
||||
can_play, next_game_time = await check_can_play_today(db, user.telegram_id)
|
||||
|
||||
if not can_play:
|
||||
await callback.answer("⏰ Вы уже играли сегодня! Приходите завтра.", show_alert=True)
|
||||
return
|
||||
|
||||
numbers_count = getattr(config, 'LUCKY_GAME_NUMBERS', 30)
|
||||
winning_numbers = getattr(config, 'LUCKY_GAME_WINNING_COUNT', 3)
|
||||
reward_amount = getattr(config, 'LUCKY_GAME_REWARD', 50.0)
|
||||
|
||||
winning_nums = random.sample(range(1, numbers_count + 1), winning_numbers)
|
||||
|
||||
await state.update_data(
|
||||
winning_numbers=winning_nums,
|
||||
reward_amount=reward_amount,
|
||||
numbers_count=numbers_count
|
||||
)
|
||||
await state.set_state(LuckyGameStates.waiting_number_choice)
|
||||
|
||||
text = f"🎯 **Выберите число от 1 до {numbers_count}**\n\n"
|
||||
text += f"🎁 Награда: {reward_amount:.0f}₽\n"
|
||||
text += f"🍀 Удачных чисел: {winning_numbers} из {numbers_count}\n\n"
|
||||
text += "Нажмите на число, чтобы испытать удачу!"
|
||||
|
||||
buttons = []
|
||||
for i in range(0, numbers_count, 5):
|
||||
row = []
|
||||
for j in range(5):
|
||||
if i + j + 1 <= numbers_count:
|
||||
number = i + j + 1
|
||||
row.append(InlineKeyboardButton(
|
||||
text=str(number),
|
||||
callback_data=f"choose_number_{number}"
|
||||
))
|
||||
buttons.append(row)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="❌ Отмена", callback_data="lucky_game")])
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting lucky game: {e}")
|
||||
await callback.answer("❌ Ошибка запуска игры")
|
||||
|
||||
@lucky_game_router.callback_query(F.data.startswith("choose_number_"), StateFilter(LuckyGameStates.waiting_number_choice))
|
||||
async def choose_number_callback(callback: CallbackQuery, db: Database, state: FSMContext, **kwargs):
|
||||
"""Обработка выбора числа"""
|
||||
user = kwargs.get('user')
|
||||
bot = kwargs.get('bot')
|
||||
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
try:
|
||||
chosen_number = int(callback.data.split("_")[2])
|
||||
|
||||
state_data = await state.get_data()
|
||||
winning_numbers = state_data['winning_numbers']
|
||||
reward_amount = state_data['reward_amount']
|
||||
numbers_count = state_data['numbers_count']
|
||||
|
||||
is_winner = chosen_number in winning_numbers
|
||||
|
||||
await save_game_result(db, user.telegram_id, chosen_number, winning_numbers, is_winner, reward_amount if is_winner else 0.0)
|
||||
|
||||
if is_winner:
|
||||
await db.add_balance(user.telegram_id, reward_amount)
|
||||
|
||||
await db.create_payment(
|
||||
user_id=user.telegram_id,
|
||||
amount=reward_amount,
|
||||
payment_type='lucky_game',
|
||||
description=f'Выигрыш в игре удачи (число {chosen_number})',
|
||||
status='completed'
|
||||
)
|
||||
|
||||
text = "🎉 **ПОЗДРАВЛЯЕМ! ВЫ ВЫИГРАЛИ!** 🎉\n\n"
|
||||
text += f"🎯 Ваше число: **{chosen_number}**\n"
|
||||
text += f"💰 Награда: **{reward_amount:.0f}₽** зачислена на баланс!\n"
|
||||
text += f"🆕 Новый баланс: **{user.balance + reward_amount:.0f}₽**\n\n"
|
||||
else:
|
||||
text = "😔 **Не повезло в этот раз...**\n\n"
|
||||
text += f"🎯 Ваше число: **{chosen_number}**\n"
|
||||
text += f"🍀 Выигрышные числа: **{', '.join(map(str, sorted(winning_numbers)))}**\n\n"
|
||||
text += "🔄 Попробуйте завтра!"
|
||||
|
||||
winning_nums_str = ', '.join(map(str, sorted(winning_numbers)))
|
||||
text += f"\n🎲 Сегодняшние удачные числа: {winning_nums_str}"
|
||||
text += f"\n\n⏰ Следующая игра будет доступна завтра!"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📈 История игр", callback_data="lucky_game_history")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="main_menu")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
|
||||
log_user_action(user.telegram_id, "lucky_game_played",
|
||||
f"Number: {chosen_number}, Winner: {is_winner}, Reward: {reward_amount if is_winner else 0}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in choose number: {e}")
|
||||
await callback.answer("❌ Ошибка обработки выбора")
|
||||
|
||||
await state.clear()
|
||||
|
||||
@lucky_game_router.callback_query(F.data == "lucky_game_history")
|
||||
async def lucky_game_history_callback(callback: CallbackQuery, db: Database, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
try:
|
||||
games = await get_user_game_history(db, user.telegram_id, limit=10)
|
||||
|
||||
if not games:
|
||||
text = "📈 **История ваших игр**\n\n"
|
||||
text += "🎯 Вы еще не играли в игру удачи.\n\n"
|
||||
text += "Начните играть, чтобы увидеть историю!"
|
||||
else:
|
||||
text = "📈 **История ваших игр** (последние 10)\n\n"
|
||||
|
||||
for i, game in enumerate(games, 1):
|
||||
date_str = format_datetime(game['played_at'], user.language)
|
||||
|
||||
if game['is_winner']:
|
||||
emoji = "🎉"
|
||||
result = f"Выиграли {game['reward_amount']:.0f}₽"
|
||||
else:
|
||||
emoji = "😔"
|
||||
result = "Не повезло"
|
||||
|
||||
text += f"{i}. {emoji} Число: **{game['chosen_number']}** - {result}\n"
|
||||
text += f" 📅 {date_str}\n\n"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🎰 К игре", callback_data="lucky_game")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="main_menu")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error showing game history: {e}")
|
||||
await callback.answer("❌ Ошибка загрузки истории")
|
||||
|
||||
@lucky_game_router.callback_query(F.data == "noop")
|
||||
async def noop_callback(callback: CallbackQuery, **kwargs):
|
||||
"""Пустая операция для неактивных кнопок"""
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def check_can_play_today(db: Database, user_id: int) -> tuple[bool, Optional[datetime]]:
|
||||
"""Проверяет, может ли пользователь играть сегодня"""
|
||||
try:
|
||||
can_play = await db.can_play_lucky_game_today(user_id)
|
||||
|
||||
if not can_play:
|
||||
now = datetime.utcnow()
|
||||
tomorrow = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
|
||||
return False, tomorrow
|
||||
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking can play today: {e}")
|
||||
return True, None
|
||||
|
||||
async def get_user_game_stats(db: Database, user_id: int) -> tuple[int, float, int]:
|
||||
"""Получает статистику игр пользователя"""
|
||||
try:
|
||||
stats = await db.get_user_game_stats(user_id)
|
||||
return stats['total_games'], stats['total_won'], stats['total_wins']
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user game stats: {e}")
|
||||
return 0, 0.0, 0
|
||||
|
||||
async def save_game_result(db: Database, user_id: int, chosen_number: int,
|
||||
winning_numbers: List[int], is_winner: bool, reward_amount: float):
|
||||
"""Сохраняет результат игры в базу данных"""
|
||||
try:
|
||||
game = await db.create_lucky_game(
|
||||
user_id=user_id,
|
||||
chosen_number=chosen_number,
|
||||
winning_numbers=winning_numbers,
|
||||
is_winner=is_winner,
|
||||
reward_amount=reward_amount
|
||||
)
|
||||
|
||||
if game:
|
||||
logger.info(f"Game result saved for user {user_id}: number={chosen_number}, winner={is_winner}, reward={reward_amount}")
|
||||
else:
|
||||
logger.error(f"Failed to save game result for user {user_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving game result: {e}")
|
||||
|
||||
async def get_user_game_history(db: Database, user_id: int, limit: int = 10) -> List[dict]:
|
||||
"""Получает историю игр пользователя"""
|
||||
try:
|
||||
return await db.get_user_game_history(user_id, limit)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user game history: {e}")
|
||||
return []
|
||||
@@ -6,16 +6,28 @@ from aiogram import Bot, Dispatcher
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from lucky_game import lucky_game_router
|
||||
from stars_handlers import stars_router
|
||||
from autopay_service import AutoPayService
|
||||
|
||||
# Import our modules
|
||||
from config import load_config
|
||||
print("🚀 Запуск бота...")
|
||||
print(f"📍 Рабочая директория: {os.getcwd()}")
|
||||
print(f"📁 Файлы в директории: {os.listdir('.')}")
|
||||
|
||||
if os.path.exists('.env'):
|
||||
print("✅ Файл .env найден")
|
||||
else:
|
||||
print("❌ Файл .env НЕ НАЙДЕН!")
|
||||
print("💡 Создайте файл .env в корне проекта")
|
||||
|
||||
from config import load_config, debug_environment
|
||||
from database import Database
|
||||
from remnawave_api import RemnaWaveAPI
|
||||
from subscription_monitor import create_subscription_monitor
|
||||
from middlewares import DatabaseMiddleware, UserMiddleware, LoggingMiddleware, ThrottlingMiddleware, WorkflowDataMiddleware, BotMiddleware
|
||||
from handlers import router
|
||||
from admin_handlers import admin_router
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
@@ -26,125 +38,337 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def main():
|
||||
"""Main function"""
|
||||
try:
|
||||
# Load configuration
|
||||
config = load_config()
|
||||
class BotApplication:
|
||||
|
||||
def __init__(self):
|
||||
self.config = None
|
||||
self.db = None
|
||||
self.api = None
|
||||
self.bot = None
|
||||
self.dp = None
|
||||
self.monitor_service = None
|
||||
self.autopay_service = None
|
||||
self.webhook_server = None
|
||||
|
||||
async def _init_autopay_service(self):
|
||||
try:
|
||||
logger.info("🔧 Initializing autopay service...")
|
||||
|
||||
if not self.bot:
|
||||
logger.error("❌ Bot instance is None, cannot initialize autopay")
|
||||
return
|
||||
|
||||
if not self.db:
|
||||
logger.error("❌ Database instance is None, cannot initialize autopay")
|
||||
return
|
||||
|
||||
self.autopay_service = AutoPayService(self.db, self.api, self.bot)
|
||||
|
||||
self.dp.workflow_data["autopay_service"] = self.autopay_service
|
||||
logger.info("✅ Autopay service added to workflow_data")
|
||||
|
||||
logger.info("🚀 Starting autopay service...")
|
||||
await self.autopay_service.start()
|
||||
|
||||
status = await self.autopay_service.get_service_status()
|
||||
if status['is_running']:
|
||||
logger.info("✅ Autopay service started successfully")
|
||||
logger.info(f"📊 Autopay status: interval=30min")
|
||||
else:
|
||||
logger.warning("⚠️ Autopay service created but not running")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to initialize autopay service: {e}", exc_info=True)
|
||||
logger.warning("⚠️ Continuing without autopay service")
|
||||
self.autopay_service = None
|
||||
|
||||
# Validate required environment variables
|
||||
if not config.BOT_TOKEN:
|
||||
async def initialize(self):
|
||||
|
||||
debug_environment()
|
||||
|
||||
self.config = load_config()
|
||||
|
||||
print(f"🔧 Загруженная конфигурация:")
|
||||
print(f" BOT_USERNAME: '{self.config.BOT_USERNAME}'")
|
||||
print(f" REFERRAL_FIRST_REWARD: {self.config.REFERRAL_FIRST_REWARD}")
|
||||
print(f" ADMIN_IDS: {self.config.ADMIN_IDS}")
|
||||
|
||||
if not self.config.BOT_TOKEN:
|
||||
logger.error("BOT_TOKEN is required")
|
||||
return
|
||||
raise ValueError("BOT_TOKEN is required")
|
||||
|
||||
if not config.REMNAWAVE_URL or not config.REMNAWAVE_TOKEN:
|
||||
if not self.config.REMNAWAVE_URL or not self.config.REMNAWAVE_TOKEN:
|
||||
logger.error("REMNAWAVE_URL and REMNAWAVE_TOKEN are required")
|
||||
return
|
||||
raise ValueError("REMNAWAVE_URL and REMNAWAVE_TOKEN are required")
|
||||
|
||||
if not self.config.BOT_USERNAME:
|
||||
logger.warning("⚠️ BOT_USERNAME не установлен! Реферальные ссылки работать не будут!")
|
||||
print("💡 Добавьте BOT_USERNAME=your_bot_username в .env файл")
|
||||
|
||||
logger.info("Starting RemnaWave Bot...")
|
||||
logger.info(f"RemnaWave URL: {config.REMNAWAVE_URL}")
|
||||
logger.info(f"Admin IDs: {config.ADMIN_IDS}")
|
||||
logger.info(f"RemnaWave URL: {self.config.REMNAWAVE_URL}")
|
||||
logger.info(f"Admin IDs: {self.config.ADMIN_IDS}")
|
||||
logger.info(f"Bot Username: {self.config.BOT_USERNAME}")
|
||||
|
||||
# Initialize database
|
||||
db = Database(config.DATABASE_URL)
|
||||
self.db = Database(self.config.DATABASE_URL)
|
||||
|
||||
# Try to initialize database with retry logic
|
||||
await self._init_database()
|
||||
|
||||
self.api = RemnaWaveAPI(
|
||||
self.config.REMNAWAVE_URL,
|
||||
self.config.REMNAWAVE_TOKEN,
|
||||
self.config.SUBSCRIPTION_BASE_URL
|
||||
)
|
||||
logger.info("RemnaWave API initialized")
|
||||
|
||||
await self._test_api_connection()
|
||||
|
||||
self.bot = Bot(
|
||||
token=self.config.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||
)
|
||||
|
||||
await self._test_bot_token()
|
||||
|
||||
self._setup_dispatcher()
|
||||
|
||||
await self._init_webhook_server()
|
||||
|
||||
if self.config.TRIBUTE_ENABLED:
|
||||
logger.info("✅ Tribute платежи включены")
|
||||
if not self.config.TRIBUTE_API_KEY:
|
||||
logger.warning("⚠️ TRIBUTE_API_KEY не установлен!")
|
||||
if not self.config.TRIBUTE_DONATE_URL:
|
||||
logger.warning("⚠️ TRIBUTE_DONATE_URL не установлен!")
|
||||
else:
|
||||
logger.info("❌ Tribute платежи отключены")
|
||||
|
||||
await self._init_monitor_service()
|
||||
await self._init_autopay_service()
|
||||
|
||||
if self.config.STARS_ENABLED:
|
||||
logger.info("✅ Telegram Stars пополнение включено")
|
||||
logger.info(f"📊 Настроенные курсы: {self.config.STARS_RATES}")
|
||||
|
||||
if not self.config.STARS_RATES or len(self.config.STARS_RATES) == 0:
|
||||
logger.warning("⚠️ STARS_RATES пустые! Telegram Stars будут недоступны")
|
||||
self.config.STARS_ENABLED = False
|
||||
else:
|
||||
valid_rates = all(
|
||||
isinstance(stars, int) and isinstance(rubles, (int, float)) and stars > 0 and rubles > 0
|
||||
for stars, rubles in self.config.STARS_RATES.items()
|
||||
)
|
||||
if not valid_rates:
|
||||
logger.warning("⚠️ Неверные STARS_RATES! Telegram Stars будут недоступны")
|
||||
self.config.STARS_ENABLED = False
|
||||
else:
|
||||
logger.info("❌ Telegram Stars пополнение отключено")
|
||||
|
||||
async def _init_webhook_server(self):
|
||||
"""Инициализация webhook сервера для Tribute"""
|
||||
try:
|
||||
logger.info("🔧 Initializing webhook server...")
|
||||
|
||||
from webhook_server import WebhookServer
|
||||
self.webhook_server = WebhookServer(self.bot, self.db, self.config)
|
||||
|
||||
logger.info("🚀 Starting webhook server...")
|
||||
await self.webhook_server.start()
|
||||
|
||||
logger.info("✅ Webhook server started successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to initialize webhook server: {e}", exc_info=True)
|
||||
logger.warning("⚠️ Continuing without webhook server")
|
||||
self.webhook_server = None
|
||||
|
||||
async def _init_database(self):
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
await db.init_db()
|
||||
logger.info("Database initialized successfully")
|
||||
logger.info(f"🗄️ Database initialization attempt {attempt + 1}/{max_retries}")
|
||||
await self.db.init_db()
|
||||
logger.info("✅ Database initialized successfully with all migrations")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Database initialization attempt {attempt + 1} failed: {e}")
|
||||
logger.error(f"❌ Database initialization attempt {attempt + 1} failed: {e}")
|
||||
if attempt == max_retries - 1:
|
||||
logger.error("Failed to initialize database after all retries")
|
||||
return
|
||||
await asyncio.sleep(2) # Wait before retry
|
||||
|
||||
# Initialize RemnaWave API
|
||||
api = RemnaWaveAPI(config.REMNAWAVE_URL, config.REMNAWAVE_TOKEN)
|
||||
logger.info("RemnaWave API initialized")
|
||||
|
||||
# Test API connection (optional - don't fail if it doesn't work)
|
||||
logger.error("💥 Failed to initialize database after all retries")
|
||||
raise
|
||||
await asyncio.sleep(2)
|
||||
|
||||
async def _test_api_connection(self):
|
||||
try:
|
||||
system_stats = await api.get_system_stats()
|
||||
system_stats = await self.api.get_system_stats()
|
||||
if system_stats:
|
||||
logger.info("RemnaWave API connection successful")
|
||||
else:
|
||||
logger.warning("RemnaWave API connection test failed - continuing anyway")
|
||||
except Exception as e:
|
||||
logger.warning(f"RemnaWave API connection error: {e} - continuing anyway")
|
||||
|
||||
# Initialize bot and dispatcher
|
||||
bot = Bot(
|
||||
token=config.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||
)
|
||||
|
||||
storage = MemoryStorage()
|
||||
dp = Dispatcher(storage=storage)
|
||||
|
||||
# Store config, api, and db in dispatcher workflow_data for access in handlers
|
||||
dp.workflow_data.update({
|
||||
"config": config,
|
||||
"api": api,
|
||||
"db": db
|
||||
})
|
||||
|
||||
# Setup middlewares in correct order
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
|
||||
dp.message.middleware(ThrottlingMiddleware(rate_limit=0.5))
|
||||
dp.callback_query.middleware(ThrottlingMiddleware(rate_limit=0.3))
|
||||
|
||||
dp.message.middleware(WorkflowDataMiddleware())
|
||||
dp.callback_query.middleware(WorkflowDataMiddleware())
|
||||
|
||||
dp.message.middleware(BotMiddleware(bot))
|
||||
dp.callback_query.middleware(BotMiddleware(bot))
|
||||
|
||||
dp.message.middleware(DatabaseMiddleware(db))
|
||||
dp.callback_query.middleware(DatabaseMiddleware(db))
|
||||
|
||||
dp.message.middleware(UserMiddleware(db, config))
|
||||
dp.callback_query.middleware(UserMiddleware(db, config))
|
||||
|
||||
# Register routers
|
||||
dp.include_router(router)
|
||||
dp.include_router(admin_router)
|
||||
|
||||
# Setup shutdown handler
|
||||
async def on_shutdown():
|
||||
logger.info("Shutting down bot...")
|
||||
try:
|
||||
await api.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing API: {e}")
|
||||
|
||||
try:
|
||||
await db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing database: {e}")
|
||||
|
||||
logger.info("Bot shutdown complete")
|
||||
|
||||
# Test bot token before starting
|
||||
async def _test_bot_token(self):
|
||||
try:
|
||||
bot_info = await bot.get_me()
|
||||
bot_info = await self.bot.get_me()
|
||||
logger.info(f"Bot started: @{bot_info.username} ({bot_info.first_name})")
|
||||
|
||||
if not self.config.BOT_USERNAME and bot_info.username:
|
||||
self.config.BOT_USERNAME = bot_info.username
|
||||
logger.info(f"✅ BOT_USERNAME автоматически установлен: {bot_info.username}")
|
||||
print("💡 Добавьте BOT_USERNAME в .env файл для постоянного сохранения")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Invalid bot token or network error: {e}")
|
||||
return
|
||||
|
||||
# Start polling
|
||||
logger.info("Bot polling started successfully")
|
||||
try:
|
||||
await dp.start_polling(bot)
|
||||
finally:
|
||||
await on_shutdown()
|
||||
raise
|
||||
|
||||
def _setup_dispatcher(self):
|
||||
storage = MemoryStorage()
|
||||
self.dp = Dispatcher(storage=storage)
|
||||
|
||||
self.dp.workflow_data.update({
|
||||
"config": self.config,
|
||||
"api": self.api,
|
||||
"db": self.db,
|
||||
"monitor_service": None,
|
||||
"autopay_service": None
|
||||
})
|
||||
|
||||
self.dp.message.middleware(LoggingMiddleware())
|
||||
self.dp.callback_query.middleware(LoggingMiddleware())
|
||||
|
||||
self.dp.message.middleware(ThrottlingMiddleware(rate_limit=0.5))
|
||||
self.dp.callback_query.middleware(ThrottlingMiddleware(rate_limit=0.3))
|
||||
|
||||
self.dp.message.middleware(WorkflowDataMiddleware())
|
||||
self.dp.callback_query.middleware(WorkflowDataMiddleware())
|
||||
|
||||
self.dp.message.middleware(BotMiddleware(self.bot))
|
||||
self.dp.callback_query.middleware(BotMiddleware(self.bot))
|
||||
|
||||
self.dp.message.middleware(DatabaseMiddleware(self.db))
|
||||
self.dp.callback_query.middleware(DatabaseMiddleware(self.db))
|
||||
|
||||
self.dp.message.middleware(UserMiddleware(self.db, self.config))
|
||||
self.dp.callback_query.middleware(UserMiddleware(self.db, self.config))
|
||||
|
||||
self.dp.include_router(router)
|
||||
self.dp.include_router(admin_router)
|
||||
self.dp.include_router(lucky_game_router)
|
||||
self.dp.include_router(stars_router)
|
||||
|
||||
async def _init_monitor_service(self):
|
||||
try:
|
||||
logger.info("🔧 Initializing subscription monitor service...")
|
||||
|
||||
if not self.bot:
|
||||
logger.error("❌ Bot instance is None, cannot initialize monitor")
|
||||
return
|
||||
|
||||
if not self.db:
|
||||
logger.error("❌ Database instance is None, cannot initialize monitor")
|
||||
return
|
||||
|
||||
if not self.config:
|
||||
logger.error("❌ Config instance is None, cannot initialize monitor")
|
||||
return
|
||||
|
||||
self.monitor_service = await create_subscription_monitor(
|
||||
self.bot, self.db, self.config, self.api
|
||||
)
|
||||
|
||||
if not self.monitor_service:
|
||||
logger.error("❌ Failed to create monitor service instance")
|
||||
return
|
||||
|
||||
self.dp.workflow_data["monitor_service"] = self.monitor_service
|
||||
logger.info("✅ Monitor service added to workflow_data")
|
||||
|
||||
logger.info("🚀 Starting monitor service...")
|
||||
await self.monitor_service.start()
|
||||
|
||||
status = await self.monitor_service.get_service_status()
|
||||
if status['is_running']:
|
||||
logger.info("✅ Subscription monitor service started successfully")
|
||||
logger.info(f"📊 Monitor status: interval={status['check_interval']}s, daily_hour={status['daily_check_hour']}, warning_days={status['warning_days']}")
|
||||
else:
|
||||
logger.warning("⚠️ Monitor service created but not running")
|
||||
logger.warning(f"📊 Monitor status: {status}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to initialize monitor service: {e}", exc_info=True)
|
||||
logger.warning("⚠️ Continuing without monitor service")
|
||||
self.monitor_service = None
|
||||
|
||||
async def start(self):
|
||||
logger.info("Bot polling started successfully")
|
||||
|
||||
if self.config.BOT_USERNAME:
|
||||
logger.info(f"🎁 Реферальная система активна! Ссылки: https://t.me/{self.config.BOT_USERNAME}?start=ref_USERID")
|
||||
else:
|
||||
logger.warning("⚠️ Реферальная система неактивна! Установите BOT_USERNAME")
|
||||
|
||||
try:
|
||||
await self.dp.start_polling(self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during polling: {e}")
|
||||
raise
|
||||
finally:
|
||||
await self.shutdown()
|
||||
|
||||
async def shutdown(self):
|
||||
logger.info("Shutting down bot...")
|
||||
|
||||
if self.webhook_server:
|
||||
try:
|
||||
await self.webhook_server.stop()
|
||||
logger.info("Webhook server stopped")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping webhook server: {e}")
|
||||
|
||||
if self.autopay_service:
|
||||
try:
|
||||
await self.autopay_service.stop()
|
||||
logger.info("Autopay service stopped")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping autopay service: {e}")
|
||||
|
||||
if self.monitor_service:
|
||||
try:
|
||||
await self.monitor_service.stop()
|
||||
logger.info("Monitor service stopped")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping monitor service: {e}")
|
||||
|
||||
if self.api:
|
||||
try:
|
||||
await self.api.close()
|
||||
logger.info("API connection closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing API: {e}")
|
||||
|
||||
if self.db:
|
||||
try:
|
||||
await self.db.close()
|
||||
logger.info("Database connection closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing database: {e}")
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
await self.bot.session.close()
|
||||
logger.info("Bot session closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing bot session: {e}")
|
||||
|
||||
logger.info("Bot shutdown complete")
|
||||
|
||||
async def main():
|
||||
app = None
|
||||
try:
|
||||
app = BotApplication()
|
||||
await app.initialize()
|
||||
await app.start()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Bot stopped by user (Ctrl+C)")
|
||||
except Exception as e:
|
||||
@@ -152,6 +376,9 @@ async def main():
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise
|
||||
finally:
|
||||
if app:
|
||||
await app.shutdown()
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
|
||||
@@ -8,7 +8,6 @@ from config import Config
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DatabaseMiddleware(BaseMiddleware):
|
||||
"""Middleware for database operations"""
|
||||
|
||||
def __init__(self, db: Database):
|
||||
self.db = db
|
||||
@@ -23,7 +22,6 @@ class DatabaseMiddleware(BaseMiddleware):
|
||||
return await handler(event, data)
|
||||
|
||||
class UserMiddleware(BaseMiddleware):
|
||||
"""Middleware for user management"""
|
||||
|
||||
def __init__(self, db: Database, config: Config):
|
||||
self.db = db
|
||||
@@ -39,25 +37,21 @@ class UserMiddleware(BaseMiddleware):
|
||||
|
||||
if telegram_user and not telegram_user.is_bot:
|
||||
try:
|
||||
# Get or create user
|
||||
user = await self.db.get_user_by_telegram_id(telegram_user.id)
|
||||
|
||||
if not user:
|
||||
# Check if user is admin
|
||||
is_admin = telegram_user.id in self.config.ADMIN_IDS
|
||||
|
||||
# Create new user
|
||||
user = await self.db.create_user(
|
||||
telegram_id=telegram_user.id,
|
||||
username=telegram_user.username,
|
||||
first_name=telegram_user.first_name,
|
||||
last_name=telegram_user.last_name,
|
||||
language=self.config.DEFAULT_LANGUAGE,
|
||||
language='',
|
||||
is_admin=is_admin
|
||||
)
|
||||
logger.info(f"Created new user: {telegram_user.id}")
|
||||
logger.info(f"Created new user: {telegram_user.id} without language")
|
||||
else:
|
||||
# Update user info if changed
|
||||
updated = False
|
||||
if user.username != telegram_user.username:
|
||||
user.username = telegram_user.username
|
||||
@@ -69,7 +63,6 @@ class UserMiddleware(BaseMiddleware):
|
||||
user.last_name = telegram_user.last_name
|
||||
updated = True
|
||||
|
||||
# Check admin status
|
||||
should_be_admin = telegram_user.id in self.config.ADMIN_IDS
|
||||
if user.is_admin != should_be_admin:
|
||||
user.is_admin = should_be_admin
|
||||
@@ -79,11 +72,10 @@ class UserMiddleware(BaseMiddleware):
|
||||
await self.db.update_user(user)
|
||||
|
||||
data['user'] = user
|
||||
data['lang'] = user.language if user else self.config.DEFAULT_LANGUAGE
|
||||
data['lang'] = user.language if user.language and user.language != '' else self.config.DEFAULT_LANGUAGE
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in UserMiddleware: {e}")
|
||||
# Create a minimal user object with defaults if database fails
|
||||
class FallbackUser:
|
||||
def __init__(self, telegram_id: int, username: str = None, config: Config = None):
|
||||
self.telegram_id = telegram_id
|
||||
@@ -95,7 +87,6 @@ class UserMiddleware(BaseMiddleware):
|
||||
self.is_admin = telegram_id in (config.ADMIN_IDS if config else [])
|
||||
self.remnawave_uuid = None
|
||||
|
||||
# Try to use fallback, but if database is completely broken, set None
|
||||
try:
|
||||
fallback_user = FallbackUser(telegram_user.id, telegram_user.username, self.config)
|
||||
data['user'] = fallback_user
|
||||
@@ -110,7 +101,6 @@ class UserMiddleware(BaseMiddleware):
|
||||
return await handler(event, data)
|
||||
|
||||
class LoggingMiddleware(BaseMiddleware):
|
||||
"""Middleware for logging"""
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
@@ -134,7 +124,6 @@ class LoggingMiddleware(BaseMiddleware):
|
||||
return await handler(event, data)
|
||||
|
||||
class ThrottlingMiddleware(BaseMiddleware):
|
||||
"""Simple throttling middleware"""
|
||||
|
||||
def __init__(self, rate_limit: float = 1.0):
|
||||
self.rate_limit = rate_limit
|
||||
@@ -163,7 +152,6 @@ class ThrottlingMiddleware(BaseMiddleware):
|
||||
return await handler(event, data)
|
||||
|
||||
class WorkflowDataMiddleware(BaseMiddleware):
|
||||
"""Middleware to pass workflow data to handlers"""
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
@@ -171,20 +159,17 @@ class WorkflowDataMiddleware(BaseMiddleware):
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
# Get dispatcher from event
|
||||
if hasattr(event, 'bot'):
|
||||
dp = getattr(event.bot, '_dispatcher', None)
|
||||
if dp and hasattr(dp, 'workflow_data'):
|
||||
# Add workflow data to handler data
|
||||
workflow_data = dp.workflow_data
|
||||
for key, value in workflow_data.items():
|
||||
if key not in data: # Don't override existing data
|
||||
if key not in data:
|
||||
data[key] = value
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
class BotMiddleware(BaseMiddleware):
|
||||
"""Middleware to add bot instance to handler data"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from database import Database
|
||||
from database import ReferralProgram, ReferralEarning
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def process_referral_rewards(user_id: int, amount: float, payment_id: int, db: Database, bot=None, payment_type: str = None):
|
||||
try:
|
||||
import os
|
||||
|
||||
if payment_type not in ['topup', 'admin_topup']:
|
||||
logger.debug(f"Skipping referral rewards for payment type: {payment_type}")
|
||||
return
|
||||
|
||||
if amount <= 0:
|
||||
logger.debug(f"Skipping referral rewards for non-positive amount: {amount}")
|
||||
return
|
||||
|
||||
threshold = float(os.getenv('REFERRAL_THRESHOLD', '300.0'))
|
||||
first_reward = float(os.getenv('REFERRAL_FIRST_REWARD', '150.0'))
|
||||
referred_bonus = float(os.getenv('REFERRAL_REFERRED_BONUS', '150.0'))
|
||||
percentage = float(os.getenv('REFERRAL_PERCENTAGE', '0.25'))
|
||||
|
||||
referral = await db.get_referral_by_referred_id(user_id)
|
||||
|
||||
if not referral:
|
||||
logger.debug(f"No referral found for user {user_id}")
|
||||
return
|
||||
|
||||
user = await db.get_user_by_telegram_id(user_id)
|
||||
if not user:
|
||||
logger.error(f"User {user_id} not found")
|
||||
return
|
||||
|
||||
logger.info(f"Processing referral rewards for topup: user {user_id}, amount {amount}, referrer {referral.referrer_id}")
|
||||
|
||||
if not referral.first_reward_paid and user.balance >= threshold:
|
||||
logger.info(f"Processing first reward for referral {referral.id} (threshold: {threshold}, reward: {first_reward})")
|
||||
|
||||
await db.add_balance(referral.referrer_id, first_reward)
|
||||
|
||||
await db.create_payment(
|
||||
user_id=referral.referrer_id,
|
||||
amount=first_reward,
|
||||
payment_type='referral',
|
||||
description=f'Первая награда за реферала ID:{user_id}',
|
||||
status='completed'
|
||||
)
|
||||
|
||||
success = await db.create_referral_earning(
|
||||
referrer_id=referral.referrer_id,
|
||||
referred_id=user_id,
|
||||
amount=first_reward,
|
||||
earning_type='first_reward',
|
||||
related_payment_id=payment_id
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"First reward paid: {first_reward}₽ to referrer {referral.referrer_id}")
|
||||
|
||||
if bot:
|
||||
try:
|
||||
await bot.send_message(
|
||||
referral.referrer_id,
|
||||
f"🎉 Поздравляем! Ваш реферал пополнил баланс на {threshold}₽+\n\n"
|
||||
f"💰 Вам начислено {first_reward}₽ за приведенного друга!\n"
|
||||
f"Теперь вы будете получать {percentage*100:.0f}% с каждого его пополнения баланса."
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
user_id,
|
||||
f"🎁 Бонус активирован! Вам начислено {referred_bonus}₽ за переход по реферальной ссылке!"
|
||||
)
|
||||
|
||||
await db.add_balance(user_id, referred_bonus)
|
||||
await db.create_payment(
|
||||
user_id=user_id,
|
||||
amount=referred_bonus,
|
||||
payment_type='referral',
|
||||
description='Бонус за переход по реферальной ссылке',
|
||||
status='completed'
|
||||
)
|
||||
|
||||
logger.info(f"Referral bonus notifications sent and balance updated")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send referral notifications: {e}")
|
||||
else:
|
||||
logger.error(f"Failed to create first reward earning")
|
||||
|
||||
if referral.first_reward_paid:
|
||||
percentage_reward = amount * percentage
|
||||
|
||||
if percentage_reward >= 0.01:
|
||||
await db.add_balance(referral.referrer_id, percentage_reward)
|
||||
|
||||
await db.create_payment(
|
||||
user_id=referral.referrer_id,
|
||||
amount=percentage_reward,
|
||||
payment_type='referral',
|
||||
description=f'{percentage*100:.0f}% от пополнения реферала ID:{user_id}',
|
||||
status='completed'
|
||||
)
|
||||
|
||||
success = await db.create_referral_earning(
|
||||
referrer_id=referral.referrer_id,
|
||||
referred_id=user_id,
|
||||
amount=percentage_reward,
|
||||
earning_type='percentage',
|
||||
related_payment_id=payment_id
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"Percentage reward paid: {percentage_reward:.2f}₽ ({percentage*100:.0f}%) to referrer {referral.referrer_id}")
|
||||
|
||||
if bot and percentage_reward >= 1.0:
|
||||
try:
|
||||
await bot.send_message(
|
||||
referral.referrer_id,
|
||||
f"💰 Реферальный доход!\n\n"
|
||||
f"Ваш реферал пополнил баланс на {amount:.2f}₽\n"
|
||||
f"Вам начислено: {percentage_reward:.2f}₽ ({percentage*100:.0f}%)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send percentage notification: {e}")
|
||||
else:
|
||||
logger.error(f"Failed to create percentage earning")
|
||||
else:
|
||||
logger.info(f"Skipping percentage reward for user {user_id} - first reward not yet paid")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing referral rewards: {e}")
|
||||
|
||||
async def create_referral_from_start_param(user_telegram_id: int, start_param: str, db: Database, bot=None):
|
||||
try:
|
||||
if not start_param.startswith("ref_"):
|
||||
return False
|
||||
|
||||
referrer_id = int(start_param.replace("ref_", ""))
|
||||
|
||||
if referrer_id == user_telegram_id:
|
||||
logger.warning(f"User {user_telegram_id} tried to refer themselves")
|
||||
return False
|
||||
|
||||
existing_reverse_referral = await db.get_referral_by_referred_id(referrer_id)
|
||||
if existing_reverse_referral and existing_reverse_referral.referrer_id == user_telegram_id:
|
||||
logger.warning(f"Mutual referral attempt blocked: {user_telegram_id} is already referrer for {referrer_id}")
|
||||
return False
|
||||
|
||||
existing_referral = await db.get_referral_by_referred_id(user_telegram_id)
|
||||
|
||||
if existing_referral:
|
||||
logger.info(f"User {user_telegram_id} already has referrer")
|
||||
return False
|
||||
|
||||
referral_code = await db.generate_unique_referral_code(referrer_id)
|
||||
|
||||
referral = await db.create_referral(referrer_id, user_telegram_id, referral_code)
|
||||
|
||||
if referral:
|
||||
logger.info(f"Created referral: {referrer_id} -> {user_telegram_id} with code {referral_code}")
|
||||
|
||||
if bot:
|
||||
try:
|
||||
referrer = await db.get_user_by_telegram_id(referrer_id)
|
||||
if referrer:
|
||||
import os
|
||||
threshold = float(os.getenv('REFERRAL_THRESHOLD', '300.0'))
|
||||
first_reward = float(os.getenv('REFERRAL_FIRST_REWARD', '150.0'))
|
||||
percentage = float(os.getenv('REFERRAL_PERCENTAGE', '0.25'))
|
||||
|
||||
await bot.send_message(
|
||||
referrer_id,
|
||||
f"🎉 Отлично! По вашей ссылке зарегистрировался новый пользователь!\n\n"
|
||||
f"Вы получите {first_reward:.0f}₽ после того, как он пополнит баланс на {threshold:.0f}₽.\n"
|
||||
f"И будете получать {percentage*100:.0f}% с каждого его пополнения баланса!"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to notify referrer: {e}")
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Invalid referral parameter: {start_param}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating referral from start param: {e}")
|
||||
return False
|
||||
|
||||
async def create_referral_from_promocode(user_telegram_id: int, referral_code: str, db: Database, bot=None):
|
||||
try:
|
||||
if not referral_code.startswith("REF"):
|
||||
return False
|
||||
|
||||
logger.info(f"Trying to use referral code {referral_code} for user {user_telegram_id}")
|
||||
|
||||
async with db.session_factory() as session:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(ReferralProgram).where(ReferralProgram.referral_code == referral_code)
|
||||
)
|
||||
referral_record = result.scalar_one_or_none()
|
||||
|
||||
if not referral_record:
|
||||
logger.warning(f"No referrer found for code {referral_code}")
|
||||
return False
|
||||
|
||||
referrer_id = referral_record.referrer_id
|
||||
|
||||
if referrer_id == user_telegram_id:
|
||||
logger.warning(f"User {user_telegram_id} tried to use own referral code")
|
||||
return False
|
||||
|
||||
existing_reverse_referral = await db.get_referral_by_referred_id(referrer_id)
|
||||
if existing_reverse_referral and existing_reverse_referral.referrer_id == user_telegram_id:
|
||||
logger.warning(f"Mutual referral attempt blocked: {user_telegram_id} is already referrer for {referrer_id}")
|
||||
return False
|
||||
|
||||
existing_referral = await db.get_referral_by_referred_id(user_telegram_id)
|
||||
|
||||
if existing_referral:
|
||||
logger.info(f"User {user_telegram_id} already has referrer")
|
||||
return False
|
||||
|
||||
referral = await db.create_referral(referrer_id, user_telegram_id, referral_code)
|
||||
|
||||
if referral:
|
||||
logger.info(f"Created referral from promocode: {referrer_id} -> {user_telegram_id}")
|
||||
|
||||
if bot:
|
||||
try:
|
||||
import os
|
||||
threshold = float(os.getenv('REFERRAL_THRESHOLD', '300.0'))
|
||||
first_reward = float(os.getenv('REFERRAL_FIRST_REWARD', '150.0'))
|
||||
|
||||
await bot.send_message(
|
||||
referrer_id,
|
||||
f"🎉 По вашему промокоду {referral_code} зарегистрировался новый пользователь!\n\n"
|
||||
f"Вы получите {first_reward:.0f}₽ после того, как он пополнит баланс на {threshold:.0f}₽."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to notify referrer: {e}")
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating referral from promocode: {e}")
|
||||
return False
|
||||
|
||||
def generate_referral_link(bot_username: str, user_id: int) -> str:
|
||||
if not bot_username:
|
||||
return ""
|
||||
|
||||
if bot_username.startswith('@'):
|
||||
bot_username = bot_username[1:]
|
||||
|
||||
return f"https://t.me/{bot_username}?start=ref_{user_id}"
|
||||
|
||||
def validate_referral_code(code: str) -> bool:
|
||||
if not code or not code.startswith("REF"):
|
||||
return False
|
||||
if len(code) < 4 or len(code) > 20:
|
||||
return False
|
||||
return True
|
||||
|
||||
def format_referral_stats(stats: dict, lang: str = 'ru') -> str:
|
||||
if lang == 'ru':
|
||||
return (f"👥 Приглашено: {stats['total_referrals']}\n"
|
||||
f"✅ Активных: {stats['active_referrals']}\n"
|
||||
f"💰 Заработано: {stats['total_earned']:.2f}₽")
|
||||
else:
|
||||
return (f"👥 Invited: {stats['total_referrals']}\n"
|
||||
f"✅ Active: {stats['active_referrals']}\n"
|
||||
f"💰 Earned: ${stats['total_earned']:.2f}")
|
||||
@@ -1,5 +1,38 @@
|
||||
aiogram==3.4.1
|
||||
aiohttp==3.9.3
|
||||
aiosqlite==0.19.0
|
||||
sqlalchemy[asyncio]==2.0.25
|
||||
python-dotenv==1.0.1
|
||||
# Telegram Bot Framework
|
||||
aiogram>=3.4.0
|
||||
|
||||
# Database
|
||||
SQLAlchemy>=2.0.0
|
||||
alembic>=1.12.0
|
||||
|
||||
# PostgreSQL driver
|
||||
asyncpg>=0.28.0
|
||||
psycopg2-binary>=2.9.0
|
||||
|
||||
# SQLite driver (fallback)
|
||||
aiosqlite>=0.19.0
|
||||
|
||||
# HTTP Client
|
||||
aiohttp>=3.8.0
|
||||
aiofiles>=23.0.0
|
||||
|
||||
# Redis (optional)
|
||||
redis>=4.5.0
|
||||
aioredis>=2.0.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv>=1.0.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
|
||||
# Logging and monitoring
|
||||
structlog>=23.0.0
|
||||
|
||||
# Date and time
|
||||
python-dateutil>=2.8.0
|
||||
|
||||
# Cryptography
|
||||
cryptography>=42.0.0
|
||||
|
||||
# JSON handling
|
||||
orjson>=3.9.0
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# === RemnaWave Bot Setup & Management Script ===
|
||||
|
||||
SERVICE_NAME="remnawave-bot"
|
||||
BOT_FILE="main.py"
|
||||
ENV_FILE=".env"
|
||||
VENV_DIR="venv"
|
||||
|
||||
# Select language
|
||||
echo "🌐 Select language / Выберите язык:"
|
||||
echo "1) English"
|
||||
echo "2) Русский"
|
||||
read -p ">>> " LANG_CHOICE
|
||||
|
||||
case $LANG_CHOICE in
|
||||
2) LANG_CODE="ru" ;;
|
||||
*) LANG_CODE="en" ;;
|
||||
esac
|
||||
|
||||
# === Internationalized messages ===
|
||||
msg() {
|
||||
case $1 in
|
||||
check_python) [[ $LANG_CODE == "ru" ]] && echo "🔍 Проверка Python..." || echo "🔍 Checking Python..." ;;
|
||||
python_missing) [[ $LANG_CODE == "ru" ]] && echo "❌ Python 3 не установлен. Установите Python 3.8+." || echo "❌ Python 3 is not installed. Please install Python 3.8+." ;;
|
||||
version) echo "📋 Python version: $PYTHON_VERSION" ;;
|
||||
creating_venv) [[ $LANG_CODE == "ru" ]] && echo "🔧 Создание виртуального окружения..." || echo "🔧 Creating virtual environment..." ;;
|
||||
activating_venv) [[ $LANG_CODE == "ru" ]] && echo "🔧 Активация виртуального окружения..." || echo "🔧 Activating virtual environment..." ;;
|
||||
upgrading_pip) [[ $LANG_CODE == "ru" ]] && echo "📦 Обновление pip..." || echo "📦 Upgrading pip..." ;;
|
||||
installing_requirements) [[ $LANG_CODE == "ru" ]] && echo "📦 Установка зависимостей..." || echo "📦 Installing requirements..." ;;
|
||||
env_missing) [[ $LANG_CODE == "ru" ]] && echo "⚠️ Файл .env не найден!" || echo "⚠️ .env file not found!" ;;
|
||||
env_created) [[ $LANG_CODE == "ru" ]] && echo "📋 Создан .env файл. Пожалуйста, отредактируйте его." || echo "📋 .env created. Please edit it." ;;
|
||||
validating) [[ $LANG_CODE == "ru" ]] && echo "🔍 Проверка конфигурации..." || echo "🔍 Validating configuration..." ;;
|
||||
config_ok) [[ $LANG_CODE == "ru" ]] && echo "✅ Конфигурация прошла проверку." || echo "✅ Configuration validated successfully." ;;
|
||||
creating_service) [[ $LANG_CODE == "ru" ]] && echo "🔧 Создание systemd-сервиса..." || echo "🔧 Creating systemd service..." ;;
|
||||
service_created) [[ $LANG_CODE == "ru" ]] && echo "✅ Сервис создан и включён." || echo "✅ Service created and enabled." ;;
|
||||
start_prompt) [[ $LANG_CODE == "ru" ]] && echo "🚀 Запуск бота..." || echo "🚀 Starting the bot..." ;;
|
||||
already_running) [[ $LANG_CODE == "ru" ]] && echo "🟢 Бот уже запущен!" || echo "🟢 Bot is already running!" ;;
|
||||
last_logs) [[ $LANG_CODE == "ru" ]] && echo "📄 Последние 20 строк лога:" || echo "📄 Last 20 log lines:" ;;
|
||||
action_menu)
|
||||
[[ $LANG_CODE == "ru" ]] && {
|
||||
echo -e "\nЧто вы хотите сделать?"
|
||||
echo "1) Остановить бота"
|
||||
echo "2) Перезапустить бота"
|
||||
echo "3) Выйти"
|
||||
} || {
|
||||
echo -e "\nWhat would you like to do?"
|
||||
echo "1) Stop the bot"
|
||||
echo "2) Restart the bot"
|
||||
echo "3) Exit"
|
||||
}
|
||||
;;
|
||||
restarting) [[ $LANG_CODE == "ru" ]] && echo "🔄 Перезапуск бота..." || echo "🔄 Restarting bot..." ;;
|
||||
completed)
|
||||
echo ""
|
||||
[[ $LANG_CODE == "ru" ]] && {
|
||||
echo "🎉 Установка завершена!"
|
||||
echo "📋 Возможности бота:"
|
||||
echo " ✅ Мультиязычный интерфейс"
|
||||
echo " ✅ Управление балансом и подписками"
|
||||
echo " ✅ Промокоды и поддержка"
|
||||
echo ""
|
||||
} || {
|
||||
echo "🎉 Installation complete!"
|
||||
echo "📋 Bot Features:"
|
||||
echo " ✅ Multi-language support"
|
||||
echo " ✅ Subscription and balance management"
|
||||
echo " ✅ Promocode system and support"
|
||||
echo ""
|
||||
}
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# === Check if bot is running ===
|
||||
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||
msg already_running
|
||||
msg last_logs
|
||||
sudo journalctl -u "$SERVICE_NAME" -n 20 --no-pager
|
||||
msg action_menu
|
||||
read -p ">>> " choice
|
||||
case $choice in
|
||||
1)
|
||||
[[ $LANG_CODE == "ru" ]] && echo "🛑 Остановка бота..." || echo "🛑 Stopping the bot..."
|
||||
sudo systemctl stop "$SERVICE_NAME"
|
||||
[[ $LANG_CODE == "ru" ]] && echo "✅ Бот остановлен." || echo "✅ Bot stopped."
|
||||
exit 0
|
||||
;;
|
||||
2)
|
||||
msg restarting
|
||||
sudo systemctl restart "$SERVICE_NAME"
|
||||
[[ $LANG_CODE == "ru" ]] && echo "✅ Бот перезапущен." || echo "✅ Bot restarted."
|
||||
exit 0
|
||||
;;
|
||||
3)
|
||||
[[ $LANG_CODE == "ru" ]] && echo "👋 Выход." || echo "👋 Exiting."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
[[ $LANG_CODE == "ru" ]] && echo "⚠️ Неверный выбор." || echo "⚠️ Invalid choice."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# === Install/Setup ===
|
||||
msg check_python
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
msg python_missing
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PYTHON_VERSION=$(python3 -c 'import sys; print(".".join(map(str, sys.version_info[:2])))')
|
||||
msg version
|
||||
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
msg creating_venv
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
msg activating_venv
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
msg upgrading_pip
|
||||
pip install --upgrade pip
|
||||
|
||||
msg installing_requirements
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Create .env if needed
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
msg env_missing
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
# Bot Configuration
|
||||
BOT_TOKEN=
|
||||
|
||||
# RemnaWave API Configuration
|
||||
REMNAWAVE_URL=
|
||||
REMNAWAVE_MODE=
|
||||
REMNAWAVE_TOKEN=
|
||||
|
||||
# Admin Configuration (comma-separated list of Telegram user IDs)
|
||||
ADMIN_IDS=
|
||||
|
||||
# Support Configuration
|
||||
SUPPORT_USERNAME=support
|
||||
|
||||
# Database Configuration (optional, defaults to SQLite)
|
||||
# DATABASE_URL=sqlite+aiosqlite:///bot.db
|
||||
|
||||
# Trial Configuration
|
||||
TRIAL_ENABLED=true
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_GB=2
|
||||
TRIAL_SQUAD_UUID=
|
||||
TRIAL_PRICE=0.0
|
||||
EOF
|
||||
msg env_created
|
||||
echo ""
|
||||
read -p "🔧 Press Enter after configuring .env / Нажмите Enter после настройки .env..."
|
||||
fi
|
||||
|
||||
# Load env
|
||||
export $(grep -v '^#' "$ENV_FILE" | xargs)
|
||||
|
||||
msg validating
|
||||
|
||||
if [[ -z "$BOT_TOKEN" || "$BOT_TOKEN" == "your_telegram_bot_token_here" ]]; then
|
||||
echo "❌ BOT_TOKEN not set properly."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$REMNAWAVE_URL" || "$REMNAWAVE_URL" == "https://your-panel.com" ]]; then
|
||||
echo "❌ REMNAWAVE_URL not set properly."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$REMNAWAVE_TOKEN" || "$REMNAWAVE_TOKEN" == "your_jwt_token_here" ]]; then
|
||||
echo "❌ REMNAWAVE_TOKEN not set properly."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg config_ok
|
||||
|
||||
# Create systemd service
|
||||
read -p "🔧 Create systemd service? (y/n): " CREATE_SERVICE
|
||||
if [[ "$CREATE_SERVICE" == "y" || "$CREATE_SERVICE" == "Y" ]]; then
|
||||
msg creating_service
|
||||
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
|
||||
CURRENT_DIR=$(pwd)
|
||||
CURRENT_USER=$(whoami)
|
||||
sudo tee "$SERVICE_FILE" > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=RemnaWave Bot
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$CURRENT_USER
|
||||
WorkingDirectory=$CURRENT_DIR
|
||||
Environment=PATH=$CURRENT_DIR/$VENV_DIR/bin
|
||||
EnvironmentFile=$CURRENT_DIR/.env
|
||||
ExecStart=$CURRENT_DIR/$VENV_DIR/bin/python $BOT_FILE
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable "$SERVICE_NAME"
|
||||
msg service_created
|
||||
fi
|
||||
|
||||
msg completed
|
||||
msg start_prompt
|
||||
python "$BOT_FILE"
|
||||
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 176 KiB |
@@ -0,0 +1 @@
|
||||
|
||||
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 111 KiB |
@@ -0,0 +1 @@
|
||||
|
||||
|
After Width: | Height: | Size: 211 KiB |
@@ -0,0 +1,415 @@
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, LabeledPrice, PreCheckoutQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from database import Database, User
|
||||
from keyboards import stars_topup_keyboard, stars_payment_keyboard, balance_keyboard
|
||||
from translations import t
|
||||
from utils import log_user_action
|
||||
from referral_utils import process_referral_rewards
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
stars_router = Router()
|
||||
|
||||
@stars_router.callback_query(F.data == "topup_stars")
|
||||
async def topup_stars_callback(callback: CallbackQuery, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
config = kwargs.get('config')
|
||||
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
if not config or not config.STARS_ENABLED:
|
||||
await callback.message.edit_text(
|
||||
"❌ Пополнение через Telegram Stars временно недоступно",
|
||||
reply_markup=balance_keyboard(user.language)
|
||||
)
|
||||
return
|
||||
|
||||
if not config.STARS_RATES:
|
||||
await callback.message.edit_text(
|
||||
"❌ Курсы звезд не настроены",
|
||||
reply_markup=balance_keyboard(user.language)
|
||||
)
|
||||
return
|
||||
|
||||
text = "⭐ **Пополнение через Telegram Stars**\n\n"
|
||||
text += "🚀 **Преимущества:**\n"
|
||||
text += "• Мгновенное зачисление\n"
|
||||
text += "• Безопасные платежи через Telegram\n"
|
||||
text += "• Без комиссий и скрытых платежей\n\n"
|
||||
|
||||
text += "💎 **Доступные варианты:**\n"
|
||||
|
||||
sorted_rates = sorted(config.STARS_RATES.items())
|
||||
for stars, rubles in sorted_rates:
|
||||
rate_per_star = rubles / stars
|
||||
|
||||
if stars >= 500:
|
||||
bonus_text = " 🔥 Выгодно!"
|
||||
elif stars >= 250:
|
||||
bonus_text = " 💎 Хорошо!"
|
||||
else:
|
||||
bonus_text = ""
|
||||
|
||||
text += f"• {stars} ⭐ → {rubles:.0f}₽{bonus_text}\n"
|
||||
|
||||
text += f"\n💡 Выберите подходящий вариант:"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=stars_topup_keyboard(config.STARS_RATES, user.language),
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
|
||||
@stars_router.callback_query(F.data.startswith("buy_stars_"))
|
||||
async def buy_stars_callback(callback: CallbackQuery, db: Database, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
config = kwargs.get('config')
|
||||
bot = kwargs.get('bot')
|
||||
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
if not config or not config.STARS_ENABLED or not config.STARS_RATES:
|
||||
await callback.answer("❌ Пополнение недоступно")
|
||||
return
|
||||
|
||||
try:
|
||||
stars_amount = int(callback.data.split("_")[2])
|
||||
|
||||
if stars_amount not in config.STARS_RATES:
|
||||
await callback.answer("❌ Неверное количество звезд")
|
||||
return
|
||||
|
||||
rub_amount = config.STARS_RATES[stars_amount]
|
||||
|
||||
star_payment = await db.create_star_payment(
|
||||
user_id=user.telegram_id,
|
||||
stars_amount=stars_amount,
|
||||
rub_amount=rub_amount
|
||||
)
|
||||
|
||||
prices = [LabeledPrice(label=f"Пополнение на {rub_amount:.0f}₽", amount=stars_amount)]
|
||||
|
||||
try:
|
||||
await callback.answer("💳 Создаю платеж...")
|
||||
|
||||
await bot.send_invoice(
|
||||
chat_id=callback.message.chat.id,
|
||||
title=f"Пополнение баланса",
|
||||
description=f"Пополнение баланса на {rub_amount:.0f}₽ за {stars_amount} ⭐",
|
||||
payload=f"star_payment_{star_payment.id}",
|
||||
currency="XTR",
|
||||
prices=prices
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
f"💳 **Оплата через Telegram Stars**\n\n"
|
||||
f"⭐ Количество звезд: {stars_amount}\n"
|
||||
f"💰 Сумма пополнения: {rub_amount:.0f}₽\n\n"
|
||||
f"👆 Нажмите кнопку \"Оплатить\" в инвойсе ниже⤵️\n\n"
|
||||
f"Если передумали - нажмите кнопку ниже \n\n❌ Отменить платеж",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="❌ Отменить платеж", callback_data=f"cancel_star_payment_{star_payment.id}")],
|
||||
[InlineKeyboardButton(text="🔙 Назад к выбору", callback_data="topup_stars")]
|
||||
]),
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await callback.message.answer(
|
||||
f"💳 **Оплата через Telegram Stars**\n\n"
|
||||
f"⭐ Количество звезд: {stars_amount}\n"
|
||||
f"💰 Сумма пополнения: {rub_amount:.0f}₽\n\n"
|
||||
f"👆 Нажмите кнопку \"Оплатить\" в инвойсе ниже⤵️",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="❌ Отменить платеж", callback_data=f"cancel_star_payment_{star_payment.id}")],
|
||||
[InlineKeyboardButton(text="🔙 Назад к выбору", callback_data="topup_stars")]
|
||||
]),
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
logger.error(f"Failed to send invoice: {e}")
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка создания платежа. Попробуйте позже.",
|
||||
reply_markup=stars_topup_keyboard(config.STARS_RATES, user.language)
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await callback.message.answer(
|
||||
"❌ Ошибка создания платежа. Попробуйте позже.",
|
||||
reply_markup=stars_topup_keyboard(config.STARS_RATES, user.language)
|
||||
)
|
||||
|
||||
await db.cancel_star_payment(star_payment.id)
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"Error processing stars amount: {e}")
|
||||
await callback.answer("❌ Ошибка обработки")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in buy_stars_callback: {e}")
|
||||
await callback.answer("❌ Произошла ошибка")
|
||||
|
||||
@stars_router.pre_checkout_query()
|
||||
async def pre_checkout_query_handler(pre_checkout_query: PreCheckoutQuery, db: Database, **kwargs):
|
||||
try:
|
||||
if not pre_checkout_query.invoice_payload.startswith("star_payment_"):
|
||||
await pre_checkout_query.answer(ok=False, error_message="Неверный формат платежа")
|
||||
return
|
||||
|
||||
payment_id = int(pre_checkout_query.invoice_payload.split("_")[2])
|
||||
|
||||
star_payment = await db.get_star_payment_by_id(payment_id)
|
||||
|
||||
if not star_payment:
|
||||
await pre_checkout_query.answer(ok=False, error_message="Платеж не найден")
|
||||
return
|
||||
|
||||
if star_payment.status != 'pending':
|
||||
await pre_checkout_query.answer(ok=False, error_message="Платеж уже обработан")
|
||||
return
|
||||
|
||||
if pre_checkout_query.total_amount != star_payment.stars_amount:
|
||||
await pre_checkout_query.answer(ok=False, error_message="Неверная сумма платежа")
|
||||
return
|
||||
|
||||
await pre_checkout_query.answer(ok=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in pre_checkout_query_handler: {e}")
|
||||
await pre_checkout_query.answer(ok=False, error_message="Внутренняя ошибка")
|
||||
|
||||
@stars_router.message(F.successful_payment)
|
||||
async def successful_payment_handler(message: Message, db: Database, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
bot = kwargs.get('bot')
|
||||
|
||||
if not user:
|
||||
logger.error("User not found in successful payment handler")
|
||||
return
|
||||
|
||||
try:
|
||||
payment_info = message.successful_payment
|
||||
|
||||
if not payment_info.invoice_payload.startswith("star_payment_"):
|
||||
logger.error(f"Invalid payment payload: {payment_info.invoice_payload}")
|
||||
return
|
||||
|
||||
payment_id = int(payment_info.invoice_payload.split("_")[2])
|
||||
|
||||
success = await db.complete_star_payment(
|
||||
payment_id=payment_id,
|
||||
telegram_payment_charge_id=payment_info.telegram_payment_charge_id
|
||||
)
|
||||
|
||||
if success:
|
||||
star_payment = await db.get_star_payment_by_id(payment_id)
|
||||
|
||||
if star_payment:
|
||||
updated_user = await db.get_user_by_telegram_id(user.telegram_id)
|
||||
|
||||
success_text = "✅ **Платеж успешно обработан!**\n\n"
|
||||
success_text += f"⭐ Оплачено звезд: {star_payment.stars_amount}\n"
|
||||
success_text += f"💰 Зачислено: {star_payment.rub_amount:.0f}₽\n"
|
||||
success_text += f"💳 Текущий баланс: {updated_user.balance:.0f}₽\n\n"
|
||||
success_text += "🎉 Средства уже доступны на вашем балансе!"
|
||||
|
||||
await message.answer(
|
||||
success_text,
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
|
||||
if bot:
|
||||
try:
|
||||
regular_payment = await db.create_payment(
|
||||
user_id=user.telegram_id,
|
||||
amount=star_payment.rub_amount,
|
||||
payment_type='stars',
|
||||
description=f'Пополнение через Telegram Stars ({star_payment.stars_amount} ⭐)',
|
||||
status='completed'
|
||||
)
|
||||
|
||||
await process_referral_rewards(
|
||||
user.telegram_id,
|
||||
star_payment.rub_amount,
|
||||
regular_payment.id,
|
||||
db,
|
||||
bot,
|
||||
payment_type='stars'
|
||||
)
|
||||
except Exception as ref_error:
|
||||
logger.error(f"Error processing referral rewards for stars payment: {ref_error}")
|
||||
|
||||
log_user_action(user.telegram_id, "stars_payment_completed",
|
||||
f"Stars: {star_payment.stars_amount}, Amount: {star_payment.rub_amount}")
|
||||
else:
|
||||
await message.answer("❌ Ошибка получения данных платежа")
|
||||
else:
|
||||
await message.answer("❌ Ошибка обработки платежа. Обратитесь в поддержку.")
|
||||
logger.error(f"Failed to complete star payment {payment_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in successful_payment_handler: {e}")
|
||||
await message.answer("❌ Произошла ошибка при обработке платежа. Обратитесь в поддержку.")
|
||||
|
||||
def get_stars_rate_info(stars_rates: Dict[int, float], lang: str = 'ru') -> str:
|
||||
if not stars_rates:
|
||||
return "Курсы не настроены"
|
||||
|
||||
text = "⭐ **Курсы Telegram Stars:**\n\n"
|
||||
|
||||
sorted_rates = sorted(stars_rates.items())
|
||||
for stars, rubles in sorted_rates:
|
||||
rate_per_star = rubles / stars
|
||||
text += f"• {stars} ⭐ = {rubles:.0f}₽ (курс: {rate_per_star:.2f}₽ за ⭐)\n"
|
||||
|
||||
return text
|
||||
|
||||
@stars_router.callback_query(F.data.startswith("cancel_star_payment_"))
|
||||
async def cancel_star_payment_callback(callback: CallbackQuery, db: Database, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
config = kwargs.get('config')
|
||||
bot = kwargs.get('bot')
|
||||
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
try:
|
||||
payment_id = int(callback.data.split("_")[-1])
|
||||
|
||||
star_payment = await db.get_star_payment_by_id(payment_id)
|
||||
|
||||
if not star_payment:
|
||||
await callback.answer("❌ Платеж не найден")
|
||||
return
|
||||
|
||||
if star_payment.user_id != user.telegram_id:
|
||||
await callback.answer("❌ Нет доступа к этому платежу")
|
||||
return
|
||||
|
||||
if star_payment.status != 'pending':
|
||||
await callback.answer("❌ Платеж уже обработан")
|
||||
return
|
||||
|
||||
success = await db.cancel_star_payment(payment_id)
|
||||
|
||||
if success:
|
||||
await callback.answer("✅ Платеж отменен", show_alert=True)
|
||||
|
||||
if bot:
|
||||
invoice_deleted = False
|
||||
try:
|
||||
await bot.delete_message(
|
||||
chat_id=callback.message.chat.id,
|
||||
message_id=callback.message.message_id
|
||||
)
|
||||
invoice_deleted = True
|
||||
logger.info(f"Deleted invoice message {callback.message.message_id} for cancelled payment {payment_id}")
|
||||
except TelegramBadRequest as e:
|
||||
logger.warning(f"Failed to delete invoice message: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting invoice message: {e}")
|
||||
|
||||
if invoice_deleted:
|
||||
try:
|
||||
if config and config.STARS_RATES:
|
||||
text = "❌ Платеж отменен\n\n⭐ **Пополнение через Telegram Stars**\n\n"
|
||||
text += "🚀 **Преимущества:**\n"
|
||||
text += "• Мгновенное зачисление\n"
|
||||
text += "• Безопасные платежи через Telegram\n"
|
||||
text += "• Без комиссий и скрытых платежей\n\n"
|
||||
text += "💎 **Доступные варианты:**\n"
|
||||
|
||||
sorted_rates = sorted(config.STARS_RATES.items())
|
||||
for stars, rubles in sorted_rates:
|
||||
if stars >= 500:
|
||||
bonus_text = " 🔥 Выгодно!"
|
||||
elif stars >= 250:
|
||||
bonus_text = " 💎 Хорошо!"
|
||||
else:
|
||||
bonus_text = ""
|
||||
text += f"• {stars} ⭐ → {rubles:.0f}₽{bonus_text}\n"
|
||||
|
||||
text += f"\n💡 Выберите другой вариант:"
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=callback.message.chat.id,
|
||||
text=text,
|
||||
reply_markup=stars_topup_keyboard(config.STARS_RATES, user.language),
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=callback.message.chat.id,
|
||||
text="❌ Платеж отменен\n\n💰 Управление балансом:",
|
||||
reply_markup=balance_keyboard(user.language)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send menu after invoice deletion: {e}")
|
||||
else:
|
||||
try:
|
||||
if config and config.STARS_RATES:
|
||||
await callback.message.edit_text(
|
||||
"❌ Платеж отменен\n\n⭐ **Пополнение через Telegram Stars**\n\nВыберите другой вариант:",
|
||||
reply_markup=stars_topup_keyboard(config.STARS_RATES, user.language),
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
"❌ Платеж отменен",
|
||||
reply_markup=balance_keyboard(user.language)
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
try:
|
||||
if config and config.STARS_RATES:
|
||||
await bot.send_message(
|
||||
chat_id=callback.message.chat.id,
|
||||
text="❌ Платеж отменен\n\n⭐ **Пополнение через Telegram Stars**\n\nВыберите другой вариант:",
|
||||
reply_markup=stars_topup_keyboard(config.STARS_RATES, user.language),
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=callback.message.chat.id,
|
||||
text="❌ Платеж отменен",
|
||||
reply_markup=balance_keyboard(user.language)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send fallback message: {e}")
|
||||
else:
|
||||
try:
|
||||
if config and config.STARS_RATES:
|
||||
await callback.message.edit_text(
|
||||
"❌ Платеж отменен\n\n⭐ **Пополнение через Telegram Stars**\n\nВыберите другой вариант:",
|
||||
reply_markup=stars_topup_keyboard(config.STARS_RATES, user.language),
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
"❌ Платеж отменен",
|
||||
reply_markup=balance_keyboard(user.language)
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
logger.warning("Failed to edit message when bot instance not available")
|
||||
|
||||
log_user_action(user.telegram_id, "stars_payment_cancelled", f"Payment ID: {payment_id}")
|
||||
else:
|
||||
await callback.answer("❌ Ошибка отмены платежа")
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"Error parsing payment ID from callback: {e}")
|
||||
await callback.answer("❌ Ошибка обработки")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in cancel_star_payment_callback: {e}")
|
||||
await callback.answer("❌ Произошла ошибка")
|
||||
@@ -0,0 +1,921 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, List, Dict, Any
|
||||
from database import Database
|
||||
from remnawave_api import RemnaWaveAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SubscriptionMonitorService:
|
||||
|
||||
def __init__(self, bot, db: Database, config, api: RemnaWaveAPI = None):
|
||||
self.bot = bot
|
||||
self.db = db
|
||||
self.config = config
|
||||
self.api = api
|
||||
self.is_running = False
|
||||
self._monitor_task = None
|
||||
self._daily_task = None
|
||||
self.last_check_time = None
|
||||
|
||||
async def start(self):
|
||||
if self.is_running:
|
||||
logger.warning("Monitor service is already running")
|
||||
return
|
||||
|
||||
if not getattr(self.config, 'MONITOR_ENABLED', True):
|
||||
logger.info("Subscription monitoring disabled in config")
|
||||
return
|
||||
|
||||
logger.info("Starting subscription monitor service...")
|
||||
|
||||
try:
|
||||
self.is_running = True
|
||||
|
||||
self._monitor_task = asyncio.create_task(self._monitor_loop())
|
||||
logger.info(f"Started monitoring loop with interval {self.config.MONITOR_CHECK_INTERVAL}s")
|
||||
|
||||
self._daily_task = asyncio.create_task(self._daily_loop())
|
||||
logger.info(f"Started daily cleanup task for {self.config.MONITOR_DAILY_CHECK_HOUR}:00")
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
monitor_running = self._monitor_task and not self._monitor_task.done()
|
||||
daily_running = self._daily_task and not self._daily_task.done()
|
||||
|
||||
if monitor_running and daily_running:
|
||||
logger.info("✅ Subscription monitor service started successfully")
|
||||
else:
|
||||
logger.warning(f"⚠️ Monitor service partially started: monitor={monitor_running}, daily={daily_running}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to start monitor service: {e}", exc_info=True)
|
||||
self.is_running = False
|
||||
|
||||
if self._monitor_task:
|
||||
self._monitor_task.cancel()
|
||||
if self._daily_task:
|
||||
self._daily_task.cancel()
|
||||
|
||||
raise
|
||||
|
||||
async def stop(self):
|
||||
if not self.is_running:
|
||||
return
|
||||
|
||||
logger.info("Stopping subscription monitor service...")
|
||||
self.is_running = False
|
||||
|
||||
if self._monitor_task:
|
||||
self._monitor_task.cancel()
|
||||
try:
|
||||
await self._monitor_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("Monitor loop stopped")
|
||||
|
||||
if self._daily_task:
|
||||
self._daily_task.cancel()
|
||||
try:
|
||||
await self._daily_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("Daily task stopped")
|
||||
|
||||
logger.info("Subscription monitor service stopped")
|
||||
|
||||
async def _monitor_loop(self):
|
||||
logger.info("🔥 Starting monitor loop")
|
||||
|
||||
logger.info("⏰ Initial check in 10 seconds...")
|
||||
await asyncio.sleep(10)
|
||||
|
||||
while self.is_running:
|
||||
try:
|
||||
logger.info("🔍 Running periodic subscription check...")
|
||||
warnings_sent = await self._check_expiring_subscriptions()
|
||||
|
||||
trial_notifications = await self._check_expired_trial_subscriptions()
|
||||
|
||||
if warnings_sent > 0 or trial_notifications > 0:
|
||||
logger.info(f"✅ Monitor check completed: {warnings_sent} warnings sent, {trial_notifications} trial notifications sent")
|
||||
else:
|
||||
logger.info("✅ Monitor check completed: no warnings needed")
|
||||
|
||||
self.last_check_time = datetime.now()
|
||||
|
||||
logger.debug(f"😴 Sleeping for {self.config.MONITOR_CHECK_INTERVAL} seconds...")
|
||||
await asyncio.sleep(self.config.MONITOR_CHECK_INTERVAL)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Monitor loop cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in monitor loop: {e}", exc_info=True)
|
||||
error_sleep = min(300, self.config.MONITOR_CHECK_INTERVAL // 2)
|
||||
logger.info(f"😴 Error recovery: sleeping for {error_sleep} seconds...")
|
||||
await asyncio.sleep(error_sleep)
|
||||
|
||||
async def _daily_loop(self):
|
||||
logger.info("📅 Starting daily loop")
|
||||
|
||||
while self.is_running:
|
||||
try:
|
||||
now = datetime.now()
|
||||
target_hour = self.config.MONITOR_DAILY_CHECK_HOUR
|
||||
|
||||
if now.hour == target_hour and now.minute < 30:
|
||||
logger.info("📅 Starting scheduled daily check...")
|
||||
deactivated = await self.force_daily_check()
|
||||
logger.info(f"📅 Daily check completed: {deactivated} subscriptions deactivated")
|
||||
|
||||
next_check = now.replace(hour=target_hour, minute=0, second=0, microsecond=0) + timedelta(days=1)
|
||||
sleep_time = (next_check - now).total_seconds()
|
||||
logger.info(f"😴 Daily check completed, next check in {sleep_time/3600:.1f} hours")
|
||||
else:
|
||||
target_time = now.replace(hour=target_hour, minute=0, second=0, microsecond=0)
|
||||
if target_time <= now:
|
||||
target_time += timedelta(days=1)
|
||||
|
||||
sleep_time = (target_time - now).total_seconds()
|
||||
logger.debug(f"😴 Next daily check in {sleep_time/3600:.1f} hours")
|
||||
|
||||
sleep_duration = min(sleep_time, 3600)
|
||||
await asyncio.sleep(sleep_duration)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Daily loop cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in daily loop: {e}", exc_info=True)
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
async def _check_expired_trial_subscriptions(self) -> int:
|
||||
try:
|
||||
logger.info("🆓 Checking for expired trial subscriptions...")
|
||||
|
||||
notifications_sent = 0
|
||||
now_utc = datetime.utcnow()
|
||||
|
||||
all_users = await self.db.get_all_users()
|
||||
|
||||
for user in all_users:
|
||||
try:
|
||||
user_subs = await self.db.get_user_subscriptions(user.telegram_id)
|
||||
|
||||
for user_sub in user_subs:
|
||||
try:
|
||||
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
|
||||
if not subscription:
|
||||
continue
|
||||
|
||||
if not subscription.is_trial:
|
||||
continue
|
||||
|
||||
expires_at_utc = user_sub.expires_at
|
||||
if expires_at_utc.tzinfo is None:
|
||||
expires_at_utc = expires_at_utc.replace(tzinfo=None)
|
||||
else:
|
||||
expires_at_utc = expires_at_utc.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
time_diff = expires_at_utc - now_utc
|
||||
hours_since_expiry = -time_diff.total_seconds() / 3600
|
||||
|
||||
if 1 <= hours_since_expiry <= 24 and user_sub.is_active:
|
||||
logger.info(f"🆓 Sending trial expiry notification to user {user.telegram_id}: "
|
||||
f"trial '{subscription.name}' expired {hours_since_expiry:.1f} hours ago")
|
||||
|
||||
try:
|
||||
await self._send_trial_expiry_notification(user, subscription)
|
||||
notifications_sent += 1
|
||||
logger.info(f"✅ Trial expiry notification sent to user {user.telegram_id}")
|
||||
except Exception as notification_error:
|
||||
logger.error(f"❌ Failed to send trial notification to user {user.telegram_id}: {notification_error}")
|
||||
|
||||
except Exception as sub_error:
|
||||
logger.error(f"❌ Error checking trial subscription {user_sub.id}: {sub_error}")
|
||||
|
||||
except Exception as user_error:
|
||||
logger.error(f"❌ Error checking trial subscriptions for user {user.telegram_id}: {user_error}")
|
||||
|
||||
if notifications_sent > 0:
|
||||
logger.info(f"🆓 Trial expiry check completed: {notifications_sent} notifications sent")
|
||||
|
||||
return notifications_sent
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Critical error in check_expired_trial_subscriptions: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
async def _send_trial_expiry_notification(self, user, subscription):
|
||||
try:
|
||||
if not self.bot:
|
||||
logger.error("❌ Bot instance is None, cannot send trial notification")
|
||||
return
|
||||
|
||||
from translations import t
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
message = t('trial_subscription_expired', user.language, name=subscription.name)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text=t('buy_subscription_btn', user.language),
|
||||
callback_data="buy_subscription"
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text=t('my_subscriptions_btn', user.language),
|
||||
callback_data="my_subscriptions"
|
||||
)]
|
||||
])
|
||||
|
||||
await self.bot.send_message(user.telegram_id, message, reply_markup=keyboard)
|
||||
|
||||
logger.info(f"✅ Trial expiry notification sent to user {user.telegram_id} for subscription '{subscription.name}'")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error sending trial expiry notification to user {user.telegram_id}: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def delete_expired_trial_subscriptions(self, force: bool = False) -> Dict[str, Any]:
|
||||
try:
|
||||
logger.info("🗑️ Starting deletion of expired trial subscriptions...")
|
||||
|
||||
now_utc = datetime.utcnow()
|
||||
delete_threshold_days = getattr(self.config, 'DELETE_EXPIRED_TRIAL_DAYS', 1)
|
||||
cutoff_date = now_utc - timedelta(days=delete_threshold_days)
|
||||
|
||||
logger.info(f"🗑️ Deleting trial subscriptions expired before: {cutoff_date} (older than {delete_threshold_days} days)")
|
||||
|
||||
results = {
|
||||
'total_checked': 0,
|
||||
'deleted_from_db': 0,
|
||||
'deleted_from_api': 0,
|
||||
'errors': [],
|
||||
'deleted_subscriptions': []
|
||||
}
|
||||
|
||||
all_users = await self.db.get_all_users()
|
||||
|
||||
for user in all_users:
|
||||
try:
|
||||
user_subs = await self.db.get_user_subscriptions(user.telegram_id)
|
||||
|
||||
for user_sub in user_subs:
|
||||
results['total_checked'] += 1
|
||||
|
||||
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
|
||||
if not subscription or not subscription.is_trial:
|
||||
continue
|
||||
|
||||
expires_at_utc = user_sub.expires_at
|
||||
if expires_at_utc.tzinfo is None:
|
||||
expires_at_utc = expires_at_utc.replace(tzinfo=None)
|
||||
else:
|
||||
expires_at_utc = expires_at_utc.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
if expires_at_utc > cutoff_date and not force:
|
||||
continue
|
||||
|
||||
logger.info(f"🗑️ Deleting expired trial subscription '{subscription.name}' for user {user.telegram_id} "
|
||||
f"(expired: {expires_at_utc}, cutoff: {cutoff_date})")
|
||||
|
||||
api_deleted = False
|
||||
if self.api and user_sub.short_uuid:
|
||||
try:
|
||||
api_result = await self.api.delete_user_by_short_uuid(user_sub.short_uuid)
|
||||
if api_result and api_result.get('success'):
|
||||
api_deleted = True
|
||||
results['deleted_from_api'] += 1
|
||||
logger.info(f"✅ Deleted from RemnaWave API: {user_sub.short_uuid}")
|
||||
else:
|
||||
results['errors'].append(f"Failed to delete {user_sub.short_uuid} from API")
|
||||
logger.warning(f"⚠️ Failed to delete {user_sub.short_uuid} from API")
|
||||
except Exception as api_error:
|
||||
results['errors'].append(f"API error for {user_sub.short_uuid}: {str(api_error)}")
|
||||
logger.error(f"❌ API error deleting {user_sub.short_uuid}: {api_error}")
|
||||
|
||||
db_deleted = await self.db.delete_user_subscription(user_sub.id)
|
||||
if db_deleted:
|
||||
results['deleted_from_db'] += 1
|
||||
results['deleted_subscriptions'].append({
|
||||
'user_id': user.telegram_id,
|
||||
'subscription_name': subscription.name,
|
||||
'short_uuid': user_sub.short_uuid,
|
||||
'expired_at': expires_at_utc.isoformat(),
|
||||
'deleted_from_api': api_deleted,
|
||||
'deleted_from_db': True
|
||||
})
|
||||
logger.info(f"✅ Deleted from database: subscription ID {user_sub.id}")
|
||||
else:
|
||||
results['errors'].append(f"Failed to delete subscription ID {user_sub.id} from database")
|
||||
logger.error(f"❌ Failed to delete subscription ID {user_sub.id} from database")
|
||||
|
||||
except Exception as user_error:
|
||||
results['errors'].append(f"Error processing user {user.telegram_id}: {str(user_error)}")
|
||||
logger.error(f"❌ Error processing user {user.telegram_id}: {user_error}")
|
||||
|
||||
logger.info(f"🗑️ Trial deletion completed: {results['deleted_from_db']} from DB, {results['deleted_from_api']} from API")
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in delete_expired_trial_subscriptions: {e}", exc_info=True)
|
||||
return {
|
||||
'total_checked': 0,
|
||||
'deleted_from_db': 0,
|
||||
'deleted_from_api': 0,
|
||||
'errors': [f"Critical error: {str(e)}"],
|
||||
'deleted_subscriptions': []
|
||||
}
|
||||
|
||||
async def delete_expired_regular_subscriptions(self, force: bool = False) -> Dict[str, Any]:
|
||||
try:
|
||||
logger.info("🗑️ Starting deletion of expired regular subscriptions...")
|
||||
|
||||
now_utc = datetime.utcnow()
|
||||
delete_threshold_days = getattr(self.config, 'DELETE_EXPIRED_REGULAR_DAYS', 7)
|
||||
cutoff_date = now_utc - timedelta(days=delete_threshold_days)
|
||||
|
||||
logger.info(f"🗑️ Deleting regular subscriptions expired before: {cutoff_date} (older than {delete_threshold_days} days)")
|
||||
|
||||
results = {
|
||||
'total_checked': 0,
|
||||
'deleted_from_db': 0,
|
||||
'deleted_from_api': 0,
|
||||
'errors': [],
|
||||
'deleted_subscriptions': []
|
||||
}
|
||||
|
||||
all_users = await self.db.get_all_users()
|
||||
|
||||
for user in all_users:
|
||||
try:
|
||||
user_subs = await self.db.get_user_subscriptions(user.telegram_id)
|
||||
|
||||
for user_sub in user_subs:
|
||||
results['total_checked'] += 1
|
||||
|
||||
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
|
||||
if not subscription or subscription.is_trial:
|
||||
continue
|
||||
|
||||
if getattr(subscription, 'is_imported', False) or subscription.name == "Старая подписка":
|
||||
continue
|
||||
|
||||
expires_at_utc = user_sub.expires_at
|
||||
if expires_at_utc.tzinfo is None:
|
||||
expires_at_utc = expires_at_utc.replace(tzinfo=None)
|
||||
else:
|
||||
expires_at_utc = expires_at_utc.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
if expires_at_utc > cutoff_date and not force:
|
||||
continue
|
||||
|
||||
logger.info(f"🗑️ Deleting expired regular subscription '{subscription.name}' for user {user.telegram_id} "
|
||||
f"(expired: {expires_at_utc}, cutoff: {cutoff_date})")
|
||||
|
||||
api_deleted = False
|
||||
if self.api and user_sub.short_uuid:
|
||||
try:
|
||||
api_result = await self.api.delete_user_by_short_uuid(user_sub.short_uuid)
|
||||
if api_result and api_result.get('success'):
|
||||
api_deleted = True
|
||||
results['deleted_from_api'] += 1
|
||||
logger.info(f"✅ Deleted from RemnaWave API: {user_sub.short_uuid}")
|
||||
else:
|
||||
results['errors'].append(f"Failed to delete {user_sub.short_uuid} from API")
|
||||
logger.warning(f"⚠️ Failed to delete {user_sub.short_uuid} from API")
|
||||
except Exception as api_error:
|
||||
results['errors'].append(f"API error for {user_sub.short_uuid}: {str(api_error)}")
|
||||
logger.error(f"❌ API error deleting {user_sub.short_uuid}: {api_error}")
|
||||
|
||||
db_deleted = await self.db.delete_user_subscription(user_sub.id)
|
||||
if db_deleted:
|
||||
results['deleted_from_db'] += 1
|
||||
results['deleted_subscriptions'].append({
|
||||
'user_id': user.telegram_id,
|
||||
'subscription_name': subscription.name,
|
||||
'short_uuid': user_sub.short_uuid,
|
||||
'expired_at': expires_at_utc.isoformat(),
|
||||
'deleted_from_api': api_deleted,
|
||||
'deleted_from_db': True
|
||||
})
|
||||
logger.info(f"✅ Deleted from database: subscription ID {user_sub.id}")
|
||||
else:
|
||||
results['errors'].append(f"Failed to delete subscription ID {user_sub.id} from database")
|
||||
logger.error(f"❌ Failed to delete subscription ID {user_sub.id} from database")
|
||||
|
||||
except Exception as user_error:
|
||||
results['errors'].append(f"Error processing user {user.telegram_id}: {str(user_error)}")
|
||||
logger.error(f"❌ Error processing user {user.telegram_id}: {user_error}")
|
||||
|
||||
logger.info(f"🗑️ Regular deletion completed: {results['deleted_from_db']} from DB, {results['deleted_from_api']} from API")
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in delete_expired_regular_subscriptions: {e}", exc_info=True)
|
||||
return {
|
||||
'total_checked': 0,
|
||||
'deleted_from_db': 0,
|
||||
'deleted_from_api': 0,
|
||||
'errors': [f"Critical error: {str(e)}"],
|
||||
'deleted_subscriptions': []
|
||||
}
|
||||
|
||||
async def _check_expiring_subscriptions(self):
|
||||
try:
|
||||
logger.info("🔍 Checking for expiring subscriptions...")
|
||||
|
||||
all_users = await self.db.get_all_users()
|
||||
logger.info(f"👥 Found {len(all_users)} total users in database")
|
||||
|
||||
if not all_users:
|
||||
logger.warning("⚠️ No users found in database")
|
||||
return 0
|
||||
|
||||
warnings_sent = 0
|
||||
errors_count = 0
|
||||
total_subscriptions = 0
|
||||
|
||||
now_utc = datetime.utcnow()
|
||||
logger.info(f"🕐 Current UTC time: {now_utc}")
|
||||
|
||||
for user in all_users:
|
||||
try:
|
||||
user_subs = await self.db.get_user_subscriptions(user.telegram_id)
|
||||
total_subscriptions += len(user_subs)
|
||||
|
||||
if not user_subs:
|
||||
logger.debug(f"👤 User {user.telegram_id} has no subscriptions")
|
||||
continue
|
||||
|
||||
logger.debug(f"👤 User {user.telegram_id}: checking {len(user_subs)} subscriptions")
|
||||
|
||||
for user_sub in user_subs:
|
||||
try:
|
||||
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
|
||||
if not subscription:
|
||||
logger.warning(f"⚠️ Subscription plan {user_sub.subscription_id} not found")
|
||||
continue
|
||||
|
||||
expires_at_utc = user_sub.expires_at
|
||||
if expires_at_utc.tzinfo is None:
|
||||
expires_at_utc = expires_at_utc.replace(tzinfo=None)
|
||||
else:
|
||||
expires_at_utc = expires_at_utc.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
time_diff = expires_at_utc - now_utc
|
||||
hours_left = time_diff.total_seconds() / 3600
|
||||
days_left = int(hours_left / 24)
|
||||
|
||||
logger.debug(f"📋 Subscription '{subscription.name}': "
|
||||
f"expires_at={expires_at_utc}, "
|
||||
f"now_utc={now_utc}, "
|
||||
f"hours_left={hours_left:.1f}, "
|
||||
f"days_left={days_left}, "
|
||||
f"threshold={self.config.MONITOR_WARNING_DAYS}")
|
||||
|
||||
if subscription.is_trial:
|
||||
logger.debug(f"⭐️ Skipping trial subscription '{subscription.name}'")
|
||||
continue
|
||||
|
||||
if getattr(subscription, 'is_imported', False) or subscription.name == "Старая подписка":
|
||||
logger.debug(f"⭐️ Skipping imported subscription '{subscription.name}'")
|
||||
continue
|
||||
|
||||
should_warn = (
|
||||
user_sub.is_active and
|
||||
days_left <= self.config.MONITOR_WARNING_DAYS and
|
||||
hours_left > 0
|
||||
)
|
||||
|
||||
if should_warn:
|
||||
logger.info(f"⚠️ Sending warning to user {user.telegram_id}: "
|
||||
f"subscription '{subscription.name}' expires in {days_left} days "
|
||||
f"({hours_left:.1f} hours)")
|
||||
|
||||
try:
|
||||
await self._send_expiry_warning(user, user_sub)
|
||||
warnings_sent += 1
|
||||
logger.info(f"✅ Warning sent successfully to user {user.telegram_id}")
|
||||
except Exception as warning_error:
|
||||
logger.error(f"❌ Failed to send warning to user {user.telegram_id}: {warning_error}")
|
||||
errors_count += 1
|
||||
elif hours_left <= 0:
|
||||
logger.debug(f"❌ Subscription '{subscription.name}' already expired {abs(hours_left):.1f} hours ago")
|
||||
else:
|
||||
logger.debug(f"✅ Subscription '{subscription.name}' is OK (expires in {days_left} days)")
|
||||
|
||||
except Exception as sub_error:
|
||||
logger.error(f"❌ Error checking subscription {user_sub.id}: {sub_error}")
|
||||
errors_count += 1
|
||||
|
||||
except Exception as user_error:
|
||||
logger.error(f"❌ Error checking subscriptions for user {user.telegram_id}: {user_error}")
|
||||
errors_count += 1
|
||||
|
||||
logger.info(f"📊 Check completed: {total_subscriptions} subscriptions checked, {warnings_sent} warnings sent, {errors_count} errors")
|
||||
return warnings_sent
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Critical error in check_expiring_subscriptions: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
async def _send_expiry_warning(self, user, user_subscription):
|
||||
try:
|
||||
if not self.bot:
|
||||
logger.error("❌ Bot instance is None, cannot send warning")
|
||||
return
|
||||
|
||||
now_utc = datetime.utcnow()
|
||||
expires_at_utc = user_subscription.expires_at
|
||||
if expires_at_utc.tzinfo is None:
|
||||
expires_at_utc = expires_at_utc.replace(tzinfo=None)
|
||||
else:
|
||||
expires_at_utc = expires_at_utc.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
time_diff = expires_at_utc - now_utc
|
||||
hours_left = time_diff.total_seconds() / 3600
|
||||
days_left = int(hours_left / 24)
|
||||
|
||||
subscription = await self.db.get_subscription_by_id(user_subscription.subscription_id)
|
||||
if not subscription:
|
||||
logger.warning(f"Subscription {user_subscription.subscription_id} not found")
|
||||
return
|
||||
|
||||
message = self._format_expiry_message_with_action(subscription.name, days_left, user.language)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from translations import t
|
||||
|
||||
keyboard = None
|
||||
if hours_left > 0:
|
||||
is_imported = (getattr(subscription, 'is_imported', False) or
|
||||
subscription.name == "Старая подписка" or
|
||||
(subscription.description and 'импорт' in subscription.description.lower()))
|
||||
|
||||
if not is_imported:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=t('extend_subscription_btn', user.language), callback_data=f"extend_sub_{user_subscription.id}")],
|
||||
[InlineKeyboardButton(text=t('my_subscriptions_btn', user.language), callback_data="my_subscriptions")]
|
||||
])
|
||||
else:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=t('my_subscriptions_btn', user.language), callback_data="my_subscriptions")],
|
||||
[InlineKeyboardButton(text=t('buy_new_subscription_btn', user.language), callback_data="buy_subscription")]
|
||||
])
|
||||
else:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=t('restore_subscription_btn', user.language), callback_data=f"extend_sub_{user_subscription.id}")],
|
||||
[InlineKeyboardButton(text=t('my_subscriptions_btn', user.language), callback_data="my_subscriptions")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(user.telegram_id, message, reply_markup=keyboard)
|
||||
|
||||
if days_left == 0:
|
||||
logger.info(f"✅ URGENT expiry warning sent to user {user.telegram_id} for subscription '{subscription.name}' (expires today, {hours_left:.1f} hours left)")
|
||||
elif days_left > 0:
|
||||
logger.info(f"✅ Expiry warning sent to user {user.telegram_id} for subscription '{subscription.name}' (expires in {days_left} days)")
|
||||
else:
|
||||
logger.info(f"✅ Expiry notification sent to user {user.telegram_id} for subscription '{subscription.name}' (already expired)")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error sending expiry warning to user {user.telegram_id}: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def _format_expiry_message_with_action(self, subscription_name: str, days_left: int, language: str = 'ru') -> str:
|
||||
from translations import t
|
||||
|
||||
if days_left < 0:
|
||||
return t('subscription_expired_message', language, name=subscription_name)
|
||||
elif days_left == 0:
|
||||
return t('subscription_expires_today', language, name=subscription_name)
|
||||
elif days_left == 1:
|
||||
return t('subscription_expires_tomorrow', language, name=subscription_name)
|
||||
elif days_left == 2:
|
||||
return t('subscription_expires_day_after_tomorrow', language, name=subscription_name, days=days_left)
|
||||
else:
|
||||
return t('subscription_expires_in_days', language, name=subscription_name, days=days_left)
|
||||
|
||||
async def force_daily_check(self):
|
||||
try:
|
||||
logger.info("🚀 Starting forced daily check and cleanup")
|
||||
|
||||
logger.info("📢 Sending expiry warnings...")
|
||||
warnings_sent = await self._check_expiring_subscriptions()
|
||||
logger.info(f"📢 Sent {warnings_sent} expiry warnings")
|
||||
|
||||
logger.info("🆓 Checking expired trial subscriptions...")
|
||||
trial_notifications = await self._check_expired_trial_subscriptions()
|
||||
logger.info(f"🆓 Sent {trial_notifications} trial expiry notifications")
|
||||
|
||||
logger.info("🔥 Deactivating expired subscriptions...")
|
||||
deactivated_count = await self.deactivate_expired_subscriptions()
|
||||
logger.info(f"🔥 Deactivated {deactivated_count} expired subscriptions")
|
||||
|
||||
deleted_trials = 0
|
||||
deleted_regular = 0
|
||||
if getattr(self.config, 'AUTO_DELETE_ENABLED', False):
|
||||
logger.info("🗑️ Auto-deletion enabled, deleting expired subscriptions...")
|
||||
|
||||
trial_result = await self.delete_expired_trial_subscriptions()
|
||||
deleted_trials = trial_result.get('deleted_from_db', 0)
|
||||
|
||||
regular_result = await self.delete_expired_regular_subscriptions()
|
||||
deleted_regular = regular_result.get('deleted_from_db', 0)
|
||||
|
||||
logger.info(f"🗑️ Auto-deleted {deleted_trials} trial and {deleted_regular} regular subscriptions")
|
||||
|
||||
logger.info("📩 Sending final expiry notifications...")
|
||||
await self._send_final_expiry_notifications()
|
||||
logger.info("📩 Final notifications sent")
|
||||
|
||||
logger.info(f"✅ Daily check completed successfully. Warnings: {warnings_sent}, Trial notifications: {trial_notifications}, Deactivated: {deactivated_count}, "
|
||||
f"Deleted trials: {deleted_trials}, Deleted regular: {deleted_regular}")
|
||||
return deactivated_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in force_daily_check: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
async def deactivate_expired_subscriptions(self) -> int:
|
||||
try:
|
||||
count = 0
|
||||
now_utc = datetime.utcnow()
|
||||
logger.info(f"🔍 Checking for expired subscriptions (current UTC: {now_utc})")
|
||||
|
||||
all_users = await self.db.get_all_users()
|
||||
|
||||
logger.info(f"🔍 Checking {len(all_users)} users for expired subscriptions")
|
||||
|
||||
for user in all_users:
|
||||
try:
|
||||
user_subs = await self.db.get_user_subscriptions(user.telegram_id)
|
||||
|
||||
for user_sub in user_subs:
|
||||
if user_sub.is_active:
|
||||
expires_at_utc = user_sub.expires_at
|
||||
if expires_at_utc.tzinfo is None:
|
||||
expires_at_utc = expires_at_utc.replace(tzinfo=None)
|
||||
else:
|
||||
expires_at_utc = expires_at_utc.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
time_diff = expires_at_utc - now_utc
|
||||
hours_until_expiry = time_diff.total_seconds() / 3600
|
||||
|
||||
if hours_until_expiry < -1.0:
|
||||
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
|
||||
subscription_name = subscription.name if subscription else f"ID:{user_sub.subscription_id}"
|
||||
|
||||
logger.info(f"❌ Deactivating truly expired subscription '{subscription_name}' "
|
||||
f"for user {user.telegram_id} (expired {abs(hours_until_expiry):.1f} hours ago)")
|
||||
|
||||
user_sub.is_active = False
|
||||
success = await self.db.update_user_subscription(user_sub)
|
||||
|
||||
if success:
|
||||
count += 1
|
||||
logger.info(f"✅ Successfully deactivated subscription '{subscription_name}' for user {user.telegram_id}")
|
||||
|
||||
if self.api and user_sub.short_uuid:
|
||||
try:
|
||||
user_data = await self.api.get_user_by_short_uuid(user_sub.short_uuid)
|
||||
if user_data and user_data.get('uuid'):
|
||||
await self.api.update_user(user_data['uuid'], {'status': 'EXPIRED'})
|
||||
logger.debug(f"🔥 Also deactivated user {user_data['uuid']} in RemnaWave")
|
||||
except Exception as api_error:
|
||||
logger.warning(f"⚠️ Could not deactivate user in RemnaWave: {api_error}")
|
||||
else:
|
||||
logger.error(f"❌ Failed to deactivate subscription {user_sub.id} in database")
|
||||
else:
|
||||
logger.debug(f"✅ Subscription expires in {hours_until_expiry:.1f} hours - not deactivating yet")
|
||||
except Exception as user_error:
|
||||
logger.error(f"❌ Error processing user {user.telegram_id}: {user_error}")
|
||||
|
||||
logger.info(f"✅ Deactivation completed: {count} subscriptions deactivated")
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error deactivating expired subscriptions: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
async def _send_final_expiry_notifications(self):
|
||||
try:
|
||||
all_users = await self.db.get_all_users()
|
||||
notifications_sent = 0
|
||||
now_utc = datetime.utcnow()
|
||||
|
||||
logger.info(f"📩 Checking for subscriptions that expired recently (current UTC: {now_utc})")
|
||||
|
||||
for user in all_users:
|
||||
try:
|
||||
user_subs = await self.db.get_user_subscriptions(user.telegram_id)
|
||||
|
||||
for user_sub in user_subs:
|
||||
expires_at_utc = user_sub.expires_at
|
||||
if expires_at_utc.tzinfo is None:
|
||||
expires_at_utc = expires_at_utc.replace(tzinfo=None)
|
||||
else:
|
||||
expires_at_utc = expires_at_utc.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
time_since_expiry = now_utc - expires_at_utc
|
||||
hours_since_expiry = time_since_expiry.total_seconds() / 3600
|
||||
|
||||
if 2 <= hours_since_expiry <= 24:
|
||||
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
|
||||
if subscription and not subscription.is_trial and not getattr(subscription, 'is_imported', False):
|
||||
message = self._format_expiry_message_with_action(subscription.name, 0, user.language)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Восстановить подписку", callback_data=f"extend_sub_{user_sub.id}")],
|
||||
[InlineKeyboardButton(text="📋 Мои подписки", callback_data="my_subscriptions")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(user.telegram_id, message, reply_markup=keyboard)
|
||||
notifications_sent += 1
|
||||
logger.info(f"📩 Sent final expiry notification for subscription '{subscription.name}' to user {user.telegram_id} (expired {hours_since_expiry:.1f} hours ago)")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error sending final notification to user {user.telegram_id}: {e}")
|
||||
|
||||
if notifications_sent > 0:
|
||||
logger.info(f"📩 Sent {notifications_sent} final expiry notifications")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error sending final expiry notifications: {e}", exc_info=True)
|
||||
|
||||
async def check_single_user(self, user_id: int):
|
||||
try:
|
||||
results = []
|
||||
|
||||
logger.info(f"🧪 Testing monitor for user {user_id}")
|
||||
|
||||
user = await self.db.get_user_by_telegram_id(user_id)
|
||||
if not user:
|
||||
results.append({
|
||||
'success': False,
|
||||
'message': f'User {user_id} not found in database',
|
||||
'error': None
|
||||
})
|
||||
return results
|
||||
|
||||
user_subs = await self.db.get_user_subscriptions(user_id)
|
||||
|
||||
if not user_subs:
|
||||
results.append({
|
||||
'success': True,
|
||||
'message': f'User {user_id} has no subscriptions',
|
||||
'error': None
|
||||
})
|
||||
return results
|
||||
|
||||
logger.info(f"🧪 Found {len(user_subs)} subscriptions for user {user_id}")
|
||||
|
||||
now_utc = datetime.utcnow()
|
||||
|
||||
for user_sub in user_subs:
|
||||
try:
|
||||
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
|
||||
|
||||
if not subscription:
|
||||
results.append({
|
||||
'success': False,
|
||||
'message': f'Subscription plan {user_sub.subscription_id} not found',
|
||||
'error': None
|
||||
})
|
||||
continue
|
||||
|
||||
expires_at_utc = user_sub.expires_at
|
||||
if expires_at_utc.tzinfo is None:
|
||||
expires_at_utc = expires_at_utc.replace(tzinfo=None)
|
||||
else:
|
||||
expires_at_utc = expires_at_utc.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
time_diff = expires_at_utc - now_utc
|
||||
hours_left = time_diff.total_seconds() / 3600
|
||||
days_left = int(hours_left / 24)
|
||||
|
||||
if subscription.is_trial:
|
||||
hours_since_expiry = -hours_left
|
||||
if 1 <= hours_since_expiry <= 24 and user_sub.is_active:
|
||||
test_message = f"🧪 [ТЕСТОВОЕ УВЕДОМЛЕНИЕ]\n\n🆓 Ваша триальная подписка '{subscription.name}' истекла! Купите новый тариф чтобы продолжить использование VPN."
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
await self.bot.send_message(user_id, test_message)
|
||||
results.append({
|
||||
'success': True,
|
||||
'message': f'✅ Sent test trial expiry notification for "{subscription.name}" (expired {hours_since_expiry:.1f} hours ago)',
|
||||
'error': None
|
||||
})
|
||||
except Exception as send_error:
|
||||
results.append({
|
||||
'success': False,
|
||||
'message': f'❌ Failed to send test trial notification for "{subscription.name}"',
|
||||
'error': str(send_error)
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
'success': True,
|
||||
'message': f'Trial subscription "{subscription.name}" - no notification needed (expired {hours_since_expiry:.1f} hours ago)',
|
||||
'error': None
|
||||
})
|
||||
continue
|
||||
|
||||
if getattr(subscription, 'is_imported', False) or subscription.name == "Старая подписка":
|
||||
results.append({
|
||||
'success': True,
|
||||
'message': f'Imported subscription "{subscription.name}" skipped (cannot be renewed)',
|
||||
'error': None
|
||||
})
|
||||
continue
|
||||
|
||||
if days_left <= self.config.MONITOR_WARNING_DAYS and hours_left > 0:
|
||||
message = self._format_expiry_message_with_action(subscription.name, days_left, user.language)
|
||||
test_message = f"🧪 [ТЕСТОВОЕ УВЕДОМЛЕНИЕ]\n\n{message}"
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
await self.bot.send_message(user_id, test_message)
|
||||
logger.info(f"✅ Test warning sent to user {user_id}")
|
||||
|
||||
results.append({
|
||||
'success': True,
|
||||
'message': f'✅ Sent test warning for subscription "{subscription.name}" (expires in {days_left} days / {hours_left:.1f} hours)',
|
||||
'error': None
|
||||
})
|
||||
except Exception as send_error:
|
||||
logger.error(f"❌ Failed to send test message: {send_error}")
|
||||
results.append({
|
||||
'success': False,
|
||||
'message': f'❌ Failed to send test warning for "{subscription.name}"',
|
||||
'error': str(send_error)
|
||||
})
|
||||
else:
|
||||
logger.error("❌ Bot instance is None")
|
||||
results.append({
|
||||
'success': False,
|
||||
'message': f'❌ Bot instance unavailable for "{subscription.name}"',
|
||||
'error': 'Bot is None'
|
||||
})
|
||||
elif hours_left <= 0:
|
||||
results.append({
|
||||
'success': True,
|
||||
'message': f'❌ Subscription "{subscription.name}" already expired {abs(hours_left):.1f} hours ago',
|
||||
'error': None
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
'success': True,
|
||||
'message': f'✅ Subscription "{subscription.name}" is OK (expires in {days_left} days / {hours_left:.1f} hours, warning threshold: {self.config.MONITOR_WARNING_DAYS} days)',
|
||||
'error': None
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error checking subscription {user_sub.id}: {e}")
|
||||
results.append({
|
||||
'success': False,
|
||||
'message': f'Error checking subscription ID {user_sub.id}',
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in check_single_user: {e}", exc_info=True)
|
||||
return [{
|
||||
'success': False,
|
||||
'message': f'Critical error checking user {user_id}',
|
||||
'error': str(e)
|
||||
}]
|
||||
|
||||
async def get_service_status(self) -> dict:
|
||||
monitor_running = self._monitor_task is not None and not self._monitor_task.done()
|
||||
daily_running = self._daily_task is not None and not self._daily_task.done()
|
||||
|
||||
return {
|
||||
'is_running': self.is_running and (monitor_running or daily_running),
|
||||
'monitor_enabled': getattr(self.config, 'MONITOR_ENABLED', True),
|
||||
'check_interval': self.config.MONITOR_CHECK_INTERVAL,
|
||||
'daily_check_hour': self.config.MONITOR_DAILY_CHECK_HOUR,
|
||||
'warning_days': self.config.MONITOR_WARNING_DAYS,
|
||||
'delete_trial_days': getattr(self.config, 'DELETE_EXPIRED_TRIAL_DAYS', 1),
|
||||
'delete_regular_days': getattr(self.config, 'DELETE_EXPIRED_REGULAR_DAYS', 7),
|
||||
'auto_delete_enabled': getattr(self.config, 'AUTO_DELETE_ENABLED', False),
|
||||
'last_check': self.last_check_time.strftime("%Y-%m-%d %H:%M:%S") if self.last_check_time else None,
|
||||
'has_tasks': {
|
||||
'monitor_task': monitor_running,
|
||||
'daily_task': daily_running
|
||||
},
|
||||
'task_status': {
|
||||
'monitor_task': 'running' if monitor_running else 'stopped',
|
||||
'daily_task': 'running' if daily_running else 'stopped'
|
||||
}
|
||||
}
|
||||
|
||||
async def create_subscription_monitor(bot, db: Database, config, api: RemnaWaveAPI = None) -> SubscriptionMonitorService:
|
||||
logger.info("🏭 Creating subscription monitor service...")
|
||||
service = SubscriptionMonitorService(bot, db, config, api)
|
||||
logger.info("✅ Subscription monitor service created")
|
||||
return service
|
||||
@@ -1,10 +1,7 @@
|
||||
TRANSLATIONS = {
|
||||
'ru': {
|
||||
# Language selection
|
||||
'select_language': '🌍 Выберите язык / Choose language',
|
||||
'language_selected': '🇷🇺 Язык выбран: Русский',
|
||||
|
||||
# Main menu
|
||||
'main_menu': '📱 Главное меню',
|
||||
'balance': 'Баланс',
|
||||
'my_subscriptions': 'Мои подписки',
|
||||
@@ -14,18 +11,32 @@ TRANSLATIONS = {
|
||||
'admin_panel': 'Админ панель',
|
||||
'trial_subscription': '🆓 Тестовая подписка',
|
||||
'trial_not_available': '❌ Тестовая подписка недоступна',
|
||||
'trial_success': '🎉 Тестовая подписка успешно активирована!\n\nТеперь вы можете найти её в разделе "Мои подписки".',
|
||||
'trial_success': '🎉 Тестовая подписка успешно активирована!\n\nТеперь вы можете найти её в разделе: \n\n"Мои подписки"',
|
||||
'trial_error': '❌ Ошибка при создании тестовой подписки',
|
||||
'trial_info': '🧪 Тестовая подписка выдается на три дня!\n\nТариф действует 3 дня!\n\nОграничение трафика - 2гб!',
|
||||
|
||||
# Balance menu
|
||||
'trial_info': 'Попробуйте наш сервис бесплатно! \n\n🧪 Тестовая подписка выдается на {days} дня!\n\nОграничение по трафику - {traffic} гб',
|
||||
'subscriptions_list': '📋 Список подписок в продаже:',
|
||||
'your_balance': '💰 Ваш баланс: {balance:.2f} руб.',
|
||||
'topup_balance': 'Пополнить баланс',
|
||||
'payment_history': 'История платежей',
|
||||
'topup_card': 'Пополнение картой',
|
||||
'topup_support': 'Через саппорт',
|
||||
'back': 'Назад',
|
||||
|
||||
'system_management': 'Управление системой',
|
||||
'nodes_management': 'Управление нодами',
|
||||
'system_users': 'Системные пользователи',
|
||||
'system_statistics': 'Системная статистика',
|
||||
'subscription_expired_message': '❌ Ваша подписка \'{name}\' истекла!\n\n🔄 Для возобновления доступа продлите подписку в разделе \'Мои подписки\'.\n\n💡 Подписку можно продлить в любой момент - срок действия будет увеличен с текущего момента.',
|
||||
'subscription_expires_today': '⚠️ Срочно! Ваша подписка \'{name}\' истекает сегодня!\n\n🔄 Продлите её прямо сейчас в разделе \'Мои подписки\', чтобы не потерять доступ.\n\n⏰ У вас есть несколько часов до истечения!',
|
||||
'subscription_expires_tomorrow': '⚠️ Внимание! Ваша подписка \'{name}\' истекает завтра!\n\n🔄 Чтобы не потерять доступ, продлите её сейчас в разделе \'Мои подписки\'.\n\n✨ При продлении срок действия будет увеличен с момента истечения.',
|
||||
'subscription_expires_day_after_tomorrow': '⏰ Ваша подписка \'{name}\' истекает послезавтра ({days} дня)!\n\n🔄 Рекомендуем продлить её заранее в разделе \'Мои подписки\'.\n\n💰 Проверьте баланс - возможно, стоит пополнить его заранее.',
|
||||
'subscription_expires_in_days': '⏳ Ваша подписка \'{name}\' истекает через {days} дн.!\n\n🔄 Продлить подписку можно в разделе \'Мои подписки\'.\n\n💡 Продление заранее увеличивает срок с момента истечения текущей подписки.',
|
||||
'restart_nodes': 'Перезагрузить ноды',
|
||||
'bulk_operations': 'Массовые операции',
|
||||
'search_user': 'Поиск пользователя',
|
||||
'user_details': 'Детали пользователя',
|
||||
'reset_traffic': 'Сбросить трафик',
|
||||
'disable_user': 'Отключить пользователя',
|
||||
'enable_user': 'Включить пользователя',
|
||||
'send_message': 'Отправить сообщение',
|
||||
'send_to_user': 'Отправить пользователю',
|
||||
'send_to_all': 'Отправить всем',
|
||||
@@ -40,8 +51,6 @@ TRANSLATIONS = {
|
||||
'subscription_extended': '✅ Подписка продлена',
|
||||
'extend_confirmation': 'Продлить подписку "{name}" на {days} дней за {price:.2f} руб.?',
|
||||
'subscription_expires_soon': 'Подписка истекает через {days} дней',
|
||||
|
||||
# Subscriptions
|
||||
'no_subscriptions': '❌ У вас нет активных подписок',
|
||||
'subscription_info': '📋 Подписка: {name}\n💰 Цена: {price:.2f} руб.\n⏱ Длительность: {days} дней\n📊 Трафик: {traffic}\n\n{description}',
|
||||
'unlimited_traffic': 'Безлимитный',
|
||||
@@ -51,41 +60,86 @@ TRANSLATIONS = {
|
||||
'subscription_expires': 'Истекает: {date}',
|
||||
'subscription_expired': 'Истекла',
|
||||
'subscription_active': 'Активна до: {date}',
|
||||
|
||||
# Payments
|
||||
'your_subscriptions': '👀 Список ваших подписок:',
|
||||
'payment_created': '✅ Заявка на пополнение создана.\nОбратитесь к @{support} для оплаты.',
|
||||
'enter_amount': '💰 Введите сумму для пополнения (руб.):',
|
||||
'invalid_amount': '❌ Неверная сумма',
|
||||
'payment_card_info': '💳 Для пополнения картой свяжитесь с @{support}',
|
||||
'no_payments': '❌ История платежей пуста',
|
||||
'payment_item': '{date}: {amount:.2f} руб. - {description} ({status})',
|
||||
|
||||
# Promocodes
|
||||
'enter_promocode': '🎁 Введите промокод:',
|
||||
'promocode_success': '✅ Промокод активирован! Скидка: {discount}',
|
||||
'promocode_not_found': '❌ Промокод не найден',
|
||||
'promocode_expired': '❌ Промокод истек',
|
||||
'promocode_used': '❌ Промокод уже использован',
|
||||
'promocode_limit': '❌ Лимит использований исчерпан',
|
||||
|
||||
# Purchase
|
||||
'referral_program': 'Реферальная программа',
|
||||
'referral_conditions': 'Условия программы',
|
||||
'referral_link': 'Ваша реферальная ссылка',
|
||||
'referral_code': 'Ваш промокод',
|
||||
'referral_stats': 'Статистика',
|
||||
'total_referrals': 'Всего приглашено',
|
||||
'active_referrals': 'Активных рефералов',
|
||||
'total_earned': 'Заработано',
|
||||
'my_referrals': 'Мои рефералы',
|
||||
'referral_reward_referrer': 'За каждого приглашенного: 150₽',
|
||||
'referral_reward_referred': 'Бонус новичку: 150₽ после пополнения на 300₽',
|
||||
'referral_percentage': 'Процент с платежей: 25%',
|
||||
'no_referrals': 'У вас пока нет рефералов',
|
||||
'referral_welcome': 'Добро пожаловать по реферальной ссылке!',
|
||||
'insufficient_balance': '❌ Недостаточно средств на балансе',
|
||||
'subscription_purchased': '✅ Подписка успешно приобретена!',
|
||||
'purchase_error': '❌ Ошибка при покупке подписки',
|
||||
|
||||
# Support
|
||||
'support_message': '🎧 Для получения поддержки обратитесь к @{support}',
|
||||
|
||||
# Admin panel
|
||||
'admin_menu': '⚙️ Админ панель',
|
||||
'manage_subscriptions': 'Управление подписками',
|
||||
'manage_users': 'Управление пользователями',
|
||||
'manage_balance': 'Управление балансом',
|
||||
'manage_promocodes': 'Управление промокодами',
|
||||
'statistics': 'Статистика',
|
||||
'admin_user_subscriptions': 'Подписки пользователей',
|
||||
'all_user_subscriptions': 'Все подписки пользователей',
|
||||
'user_subscription_filters': 'Фильтры подписок',
|
||||
'active_subscriptions': 'Активные подписки',
|
||||
'expired_subscriptions': 'Истекшие подписки',
|
||||
'expiring_subscriptions': 'Истекающие подписки',
|
||||
'autopay_subscriptions': 'Подписки с автоплатежом',
|
||||
'trial_subscriptions': 'Триальные подписки',
|
||||
'imported_subscriptions': 'Импортированные подписки',
|
||||
'subscription_details': 'Детали подписки',
|
||||
'user_info': 'Информация о пользователе',
|
||||
'subscription_info': 'Информация о подписке',
|
||||
'subscription_status': 'Статус подписки',
|
||||
'subscription_dates': 'Временные рамки',
|
||||
'autopay_settings': 'Настройки автоплатежа',
|
||||
'subscription_type': 'Тип подписки',
|
||||
'edit_subscription': 'Редактировать подписку',
|
||||
'toggle_status': 'Изменить статус',
|
||||
'edit_expiry': 'Изменить срок',
|
||||
'edit_autopay': 'Настроить автоплатеж',
|
||||
'subscription_activated': 'Подписка активирована',
|
||||
'subscription_deactivated': 'Подписка деактивирована',
|
||||
'insufficient_funds': 'Недостаточно средств',
|
||||
'funds_needed': 'Нужно еще',
|
||||
'created_date': 'Дата создания',
|
||||
'expires_date': 'Дата истечения',
|
||||
'updated_date': 'Дата обновления',
|
||||
'days_left': 'Дней осталось',
|
||||
'autopay_enabled': 'Автоплатеж включен',
|
||||
'autopay_disabled': 'Автоплатеж отключен',
|
||||
'renew_before_days': 'Продлять за дней',
|
||||
'view_details': 'Просмотр деталей',
|
||||
'back_to_list': 'К списку',
|
||||
'refresh_data': 'Обновить данные',
|
||||
'no_subscriptions_found': 'Подписки не найдены',
|
||||
'subscription_not_found': 'Подписка не найдена',
|
||||
'database_unavailable': 'База данных недоступна',
|
||||
'error_loading_subscriptions': 'Ошибка загрузки подписок',
|
||||
'error_loading_details': 'Ошибка загрузки деталей',
|
||||
'pagination_error': 'Ошибка навигации',
|
||||
'edit_error': 'Ошибка редактирования',
|
||||
'status_toggle_error': 'Ошибка изменения статуса',
|
||||
'not_admin': '❌ У вас нет прав администратора',
|
||||
|
||||
# Admin - subscriptions
|
||||
'create_subscription': 'Создать подписку',
|
||||
'enter_sub_name': 'Введите название подписки:',
|
||||
'enter_sub_description': 'Введите описание подписки:',
|
||||
@@ -94,43 +148,61 @@ TRANSLATIONS = {
|
||||
'enter_sub_traffic': 'Введите лимит трафика (ГБ, 0 для безлимита):',
|
||||
'enter_squad_uuid': 'Введите UUID squad из панели:',
|
||||
'subscription_created': '✅ Подписка создана',
|
||||
|
||||
# Admin - users
|
||||
'extend_subscription_btn': '🔄 Продлить подписку',
|
||||
'my_subscriptions_btn': '📋 Мои подписки',
|
||||
'buy_new_subscription_btn': '🛒 Купить новую подписку',
|
||||
'restore_subscription_btn': '🔄 Восстановить подписку',
|
||||
'user_list': 'Список пользователей:',
|
||||
'user_item': 'ID: {id}, @{username}, Баланс: {balance:.2f} руб.',
|
||||
'enter_user_id': 'Введите Telegram ID пользователя:',
|
||||
'enter_balance_amount': 'Введите сумму для пополнения:',
|
||||
'balance_added': '✅ Баланс пополнен',
|
||||
'user_not_found': '❌ Пользователь не найден',
|
||||
|
||||
# Admin - promocodes
|
||||
'create_promocode': 'Создать промокод',
|
||||
'enter_promo_code': 'Введите код промокода:',
|
||||
'enter_promo_discount': 'Введите размер скидки (руб.):',
|
||||
'enter_promo_limit': 'Введите лимит использований:',
|
||||
'promocode_created': '✅ Промокод создан',
|
||||
'promocode_exists': '❌ Промокод уже существует',
|
||||
|
||||
# Statistics
|
||||
'stats_info': '📊 Статистика:\n👥 Пользователей: {users}\n📋 Подписок: {subscriptions}\n💰 Выручка: {revenue:.2f} руб.',
|
||||
|
||||
# Errors
|
||||
'error_occurred': '❌ Произошла ошибка',
|
||||
'try_again': 'Попробуйте еще раз',
|
||||
'invalid_input': '❌ Неверный ввод',
|
||||
'cancel': 'Отмена',
|
||||
|
||||
# Connection
|
||||
'connection_link': '🔗 Ссылка для подключения:\n\n`{link}`\n\nНажмите на ссылку чтобы скопировать',
|
||||
'connection_error': '❌ Ошибка получения ссылки подключения',
|
||||
'lucky_game': 'Игра удачи',
|
||||
'lucky_game_title': '🎰 Проверь свою удачу!',
|
||||
'lucky_game_description': 'Выберите число от 1 до {numbers} и попытайтесь угадать выигрышное!',
|
||||
'lucky_game_reward': 'Награда за выигрыш: {reward}₽',
|
||||
'lucky_game_rules': 'Правила игры',
|
||||
'lucky_game_play': 'Играть!',
|
||||
'lucky_game_history': 'История игр',
|
||||
'lucky_game_stats': 'Ваша статистика',
|
||||
'lucky_game_choose_number': 'Выберите число от 1 до {numbers}',
|
||||
'lucky_game_winner': '🎉 ПОЗДРАВЛЯЕМ! ВЫ ВЫИГРАЛИ!',
|
||||
'lucky_game_loser': '😔 Не повезло в этот раз...',
|
||||
'lucky_game_your_number': 'Ваше число: {number}',
|
||||
'lucky_game_winning_numbers': 'Выигрышные числа: {numbers}',
|
||||
'lucky_game_reward_added': 'Награда {reward}₽ зачислена на баланс!',
|
||||
'lucky_game_new_balance': 'Новый баланс: {balance}₽',
|
||||
'lucky_game_try_tomorrow': 'Попробуйте завтра!',
|
||||
'lucky_game_already_played': 'Вы уже играли сегодня! Приходите завтра.',
|
||||
'lucky_game_come_back_in': 'Приходи через {time}',
|
||||
'lucky_game_no_history': 'Вы еще не играли в игру удачи.',
|
||||
'lucky_game_start_playing': 'Начните играть, чтобы увидеть историю!',
|
||||
'lucky_game_games_played': 'Игр сыграно: {count}',
|
||||
'lucky_game_wins': 'Выигрышей: {count}',
|
||||
'lucky_game_total_won': 'Всего выиграно: {amount}₽',
|
||||
'lucky_game_win_rate': 'Процент побед: {rate}%',
|
||||
'trial_subscription_expired': '🆓 Ваша триальная подписка "{name}" истекла!\n\n'
|
||||
'💡 Чтобы продолжить использование VPN, купите полный тариф.\n\n'
|
||||
'✨ Доступны различные планы подписки с выгодными ценами!'
|
||||
},
|
||||
|
||||
'en': {
|
||||
# Language selection
|
||||
'select_language': '🌍 Choose language / Выберите язык',
|
||||
'language_selected': '🇺🇸 Language selected: English',
|
||||
|
||||
# Main menu
|
||||
'main_menu': '📱 Main Menu',
|
||||
'balance': 'Balance',
|
||||
'my_subscriptions': 'My Subscriptions',
|
||||
@@ -145,17 +217,24 @@ TRANSLATIONS = {
|
||||
'enter_user_id_message': 'Enter user id message',
|
||||
'enter_message_text': 'Enter message text',
|
||||
'trial_subscription': 'Trial subscription',
|
||||
|
||||
|
||||
# Balance menu
|
||||
'system_management': 'System Management',
|
||||
'nodes_management': 'Nodes Management',
|
||||
'system_users': 'System Users',
|
||||
'system_statistics': 'System Statistics',
|
||||
'restart_nodes': 'Restart Nodes',
|
||||
'bulk_operations': 'Bulk Operations',
|
||||
'search_user': 'Search User',
|
||||
'user_details': 'User Details',
|
||||
'reset_traffic': 'Reset Traffic',
|
||||
'disable_user': 'Disable User',
|
||||
'enable_user': 'Enable User',
|
||||
'your_balance': '💰 Your balance: ${balance:.2f}',
|
||||
'topup_balance': 'Top up balance',
|
||||
'payment_history': 'Payment history',
|
||||
'topup_card': 'Card payment',
|
||||
'topup_support': 'Through support',
|
||||
'your_subscriptions': 'Your Subscriptions',
|
||||
'back': 'Back',
|
||||
|
||||
# Subscriptions
|
||||
'no_subscriptions': '❌ You have no active subscriptions',
|
||||
'subscription_info': '📋 Subscription: {name}\n💰 Price: ${price:.2f}\n⏱ Duration: {days} days\n📊 Traffic: {traffic}\n\n{description}',
|
||||
'unlimited_traffic': 'Unlimited',
|
||||
@@ -165,32 +244,49 @@ TRANSLATIONS = {
|
||||
'subscription_expires': 'Expires: {date}',
|
||||
'subscription_expired': 'Expired',
|
||||
'subscription_active': 'Active until: {date}',
|
||||
|
||||
# Payments
|
||||
'subscription_expired_message': '❌ Your subscription \'{name}\' has expired!\n\nTo renew, go to \'My Subscriptions\'.\n\n💡 You can renew at any time - the term will be extended from the current moment.',
|
||||
'subscription_expires_today': '⚠️ Urgent! Your subscription \'{name}\' expires today!\n\nRenew it now in \'My Subscriptions\' to keep access.\n\n⏰ You have a few hours left!',
|
||||
'subscription_expires_tomorrow': '⚠️ Your subscription \'{name}\' expires tomorrow!\n\nDon\'t forget to renew it in \'My Subscriptions\'.\n\n✨ Renewal will extend the term from expiration date.',
|
||||
'subscription_expires_day_after_tomorrow': '⏰ Your subscription \'{name}\' expires in {days} days!\n\nWe recommend renewing it in advance in \'My Subscriptions\'.\n\n💰 Check your balance - you might want to top it up.',
|
||||
'subscription_expires_in_days': '⏳ Your subscription \'{name}\' expires in {days} days!\n\nYou can renew it in \'My Subscriptions\'.\n\n💡 Early renewal extends from current expiration date.',
|
||||
'extend_subscription_btn': '🔄 Extend Subscription',
|
||||
'trial_subscription_expired': '🆓 Your trial subscription "{name}" has expired!\n\n'
|
||||
'💡 To continue using VPN, please purchase a full plan.\n\n'
|
||||
'✨ Various subscription plans with great prices are available!',
|
||||
'my_subscriptions_btn': '📋 My Subscriptions',
|
||||
'buy_new_subscription_btn': '🛒 Buy New Subscription',
|
||||
'restore_subscription_btn': '🔄 Restore Subscription',
|
||||
'payment_created': '✅ Top-up request created.\nContact @{support} for payment.',
|
||||
'enter_amount': '💰 Enter amount to top up ($):',
|
||||
'invalid_amount': '❌ Invalid amount',
|
||||
'payment_card_info': '💳 For card payment contact @{support}',
|
||||
'no_payments': '❌ Payment history is empty',
|
||||
'payment_item': '{date}: ${amount:.2f} - {description} ({status})',
|
||||
|
||||
# Promocodes
|
||||
'referral_program': 'Referral Program',
|
||||
'referral_conditions': 'Program Conditions',
|
||||
'referral_link': 'Your referral link',
|
||||
'referral_code': 'Your promo code',
|
||||
'referral_stats': 'Statistics',
|
||||
'total_referrals': 'Total invited',
|
||||
'active_referrals': 'Active referrals',
|
||||
'total_earned': 'Total earned',
|
||||
'language_changed': 'Language chanched ✅',
|
||||
'my_referrals': 'My referrals',
|
||||
'referral_reward_referrer': 'For each invited: $150',
|
||||
'referral_reward_referred': 'Newbie bonus: $150 after topping up $300',
|
||||
'referral_percentage': 'Percentage from payments: 25%',
|
||||
'no_referrals': 'You have no referrals yet',
|
||||
'referral_welcome': 'Welcome via referral link!',
|
||||
'enter_promocode': '🎁 Enter promocode:',
|
||||
'promocode_success': '✅ Promocode activated! Discount: {discount}',
|
||||
'promocode_not_found': '❌ Promocode not found',
|
||||
'promocode_expired': '❌ Promocode expired',
|
||||
'promocode_used': '❌ Promocode already used',
|
||||
'promocode_limit': '❌ Usage limit reached',
|
||||
|
||||
# Purchase
|
||||
'insufficient_balance': '❌ Insufficient balance',
|
||||
'subscription_purchased': '✅ Subscription purchased successfully!',
|
||||
'purchase_error': '❌ Error purchasing subscription',
|
||||
|
||||
# Support
|
||||
'support_message': '🎧 For support contact @{support}',
|
||||
|
||||
# Admin panel
|
||||
'admin_menu': '⚙️ Admin Panel',
|
||||
'manage_subscriptions': 'Manage Subscriptions',
|
||||
'manage_users': 'Manage Users',
|
||||
@@ -198,8 +294,6 @@ TRANSLATIONS = {
|
||||
'manage_promocodes': 'Manage Promocodes',
|
||||
'statistics': 'Statistics',
|
||||
'not_admin': '❌ You don\'t have admin rights',
|
||||
|
||||
# Admin - subscriptions
|
||||
'create_subscription': 'Create Subscription',
|
||||
'enter_sub_name': 'Enter subscription name:',
|
||||
'enter_sub_description': 'Enter subscription description:',
|
||||
@@ -208,40 +302,95 @@ TRANSLATIONS = {
|
||||
'enter_sub_traffic': 'Enter traffic limit (GB, 0 for unlimited):',
|
||||
'enter_squad_uuid': 'Enter squad UUID from panel:',
|
||||
'subscription_created': '✅ Subscription created',
|
||||
|
||||
# Admin - users
|
||||
'user_list': '👥 User list:',
|
||||
'user_item': 'ID: {id}, @{username}, Balance: ${balance:.2f}',
|
||||
'enter_user_id': 'Enter user Telegram ID:',
|
||||
'enter_balance_amount': 'Enter amount to add:',
|
||||
'balance_added': '✅ Balance added',
|
||||
'user_not_found': '❌ User not found',
|
||||
|
||||
# Admin - promocodes
|
||||
'create_promocode': 'Create Promocode',
|
||||
'enter_promo_code': 'Enter promocode:',
|
||||
'enter_promo_discount': 'Enter discount amount ($):',
|
||||
'enter_promo_limit': 'Enter usage limit:',
|
||||
'promocode_created': '✅ Promocode created',
|
||||
'promocode_exists': '❌ Promocode already exists',
|
||||
|
||||
# Statistics
|
||||
'stats_info': '📊 Statistics:\n👥 Users: {users}\n📋 Subscriptions: {subscriptions}\n💰 Revenue: ${revenue:.2f}',
|
||||
|
||||
# Errors
|
||||
'error_occurred': '❌ An error occurred',
|
||||
'try_again': 'Try again',
|
||||
'invalid_input': '❌ Invalid input',
|
||||
'cancel': 'Cancel',
|
||||
|
||||
# Connection
|
||||
'connection_link': '🔗 Connection link:\n\n`{link}`\n\nClick the link to copy',
|
||||
'connection_error': '❌ Error getting connection link',
|
||||
'lucky_game': 'Lucky Game',
|
||||
'lucky_game_title': '🎰 Test Your Luck!',
|
||||
'lucky_game_description': 'Choose a number from 1 to {numbers} and try to guess the winning one!',
|
||||
'lucky_game_reward': 'Winning reward: ${reward}',
|
||||
'lucky_game_rules': 'Game Rules',
|
||||
'lucky_game_play': 'Play!',
|
||||
'lucky_game_history': 'Game History',
|
||||
'lucky_game_stats': 'Your Statistics',
|
||||
'lucky_game_choose_number': 'Choose a number from 1 to {numbers}',
|
||||
'lucky_game_winner': '🎉 CONGRATULATIONS! YOU WON!',
|
||||
'lucky_game_loser': '😔 Better luck next time...',
|
||||
'lucky_game_your_number': 'Your number: {number}',
|
||||
'lucky_game_winning_numbers': 'Winning numbers: {numbers}',
|
||||
'lucky_game_reward_added': 'Reward ${reward} added to balance!',
|
||||
'lucky_game_new_balance': 'New balance: ${balance}',
|
||||
'lucky_game_try_tomorrow': 'Try again tomorrow!',
|
||||
'lucky_game_already_played': 'You already played today! Come back tomorrow.',
|
||||
'lucky_game_come_back_in': 'Come back in {time}',
|
||||
'lucky_game_no_history': 'You haven\'t played the lucky game yet.',
|
||||
'lucky_game_start_playing': 'Start playing to see history!',
|
||||
'lucky_game_games_played': 'Games played: {count}',
|
||||
'lucky_game_wins': 'Wins: {count}',
|
||||
'lucky_game_total_won': 'Total won: ${amount}',
|
||||
'lucky_game_win_rate': 'Win rate: {rate}%',
|
||||
'admin_user_subscriptions': 'User Subscriptions',
|
||||
'all_user_subscriptions': 'All User Subscriptions',
|
||||
'user_subscription_filters': 'Subscription Filters',
|
||||
'active_subscriptions': 'Active Subscriptions',
|
||||
'expired_subscriptions': 'Expired Subscriptions',
|
||||
'expiring_subscriptions': 'Expiring Subscriptions',
|
||||
'autopay_subscriptions': 'Autopay Subscriptions',
|
||||
'trial_subscriptions': 'Trial Subscriptions',
|
||||
'imported_subscriptions': 'Imported Subscriptions',
|
||||
'subscription_details': 'Subscription Details',
|
||||
'user_info': 'User Information',
|
||||
'subscription_info': 'Subscription Information',
|
||||
'subscription_status': 'Subscription Status',
|
||||
'subscription_dates': 'Time Frames',
|
||||
'autopay_settings': 'Autopay Settings',
|
||||
'subscription_type': 'Subscription Type',
|
||||
'edit_subscription': 'Edit Subscription',
|
||||
'toggle_status': 'Toggle Status',
|
||||
'edit_expiry': 'Edit Expiry',
|
||||
'edit_autopay': 'Configure Autopay',
|
||||
'subscription_activated': 'Subscription activated',
|
||||
'subscription_deactivated': 'Subscription deactivated',
|
||||
'insufficient_funds': 'Insufficient funds',
|
||||
'funds_needed': 'Need more',
|
||||
'created_date': 'Created Date',
|
||||
'expires_date': 'Expiry Date',
|
||||
'updated_date': 'Updated Date',
|
||||
'days_left': 'Days Left',
|
||||
'autopay_enabled': 'Autopay Enabled',
|
||||
'autopay_disabled': 'Autopay Disabled',
|
||||
'renew_before_days': 'Renew Before Days',
|
||||
'view_details': 'View Details',
|
||||
'back_to_list': 'Back to List',
|
||||
'refresh_data': 'Refresh Data',
|
||||
'no_subscriptions_found': 'No subscriptions found',
|
||||
'subscription_not_found': 'Subscription not found',
|
||||
'database_unavailable': 'Database unavailable',
|
||||
'error_loading_subscriptions': 'Error loading subscriptions',
|
||||
'error_loading_details': 'Error loading details',
|
||||
'pagination_error': 'Navigation error',
|
||||
'edit_error': 'Edit error',
|
||||
'status_toggle_error': 'Status toggle error'
|
||||
}
|
||||
}
|
||||
|
||||
def t(key: str, lang: str = 'ru', **kwargs) -> str:
|
||||
"""Get translation for key with optional formatting"""
|
||||
translation = TRANSLATIONS.get(lang, TRANSLATIONS['ru']).get(key, key)
|
||||
if kwargs:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import logging
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config import Config
|
||||
from database import Database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def convert_period_to_months(period: Optional[str]) -> int:
|
||||
"""Map Tribute subscription period strings to months."""
|
||||
if not period:
|
||||
return 1
|
||||
|
||||
mapping = {
|
||||
"monthly": 1,
|
||||
"quarterly": 3,
|
||||
"3-month": 3,
|
||||
"3months": 3,
|
||||
"3-months": 3,
|
||||
"q": 3,
|
||||
"halfyearly": 6,
|
||||
"yearly": 12,
|
||||
"annual": 12,
|
||||
"y": 12,
|
||||
}
|
||||
return mapping.get(period.lower(), 1)
|
||||
|
||||
|
||||
class TributeService:
|
||||
def __init__(
|
||||
self,
|
||||
bot: Bot,
|
||||
config: Config,
|
||||
db: Database,
|
||||
):
|
||||
self.bot = bot
|
||||
self.config = config
|
||||
self.db = db
|
||||
|
||||
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
|
||||
def ok(data: Optional[dict] = None) -> web.Response:
|
||||
payload = {"status": "ok"}
|
||||
if data:
|
||||
payload.update(data)
|
||||
return web.json_response(payload, status=200)
|
||||
|
||||
def ignored(reason: str) -> web.Response:
|
||||
return web.json_response({"status": "ignored", "reason": reason}, status=200)
|
||||
|
||||
def bad_request(reason: str) -> web.Response:
|
||||
return web.json_response({"status": "error", "reason": reason}, status=400)
|
||||
|
||||
if hasattr(self.config, 'TRIBUTE_API_KEY') and self.config.TRIBUTE_API_KEY:
|
||||
if not signature_header:
|
||||
return web.json_response({"status": "error", "reason": "no_signature"}, status=403)
|
||||
expected_sig = hmac.new(self.config.TRIBUTE_API_KEY.encode(), raw_body,
|
||||
hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected_sig, signature_header):
|
||||
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=403)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode())
|
||||
except Exception:
|
||||
return bad_request("invalid_json")
|
||||
|
||||
logging.info(
|
||||
"Tribute webhook data: %s",
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
)
|
||||
|
||||
event_name = payload.get("name")
|
||||
data = payload.get("payload", {})
|
||||
|
||||
user_id = data.get("telegram_user_id")
|
||||
if not user_id:
|
||||
return ignored("missing_telegram_user_id")
|
||||
|
||||
amount_value = data.get("amount", 0)
|
||||
currency = data.get("currency", "RUB").upper()
|
||||
amount_float = round(amount_value / 100.0, 2)
|
||||
|
||||
if event_name == "new_donation":
|
||||
await self._handle_new_donation(user_id, amount_float, currency, data)
|
||||
elif event_name == "cancelled_subscription":
|
||||
await self._handle_cancellation(user_id)
|
||||
|
||||
return ok({"event": event_name or "unknown"})
|
||||
|
||||
async def _handle_new_donation(self, user_id: int, amount: float, currency: str, data: dict):
|
||||
try:
|
||||
if not user_id:
|
||||
logger.warning(f"No telegram_user_id in webhook data")
|
||||
return
|
||||
|
||||
async with self.db.session_factory() as session:
|
||||
payment = await self.db.create_payment(
|
||||
user_id=int(user_id),
|
||||
amount=amount,
|
||||
payment_type='tribute',
|
||||
description=f'Пополнение через Tribute: {amount} {currency}',
|
||||
status='completed'
|
||||
)
|
||||
|
||||
await self.db.add_balance(int(user_id), amount)
|
||||
|
||||
try:
|
||||
success_msg = (
|
||||
f"✅ **Платеж через Tribute получен!**\n\n"
|
||||
f"💰 Сумма: {amount} {currency}\n"
|
||||
f"🎉 Средства зачислены на баланс!\n\n"
|
||||
f"💳 Ваш текущий баланс можно посмотреть в главном меню."
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="main_menu")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
int(user_id),
|
||||
success_msg,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Tribute payment success message to user {user_id}: {e}")
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"Successfully processed Tribute donation: {amount} {currency} for user {user_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling tribute donation: {e}")
|
||||
|
||||
async def _handle_cancellation(self, user_id: int):
|
||||
try:
|
||||
cancellation_msg = (
|
||||
"🚨 Ваш платеж Tribute был отменен.\n\n"
|
||||
"Если это произошло по ошибке, обратитесь в поддержку."
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
int(user_id),
|
||||
cancellation_msg,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"Tribute subscription cancelled for user {user_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling tribute cancellation for user {user_id}: {e}")
|
||||
|
||||
|
||||
async def tribute_webhook_route(request: web.Request):
|
||||
tribute_service: TributeService = request.app['tribute_service']
|
||||
raw_body = await request.read()
|
||||
signature_header = request.headers.get('trbt-signature')
|
||||
return await tribute_service.handle_webhook(raw_body, signature_header)
|
||||
@@ -1,194 +1,110 @@
|
||||
import re
|
||||
import uuid
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
import logging
|
||||
from typing import Tuple, Optional, Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def generate_username() -> str:
|
||||
"""Generate random username for RemnaWave"""
|
||||
return f"user_{secrets.token_hex(8)}"
|
||||
|
||||
def generate_password() -> str:
|
||||
"""Generate random password"""
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(12))
|
||||
|
||||
def generate_promocode() -> str:
|
||||
"""Generate random promocode"""
|
||||
alphabet = string.ascii_uppercase + string.digits
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(8))
|
||||
|
||||
def is_valid_email(email: str) -> bool:
|
||||
"""Validate email format"""
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
return re.match(pattern, email) is not None
|
||||
|
||||
def is_valid_amount(amount_str: str) -> Tuple[bool, float]:
|
||||
"""Validate and parse amount"""
|
||||
def is_valid_amount(text: str) -> Tuple[bool, float]:
|
||||
try:
|
||||
amount = float(amount_str.replace(',', '.'))
|
||||
text = text.strip().replace(' ', '').replace(',', '.')
|
||||
|
||||
amount = float(text)
|
||||
|
||||
if amount <= 0:
|
||||
return False, 0
|
||||
if amount > 100000: # Max amount limit
|
||||
return False, 0
|
||||
return False, 0.0
|
||||
|
||||
if amount > 1000000:
|
||||
return False, 0.0
|
||||
|
||||
amount = round(amount, 2)
|
||||
|
||||
return True, amount
|
||||
except ValueError:
|
||||
return False, 0
|
||||
|
||||
def format_date(date: datetime, lang: str = 'ru') -> str:
|
||||
"""Format date for display"""
|
||||
if lang == 'ru':
|
||||
months = [
|
||||
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
|
||||
]
|
||||
return f"{date.day} {months[date.month-1]} {date.year}"
|
||||
else:
|
||||
return date.strftime("%B %d, %Y")
|
||||
|
||||
def format_datetime(date: datetime, lang: str = 'ru') -> str:
|
||||
"""Format datetime for display"""
|
||||
if lang == 'ru':
|
||||
return date.strftime("%d.%m.%Y %H:%M")
|
||||
else:
|
||||
return date.strftime("%m/%d/%Y %H:%M")
|
||||
|
||||
def calculate_expiry_date(days: int) -> str:
|
||||
"""Calculate expiry date in ISO format"""
|
||||
expiry = datetime.utcnow() + timedelta(days=days)
|
||||
return expiry.isoformat() + 'Z'
|
||||
|
||||
def parse_telegram_id(text: str) -> Optional[int]:
|
||||
"""Parse Telegram ID from text"""
|
||||
try:
|
||||
telegram_id = int(text.strip())
|
||||
if telegram_id > 0:
|
||||
return telegram_id
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def format_traffic(gb: int, lang: str = 'ru') -> str:
|
||||
"""Format traffic limit for display"""
|
||||
if gb == 0:
|
||||
return "Безлимитный" if lang == 'ru' else "Unlimited"
|
||||
else:
|
||||
return f"{gb} ГБ" if lang == 'ru' else f"{gb} GB"
|
||||
|
||||
def paginate_list(items: List[Any], page: int, per_page: int = 10) -> Tuple[List[Any], int]:
|
||||
"""Paginate list of items"""
|
||||
total_pages = (len(items) + per_page - 1) // per_page
|
||||
start_idx = (page - 1) * per_page
|
||||
end_idx = start_idx + per_page
|
||||
return items[start_idx:end_idx], total_pages
|
||||
|
||||
def escape_markdown(text: str) -> str:
|
||||
"""Escape markdown special characters"""
|
||||
special_chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!']
|
||||
for char in special_chars:
|
||||
text = text.replace(char, f'\\{char}')
|
||||
return text
|
||||
|
||||
def truncate_text(text: str, max_length: int = 4000) -> str:
|
||||
"""Truncate text to fit Telegram message limits"""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return text[:max_length-3] + "..."
|
||||
|
||||
def validate_squad_uuid(uuid_str: str) -> bool:
|
||||
"""Validate UUID format"""
|
||||
try:
|
||||
uuid.UUID(uuid_str)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def format_subscription_info(subscription: Dict[str, Any], lang: str = 'ru') -> str:
|
||||
"""Format subscription information for display"""
|
||||
from translations import t
|
||||
|
||||
traffic = format_traffic(subscription['traffic_limit_gb'], lang)
|
||||
|
||||
info = t('subscription_info', lang,
|
||||
name=subscription['name'],
|
||||
price=subscription['price'],
|
||||
days=subscription['duration_days'],
|
||||
traffic=traffic,
|
||||
description=subscription.get('description', '')
|
||||
)
|
||||
|
||||
return info
|
||||
|
||||
def format_user_subscription_info(user_sub: Dict[str, Any], subscription: Dict[str, Any],
|
||||
expires_at: datetime, lang: str = 'ru') -> str:
|
||||
"""Format user subscription information"""
|
||||
from translations import t
|
||||
|
||||
traffic = format_traffic(subscription['traffic_limit_gb'], lang)
|
||||
|
||||
# Check if expired
|
||||
now = datetime.utcnow()
|
||||
if expires_at < now:
|
||||
status = t('subscription_expired', lang)
|
||||
else:
|
||||
status = t('subscription_active', lang, date=format_date(expires_at, lang))
|
||||
|
||||
info = f"📋 {subscription['name']}\n"
|
||||
info += f"⏱ {subscription['duration_days']} дней\n" if lang == 'ru' else f"⏱ {subscription['duration_days']} days\n"
|
||||
info += f"📊 {traffic}\n"
|
||||
info += f"🕒 {status}\n"
|
||||
|
||||
if subscription.get('description'):
|
||||
info += f"\n{subscription['description']}"
|
||||
|
||||
return info
|
||||
|
||||
except (ValueError, TypeError):
|
||||
return False, 0.0
|
||||
|
||||
def validate_promocode_format(code: str) -> bool:
|
||||
"""Validate promocode format"""
|
||||
if not code:
|
||||
return False
|
||||
|
||||
code = code.strip().upper()
|
||||
|
||||
if len(code) < 3 or len(code) > 20:
|
||||
return False
|
||||
if not re.match(r'^[A-Z0-9]+$', code.upper()):
|
||||
|
||||
if not code.replace('_', '').isalnum():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def calculate_discount(original_price: float, promocode: Dict[str, Any]) -> float:
|
||||
"""Calculate discount amount"""
|
||||
if promocode.get('discount_percent'):
|
||||
return original_price * (promocode['discount_percent'] / 100)
|
||||
def validate_squad_uuid(uuid: str) -> bool:
|
||||
if not uuid or not isinstance(uuid, str):
|
||||
return False
|
||||
|
||||
uuid = uuid.strip()
|
||||
|
||||
if len(uuid) < 8:
|
||||
return False
|
||||
|
||||
allowed_chars = set('0123456789abcdefABCDEF-')
|
||||
if not all(c in allowed_chars for c in uuid):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def parse_telegram_id(text: str) -> Optional[int]:
|
||||
try:
|
||||
text = text.strip().replace(' ', '')
|
||||
|
||||
if text.startswith('@'):
|
||||
text = text[1:]
|
||||
|
||||
if text.startswith('id'):
|
||||
text = text[2:]
|
||||
|
||||
telegram_id = int(text)
|
||||
|
||||
if telegram_id <= 0 or telegram_id > 9999999999:
|
||||
return None
|
||||
|
||||
return telegram_id
|
||||
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def generate_username() -> str:
|
||||
prefix = "user_"
|
||||
random_part = ''.join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(8))
|
||||
return prefix + random_part
|
||||
|
||||
def generate_password() -> str:
|
||||
return ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(12))
|
||||
|
||||
def calculate_expiry_date(days: int) -> str:
|
||||
expiry_date = datetime.now() + timedelta(days=days)
|
||||
return expiry_date.isoformat() + 'Z'
|
||||
|
||||
def format_datetime(dt: datetime, language: str = 'ru') -> str:
|
||||
if not dt:
|
||||
return "N/A"
|
||||
|
||||
if language == 'ru':
|
||||
return dt.strftime('%d.%m.%Y %H:%M')
|
||||
else:
|
||||
return min(promocode.get('discount_amount', 0), original_price)
|
||||
return dt.strftime('%Y-%m-%d %H:%M')
|
||||
|
||||
def format_payment_status(status: str, lang: str = 'ru') -> str:
|
||||
"""Format payment status for display"""
|
||||
status_map = {
|
||||
'pending': 'В ожидании' if lang == 'ru' else 'Pending',
|
||||
'completed': 'Завершен' if lang == 'ru' else 'Completed',
|
||||
'cancelled': 'Отменен' if lang == 'ru' else 'Cancelled',
|
||||
'failed': 'Ошибка' if lang == 'ru' else 'Failed'
|
||||
}
|
||||
return status_map.get(status, status)
|
||||
|
||||
def clean_phone_number(phone: str) -> str:
|
||||
"""Clean and format phone number"""
|
||||
# Remove all non-digit characters
|
||||
digits = re.sub(r'\D', '', phone)
|
||||
def format_date(dt: datetime, language: str = 'ru') -> str:
|
||||
if not dt:
|
||||
return "N/A"
|
||||
|
||||
# Handle Russian phone numbers
|
||||
if digits.startswith('8') and len(digits) == 11:
|
||||
digits = '7' + digits[1:]
|
||||
elif digits.startswith('9') and len(digits) == 10:
|
||||
digits = '7' + digits
|
||||
|
||||
return digits
|
||||
if language == 'ru':
|
||||
return dt.strftime('%d.%m.%Y')
|
||||
else:
|
||||
return dt.strftime('%Y-%m-%d')
|
||||
|
||||
def format_bytes(bytes_value: int) -> str:
|
||||
"""Format bytes to human readable format"""
|
||||
if bytes_value == 0:
|
||||
return "0 B"
|
||||
|
||||
@@ -200,36 +116,159 @@ def format_bytes(bytes_value: int) -> str:
|
||||
value /= 1024
|
||||
unit_index += 1
|
||||
|
||||
if unit_index == 0:
|
||||
return f"{int(value)} {units[unit_index]}"
|
||||
else:
|
||||
if value >= 100:
|
||||
return f"{value:.0f} {units[unit_index]}"
|
||||
elif value >= 10:
|
||||
return f"{value:.1f} {units[unit_index]}"
|
||||
else:
|
||||
return f"{value:.2f} {units[unit_index]}"
|
||||
|
||||
def get_subscription_connection_url(base_url: str, short_uuid: str) -> str:
|
||||
"""Generate subscription connection URL"""
|
||||
return f"{base_url.rstrip('/')}/api/sub/{short_uuid}"
|
||||
def format_payment_status(status: str, language: str = 'ru') -> str:
|
||||
status_map = {
|
||||
'ru': {
|
||||
'pending': 'Ожидает',
|
||||
'completed': 'Завершен',
|
||||
'cancelled': 'Отменен',
|
||||
'failed': 'Ошибка'
|
||||
},
|
||||
'en': {
|
||||
'pending': 'Pending',
|
||||
'completed': 'Completed',
|
||||
'cancelled': 'Cancelled',
|
||||
'failed': 'Failed'
|
||||
}
|
||||
}
|
||||
|
||||
return status_map.get(language, status_map['ru']).get(status, status)
|
||||
|
||||
def format_subscription_info(subscription: Dict[str, Any], language: str = 'ru') -> str:
|
||||
text = ""
|
||||
|
||||
if language == 'ru':
|
||||
text += f"📋 **Подписка: {subscription['name']}**\n\n"
|
||||
text += f"💰 Цена: {subscription['price']} руб.\n"
|
||||
text += f"⏱ Длительность: {subscription['duration_days']} дн.\n"
|
||||
|
||||
if subscription['traffic_limit_gb'] > 0:
|
||||
text += f"📊 Лимит трафика: {subscription['traffic_limit_gb']} ГБ\n"
|
||||
else:
|
||||
text += f"📊 Лимит трафика: Безлимит\n"
|
||||
|
||||
if subscription.get('description'):
|
||||
text += f"\n📝 Описание:\n{subscription['description']}"
|
||||
else:
|
||||
text += f"📋 **Subscription: {subscription['name']}**\n\n"
|
||||
text += f"💰 Price: ${subscription['price']}\n"
|
||||
text += f"⏱ Duration: {subscription['duration_days']} days\n"
|
||||
|
||||
if subscription['traffic_limit_gb'] > 0:
|
||||
text += f"📊 Traffic limit: {subscription['traffic_limit_gb']} GB\n"
|
||||
else:
|
||||
text += f"📊 Traffic limit: Unlimited\n"
|
||||
|
||||
if subscription.get('description'):
|
||||
text += f"\n📝 Description:\n{subscription['description']}"
|
||||
|
||||
return text
|
||||
|
||||
def format_user_subscription_info(user_sub: Dict[str, Any], subscription: Dict[str, Any],
|
||||
expires_at: datetime, language: str = 'ru') -> str:
|
||||
text = ""
|
||||
|
||||
if language == 'ru':
|
||||
text += f"📋 **{subscription['name']}**\n\n"
|
||||
|
||||
now = datetime.utcnow()
|
||||
if expires_at < now:
|
||||
status = "❌ Истекла"
|
||||
days_left = 0
|
||||
elif not user_sub.get('is_active', True):
|
||||
status = "⏸ Приостановлена"
|
||||
days_left = (expires_at - now).days
|
||||
else:
|
||||
days_left = (expires_at - now).days
|
||||
status = f"✅ Активна"
|
||||
|
||||
text += f"🔘 Статус: {status}\n"
|
||||
text += f"📅 Истекает: {format_datetime(expires_at, language)}\n"
|
||||
|
||||
if days_left > 0:
|
||||
text += f"⏰ Осталось: {days_left} дн.\n"
|
||||
|
||||
if subscription['traffic_limit_gb'] > 0:
|
||||
text += f"📊 Лимит трафика: {subscription['traffic_limit_gb']} ГБ\n"
|
||||
else:
|
||||
text += f"📊 Лимит трафика: Безлимит\n"
|
||||
|
||||
if subscription.get('name') == "Старая подписка" or (subscription.get('description') and 'импорт' in subscription.get('description', '').lower()):
|
||||
text += f"\n🔄 Тип: Импортированная из старой системы\n"
|
||||
text += f"ℹ️ Продление недоступно"
|
||||
|
||||
if subscription.get('description') and not ('импорт' in subscription.get('description', '').lower()):
|
||||
text += f"\n📝 {subscription['description']}"
|
||||
else:
|
||||
text += f"📋 **{subscription['name']}**\n\n"
|
||||
|
||||
now = datetime.utcnow()
|
||||
if expires_at < now:
|
||||
status = "❌ Expired"
|
||||
days_left = 0
|
||||
elif not user_sub.get('is_active', True):
|
||||
status = "⏸ Suspended"
|
||||
days_left = (expires_at - now).days
|
||||
else:
|
||||
days_left = (expires_at - now).days
|
||||
status = f"✅ Active"
|
||||
|
||||
text += f"🔘 Status: {status}\n"
|
||||
text += f"📅 Expires: {format_datetime(expires_at, language)}\n"
|
||||
|
||||
if days_left > 0:
|
||||
text += f"⏰ Days left: {days_left}\n"
|
||||
|
||||
if subscription['traffic_limit_gb'] > 0:
|
||||
text += f"📊 Traffic limit: {subscription['traffic_limit_gb']} GB\n"
|
||||
else:
|
||||
text += f"📊 Traffic limit: Unlimited\n"
|
||||
|
||||
if subscription.get('name') == "Старая подписка" or (subscription.get('description') and 'import' in subscription.get('description', '').lower()):
|
||||
text += f"\n🔄 Type: Imported from old system\n"
|
||||
text += f"ℹ️ Extension not available"
|
||||
|
||||
if subscription.get('description') and not ('import' in subscription.get('description', '').lower()):
|
||||
text += f"\n📝 {subscription['description']}"
|
||||
|
||||
return text
|
||||
|
||||
def log_user_action(user_id: int, action: str, details: str = ""):
|
||||
"""Log user action"""
|
||||
logger.info(f"User {user_id} - {action}: {details}")
|
||||
logger.info(f"USER_ACTION: {user_id} - {action}" + (f" - {details}" if details else ""))
|
||||
|
||||
class States:
|
||||
"""State constants for FSM"""
|
||||
WAITING_LANGUAGE = "waiting_language"
|
||||
WAITING_AMOUNT = "waiting_amount"
|
||||
WAITING_PROMOCODE = "waiting_promocode"
|
||||
def bytes_to_gb(bytes_value: int) -> float:
|
||||
if not bytes_value or bytes_value == 0:
|
||||
return 0.0
|
||||
return round(bytes_value / (1024**3), 2)
|
||||
|
||||
def format_memory_usage(used_gb: float, total_gb: float) -> str:
|
||||
if total_gb == 0:
|
||||
return "N/A"
|
||||
|
||||
# Admin states
|
||||
ADMIN_CREATE_SUB_NAME = "admin_create_sub_name"
|
||||
ADMIN_CREATE_SUB_DESC = "admin_create_sub_desc"
|
||||
ADMIN_CREATE_SUB_PRICE = "admin_create_sub_price"
|
||||
ADMIN_CREATE_SUB_DAYS = "admin_create_sub_days"
|
||||
ADMIN_CREATE_SUB_TRAFFIC = "admin_create_sub_traffic"
|
||||
ADMIN_CREATE_SUB_SQUAD = "admin_create_sub_squad"
|
||||
usage_percent = (used_gb / total_gb) * 100
|
||||
available_gb = total_gb - used_gb
|
||||
|
||||
ADMIN_ADD_BALANCE_USER = "admin_add_balance_user"
|
||||
ADMIN_ADD_BALANCE_AMOUNT = "admin_add_balance_amount"
|
||||
return f"{used_gb:.1f}/{total_gb:.1f} ГБ ({usage_percent:.1f}%) • Доступно: {available_gb:.1f} ГБ"
|
||||
|
||||
def format_uptime(uptime_seconds: float) -> str:
|
||||
if uptime_seconds <= 0:
|
||||
return "N/A"
|
||||
|
||||
ADMIN_CREATE_PROMO_CODE = "admin_create_promo_code"
|
||||
ADMIN_CREATE_PROMO_DISCOUNT = "admin_create_promo_discount"
|
||||
ADMIN_CREATE_PROMO_LIMIT = "admin_create_promo_limit"
|
||||
uptime_hours = int(uptime_seconds // 3600)
|
||||
uptime_days = uptime_hours // 24
|
||||
uptime_hours = uptime_hours % 24
|
||||
uptime_minutes = int((uptime_seconds % 3600) // 60)
|
||||
|
||||
if uptime_days > 0:
|
||||
return f"{uptime_days}д {uptime_hours}ч"
|
||||
elif uptime_hours > 0:
|
||||
return f"{uptime_hours}ч {uptime_minutes}м"
|
||||
else:
|
||||
return f"{uptime_minutes}м"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import logging
|
||||
from aiohttp import web, ClientSession
|
||||
from aiogram import Bot
|
||||
from database import Database
|
||||
from config import Config
|
||||
from tribute_service import TributeService, tribute_webhook_route
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class WebhookServer:
|
||||
def __init__(self, bot: Bot, db: Database, config: Config):
|
||||
self.bot = bot
|
||||
self.db = db
|
||||
self.config = config
|
||||
self.app = None
|
||||
self.runner = None
|
||||
self.site = None
|
||||
|
||||
async def create_app(self):
|
||||
self.app = web.Application()
|
||||
|
||||
tribute_service = TributeService(self.bot, self.config, self.db)
|
||||
|
||||
self.app['tribute_service'] = tribute_service
|
||||
|
||||
self.app.router.add_post(self.config.TRIBUTE_WEBHOOK_PATH, tribute_webhook_route)
|
||||
|
||||
async def health_check(request):
|
||||
return web.json_response({"status": "ok", "service": "tribute-webhooks"})
|
||||
|
||||
self.app.router.add_get('/health', health_check)
|
||||
|
||||
logger.info(f"Webhook server configured with route: {self.config.TRIBUTE_WEBHOOK_PATH}")
|
||||
return self.app
|
||||
|
||||
async def start(self):
|
||||
try:
|
||||
if not self.app:
|
||||
await self.create_app()
|
||||
|
||||
self.runner = web.AppRunner(self.app)
|
||||
await self.runner.setup()
|
||||
|
||||
self.site = web.TCPSite(
|
||||
self.runner,
|
||||
host='0.0.0.0',
|
||||
port=self.config.TRIBUTE_WEBHOOK_PORT
|
||||
)
|
||||
|
||||
await self.site.start()
|
||||
|
||||
logger.info(f"✅ Webhook server started on port {self.config.TRIBUTE_WEBHOOK_PORT}")
|
||||
logger.info(f"🎯 Tribute webhook URL: http://your-server:{self.config.TRIBUTE_WEBHOOK_PORT}{self.config.TRIBUTE_WEBHOOK_PATH}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to start webhook server: {e}")
|
||||
raise
|
||||
|
||||
async def stop(self):
|
||||
try:
|
||||
if self.site:
|
||||
await self.site.stop()
|
||||
logger.info("Webhook site stopped")
|
||||
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
logger.info("Webhook runner cleaned up")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping webhook server: {e}")
|
||||