Compare commits
148 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 |
@@ -10,7 +10,6 @@ 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)
|
||||
@@ -24,6 +23,9 @@ 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 # Через сколько выслать повторно
|
||||
|
||||
# Monitor Service Settings (дополнительные настройки)
|
||||
MONITOR_CHECK_INTERVAL=21600 # Промежуток проверики (3600 - будет раз в час проверять и слать уведомления)
|
||||
@@ -33,6 +35,7 @@ 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)
|
||||
@@ -59,3 +62,10 @@ STARS_500_RATE=550
|
||||
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
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
name: BedolagaBot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
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@v4
|
||||
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 }}
|
||||
@@ -1,96 +0,0 @@
|
||||
# RemnaWave Bot Docker Management
|
||||
|
||||
.PHONY: help build up down restart logs clean db-backup db-restore
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@echo " build - Build all Docker images"
|
||||
@echo " up - Start all services"
|
||||
@echo " up-min - Start only bot and database (minimal setup)"
|
||||
@echo " up-full - Start all services including nginx and redis"
|
||||
@echo " down - Stop all services"
|
||||
@echo " restart - Restart all services"
|
||||
@echo " logs - Show logs for all services"
|
||||
@echo " logs-bot - Show logs for bot service only"
|
||||
@echo " logs-db - Show logs for database service only"
|
||||
@echo " clean - Remove all containers, networks, and volumes"
|
||||
@echo " db-backup - Create database backup"
|
||||
@echo " db-restore - Restore database from backup"
|
||||
@echo " shell-bot - Open shell in bot container"
|
||||
@echo " shell-db - Open shell in database container"
|
||||
|
||||
# Build all images
|
||||
build:
|
||||
docker compose build
|
||||
|
||||
# Start minimal services (bot + database)
|
||||
up-min: setup-dirs
|
||||
docker compose up -d postgres bot
|
||||
|
||||
# Start all services including optional ones
|
||||
up-full: setup-dirs
|
||||
docker compose --profile with-nginx up -d
|
||||
|
||||
# Start main services (default)
|
||||
up: setup-dirs
|
||||
docker compose up -d postgres redis bot
|
||||
|
||||
# Setup required directories
|
||||
setup-dirs:
|
||||
mkdir -p logs data backups
|
||||
|
||||
# Stop all services
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
# Restart all services
|
||||
restart:
|
||||
docker compose restart
|
||||
|
||||
# Show logs for all services
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
# Show logs for bot only
|
||||
logs-bot:
|
||||
docker compose logs -f bot
|
||||
|
||||
# Show logs for database only
|
||||
logs-db:
|
||||
docker compose logs -f postgres
|
||||
|
||||
# Clean up everything (DANGEROUS - removes all data)
|
||||
clean:
|
||||
@echo "This will remove all containers, networks, and volumes. Are you sure? [y/N]"
|
||||
@read answer && [ "$$answer" = "y" ] || [ "$$answer" = "Y" ]
|
||||
docker compose down -v --remove-orphans
|
||||
docker system prune -f
|
||||
|
||||
# Database backup
|
||||
db-backup:
|
||||
@mkdir -p backups
|
||||
docker compose exec postgres pg_dump -U remnawave_user remnawave_bot > backups/backup_$(shell date +%Y%m%d_%H%M%S).sql
|
||||
@echo "Backup created in backups/ directory"
|
||||
|
||||
# Database restore (use: make db-restore BACKUP=backup_20231201_120000.sql)
|
||||
db-restore:
|
||||
@if [ -z "$(BACKUP)" ]; then echo "Usage: make db-restore BACKUP=backup_file.sql"; exit 1; fi
|
||||
docker compose exec -T postgres psql -U remnawave_user -d remnawave_bot < backups/$(BACKUP)
|
||||
@echo "Database restored from $(BACKUP)"
|
||||
|
||||
# Open shell in bot container
|
||||
shell-bot:
|
||||
docker compose exec bot /bin/bash
|
||||
|
||||
# Open shell in database container
|
||||
shell-db:
|
||||
docker compose exec postgres psql -U remnawave_user -d remnawave_bot
|
||||
|
||||
# Check services status
|
||||
status:
|
||||
docker compose ps
|
||||
|
||||
# View service resource usage
|
||||
stats:
|
||||
docker stats --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}"
|
||||
@@ -261,7 +261,7 @@
|
||||
text-anchor="middle"
|
||||
fill="url(#mainGradient)"
|
||||
filter="url(#glow)">
|
||||
RemnaWave
|
||||
Remnawave
|
||||
</text>
|
||||
|
||||
<text x="200" y="345"
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB 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
|
||||
}
|
||||
@@ -4,50 +4,44 @@ from typing import List, Dict
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
# Bot configuration
|
||||
BOT_TOKEN: str
|
||||
BOT_USERNAME: str
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str
|
||||
|
||||
# RemnaWave API
|
||||
REMNAWAVE_URL: str
|
||||
REMNAWAVE_TOKEN: str
|
||||
SUBSCRIPTION_BASE_URL: str
|
||||
|
||||
# Admin configuration
|
||||
ADMIN_IDS: List[int]
|
||||
SUPPORT_USERNAME: str
|
||||
DEFAULT_LANGUAGE: str
|
||||
|
||||
# Trial subscription
|
||||
TRIAL_ENABLED: bool
|
||||
TRIAL_DURATION_DAYS: int
|
||||
TRIAL_TRAFFIC_GB: int
|
||||
TRIAL_SQUAD_UUID: str
|
||||
|
||||
# Referral system
|
||||
REFERRAL_FIRST_REWARD: float
|
||||
REFERRAL_REFERRED_BONUS: float
|
||||
REFERRAL_THRESHOLD: float
|
||||
REFERRAL_PERCENTAGE: float
|
||||
|
||||
# Конфигурация мониторинга подписок
|
||||
MONITOR_ENABLED: bool = True
|
||||
MONITOR_CHECK_INTERVAL: int = 3600 # 1 час
|
||||
MONITOR_DAILY_CHECK_HOUR: int = 10 # 10:00 утра
|
||||
MONITOR_WARNING_DAYS: int = 3 # предупреждать за 3 дня
|
||||
|
||||
# Новые настройки для удаления истекших подписок
|
||||
DELETE_EXPIRED_TRIAL_DAYS: int = 1 # Удалять триальные через 1 день после истечения
|
||||
DELETE_EXPIRED_REGULAR_DAYS: int = 7 # Удалять обычные через 7 дней после истечения
|
||||
AUTO_DELETE_ENABLED: bool = False # Автоматическое удаление при ежедневной проверке
|
||||
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 # Общее количество чисел (1-30)
|
||||
LUCKY_GAME_WINNING_COUNT: int = 3 # Количество выигрышных чисел
|
||||
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
|
||||
@@ -79,7 +73,6 @@ def load_config() -> Config:
|
||||
return default
|
||||
|
||||
def parse_stars_rates() -> Dict[int, float]:
|
||||
"""Парсинг курсов звезд из переменных окружения"""
|
||||
default_rates = {
|
||||
100: 150.0,
|
||||
150: 220.0,
|
||||
@@ -106,51 +99,43 @@ def load_config() -> Config:
|
||||
return Config(
|
||||
BOT_TOKEN=os.getenv('BOT_TOKEN', ''),
|
||||
BOT_USERNAME=os.getenv('BOT_USERNAME', ''),
|
||||
|
||||
# Database
|
||||
DATABASE_URL=os.getenv('DATABASE_URL', 'sqlite+aiosqlite:///bot.db'),
|
||||
|
||||
# RemnaWave API
|
||||
REMNAWAVE_URL=os.getenv('REMNAWAVE_URL', ''),
|
||||
REMNAWAVE_TOKEN=os.getenv('REMNAWAVE_TOKEN', ''),
|
||||
SUBSCRIPTION_BASE_URL=os.getenv('SUBSCRIPTION_BASE_URL', ''),
|
||||
|
||||
# Admin configuration
|
||||
ADMIN_IDS=parse_admin_ids(os.getenv('ADMIN_IDS', '')),
|
||||
SUPPORT_USERNAME=os.getenv('SUPPORT_USERNAME', 'support'),
|
||||
DEFAULT_LANGUAGE=os.getenv('DEFAULT_LANGUAGE', 'ru'),
|
||||
|
||||
# Trial subscription
|
||||
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 system
|
||||
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), # 1 час по умолчанию
|
||||
MONITOR_DAILY_CHECK_HOUR=get_int('MONITOR_DAILY_CHECK_HOUR', 10), # 10 утра
|
||||
MONITOR_WARNING_DAYS=get_int('MONITOR_WARNING_DAYS', 3), # за 3 дня
|
||||
|
||||
# Новые настройки для удаления истекших подписок
|
||||
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),
|
||||
|
||||
# Telegram Stars
|
||||
STARS_ENABLED=get_bool('STARS_ENABLED', True),
|
||||
STARS_RATES=parse_stars_rates()
|
||||
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():
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
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
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
|
||||
ports:
|
||||
- "5432:5432"
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U remnawave_user -d remnawave_bot"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# RemnaWave Bot
|
||||
bot:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: remnawave_bot
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# Override database URL to use PostgreSQL container
|
||||
DATABASE_URL: postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c 'print(\"Bot is running\")'"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# Redis (optional, for caching and session storage)
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: remnawave_bot_redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --requirepass redis_password_123
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ports:
|
||||
- "6379:6379"
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
# Nginx (optional, for serving static files or reverse proxy)
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: remnawave_bot_nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./ssl:/etc/nginx/ssl:ro
|
||||
- ./static:/usr/share/nginx/html:ro
|
||||
networks:
|
||||
- bot_network
|
||||
depends_on:
|
||||
- bot
|
||||
profiles:
|
||||
- with-nginx
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
@@ -8,7 +8,7 @@ import logging
|
||||
import secrets
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from database import Database, User, ReferralProgram
|
||||
from database import Database, User, ReferralProgram, ServiceRule
|
||||
from remnawave_api import RemnaWaveAPI
|
||||
from keyboards import *
|
||||
from translations import t
|
||||
@@ -60,6 +60,13 @@ class BotStates(StatesGroup):
|
||||
admin_debug_user_structure = State()
|
||||
admin_rename_plans_confirm = State()
|
||||
waiting_number_choice = State()
|
||||
waiting_rule_title = State()
|
||||
waiting_rule_content = State()
|
||||
waiting_rule_order = State()
|
||||
waiting_rule_edit_title = State()
|
||||
waiting_rule_edit_content = State()
|
||||
waiting_rule_edit_order = State()
|
||||
waiting_tribute_amount = State()
|
||||
|
||||
|
||||
router = Router()
|
||||
@@ -92,10 +99,9 @@ async def start_command(message: Message, state: FSMContext, db: Database, **kwa
|
||||
success = await create_referral_from_start_param(user.telegram_id, start_param, db, bot)
|
||||
|
||||
if success:
|
||||
import os
|
||||
threshold = float(os.getenv('REFERRAL_THRESHOLD', '300.0'))
|
||||
referred_bonus = float(os.getenv('REFERRAL_REFERRED_BONUS', '150.0'))
|
||||
|
||||
threshold = config.REFERRAL_THRESHOLD if config else 300.0
|
||||
referred_bonus = config.REFERRAL_REFERRED_BONUS if config else 150.0
|
||||
|
||||
await message.answer(
|
||||
"🎁 Добро пожаловать!\n\n"
|
||||
f"Вы перешли по реферальной ссылке! После пополнения баланса на {threshold:.0f}₽ "
|
||||
@@ -170,14 +176,14 @@ async def language_callback(callback: CallbackQuery, state: FSMContext, db: Data
|
||||
async def show_main_menu(message: Message, lang: str, is_admin: bool = False, user_id: int = None, db: Database = None, config: Config = None):
|
||||
try:
|
||||
show_trial = False
|
||||
show_lucky_game = True
|
||||
show_lucky_game = False
|
||||
|
||||
if config and config.TRIAL_ENABLED and user_id and db:
|
||||
has_used = await db.has_used_trial(user_id)
|
||||
show_trial = not has_used
|
||||
|
||||
if config:
|
||||
show_lucky_game = getattr(config, 'LUCKY_GAME_ENABLED', True)
|
||||
if config and hasattr(config, 'LUCKY_GAME_ENABLED'):
|
||||
show_lucky_game = config.LUCKY_GAME_ENABLED
|
||||
|
||||
await message.answer(
|
||||
t('main_menu', lang),
|
||||
@@ -187,6 +193,22 @@ async def show_main_menu(message: Message, lang: str, is_admin: bool = False, us
|
||||
logger.error(f"Error showing main menu: {e}")
|
||||
await message.answer("❌ Ошибка отображения меню")
|
||||
|
||||
def get_main_menu_keyboard(lang: str, is_admin: bool = False, config: Config = None, db: Database = None, user_id: int = None):
|
||||
show_trial = False
|
||||
show_lucky_game = False
|
||||
|
||||
if config and config.TRIAL_ENABLED and user_id and db:
|
||||
try:
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
|
||||
if config and hasattr(config, 'LUCKY_GAME_ENABLED'):
|
||||
show_lucky_game = config.LUCKY_GAME_ENABLED
|
||||
|
||||
return main_menu_keyboard(lang, is_admin, show_trial, show_lucky_game)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "main_menu")
|
||||
async def main_menu_callback(callback: CallbackQuery, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
@@ -198,6 +220,7 @@ async def main_menu_callback(callback: CallbackQuery, **kwargs):
|
||||
return
|
||||
|
||||
show_trial = False
|
||||
show_lucky_game = False
|
||||
|
||||
if config and config.TRIAL_ENABLED and db:
|
||||
try:
|
||||
@@ -206,9 +229,12 @@ async def main_menu_callback(callback: CallbackQuery, **kwargs):
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking trial availability: {e}")
|
||||
|
||||
if config and hasattr(config, 'LUCKY_GAME_ENABLED'):
|
||||
show_lucky_game = config.LUCKY_GAME_ENABLED
|
||||
|
||||
await callback.message.edit_text(
|
||||
t('main_menu', user.language),
|
||||
reply_markup=main_menu_keyboard(user.language, user.is_admin, show_trial)
|
||||
reply_markup=main_menu_keyboard(user.language, user.is_admin, show_trial, show_lucky_game)
|
||||
)
|
||||
|
||||
@router.callback_query(F.data == "trial_subscription")
|
||||
@@ -422,18 +448,19 @@ async def topup_balance_callback(callback: CallbackQuery, **kwargs):
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
# Проверяем, включены ли Telegram Stars
|
||||
tribute_enabled = config and config.TRIBUTE_ENABLED
|
||||
stars_enabled = config and config.STARS_ENABLED and config.STARS_RATES
|
||||
|
||||
text = t('topup_balance', user.language)
|
||||
text = "💰 Выберите способ пополнения баланса:"
|
||||
|
||||
if tribute_enabled:
|
||||
text += "\n\n💳 **Tribute** - Оплата банковской картой"
|
||||
if stars_enabled:
|
||||
text += "\n\n⭐ **Новинка!** Теперь можно пополнять баланс через Telegram Stars!"
|
||||
text += "\n💎 Быстро, безопасно, без комиссий!"
|
||||
text += "\n⭐ **Telegram Stars** - быстро и безопасно"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=topup_keyboard(user.language),
|
||||
reply_markup=topup_keyboard(user.language, tribute_enabled, stars_enabled),
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
|
||||
@@ -533,10 +560,8 @@ async def payment_history_callback(callback: CallbackQuery, db: Database, **kwar
|
||||
return
|
||||
|
||||
try:
|
||||
# Получаем обычные платежи
|
||||
payments = await db.get_user_payments(user.telegram_id)
|
||||
|
||||
# Получаем платежи через звезды
|
||||
star_payments = await db.get_user_star_payments(user.telegram_id, 5)
|
||||
|
||||
if not payments and not star_payments:
|
||||
@@ -544,7 +569,6 @@ async def payment_history_callback(callback: CallbackQuery, db: Database, **kwar
|
||||
else:
|
||||
text = "📊 " + t('payment_history', user.language) + ":\n\n"
|
||||
|
||||
# Объединяем и сортируем все платежи по дате
|
||||
all_payments = []
|
||||
|
||||
for payment in payments[:10]:
|
||||
@@ -567,10 +591,8 @@ async def payment_history_callback(callback: CallbackQuery, db: Database, **kwar
|
||||
'stars': star_payment.stars_amount
|
||||
})
|
||||
|
||||
# Сортируем по дате (новые сначала)
|
||||
all_payments.sort(key=lambda x: x['date'], reverse=True)
|
||||
|
||||
# Показываем последние 10 платежей
|
||||
for payment in all_payments[:10]:
|
||||
date_str = format_datetime(payment['date'], user.language)
|
||||
status = format_payment_status(payment['status'], user.language)
|
||||
@@ -940,6 +962,20 @@ async def view_subscription_detail(callback: CallbackQuery, db: Database, **kwar
|
||||
|
||||
text = format_user_subscription_info(user_sub.__dict__, sub_dict, user_sub.expires_at, user.language)
|
||||
|
||||
if not is_trial and not is_imported and subscription.price > 0:
|
||||
text += f"\n\n🔄 **Автоплатеж:**\n"
|
||||
if user_sub.auto_pay_enabled:
|
||||
text += f"✅ Включен (за {user_sub.auto_pay_days_before} дн. до истечения)\n"
|
||||
text += f"💰 Стоимость продления: {subscription.price} руб.\n"
|
||||
text += f"💳 Ваш баланс: {user.balance} руб."
|
||||
|
||||
if user.balance < subscription.price:
|
||||
needed = subscription.price - user.balance
|
||||
text += f"\n⚠️ Недостаточно средств! Нужно еще {needed} руб."
|
||||
else:
|
||||
text += f"❌ Отключен\n"
|
||||
text += f"💡 Включите для автоматического продления"
|
||||
|
||||
if user_sub.short_uuid and api:
|
||||
try:
|
||||
subscription_url = await api.get_subscription_url(user_sub.short_uuid)
|
||||
@@ -959,7 +995,14 @@ async def view_subscription_detail(callback: CallbackQuery, db: Database, **kwar
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=user_subscription_detail_keyboard(user_sub_id, user.language, show_extend, is_imported),
|
||||
reply_markup=user_subscription_detail_keyboard(
|
||||
user_sub_id,
|
||||
user.language,
|
||||
show_extend,
|
||||
is_imported,
|
||||
is_trial,
|
||||
user_sub.auto_pay_enabled
|
||||
),
|
||||
parse_mode='HTML',
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
@@ -1103,6 +1146,16 @@ async def confirm_extend_subscription_callback(callback: CallbackQuery, db: Data
|
||||
|
||||
if result:
|
||||
logger.info(f"Successfully updated RemnaWave user expiry")
|
||||
|
||||
try:
|
||||
traffic_reset = await api.reset_user_traffic(user_uuid)
|
||||
if traffic_reset:
|
||||
logger.info(f"Successfully reset traffic for user {user_uuid}")
|
||||
else:
|
||||
logger.warning(f"Failed to reset traffic for user {user_uuid}")
|
||||
except Exception as traffic_error:
|
||||
logger.error(f"Error resetting traffic for user {user_uuid}: {traffic_error}")
|
||||
|
||||
else:
|
||||
logger.warning(f"Failed to update user in RemnaWave")
|
||||
|
||||
@@ -1254,23 +1307,48 @@ async def promocode_callback(callback: CallbackQuery, state: FSMContext, **kwarg
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
t('enter_promocode', user.language),
|
||||
await state.clear()
|
||||
|
||||
edited_message = await callback.message.edit_text(
|
||||
"🎁 Введите промокод:\n\n"
|
||||
"• Обычные промокоды (скидки)\n"
|
||||
"• Реферальные коды (REF...)\n\n"
|
||||
"ℹ️ После ввода вы вернетесь в главное меню",
|
||||
reply_markup=cancel_keyboard(user.language)
|
||||
)
|
||||
|
||||
await state.update_data(promocode_message_id=edited_message.message_id)
|
||||
await state.set_state(BotStates.waiting_promocode)
|
||||
|
||||
@router.message(StateFilter(BotStates.waiting_promocode))
|
||||
async def handle_promocode(message: Message, state: FSMContext, db: Database, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
config = kwargs.get('config')
|
||||
bot = kwargs.get('bot')
|
||||
|
||||
if not user:
|
||||
await message.answer("❌ Ошибка пользователя")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
code = message.text.strip().upper()
|
||||
|
||||
state_data = await state.get_data()
|
||||
promocode_message_id = state_data.get('promocode_message_id')
|
||||
|
||||
if not validate_promocode_format(code):
|
||||
await message.answer(t('invalid_input', user.language))
|
||||
response_msg = await message.answer(
|
||||
"❌ Неверный формат промокода",
|
||||
reply_markup=main_menu_keyboard(user.language, user.is_admin)
|
||||
)
|
||||
|
||||
if bot and promocode_message_id:
|
||||
try:
|
||||
await bot.delete_message(chat_id=message.chat.id, message_id=promocode_message_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not delete promocode request message: {e}")
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -1278,17 +1356,50 @@ async def handle_promocode(message: Message, state: FSMContext, db: Database, **
|
||||
|
||||
if promocode and promocode.is_active:
|
||||
if promocode.expires_at and promocode.expires_at < datetime.utcnow():
|
||||
await message.answer(t('promocode_expired', user.language))
|
||||
response_msg = await message.answer(
|
||||
"❌ Промокод истек",
|
||||
reply_markup=main_menu_keyboard(user.language, user.is_admin)
|
||||
)
|
||||
|
||||
if bot and promocode_message_id:
|
||||
try:
|
||||
await bot.delete_message(chat_id=message.chat.id, message_id=promocode_message_id)
|
||||
except:
|
||||
pass
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if promocode.used_count >= promocode.usage_limit:
|
||||
await message.answer(t('promocode_limit', user.language))
|
||||
response_msg = await message.answer(
|
||||
"❌ Лимит использования промокода исчерпан",
|
||||
reply_markup=main_menu_keyboard(user.language, user.is_admin)
|
||||
)
|
||||
|
||||
if bot and promocode_message_id:
|
||||
try:
|
||||
await bot.delete_message(chat_id=message.chat.id, message_id=promocode_message_id)
|
||||
except:
|
||||
pass
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
success = await db.use_promocode(user.telegram_id, promocode)
|
||||
|
||||
if not success:
|
||||
await message.answer(t('promocode_used', user.language))
|
||||
response_msg = await message.answer(
|
||||
"❌ Вы уже использовали этот промокод",
|
||||
reply_markup=main_menu_keyboard(user.language, user.is_admin)
|
||||
)
|
||||
|
||||
if bot and promocode_message_id:
|
||||
try:
|
||||
await bot.delete_message(chat_id=message.chat.id, message_id=promocode_message_id)
|
||||
except:
|
||||
pass
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await db.add_balance(user.telegram_id, promocode.discount_amount)
|
||||
@@ -1302,18 +1413,23 @@ async def handle_promocode(message: Message, state: FSMContext, db: Database, **
|
||||
)
|
||||
|
||||
discount_text = f"{promocode.discount_amount} руб."
|
||||
await message.answer(
|
||||
success_msg = await message.answer(
|
||||
t('promocode_success', user.language, discount=discount_text),
|
||||
reply_markup=main_menu_keyboard(user.language, user.is_admin)
|
||||
)
|
||||
|
||||
if bot and promocode_message_id:
|
||||
try:
|
||||
await bot.delete_message(chat_id=message.chat.id, message_id=promocode_message_id)
|
||||
logger.info(f"Deleted promocode request message {promocode_message_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not delete promocode request message: {e}")
|
||||
|
||||
await state.clear()
|
||||
log_user_action(user.telegram_id, "promocode_used", code)
|
||||
return
|
||||
|
||||
if code.startswith("REF"):
|
||||
bot = kwargs.get('bot')
|
||||
|
||||
async with db.session_factory() as session:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
@@ -1326,42 +1442,93 @@ async def handle_promocode(message: Message, state: FSMContext, db: Database, **
|
||||
|
||||
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:
|
||||
await message.answer(
|
||||
response_msg = await message.answer(
|
||||
"❌ Нельзя использовать код человека, которого вы пригласили!\n\n"
|
||||
"Взаимные рефералы не допускаются."
|
||||
"Взаимные рефералы не допускаются.",
|
||||
reply_markup=main_menu_keyboard(user.language, user.is_admin)
|
||||
)
|
||||
|
||||
if bot and promocode_message_id:
|
||||
try:
|
||||
await bot.delete_message(chat_id=message.chat.id, message_id=promocode_message_id)
|
||||
except:
|
||||
pass
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
success = await create_referral_from_promocode(user.telegram_id, code, db, bot)
|
||||
|
||||
if success:
|
||||
await message.answer(
|
||||
"🎉 Реферальный код активирован!\n\n"
|
||||
"После пополнения баланса на 200₽ вы получите бонус 150₽!",
|
||||
threshold = config.REFERRAL_THRESHOLD if config else 300.0
|
||||
referred_bonus = config.REFERRAL_REFERRED_BONUS if config else 150.0
|
||||
|
||||
success_msg = await message.answer(
|
||||
f"🎉 Реферальный код активирован!\n\n"
|
||||
f"После пополнения баланса на {threshold:.0f}₽ вы получите бонус {referred_bonus:.0f}₽!",
|
||||
reply_markup=main_menu_keyboard(user.language, user.is_admin)
|
||||
)
|
||||
|
||||
if bot and promocode_message_id:
|
||||
try:
|
||||
await bot.delete_message(chat_id=message.chat.id, message_id=promocode_message_id)
|
||||
logger.info(f"Deleted promocode request message {promocode_message_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not delete promocode request message: {e}")
|
||||
|
||||
await state.clear()
|
||||
log_user_action(user.telegram_id, "referral_code_used", code)
|
||||
return
|
||||
else:
|
||||
existing_referral = await db.get_referral_by_referred_id(user.telegram_id)
|
||||
if existing_referral:
|
||||
await message.answer("❌ Вы уже использовали реферальный код!")
|
||||
error_text = "❌ Вы уже использовали реферальный код!"
|
||||
else:
|
||||
await message.answer("❌ Неверный реферальный код!")
|
||||
error_text = "❌ Неверный реферальный код!"
|
||||
|
||||
response_msg = await message.answer(
|
||||
error_text,
|
||||
reply_markup=main_menu_keyboard(user.language, user.is_admin)
|
||||
)
|
||||
|
||||
if bot and promocode_message_id:
|
||||
try:
|
||||
await bot.delete_message(chat_id=message.chat.id, message_id=promocode_message_id)
|
||||
except:
|
||||
pass
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await message.answer(t('promocode_not_found', user.language))
|
||||
response_msg = await message.answer(
|
||||
"❌ Промокод не найден\n\n"
|
||||
"Проверьте правильность ввода и попробуйте снова через главное меню.",
|
||||
reply_markup=main_menu_keyboard(user.language, user.is_admin)
|
||||
)
|
||||
|
||||
if bot and promocode_message_id:
|
||||
try:
|
||||
await bot.delete_message(chat_id=message.chat.id, message_id=promocode_message_id)
|
||||
logger.info(f"Deleted promocode request message {promocode_message_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not delete promocode request message: {e}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling promocode: {e}")
|
||||
await message.answer(
|
||||
t('error_occurred', user.language),
|
||||
response_msg = await message.answer(
|
||||
"❌ Произошла ошибка при обработке промокода",
|
||||
reply_markup=main_menu_keyboard(user.language, user.is_admin)
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
if bot and promocode_message_id:
|
||||
try:
|
||||
await bot.delete_message(chat_id=message.chat.id, message_id=promocode_message_id)
|
||||
except:
|
||||
pass
|
||||
|
||||
await state.clear()
|
||||
|
||||
@router.callback_query(F.data == "referral_program")
|
||||
async def referral_program_callback(callback: CallbackQuery, db: Database, **kwargs):
|
||||
@@ -1545,3 +1712,433 @@ async def my_referrals_callback(callback: CallbackQuery, db: Database, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Error showing referrals: {e}")
|
||||
await callback.answer("❌ Ошибка загрузки")
|
||||
|
||||
@router.callback_query(F.data == "service_rules")
|
||||
async def service_rules_callback(callback: CallbackQuery, db: Database, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
try:
|
||||
rules = await db.get_all_service_rules(active_only=True)
|
||||
|
||||
if not rules:
|
||||
await callback.message.edit_text(
|
||||
"📜 Правила сервиса пока не добавлены администратором.",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="main_menu")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
await show_rules_page(callback, rules, 0, user.language)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error showing service rules: {e}")
|
||||
await callback.answer("❌ Ошибка загрузки правил")
|
||||
|
||||
async def show_rules_page(callback: CallbackQuery, rules: List[ServiceRule], page_index: int, lang: str = 'ru'):
|
||||
if page_index < 0 or page_index >= len(rules):
|
||||
page_index = 0
|
||||
|
||||
rule = rules[page_index]
|
||||
total_pages = len(rules)
|
||||
|
||||
safe_title = rule.title.replace('*', '\\*').replace('_', '\\_').replace('[', '\\[').replace(']', '\\]').replace('`', '\\`')
|
||||
text = f"📜 **{safe_title}**\n\n"
|
||||
text += rule.content
|
||||
|
||||
if total_pages > 1:
|
||||
text += f"\n\n📄 Страница {page_index + 1} из {total_pages}"
|
||||
|
||||
if len(text) > 4000:
|
||||
text = text[:3950] + "\n\n... (текст обрезан)"
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=service_rules_keyboard(page_index, total_pages, lang),
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
except Exception as markdown_error:
|
||||
logger.warning(f"Markdown parsing failed in user rules, retrying without markdown: {markdown_error}")
|
||||
try:
|
||||
clean_text = text.replace('**', '').replace('*', '').replace('_', '').replace('`', '')
|
||||
await callback.message.edit_text(
|
||||
clean_text,
|
||||
reply_markup=service_rules_keyboard(page_index, total_pages, lang)
|
||||
)
|
||||
except Exception as edit_error:
|
||||
await callback.message.answer(
|
||||
clean_text,
|
||||
reply_markup=service_rules_keyboard(page_index, total_pages, lang)
|
||||
)
|
||||
|
||||
@router.callback_query(F.data.startswith("rules_page_"))
|
||||
async def rules_page_callback(callback: CallbackQuery, db: Database, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
try:
|
||||
page_index = int(callback.data.split("_")[-1])
|
||||
rules = await db.get_all_service_rules(active_only=True)
|
||||
|
||||
if not rules:
|
||||
await callback.answer("❌ Правила не найдены")
|
||||
return
|
||||
|
||||
await show_rules_page(callback, rules, page_index, user.language)
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"Error parsing rules page number: {e}")
|
||||
await callback.answer("❌ Ошибка навигации")
|
||||
except Exception as e:
|
||||
logger.error(f"Error switching rules page: {e}")
|
||||
await callback.answer("❌ Ошибка загрузки страницы")
|
||||
|
||||
@router.callback_query(F.data == "cancel", StateFilter(BotStates.waiting_promocode))
|
||||
async def cancel_promocode_callback(callback: CallbackQuery, state: FSMContext, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
config = kwargs.get('config')
|
||||
db = kwargs.get('db')
|
||||
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
show_trial = False
|
||||
show_lucky_game = True
|
||||
|
||||
if config and config.TRIAL_ENABLED and db:
|
||||
try:
|
||||
has_used = await db.has_used_trial(user.telegram_id)
|
||||
show_trial = not has_used
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking trial availability: {e}")
|
||||
|
||||
if config:
|
||||
show_lucky_game = getattr(config, 'LUCKY_GAME_ENABLED', True)
|
||||
|
||||
await callback.message.edit_text(
|
||||
t('main_menu', user.language),
|
||||
reply_markup=main_menu_keyboard(user.language, user.is_admin, show_trial, show_lucky_game)
|
||||
)
|
||||
|
||||
@router.callback_query(F.data.startswith("toggle_autopay_"))
|
||||
async def toggle_autopay_callback(callback: CallbackQuery, db: Database, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
try:
|
||||
user_sub_id = int(callback.data.split("_")[2])
|
||||
|
||||
user_subs = await db.get_user_subscriptions(user.telegram_id)
|
||||
user_sub = next((sub for sub in user_subs if sub.id == user_sub_id), None)
|
||||
|
||||
if not user_sub:
|
||||
await callback.answer("❌ Подписка не найдена")
|
||||
return
|
||||
|
||||
subscription = await db.get_subscription_by_id(user_sub.subscription_id)
|
||||
if not subscription:
|
||||
await callback.answer("❌ План подписки не найден")
|
||||
return
|
||||
|
||||
if subscription.is_trial:
|
||||
await callback.answer("❌ Автоплатеж недоступен для тестовых подписок")
|
||||
return
|
||||
|
||||
if subscription.is_imported or subscription.price == 0:
|
||||
await callback.answer("❌ Автоплатеж недоступен для импортированных подписок")
|
||||
return
|
||||
|
||||
new_state = not user_sub.auto_pay_enabled
|
||||
success = await db.toggle_autopay(user_sub_id, new_state)
|
||||
|
||||
if success:
|
||||
status_text = "включен" if new_state else "отключен"
|
||||
await callback.answer(f"✅ Автоплатеж {status_text}")
|
||||
|
||||
updated_subs = await db.get_user_subscriptions(user.telegram_id)
|
||||
updated_sub = next((sub for sub in updated_subs if sub.id == user_sub_id), None)
|
||||
|
||||
if updated_sub:
|
||||
await autopay_settings_callback(callback, db, user=user)
|
||||
else:
|
||||
await callback.answer("❌ Ошибка изменения настроек")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error toggling autopay: {e}")
|
||||
await callback.answer("❌ Ошибка операции")
|
||||
|
||||
@router.callback_query(F.data == "autopay_help")
|
||||
async def autopay_help_callback(callback: CallbackQuery, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
text = "🔄 **Автоплатеж - как это работает?**\n\n"
|
||||
|
||||
text += "**🎯 Что такое автоплатеж:**\n"
|
||||
text += "Автоматическое продление подписки за несколько дней до истечения срока действия.\n\n"
|
||||
|
||||
text += "**⚙️ Как настроить:**\n"
|
||||
text += "1. Перейдите в 'Мои подписки'\n"
|
||||
text += "2. Выберите нужную подписку\n"
|
||||
text += "3. Нажмите 'Настроить автоплатеж'\n"
|
||||
text += "4. Включите автоплатеж и выберите за сколько дней до истечения продлевать\n\n"
|
||||
|
||||
text += "**💰 Требования:**\n"
|
||||
text += "• На балансе должно быть достаточно средств\n"
|
||||
text += "• Подписка должна быть активной\n"
|
||||
text += "• Автоплатеж работает только с обычными подписками\n\n"
|
||||
|
||||
text += "**📅 Варианты продления:**\n"
|
||||
text += "• За 1 день - для тех, кто следит за балансом\n"
|
||||
text += "• За 3 дня - рекомендуемый вариант\n"
|
||||
text += "• За 5 дней - для подстраховки\n"
|
||||
text += "• За 7 дней - максимальный запас времени\n\n"
|
||||
|
||||
text += "**🔔 Уведомления:**\n"
|
||||
text += "Вы получите уведомление при успешном продлении или если недостаточно средств.\n\n"
|
||||
|
||||
text += "**❓ Проблемы:**\n"
|
||||
text += "Если автоплатеж не сработал, проверьте баланс и обратитесь в поддержку."
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=autopay_help_keyboard(user.language),
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
|
||||
@router.callback_query(F.data == "autopay_insufficient_balance_help")
|
||||
async def autopay_insufficient_balance_help_callback(callback: CallbackQuery, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
text = "⚠️ **Недостаточно средств для автоплатежа**\n\n"
|
||||
|
||||
text += f"💳 **Ваш текущий баланс:** {user.balance}₽\n\n"
|
||||
|
||||
text += "**🔄 Что происходит:**\n"
|
||||
text += "Система пыталась автоматически продлить вашу подписку, но на балансе недостаточно средств.\n\n"
|
||||
|
||||
text += "**✅ Что нужно сделать:**\n"
|
||||
text += "1. Пополните баланс на нужную сумму\n"
|
||||
text += "2. Автоплатеж попробует снова при следующей проверке\n"
|
||||
text += "3. Или продлите подписку вручную\n\n"
|
||||
|
||||
text += "**⏰ Важно:**\n"
|
||||
text += "Пополните баланс до истечения срока подписки, иначе доступ будет приостановлен."
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=autopay_help_keyboard(user.language),
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
|
||||
@router.callback_query(F.data.startswith("autopay_settings_"))
|
||||
async def autopay_settings_callback(callback: CallbackQuery, db: Database, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
try:
|
||||
user_sub_id = int(callback.data.split("_")[2])
|
||||
|
||||
user_subs = await db.get_user_subscriptions(user.telegram_id)
|
||||
user_sub = next((sub for sub in user_subs if sub.id == user_sub_id), None)
|
||||
|
||||
if not user_sub:
|
||||
await callback.answer("❌ Подписка не найдена")
|
||||
return
|
||||
|
||||
subscription = await db.get_subscription_by_id(user_sub.subscription_id)
|
||||
if not subscription:
|
||||
await callback.answer("❌ План подписки не найден")
|
||||
return
|
||||
|
||||
if subscription.is_trial:
|
||||
await callback.answer("❌ Автоплатеж недоступен для тестовых подписок")
|
||||
return
|
||||
|
||||
if subscription.is_imported or subscription.price == 0:
|
||||
await callback.answer("❌ Автоплатеж недоступен для импортированных подписок")
|
||||
return
|
||||
|
||||
from datetime import datetime
|
||||
now = datetime.utcnow()
|
||||
days_left = (user_sub.expires_at - now).days
|
||||
|
||||
text = f"⚙️ Настройки автоплатежа\n\n"
|
||||
text += f"📋 Подписка: {subscription.name}\n"
|
||||
text += f"💰 Цена продления: {subscription.price} руб.\n"
|
||||
text += f"⏱ Длительность: {subscription.duration_days} дн.\n"
|
||||
text += f"📅 Истекает через: {days_left} дн.\n\n"
|
||||
|
||||
status_emoji = "✅" if user_sub.auto_pay_enabled else "❌"
|
||||
status_text = "Включен" if user_sub.auto_pay_enabled else "Отключен"
|
||||
text += f"🔄 Автоплатеж: {status_emoji} {status_text}\n"
|
||||
|
||||
if user_sub.auto_pay_enabled:
|
||||
text += f"📅 Продлять за: {user_sub.auto_pay_days_before} дн. до истечения\n\n"
|
||||
|
||||
if user.balance >= subscription.price:
|
||||
text += f"✅ На балансе достаточно средств ({user.balance}₽)\n"
|
||||
text += f"💡 Подписка будет автоматически продлена"
|
||||
else:
|
||||
needed = subscription.price - user.balance
|
||||
text += f"⚠️ Недостаточно средств на балансе!\n"
|
||||
text += f"💳 Ваш баланс: {user.balance}₽\n"
|
||||
text += f"💸 Нужно еще: {needed}₽\n"
|
||||
text += f"💡 Пополните баланс для автоматического продления"
|
||||
else:
|
||||
text += f"\n💡 Включите автоплатеж для автоматического продления подписки\n"
|
||||
text += f"🎯 Рекомендуем включить за 3 дня до истечения"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=autopay_settings_keyboard(user_sub_id, user_sub, user.language)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error showing autopay settings: {e}")
|
||||
await callback.answer("❌ Ошибка загрузки настроек")
|
||||
|
||||
@router.callback_query(F.data.startswith("autopay_days_"))
|
||||
async def autopay_days_callback(callback: CallbackQuery, db: Database, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
try:
|
||||
parts = callback.data.split("_")
|
||||
user_sub_id = int(parts[2])
|
||||
days = int(parts[3])
|
||||
|
||||
user_subs = await db.get_user_subscriptions(user.telegram_id)
|
||||
user_sub = next((sub for sub in user_subs if sub.id == user_sub_id), None)
|
||||
|
||||
if not user_sub:
|
||||
await callback.answer("❌ Подписка не найдена")
|
||||
return
|
||||
|
||||
success = await db.set_autopay_days(user_sub_id, days)
|
||||
|
||||
if success:
|
||||
await callback.answer(f"✅ Установлено: продлять за {days} дн.")
|
||||
|
||||
updated_subs = await db.get_user_subscriptions(user.telegram_id)
|
||||
updated_sub = next((sub for sub in updated_subs if sub.id == user_sub_id), None)
|
||||
|
||||
if updated_sub:
|
||||
await autopay_settings_callback(callback, db, user=user)
|
||||
else:
|
||||
await callback.answer("❌ Ошибка обновления настроек")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting autopay days: {e}")
|
||||
await callback.answer("❌ Ошибка операции")
|
||||
|
||||
@router.callback_query(F.data == "topup_tribute")
|
||||
async def topup_tribute_callback(callback: CallbackQuery, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
config = kwargs.get('config')
|
||||
|
||||
if not user or not config or not config.TRIBUTE_ENABLED:
|
||||
await callback.answer("⚠️ Tribute платежи недоступны")
|
||||
return
|
||||
|
||||
text = (
|
||||
"💳 **Пополнение через Tribute**\n\n"
|
||||
"🔹 **Доступные способы оплаты:**\n"
|
||||
"• 💳 Банковские карты (Visa, MasterCard, МИР)\n"
|
||||
"💰 **Введите любую сумму от 100 до 15000 рублей**\n\n"
|
||||
"⏱️ После оплаты средства поступят на баланс автоматически в течение 1 минуты"
|
||||
"🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!"
|
||||
"Иначе дс не поступят на ваш счет!"
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 Открыть Tribute", url=config.TRIBUTE_DONATE_LINK)],
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data="topup_balance")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="Markdown")
|
||||
|
||||
@router.message(StateFilter(BotStates.waiting_tribute_amount))
|
||||
async def create_tribute_payment(callback, user, amount: int, config):
|
||||
try:
|
||||
tribute_donate_link = config.TRIBUTE_DONATE_LINK
|
||||
|
||||
text = (
|
||||
f"💳 **Пополнение через Tribute**\n\n"
|
||||
f"💰 Сумма: **{amount}₽**\n\n"
|
||||
f"📋 **Инструкция:**\n"
|
||||
f"1️⃣ Нажмите кнопку «Открыть Tribute»\n"
|
||||
f"2️⃣ Введите сумму: **{amount}₽**\n"
|
||||
f"3️⃣ Выберите способ оплаты\n"
|
||||
f"4️⃣ Завершите платеж\n\n"
|
||||
f"⏱️ После оплаты средства поступят на баланс автоматически в течение 1 минуты\n\n"
|
||||
f"💡 Ваш аккаунт привязан автоматически, никаких дополнительных данных вводить не нужно"
|
||||
f"🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!"
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 Открыть Tribute", url=tribute_donate_link)],
|
||||
[InlineKeyboardButton(text="🔄 Проверить платеж", callback_data=f"check_tribute_{amount}")],
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data="topup_balance")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="Markdown")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating Tribute payment: {e}")
|
||||
await callback.answer("❌ Ошибка создания платежа")
|
||||
|
||||
@router.callback_query(F.data.startswith("check_tribute_"))
|
||||
async def check_tribute_payment_callback(callback: CallbackQuery, db: Database, **kwargs):
|
||||
user = kwargs.get('user')
|
||||
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
try:
|
||||
payments = await db.get_user_payments(user.telegram_id)
|
||||
recent_tribute_payments = [
|
||||
p for p in payments
|
||||
if p.payment_type == 'tribute' and p.status == 'completed'
|
||||
and (datetime.utcnow() - p.created_at).total_seconds() < 1800
|
||||
]
|
||||
|
||||
if recent_tribute_payments:
|
||||
await callback.answer("✅ Платеж найден! Средства зачислены на баланс", show_alert=True)
|
||||
await callback.message.edit_text(
|
||||
f"✅ **Платеж успешно обработан!**\n\n"
|
||||
f"💰 Зачислено: {recent_tribute_payments[0].amount}₽\n"
|
||||
f"💳 Текущий баланс: {user.balance}₽",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="main_menu")]
|
||||
]),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
else:
|
||||
await callback.answer("⏳ Платеж еще не обработан. Попробуйте через минуту.", show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking Tribute payment: {e}")
|
||||
await callback.answer("❌ Ошибка проверки платежа")
|
||||
|
||||
@@ -2,6 +2,7 @@ from database import Subscription
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from typing import List, Optional, Dict
|
||||
from translations import t
|
||||
from datetime import datetime
|
||||
|
||||
def language_keyboard() -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -12,7 +13,7 @@ def language_keyboard() -> InlineKeyboardMarkup:
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def main_menu_keyboard(lang: str = 'ru', is_admin: bool = False, show_trial: bool = False, show_lucky_game: bool = True) -> InlineKeyboardMarkup:
|
||||
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"),
|
||||
@@ -35,7 +36,8 @@ def main_menu_keyboard(lang: str = 'ru', is_admin: bool = False, show_trial: boo
|
||||
[
|
||||
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:
|
||||
@@ -51,13 +53,38 @@ def balance_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def topup_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
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:
|
||||
buttons = []
|
||||
@@ -109,22 +136,52 @@ def user_subscriptions_keyboard(user_subscriptions: List[dict], lang: str = 'ru'
|
||||
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, is_imported: bool = False) -> InlineKeyboardMarkup:
|
||||
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 = []
|
||||
|
||||
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}")])
|
||||
|
||||
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:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
@@ -159,13 +216,17 @@ def admin_menu_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📨 " + t('send_message', lang), callback_data="admin_messages"),
|
||||
InlineKeyboardButton(text="👥 Рефералы", callback_data="admin_referrals") # НОВАЯ КНОПКА
|
||||
InlineKeyboardButton(text="👥 Рефералы", callback_data="admin_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🖥 Система RemnaWave", callback_data="admin_system")
|
||||
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="🔍 Мониторинг подписок", callback_data="admin_monitor"),
|
||||
InlineKeyboardButton(text="📊 " + t('statistics', lang), callback_data="admin_stats")
|
||||
],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")]
|
||||
@@ -183,11 +244,84 @@ def admin_subscriptions_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
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_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:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💸 Пополнить баланс пользователю", callback_data="admin_add_balance")],
|
||||
@@ -604,28 +738,16 @@ def lucky_game_result_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="main_menu")]
|
||||
])
|
||||
|
||||
def topup_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⭐ Telegram Stars", callback_data="topup_stars")],
|
||||
[InlineKeyboardButton(text="👨💼 " + t('topup_support', lang), callback_data="topup_support")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="balance")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def stars_topup_keyboard(stars_rates: Dict[int, float], lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Клавиатура с вариантами пополнения через звезды"""
|
||||
buttons = []
|
||||
|
||||
# Сортируем по количеству звезд
|
||||
sorted_rates = sorted(stars_rates.items())
|
||||
|
||||
# Создаем кнопки по 2 в ряд
|
||||
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:
|
||||
@@ -640,14 +762,186 @@ def stars_topup_keyboard(stars_rates: Dict[int, float], lang: str = 'ru') -> Inl
|
||||
))
|
||||
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:
|
||||
"""Клавиатура подтверждения платежа через звезды (не используется в send_invoice)"""
|
||||
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
|
||||
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
|
||||
print("🚀 Запуск бота...")
|
||||
print(f"📍 Рабочая директория: {os.getcwd()}")
|
||||
@@ -46,6 +47,40 @@ class BotApplication:
|
||||
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
|
||||
|
||||
async def initialize(self):
|
||||
|
||||
@@ -76,6 +111,7 @@ class BotApplication:
|
||||
logger.info(f"Bot Username: {self.config.BOT_USERNAME}")
|
||||
|
||||
self.db = Database(self.config.DATABASE_URL)
|
||||
|
||||
await self._init_database()
|
||||
|
||||
self.api = RemnaWaveAPI(
|
||||
@@ -95,8 +131,20 @@ class BotApplication:
|
||||
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 пополнение включено")
|
||||
@@ -115,18 +163,37 @@ class BotApplication:
|
||||
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:
|
||||
logger.info(f"🗄️ Database initialization attempt {attempt + 1}/{max_retries}")
|
||||
await self.db.init_db()
|
||||
logger.info("Database initialized successfully")
|
||||
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")
|
||||
logger.error("💥 Failed to initialize database after all retries")
|
||||
raise
|
||||
await asyncio.sleep(2)
|
||||
|
||||
@@ -162,7 +229,8 @@ class BotApplication:
|
||||
"config": self.config,
|
||||
"api": self.api,
|
||||
"db": self.db,
|
||||
"monitor_service": None
|
||||
"monitor_service": None,
|
||||
"autopay_service": None
|
||||
})
|
||||
|
||||
self.dp.message.middleware(LoggingMiddleware())
|
||||
@@ -249,6 +317,20 @@ class BotApplication:
|
||||
|
||||
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:
|
||||
|
||||
@@ -19,7 +19,9 @@ class RemnaWaveAPI:
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.token}',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
'Accept': 'application/json',
|
||||
'X-Forwarded-Proto': 'https', # ← ДОБАВЬ ЭТО
|
||||
'X-Forwarded-For': '127.0.0.1' # ← И ЭТО
|
||||
}
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
self.session = aiohttp.ClientSession(
|
||||
|
||||
|
Before Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 122 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 |
|
Before Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 120 KiB |
@@ -0,0 +1 @@
|
||||
|
||||
|
After Width: | Height: | Size: 211 KiB |
@@ -17,7 +17,6 @@ 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')
|
||||
|
||||
@@ -70,7 +69,6 @@ async def topup_stars_callback(callback: CallbackQuery, **kwargs):
|
||||
|
||||
@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')
|
||||
@@ -117,8 +115,8 @@ async def buy_stars_callback(callback: CallbackQuery, db: Database, **kwargs):
|
||||
f"💳 **Оплата через Telegram Stars**\n\n"
|
||||
f"⭐ Количество звезд: {stars_amount}\n"
|
||||
f"💰 Сумма пополнения: {rub_amount:.0f}₽\n\n"
|
||||
f"👆 Нажмите кнопку \"Оплатить\" в инвойсе выше\n\n"
|
||||
f"❌ Если передумали - нажмите кнопку ниже",
|
||||
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")]
|
||||
@@ -130,7 +128,7 @@ async def buy_stars_callback(callback: CallbackQuery, db: Database, **kwargs):
|
||||
f"💳 **Оплата через Telegram Stars**\n\n"
|
||||
f"⭐ Количество звезд: {stars_amount}\n"
|
||||
f"💰 Сумма пополнения: {rub_amount:.0f}₽\n\n"
|
||||
f"👆 Нажмите кнопку \"Оплатить\" в инвойсе выше",
|
||||
f"👆 Нажмите кнопку \"Оплатить\" в инвойсе ниже⤵️",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="❌ Отменить платеж", callback_data=f"cancel_star_payment_{star_payment.id}")],
|
||||
[InlineKeyboardButton(text="🔙 Назад к выбору", callback_data="topup_stars")]
|
||||
@@ -164,7 +162,6 @@ async def buy_stars_callback(callback: CallbackQuery, db: Database, **kwargs):
|
||||
|
||||
@stars_router.pre_checkout_query()
|
||||
async def pre_checkout_query_handler(pre_checkout_query: PreCheckoutQuery, db: Database, **kwargs):
|
||||
"""Обработка pre-checkout запроса для звезд"""
|
||||
try:
|
||||
if not pre_checkout_query.invoice_payload.startswith("star_payment_"):
|
||||
await pre_checkout_query.answer(ok=False, error_message="Неверный формат платежа")
|
||||
@@ -194,7 +191,6 @@ async def pre_checkout_query_handler(pre_checkout_query: PreCheckoutQuery, db: D
|
||||
|
||||
@stars_router.message(F.successful_payment)
|
||||
async def successful_payment_handler(message: Message, db: Database, **kwargs):
|
||||
"""Обработка успешного платежа звездами"""
|
||||
user = kwargs.get('user')
|
||||
bot = kwargs.get('bot')
|
||||
|
||||
@@ -267,7 +263,6 @@ async def successful_payment_handler(message: Message, db: Database, **kwargs):
|
||||
await message.answer("❌ Произошла ошибка при обработке платежа. Обратитесь в поддержку.")
|
||||
|
||||
def get_stars_rate_info(stars_rates: Dict[int, float], lang: str = 'ru') -> str:
|
||||
"""Получить информацию о курсах звезд"""
|
||||
if not stars_rates:
|
||||
return "Курсы не настроены"
|
||||
|
||||
@@ -284,6 +279,7 @@ def get_stars_rate_info(stars_rates: Dict[int, float], lang: str = 'ru') -> str:
|
||||
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("❌ Ошибка пользователя")
|
||||
@@ -311,30 +307,101 @@ async def cancel_star_payment_callback(callback: CallbackQuery, db: Database, **
|
||||
if success:
|
||||
await callback.answer("✅ Платеж отменен", show_alert=True)
|
||||
|
||||
if config and config.STARS_RATES:
|
||||
if bot:
|
||||
invoice_deleted = False
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
"❌ Платеж отменен\n\n⭐ **Пополнение через Telegram Stars**\n\nВыберите другой вариант:",
|
||||
reply_markup=stars_topup_keyboard(config.STARS_RATES, user.language),
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await callback.message.answer(
|
||||
"❌ Платеж отменен\n\n⭐ **Пополнение через Telegram Stars**\n\nВыберите другой вариант:",
|
||||
reply_markup=stars_topup_keyboard(config.STARS_RATES, user.language),
|
||||
parse_mode='Markdown'
|
||||
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:
|
||||
await callback.message.edit_text(
|
||||
"❌ Платеж отменен",
|
||||
reply_markup=balance_keyboard(user.language)
|
||||
)
|
||||
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:
|
||||
await callback.message.answer(
|
||||
"❌ Платеж отменен",
|
||||
reply_markup=balance_keyboard(user.language)
|
||||
)
|
||||
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:
|
||||
|
||||
@@ -86,7 +86,7 @@ class SubscriptionMonitorService:
|
||||
logger.info("Subscription monitor service stopped")
|
||||
|
||||
async def _monitor_loop(self):
|
||||
logger.info("🔄 Starting monitor loop")
|
||||
logger.info("🔥 Starting monitor loop")
|
||||
|
||||
logger.info("⏰ Initial check in 10 seconds...")
|
||||
await asyncio.sleep(10)
|
||||
@@ -96,8 +96,10 @@ class SubscriptionMonitorService:
|
||||
logger.info("🔍 Running periodic subscription check...")
|
||||
warnings_sent = await self._check_expiring_subscriptions()
|
||||
|
||||
if warnings_sent > 0:
|
||||
logger.info(f"✅ Monitor check completed: {warnings_sent} warnings sent")
|
||||
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")
|
||||
|
||||
@@ -147,12 +149,96 @@ class SubscriptionMonitorService:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in daily loop: {e}", exc_info=True)
|
||||
await asyncio.sleep(3600) # Wait 1 hour before retry
|
||||
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...")
|
||||
|
||||
@@ -179,12 +265,10 @@ class SubscriptionMonitorService:
|
||||
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)
|
||||
@@ -192,12 +276,11 @@ class SubscriptionMonitorService:
|
||||
expires_at_utc = expires_at_utc.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
if expires_at_utc > cutoff_date and not force:
|
||||
continue # Подписка еще не готова к удалению
|
||||
continue
|
||||
|
||||
logger.info(f"🗑️ Deleting expired trial subscription '{subscription.name}' for user {user.telegram_id} "
|
||||
f"(expired: {expires_at_utc}, cutoff: {cutoff_date})")
|
||||
|
||||
# Удаляем из RemnaWave API
|
||||
api_deleted = False
|
||||
if self.api and user_sub.short_uuid:
|
||||
try:
|
||||
@@ -213,7 +296,6 @@ class SubscriptionMonitorService:
|
||||
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
|
||||
@@ -248,9 +330,6 @@ class SubscriptionMonitorService:
|
||||
}
|
||||
|
||||
async def delete_expired_regular_subscriptions(self, force: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Удаляет истекшие обычные подписки
|
||||
"""
|
||||
try:
|
||||
logger.info("🗑️ Starting deletion of expired regular subscriptions...")
|
||||
|
||||
@@ -277,16 +356,13 @@ class SubscriptionMonitorService:
|
||||
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)
|
||||
@@ -294,12 +370,11 @@ class SubscriptionMonitorService:
|
||||
expires_at_utc = expires_at_utc.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
if expires_at_utc > cutoff_date and not force:
|
||||
continue # Подписка еще не готова к удалению
|
||||
continue
|
||||
|
||||
logger.info(f"🗑️ Deleting expired regular subscription '{subscription.name}' for user {user.telegram_id} "
|
||||
f"(expired: {expires_at_utc}, cutoff: {cutoff_date})")
|
||||
|
||||
# Удаляем из RemnaWave API
|
||||
api_deleted = False
|
||||
if self.api and user_sub.short_uuid:
|
||||
try:
|
||||
@@ -315,7 +390,6 @@ class SubscriptionMonitorService:
|
||||
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
|
||||
@@ -403,11 +477,11 @@ class SubscriptionMonitorService:
|
||||
f"threshold={self.config.MONITOR_WARNING_DAYS}")
|
||||
|
||||
if subscription.is_trial:
|
||||
logger.debug(f"⏭️ Skipping trial subscription '{subscription.name}'")
|
||||
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}'")
|
||||
logger.debug(f"⭐️ Skipping imported subscription '{subscription.name}'")
|
||||
continue
|
||||
|
||||
should_warn = (
|
||||
@@ -532,11 +606,14 @@ class SubscriptionMonitorService:
|
||||
warnings_sent = await self._check_expiring_subscriptions()
|
||||
logger.info(f"📢 Sent {warnings_sent} expiry warnings")
|
||||
|
||||
logger.info("🔄 Deactivating expired subscriptions...")
|
||||
deactivated_count = await self.deactivate_expired_subscriptions()
|
||||
logger.info(f"🔄 Deactivated {deactivated_count} expired subscriptions")
|
||||
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):
|
||||
@@ -554,7 +631,7 @@ class SubscriptionMonitorService:
|
||||
await self._send_final_expiry_notifications()
|
||||
logger.info("📩 Final notifications sent")
|
||||
|
||||
logger.info(f"✅ Daily check completed successfully. Warnings: {warnings_sent}, Deactivated: {deactivated_count}, "
|
||||
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
|
||||
|
||||
@@ -606,7 +683,7 @@ class SubscriptionMonitorService:
|
||||
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")
|
||||
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:
|
||||
@@ -720,11 +797,30 @@ class SubscriptionMonitorService:
|
||||
days_left = int(hours_left / 24)
|
||||
|
||||
if subscription.is_trial:
|
||||
results.append({
|
||||
'success': True,
|
||||
'message': f'Trial subscription "{subscription.name}" skipped (no warnings for trials)',
|
||||
'error': None
|
||||
})
|
||||
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 == "Старая подписка":
|
||||
|
||||
@@ -11,9 +11,9 @@ 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гб',
|
||||
'trial_info': 'Попробуйте наш сервис бесплатно! \n\n🧪 Тестовая подписка выдается на {days} дня!\n\nОграничение по трафику - {traffic} гб',
|
||||
'subscriptions_list': '📋 Список подписок в продаже:',
|
||||
'your_balance': '💰 Ваш баланс: {balance:.2f} руб.',
|
||||
'topup_balance': 'Пополнить баланс',
|
||||
@@ -97,6 +97,48 @@ TRANSLATIONS = {
|
||||
'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': '❌ У вас нет прав администратора',
|
||||
'create_subscription': 'Создать подписку',
|
||||
'enter_sub_name': 'Введите название подписки:',
|
||||
@@ -152,7 +194,10 @@ TRANSLATIONS = {
|
||||
'lucky_game_games_played': 'Игр сыграно: {count}',
|
||||
'lucky_game_wins': 'Выигрышей: {count}',
|
||||
'lucky_game_total_won': 'Всего выиграно: {amount}₽',
|
||||
'lucky_game_win_rate': 'Процент побед: {rate}%'
|
||||
'lucky_game_win_rate': 'Процент побед: {rate}%',
|
||||
'trial_subscription_expired': '🆓 Ваша триальная подписка "{name}" истекла!\n\n'
|
||||
'💡 Чтобы продолжить использование VPN, купите полный тариф.\n\n'
|
||||
'✨ Доступны различные планы подписки с выгодными ценами!'
|
||||
},
|
||||
|
||||
'en': {
|
||||
@@ -188,6 +233,7 @@ TRANSLATIONS = {
|
||||
'payment_history': 'Payment history',
|
||||
'topup_card': 'Card payment',
|
||||
'topup_support': 'Through support',
|
||||
'your_subscriptions': 'Your Subscriptions',
|
||||
'back': 'Back',
|
||||
'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}',
|
||||
@@ -204,6 +250,9 @@ TRANSLATIONS = {
|
||||
'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',
|
||||
@@ -221,6 +270,7 @@ TRANSLATIONS = {
|
||||
'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',
|
||||
@@ -294,7 +344,49 @@ TRANSLATIONS = {
|
||||
'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}%'
|
||||
'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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||