Compare commits
91 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 |
@@ -23,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 - будет раз в час проверять и слать уведомления)
|
||||
@@ -32,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)
|
||||
@@ -58,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}}"
|
||||
+331
-30
@@ -5,7 +5,7 @@ from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import logging
|
||||
from typing import List, Dict
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from database import Database, User, ReferralProgram, ReferralEarning, ServiceRule
|
||||
from remnawave_api import RemnaWaveAPI
|
||||
@@ -52,7 +52,6 @@ logger = logging.getLogger(__name__)
|
||||
admin_router = Router()
|
||||
|
||||
async def check_admin_access(callback: CallbackQuery, user: User) -> bool:
|
||||
"""Check if user has admin access"""
|
||||
if not user.is_admin:
|
||||
await callback.answer(t('not_admin', user.language))
|
||||
return False
|
||||
@@ -60,7 +59,6 @@ async def check_admin_access(callback: CallbackQuery, user: User) -> bool:
|
||||
|
||||
@admin_router.callback_query(F.data == "admin_panel")
|
||||
async def admin_panel_callback(callback: CallbackQuery, user: User, **kwargs):
|
||||
"""Show admin panel"""
|
||||
if not await check_admin_access(callback, user):
|
||||
return
|
||||
|
||||
@@ -71,54 +69,249 @@ async def admin_panel_callback(callback: CallbackQuery, user: User, **kwargs):
|
||||
|
||||
@admin_router.callback_query(F.data == "admin_stats")
|
||||
async def admin_stats_callback(callback: CallbackQuery, user: User, db: Database, api: RemnaWaveAPI = None, **kwargs):
|
||||
"""Show statistics"""
|
||||
if not await check_admin_access(callback, user):
|
||||
return
|
||||
|
||||
try:
|
||||
db_stats = await db.get_stats()
|
||||
|
||||
system_stats = None
|
||||
nodes_stats = None
|
||||
referral_stats = await get_referral_stats(db)
|
||||
|
||||
lucky_stats = await db.get_lucky_game_admin_stats()
|
||||
|
||||
recent_topups = await get_recent_topups(db)
|
||||
recent_lucky_games = await get_recent_lucky_games(db)
|
||||
recent_ref_earnings = await get_recent_referral_earnings(db)
|
||||
|
||||
text = "📊 Расширенная статистика системы\n\n"
|
||||
|
||||
text += "💾 База данных бота:\n"
|
||||
text += f"👥 Пользователей: {db_stats['total_users']}\n"
|
||||
text += f"📋 Подписок: {db_stats['total_subscriptions_non_trial']}\n"
|
||||
text += f"💰 Доходы: {db_stats['total_revenue']:.1f}₽\n\n"
|
||||
|
||||
text += "👥 Реферальная программа:\n"
|
||||
text += f"🎁 Всего выплачено: {referral_stats['total_paid']:.1f}₽\n"
|
||||
text += f"👤 Активных рефереров: {referral_stats['active_referrers']}\n"
|
||||
text += f"🔥 Всего рефералов: {referral_stats['total_referrals']}\n\n"
|
||||
|
||||
text += "🎰 Игра в удачу:\n"
|
||||
if lucky_stats and lucky_stats['total_games'] > 0:
|
||||
text += f"🎲 Всего игр: {lucky_stats['total_games']}\n"
|
||||
text += f"🏆 Выигрышей: {lucky_stats['total_wins']} ({lucky_stats['win_rate']:.1f}%)\n"
|
||||
text += f"👥 Уникальных игроков: {lucky_stats['unique_players']}\n"
|
||||
text += f"💎 Выплачено наград: {lucky_stats['total_rewards']:.1f}₽\n"
|
||||
|
||||
if lucky_stats.get('games_today', 0) > 0:
|
||||
text += f"📅 За сегодня: {lucky_stats['games_today']} игр, {lucky_stats['wins_today']} побед\n"
|
||||
else:
|
||||
text += "🎯 Игр еще не было\n"
|
||||
text += "\n"
|
||||
|
||||
if recent_topups:
|
||||
text += "💰 Последние 5 пополнений:\n"
|
||||
for topup in recent_topups:
|
||||
username = topup.get('username') or 'N/A'
|
||||
try:
|
||||
date_str = format_datetime(topup['created_at'], user.language)
|
||||
except Exception:
|
||||
date_str = str(topup['created_at'])[:16]
|
||||
text += f"• @{username}: {topup['amount']:.0f}₽ ({date_str})\n"
|
||||
text += "\n"
|
||||
|
||||
if recent_lucky_games:
|
||||
text += "🎰 Последние 5 игр в удачу:\n"
|
||||
for game in recent_lucky_games:
|
||||
username = game.get('username') or 'N/A'
|
||||
result = "🏆" if game.get('is_winner') else "❌"
|
||||
reward = f" +{game['reward_amount']:.0f}₽" if game.get('is_winner') else ""
|
||||
try:
|
||||
date_str = format_datetime(game['played_at'], user.language)
|
||||
except Exception:
|
||||
date_str = str(game['played_at'])[:16]
|
||||
text += f"• {result} @{username}: #{game['chosen_number']}{reward} ({date_str})\n"
|
||||
text += "\n"
|
||||
|
||||
if recent_ref_earnings:
|
||||
text += "🎁 Последние 5 реферальных выплат:\n"
|
||||
for earning in recent_ref_earnings:
|
||||
referrer_name = earning.get('referrer_name') or 'N/A'
|
||||
earning_type = "🎁" if earning.get('earning_type') == 'first_reward' else "💵"
|
||||
try:
|
||||
date_str = format_datetime(earning['created_at'], user.language)
|
||||
except Exception:
|
||||
date_str = str(earning['created_at'])[:16]
|
||||
text += f"• {earning_type} @{referrer_name}: {earning['amount']:.0f}₽ ({date_str})\n"
|
||||
text += "\n"
|
||||
|
||||
if api:
|
||||
try:
|
||||
system_stats = await api.get_system_stats()
|
||||
nodes_stats = await api.get_nodes_statistics()
|
||||
if nodes_stats and 'data' in nodes_stats:
|
||||
nodes = nodes_stats['data']
|
||||
online_nodes = len([n for n in nodes if n.get('status') == 'online'])
|
||||
text += f"🖥 Ноды RemnaWave: {online_nodes}/{len(nodes)} онлайн\n"
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get RemnaWave stats: {e}")
|
||||
|
||||
text = t('stats_info', user.language,
|
||||
users=db_stats['total_users'],
|
||||
subscriptions=db_stats['total_subscriptions_non_trial'],
|
||||
revenue=db_stats['total_revenue']
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🎰 Детали игры в удачу", callback_data="lucky_game_admin_details")],
|
||||
[InlineKeyboardButton(text="👥 Реферальная статистика", callback_data="referral_statistics")],
|
||||
[InlineKeyboardButton(text="🖥 Системная статистика", callback_data="admin_system")],
|
||||
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', user.language), callback_data="admin_panel")]
|
||||
])
|
||||
|
||||
if system_stats:
|
||||
text += "\n\n🖥 Системная статистика:"
|
||||
if 'data' in system_stats:
|
||||
data = system_stats['data']
|
||||
if 'bandwidth' in data:
|
||||
bandwidth = data['bandwidth']
|
||||
text += f"\n📊 Трафик: ↓{format_bytes(bandwidth.get('downlink', 0))} ↑{format_bytes(bandwidth.get('uplink', 0))}"
|
||||
|
||||
if nodes_stats and 'data' in nodes_stats:
|
||||
nodes = nodes_stats['data']
|
||||
online_nodes = len([n for n in nodes if n.get('status') == 'online'])
|
||||
text += f"\n🖥 Нод: {online_nodes}/{len(nodes)} онлайн"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=back_keyboard("admin_panel", user.language)
|
||||
)
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting statistics: {e}")
|
||||
await callback.message.edit_text(
|
||||
t('error_occurred', user.language),
|
||||
"❌ " + t('error_occurred', user.language),
|
||||
reply_markup=back_keyboard("admin_panel", user.language)
|
||||
)
|
||||
|
||||
async def get_referral_stats(db: Database) -> Dict[str, Any]:
|
||||
try:
|
||||
async with db.session_factory() as session:
|
||||
from sqlalchemy import select, func, and_
|
||||
from database import ReferralProgram, ReferralEarning
|
||||
|
||||
total_paid = await session.execute(
|
||||
select(func.sum(ReferralEarning.amount))
|
||||
)
|
||||
total_paid = total_paid.scalar() or 0.0
|
||||
|
||||
active_referrers = await session.execute(
|
||||
select(func.count(func.distinct(ReferralEarning.referrer_id)))
|
||||
)
|
||||
active_referrers = active_referrers.scalar() or 0
|
||||
|
||||
total_referrals = await session.execute(
|
||||
select(func.count(ReferralProgram.id)).where(
|
||||
and_(
|
||||
ReferralProgram.referred_id < 900000000,
|
||||
ReferralProgram.referred_id > 0
|
||||
)
|
||||
)
|
||||
)
|
||||
total_referrals = total_referrals.scalar() or 0
|
||||
|
||||
return {
|
||||
'total_paid': total_paid,
|
||||
'active_referrers': active_referrers,
|
||||
'total_referrals': total_referrals
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting referral stats: {e}")
|
||||
return {'total_paid': 0.0, 'active_referrers': 0, 'total_referrals': 0}
|
||||
|
||||
async def get_recent_topups(db: Database) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
async with db.session_factory() as session:
|
||||
from sqlalchemy import select, desc
|
||||
from database import Payment, User
|
||||
|
||||
result = await session.execute(
|
||||
select(
|
||||
Payment.amount,
|
||||
Payment.created_at,
|
||||
Payment.payment_type,
|
||||
User.username,
|
||||
User.first_name
|
||||
).select_from(
|
||||
Payment.__table__.join(User.__table__, Payment.user_id == User.telegram_id)
|
||||
).where(
|
||||
and_(
|
||||
Payment.status == 'completed',
|
||||
Payment.payment_type.in_(['topup', 'subscription', 'subscription_extend', 'promocode', 'admin_topup', 'stars'])
|
||||
)
|
||||
).order_by(desc(Payment.created_at)).limit(5)
|
||||
)
|
||||
|
||||
topups = []
|
||||
for row in result.fetchall():
|
||||
topups.append({
|
||||
'amount': row.amount,
|
||||
'created_at': row.created_at,
|
||||
'payment_type': row.payment_type,
|
||||
'username': row.username,
|
||||
'first_name': row.first_name
|
||||
})
|
||||
|
||||
return topups
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting recent topups: {e}")
|
||||
return []
|
||||
|
||||
async def get_recent_lucky_games(db: Database) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
async with db.session_factory() as session:
|
||||
from sqlalchemy import select, desc
|
||||
from database import LuckyGame, User
|
||||
|
||||
result = await session.execute(
|
||||
select(
|
||||
LuckyGame.chosen_number,
|
||||
LuckyGame.is_winner,
|
||||
LuckyGame.reward_amount,
|
||||
LuckyGame.played_at,
|
||||
User.username,
|
||||
User.first_name
|
||||
).select_from(
|
||||
LuckyGame.__table__.join(User.__table__, LuckyGame.user_id == User.telegram_id)
|
||||
).order_by(desc(LuckyGame.played_at)).limit(5)
|
||||
)
|
||||
|
||||
games = []
|
||||
for row in result.fetchall():
|
||||
games.append({
|
||||
'chosen_number': row.chosen_number,
|
||||
'is_winner': row.is_winner,
|
||||
'reward_amount': row.reward_amount,
|
||||
'played_at': row.played_at,
|
||||
'username': row.username,
|
||||
'first_name': row.first_name
|
||||
})
|
||||
|
||||
return games
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting recent lucky games: {e}")
|
||||
return []
|
||||
|
||||
async def get_recent_referral_earnings(db: Database) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
async with db.session_factory() as session:
|
||||
from sqlalchemy import select, desc
|
||||
from database import ReferralEarning, User
|
||||
|
||||
result = await session.execute(
|
||||
select(
|
||||
ReferralEarning.amount,
|
||||
ReferralEarning.earning_type,
|
||||
ReferralEarning.created_at,
|
||||
User.username,
|
||||
User.first_name
|
||||
).select_from(
|
||||
ReferralEarning.__table__.join(User.__table__, ReferralEarning.referrer_id == User.telegram_id)
|
||||
).order_by(desc(ReferralEarning.created_at)).limit(5)
|
||||
)
|
||||
|
||||
earnings = []
|
||||
for row in result.fetchall():
|
||||
earnings.append({
|
||||
'amount': row.amount,
|
||||
'earning_type': row.earning_type,
|
||||
'created_at': row.created_at,
|
||||
'referrer_name': row.username,
|
||||
'referrer_first_name': row.first_name
|
||||
})
|
||||
|
||||
return earnings
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting recent referral earnings: {e}")
|
||||
return []
|
||||
|
||||
@admin_router.callback_query(F.data == "admin_subscriptions")
|
||||
async def admin_subscriptions_callback(callback: CallbackQuery, user: User, **kwargs):
|
||||
if not await check_admin_access(callback, user):
|
||||
@@ -8303,3 +8496,111 @@ async def edit_sub_autopay_callback(callback: CallbackQuery, user: User, **kwarg
|
||||
except Exception as e:
|
||||
logger.error(f"Error editing subscription autopay: {e}")
|
||||
await callback.answer("❌ Ошибка редактирования", show_alert=True)
|
||||
|
||||
@admin_router.callback_query(F.data == "lucky_game_admin_details")
|
||||
async def lucky_game_admin_details_callback(callback: CallbackQuery, user: User, db: Database, **kwargs):
|
||||
if not await check_admin_access(callback, user):
|
||||
return
|
||||
|
||||
try:
|
||||
lucky_stats = await db.get_lucky_game_admin_stats()
|
||||
top_players = await db.get_lucky_game_top_players(5)
|
||||
|
||||
text = "🎰 **Детальная статистика игры в удачу**\n\n"
|
||||
|
||||
if lucky_stats and lucky_stats.get('total_games', 0) > 0:
|
||||
text += "📊 **Общая статистика:**\n"
|
||||
text += f"🎲 Всего игр сыграно: {lucky_stats['total_games']}\n"
|
||||
text += f"🏆 Всего выигрышей: {lucky_stats['total_wins']}\n"
|
||||
text += f"📈 Процент побед: {lucky_stats['win_rate']:.2f}%\n"
|
||||
text += f"👥 Уникальных игроков: {lucky_stats['unique_players']}\n"
|
||||
text += f"💎 Общая сумма выплат: {lucky_stats['total_rewards']:.0f}₽\n"
|
||||
|
||||
if lucky_stats.get('avg_reward', 0) > 0:
|
||||
text += f"💰 Средняя выплата: {lucky_stats['avg_reward']:.1f}₽\n"
|
||||
text += "\n"
|
||||
|
||||
text += "📅 **За сегодня:**\n"
|
||||
text += f"🎯 Игр: {lucky_stats.get('games_today', 0)}\n"
|
||||
text += f"🎉 Выигрышей: {lucky_stats.get('wins_today', 0)}\n"
|
||||
if lucky_stats.get('games_today', 0) > 0:
|
||||
text += f"📊 Процент побед: {lucky_stats.get('win_rate_today', 0):.1f}%\n"
|
||||
text += "\n"
|
||||
|
||||
if top_players:
|
||||
text += "🏆 **Топ-5 игроков:**\n"
|
||||
for i, player in enumerate(top_players, 1):
|
||||
name = player.get('first_name', 'Unknown')
|
||||
if not name or name == 'Unknown':
|
||||
name = player.get('username', 'N/A')
|
||||
|
||||
text += f"{i}. {name}\n"
|
||||
text += f" 💰 Выиграл: {player.get('total_won', 0):.0f}₽\n"
|
||||
text += f" 🎯 Игр: {player.get('games_played', 0)} | "
|
||||
text += f"Побед: {player.get('wins', 0)} ({player.get('win_rate', 0):.1f}%)\n"
|
||||
|
||||
if player.get('last_game'):
|
||||
try:
|
||||
if isinstance(player['last_game'], str):
|
||||
last_game_dt = datetime.fromisoformat(player['last_game']).replace(tzinfo=None)
|
||||
else:
|
||||
last_game_dt = player['last_game']
|
||||
|
||||
last_game = format_datetime(last_game_dt, user.language)
|
||||
text += f" 🕐 Последняя игра: {last_game}\n"
|
||||
except Exception as e:
|
||||
logger.warning(f"Error formatting last_game: {e}")
|
||||
text += f" 🕐 Последняя игра: {str(player['last_game'])[:16]}\n"
|
||||
text += "\n"
|
||||
|
||||
first_game = lucky_stats.get('first_game')
|
||||
last_game = lucky_stats.get('last_game')
|
||||
|
||||
if first_game and last_game:
|
||||
text += f"🕐 **Временные рамки:**\n"
|
||||
try:
|
||||
if isinstance(first_game, str):
|
||||
first_game_dt = datetime.fromisoformat(first_game).replace(tzinfo=None)
|
||||
else:
|
||||
first_game_dt = first_game
|
||||
|
||||
if isinstance(last_game, str):
|
||||
last_game_dt = datetime.fromisoformat(last_game).replace(tzinfo=None)
|
||||
else:
|
||||
last_game_dt = last_game
|
||||
|
||||
first_game_str = format_datetime(first_game_dt, user.language)
|
||||
last_game_str = format_datetime(last_game_dt, user.language)
|
||||
|
||||
text += f"🥇 Первая игра: {first_game_str}\n"
|
||||
text += f"🕐 Последняя игра: {last_game_str}\n\n"
|
||||
except Exception as e:
|
||||
logger.warning(f"Error formatting game times: {e}")
|
||||
text += f"🥇 Первая игра: {str(first_game)[:16]}\n"
|
||||
text += f"🕐 Последняя игра: {str(last_game)[:16]}\n\n"
|
||||
|
||||
else:
|
||||
text += "🎯 В игру в удачу еще никто не играл.\n\n"
|
||||
text += "Игроки смогут играть после активации функции в боте.\n\n"
|
||||
|
||||
current_time = datetime.now()
|
||||
text += f"🕕 _Обновлено: {format_datetime(current_time, user.language)}_"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Обновить", callback_data="lucky_game_admin_details")],
|
||||
[InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_stats")],
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_panel")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting lucky game admin details: {e}")
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка получения детальной статистики игры",
|
||||
reply_markup=back_keyboard("admin_stats", user.language)
|
||||
)
|
||||
|
||||
+1
-1
@@ -278,7 +278,7 @@ class AutoPayService:
|
||||
async def get_service_status(self) -> dict:
|
||||
return {
|
||||
'is_running': self.is_running,
|
||||
'check_interval': 1800, # 30 минут
|
||||
'check_interval': 1800,
|
||||
'has_api': self.api is not None,
|
||||
'has_bot': self.bot is not None
|
||||
}
|
||||
|
||||
+1168
-1223
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,15 @@ class Config:
|
||||
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
|
||||
@@ -112,12 +121,21 @@ def load_config() -> Config:
|
||||
DELETE_EXPIRED_TRIAL_DAYS=get_int('DELETE_EXPIRED_TRIAL_DAYS', 1),
|
||||
DELETE_EXPIRED_REGULAR_DAYS=get_int('DELETE_EXPIRED_REGULAR_DAYS', 7),
|
||||
AUTO_DELETE_ENABLED=get_bool('AUTO_DELETE_ENABLED', False),
|
||||
TRIAL_NOTIFICATION_ENABLED=get_bool('TRIAL_NOTIFICATION_ENABLED', True),
|
||||
TRIAL_NOTIFICATION_HOURS_AFTER=get_int('TRIAL_NOTIFICATION_HOURS_AFTER', 2),
|
||||
TRIAL_NOTIFICATION_HOURS_WINDOW=get_int('TRIAL_NOTIFICATION_HOURS_WINDOW', 22),
|
||||
LUCKY_GAME_ENABLED=get_bool('LUCKY_GAME_ENABLED', True),
|
||||
LUCKY_GAME_REWARD=get_float('LUCKY_GAME_REWARD', 50.0),
|
||||
LUCKY_GAME_NUMBERS=get_int('LUCKY_GAME_NUMBERS', 30),
|
||||
LUCKY_GAME_WINNING_COUNT=get_int('LUCKY_GAME_WINNING_COUNT', 3),
|
||||
STARS_ENABLED=get_bool('STARS_ENABLED', True),
|
||||
STARS_RATES=parse_stars_rates()
|
||||
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():
|
||||
|
||||
+153
-6
@@ -662,28 +662,38 @@ class Database:
|
||||
async def get_stats(self) -> dict:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy import select, func, and_
|
||||
|
||||
total_users = await session.execute(
|
||||
select(func.count(User.id))
|
||||
)
|
||||
total_users = total_users.scalar()
|
||||
|
||||
|
||||
total_subs_non_trial = await session.execute(
|
||||
select(func.count(UserSubscription.id))
|
||||
.join(Subscription, UserSubscription.subscription_id == Subscription.id)
|
||||
.where(Subscription.is_trial == False)
|
||||
)
|
||||
total_subs_non_trial = total_subs_non_trial.scalar()
|
||||
|
||||
|
||||
total_payments = await session.execute(
|
||||
select(func.sum(Payment.amount)).where(
|
||||
Payment.status == 'completed',
|
||||
Payment.payment_type != 'trial'
|
||||
and_(
|
||||
Payment.status == 'completed',
|
||||
Payment.payment_type.in_([
|
||||
'topup', # Обычные пополнения
|
||||
'subscription', # Покупка подписок
|
||||
'subscription_extend', # Продление подписок
|
||||
'promocode', # Активация промокодов
|
||||
'admin_topup', # Пополнения администратором
|
||||
'stars', # Пополнения через Telegram Stars
|
||||
'autopay' # Автоплатежи
|
||||
])
|
||||
)
|
||||
)
|
||||
)
|
||||
total_payments = total_payments.scalar() or 0
|
||||
|
||||
|
||||
return {
|
||||
'total_users': total_users,
|
||||
'total_subscriptions_non_trial': total_subs_non_trial,
|
||||
@@ -2435,3 +2445,140 @@ class Database:
|
||||
'trial_subscriptions': 0,
|
||||
'imported_subscriptions': 0
|
||||
}
|
||||
|
||||
async def get_lucky_game_admin_stats(self) -> dict:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select, func, and_, text
|
||||
from datetime import date
|
||||
|
||||
total_games = await session.execute(
|
||||
select(func.count(LuckyGame.id))
|
||||
)
|
||||
total_games = total_games.scalar() or 0
|
||||
|
||||
total_wins = await session.execute(
|
||||
select(func.count(LuckyGame.id)).where(LuckyGame.is_winner == True)
|
||||
)
|
||||
total_wins = total_wins.scalar() or 0
|
||||
|
||||
unique_players = await session.execute(
|
||||
select(func.count(func.distinct(LuckyGame.user_id)))
|
||||
)
|
||||
unique_players = unique_players.scalar() or 0
|
||||
|
||||
total_rewards = await session.execute(
|
||||
select(func.sum(LuckyGame.reward_amount))
|
||||
)
|
||||
total_rewards = total_rewards.scalar() or 0.0
|
||||
|
||||
avg_reward = await session.execute(
|
||||
select(func.avg(LuckyGame.reward_amount)).where(LuckyGame.is_winner == True)
|
||||
)
|
||||
avg_reward = avg_reward.scalar() or 0.0
|
||||
|
||||
today = date.today()
|
||||
games_today = await session.execute(
|
||||
select(func.count(LuckyGame.id)).where(
|
||||
func.date(LuckyGame.played_at) == today
|
||||
)
|
||||
)
|
||||
games_today = games_today.scalar() or 0
|
||||
|
||||
wins_today = await session.execute(
|
||||
select(func.count(LuckyGame.id)).where(
|
||||
and_(
|
||||
func.date(LuckyGame.played_at) == today,
|
||||
LuckyGame.is_winner == True
|
||||
)
|
||||
)
|
||||
)
|
||||
wins_today = wins_today.scalar() or 0
|
||||
|
||||
first_game = await session.execute(
|
||||
select(LuckyGame.played_at).order_by(LuckyGame.played_at.asc()).limit(1)
|
||||
)
|
||||
first_game_date = first_game.scalar()
|
||||
|
||||
last_game = await session.execute(
|
||||
select(LuckyGame.played_at).order_by(LuckyGame.played_at.desc()).limit(1)
|
||||
)
|
||||
last_game_date = last_game.scalar()
|
||||
|
||||
win_rate = (total_wins / total_games * 100) if total_games > 0 else 0
|
||||
win_rate_today = (wins_today / games_today * 100) if games_today > 0 else 0
|
||||
|
||||
return {
|
||||
'total_games': total_games,
|
||||
'total_wins': total_wins,
|
||||
'unique_players': unique_players,
|
||||
'total_rewards': total_rewards,
|
||||
'avg_reward': avg_reward,
|
||||
'games_today': games_today,
|
||||
'wins_today': wins_today,
|
||||
'win_rate': win_rate,
|
||||
'win_rate_today': win_rate_today,
|
||||
'first_game': first_game_date.isoformat() if first_game_date else None,
|
||||
'last_game': last_game_date.isoformat() if last_game_date else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting lucky game admin stats: {e}")
|
||||
return {
|
||||
'total_games': 0,
|
||||
'total_wins': 0,
|
||||
'unique_players': 0,
|
||||
'total_rewards': 0.0,
|
||||
'avg_reward': 0.0,
|
||||
'games_today': 0,
|
||||
'wins_today': 0,
|
||||
'win_rate': 0,
|
||||
'win_rate_today': 0,
|
||||
'first_game': None,
|
||||
'last_game': None
|
||||
}
|
||||
|
||||
async def get_lucky_game_top_players(self, limit: int = 5) -> List[dict]:
|
||||
try:
|
||||
async with self.session_factory() as session:
|
||||
from sqlalchemy import select, func, case, desc
|
||||
from database import LuckyGame, User
|
||||
|
||||
query = select(
|
||||
LuckyGame.user_id,
|
||||
User.username,
|
||||
User.first_name,
|
||||
func.count(LuckyGame.id).label('games_played'),
|
||||
func.count(case((LuckyGame.is_winner == True, 1))).label('wins'),
|
||||
func.sum(LuckyGame.reward_amount).label('total_won'),
|
||||
func.max(LuckyGame.played_at).label('last_game')
|
||||
).select_from(
|
||||
LuckyGame.__table__.join(User.__table__, LuckyGame.user_id == User.telegram_id)
|
||||
).group_by(
|
||||
LuckyGame.user_id, User.username, User.first_name
|
||||
).order_by(
|
||||
desc(func.sum(LuckyGame.reward_amount)),
|
||||
desc(func.count(case((LuckyGame.is_winner == True, 1))))
|
||||
).limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
players = []
|
||||
|
||||
for row in result.fetchall():
|
||||
win_rate = (row.wins / row.games_played) * 100 if row.games_played > 0 else 0
|
||||
players.append({
|
||||
'user_id': row.user_id,
|
||||
'username': row.username or 'N/A',
|
||||
'first_name': row.first_name or 'Unknown',
|
||||
'games_played': row.games_played,
|
||||
'wins': row.wins,
|
||||
'total_won': row.total_won or 0.0,
|
||||
'last_game': row.last_game.isoformat() if row.last_game else None,
|
||||
'win_rate': win_rate
|
||||
})
|
||||
|
||||
return players
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting lucky game top players: {e}")
|
||||
return []
|
||||
|
||||
@@ -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
|
||||
@@ -1,100 +0,0 @@
|
||||
"""
|
||||
Экстренное исправление проблемы с отображением подписок
|
||||
Этот патч добавляет недостающие поля в таблицу user_subscriptions
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем корневую директорию в путь
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from config import load_config
|
||||
from database import Database
|
||||
from sqlalchemy import text
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def check_and_add_column(db, column_name, column_definition):
|
||||
"""Проверяет и добавляет колонку в таблицу"""
|
||||
try:
|
||||
# Отдельная транзакция для проверки
|
||||
async with db.engine.begin() as conn:
|
||||
await conn.execute(text(f"SELECT {column_name} FROM user_subscriptions LIMIT 1"))
|
||||
logger.info(f"✅ Поле {column_name} уже существует")
|
||||
return True
|
||||
except Exception:
|
||||
# Отдельная транзакция для добавления колонки
|
||||
try:
|
||||
async with db.engine.begin() as conn:
|
||||
logger.info(f"➕ Добавляю поле {column_name}...")
|
||||
await conn.execute(text(f"""
|
||||
ALTER TABLE user_subscriptions
|
||||
ADD COLUMN {column_name} {column_definition}
|
||||
"""))
|
||||
logger.info(f"✅ Поле {column_name} добавлено")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при добавлении {column_name}: {e}")
|
||||
return False
|
||||
|
||||
async def emergency_fix():
|
||||
"""Экстренное исправление базы данных"""
|
||||
|
||||
try:
|
||||
# Загружаем конфигурацию
|
||||
config = load_config()
|
||||
|
||||
# Подключаемся к базе данных
|
||||
db = Database(config.DATABASE_URL)
|
||||
|
||||
logger.info("🔧 Выполняю экстренное исправление базы данных...")
|
||||
|
||||
# Проверяем существование таблицы user_subscriptions
|
||||
try:
|
||||
async with db.engine.begin() as conn:
|
||||
result = await conn.execute(text("SELECT COUNT(*) FROM user_subscriptions"))
|
||||
count = result.scalar()
|
||||
logger.info(f"📊 Найдено {count} подписок в таблице user_subscriptions")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Таблица user_subscriptions не найдена: {e}")
|
||||
await db.close()
|
||||
return
|
||||
|
||||
# Добавляем поля по одному в отдельных транзакциях
|
||||
success1 = await check_and_add_column(db, "auto_pay_enabled", "BOOLEAN DEFAULT FALSE")
|
||||
success2 = await check_and_add_column(db, "auto_pay_days_before", "INTEGER DEFAULT 3")
|
||||
|
||||
# Финальная проверка в отдельной транзакции
|
||||
if success1 and success2:
|
||||
try:
|
||||
async with db.engine.begin() as conn:
|
||||
result = await conn.execute(text("""
|
||||
SELECT id, auto_pay_enabled, auto_pay_days_before
|
||||
FROM user_subscriptions LIMIT 1
|
||||
"""))
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
logger.info("✅ Все поля доступны для чтения")
|
||||
logger.info(f"🔍 Пример записи: id={row[0]}, auto_pay_enabled={row[1]}, auto_pay_days_before={row[2]}")
|
||||
else:
|
||||
logger.info("✅ Все поля доступны, но таблица пуста")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Поля все еще недоступны: {e}")
|
||||
else:
|
||||
logger.error("❌ Не удалось добавить все необходимые поля")
|
||||
|
||||
await db.close()
|
||||
logger.info("🎉 Экстренное исправление завершено!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"💥 Критическая ошибка: {e}")
|
||||
raise
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(emergency_fix())
|
||||
+132
-8
@@ -66,6 +66,7 @@ class BotStates(StatesGroup):
|
||||
waiting_rule_edit_title = State()
|
||||
waiting_rule_edit_content = State()
|
||||
waiting_rule_edit_order = State()
|
||||
waiting_tribute_amount = State()
|
||||
|
||||
|
||||
router = Router()
|
||||
@@ -175,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),
|
||||
@@ -192,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')
|
||||
@@ -203,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:
|
||||
@@ -211,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")
|
||||
@@ -427,17 +448,19 @@ async def topup_balance_callback(callback: CallbackQuery, **kwargs):
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
|
||||
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'
|
||||
)
|
||||
|
||||
@@ -1123,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")
|
||||
|
||||
@@ -2018,3 +2051,94 @@ async def autopay_days_callback(callback: CallbackQuery, db: Database, **kwargs)
|
||||
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("❌ Ошибка проверки платежа")
|
||||
|
||||
+32
-15
@@ -13,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"),
|
||||
@@ -53,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 = []
|
||||
@@ -713,14 +738,6 @@ 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 = []
|
||||
|
||||
|
||||
@@ -48,9 +48,9 @@ class BotApplication:
|
||||
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...")
|
||||
|
||||
@@ -111,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(
|
||||
@@ -130,6 +131,17 @@ 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()
|
||||
@@ -151,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)
|
||||
|
||||
@@ -198,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())
|
||||
@@ -286,6 +318,13 @@ 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()
|
||||
|
||||
+2
-2
@@ -20,8 +20,8 @@ class RemnaWaveAPI:
|
||||
'Authorization': f'Bearer {self.token}',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
'X-Forwarded-For': '127.0.0.1'
|
||||
'X-Forwarded-Proto': 'https', # ← ДОБАВЬ ЭТО
|
||||
'X-Forwarded-For': '127.0.0.1' # ← И ЭТО
|
||||
}
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
self.session = aiohttp.ClientSession(
|
||||
|
||||
+127
-15
@@ -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,7 +149,94 @@ class SubscriptionMonitorService:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in daily loop: {e}", exc_info=True)
|
||||
await asyncio.sleep(3600)
|
||||
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:
|
||||
@@ -388,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 = (
|
||||
@@ -517,9 +606,13 @@ class SubscriptionMonitorService:
|
||||
warnings_sent = await self._check_expiring_subscriptions()
|
||||
logger.info(f"📢 Sent {warnings_sent} expiry warnings")
|
||||
|
||||
logger.info("🔄 Deactivating 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")
|
||||
logger.info(f"🔥 Deactivated {deactivated_count} expired subscriptions")
|
||||
|
||||
deleted_trials = 0
|
||||
deleted_regular = 0
|
||||
@@ -538,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
|
||||
|
||||
@@ -590,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:
|
||||
@@ -704,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 == "Старая подписка":
|
||||
|
||||
+9
-1
@@ -194,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': {
|
||||
@@ -230,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}',
|
||||
@@ -246,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',
|
||||
@@ -263,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',
|
||||
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user