Compare commits

...

105 Commits

Author SHA1 Message Date
Egor 6288d3fb7f Merge pull request #2478 from BEDOLAGA-DEV/dev
Dev
2026-02-01 00:54:44 +03:00
Egor d611eecece Update user.py 2026-02-01 00:54:17 +03:00
Egor 779cccffe6 Update tribute.py 2026-02-01 00:53:50 +03:00
Egor 04144dc7a5 Merge pull request #2477 from BEDOLAGA-DEV/dev
Update subscription.py
2026-01-31 21:28:51 +03:00
Egor b7bfdbb485 Update subscription.py 2026-01-31 21:28:35 +03:00
Egor 21b7c039b7 Merge pull request #2476 from BEDOLAGA-DEV/dev
Dev
2026-01-31 21:15:22 +03:00
Egor dc7d2eb02a Add files via upload 2026-01-31 21:13:54 +03:00
Egor 6c26d5c7f8 Update texts.py 2026-01-31 21:13:04 +03:00
Egor 06e99d5a43 Merge pull request #2475 from BEDOLAGA-DEV/dev
Dev
2026-01-31 20:49:48 +03:00
Egor a7712c7151 Add files via upload 2026-01-31 20:46:25 +03:00
Egor 418d329b75 Update subscription_auto_purchase_service.py 2026-01-31 20:45:55 +03:00
Egor a4d5b8067c Merge pull request #2474 from BEDOLAGA-DEV/dev
Update subscription.py
2026-01-31 20:34:27 +03:00
Egor f61ee0f64a Update subscription.py 2026-01-31 20:33:39 +03:00
Egor 841b1e4c52 Merge pull request #2473 from BEDOLAGA-DEV/dev
Dev
2026-01-31 20:16:36 +03:00
Egor 927ec91e0e Update admin_users.py 2026-01-31 20:12:58 +03:00
Egor 9cc2a285dc Update user.py 2026-01-31 20:12:25 +03:00
Egor f11173d5aa Merge pull request #2472 from BEDOLAGA-DEV/dev
Dev
2026-01-31 19:52:58 +03:00
Egor 7bd5d13cc8 Update websocket.py 2026-01-31 19:50:47 +03:00
Egor c30e22e1b1 Update subscription_auto_purchase_service.py 2026-01-31 19:50:03 +03:00
Egor ffff91466e Merge pull request #2471 from BEDOLAGA-DEV/dev
Dev
2026-01-31 19:42:07 +03:00
Egor a7669d0f35 Update devices.py 2026-01-31 19:41:16 +03:00
Egor 1371f21d17 Update subscription_auto_purchase_service.py 2026-01-31 19:40:18 +03:00
Egor ec553d3334 Update subscription.py 2026-01-31 19:39:28 +03:00
Egor 638644d1e9 Update subscription.py 2026-01-31 19:02:30 +03:00
Egor 8ae6ef5cb8 Merge pull request #2470 from BEDOLAGA-DEV/dev
Update universal_migration.py
2026-01-31 18:13:59 +03:00
Egor 1092301767 Update universal_migration.py 2026-01-31 18:13:42 +03:00
Egor 365f75447f Update universal_migration.py 2026-01-31 18:12:53 +03:00
Egor 2e3dbaa18c Merge pull request #2469 from BEDOLAGA-DEV/dev
Dev
2026-01-31 17:50:21 +03:00
Egor 68df186f72 Update admin_broadcasts.py 2026-01-31 17:50:00 +03:00
Egor 8d3bedefb0 Update broadcast_service.py 2026-01-31 17:49:30 +03:00
Egor 701a4d51de Update universal_migration.py 2026-01-31 17:49:02 +03:00
Egor fbb45c10c1 Update main.py 2026-01-31 17:47:43 +03:00
Egor e4f182ffc6 Update broadcasts.py 2026-01-31 17:47:00 +03:00
Egor fa94042284 Update admin_broadcasts.py 2026-01-31 17:46:37 +03:00
Egor b258715cc1 Update broadcast_service.py 2026-01-31 17:45:59 +03:00
Egor 4e9f01d439 Add files via upload 2026-01-31 17:45:19 +03:00
Egor 5dad41953a Merge pull request #2468 from BEDOLAGA-DEV/dev
Update channel_checker.py
2026-01-31 17:10:40 +03:00
Egor dd9b33af83 Update channel_checker.py 2026-01-31 17:10:04 +03:00
Egor fbf2325a42 Merge pull request #2467 from BEDOLAGA-DEV/dev
Dev
2026-01-31 17:06:05 +03:00
Egor 11eb27437b Update devices.py 2026-01-31 17:05:35 +03:00
Egor 1bb5ef85aa Update inline.py 2026-01-31 17:04:59 +03:00
Egor 35c5d78963 Merge pull request #2466 from BEDOLAGA-DEV/dev
Dev
2026-01-31 16:58:57 +03:00
Egor 38ff15e794 Update subscription.py 2026-01-31 16:58:32 +03:00
Egor 2992dfbada Update subscription_service.py 2026-01-31 16:58:01 +03:00
Egor a1e5a71ad3 Merge pull request #2465 from BEDOLAGA-DEV/dev
Update subscription_auto_purchase_service.py
2026-01-31 15:22:36 +03:00
Egor 28dbe3dca7 Update subscription_auto_purchase_service.py 2026-01-31 15:22:11 +03:00
Egor 49b48164fd Merge pull request #2464 from BEDOLAGA-DEV/dev
Update monitoring_service.py
2026-01-30 23:02:46 +03:00
Egor f688c74aee Update monitoring_service.py 2026-01-30 23:02:00 +03:00
Egor fe42548dd7 Merge pull request #2462 from BEDOLAGA-DEV/dev
Dev
2026-01-30 21:05:05 +03:00
Egor 7000cd5bc2 Update balance.py 2026-01-30 21:04:46 +03:00
Egor e3901c8d39 Add files via upload 2026-01-30 21:04:10 +03:00
Egor 23f72dc4ec Merge pull request #2461 from BEDOLAGA-DEV/dev
Dev
2026-01-30 20:44:07 +03:00
Egor 55d817bcad Update user_service.py 2026-01-30 20:43:45 +03:00
Egor 8a9994e539 Update user_service.py 2026-01-30 20:41:42 +03:00
Egor f050a62bc6 Merge pull request #2460 from BEDOLAGA-DEV/main
w
2026-01-30 20:40:52 +03:00
c0mrade e26464ddad Merge pull request #2459 from BEDOLAGA-DEV/feat/websocket-subscription-balance-notifications
feat/websocket subscription balance notifications
2026-01-30 20:12:45 +03:00
c0mrade 3263606702 fix: resolve circular import with lazy websocket imports
Move websocket notification imports inside functions to avoid
circular dependency when module is loaded.
2026-01-30 19:17:25 +03:00
c0mrade 86350424d5 feat(websocket): add real-time notifications for subscription and balance events
- Import and call notify_user_subscription_renewed in auto-extend flows
- Import and call notify_user_subscription_activated for new subscriptions
- Add WebSocket notifications to _auto_purchase_tariff and _auto_purchase_daily_tariff
- Add WebSocket notifications to auto_activate_subscription_after_topup
- Add notify_user_balance_topup call in payment common mixin
2026-01-30 19:04:44 +03:00
Egor d2ade1d9ed Merge pull request #2457 from BEDOLAGA-DEV/dev
Update subscription.py
2026-01-30 18:17:05 +03:00
Egor cc47cea268 Update subscription.py 2026-01-30 18:16:49 +03:00
Egor 6f420264f8 Merge pull request #2456 from BEDOLAGA-DEV/dev
Update user_cart_service.py
2026-01-30 17:47:39 +03:00
Egor 5949460572 Update user_cart_service.py 2026-01-30 17:46:55 +03:00
Egor 4330775d01 Merge pull request #2454 from BEDOLAGA-DEV/dev
Dev
2026-01-30 16:58:59 +03:00
Egor fa5c217dd0 Update heleket.py 2026-01-30 16:58:40 +03:00
Egor aa270c9ab4 Update subscription_auto_purchase_service.py 2026-01-30 16:58:12 +03:00
Egor 8b742082a4 Merge pull request #2453 from BEDOLAGA-DEV/dev
Dev
2026-01-30 16:09:42 +03:00
Egor 8078a4e64d Update remnawave_service.py 2026-01-30 16:09:13 +03:00
Egor 79f2cc0da5 Update .env.example 2026-01-30 16:07:47 +03:00
Egor 0c769ac16d Update config.py 2026-01-30 16:06:28 +03:00
Egor 25aba75413 Update user.py 2026-01-30 16:06:06 +03:00
Egor 9afe370a98 Add files via upload 2026-01-30 16:05:36 +03:00
Egor 6202801793 Update auth.py 2026-01-30 16:03:34 +03:00
Egor 4d48418b2c Update auth.py 2026-01-30 15:59:26 +03:00
Egor a21dfa75f0 Update email_service.py 2026-01-30 15:59:00 +03:00
Egor e09d9b6607 Update email_verification.py 2026-01-30 15:58:12 +03:00
Egor 68a734051d Merge pull request #2451 from BEDOLAGA-DEV/dev
Update remnawave_service.py
2026-01-29 02:03:15 +03:00
Egor 3f43371e60 Update remnawave_service.py 2026-01-29 02:02:59 +03:00
Egor cb8d233a16 Merge pull request #2450 from BEDOLAGA-DEV/dev
Dev
2026-01-29 01:29:57 +03:00
Egor d95aa7ca5c Rename SubscriptionResponse to SubscriptionData 2026-01-29 01:03:01 +03:00
Egor f7fc7d5cb0 Refactor subscription endpoint to return SubscriptionStatusResponse 2026-01-29 01:02:40 +03:00
Egor 5d6d3b962b Update remnawave_service.py 2026-01-29 01:02:18 +03:00
Egor 6bf8f85c80 Merge pull request #2449 from BEDOLAGA-DEV/dev
Improve payload management and subscription validation
2026-01-29 00:09:25 +03:00
Egor 0a254b1903 Improve payload management and subscription validation
Refactor payload handling and user subscription check logic.
2026-01-29 00:07:52 +03:00
Egor 9f33331618 Merge pull request #2448 from BEDOLAGA-DEV/dev
Dev
2026-01-28 20:17:18 +03:00
Egor 595ecff396 Update payment_method_config_service.py 2026-01-28 20:16:58 +03:00
Egor ad96e8951d Merge pull request #2447 from BEDOLAGA-DEV/main
ц
2026-01-28 20:16:17 +03:00
Mikhail 385e1b4287 Merge pull request #2446 from Gy9vin/main
fix
2026-01-28 13:55:37 +03:00
Mikhail 6551ac1fe9 Merge branch 'main' into main 2026-01-28 13:55:24 +03:00
Egor 327ba81d25 Merge pull request #2445 from BEDOLAGA-DEV/dev
Update remnawave_service.py
2026-01-28 12:48:51 +03:00
Egor bf65e16d4d Update remnawave_service.py 2026-01-28 12:48:29 +03:00
Egor 9cf24deb93 Update remnawave_service.py 2026-01-28 12:47:25 +03:00
Egor 3791f1db5a Merge pull request #2444 from BEDOLAGA-DEV/dev
Dev
2026-01-28 11:59:11 +03:00
Egor 0c3070a0cc Add Kassa AI payment method support 2026-01-28 11:56:02 +03:00
Egor a37ec7a308 Update payment_method_config_service.py 2026-01-28 11:55:29 +03:00
Egor b161a8604e Merge pull request #2443 from BEDOLAGA-DEV/dev
Dev
2026-01-28 11:46:47 +03:00
Egor 1e93f24f78 Add files via upload 2026-01-28 11:46:12 +03:00
Egor f8cd3076e9 Add files via upload 2026-01-28 11:43:22 +03:00
Egor e557504309 Implement panel_datetime_to_naive_utc function
Add function to convert panel datetime to naive UTC.
2026-01-28 11:42:45 +03:00
Egor 4602f72030 Update remnawave_service.py 2026-01-28 11:41:40 +03:00
Egor d811321808 Merge pull request #2442 from BEDOLAGA-DEV/dev
Dev
2026-01-28 11:07:08 +03:00
Egor dffb637e8d Update remnawave_service.py 2026-01-28 11:06:51 +03:00
Egor 32129bebc0 Merge pull request #2441 from BEDOLAGA-DEV/main
w
2026-01-28 11:06:01 +03:00
gy9vin dd5ee45ab5 Keep local customs over main 2026-01-27 23:51:07 +03:00
gy9vin 95b7152c05 касса и прочее 2026-01-27 23:47:39 +03:00
Egor aa093a074a Delete miniapp directory 2026-01-27 18:37:07 +03:00
49 changed files with 3590 additions and 23903 deletions
+2
View File
@@ -51,6 +51,8 @@ TEST_EMAIL_PASSWORD=
CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS=24
# Время жизни токена сброса пароля в часах
CABINET_PASSWORD_RESET_EXPIRE_HOURS=1
# Время жизни кода подтверждения смены email в минутах
CABINET_EMAIL_CHANGE_CODE_EXPIRE_MINUTES=15
# ===== SMTP НАСТРОЙКИ (для email в личном кабинете) =====
# SMTP сервер (например: smtp.gmail.com, smtp.yandex.ru)
+21
View File
@@ -6,6 +6,27 @@ from datetime import datetime, timedelta
from app.config import settings
def generate_email_change_code() -> str:
"""
Generate a 6-digit verification code for email change.
Returns:
6-digit numeric string
"""
return str(secrets.randbelow(900000) + 100000)
def get_email_change_expires_at() -> datetime:
"""
Get the expiration datetime for an email change code.
Returns:
Datetime when the email change code expires
"""
minutes = settings.get_cabinet_email_change_code_expire_minutes()
return datetime.utcnow() + timedelta(minutes=minutes)
def generate_verification_token() -> str:
"""
Generate a secure random verification token.
+289 -3
View File
@@ -4,7 +4,7 @@ import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy import distinct, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import BroadcastHistory, Subscription, SubscriptionStatus, Tariff, User
@@ -13,7 +13,9 @@ from app.keyboards.admin import BROADCAST_BUTTONS, DEFAULT_BROADCAST_BUTTONS
from app.services.broadcast_service import (
BroadcastConfig,
BroadcastMediaConfig,
EmailBroadcastConfig,
broadcast_service,
email_broadcast_service,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
@@ -28,6 +30,11 @@ from ..schemas.broadcasts import (
BroadcastPreviewResponse,
BroadcastResponse,
BroadcastTariffsResponse,
CombinedBroadcastCreateRequest,
EmailFilterItem,
EmailFiltersResponse,
EmailPreviewRequest,
EmailPreviewResponse,
TariffFilter,
TariffForBroadcast,
)
@@ -87,6 +94,25 @@ CUSTOM_FILTER_GROUPS = {
}
# ============ Email Filter Labels ============
EMAIL_FILTER_LABELS = {
'all_email': 'Все с email',
'email_only': 'Только email-регистрация',
'telegram_with_email': 'Telegram с email',
'active_email': 'С активной подпиской',
'expired_email': 'С истекшей подпиской',
}
EMAIL_FILTER_GROUPS = {
'all_email': 'basic',
'email_only': 'auth_type',
'telegram_with_email': 'auth_type',
'active_email': 'subscription',
'expired_email': 'subscription',
}
# ============ Helper Functions ============
@@ -113,9 +139,73 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
created_at=broadcast.created_at,
completed_at=broadcast.completed_at,
progress_percent=progress,
channel=getattr(broadcast, 'channel', 'telegram') or 'telegram',
email_subject=getattr(broadcast, 'email_subject', None),
email_html_content=getattr(broadcast, 'email_html_content', None),
)
async def _get_email_filter_count(db: AsyncSession, target: str) -> int:
"""Get count of email users matching the filter."""
base_conditions = [
User.email.isnot(None),
User.email_verified == True,
User.status == 'active',
]
if target == 'all_email':
query = select(func.count(User.id)).where(*base_conditions)
elif target == 'email_only':
query = select(func.count(User.id)).where(
*base_conditions,
User.auth_type == 'email',
)
elif target == 'telegram_with_email':
query = select(func.count(User.id)).where(
*base_conditions,
User.auth_type == 'telegram',
User.telegram_id.isnot(None),
)
elif target == 'active_email':
query = (
select(func.count(distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
*base_conditions,
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
)
elif target == 'expired_email':
query = (
select(func.count(distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
*base_conditions,
Subscription.status.in_(
[
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
]
),
)
)
else:
return 0
result = await db.execute(query)
return result.scalar() or 0
def _validate_email_target(target: str) -> bool:
"""Validate email target filter."""
return target in EMAIL_FILTER_LABELS
async def _get_tariff_user_counts(db: AsyncSession) -> dict:
"""Get count of active users per tariff."""
result = await db.execute(
@@ -388,6 +478,194 @@ async def list_broadcasts(
)
# ============ Email Broadcast Endpoints ============
@router.get('/email-filters', response_model=EmailFiltersResponse)
async def get_email_filters(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> EmailFiltersResponse:
"""Get all available email filters with user counts."""
filters = []
total_with_email = 0
for key, label in EMAIL_FILTER_LABELS.items():
try:
count = await _get_email_filter_count(db, key)
except Exception as e:
logger.warning(f'Failed to get count for email filter {key}: {e}')
count = 0
filters.append(
EmailFilterItem(
key=key,
label=label,
count=count,
group=EMAIL_FILTER_GROUPS.get(key),
)
)
# Track total with email (all_email filter)
if key == 'all_email':
total_with_email = count
return EmailFiltersResponse(
filters=filters,
total_with_email=total_with_email,
)
@router.post('/email-preview', response_model=EmailPreviewResponse)
async def preview_email_broadcast(
request: EmailPreviewRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> EmailPreviewResponse:
"""Preview email broadcast recipients count."""
if not _validate_email_target(request.target):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid email target: {request.target}',
)
try:
count = await _get_email_filter_count(db, request.target)
except Exception as e:
logger.error(f'Failed to get email count for target {request.target}: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to count email recipients',
)
return EmailPreviewResponse(target=request.target, count=count)
@router.post('/send', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_combined_broadcast(
request: CombinedBroadcastCreateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Create and start a combined broadcast (telegram/email/both)."""
# Get tariff IDs for target validation
result = await db.execute(select(Tariff.id))
tariff_ids = {row[0] for row in result.all()}
admin_name = admin.username or f'Admin #{admin.id}'
# Validate based on channel
if request.channel in ('telegram', 'both'):
# Validate telegram target
if not _validate_target(request.target, tariff_ids):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid target: {request.target}',
)
# Validate telegram message
if not request.message_text or not request.message_text.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Message text is required for Telegram broadcast',
)
# Validate buttons
if not _validate_buttons(request.selected_buttons):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid button key',
)
if request.channel in ('email', 'both'):
# For email channel, target must be email filter or we use telegram target for 'both'
if request.channel == 'email' and not _validate_email_target(request.target):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid email target: {request.target}',
)
# Validate email fields
if not request.email_subject or not request.email_subject.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Email subject is required for email broadcast',
)
if not request.email_html_content or not request.email_html_content.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Email HTML content is required for email broadcast',
)
media_payload = request.media
# Create broadcast record
broadcast = BroadcastHistory(
target_type=request.target,
message_text=request.message_text.strip() if request.message_text else None,
has_media=media_payload is not None,
media_type=media_payload.type if media_payload else None,
media_file_id=media_payload.file_id if media_payload else None,
media_caption=media_payload.caption if media_payload else None,
total_count=0,
sent_count=0,
failed_count=0,
status='queued',
admin_id=admin.id,
admin_name=admin_name,
channel=request.channel,
email_subject=request.email_subject.strip() if request.email_subject else None,
email_html_content=request.email_html_content.strip() if request.email_html_content else None,
)
db.add(broadcast)
await db.commit()
await db.refresh(broadcast)
# Start broadcasts based on channel
if request.channel in ('telegram', 'both'):
# Prepare media config
media_config = None
if media_payload:
media_config = BroadcastMediaConfig(
type=media_payload.type,
file_id=media_payload.file_id,
caption=media_payload.caption or request.message_text,
)
# Create telegram broadcast config
telegram_config = BroadcastConfig(
target=request.target,
message_text=request.message_text.strip(),
selected_buttons=request.selected_buttons,
media=media_config,
initiator_name=admin_name,
)
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
if request.channel in ('email', 'both'):
# For 'both' channel, we use 'all_email' as default email target
# since telegram target won't match email filters
email_target = request.target if request.channel == 'email' else 'all_email'
# Create email broadcast config
email_config = EmailBroadcastConfig(
target=email_target,
email_subject=request.email_subject.strip(),
email_html_content=request.email_html_content.strip(),
initiator_name=admin_name,
)
await email_broadcast_service.start_broadcast(broadcast.id, email_config)
await db.refresh(broadcast)
logger.info(f"Admin {admin.id} created {request.channel} broadcast {broadcast.id} for target '{request.target}'")
return _serialize_broadcast(broadcast)
@router.get('/{broadcast_id}', response_model=BroadcastResponse)
async def get_broadcast(
broadcast_id: int,
@@ -410,7 +688,7 @@ async def stop_broadcast(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Stop a running broadcast."""
"""Stop a running broadcast (telegram or email)."""
broadcast = await db.get(BroadcastHistory, broadcast_id)
if not broadcast:
raise HTTPException(
@@ -424,7 +702,15 @@ async def stop_broadcast(
detail='Broadcast is not running',
)
is_running = await broadcast_service.request_stop(broadcast_id)
# Try to stop both telegram and email broadcasts (one or both may be running)
channel = getattr(broadcast, 'channel', 'telegram') or 'telegram'
is_running = False
if channel in ('telegram', 'both'):
is_running = await broadcast_service.request_stop(broadcast_id) or is_running
if channel in ('email', 'both'):
is_running = await email_broadcast_service.request_stop(broadcast_id) or is_running
if is_running:
broadcast.status = 'cancelling'
+13 -19
View File
@@ -1,7 +1,7 @@
"""Admin routes for managing users in cabinet."""
import logging
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import Integer, and_, func, or_, select
@@ -32,6 +32,7 @@ from app.database.models import (
User,
UserStatus,
)
from app.utils.timezone import panel_datetime_to_naive_utc
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.users import (
@@ -275,6 +276,7 @@ async def list_users(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
search: str | None = Query(None, max_length=255),
email: str | None = Query(None, max_length=255),
status: UserStatusEnum | None = Query(None),
sort_by: SortByEnum = Query(SortByEnum.CREATED_AT),
admin: User = Depends(get_current_admin_user),
@@ -286,6 +288,7 @@ async def list_users(
- **offset**: Pagination offset
- **limit**: Number of users per page (max 200)
- **search**: Search by telegram_id, username, first_name, last_name
- **email**: Search by email
- **status**: Filter by user status (active, blocked, deleted)
- **sort_by**: Sort field (created_at, balance, traffic, last_activity, total_spent, purchase_count)
"""
@@ -306,6 +309,7 @@ async def list_users(
offset=offset,
limit=limit,
search=search,
email=email,
status=user_status,
order_by_balance=order_by_balance,
order_by_traffic=order_by_traffic,
@@ -314,7 +318,7 @@ async def list_users(
order_by_purchase_count=order_by_purchase_count,
)
total = await get_users_count(db=db, status=user_status, search=search)
total = await get_users_count(db=db, status=user_status, search=search, email=email)
# Get spending stats for all users
user_ids = [u.id for u in users]
@@ -1372,12 +1376,8 @@ async def get_user_sync_status(
bot_end_utc = (
bot_sub_end_date.replace(tzinfo=None) if bot_sub_end_date.tzinfo else bot_sub_end_date
)
# Panel dates might be timezone-aware, convert to UTC first
if panel_expire_at.tzinfo:
panel_end_utc = panel_expire_at.astimezone(UTC).replace(tzinfo=None)
else:
# Panel might return naive datetime in MSK (UTC+3), try both interpretations
panel_end_utc = panel_expire_at
# Panel returns local time with misleading +00:00 offset
panel_end_utc = panel_datetime_to_naive_utc(panel_expire_at)
diff_seconds = abs((bot_end_utc - panel_end_utc).total_seconds())
# Allow for timezone offset (3 hours = MSK) and small sync delays
@@ -1508,7 +1508,7 @@ async def sync_user_from_panel(
short_uuid=panel_user.short_uuid,
username=panel_user.username,
status=panel_user.status.value if panel_user.status else None,
expire_at=panel_user.expire_at,
expire_at=panel_datetime_to_naive_utc(panel_user.expire_at) if panel_user.expire_at else None,
traffic_limit_gb=panel_user.traffic_limit_bytes / (1024**3) if panel_user.traffic_limit_bytes else 0,
traffic_used_gb=panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes else 0,
device_limit=panel_user.hwid_device_limit or 1,
@@ -1527,11 +1527,8 @@ async def sync_user_from_panel(
# Update end date (normalize timezone)
if panel_user.expire_at:
# Convert panel expire_at to naive UTC for storage
if panel_user.expire_at.tzinfo:
panel_expire_utc = panel_user.expire_at.astimezone(UTC).replace(tzinfo=None)
else:
panel_expire_utc = panel_user.expire_at
# Panel returns local time with misleading +00:00 offset
panel_expire_utc = panel_datetime_to_naive_utc(panel_user.expire_at)
sub_end_naive = (
sub.end_date.replace(tzinfo=None) if sub.end_date and sub.end_date.tzinfo else sub.end_date
@@ -1602,11 +1599,8 @@ async def sync_user_from_panel(
panel_traffic_limit = (
int(panel_user.traffic_limit_bytes / (1024**3)) if panel_user.traffic_limit_bytes else 100
)
# Normalize panel expire date for calculation
if panel_user.expire_at.tzinfo:
panel_expire_naive = panel_user.expire_at.astimezone(UTC).replace(tzinfo=None)
else:
panel_expire_naive = panel_user.expire_at
# Panel returns local time with misleading +00:00 offset
panel_expire_naive = panel_datetime_to_naive_utc(panel_user.expire_at)
days_remaining = max(1, (panel_expire_naive - datetime.utcnow()).days)
new_sub = await create_paid_subscription(
+168 -17
View File
@@ -11,14 +11,19 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.user import (
clear_email_change_pending,
create_user,
create_user_by_email,
get_user_by_id,
get_user_by_referral_code,
get_user_by_telegram_id,
is_email_taken,
set_email_change_pending,
verify_and_apply_email_change,
)
from app.database.models import CabinetRefreshToken, User
from app.services.referral_service import process_referral_registration
from app.utils.timezone import panel_datetime_to_naive_utc
from ..auth import (
create_access_token,
@@ -30,8 +35,10 @@ from ..auth import (
verify_password,
)
from ..auth.email_verification import (
generate_email_change_code,
generate_password_reset_token,
generate_verification_token,
get_email_change_expires_at,
get_password_reset_expires_at,
get_verification_expires_at,
is_token_expired,
@@ -40,6 +47,9 @@ from ..auth.jwt_handler import get_refresh_token_expires_at
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.auth import (
AuthResponse,
EmailChangeRequest,
EmailChangeResponse,
EmailChangeVerifyRequest,
EmailLoginRequest,
EmailRegisterRequest,
EmailRegisterStandaloneRequest,
@@ -162,8 +172,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
existing_sub = await get_subscription_by_user_id(db, user.id)
# Parse panel data
expire_at = panel_user.expire_at
# Parse panel data — panel returns local time with misleading +00:00 offset
expire_at = panel_datetime_to_naive_utc(panel_user.expire_at)
traffic_limit_gb = panel_user.traffic_limit_bytes // (1024**3) if panel_user.traffic_limit_bytes > 0 else 0
traffic_used_gb = panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes > 0 else 0
@@ -173,11 +183,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
# Device limit from panel
device_limit = panel_user.hwid_device_limit or 1
# Determine status - use timezone-aware datetime for comparison
current_time = datetime.now(UTC)
# Make expire_at timezone-aware if it's naive
if expire_at.tzinfo is None:
expire_at = expire_at.replace(tzinfo=UTC)
# Determine status — expire_at is now naive UTC
current_time = datetime.now(UTC).replace(tzinfo=None)
if panel_user.status.value == 'ACTIVE' and expire_at > current_time:
sub_status = SubscriptionStatus.ACTIVE
@@ -187,10 +194,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
sub_status = SubscriptionStatus.DISABLED
if existing_sub:
# Update existing subscription
# Convert to naive datetime for database storage
end_date_naive = expire_at.replace(tzinfo=None) if expire_at.tzinfo else expire_at
existing_sub.end_date = end_date_naive
# Update existing subscription (expire_at already naive UTC)
existing_sub.end_date = expire_at
existing_sub.traffic_limit_gb = traffic_limit_gb
existing_sub.traffic_used_gb = traffic_used_gb
existing_sub.status = sub_status.value
@@ -204,14 +209,11 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
f'Updated subscription for email user {user.email}, squads: {connected_squads}, devices: {device_limit}'
)
else:
# Create new subscription
# Convert current_time to naive for database storage if needed
start_date_naive = current_time.replace(tzinfo=None)
end_date_naive = expire_at.replace(tzinfo=None) if expire_at.tzinfo else expire_at
# Create new subscription (expire_at and current_time already naive UTC)
new_sub = Subscription(
user_id=user.id,
start_date=start_date_naive,
end_date=end_date_naive,
start_date=current_time,
end_date=expire_at,
traffic_limit_gb=traffic_limit_gb,
traffic_used_gb=traffic_used_gb,
status=sub_status.value,
@@ -941,3 +943,152 @@ async def check_is_admin(
"""Check if current user is an admin."""
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
return {'is_admin': is_admin}
@router.post('/email/change', response_model=EmailChangeResponse)
async def request_email_change(
request: EmailChangeRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Request email change.
Sends a 6-digit verification code to the new email address.
User must have a verified email to change it.
"""
# Check if user has a verified email
if not user.email or not user.email_verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='You must have a verified email to change it',
)
# Check if new email is the same as current
if request.new_email.lower() == user.email.lower():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='New email is the same as current email',
)
# Check if new email is already taken
if await is_email_taken(db, request.new_email, exclude_user_id=user.id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This email is already registered',
)
# Generate verification code
code = generate_email_change_code()
expires_at = get_email_change_expires_at()
expire_minutes = settings.get_cabinet_email_change_code_expire_minutes()
# Save pending email change
await set_email_change_pending(db, user, request.new_email, code, expires_at)
# Send verification email to new address
if email_service.is_configured():
lang = user.language or 'ru'
# Check for admin template override
override = await get_rendered_override(
'email_change_code',
lang,
context={
'username': user.first_name or '',
'code': code,
'expire_minutes': str(expire_minutes),
},
db=db,
)
custom_subject, custom_body = override if override else (None, None)
await asyncio.to_thread(
email_service.send_email_change_code,
to_email=request.new_email,
code=code,
username=user.first_name,
language=lang,
custom_subject=custom_subject,
custom_body_html=custom_body,
)
else:
# Clear pending change if email service is not configured
await clear_email_change_pending(db, user)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Email service is not configured',
)
logger.info(f'Email change requested for user {user.id}: {user.email} -> {request.new_email}')
return EmailChangeResponse(
message='Verification code sent to new email',
new_email=request.new_email,
expires_in_minutes=expire_minutes,
)
@router.post('/email/change/verify')
async def verify_email_change(
request: EmailChangeVerifyRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Verify email change with code.
Completes the email change process if the code is valid.
"""
success, message = await verify_and_apply_email_change(db, user, request.code)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=message,
)
return {
'message': message,
'new_email': user.email,
}
@router.post('/email/change/cancel')
async def cancel_email_change(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Cancel pending email change.
"""
if not user.email_change_new:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='No pending email change',
)
await clear_email_change_pending(db, user)
return {'message': 'Email change cancelled'}
@router.get('/email/change/status')
async def get_email_change_status(
user: User = Depends(get_current_cabinet_user),
):
"""
Get pending email change status.
"""
if not user.email_change_new:
return {
'pending': False,
'new_email': None,
'expires_at': None,
}
return {
'pending': True,
'new_email': user.email_change_new,
'expires_at': user.email_change_expires.isoformat() if user.email_change_expires else None,
}
+105 -126
View File
@@ -13,6 +13,7 @@ from app.config import settings
from app.database.crud.user import get_user_by_id
from app.database.models import PaymentMethod, Transaction, User
from app.external.cryptobot import CryptoBotService
from app.services.payment_method_config_service import get_enabled_methods_for_user
from app.services.payment_service import PaymentService
from app.services.payment_verification_service import (
SUPPORTED_MANUAL_CHECK_METHODS,
@@ -127,134 +128,82 @@ async def get_transactions(
)
async def _get_available_payment_methods(
db: AsyncSession,
user: User,
) -> list[PaymentMethodResponse]:
"""Get available payment methods filtered by DB config and user context.
@router.get('/payment-methods', response_model=list[PaymentMethodResponse])
async def get_payment_methods(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get available payment methods for the current user.
Combines env-var availability with DB-based admin config (ordering, display conditions).
Uses PaymentMethodConfig from database for:
- Sort order (sort_order)
- Enabled/disabled status (is_enabled)
- Display names (display_name with fallback to env)
- Min/max amounts (with fallback to env defaults)
- Sub-options filtering (sub_options)
- User filters (user_type_filter, first_topup_filter, promo_group_filter)
"""
from app.services.payment_method_config_service import (
_get_method_defaults,
get_all_configs,
# Check if this is user's first topup
from sqlalchemy import exists
has_completed_topup = await db.execute(
select(
exists().where(
Transaction.user_id == user.id,
Transaction.type == 'deposit',
Transaction.is_completed == True,
)
)
)
is_first_topup = not has_completed_topup.scalar()
configs = await get_all_configs(db)
defaults = _get_method_defaults()
# Get enabled methods from database config
enabled_methods = await get_enabled_methods_for_user(db, user=user, is_first_topup=is_first_topup)
# Provider availability checks from env vars
provider_enabled = {
'telegram_stars': settings.TELEGRAM_STARS_ENABLED,
'tribute': settings.TRIBUTE_ENABLED and bool(getattr(settings, 'TRIBUTE_DONATE_LINK', '')),
'cryptobot': settings.is_cryptobot_enabled(),
'heleket': settings.is_heleket_enabled(),
'yookassa': settings.is_yookassa_enabled(),
'mulenpay': settings.is_mulenpay_enabled(),
'pal24': settings.is_pal24_enabled(),
'platega': settings.is_platega_enabled(),
'wata': settings.is_wata_enabled(),
'freekassa': settings.is_freekassa_enabled(),
'cloudpayments': settings.is_cloudpayments_enabled(),
}
# Build response with additional options formatting
methods = []
for method_data in enabled_methods:
method_id = method_data['id']
# Default options builder (for methods with sub-options)
def _build_options(method_id: str, config_sub_options: dict | None) -> list[dict] | None:
if method_id == 'yookassa':
all_opts = [
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей (QR)'},
]
elif method_id == 'pal24':
all_opts = [
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей'},
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
]
elif method_id == 'platega':
platega_methods = settings.get_platega_active_methods()
definitions = settings.get_platega_method_definitions()
all_opts = []
for method_code in platega_methods:
info = definitions.get(method_code, {})
all_opts.append(
# Format options with descriptions for specific methods
options = method_data.get('options')
if options:
formatted_options = []
for opt in options:
opt_id = opt['id']
opt_name = opt.get('name', opt_id)
description = ''
# Add descriptions based on method and option
if method_id in ('yookassa', 'pal24', 'cloudpayments', 'freekassa'):
if opt_id == 'card':
opt_name = f'💳 {opt_name}'
description = 'Банковская карта'
elif opt_id == 'sbp':
opt_name = f'🏦 {opt_name}'
description = 'Система быстрых платежей'
elif method_id == 'platega':
# Platega options already have descriptions from config
definitions = settings.get_platega_method_definitions()
info = definitions.get(int(opt_id), {}) if opt_id.isdigit() else {}
description = info.get('description') or info.get('name') or ''
formatted_options.append(
{
'id': str(method_code),
'name': info.get('title') or info.get('name') or f'Platega {method_code}',
'description': info.get('description') or info.get('name') or '',
'id': opt_id,
'name': opt_name,
'description': description,
}
)
elif method_id == 'freekassa':
all_opts = [
{'id': 'sbp', 'name': '🏦 NSPK СБП', 'description': 'Система быстрых платежей'},
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
]
elif method_id == 'cloudpayments':
all_opts = [
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей'},
]
else:
return None
if not all_opts:
return None
# Filter by sub_options config from DB
if config_sub_options:
all_opts = [o for o in all_opts if config_sub_options.get(o['id'], True)]
return all_opts if all_opts else None
# User promo group IDs for filtering
user_promo_group_ids: set[int] = set()
if hasattr(user, 'user_promo_groups') and user.user_promo_groups:
for upg in user.user_promo_groups:
user_promo_group_ids.add(upg.promo_group_id)
if hasattr(user, 'promo_group_id') and user.promo_group_id:
user_promo_group_ids.add(user.promo_group_id)
methods = []
for config in configs:
mid = config.method_id
# 1. Check env-var provider availability AND DB admin toggle
if not provider_enabled.get(mid, False):
continue
if not config.is_enabled:
continue
# 2. Check user type filter
if config.user_type_filter == 'telegram' and user.auth_type != 'telegram':
continue
if config.user_type_filter == 'email' and user.auth_type != 'email':
continue
# 3. Check first topup filter
if config.first_topup_filter == 'yes' and not user.has_made_first_topup:
continue
if config.first_topup_filter == 'no' and user.has_made_first_topup:
continue
# 4. Check promo group filter
if config.promo_group_filter_mode == 'selected' and config.allowed_promo_groups:
allowed_ids = {pg.id for pg in config.allowed_promo_groups}
if not user_promo_group_ids.intersection(allowed_ids):
continue
# Build the response
method_def = defaults.get(mid, {})
display_name = config.display_name or method_def.get('default_display_name', mid)
min_amount = config.min_amount_kopeks or method_def.get('default_min', 1000)
max_amount = config.max_amount_kopeks or method_def.get('default_max', 10000000)
options = _build_options(mid, config.sub_options)
options = formatted_options if formatted_options else None
methods.append(
PaymentMethodResponse(
id=mid,
name=display_name,
id=method_id,
name=method_data['name'],
description=None,
min_amount_kopeks=min_amount,
max_amount_kopeks=max_amount,
min_amount_kopeks=method_data['min_amount_kopeks'],
max_amount_kopeks=method_data['max_amount_kopeks'],
is_available=True,
options=options,
)
@@ -263,15 +212,6 @@ async def _get_available_payment_methods(
return methods
@router.get('/payment-methods', response_model=list[PaymentMethodResponse])
async def get_payment_methods(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get available payment methods."""
return await _get_available_payment_methods(db, user)
@router.post('/stars-invoice', response_model=StarsInvoiceResponse)
async def create_stars_invoice(
request: StarsInvoiceRequest,
@@ -373,7 +313,7 @@ async def create_topup(
):
"""Create payment for balance top-up."""
# Validate payment method
methods = await _get_available_payment_methods(db, user)
methods = await get_payment_methods(user=user, db=db)
method = next((m for m in methods if m.id == request.payment_method), None)
if not method or not method.is_available:
@@ -700,6 +640,32 @@ async def create_topup(
detail='Failed to create FreeKassa payment',
)
elif request.payment_method == 'kassa_ai':
if not settings.is_kassa_ai_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='KassaAI payment method is unavailable',
)
payment_service = PaymentService()
result = await payment_service.create_kassa_ai_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create KassaAI payment',
)
elif request.payment_method == 'tribute':
if not settings.TRIBUTE_ENABLED or not settings.TRIBUTE_DONATE_LINK:
raise HTTPException(
@@ -840,6 +806,17 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
}
return mapping.get(status, ('', 'Неизвестно'))
if record.method == PaymentMethod.KASSA_AI:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'success': ('', 'Оплачено'),
'paid': ('', 'Оплачено'),
'canceled': ('', 'Отменено'),
'failed': ('', 'Ошибка'),
'expired': ('', 'Истёк'),
}
return mapping.get(status, ('', 'Неизвестно'))
return '', 'Неизвестно'
@@ -868,6 +845,8 @@ def _is_checkable(record: PendingPayment) -> bool:
return status in {'pending', 'authorized'}
if record.method == PaymentMethod.FREEKASSA:
return status in {'pending', 'created', 'processing'}
if record.method == PaymentMethod.KASSA_AI:
return status in {'pending', 'created', 'processing'}
return False
@@ -891,7 +870,7 @@ def _get_payment_url(record: PendingPayment) -> str | None:
)
elif record.method == PaymentMethod.PLATEGA:
payment_url = getattr(payment, 'redirect_url', None) or payment_url
elif record.method == PaymentMethod.CLOUDPAYMENTS or record.method == PaymentMethod.FREEKASSA:
elif record.method in (PaymentMethod.CLOUDPAYMENTS, PaymentMethod.FREEKASSA, PaymentMethod.KASSA_AI):
payment_url = getattr(payment, 'payment_url', None) or payment_url
return payment_url
+370 -23
View File
@@ -48,7 +48,9 @@ from ..schemas.subscription import (
RenewalOptionResponse,
RenewalRequest,
ServerInfo,
SubscriptionData,
SubscriptionResponse,
SubscriptionStatusResponse,
TariffPurchaseRequest,
TrafficPackageResponse,
TrafficPurchaseRequest,
@@ -66,7 +68,7 @@ def _subscription_to_response(
servers: list[ServerInfo] | None = None,
tariff_name: str | None = None,
traffic_purchases: list[dict[str, Any]] | None = None,
) -> SubscriptionResponse:
) -> SubscriptionData:
"""Convert Subscription model to response."""
now = datetime.utcnow()
@@ -171,7 +173,7 @@ def _subscription_to_response(
)
@router.get('', response_model=SubscriptionResponse)
@router.get('', response_model=SubscriptionStatusResponse)
async def get_subscription(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
@@ -184,10 +186,8 @@ async def get_subscription(
fresh_user = await get_user_by_id(db, user.id)
if not fresh_user or not fresh_user.subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
# Return 200 with has_subscription: false instead of 404
return SubscriptionStatusResponse(has_subscription=False, subscription=None)
# Load tariff for daily subscription check and tariff name
tariff_name = None
@@ -241,7 +241,8 @@ async def get_subscription(
}
)
return _subscription_to_response(fresh_user.subscription, servers, tariff_name, traffic_purchases_data)
subscription_data = _subscription_to_response(fresh_user.subscription, servers, tariff_name, traffic_purchases_data)
return SubscriptionStatusResponse(has_subscription=True, subscription=subscription_data)
@router.get('/renewal-options', response_model=list[RenewalOptionResponse])
@@ -665,9 +666,35 @@ async def purchase_traffic(
# Проверяем баланс
if user.balance_kopeks < final_price:
missing = final_price - user.balance_kopeks
# Save cart for auto-purchase after balance top-up
cart_data = {
'cart_mode': 'add_traffic',
'subscription_id': subscription.id,
'traffic_gb': request.gb,
'price_kopeks': final_price,
'base_price_kopeks': base_price_kopeks,
'discount_percent': traffic_discount_percent,
'source': 'cabinet',
'description': f'Докупка {request.gb} ГБ трафика',
}
try:
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f'Cart saved for traffic purchase (cabinet) user {user.id}: +{request.gb} GB')
except Exception as e:
logger.error(f'Error saving cart for traffic purchase (cabinet): {e}')
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=f'Insufficient balance. Need {final_price / 100:.2f} RUB, have {user.balance_kopeks / 100:.2f} RUB',
detail={
'code': 'insufficient_funds',
'message': f'Недостаточно средств. Не хватает {settings.format_price(missing)}',
'missing_amount': missing,
'cart_saved': True,
'cart_mode': 'add_traffic',
},
)
# Формируем описание
@@ -723,6 +750,31 @@ async def purchase_traffic(
await db.refresh(user)
await db.refresh(subscription)
# Отправляем уведомление админам
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
old_traffic = subscription.traffic_limit_gb - request.gb
await notification_service.send_subscription_update_notification(
db=db,
user=user,
subscription=subscription,
update_type='traffic',
old_value=old_traffic,
new_value=subscription.traffic_limit_gb,
price_paid=final_price,
)
finally:
await bot.session.close()
except Exception as e:
logger.error(f'Failed to send admin notification for traffic purchase: {e}')
return {
'success': True,
'message': 'Traffic purchased successfully',
@@ -754,9 +806,31 @@ async def purchase_devices(
# Check balance
if user.balance_kopeks < total_price:
missing = total_price - user.balance_kopeks
# Сохраняем корзину для автопокупки после пополнения
try:
cart_data = {
'cart_mode': 'add_devices',
'devices_to_add': request.devices,
'price_kopeks': total_price,
'source': 'cabinet',
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f'Cart saved for device purchase (cabinet /devices) user {user.id}: +{request.devices} devices')
except Exception as e:
logger.error(f'Error saving cart for device purchase (cabinet /devices): {e}')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Insufficient balance',
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_funds',
'error': 'Insufficient balance',
'required_kopeks': total_price,
'current_kopeks': user.balance_kopeks,
'missing_kopeks': missing,
'cart_saved': True,
},
)
# Check max devices limit
@@ -770,11 +844,48 @@ async def purchase_devices(
detail=f'Maximum device limit is {max_devices}',
)
# Deduct balance and add devices
user.balance_kopeks -= total_price
# Deduct balance and create transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import PaymentMethod
await subtract_user_balance(
db=db,
user=user,
amount_kopeks=total_price,
description=f'Покупка {request.devices} доп. устройств',
create_transaction=True,
payment_method=PaymentMethod.BALANCE,
)
# Add devices
user.subscription.device_limit = new_devices
await db.commit()
await db.refresh(user)
# Отправляем уведомление админам
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_update_notification(
db=db,
user=user,
subscription=user.subscription,
update_type='devices',
old_value=current_devices,
new_value=new_devices,
price_paid=total_price,
)
finally:
await bot.session.close()
except Exception as e:
logger.error(f'Failed to send admin notification for device purchase: {e}')
return {
'message': 'Devices added successfully',
@@ -1666,8 +1777,6 @@ async def purchase_tariff(
# Save cart for auto-renewal (not for daily tariffs - they have their own charging)
if not is_daily_tariff:
try:
from app.services.user_cart_service import user_cart_service
cart_data = {
'cart_mode': 'extend',
'subscription_id': subscription.id,
@@ -1828,24 +1937,43 @@ async def purchase_devices(
# Check balance
if user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
# Сохраняем корзину для автопокупки после пополнения
try:
cart_data = {
'cart_mode': 'add_devices',
'devices_to_add': request.devices,
'price_kopeks': price_kopeks,
'source': 'cabinet',
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f'Cart saved for device purchase (cabinet) user {user.id}: +{request.devices} devices')
except Exception as e:
logger.error(f'Error saving cart for device purchase (cabinet): {e}')
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_funds',
'error': 'Insufficient balance',
'required_kopeks': price_kopeks,
'current_kopeks': user.balance_kopeks,
'missing_kopeks': missing,
'cart_saved': True,
},
)
# Deduct balance
# Deduct balance and create transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import PaymentMethod
await subtract_user_balance(
db=db,
user=user,
amount_kopeks=price_kopeks,
description=f'Покупка {request.devices} доп. устройств',
create_transaction=True,
payment_method=PaymentMethod.BALANCE,
)
# Increase device limit
@@ -1867,6 +1995,30 @@ async def purchase_devices(
logger.info(f'User {user.id} purchased {request.devices} devices for {price_kopeks} kopeks')
# Отправляем уведомление админам
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_update_notification(
db=db,
user=user,
subscription=subscription,
update_type='devices',
old_value=current_devices,
new_value=subscription.device_limit,
price_paid=price_kopeks,
)
finally:
await bot.session.close()
except Exception as e:
logger.error(f'Failed to send admin notification for device purchase: {e}')
return {
'success': True,
'message': f'Добавлено {request.devices} устройств',
@@ -1888,6 +2040,197 @@ async def purchase_devices(
)
@router.post('/traffic/save-cart')
async def save_traffic_cart(
request: TrafficPurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, bool]:
"""Save cart for traffic purchase (for insufficient balance flow)."""
from app.utils.pricing_utils import calculate_prorated_price
await db.refresh(user, ['subscription'])
subscription = user.subscription
if not subscription:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='У вас нет активной подписки',
)
if subscription.status not in ['active', 'trial']:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Ваша подписка неактивна',
)
if subscription.is_trial:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Докупка трафика недоступна на пробном периоде',
)
if subscription.traffic_limit_gb == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='У вас уже безлимитный трафик',
)
# Get traffic price from tariff or settings
tariff = None
base_price_kopeks = 0
is_tariff_mode = settings.is_tariffs_mode() and subscription.tariff_id
if is_tariff_mode:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Тариф не найден',
)
if not getattr(tariff, 'traffic_topup_enabled', False):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Докупка трафика недоступна на вашем тарифе',
)
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
if request.gb not in packages:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Пакет трафика {request.gb} ГБ недоступен',
)
base_price_kopeks = packages[request.gb]
else:
if not settings.is_traffic_topup_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Докупка трафика отключена',
)
packages = settings.get_traffic_packages()
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
if not matching_pkg:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Недоступный пакет трафика',
)
base_price_kopeks = matching_pkg['price']
# Apply promo group discount
traffic_discount_percent = 0
promo_group = (
user.get_primary_promo_group()
if hasattr(user, 'get_primary_promo_group')
else getattr(user, 'promo_group', None)
)
if promo_group:
apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True)
if apply_to_addons:
traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0)))
if traffic_discount_percent > 0:
base_price_kopeks = int(base_price_kopeks * (100 - traffic_discount_percent) / 100)
# Calculate prorated price
final_price, _ = calculate_prorated_price(
base_price_kopeks,
subscription.end_date,
)
# Save cart for auto-purchase after balance top-up
cart_data = {
'cart_mode': 'add_traffic',
'subscription_id': subscription.id,
'traffic_gb': request.gb,
'price_kopeks': final_price,
'base_price_kopeks': base_price_kopeks,
'discount_percent': traffic_discount_percent,
'source': 'cabinet',
'description': f'Докупка {request.gb} ГБ трафика',
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f'Cart saved for traffic purchase (cabinet save-cart) user {user.id}: +{request.gb} GB')
return {'success': True, 'cart_saved': True}
@router.post('/devices/save-cart')
async def save_devices_cart(
request: DevicePurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, bool]:
"""Save cart for device purchase (for insufficient balance flow)."""
await db.refresh(user, ['subscription'])
subscription = user.subscription
if not subscription:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='У вас нет активной подписки',
)
if subscription.status not in ['active', 'trial']:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Ваша подписка неактивна',
)
# Get tariff for device price (if exists)
tariff = None
if subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
# Determine device price and max limit from tariff or settings
if tariff and tariff.device_price_kopeks:
device_price = tariff.device_price_kopeks
max_device_limit = tariff.max_device_limit
else:
device_price = settings.PRICE_PER_DEVICE
max_device_limit = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
if not device_price or device_price <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Докупка устройств недоступна',
)
# Check max device limit
current_devices = subscription.device_limit or 1
new_device_count = current_devices + request.devices
if max_device_limit and new_device_count > max_device_limit:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Максимальное количество устройств: {max_device_limit}',
)
# Calculate prorated price based on remaining days
now = datetime.now(UTC)
end_date = subscription.end_date
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
days_left = max(1, (end_date - now).days)
total_days = 30
price_kopeks = int(device_price * request.devices * days_left / total_days)
price_kopeks = max(100, price_kopeks) # Minimum 1 ruble
# Save cart for auto-purchase after balance top-up
cart_data = {
'cart_mode': 'add_devices',
'devices_to_add': request.devices,
'price_kopeks': price_kopeks,
'source': 'cabinet',
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f'Cart saved for device purchase (cabinet save-cart) user {user.id}: +{request.devices} devices')
return {'success': True, 'cart_saved': True}
@router.get('/devices/price')
async def get_device_price(
devices: int = 1,
@@ -3285,6 +3628,11 @@ async def toggle_subscription_pause(
new_paused_state = not is_currently_paused
user.subscription.is_daily_paused = new_paused_state
# Сохраняем статус ДО изменения для проверки RemnaWave
from app.database.models import SubscriptionStatus
was_disabled = user.subscription.status == SubscriptionStatus.DISABLED.value
# If resuming, check balance
if not new_paused_state:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
@@ -3300,9 +3648,7 @@ async def toggle_subscription_pause(
)
# Restore ACTIVE status if was DISABLED
from app.database.models import SubscriptionStatus
if user.subscription.status == SubscriptionStatus.DISABLED.value:
if was_disabled:
user.subscription.status = SubscriptionStatus.ACTIVE.value
user.subscription.last_daily_charge_at = datetime.utcnow()
user.subscription.end_date = datetime.utcnow() + timedelta(days=1)
@@ -3311,14 +3657,15 @@ async def toggle_subscription_pause(
await db.refresh(user.subscription)
await db.refresh(user)
# Sync with RemnaWave when resuming
if not new_paused_state:
# Sync with RemnaWave only when resuming from DISABLED state
# При паузе НЕ отключаем - пользователь может пользоваться до конца оплаченного периода
# При возобновлении включаем только если подписка была отключена (DISABLED)
if not new_paused_state and user.remnawave_uuid and was_disabled:
try:
subscription_service = SubscriptionService()
if user.remnawave_uuid:
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
except Exception as e:
logger.error(f'Error syncing with RemnaWave on resume: {e}')
logger.error(f'Error enabling RemnaWave user on resume: {e}')
if new_paused_state:
message = 'Daily subscription paused'
+38
View File
@@ -374,6 +374,44 @@ async def notify_user_subscription_renewed(
)
async def notify_user_devices_purchased(
user_id: int,
devices_added: int,
new_device_limit: int,
amount_kopeks: int,
) -> None:
"""Уведомить пользователя о покупке устройств."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.devices_purchased',
'devices_added': devices_added,
'new_device_limit': new_device_limit,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
},
)
async def notify_user_traffic_purchased(
user_id: int,
traffic_gb_added: int,
new_traffic_limit_gb: int,
amount_kopeks: int,
) -> None:
"""Уведомить пользователя о покупке трафика."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.traffic_purchased',
'traffic_gb_added': traffic_gb_added,
'new_traffic_limit_gb': new_traffic_limit_gb,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
},
)
# ============================================================================
# Уведомления об автопродлении
# ============================================================================
+20
View File
@@ -118,3 +118,23 @@ class RegisterResponse(BaseModel):
message: str = Field(..., description='Success message')
email: str = Field(..., description='Email address to verify')
requires_verification: bool = Field(True, description='Whether email verification is required')
class EmailChangeRequest(BaseModel):
"""Request to initiate email change."""
new_email: EmailStr = Field(..., description='New email address')
class EmailChangeVerifyRequest(BaseModel):
"""Request to verify email change with code."""
code: str = Field(..., min_length=6, max_length=6, description='6-digit verification code')
class EmailChangeResponse(BaseModel):
"""Response for email change initiation."""
message: str = Field(..., description='Success message')
new_email: str = Field(..., description='New email address pending verification')
expires_in_minutes: int = Field(..., description='Code expiration time in minutes')
+66 -1
View File
@@ -1,10 +1,16 @@
"""Pydantic schemas for cabinet broadcasts."""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
# ============ Channel Types ============
BroadcastChannel = Literal['telegram', 'email', 'both']
# ============ Filters ============
@@ -100,7 +106,7 @@ class BroadcastResponse(BaseModel):
id: int
target_type: str
message_text: str
message_text: str | None = None
has_media: bool
media_type: str | None = None
media_file_id: str | None = None
@@ -115,6 +121,11 @@ class BroadcastResponse(BaseModel):
completed_at: datetime | None = None
progress_percent: float = 0.0
# Email/channel fields
channel: str = 'telegram' # telegram|email|both
email_subject: str | None = None
email_html_content: str | None = None
class Config:
from_attributes = True
@@ -142,3 +153,57 @@ class BroadcastPreviewResponse(BaseModel):
target: str
count: int
# ============ Email Filters ============
class EmailFilterItem(BaseModel):
"""Single email filter with count."""
key: str
label: str
count: int
group: str | None = None
class EmailFiltersResponse(BaseModel):
"""Response with all email filters and their counts."""
filters: list[EmailFilterItem]
total_with_email: int
# ============ Combined Broadcast ============
class CombinedBroadcastCreateRequest(BaseModel):
"""Request to create a combined (telegram/email/both) broadcast."""
channel: BroadcastChannel
target: str
# Telegram-specific fields
message_text: str | None = Field(default=None, max_length=4000)
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
media: BroadcastMediaRequest | None = None
# Email-specific fields
email_subject: str | None = Field(default=None, max_length=255)
email_html_content: str | None = Field(default=None, max_length=100000)
# ============ Email Preview ============
class EmailPreviewRequest(BaseModel):
"""Request to preview email broadcast recipients."""
target: str
class EmailPreviewResponse(BaseModel):
"""Preview response for email broadcast."""
target: str
count: int
+12 -1
View File
@@ -24,7 +24,7 @@ class TrafficPurchaseInfo(BaseModel):
progress_percent: float
class SubscriptionResponse(BaseModel):
class SubscriptionData(BaseModel):
"""User subscription data."""
id: int
@@ -61,6 +61,17 @@ class SubscriptionResponse(BaseModel):
from_attributes = True
# Backward compatibility alias
SubscriptionResponse = SubscriptionData
class SubscriptionStatusResponse(BaseModel):
"""Response for subscription status endpoint - handles users with and without subscription."""
has_subscription: bool
subscription: SubscriptionData | None = None
class RenewalOptionResponse(BaseModel):
"""Available subscription renewal option."""
+116
View File
@@ -336,6 +336,122 @@ class EmailService:
return self.send_email(to_email, subject, body_html)
def send_email_change_code(
self,
to_email: str,
code: str,
username: str | None = None,
language: str = 'ru',
custom_subject: str | None = None,
custom_body_html: str | None = None,
) -> bool:
"""
Send email change verification code.
Args:
to_email: New email address
code: 6-digit verification code
username: User's name for personalization
language: Language code (ru, en, zh, ua)
custom_subject: Override subject from admin template
custom_body_html: Override body HTML from admin template
Returns:
True if email was sent successfully, False otherwise
"""
if custom_subject and custom_body_html:
return self.send_email(to_email, custom_subject, custom_body_html)
expire_minutes = settings.get_cabinet_email_change_code_expire_minutes()
texts = {
'ru': {
'greeting': f'Здравствуйте{", " + username if username else ""}!',
'subject': 'Код подтверждения для смены email',
'intro': 'Вы запросили смену email адреса. Используйте код ниже для подтверждения:',
'code_label': 'Ваш код подтверждения:',
'expires': f'Код действителен в течение {expire_minutes} минут.',
'ignore': 'Если вы не запрашивали смену email, просто проигнорируйте это письмо.',
'regards': 'С уважением,',
},
'en': {
'greeting': f'Hello{", " + username if username else ""}!',
'subject': 'Email change verification code',
'intro': 'You requested to change your email address. Use the code below to confirm:',
'code_label': 'Your verification code:',
'expires': f'This code will expire in {expire_minutes} minutes.',
'ignore': "If you didn't request an email change, you can safely ignore this email.",
'regards': 'Best regards,',
},
'zh': {
'greeting': f'您好{", " + username if username else ""}!',
'subject': '邮箱更换验证码',
'intro': '您请求更换邮箱地址。请使用以下验证码确认:',
'code_label': '您的验证码:',
'expires': f'此验证码将在 {expire_minutes} 分钟后过期。',
'ignore': '如果您没有请求更换邮箱,请忽略此邮件。',
'regards': '此致,',
},
'ua': {
'greeting': f'Вітаємо{", " + username if username else ""}!',
'subject': 'Код підтвердження для зміни email',
'intro': 'Ви запросили зміну email адреси. Використовуйте код нижче для підтвердження:',
'code_label': 'Ваш код підтвердження:',
'expires': f'Код дійсний протягом {expire_minutes} хвилин.',
'ignore': 'Якщо ви не запитували зміну email, просто проігноруйте цей лист.',
'regards': 'З повагою,',
},
}
t = texts.get(language, texts['ru'])
subject = t['subject']
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.code-box {{
background-color: #f8f9fa;
border: 2px solid #007bff;
border-radius: 8px;
padding: 20px;
text-align: center;
margin: 20px 0;
}}
.code {{
font-size: 32px;
font-weight: bold;
letter-spacing: 8px;
color: #007bff;
font-family: monospace;
}}
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
</style>
</head>
<body>
<div class="container">
<h2>{t['greeting']}</h2>
<p>{t['intro']}</p>
<div class="code-box">
<p>{t['code_label']}</p>
<p class="code">{code}</p>
</div>
<p>{t['expires']}</p>
<p>{t['ignore']}</p>
<div class="footer">
<p>{t['regards']}<br>{self.from_name}</p>
</div>
</div>
</body>
</html>
"""
return self.send_email(to_email, subject, body_html)
# Singleton instance
email_service = EmailService()
+4
View File
@@ -691,6 +691,7 @@ class Settings(BaseSettings):
CABINET_EMAIL_VERIFICATION_ENABLED: bool = True
CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS: int = 24
CABINET_PASSWORD_RESET_EXPIRE_HOURS: int = 1
CABINET_EMAIL_CHANGE_CODE_EXPIRE_MINUTES: int = 15 # Email change verification code expiration
CABINET_EMAIL_AUTH_ENABLED: bool = True # Enable email registration/login in cabinet
CABINET_URL: str = 'https://example.com/cabinet' # Base URL for cabinet (used in verification emails)
@@ -2498,6 +2499,9 @@ class Settings(BaseSettings):
def get_cabinet_password_reset_expire_hours(self) -> int:
return max(1, self.CABINET_PASSWORD_RESET_EXPIRE_HOURS)
def get_cabinet_email_change_code_expire_minutes(self) -> int:
return max(1, self.CABINET_EMAIL_CHANGE_CODE_EXPIRE_MINUTES)
def is_cabinet_email_auth_enabled(self) -> bool:
return bool(self.CABINET_EMAIL_AUTH_ENABLED)
+94
View File
@@ -9,6 +9,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import (
ReferralContest,
ReferralContestEvent,
ReferralContestVirtualParticipant,
Transaction,
TransactionType,
User,
@@ -918,3 +919,96 @@ async def cleanup_invalid_contest_events(
'contest_start': contest_start.isoformat(),
'contest_end': contest_end.isoformat(),
}
# ── Виртуальные участники ──────────────────────────────────────────────
async def add_virtual_participant(
db: AsyncSession,
contest_id: int,
display_name: str,
referral_count: int,
total_amount_kopeks: int = 0,
) -> ReferralContestVirtualParticipant:
vp = ReferralContestVirtualParticipant(
contest_id=contest_id,
display_name=display_name,
referral_count=referral_count,
total_amount_kopeks=total_amount_kopeks,
)
db.add(vp)
await db.commit()
await db.refresh(vp)
return vp
async def list_virtual_participants(
db: AsyncSession,
contest_id: int,
) -> Sequence[ReferralContestVirtualParticipant]:
result = await db.execute(
select(ReferralContestVirtualParticipant)
.where(ReferralContestVirtualParticipant.contest_id == contest_id)
.order_by(ReferralContestVirtualParticipant.referral_count.desc())
)
return result.scalars().all()
async def delete_virtual_participant(
db: AsyncSession,
participant_id: int,
) -> bool:
result = await db.execute(
select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == participant_id)
)
vp = result.scalar_one_or_none()
if not vp:
return False
await db.delete(vp)
await db.commit()
return True
async def update_virtual_participant_count(
db: AsyncSession,
participant_id: int,
referral_count: int,
) -> ReferralContestVirtualParticipant | None:
result = await db.execute(
select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == participant_id)
)
vp = result.scalar_one_or_none()
if not vp:
return None
vp.referral_count = referral_count
await db.commit()
await db.refresh(vp)
return vp
async def get_contest_leaderboard_with_virtual(
db: AsyncSession,
contest_id: int,
*,
limit: int | None = None,
) -> list[tuple[str, int, int, bool]]:
"""Лидерборд с виртуальными участниками.
Возвращает список кортежей (display_name, referral_count, total_amount, is_virtual).
"""
real = await get_contest_leaderboard(db, contest_id)
virtual = await list_virtual_participants(db, contest_id)
merged: list[tuple[str, int, int, bool]] = []
for user, score, amount in real:
merged.append((user.full_name, score, amount, False))
for vp in virtual:
merged.append((vp.display_name, vp.referral_count, vp.total_amount_kopeks, True))
merged.sort(key=lambda x: (-x[1], -x[2]))
if limit:
merged = merged[:limit]
return merged
+152 -17
View File
@@ -408,29 +408,33 @@ async def add_user_balance(
# Автоматическое возобновление приостановленной суточной подписки
try:
from app.database.crud.subscription import resume_daily_subscription
from app.database.crud.tariff import get_tariff_by_id
from app.database.models import SubscriptionStatus
subscription = user.subscription
if subscription and subscription.status == SubscriptionStatus.DISABLED.value:
# Проверяем что это суточный тариф
is_daily = getattr(subscription, 'is_daily_tariff', False)
if is_daily and subscription.tariff:
daily_price = getattr(subscription.tariff, 'daily_price_kopeks', 0)
# Если баланс достаточный для суточной оплаты - возобновляем
if daily_price > 0 and user.balance_kopeks >= daily_price:
await resume_daily_subscription(db, subscription)
logger.info(
f'✅ Автоматически возобновлена суточная подписка {subscription.id} '
f'после пополнения баланса (user_id={user.id})'
)
# Синхронизируем с RemnaWave
try:
from app.services.subscription_service import SubscriptionService
if is_daily and subscription.tariff_id:
# Загружаем тариф явно, чтобы избежать lazy loading
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
# Если баланс достаточный для суточной оплаты - возобновляем
if daily_price > 0 and user.balance_kopeks >= daily_price:
await resume_daily_subscription(db, subscription)
logger.info(
f'✅ Автоматически возобновлена суточная подписка {subscription.id} '
f'после пополнения баланса (user_id={user.id})'
)
# Синхронизируем с RemnaWave
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as sync_err:
logger.warning(f'Не удалось синхронизировать с RemnaWave: {sync_err}')
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as sync_err:
logger.warning(f'Не удалось синхронизировать с RemnaWave: {sync_err}')
except Exception as resume_err:
logger.warning(f'Ошибка при попытке возобновить суточную подписку: {resume_err}')
@@ -686,6 +690,7 @@ async def get_users_list(
offset: int = 0,
limit: int = 50,
search: str | None = None,
email: str | None = None,
status: UserStatus | None = None,
order_by_balance: bool = False,
order_by_traffic: bool = False,
@@ -722,6 +727,9 @@ async def get_users_list(
query = query.where(or_(*conditions))
if email:
query = query.where(User.email.ilike(f'%{email}%'))
sort_flags = [
order_by_balance,
order_by_traffic,
@@ -777,7 +785,9 @@ async def get_users_list(
return users
async def get_users_count(db: AsyncSession, status: UserStatus | None = None, search: str | None = None) -> int:
async def get_users_count(
db: AsyncSession, status: UserStatus | None = None, search: str | None = None, email: str | None = None
) -> int:
query = select(func.count(User.id))
if status:
@@ -803,6 +813,9 @@ async def get_users_count(db: AsyncSession, status: UserStatus | None = None, se
query = query.where(or_(*conditions))
if email:
query = query.where(User.email.ilike(f'%{email}%'))
result = await db.execute(query)
return result.scalar()
@@ -1099,3 +1112,125 @@ async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
"""Get user by email address."""
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def is_email_taken(db: AsyncSession, email: str, exclude_user_id: int | None = None) -> bool:
"""
Check if email is already taken by another user.
Args:
db: Database session
email: Email to check
exclude_user_id: User ID to exclude from check (for current user)
Returns:
True if email is taken, False otherwise
"""
query = select(User.id).where(User.email == email)
if exclude_user_id:
query = query.where(User.id != exclude_user_id)
result = await db.execute(query)
return result.scalar_one_or_none() is not None
async def set_email_change_pending(
db: AsyncSession,
user: User,
new_email: str,
code: str,
expires_at: datetime,
) -> User:
"""
Set pending email change for user.
Args:
db: Database session
user: User object
new_email: New email address
code: 6-digit verification code
expires_at: Code expiration datetime
Returns:
Updated User object
"""
user.email_change_new = new_email
user.email_change_code = code
user.email_change_expires = expires_at
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
logger.info(f'Email change pending for user {user.id}: {user.email} -> {new_email}')
return user
async def verify_and_apply_email_change(db: AsyncSession, user: User, code: str) -> tuple[bool, str]:
"""
Verify email change code and apply the change.
Args:
db: Database session
user: User object
code: Verification code from user
Returns:
Tuple of (success: bool, message: str)
"""
if not user.email_change_new or not user.email_change_code:
return False, 'No pending email change'
if user.email_change_expires and datetime.utcnow() > user.email_change_expires:
# Clear expired data
user.email_change_new = None
user.email_change_code = None
user.email_change_expires = None
await db.commit()
return False, 'Verification code has expired'
if user.email_change_code != code:
return False, 'Invalid verification code'
# Check if new email is still available
existing = await get_user_by_email(db, user.email_change_new)
if existing and existing.id != user.id:
user.email_change_new = None
user.email_change_code = None
user.email_change_expires = None
await db.commit()
return False, 'This email is already taken'
old_email = user.email
new_email = user.email_change_new
# Apply the change
user.email = new_email
user.email_verified = True
user.email_verified_at = datetime.utcnow()
user.email_change_new = None
user.email_change_code = None
user.email_change_expires = None
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
logger.info(f'Email changed for user {user.id}: {old_email} -> {new_email}')
return True, 'Email changed successfully'
async def clear_email_change_pending(db: AsyncSession, user: User) -> None:
"""
Clear pending email change data.
Args:
db: Database session
user: User object
"""
user.email_change_new = None
user.email_change_code = None
user.email_change_expires = None
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f'Email change cancelled for user {user.id}')
+29 -1
View File
@@ -991,6 +991,10 @@ class User(Base):
password_reset_token = Column(String(255), nullable=True)
password_reset_expires = Column(DateTime, nullable=True)
cabinet_last_login = Column(DateTime, nullable=True)
# Email change fields
email_change_new = Column(String(255), nullable=True) # New email pending verification
email_change_code = Column(String(6), nullable=True) # 6-digit verification code
email_change_expires = Column(DateTime, nullable=True) # Code expiration
broadcasts = relationship('BroadcastHistory', back_populates='admin')
referrals = relationship('User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id')
subscription = relationship('Subscription', back_populates='user', uselist=False)
@@ -1548,6 +1552,24 @@ class ReferralContestEvent(Base):
)
class ReferralContestVirtualParticipant(Base):
__tablename__ = 'referral_contest_virtual_participants'
id = Column(Integer, primary_key=True, index=True)
contest_id = Column(Integer, ForeignKey('referral_contests.id', ondelete='CASCADE'), nullable=False)
display_name = Column(String(255), nullable=False)
referral_count = Column(Integer, nullable=False, default=0)
total_amount_kopeks = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, default=func.now())
contest = relationship('ReferralContest')
def __repr__(self):
return (
f"<ReferralContestVirtualParticipant id={self.id} name='{self.display_name}' count={self.referral_count}>"
)
class ContestTemplate(Base):
__tablename__ = 'contest_templates'
@@ -1844,7 +1866,7 @@ class BroadcastHistory(Base):
id = Column(Integer, primary_key=True, index=True)
target_type = Column(String(100), nullable=False)
message_text = Column(Text, nullable=False)
message_text = Column(Text, nullable=True) # Nullable for email-only broadcasts
has_media = Column(Boolean, default=False)
media_type = Column(String(20), nullable=True)
media_file_id = Column(String(255), nullable=True)
@@ -1857,6 +1879,12 @@ class BroadcastHistory(Base):
admin_name = Column(String(255))
created_at = Column(DateTime(timezone=True), server_default=func.now())
completed_at = Column(DateTime(timezone=True), nullable=True)
# Email broadcast fields
channel = Column(String(20), default='telegram', nullable=False) # telegram|email|both
email_subject = Column(String(255), nullable=True)
email_html_content = Column(Text, nullable=True)
admin = relationship('User', back_populates='broadcasts')
+133
View File
@@ -1731,6 +1731,65 @@ async def create_referral_contest_events_table() -> bool:
return False
async def create_referral_contest_virtual_participants_table() -> bool:
table_exists = await check_table_exists('referral_contest_virtual_participants')
if table_exists:
logger.info('Таблица referral_contest_virtual_participants уже существует')
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(
text("""
CREATE TABLE referral_contest_virtual_participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contest_id INTEGER NOT NULL,
display_name VARCHAR(255) NOT NULL,
referral_count INTEGER NOT NULL DEFAULT 0,
total_amount_kopeks INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE
)
""")
)
elif db_type == 'postgresql':
await conn.execute(
text("""
CREATE TABLE referral_contest_virtual_participants (
id SERIAL PRIMARY KEY,
contest_id INTEGER NOT NULL REFERENCES referral_contests(id) ON DELETE CASCADE,
display_name VARCHAR(255) NOT NULL,
referral_count INTEGER NOT NULL DEFAULT 0,
total_amount_kopeks INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
)
else:
await conn.execute(
text("""
CREATE TABLE referral_contest_virtual_participants (
id INT AUTO_INCREMENT PRIMARY KEY,
contest_id INT NOT NULL,
display_name VARCHAR(255) NOT NULL,
referral_count INT NOT NULL DEFAULT 0,
total_amount_kopeks INT NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE
)
""")
)
logger.info('✅ Таблица referral_contest_virtual_participants создана')
return True
except Exception as error:
logger.error(f'Ошибка создания таблицы referral_contest_virtual_participants: {error}')
return False
async def ensure_referral_contest_summary_columns() -> bool:
ok = True
for column in ['daily_summary_times', 'last_daily_summary_at']:
@@ -3298,6 +3357,54 @@ async def add_media_fields_to_broadcast_history():
return False
async def add_email_fields_to_broadcast_history():
"""Добавление полей для email-рассылки в broadcast_history."""
logger.info('=== ДОБАВЛЕНИЕ ПОЛЕЙ EMAIL В BROADCAST_HISTORY ===')
email_fields = {
'channel': "VARCHAR(20) DEFAULT 'telegram'",
'email_subject': 'VARCHAR(255)',
'email_html_content': 'TEXT',
}
try:
async with engine.begin() as conn:
db_type = await get_database_type()
# Добавление новых полей
for field_name, field_type in email_fields.items():
field_exists = await check_column_exists('broadcast_history', field_name)
if not field_exists:
logger.info(f'Добавление поля {field_name} в таблицу broadcast_history')
alter_sql = f'ALTER TABLE broadcast_history ADD COLUMN {field_name} {field_type}'
await conn.execute(text(alter_sql))
logger.info(f'✅ Поле {field_name} успешно добавлено')
else:
logger.info(f'Поле {field_name} уже существует в broadcast_history')
# Сделать message_text nullable для email-only рассылок
try:
if db_type == 'postgresql':
await conn.execute(text('ALTER TABLE broadcast_history ALTER COLUMN message_text DROP NOT NULL'))
logger.info('✅ Колонка message_text теперь nullable')
elif db_type == 'mysql':
await conn.execute(text('ALTER TABLE broadcast_history MODIFY COLUMN message_text TEXT NULL'))
logger.info('✅ Колонка message_text теперь nullable')
# SQLite не поддерживает ALTER COLUMN, но там по умолчанию nullable
except Exception as e:
# Игнорируем если уже nullable или другая ошибка
logger.debug(f'message_text nullable: {e}')
logger.info('✅ Все поля email в broadcast_history готовы')
return True
except Exception as e:
logger.error(f'Ошибка при добавлении полей email в broadcast_history: {e}')
return False
async def add_ticket_reply_block_columns():
try:
col_perm_exists = await check_column_exists('tickets', 'user_reply_block_permanent')
@@ -3434,6 +3541,10 @@ async def add_user_cabinet_columns() -> bool:
('password_reset_token', 'VARCHAR(255)', 'VARCHAR(255)', 'VARCHAR(255)'),
('password_reset_expires', 'DATETIME', 'TIMESTAMP', 'DATETIME'),
('cabinet_last_login', 'DATETIME', 'TIMESTAMP', 'DATETIME'),
# Email change fields
('email_change_new', 'VARCHAR(255)', 'VARCHAR(255)', 'VARCHAR(255)'),
('email_change_code', 'VARCHAR(6)', 'VARCHAR(6)', 'VARCHAR(6)'),
('email_change_expires', 'DATETIME', 'TIMESTAMP', 'DATETIME'),
]
try:
@@ -6459,6 +6570,12 @@ async def run_universal_migration():
else:
logger.warning('⚠️ Проблемы с таблицей referral_contest_events')
virtual_participants_ready = await create_referral_contest_virtual_participants_table()
if virtual_participants_ready:
logger.info('✅ Таблица referral_contest_virtual_participants готова')
else:
logger.warning('⚠️ Проблемы с таблицей referral_contest_virtual_participants')
contest_type_ready = await ensure_referral_contest_type_column()
if contest_type_ready:
logger.info('✅ Колонка contest_type для referral_contests готова')
@@ -6632,6 +6749,13 @@ async def run_universal_migration():
else:
logger.warning('⚠️ Проблемы с добавлением медиа полей')
logger.info('=== ДОБАВЛЕНИЕ EMAIL ПОЛЕЙ В BROADCAST_HISTORY ===')
email_fields_added = await add_email_fields_to_broadcast_history()
if email_fields_added:
logger.info('✅ Email поля в broadcast_history готовы')
else:
logger.warning('⚠️ Проблемы с добавлением email полей')
logger.info('=== ДОБАВЛЕНИЕ ПОЛЕЙ БЛОКИРОВКИ В TICKETS ===')
tickets_block_cols_added = await add_ticket_reply_block_columns()
if tickets_block_cols_added:
@@ -6986,6 +7110,7 @@ async def check_migration_status():
'pinned_messages_start_mode_column': False,
'users_last_pinned_column': False,
'broadcast_history_media_fields': False,
'broadcast_history_email_fields': False,
'subscription_duplicates': False,
'subscription_conversions_table': False,
'subscription_events_table': False,
@@ -7133,6 +7258,13 @@ async def check_migration_status():
)
status['broadcast_history_media_fields'] = media_fields_exist
email_fields_exist = (
await check_column_exists('broadcast_history', 'channel')
and await check_column_exists('broadcast_history', 'email_subject')
and await check_column_exists('broadcast_history', 'email_html_content')
)
status['broadcast_history_email_fields'] = email_fields_exist
pinned_media_columns_exist = (
status['pinned_messages_table']
and await check_column_exists('pinned_messages', 'media_type')
@@ -7185,6 +7317,7 @@ async def check_migration_status():
'pinned_messages_start_mode_column': 'Режим отправки закрепа при /start',
'users_last_pinned_column': 'Колонка last_pinned_message_id у пользователей',
'broadcast_history_media_fields': 'Медиа поля в broadcast_history',
'broadcast_history_email_fields': 'Email поля в broadcast_history',
'subscription_conversions_table': 'Таблица конверсий подписок',
'subscription_events_table': 'Таблица событий подписок',
'subscription_duplicates': 'Отсутствие дубликатов подписок',
+1 -1
View File
@@ -91,7 +91,7 @@ class TributeService:
amount_kopeks = data.get('amount', 0)
telegram_user_id = data.get('telegram_user_id')
if event_name == 'new_donation':
if event_name in ('new_donation', 'recurrent_donation'):
status = 'paid'
elif event_name == 'cancelled_subscription':
status = 'cancelled'
+299 -9
View File
@@ -9,15 +9,19 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.referral_contest import (
add_virtual_participant,
create_referral_contest,
delete_referral_contest,
delete_virtual_participant,
get_contest_events_count,
get_contest_leaderboard,
get_contest_leaderboard_with_virtual,
get_referral_contest,
get_referral_contests_count,
list_referral_contests,
list_virtual_participants,
toggle_referral_contest,
update_referral_contest,
update_virtual_participant_count,
)
from app.keyboards.admin import (
get_admin_contests_keyboard,
@@ -240,8 +244,10 @@ async def show_contest_details(
return
tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
leaderboard = await get_contest_leaderboard(db, contest.id, limit=5)
total_events = await get_contest_events_count(db, contest.id)
leaderboard = await get_contest_leaderboard_with_virtual(db, contest.id, limit=5)
virtual_list = await list_virtual_participants(db, contest.id)
virtual_count = sum(vp.referral_count for vp in virtual_list)
total_events = await get_contest_events_count(db, contest.id) + virtual_count
lines = [
f'🏆 <b>{contest.title}</b>',
@@ -256,8 +262,9 @@ async def show_contest_details(
if leaderboard:
lines.append('')
lines.append(texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:'))
for idx, (user, score, _) in enumerate(leaderboard, start=1):
lines.append(f'{idx}. {user.full_name}{score}')
for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1):
virt_mark = ' 👻' if is_virtual else ''
lines.append(f'{idx}. {name}{virt_mark}{score}')
await callback.message.edit_text(
'\n'.join(lines),
@@ -427,7 +434,7 @@ async def show_leaderboard(
await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True)
return
leaderboard = await get_contest_leaderboard(db, contest_id, limit=10)
leaderboard = await get_contest_leaderboard_with_virtual(db, contest_id, limit=10)
if not leaderboard:
await callback.answer(texts.t('ADMIN_CONTEST_EMPTY_LEADERBOARD', 'Пока нет участников.'), show_alert=True)
return
@@ -435,9 +442,9 @@ async def show_leaderboard(
lines = [
texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:'),
]
for idx, (user, score, _) in enumerate(leaderboard, start=1):
user_id_display = user.telegram_id or user.email or f'#{user.id}'
lines.append(f'{idx}. {user.full_name} ({user_id_display}){score}')
for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1):
virt_mark = ' 👻' if is_virtual else ''
lines.append(f'{idx}. {name}{virt_mark}{score}')
await callback.message.edit_text(
'\n'.join(lines),
@@ -676,6 +683,9 @@ async def show_detailed_stats(
from app.services.referral_contest_service import referral_contest_service
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
virtual = await list_virtual_participants(db, contest_id)
virtual_count = len(virtual)
virtual_referrals = sum(vp.referral_count for vp in virtual)
# Общее сообщение с основной статистикой
general_lines = [
@@ -693,6 +703,10 @@ async def show_detailed_stats(
f' 📥 Пополнения баланса: <b>{stats.get("deposit_total", 0) // 100} руб.</b>',
]
if virtual_count > 0:
general_lines.append('')
general_lines.append(f'👻 Виртуальных: <b>{virtual_count}</b> (рефералов: {virtual_referrals})')
await callback.message.edit_text(
'\n'.join(general_lines),
reply_markup=get_referral_contest_manage_keyboard(
@@ -970,6 +984,274 @@ async def debug_contest_transactions(
)
# ── Виртуальные участники ──────────────────────────────────────────────
@admin_required
@error_handler
async def show_virtual_participants(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
contest_id = int(callback.data.split('_')[-1])
contest = await get_referral_contest(db, contest_id)
if not contest:
await callback.answer('Конкурс не найден.', show_alert=True)
return
vps = await list_virtual_participants(db, contest_id)
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
if vps:
for vp in vps:
lines.append(f'{vp.display_name}{vp.referral_count} реф.')
else:
lines.append('Пока нет виртуальных участников.')
rows = [
[
types.InlineKeyboardButton(
text=' Добавить',
callback_data=f'admin_contest_vp_add_{contest_id}',
),
],
]
if vps:
for vp in vps:
rows.append(
[
types.InlineKeyboardButton(
text=f'✏️ {vp.display_name}',
callback_data=f'admin_contest_vp_edit_{vp.id}',
),
types.InlineKeyboardButton(
text='🗑',
callback_data=f'admin_contest_vp_del_{vp.id}',
),
]
)
rows.append(
[
types.InlineKeyboardButton(
text='⬅️ Назад',
callback_data=f'admin_contest_view_{contest_id}',
),
]
)
await callback.message.edit_text(
'\n'.join(lines),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=rows),
)
await callback.answer()
@admin_required
@error_handler
async def start_add_virtual_participant(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
contest_id = int(callback.data.split('_')[-1])
await state.set_state(AdminStates.adding_virtual_participant_name)
await state.update_data(vp_contest_id=contest_id)
await callback.message.edit_text(
'👻 Введите отображаемое имя виртуального участника:',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{contest_id}')],
]
),
)
await callback.answer()
@admin_required
@error_handler
async def process_virtual_participant_name(
message: types.Message,
db_user,
db: AsyncSession,
state: FSMContext,
):
name = message.text.strip()
if not name or len(name) > 200:
await message.answer('Имя должно быть от 1 до 200 символов. Попробуйте ещё раз:')
return
await state.update_data(vp_name=name)
await state.set_state(AdminStates.adding_virtual_participant_count)
await message.answer(f'Имя: <b>{name}</b>\n\nВведите количество рефералов (число):')
@admin_required
@error_handler
async def process_virtual_participant_count(
message: types.Message,
db_user,
db: AsyncSession,
state: FSMContext,
):
try:
count = int(message.text.strip())
if count < 1:
raise ValueError
except (ValueError, TypeError):
await message.answer('Введите положительное целое число:')
return
data = await state.get_data()
contest_id = data['vp_contest_id']
display_name = data['vp_name']
await state.clear()
vp = await add_virtual_participant(db, contest_id, display_name, count)
await message.answer(
f'✅ Виртуальный участник добавлен:\nИмя: <b>{vp.display_name}</b>\nРефералов: <b>{vp.referral_count}</b>',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='👻 К списку', callback_data=f'admin_contest_vp_{contest_id}')],
[types.InlineKeyboardButton(text='⬅️ К конкурсу', callback_data=f'admin_contest_view_{contest_id}')],
]
),
)
@admin_required
@error_handler
async def delete_virtual_participant_handler(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
vp_id = int(callback.data.split('_')[-1])
# Получим contest_id до удаления
from sqlalchemy import select as sa_select
from app.database.models import ReferralContestVirtualParticipant
result = await db.execute(
sa_select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == vp_id)
)
vp = result.scalar_one_or_none()
if not vp:
await callback.answer('Участник не найден.', show_alert=True)
return
contest_id = vp.contest_id
deleted = await delete_virtual_participant(db, vp_id)
if deleted:
await callback.answer('✅ Удалён', show_alert=False)
else:
await callback.answer('Не удалось удалить.', show_alert=True)
# Вернуться к списку
vps = await list_virtual_participants(db, contest_id)
contest = await get_referral_contest(db, contest_id)
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
if vps:
for v in vps:
lines.append(f'{v.display_name}{v.referral_count} реф.')
else:
lines.append('Пока нет виртуальных участников.')
rows = [
[types.InlineKeyboardButton(text=' Добавить', callback_data=f'admin_contest_vp_add_{contest_id}')],
]
if vps:
for v in vps:
rows.append(
[
types.InlineKeyboardButton(
text=f'✏️ {v.display_name}', callback_data=f'admin_contest_vp_edit_{v.id}'
),
types.InlineKeyboardButton(text='🗑', callback_data=f'admin_contest_vp_del_{v.id}'),
]
)
rows.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_contest_view_{contest_id}')])
await callback.message.edit_text(
'\n'.join(lines),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=rows),
)
@admin_required
@error_handler
async def start_edit_virtual_participant(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
vp_id = int(callback.data.split('_')[-1])
from sqlalchemy import select as sa_select
from app.database.models import ReferralContestVirtualParticipant
result = await db.execute(
sa_select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == vp_id)
)
vp = result.scalar_one_or_none()
if not vp:
await callback.answer('Участник не найден.', show_alert=True)
return
await state.set_state(AdminStates.editing_virtual_participant_count)
await state.update_data(vp_edit_id=vp_id, vp_edit_contest_id=vp.contest_id)
await callback.message.edit_text(
f'✏️ <b>{vp.display_name}</b>\n'
f'Текущее кол-во рефералов: <b>{vp.referral_count}</b>\n\n'
f'Введите новое количество:',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{vp.contest_id}')],
]
),
)
await callback.answer()
@admin_required
@error_handler
async def process_edit_virtual_participant_count(
message: types.Message,
db_user,
db: AsyncSession,
state: FSMContext,
):
try:
count = int(message.text.strip())
if count < 1:
raise ValueError
except (ValueError, TypeError):
await message.answer('Введите положительное целое число:')
return
data = await state.get_data()
vp_id = data['vp_edit_id']
contest_id = data['vp_edit_contest_id']
await state.clear()
vp = await update_virtual_participant_count(db, vp_id, count)
if vp:
await message.answer(
f'✅ Обновлено: <b>{vp.display_name}</b> — {vp.referral_count} реф.',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='👻 К списку', callback_data=f'admin_contest_vp_{contest_id}')],
]
),
)
else:
await message.answer('Участник не найден.')
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_contests_menu, F.data == 'admin_contests')
dp.callback_query.register(show_referral_contests_menu, F.data == 'admin_contests_referral')
@@ -996,3 +1278,11 @@ def register_handlers(dp: Dispatcher):
dp.message.register(process_end_date, AdminStates.creating_referral_contest_end)
dp.message.register(finalize_contest_creation, AdminStates.creating_referral_contest_time)
dp.message.register(process_edit_summary_times, AdminStates.editing_referral_contest_summary_times)
dp.callback_query.register(start_add_virtual_participant, F.data.startswith('admin_contest_vp_add_'))
dp.callback_query.register(delete_virtual_participant_handler, F.data.startswith('admin_contest_vp_del_'))
dp.callback_query.register(start_edit_virtual_participant, F.data.startswith('admin_contest_vp_edit_'))
dp.callback_query.register(show_virtual_participants, F.data.regexp(r'^admin_contest_vp_\d+$'))
dp.message.register(process_virtual_participant_name, AdminStates.adding_virtual_participant_name)
dp.message.register(process_virtual_participant_count, AdminStates.adding_virtual_participant_count)
dp.message.register(process_edit_virtual_participant_count, AdminStates.editing_virtual_participant_count)
+36 -28
View File
@@ -1717,7 +1717,8 @@ async def required_sub_channel_check(
try:
state_data = await state.get_data() or {}
pending_start_payload = state_data.pop('pending_start_payload', None)
# Получаем payload БЕЗ удаления - удалим только после успешной проверки подписки
pending_start_payload = state_data.get('pending_start_payload')
# Если в FSM state нет payload, пробуем получить из Redis (резервный механизм)
if not pending_start_payload:
@@ -1729,15 +1730,46 @@ async def required_sub_channel_check(
pending_start_payload,
)
state_updated = pending_start_payload is not None
if pending_start_payload:
logger.info(
"📦 CHANNEL CHECK: Найден сохраненный payload '%s'",
pending_start_payload,
)
# Очищаем Redis после получения payload
user = db_user
if not user:
user = await get_user_by_telegram_id(db, query.from_user.id)
if user and getattr(user, 'language', None):
language = user.language
elif state_data.get('language'):
language = state_data['language']
texts = get_texts(language)
chat_member = await bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=query.from_user.id)
if chat_member.status not in [
ChatMemberStatus.MEMBER,
ChatMemberStatus.ADMINISTRATOR,
ChatMemberStatus.CREATOR,
]:
# НЕ удаляем payload - пользователь может попробовать снова после подписки
logger.info(
"📦 CHANNEL CHECK: Подписка не подтверждена, payload '%s' сохранён для следующей попытки",
pending_start_payload,
)
return await query.answer(
texts.t('CHANNEL_SUBSCRIBE_REQUIRED_ALERT', '❌ Вы не подписались на канал!'),
show_alert=True,
)
# Подписка подтверждена - теперь удаляем payload и обрабатываем его
if pending_start_payload:
# Удаляем из FSM state
state_data.pop('pending_start_payload', None)
# Очищаем Redis после успешной проверки подписки
await delete_pending_payload_from_redis(query.from_user.id)
# Всегда обновляем referral_code если есть новый payload
@@ -1760,32 +1792,8 @@ async def required_sub_channel_check(
'🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код',
)
if state_updated:
await state.set_data(state_data)
user = db_user
if not user:
user = await get_user_by_telegram_id(db, query.from_user.id)
if user and getattr(user, 'language', None):
language = user.language
elif state_data.get('language'):
language = state_data['language']
texts = get_texts(language)
chat_member = await bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=query.from_user.id)
if chat_member.status not in [
ChatMemberStatus.MEMBER,
ChatMemberStatus.ADMINISTRATOR,
ChatMemberStatus.CREATOR,
]:
return await query.answer(
texts.t('CHANNEL_SUBSCRIBE_REQUIRED_ALERT', '❌ Вы не подписались на канал!'),
show_alert=True,
)
if user and user.subscription:
subscription = user.subscription
if subscription.is_trial and subscription.status == SubscriptionStatus.DISABLED.value:
+132 -41
View File
@@ -22,6 +22,7 @@ from app.keyboards.inline import (
from app.localization.texts import get_texts
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import SubscriptionService
from app.services.user_cart_service import user_cart_service
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
apply_percentage_discount,
@@ -290,26 +291,54 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
chargeable_devices = additional_devices
devices_price_per_month = chargeable_devices * price_per_device
months_hint = get_remaining_months(subscription.end_date)
period_hint_days = months_hint * 30 if months_hint > 0 else None
devices_discount_percent = _get_addon_discount_percent_for_user(
db_user,
'devices',
period_hint_days,
)
discounted_per_month, discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
price, charged_months = calculate_prorated_price(
discounted_per_month,
subscription.end_date,
)
total_discount = discount_per_month * charged_months
# Проверяем является ли тариф суточным
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
if is_daily_tariff:
# Для суточных тарифов считаем по дням (как в кабинете)
now = datetime.utcnow()
days_left = max(1, (subscription.end_date - now).days)
period_hint_days = days_left
devices_discount_percent = _get_addon_discount_percent_for_user(
db_user,
'devices',
period_hint_days,
)
discounted_per_month, discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
# Цена = месячная_цена * days_left / 30
price = int(discounted_per_month * days_left / 30)
price = max(100, price) # Минимум 1 рубль
total_discount = int(discount_per_month * days_left / 30)
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
else:
# Для обычных тарифов - по месяцам
months_hint = get_remaining_months(subscription.end_date)
period_hint_days = months_hint * 30 if months_hint > 0 else None
devices_discount_percent = _get_addon_discount_percent_for_user(
db_user,
'devices',
period_hint_days,
)
discounted_per_month, discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
price, charged_months = calculate_prorated_price(
discounted_per_month,
subscription.end_date,
)
total_discount = discount_per_month * charged_months
period_label = f'{charged_months} мес'
if price > 0 and db_user.balance_kopeks < price:
missing_kopeks = price - db_user.balance_kopeks
required_text = f'{texts.format_price(price)} (за {charged_months} мес)'
required_text = f'{texts.format_price(price)} (за {period_label})'
message_text = texts.t(
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
(
@@ -325,11 +354,28 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
missing=texts.format_price(missing_kopeks),
)
# Сохраняем корзину для автопокупки после пополнения баланса
await user_cart_service.save_user_cart(
user_id=db_user.id,
cart_data={
'cart_mode': 'add_devices',
'devices_to_add': devices_difference,
'price_kopeks': price,
},
)
logger.info(
'Сохранена корзина add_devices для пользователя %s: +%s устройств, цена %s коп.',
db_user.telegram_id,
devices_difference,
price,
)
await callback.message.answer(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
amount_kopeks=missing_kopeks,
has_saved_cart=True,
),
parse_mode='HTML',
)
@@ -343,10 +389,10 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
if price > 0:
cost_text = texts.t(
'DEVICE_CHANGE_EXTRA_COST',
'Доплата: {amount} (за {months} мес)',
'Доплата: {amount} (за {period})',
).format(
amount=texts.format_price(price),
months=charged_months,
period=period_label,
)
if total_discount > 0:
cost_text += texts.t(
@@ -949,35 +995,63 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
return
devices_price_per_month = devices_count * price_per_device
months_hint = get_remaining_months(subscription.end_date)
period_hint_days = months_hint * 30 if months_hint > 0 else None
devices_discount_percent = _get_addon_discount_percent_for_user(
db_user,
'devices',
period_hint_days,
)
discounted_per_month, discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
price, charged_months = calculate_prorated_price(
discounted_per_month,
subscription.end_date,
)
total_discount = discount_per_month * charged_months
# Проверяем является ли тариф суточным
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
if is_daily_tariff:
# Для суточных тарифов считаем по дням (как в кабинете)
now = datetime.utcnow()
days_left = max(1, (subscription.end_date - now).days)
period_hint_days = days_left
devices_discount_percent = _get_addon_discount_percent_for_user(
db_user,
'devices',
period_hint_days,
)
discounted_per_month, discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
# Цена = месячная_цена * days_left / 30
price = int(discounted_per_month * days_left / 30)
price = max(100, price) # Минимум 1 рубль
total_discount = int(discount_per_month * days_left / 30)
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
else:
# Для обычных тарифов - по месяцам
months_hint = get_remaining_months(subscription.end_date)
period_hint_days = months_hint * 30 if months_hint > 0 else None
devices_discount_percent = _get_addon_discount_percent_for_user(
db_user,
'devices',
period_hint_days,
)
discounted_per_month, discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
price, charged_months = calculate_prorated_price(
discounted_per_month,
subscription.end_date,
)
total_discount = discount_per_month * charged_months
period_label = f'{charged_months} мес'
logger.info(
'Добавление %s устройств: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)',
'Добавление %s устройств: %.2f₽/мес × %s = %.2f₽ (скидка %.2f₽)',
devices_count,
discounted_per_month / 100,
charged_months,
period_label,
price / 100,
total_discount / 100,
)
if db_user.balance_kopeks < price:
missing_kopeks = price - db_user.balance_kopeks
required_text = f'{texts.format_price(price)} (за {charged_months} мес)'
required_text = f'{texts.format_price(price)} (за {period_label})'
message_text = texts.t(
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
(
@@ -993,12 +1067,29 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
missing=texts.format_price(missing_kopeks),
)
# Сохраняем корзину для автопокупки после пополнения баланса
await user_cart_service.save_user_cart(
user_id=db_user.id,
cart_data={
'cart_mode': 'add_devices',
'devices_to_add': devices_count,
'price_kopeks': price,
},
)
logger.info(
'Сохранена корзина add_devices для пользователя %s: +%s устройств, цена %s коп.',
db_user.telegram_id,
devices_count,
price,
)
await callback.message.edit_text(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
resume_callback=resume_callback,
amount_kopeks=missing_kopeks,
has_saved_cart=True,
),
parse_mode='HTML',
)
@@ -1007,7 +1098,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
try:
success = await subtract_user_balance(
db, db_user, price, f'Добавление {devices_count} устройств на {charged_months} мес'
db, db_user, price, f'Добавление {devices_count} устройств на {period_label}'
)
if not success:
@@ -1024,7 +1115,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price,
description=f'Добавление {devices_count} устройств на {charged_months} мес',
description=f'Добавление {devices_count} устройств на {period_label}',
)
await db.refresh(db_user)
@@ -1035,7 +1126,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
f'📱 Добавлено: {devices_count} устройств\n'
f'Новый лимит: {subscription.device_limit} устройств\n'
)
success_text += f'💰 Списано: {texts.format_price(price)} (за {charged_months} мес)'
success_text += f'💰 Списано: {texts.format_price(price)} (за {period_label})'
if total_discount > 0:
success_text += f' (скидка {devices_discount_percent}%: -{texts.format_price(total_discount)})'
+1 -1
View File
@@ -428,7 +428,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
type=type_text,
end_date=format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M'),
days_left=max(0, subscription.days_left),
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
traffic_used=texts.format_traffic(subscription.traffic_used_gb, is_limit=False),
traffic_limit=traffic_text,
countries_count=len(subscription.connected_squads),
devices_used=devices_used,
+2 -2
View File
@@ -3084,8 +3084,8 @@ async def handle_subscription_settings(callback: types.CallbackQuery, db_user: U
settings_text = settings_template.format(
countries_count=len(subscription.connected_squads),
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
traffic_limit=texts.format_traffic(subscription.traffic_limit_gb),
traffic_used=texts.format_traffic(subscription.traffic_used_gb, is_limit=False),
traffic_limit=texts.format_traffic(subscription.traffic_limit_gb, is_limit=True),
devices_used=devices_used,
devices_limit=devices_limit_display,
)
+20 -1
View File
@@ -21,6 +21,7 @@ from app.keyboards.inline import (
from app.localization.texts import get_texts
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import SubscriptionService
from app.services.user_cart_service import user_cart_service
from app.states import SubscriptionStates
from app.utils.pricing_utils import (
apply_percentage_discount,
@@ -239,7 +240,7 @@ async def handle_reset_traffic(callback: types.CallbackQuery, db_user: User, db:
await callback.message.edit_text(
f'🔄 <b>Сброс трафика</b>\n\n'
f'Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n'
f'Использовано: {texts.format_traffic(subscription.traffic_used_gb, is_limit=False)}\n'
f'Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}\n\n'
f'Стоимость сброса: {texts.format_price(reset_price)}{price_info}{balance_info}\n\n'
'После сброса счетчик использованного трафика станет равным 0.',
@@ -495,6 +496,24 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
if db_user.balance_kopeks < price:
missing_kopeks = price - db_user.balance_kopeks
# Save cart for auto-purchase after balance top-up
cart_data = {
'cart_mode': 'add_traffic',
'subscription_id': subscription.id,
'traffic_gb': traffic_gb,
'price_kopeks': price,
'base_price_kopeks': discounted_per_month,
'discount_percent': discount_result['percent'],
'source': 'bot',
'description': f'Докупка {traffic_gb} ГБ трафика',
}
try:
await user_cart_service.save_user_cart(db_user.id, cart_data)
logger.info(f'Cart saved for traffic purchase (bot) user {db_user.telegram_id}: +{traffic_gb} GB')
except Exception as e:
logger.error(f'Error saving cart for traffic purchase (bot): {e}')
message_text = texts.t(
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
(
+6
View File
@@ -649,6 +649,12 @@ def get_referral_contest_manage_keyboard(
callback_data=f'admin_contest_edit_times_{contest_id}',
),
],
[
InlineKeyboardButton(
text='👻 Виртуальные',
callback_data=f'admin_contest_vp_{contest_id}',
),
],
[
InlineKeyboardButton(
text='🔄 Синхронизация',
+33 -10
View File
@@ -1924,12 +1924,28 @@ def get_change_devices_keyboard(
texts = get_texts(language)
months_multiplier = 1
period_text = ''
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
if months_multiplier > 1:
period_text = f' (за {months_multiplier} мес)'
# Проверяем является ли тариф суточным
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
# Для суточных тарифов считаем по дням, для обычных - по месяцам
if is_daily_tariff and subscription_end_date:
# Суточный тариф: цена за оставшиеся дни (обычно 1 день)
from datetime import datetime
now = datetime.utcnow()
days_left = max(1, (subscription_end_date - now).days)
# Множитель = days_left / 30 (как в кабинете)
price_multiplier = days_left / 30
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
else:
# Обычный тариф: цена за оставшиеся месяцы
months_multiplier = 1
period_text = ''
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
if months_multiplier > 1:
period_text = f' (за {months_multiplier} мес)'
price_multiplier = months_multiplier
# Используем цену из тарифа если есть, иначе глобальную настройку
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
@@ -1943,7 +1959,12 @@ def get_change_devices_keyboard(
buttons = []
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20
# Используем max_device_limit из тарифа если есть, иначе глобальную настройку
tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None
if tariff_max_devices and tariff_max_devices > 0:
max_devices = tariff_max_devices
else:
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20
start_range = max(1, min(current_devices - 3, max_devices - 6))
end_range = min(max_devices + 1, max(current_devices + 4, 7))
@@ -1967,10 +1988,12 @@ def get_change_devices_keyboard(
price_per_month,
discount_percent,
)
total_price = discounted_per_month * months_multiplier
total_price = int(discounted_per_month * price_multiplier)
total_price = max(100, total_price) # Минимум 1 рубль
price_text = f' (+{total_price // 100}{period_text})'
if discount_percent > 0 and discount_per_month * months_multiplier > 0:
price_text += f' (скидка {discount_percent}%: -{(discount_per_month * months_multiplier) // 100}₽)'
total_discount = int(discount_per_month * price_multiplier)
if discount_percent > 0 and total_discount > 0:
price_text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
action_text = ''
else:
price_text = ' (бесплатно)'
+8 -2
View File
@@ -190,9 +190,15 @@ class Texts:
return settings.format_price(kopeks)
@staticmethod
def format_traffic(gb: float) -> str:
def format_traffic(gb: float, is_limit: bool = True) -> str:
"""Format traffic value.
Args:
gb: Traffic in gigabytes
is_limit: If True, 0 means unlimited. If False, 0 means zero used.
"""
if gb == 0:
return '∞ (безлимит)'
return '∞ (безлимит)' if is_limit else '0 ГБ'
if gb >= 1024:
return f'{gb / 1024:.1f} ТБ'
return f'{gb:.0f} ГБ'
+9 -1
View File
@@ -15,7 +15,7 @@ from app.database.crud.campaign import get_campaign_by_start_parameter
from app.database.crud.subscription import deactivate_subscription, reactivate_subscription
from app.database.crud.user import get_user_by_telegram_id
from app.database.database import AsyncSessionLocal
from app.database.models import SubscriptionStatus
from app.database.models import SubscriptionStatus, UserStatus
from app.keyboards.inline import get_channel_sub_keyboard
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
@@ -396,6 +396,14 @@ class ChannelCheckerMiddleware(BaseMiddleware):
if not user or not user.subscription:
return
# НЕ реактивируем подписку заблокированных пользователей
if user.status == UserStatus.BLOCKED.value:
logger.info(
'🚫 Пропуск реактивации подписки для заблокированного пользователя %s',
telegram_id,
)
return
subscription = user.subscription
# Реактивируем только DISABLED подписки
+412
View File
@@ -4,6 +4,7 @@ import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup
@@ -18,6 +19,10 @@ from app.handlers.admin.messages import (
)
if TYPE_CHECKING:
from app.cabinet.services.email_service import EmailService
logger = logging.getLogger(__name__)
@@ -25,6 +30,10 @@ VALID_MEDIA_TYPES = {'photo', 'video', 'document'}
LARGE_BROADCAST_THRESHOLD = 20_000
PROGRESS_UPDATE_STEP = 5_000
# Email broadcast rate limiting: max 8 emails per second
EMAIL_RATE_LIMIT = 8
EMAIL_BATCH_SIZE = 50
@dataclass(slots=True)
class BroadcastMediaConfig:
@@ -42,6 +51,16 @@ class BroadcastConfig:
initiator_name: str | None = None
@dataclass
class EmailBroadcastConfig:
"""Configuration for email broadcast."""
target: str
email_subject: str
email_html_content: str
initiator_name: str | None = None
@dataclass(slots=True)
class _BroadcastTask:
task: asyncio.Task
@@ -473,3 +492,396 @@ class BroadcastService:
broadcast_service = BroadcastService()
class EmailBroadcastService:
"""Handles email broadcast execution triggered from the admin web API."""
def __init__(self) -> None:
self._email_service: EmailService | None = None
self._tasks: dict[int, _BroadcastTask] = {}
self._lock = asyncio.Lock()
def set_email_service(self, email_service: EmailService) -> None:
"""Set email service instance."""
self._email_service = email_service
def is_running(self, broadcast_id: int) -> bool:
"""Check if broadcast is currently running."""
task_entry = self._tasks.get(broadcast_id)
return bool(task_entry and not task_entry.task.done())
async def start_broadcast(self, broadcast_id: int, config: EmailBroadcastConfig) -> None:
"""Start email broadcast in background."""
if self._email_service is None:
logger.error('Cannot start email broadcast %s: email service not initialized', broadcast_id)
await self._mark_failed(broadcast_id)
return
if not self._email_service.is_configured():
logger.error('Cannot start email broadcast %s: SMTP not configured', broadcast_id)
await self._mark_failed(broadcast_id)
return
cancel_event = asyncio.Event()
async with self._lock:
if broadcast_id in self._tasks and not self._tasks[broadcast_id].task.done():
logger.warning('Email broadcast %s is already running', broadcast_id)
return
task = asyncio.create_task(
self._run_broadcast(broadcast_id, config, cancel_event),
name=f'email-broadcast-{broadcast_id}',
)
self._tasks[broadcast_id] = _BroadcastTask(task=task, cancel_event=cancel_event)
task.add_done_callback(lambda _: self._tasks.pop(broadcast_id, None))
async def request_stop(self, broadcast_id: int) -> bool:
"""Request to stop a running broadcast."""
async with self._lock:
task_entry = self._tasks.get(broadcast_id)
if not task_entry:
return False
task_entry.cancel_event.set()
return True
async def _run_broadcast(
self,
broadcast_id: int,
config: EmailBroadcastConfig,
cancel_event: asyncio.Event,
) -> None:
"""Execute email broadcast."""
sent_count = 0
failed_count = 0
try:
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
# Update status to in_progress
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
logger.error('Broadcast record %s not found', broadcast_id)
return
broadcast.status = 'in_progress'
broadcast.sent_count = 0
broadcast.failed_count = 0
await session.commit()
# Fetch email recipients
recipients = await self._fetch_email_recipients(config.target)
# Update total count
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
logger.error('Broadcast record %s deleted before start', broadcast_id)
return
broadcast.total_count = len(recipients)
await session.commit()
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
if not recipients:
logger.info('Email broadcast %s: no recipients found', broadcast_id)
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
return
# Send emails with rate limiting
sent_count, failed_count, was_cancelled = await self._send_emails(
broadcast_id,
recipients,
config,
cancel_event,
)
if was_cancelled:
logger.info('Email broadcast %s was cancelled during execution', broadcast_id)
return
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
except asyncio.CancelledError:
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
raise
except Exception as exc:
logger.exception('Critical error in email broadcast %s: %s', broadcast_id, exc)
await self._mark_failed(broadcast_id, sent_count, failed_count)
async def _fetch_email_recipients(self, target: str) -> list:
"""Fetch email recipients based on target filter."""
from sqlalchemy import select
from app.database.models import Subscription, SubscriptionStatus, User
async with AsyncSessionLocal() as session:
# Base query: verified email users with active status
base_conditions = [
User.email.isnot(None),
User.email_verified == True,
User.status == 'active',
]
if target == 'all_email':
# All users with verified email
query = select(User).where(*base_conditions)
elif target == 'email_only':
# Only email-registered users (no telegram)
query = select(User).where(
*base_conditions,
User.auth_type == 'email',
)
elif target == 'telegram_with_email':
# Telegram users who also have email
query = select(User).where(
*base_conditions,
User.auth_type == 'telegram',
User.telegram_id.isnot(None),
)
elif target == 'active_email':
# Email users with active subscription
query = (
select(User)
.join(Subscription, User.id == Subscription.user_id)
.where(
*base_conditions,
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
)
elif target == 'expired_email':
# Email users with expired subscription
query = (
select(User)
.join(Subscription, User.id == Subscription.user_id)
.where(
*base_conditions,
Subscription.status.in_(
[
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
]
),
)
)
else:
logger.warning('Unknown email target filter: %s', target)
return []
# Load users in batches
users: list = []
offset = 0
batch_size = 1000
while True:
result = await session.execute(query.offset(offset).limit(batch_size))
batch = result.scalars().all()
if not batch:
break
users.extend(batch)
offset += batch_size
return users
async def _send_emails(
self,
broadcast_id: int,
recipients: list,
config: EmailBroadcastConfig,
cancel_event: asyncio.Event,
) -> tuple[int, int, bool]:
"""Send emails with rate limiting."""
sent_count = 0
failed_count = 0
# Semaphore for rate limiting (max EMAIL_RATE_LIMIT concurrent sends)
semaphore = asyncio.Semaphore(EMAIL_RATE_LIMIT)
async def send_single_email(user) -> bool | None:
"""Send single email with rate limiting."""
async with semaphore:
if cancel_event.is_set():
return None
email = getattr(user, 'email', None)
if not email:
return None
# Render template with variables
html_content = self._render_template(config.email_html_content, user)
subject = self._render_template(config.email_subject, user)
try:
# Run sync email send in executor to not block event loop
loop = asyncio.get_event_loop()
success = await loop.run_in_executor(
None,
self._email_service.send_email,
email,
subject,
html_content,
)
return success
except Exception as exc:
logger.error(
'Error sending email broadcast %s to %s: %s',
broadcast_id,
email,
exc,
)
return False
# Process in batches
for i in range(0, len(recipients), EMAIL_BATCH_SIZE):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return sent_count, failed_count, True
batch = recipients[i : i + EMAIL_BATCH_SIZE]
tasks = [send_single_email(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if result is True:
sent_count += 1
elif result is None:
# Skipped (cancelled or no email)
pass
else:
failed_count += 1
# Update progress periodically
processed = sent_count + failed_count
if processed % PROGRESS_UPDATE_STEP == 0 or i + EMAIL_BATCH_SIZE >= len(recipients):
await self._update_progress(broadcast_id, sent_count, failed_count)
# Rate limiting delay between batches (ensure ~8 emails/sec)
await asyncio.sleep(EMAIL_BATCH_SIZE / EMAIL_RATE_LIMIT)
return sent_count, failed_count, False
def _render_template(self, template: str, user) -> str:
"""Render template with user variables."""
if not template:
return template
# Get user name
user_name = getattr(user, 'username', None)
if not user_name:
user_name = getattr(user, 'first_name', None) or ''
if last_name := getattr(user, 'last_name', None):
user_name = f'{user_name} {last_name}'.strip()
if not user_name:
user_name = getattr(user, 'email', '').split('@')[0] if getattr(user, 'email', None) else 'User'
email = getattr(user, 'email', '') or ''
# Replace template variables
result = template.replace('{{user_name}}', user_name)
result = result.replace('{{email}}', email)
return result
async def _mark_finished(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
*,
cancelled: bool,
) -> None:
"""Mark broadcast as finished."""
status = 'cancelled' if cancelled else ('completed' if failed_count == 0 else 'partial')
await self._safe_status_update(broadcast_id, sent_count, failed_count, status=status)
async def _mark_cancelled(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
) -> None:
"""Mark broadcast as cancelled."""
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=True)
async def _mark_failed(
self,
broadcast_id: int,
sent_count: int = 0,
failed_count: int = 0,
) -> None:
"""Mark broadcast as failed."""
await self._safe_status_update(broadcast_id, sent_count, failed_count, status='failed')
async def _update_progress(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
) -> None:
"""Update broadcast progress."""
await self._safe_status_update(
broadcast_id,
sent_count,
failed_count,
status='in_progress',
update_completed_at=False,
)
async def _safe_status_update(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
*,
status: str,
update_completed_at: bool = True,
) -> None:
"""Safely update broadcast status with retry."""
attempts = 0
while attempts < 2:
try:
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
return
broadcast.sent_count = sent_count
broadcast.failed_count = failed_count
broadcast.status = status
if update_completed_at:
broadcast.completed_at = datetime.utcnow()
await session.commit()
return
except InterfaceError as exc:
attempts += 1
logger.warning(
'Connection issue updating email broadcast %s: %s. Retry %s/2',
broadcast_id,
exc,
attempts,
)
await asyncio.sleep(0.2)
except SQLAlchemyError:
logger.exception('Failed to update email broadcast status %s', broadcast_id)
return
email_broadcast_service = EmailBroadcastService()
+1 -57
View File
@@ -1193,57 +1193,7 @@ class MonitoringService:
try:
get_texts(user.language)
# Рассчитываем минимальную цену за подписку с минимальной конфигурацией
from app.config import PERIOD_PRICES, settings
from app.utils.pricing_utils import apply_percentage_discount
# Базовая цена за 30 дней
base_price_original = PERIOD_PRICES.get(30, settings.PRICE_30_DAYS)
# Применяем скидку промогруппы для категории "period"
promo_group_discount = user.get_promo_discount('period', 30) if user else 0
# Применяем пользовательскую промо-скидку (если есть)
user_discount_percent = self._get_user_promo_offer_discount_percent(user)
# Общая скидка - максимальная из промогруппы и пользовательской
total_discount_percent = max(promo_group_discount, user_discount_percent)
base_price, _ = apply_percentage_discount(base_price_original, total_discount_percent)
# Добавляем цену за трафик (если фиксированный трафик включён)
if settings.is_traffic_fixed():
traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit())
# Применяем скидки на трафик
traffic_discount = user.get_promo_discount('traffic', 30) if user else 0
traffic_price, _ = apply_percentage_discount(traffic_price, traffic_discount)
else:
traffic_price = 0 # Трафик не фиксирован, цена включена в базовую
# Добавляем цену за серверы (предполагаем минимум 1 сервер по минимальной цене)
# Вместо сложного запроса к БД, используем настройки
# Для минимальной конфигурации - один сервер с минимальной ценой
min_server_price = getattr(settings, 'MIN_SERVER_PRICE', 0) or 0
if min_server_price == 0:
# Если нет явной минимальной цены, используем базовую цену
# В реальных условиях цена сервера будет определяться в ходе оформления подписки
min_server_price = 0
# Добавляем цену за устройства (если больше базового лимита)
# В минимальной конфигурации - базовый лимит, без доп. устройств
device_limit = settings.DEFAULT_DEVICE_LIMIT
additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT)
additional_devices * settings.PRICE_PER_DEVICE
# Для простоты и правильной работы без обращения к БД, рассчитываем минимальную цену как:
# базовая цена + минимальная цена за трафик (если есть фиксированный)
min_server_price = 0 # для минимальной конфигурации с 1 сервером используем 0 или минимальную известную
# Попробуем получить минимальную цену сервера из настроек или используем подходящее значение
# Находим минимальную возможную цену из возможных цен серверов
# В упрощенном варианте используем базовую конфигурацию: базовая цена + трафик
min_total_price = base_price + traffic_price
message = f"""
message = """
🎁 <b>Тестовая подписка скоро закончится!</b>
Ваша тестовая подписка истекает через 2 часа.
@@ -1251,12 +1201,6 @@ class MonitoringService:
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку!
🔥 <b>Специальное предложение:</b>
30 дней всего за {settings.format_price(min_total_price)}
Безлимитный трафик
Все серверы доступны
Скорость до 1ГБит/сек
Успейте оформить до окончания тестового периода!
"""
+22
View File
@@ -132,6 +132,28 @@ class PaymentCommonMixin:
payment_method_title: str | None = None,
) -> None:
"""Отправляет пользователю уведомление об успешном платеже."""
# Lazy import to avoid circular dependency
from app.cabinet.routes.websocket import notify_user_balance_topup
# Send WebSocket notification to cabinet frontend (works for both Telegram and email-only users)
user_id = getattr(user, 'id', None) if user else None
if user_id:
try:
# Get new balance from user
new_balance = getattr(user, 'balance_kopeks', 0)
await notify_user_balance_topup(
user_id=user_id,
amount_kopeks=amount_kopeks,
new_balance_kopeks=new_balance,
description=payment_method_title or '',
)
except Exception as ws_error:
logger.warning(
'Не удалось отправить WS уведомление о пополнении баланса для user_id=%s: %s',
user_id,
ws_error,
)
if not getattr(self, 'bot', None):
# Если бот не передан (например, внутри фоновых задач), уведомление пропускаем.
return
+52
View File
@@ -12,6 +12,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
@@ -424,6 +428,54 @@ class HeleketPaymentMixin:
else:
logger.info(f'Пропуск Telegram-уведомления Heleket для email-пользователя {user.id}')
# Автопокупка из сохранённой корзины и умная автоактивация
try:
from app.services.user_cart_service import user_cart_service
has_saved_cart = await user_cart_service.has_user_cart(user.id)
auto_purchase_success = False
if has_saved_cart:
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=getattr(self, 'bot', None),
)
except Exception as auto_error:
logger.error(
'Ошибка автоматической покупки подписки для пользователя %s: %s',
user.id,
auto_error,
exc_info=True,
)
if auto_purchase_success:
has_saved_cart = False
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(
db,
user,
bot=getattr(self, 'bot', None),
topup_amount=amount_kopeks,
)
except Exception as auto_activate_error:
logger.error(
'Ошибка умной автоактивации для пользователя %s: %s',
user.id,
auto_activate_error,
exc_info=True,
)
except Exception as error:
logger.error(
'Ошибка при работе с автоактивацией для пользователя %s: %s',
user.id,
error,
exc_info=True,
)
return updated_payment
async def process_heleket_webhook(
+114 -1
View File
@@ -109,6 +109,13 @@ def _get_method_defaults() -> dict:
{'id': 'sbp', 'name': 'СБП'},
],
},
'kassa_ai': {
'default_display_name': settings.get_kassa_ai_display_name(),
'is_configured': settings.is_kassa_ai_enabled(),
'default_min': settings.KASSA_AI_MIN_AMOUNT_KOPEKS,
'default_max': settings.KASSA_AI_MAX_AMOUNT_KOPEKS,
'available_sub_options': None,
},
}
@@ -146,6 +153,7 @@ DEFAULT_METHOD_ORDER = [
'wata',
'freekassa',
'cloudpayments',
'kassa_ai',
]
@@ -253,7 +261,7 @@ async def update_config(
config.allowed_promo_groups = groups
await db.commit()
await db.refresh(config, attribute_names=['allowed_promo_groups'])
await db.refresh(config)
return config
@@ -272,3 +280,108 @@ async def get_all_promo_groups(db: AsyncSession) -> list[PromoGroup]:
"""Get all promo groups for the filter selector."""
result = await db.execute(select(PromoGroup).order_by(PromoGroup.priority.desc(), PromoGroup.name))
return list(result.scalars().all())
# ============ User-facing methods ============
async def get_enabled_methods_for_user(
db: AsyncSession,
user: 'User | None' = None,
is_first_topup: bool | None = None,
) -> list[dict]:
"""Get payment methods available for a specific user.
Applies all filters from PaymentMethodConfig:
- is_enabled
- is_provider_configured (from env)
- user_type_filter
- first_topup_filter
- promo_group_filter
Returns list of dicts with method info ready for API response.
"""
from app.database.models import UserPromoGroup
configs = await get_all_configs(db)
defaults = _get_method_defaults()
result = []
for config in configs:
method_id = config.method_id
method_def = defaults.get(method_id, {})
# Skip if not enabled in admin panel
if not config.is_enabled:
continue
# Skip if provider not configured in env
if not method_def.get('is_configured', False):
continue
# Apply user_type_filter
if user and config.user_type_filter != 'all':
if config.user_type_filter == 'telegram' and not user.telegram_id:
continue
if config.user_type_filter == 'email' and not getattr(user, 'email', None):
continue
# Apply first_topup_filter
if config.first_topup_filter != 'any' and is_first_topup is not None:
if config.first_topup_filter == 'yes' and not is_first_topup:
continue
if config.first_topup_filter == 'no' and is_first_topup:
continue
# Apply promo_group_filter
if config.promo_group_filter_mode == 'selected' and user:
allowed_group_ids = {pg.id for pg in config.allowed_promo_groups}
if allowed_group_ids:
# Get user's promo groups
user_groups_result = await db.execute(
select(UserPromoGroup.promo_group_id).where(UserPromoGroup.user_id == user.id)
)
user_group_ids = set(user_groups_result.scalars().all())
# Check if user has at least one allowed group
if not user_group_ids.intersection(allowed_group_ids):
continue
# Build display name
display_name = config.display_name or method_def.get('default_display_name', method_id)
# Build min/max amounts (DB overrides env defaults)
min_amount = (
config.min_amount_kopeks if config.min_amount_kopeks is not None else method_def.get('default_min', 1000)
)
max_amount = (
config.max_amount_kopeks
if config.max_amount_kopeks is not None
else method_def.get('default_max', 10000000)
)
# Build options (filter by sub_options config)
options = None
available_sub_options = method_def.get('available_sub_options')
if available_sub_options and config.sub_options:
enabled_options = []
for opt in available_sub_options:
opt_id = opt['id']
if config.sub_options.get(opt_id, True):
enabled_options.append(opt)
if enabled_options:
options = enabled_options
result.append(
{
'id': method_id,
'name': display_name,
'min_amount_kopeks': min_amount,
'max_amount_kopeks': max_amount,
'options': options,
'sort_order': config.sort_order,
}
)
return result
@@ -22,6 +22,7 @@ from app.database.models import (
CryptoBotPayment,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
Pal24Payment,
PaymentMethod,
@@ -109,6 +110,8 @@ def method_display_name(method: PaymentMethod) -> str:
return 'CloudPayments'
if method == PaymentMethod.FREEKASSA:
return 'Freekassa'
if method == PaymentMethod.KASSA_AI:
return settings.get_kassa_ai_display_name()
if method == PaymentMethod.TELEGRAM_STARS:
return 'Telegram Stars'
return method.value
@@ -133,6 +136,8 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
return settings.is_cloudpayments_enabled()
if method == PaymentMethod.FREEKASSA:
return settings.is_freekassa_enabled()
if method == PaymentMethod.KASSA_AI:
return settings.is_kassa_ai_enabled()
return False
@@ -356,6 +361,13 @@ def _is_freekassa_pending(payment: FreekassaPayment) -> bool:
return status in {'pending', 'created', 'processing'}
def _is_kassa_ai_pending(payment: KassaAiPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or '').lower()
return status in {'pending', 'created', 'processing'}
def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
payload = payment.payload or ''
match = re.search(r'_(\d+)$', payload)
@@ -648,6 +660,31 @@ async def _fetch_freekassa_payments(db: AsyncSession, cutoff: datetime) -> list[
return records
async def _fetch_kassa_ai_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(KassaAiPayment)
.options(selectinload(KassaAiPayment.user))
.where(KassaAiPayment.created_at >= cutoff)
.order_by(desc(KassaAiPayment.created_at))
)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
if not _is_kassa_ai_pending(payment):
continue
record = _build_record(
PaymentMethod.KASSA_AI,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
)
if record:
records.append(record)
return records
async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(Transaction)
@@ -694,6 +731,7 @@ async def list_recent_pending_payments(
await _fetch_cryptobot_payments(db, cutoff),
await _fetch_cloudpayments_payments(db, cutoff),
await _fetch_freekassa_payments(db, cutoff),
await _fetch_kassa_ai_payments(db, cutoff),
await _fetch_stars_transactions(db, cutoff),
)
@@ -848,6 +886,20 @@ async def get_payment_record(
is_paid=bool(payment.is_paid),
)
if method == PaymentMethod.KASSA_AI:
payment = await db.get(KassaAiPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=['user'])
return _build_record(
method,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
)
if method == PaymentMethod.TELEGRAM_STARS:
transaction = await db.get(Transaction, local_payment_id)
if not transaction:
+13 -11
View File
@@ -12,10 +12,11 @@ from app.config import settings
from app.database.crud.referral_contest import (
add_contest_event,
get_contest_events_count,
get_contest_leaderboard,
get_contest_leaderboard_with_virtual,
get_contests_for_events,
get_contests_for_summaries,
get_referrer_score,
list_virtual_participants,
mark_daily_summary_sent,
mark_final_summary_sent,
)
@@ -173,8 +174,10 @@ class ReferralContestService:
day_start_utc = day_start_local.astimezone(UTC).replace(tzinfo=None)
day_end_utc = day_end_local.astimezone(UTC).replace(tzinfo=None)
leaderboard = list(await get_contest_leaderboard(db, contest.id))
total_events = await get_contest_events_count(db, contest.id)
leaderboard = await get_contest_leaderboard_with_virtual(db, contest.id)
virtual_participants = await list_virtual_participants(db, contest.id)
virtual_count = sum(vp.referral_count for vp in virtual_participants)
total_events = await get_contest_events_count(db, contest.id) + virtual_count
today_events = await get_contest_events_count(
db,
contest.id,
@@ -269,7 +272,7 @@ class ReferralContestService:
self,
*,
contest: ReferralContest,
leaderboard: Sequence[tuple[User, int, int]],
leaderboard: Sequence[tuple[str, int, int, bool]],
total_events: int,
today_events: int,
is_final: bool,
@@ -293,10 +296,9 @@ class ReferralContestService:
]
if leaderboard:
for idx, (user, score, _) in enumerate(leaderboard[:5], start=1):
name = user.full_name
user_id_display = user.telegram_id or user.email or f'#{user.id}'
lines.append(f'{idx}. {name} ({user_id_display}) — {score}')
for idx, (name, score, _, is_virtual) in enumerate(leaderboard[:5], start=1):
virt_mark = ' 👻' if is_virtual else ''
lines.append(f'{idx}. {name}{virt_mark}{score}')
else:
lines.append('Пока нет участников.')
@@ -318,7 +320,7 @@ class ReferralContestService:
self,
*,
contest: ReferralContest,
leaderboard: Sequence[tuple[User, int, int]],
leaderboard: Sequence[tuple[str, int, int, bool]],
total_events: int,
today_events: int,
is_final: bool,
@@ -346,8 +348,8 @@ class ReferralContestService:
]
if leaderboard:
for idx, (user, score, _) in enumerate(leaderboard[:5], start=1):
lines.append(f'{idx}. {user.full_name}{score}')
for idx, (name, score, _, _is_virtual) in enumerate(leaderboard[:5], start=1):
lines.append(f'{idx}. {name}{score}')
else:
lines.append('Пока нет участников.')
+88 -55
View File
@@ -236,6 +236,18 @@ class RemnaWaveService:
"""Возвращает текущее время в UTC без привязки к часовому поясу."""
return datetime.now(self._utc_timezone).replace(tzinfo=None)
def _local_to_utc(self, local_dt: datetime) -> datetime:
"""Конвертирует naive локальную дату (в таймзоне панели/бота) в naive UTC.
Используется для корректного сравнения дат из БД с датами из RemnaWave.
"""
if local_dt.tzinfo is not None:
# Уже есть tzinfo - конвертируем напрямую
return local_dt.astimezone(self._utc_timezone).replace(tzinfo=None)
# Naive datetime - интерпретируем как локальное время панели
local_aware = local_dt.replace(tzinfo=self._panel_timezone)
return local_aware.astimezone(self._utc_timezone).replace(tzinfo=None)
def _parse_remnawave_date(self, date_str: str) -> datetime:
if not date_str:
return self._now_utc() + timedelta(days=30)
@@ -253,14 +265,14 @@ class RemnaWaveService:
parsed_date = datetime.fromisoformat(cleaned_date)
# Панель RemnaWave всегда отдаёт время в UTC
# Если есть tzinfo - конвертируем в UTC, иначе считаем что уже UTC
if parsed_date.tzinfo is not None:
localized = parsed_date.astimezone(self._panel_timezone)
utc_normalized = parsed_date.astimezone(self._utc_timezone).replace(tzinfo=None)
else:
localized = parsed_date.replace(tzinfo=self._panel_timezone)
utc_normalized = parsed_date
utc_normalized = localized.astimezone(self._utc_timezone).replace(tzinfo=None)
logger.debug(f'Успешно распарсена дата: {date_str} -> {utc_normalized} (нормализовано в UTC)')
logger.debug(f'Успешно распарсена дата: {date_str} -> {utc_normalized} (UTC)')
return utc_normalized
except Exception as e:
@@ -268,27 +280,33 @@ class RemnaWaveService:
return self._now_utc() + timedelta(days=30)
def _safe_expire_at_for_panel(self, expire_at: datetime | None) -> datetime:
"""Гарантирует, что дата окончания не в прошлом для панели."""
"""Гарантирует, что дата окончания не в прошлом для панели.
Принимает naive UTC datetime, возвращает naive datetime в таймзоне панели.
"""
now = self._now_utc()
minimum_expire = now + timedelta(minutes=1)
if not expire_at:
return minimum_expire
result = minimum_expire
else:
normalized_expire = expire_at
if normalized_expire.tzinfo is not None:
normalized_expire = normalized_expire.replace(tzinfo=None)
normalized_expire = expire_at
if normalized_expire.tzinfo is not None:
normalized_expire = normalized_expire.replace(tzinfo=None)
if normalized_expire < minimum_expire:
logger.debug(
'⚙️ Коррекция даты истечения (%s) до минимально допустимой (%s) для панели',
normalized_expire,
minimum_expire,
)
result = minimum_expire
else:
result = normalized_expire
if normalized_expire < minimum_expire:
logger.debug(
'⚙️ Коррекция даты истечения (%s) до минимально допустимой (%s) для панели',
normalized_expire,
minimum_expire,
)
return minimum_expire
return normalized_expire
# Панель RemnaWave ожидает время в UTC
return result
def _safe_panel_expire_date(self, panel_user: dict[str, Any]) -> datetime:
"""Парсит дату окончания подписки пользователя панели для сравнения."""
@@ -1139,7 +1157,7 @@ class RemnaWaveService:
'status': user_obj.status.value,
'telegramId': user_obj.telegram_id,
'email': user_obj.email, # Email для синхронизации email-only пользователей
'expireAt': user_obj.expire_at.isoformat() + 'Z',
'expireAt': user_obj.expire_at.replace(tzinfo=None).isoformat(),
'trafficLimitBytes': user_obj.traffic_limit_bytes,
'usedTrafficBytes': user_obj.used_traffic_bytes,
'hwidDeviceLimit': user_obj.hwid_device_limit,
@@ -1283,20 +1301,24 @@ class RemnaWaveService:
logger.info(f'🔄 Обновлены поля {updated_fields} для пользователя {telegram_id}')
await db.flush() # Сохраняем изменения без коммита
# Проверяем, есть ли у пользователя подписка, загруженная с пользователем
if hasattr(db_user, 'subscription') and db_user.subscription:
# Используем уже загруженную подписку
await self._update_subscription_from_panel_data(db, db_user, panel_user)
else:
# Если подписки нет, создаем новую
await self._create_subscription_from_panel_data(db, db_user, panel_user)
# Обновляем UUID ДО операций с подпиской, чтобы избежать
# greenlet_spawn ошибки при доступе к атрибутам после flush
_, uuid_mutation = self._ensure_user_remnawave_uuid(
db_user,
panel_user.get('uuid'),
bot_users_by_uuid,
)
# Используем async запрос вместо доступа к relationship,
# чтобы избежать lazy-load в async контексте
from app.database.crud.subscription import get_subscription_by_user_id as _get_sub
existing_sub = await _get_sub(db, db_user.id)
if existing_sub:
await self._update_subscription_from_panel_data(db, db_user, panel_user)
else:
await self._create_subscription_from_panel_data(db, db_user, panel_user)
stats['updated'] += 1
logger.debug(f'✅ Обновлён пользователь {telegram_id}')
@@ -1369,8 +1391,12 @@ class RemnaWaveService:
if panel_uuid and not db_user.remnawave_uuid:
db_user.remnawave_uuid = panel_uuid
# Обновляем или создаем подписку
if hasattr(db_user, 'subscription') and db_user.subscription:
# Используем async запрос вместо доступа к relationship,
# чтобы избежать lazy-load (greenlet_spawn) в async контексте
from app.database.crud.subscription import get_subscription_by_user_id as _get_sub_email
existing_sub = await _get_sub_email(db, db_user.id)
if existing_sub:
await self._update_subscription_from_panel_data(db, db_user, panel_user)
else:
await self._create_subscription_from_panel_data(db, db_user, panel_user)
@@ -1637,18 +1663,9 @@ class RemnaWaveService:
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.models import SubscriptionStatus
# Сначала пытаемся использовать уже загруженную подписку, если она есть
subscription = None
try:
# Проверяем, что подписка уже загружена (была загружена через selectinload)
if hasattr(user, 'subscription') and user.subscription:
subscription = user.subscription
else:
# В противном случае, получаем подписку через CRUD метод
subscription = await get_subscription_by_user_id(db, user.id)
except:
# Если не удалось получить подписку через ленивую загрузку
subscription = await get_subscription_by_user_id(db, user.id)
# Всегда используем async CRUD запрос для получения подписки,
# чтобы избежать lazy-load (greenlet_spawn) в async контексте
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription:
await self._create_subscription_from_panel_data(db, user, panel_user)
@@ -1658,25 +1675,36 @@ class RemnaWaveService:
expire_at_str = panel_user.get('expireAt', '')
if expire_at_str:
# expire_at приходит в UTC (naive) из _parse_remnawave_date
expire_at = self._parse_remnawave_date(expire_at_str)
# Конвертируем локальную дату из БД в UTC для корректного сравнения
# subscription.end_date хранится в локальной таймзоне (MSK)
local_end_date_utc = self._local_to_utc(subscription.end_date)
# КРИТИЧНО: НЕ перезаписываем end_date если локальная дата ПОЗЖЕ
# Это защищает от ситуации когда подписка была продлена в боте,
# но RemnaWave ещё не получил обновление или вернул старую дату
time_diff = abs((subscription.end_date - expire_at).total_seconds())
time_diff = abs((local_end_date_utc - expire_at).total_seconds())
if time_diff > 60:
if expire_at > subscription.end_date:
if expire_at > local_end_date_utc:
# RemnaWave имеет более позднюю дату - обновляем
subscription.end_date = expire_at
# Конвертируем UTC обратно в локальное время для сохранения в БД
new_end_date_local = (
expire_at.replace(tzinfo=self._utc_timezone)
.astimezone(self._panel_timezone)
.replace(tzinfo=None)
)
logger.info(
f'✅ Sync: обновлена end_date для user {getattr(user, "telegram_id", "?")}: '
f'{subscription.end_date} -> {expire_at} (разница: {time_diff:.0f}с)'
f'{subscription.end_date} -> {new_end_date_local} (разница: {time_diff:.0f}с)'
)
subscription.end_date = new_end_date_local
else:
# Локальная дата позже - НЕ перезаписываем, логируем предупреждение
logger.warning(
f' Sync: пропускаем обновление end_date для user {getattr(user, "telegram_id", "?")}: '
f'локальная дата ({subscription.end_date}) позже чем в RemnaWave ({expire_at})'
# Локальная дата позже - НЕ перезаписываем
logger.debug(
f' Sync: end_date для user {getattr(user, "telegram_id", "?")} актуальна: '
f'локальная ({subscription.end_date} / UTC: {local_end_date_utc}) >= RemnaWave ({expire_at} UTC)'
)
else:
logger.debug(
@@ -1685,18 +1713,21 @@ class RemnaWaveService:
)
current_time = self._now_utc()
if panel_status == 'ACTIVE' and subscription.end_date > current_time:
# Конвертируем end_date в UTC для корректного сравнения с current_time
end_date_utc = self._local_to_utc(subscription.end_date)
if panel_status == 'ACTIVE' and end_date_utc > current_time:
new_status = SubscriptionStatus.ACTIVE.value
elif panel_status == 'DISABLED':
new_status = SubscriptionStatus.DISABLED.value
elif subscription.end_date <= current_time:
elif end_date_utc <= current_time:
# КРИТИЧНО: НЕ деактивируем если текущий статус ACTIVE
# Это защищает от race condition когда sync использует старую end_date из памяти,
# а реальная end_date уже обновлена продлением
if subscription.status == SubscriptionStatus.ACTIVE.value:
logger.warning(
f'⚠️ Sync: пропускаем деактивацию подписки user {getattr(user, "telegram_id", "?")}: '
f'статус ACTIVE, end_date в памяти ({subscription.end_date}) <= now. '
f'статус ACTIVE, end_date ({subscription.end_date} / UTC: {end_date_utc}) <= now ({current_time}). '
f'Деактивация будет выполнена через middleware с буфером.'
)
new_status = subscription.status # Сохраняем текущий статус
@@ -2493,13 +2524,15 @@ class RemnaWaveService:
issues_fixed = 0
current_time = self._now_utc()
# Конвертируем end_date в UTC для корректного сравнения
end_date_utc = self._local_to_utc(subscription.end_date)
# Добавляем буфер 5 минут для защиты от race condition при продлении
expiry_buffer = timedelta(minutes=5)
if (
subscription.end_date + expiry_buffer <= current_time
end_date_utc + expiry_buffer <= current_time
and subscription.status == SubscriptionStatus.ACTIVE.value
):
time_since_expiry = current_time - subscription.end_date
time_since_expiry = current_time - end_date_utc
logger.warning(
f'🔧 fix_data_issues: деактивируем подписку {subscription.id} '
f'(user={user.telegram_id}), просрочена на {time_since_expiry}'
@@ -347,6 +347,9 @@ async def _auto_extend_subscription(
*,
bot: Bot | None = None,
) -> bool:
# Lazy import to avoid circular dependency
from app.cabinet.routes.websocket import notify_user_subscription_renewed
try:
prepared = await _prepare_auto_extend_context(db, user, cart_data)
except Exception as error: # pragma: no cover - defensive logging
@@ -559,6 +562,20 @@ async def _auto_extend_subscription(
_format_user_id(user),
)
# Send WebSocket notification to cabinet frontend
try:
await notify_user_subscription_renewed(
user_id=user.id,
new_expires_at=new_end_date.isoformat() if new_end_date else '',
amount_kopeks=prepared.price_kopeks,
)
except Exception as ws_error:
logger.warning(
'⚠️ Автопокупка: не удалось отправить WS уведомление о продлении для %s: %s',
_format_user_id(user),
ws_error,
)
return True
@@ -570,6 +587,11 @@ async def _auto_purchase_tariff(
bot: Bot | None = None,
) -> bool:
"""Автоматическая покупка периодного тарифа из сохранённой корзины."""
# Lazy imports to avoid circular dependency
from app.cabinet.routes.websocket import (
notify_user_subscription_activated,
notify_user_subscription_renewed,
)
from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.subscription import (
create_paid_subscription,
@@ -814,6 +836,29 @@ async def _auto_purchase_tariff(
_format_user_id(user),
)
# Send WebSocket notification to cabinet frontend
try:
if existing_subscription:
# Renewal of existing subscription
await notify_user_subscription_renewed(
user_id=user.id,
new_expires_at=subscription.end_date.isoformat() if subscription.end_date else '',
amount_kopeks=final_price,
)
else:
# New subscription activation
await notify_user_subscription_activated(
user_id=user.id,
expires_at=subscription.end_date.isoformat() if subscription.end_date else '',
tariff_name=tariff.name,
)
except Exception as ws_error:
logger.warning(
'⚠️ Автопокупка тарифа: не удалось отправить WS уведомление для %s: %s',
_format_user_id(user),
ws_error,
)
return True
@@ -827,6 +872,11 @@ async def _auto_purchase_daily_tariff(
"""Автоматическая покупка суточного тарифа из сохранённой корзины."""
from datetime import datetime, timedelta
# Lazy imports to avoid circular dependency
from app.cabinet.routes.websocket import (
notify_user_subscription_activated,
notify_user_subscription_renewed,
)
from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id
from app.database.crud.tariff import get_tariff_by_id
@@ -1051,6 +1101,458 @@ async def _auto_purchase_daily_tariff(
_format_user_id(user),
)
# Send WebSocket notification to cabinet frontend
try:
if existing_subscription:
# Renewal/upgrade of existing subscription
await notify_user_subscription_renewed(
user_id=user.id,
new_expires_at=subscription.end_date.isoformat() if subscription.end_date else '',
amount_kopeks=daily_price,
)
else:
# New subscription activation
await notify_user_subscription_activated(
user_id=user.id,
expires_at=subscription.end_date.isoformat() if subscription.end_date else '',
tariff_name=tariff.name,
)
except Exception as ws_error:
logger.warning(
'⚠️ Автопокупка суточного тарифа: не удалось отправить WS уведомление для %s: %s',
_format_user_id(user),
ws_error,
)
return True
async def _auto_add_devices(
db: AsyncSession,
user: User,
cart_data: dict,
*,
bot: Bot | None = None,
) -> bool:
"""Auto-purchase devices from saved cart after balance topup."""
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.crud.user import subtract_user_balance
from app.database.models import PaymentMethod
devices_to_add = _safe_int(cart_data.get('devices_to_add'))
price_kopeks = _safe_int(cart_data.get('price_kopeks'))
if devices_to_add <= 0 or price_kopeks <= 0:
logger.warning(
'🔁 Автопокупка устройств: некорректные данные корзины для пользователя %s (devices=%s, price=%s)',
_format_user_id(user),
devices_to_add,
price_kopeks,
)
return False
# Проверяем баланс
if user.balance_kopeks < price_kopeks:
logger.info(
'🔁 Автопокупка устройств: у пользователя %s недостаточно средств (%s < %s)',
_format_user_id(user),
user.balance_kopeks,
price_kopeks,
)
return False
# Проверяем подписку
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription:
logger.warning(
'🔁 Автопокупка устройств: у пользователя %s нет подписки',
_format_user_id(user),
)
await user_cart_service.delete_user_cart(user.id)
return False
if subscription.status not in ('active', 'trial', 'ACTIVE', 'TRIAL'):
logger.warning(
'🔁 Автопокупка устройств: подписка пользователя %s не активна (status=%s)',
_format_user_id(user),
subscription.status,
)
await user_cart_service.delete_user_cart(user.id)
return False
# Списываем баланс
description = f'Покупка {devices_to_add} доп. устройств'
try:
success = await subtract_user_balance(
db,
user,
price_kopeks,
description,
create_transaction=True,
payment_method=PaymentMethod.BALANCE,
)
if not success:
logger.warning(
'❌ Автопокупка устройств: не удалось списать баланс пользователя %s',
_format_user_id(user),
)
return False
except Exception as error:
logger.error(
'❌ Автопокупка устройств: ошибка списания баланса пользователя %s: %s',
_format_user_id(user),
error,
exc_info=True,
)
return False
# Добавляем устройства
old_device_limit = subscription.device_limit or 1
subscription.device_limit = old_device_limit + devices_to_add
try:
await db.commit()
await db.refresh(subscription)
except Exception as error:
logger.error(
'❌ Автопокупка устройств: ошибка сохранения подписки пользователя %s: %s',
_format_user_id(user),
error,
exc_info=True,
)
await db.rollback()
return False
# Синхронизация с RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as error:
logger.warning(
'⚠️ Автопокупка устройств: не удалось обновить Remnawave для пользователя %s: %s',
_format_user_id(user),
error,
)
# Очищаем корзину (транзакция уже создана в subtract_user_balance)
await user_cart_service.delete_user_cart(user.id)
logger.info(
'✅ Автопокупка устройств: пользователь %s добавил %s устройств (было %s, стало %s) за %s коп.',
_format_user_id(user),
devices_to_add,
old_device_limit,
subscription.device_limit,
price_kopeks,
)
# WebSocket уведомление для кабинета
try:
from app.cabinet.routes.websocket import notify_user_devices_purchased
await notify_user_devices_purchased(
user_id=user.id,
devices_added=devices_to_add,
new_device_limit=subscription.device_limit,
amount_kopeks=price_kopeks,
)
except Exception as ws_error:
logger.warning(
'⚠️ Автопокупка устройств: не удалось отправить WebSocket уведомление: %s',
ws_error,
)
# Уведомление пользователю
if bot and user.telegram_id:
texts = get_texts(getattr(user, 'language', 'ru'))
try:
message = texts.t(
'AUTO_PURCHASE_DEVICES_SUCCESS',
(
'✅ <b>Устройства добавлены автоматически!</b>\n\n'
'📱 Добавлено: {devices_to_add} устройств\n'
'📊 Новый лимит: {new_limit} устройств\n'
'💰 Списано: {price}'
),
).format(
devices_to_add=devices_to_add,
new_limit=subscription.device_limit,
price=texts.format_price(price_kopeks),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
callback_data='menu_subscription',
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '🏠 Главное меню'),
callback_data='back_to_menu',
)
],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=message,
reply_markup=keyboard,
parse_mode='HTML',
)
except Exception as error:
logger.warning(
'⚠️ Автопокупка устройств: не удалось уведомить пользователя %s: %s',
user.telegram_id,
error,
)
# Уведомление админам
if bot:
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_update_notification(
db,
user,
subscription,
'devices',
old_device_limit,
subscription.device_limit,
price_kopeks,
)
except Exception as error:
logger.warning(
'⚠️ Автопокупка устройств: не удалось уведомить админов: %s',
error,
)
return True
async def _auto_add_traffic(
db: AsyncSession,
user: User,
cart_data: dict,
*,
bot: Bot | None = None,
) -> bool:
"""Auto-purchase traffic from saved cart after balance topup."""
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from app.database.crud.subscription import add_subscription_traffic, get_subscription_by_user_id
from app.database.crud.user import subtract_user_balance
from app.database.models import PaymentMethod
traffic_gb = _safe_int(cart_data.get('traffic_gb'))
price_kopeks = _safe_int(cart_data.get('price_kopeks'))
if traffic_gb <= 0 or price_kopeks <= 0:
logger.warning(
'🔁 Автопокупка трафика: некорректные данные корзины для пользователя %s (traffic_gb=%s, price=%s)',
_format_user_id(user),
traffic_gb,
price_kopeks,
)
return False
# Verify balance
if user.balance_kopeks < price_kopeks:
logger.info(
'🔁 Автопокупка трафика: у пользователя %s недостаточно средств (%s < %s)',
_format_user_id(user),
user.balance_kopeks,
price_kopeks,
)
return False
# Verify subscription
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription:
logger.warning(
'🔁 Автопокупка трафика: у пользователя %s нет подписки',
_format_user_id(user),
)
await user_cart_service.delete_user_cart(user.id)
return False
if subscription.status not in ('active', 'trial', 'ACTIVE', 'TRIAL'):
logger.warning(
'🔁 Автопокупка трафика: подписка пользователя %s не активна (status=%s)',
_format_user_id(user),
subscription.status,
)
await user_cart_service.delete_user_cart(user.id)
return False
if subscription.is_trial:
logger.warning(
'🔁 Автопокупка трафика: у пользователя %s пробная подписка',
_format_user_id(user),
)
await user_cart_service.delete_user_cart(user.id)
return False
if subscription.traffic_limit_gb == 0:
logger.warning(
'🔁 Автопокупка трафика: у пользователя %s уже безлимитный трафик',
_format_user_id(user),
)
await user_cart_service.delete_user_cart(user.id)
return False
# Deduct balance
description = f'Докупка {traffic_gb} ГБ трафика'
try:
success = await subtract_user_balance(
db,
user,
price_kopeks,
description,
create_transaction=True,
payment_method=PaymentMethod.BALANCE,
)
if not success:
logger.warning(
'❌ Автопокупка трафика: не удалось списать баланс пользователя %s',
_format_user_id(user),
)
return False
except Exception as error:
logger.error(
'❌ Автопокупка трафика: ошибка списания баланса пользователя %s: %s',
_format_user_id(user),
error,
exc_info=True,
)
return False
# Add traffic
old_traffic_limit = subscription.traffic_limit_gb or 0
try:
await add_subscription_traffic(db, subscription, traffic_gb)
await db.commit()
await db.refresh(subscription)
except Exception as error:
logger.error(
'❌ Автопокупка трафика: ошибка добавления трафика пользователю %s: %s',
_format_user_id(user),
error,
exc_info=True,
)
await db.rollback()
return False
# Sync with RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as error:
logger.warning(
'⚠️ Автопокупка трафика: не удалось обновить Remnawave для пользователя %s: %s',
_format_user_id(user),
error,
)
# Clear cart (transaction already created in subtract_user_balance)
await user_cart_service.delete_user_cart(user.id)
logger.info(
'✅ Автопокупка трафика: пользователь %s добавил %s ГБ (было %s, стало %s) за %s коп.',
_format_user_id(user),
traffic_gb,
old_traffic_limit,
subscription.traffic_limit_gb,
price_kopeks,
)
# WebSocket notification for cabinet
try:
from app.cabinet.routes.websocket import notify_user_traffic_purchased
await notify_user_traffic_purchased(
user_id=user.id,
traffic_gb_added=traffic_gb,
new_traffic_limit_gb=subscription.traffic_limit_gb or 0,
amount_kopeks=price_kopeks,
)
except Exception as ws_error:
logger.warning(
'⚠️ Автопокупка трафика: не удалось отправить WebSocket уведомление: %s',
ws_error,
)
# User notification
if bot and user.telegram_id:
texts = get_texts(getattr(user, 'language', 'ru'))
try:
message = texts.t(
'AUTO_PURCHASE_TRAFFIC_SUCCESS',
(
'✅ <b>Трафик добавлен автоматически!</b>\n\n'
'📈 Добавлено: {traffic_gb} ГБ\n'
'📊 Новый лимит: {new_limit} ГБ\n'
'💰 Списано: {price}'
),
).format(
traffic_gb=traffic_gb,
new_limit=subscription.traffic_limit_gb,
price=texts.format_price(price_kopeks),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
callback_data='menu_subscription',
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '🏠 Главное меню'),
callback_data='back_to_menu',
)
],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=message,
reply_markup=keyboard,
parse_mode='HTML',
)
except Exception as error:
logger.warning(
'⚠️ Автопокупка трафика: не удалось уведомить пользователя %s: %s',
user.telegram_id,
error,
)
# Admin notification
if bot:
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_update_notification(
db,
user,
subscription,
'traffic',
old_traffic_limit,
subscription.traffic_limit_gb,
price_kopeks,
)
except Exception as error:
logger.warning(
'⚠️ Автопокупка трафика: не удалось уведомить админов: %s',
error,
)
return True
@@ -1061,6 +1563,14 @@ async def auto_purchase_saved_cart_after_topup(
bot: Bot | None = None,
) -> bool:
"""Attempts to automatically purchase a subscription from a saved cart."""
from datetime import datetime, timedelta
# Lazy imports to avoid circular dependency
from app.cabinet.routes.websocket import (
notify_user_subscription_activated,
notify_user_subscription_renewed,
)
from app.database.crud.transaction import get_user_transactions
if not settings.is_auto_purchase_after_topup_enabled():
return False
@@ -1076,6 +1586,33 @@ async def auto_purchase_saved_cart_after_topup(
cart_mode = cart_data.get('cart_mode') or cart_data.get('mode')
# Защита от race condition: если подписка была куплена/продлена в последние 60 секунд,
# пропускаем автопокупку чтобы избежать двойного списания
if cart_mode in ('extend', 'tariff_purchase', 'daily_tariff_purchase'):
try:
recent_transactions = await get_user_transactions(db, user.id, limit=1)
if recent_transactions:
last_tx = recent_transactions[0]
if (
last_tx.type == TransactionType.SUBSCRIPTION_PAYMENT
and last_tx.created_at
and (datetime.utcnow() - last_tx.created_at) < timedelta(seconds=60)
):
logger.info(
'🔁 Автопокупка: пропускаем для пользователя %s - подписка уже куплена %s секунд назад',
_format_user_id(user),
(datetime.utcnow() - last_tx.created_at).total_seconds(),
)
# Очищаем корзину чтобы не срабатывало повторно
await user_cart_service.delete_user_cart(user.id)
return False
except Exception as check_error:
logger.warning(
'🔁 Автопокупка: ошибка проверки последней транзакции для %s: %s',
_format_user_id(user),
check_error,
)
# Обработка продления подписки
if cart_mode == 'extend':
return await _auto_extend_subscription(db, user, cart_data, bot=bot)
@@ -1088,6 +1625,14 @@ async def auto_purchase_saved_cart_after_topup(
if cart_mode == 'daily_tariff_purchase':
return await _auto_purchase_daily_tariff(db, user, cart_data, bot=bot)
# Обработка докупки устройств
if cart_mode == 'add_devices':
return await _auto_add_devices(db, user, cart_data, bot=bot)
# Обработка докупки трафика
if cart_mode == 'add_traffic':
return await _auto_add_traffic(db, user, cart_data, bot=bot)
try:
prepared = await _prepare_auto_purchase(db, user, cart_data)
except PurchaseValidationError as error:
@@ -1243,6 +1788,29 @@ async def auto_purchase_saved_cart_after_topup(
_format_user_id(user),
)
# Send WebSocket notification to cabinet frontend
try:
if was_trial_conversion:
# Trial conversion = activation
await notify_user_subscription_activated(
user_id=user.id,
expires_at=subscription.end_date.isoformat() if subscription and subscription.end_date else '',
tariff_name='',
)
else:
# Regular purchase = renewal or new activation
await notify_user_subscription_renewed(
user_id=user.id,
new_expires_at=subscription.end_date.isoformat() if subscription and subscription.end_date else '',
amount_kopeks=pricing.final_total,
)
except Exception as ws_error:
logger.warning(
'⚠️ Автопокупка: не удалось отправить WS уведомление для %s: %s',
_format_user_id(user),
ws_error,
)
return True
@@ -1273,6 +1841,11 @@ async def auto_activate_subscription_after_topup(
"""
from datetime import datetime
# Lazy imports to avoid circular dependency
from app.cabinet.routes.websocket import (
notify_user_subscription_activated,
notify_user_subscription_renewed,
)
from app.database.crud.server_squad import get_available_server_squads, get_server_ids_by_uuids
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id
from app.database.crud.transaction import create_transaction
@@ -1397,6 +1970,20 @@ async def auto_activate_subscription_after_topup(
best_price,
)
# Send WebSocket notification to cabinet frontend
try:
await notify_user_subscription_renewed(
user_id=user.id,
new_expires_at=result.subscription.end_date.isoformat() if result.subscription.end_date else '',
amount_kopeks=best_price,
)
except Exception as ws_error:
logger.warning(
'⚠️ Автоактивация: не удалось отправить WS уведомление о продлении для %s: %s',
_format_user_id(user),
ws_error,
)
# Уведомление пользователю (только для Telegram-пользователей)
if bot and user.telegram_id:
try:
@@ -1475,6 +2062,20 @@ async def auto_activate_subscription_after_topup(
best_price,
)
# Send WebSocket notification to cabinet frontend
try:
await notify_user_subscription_activated(
user_id=user.id,
expires_at=new_subscription.end_date.isoformat() if new_subscription.end_date else '',
tariff_name='',
)
except Exception as ws_error:
logger.warning(
'⚠️ Автоактивация: не удалось отправить WS уведомление об активации для %s: %s',
_format_user_id(user),
ws_error,
)
# Уведомление пользователю (только для Telegram-пользователей)
if bot and user.telegram_id:
try:
@@ -1542,9 +2143,11 @@ async def auto_activate_subscription_after_topup(
e,
exc_info=True,
)
try:
await db.rollback()
except Exception:
pass
return (False, False)
await db.rollback()
return False
__all__ = ['auto_activate_subscription_after_topup', 'auto_purchase_saved_cart_after_topup']
+10
View File
@@ -446,6 +446,11 @@ class SubscriptionService:
return True
except Exception as e:
error_msg = str(e).lower()
# "User already disabled" - считаем успехом
if 'already disabled' in error_msg:
logger.info(f'✅ RemnaWave пользователь {user_uuid} уже отключен')
return True
logger.error(f'Ошибка отключения RemnaWave пользователя: {e}')
return False
@@ -458,6 +463,11 @@ class SubscriptionService:
return True
except Exception as e:
error_msg = str(e).lower()
# "User already enabled" - считаем успехом
if 'already enabled' in error_msg:
logger.info(f'✅ RemnaWave пользователь {user_uuid} уже включен')
return True
logger.error(f'Ошибка включения RemnaWave пользователя: {e}')
return False
+9 -4
View File
@@ -52,6 +52,7 @@ class UserCartService:
"""
client = self._get_redis_client()
if client is None:
logger.warning(f'🛒 Redis недоступен, корзина пользователя {user_id} НЕ сохранена')
return False
try:
@@ -59,10 +60,11 @@ class UserCartService:
json_data = json.dumps(cart_data, ensure_ascii=False)
effective_ttl = ttl if ttl is not None else settings.CART_TTL_SECONDS
await client.setex(key, effective_ttl, json_data)
logger.debug(f'Корзина пользователя {user_id} сохранена в Redis')
cart_mode = cart_data.get('cart_mode', 'unknown')
logger.info(f'🛒 Корзина пользователя {user_id} сохранена в Redis (mode={cart_mode}, ttl={effective_ttl}s)')
return True
except Exception as e:
logger.error(f'Ошибка сохранения корзины пользователя {user_id}: {e}')
logger.error(f'🛒 Ошибка сохранения корзины пользователя {user_id}: {e}')
return False
async def get_user_cart(self, user_id: int) -> dict[str, Any] | None:
@@ -127,14 +129,17 @@ class UserCartService:
"""
client = self._get_redis_client()
if client is None:
logger.warning(f'🛒 Redis недоступен, проверка корзины пользователя {user_id} невозможна')
return False
try:
key = f'user_cart:{user_id}'
exists = await client.exists(key)
return bool(exists)
result = bool(exists)
logger.info(f'🛒 Проверка корзины пользователя {user_id}: {"найдена" if result else "не найдена"}')
return result
except Exception as e:
logger.error(f'Ошибка проверки наличия корзины пользователя {user_id}: {e}')
logger.error(f'🛒 Ошибка проверки наличия корзины пользователя {user_id}: {e}')
return False
+12
View File
@@ -49,6 +49,7 @@ from app.database.models import (
User,
UserMessage,
UserStatus,
WataPayment,
WelcomeText,
YooKassaPayment,
)
@@ -989,6 +990,17 @@ class UserService:
except Exception as e:
logger.error(f'❌ Ошибка удаления подписки: {e}')
try:
wata_payments_result = await db.execute(select(WataPayment).where(WataPayment.user_id == user_id))
wata_payments = wata_payments_result.scalars().all()
if wata_payments:
logger.info(f'🔄 Удаляем {len(wata_payments)} Wata платежей')
await db.execute(delete(WataPayment).where(WataPayment.user_id == user_id))
await db.flush()
except Exception as e:
logger.error(f'❌ Ошибка удаления Wata платежей: {e}')
try:
await db.execute(delete(User).where(User.id == user_id))
await db.commit()
+3
View File
@@ -115,6 +115,9 @@ class AdminStates(StatesGroup):
creating_referral_contest_end = State()
creating_referral_contest_time = State()
editing_referral_contest_summary_times = State()
adding_virtual_participant_name = State()
adding_virtual_participant_count = State()
editing_virtual_participant_count = State()
editing_daily_contest_field = State()
editing_daily_contest_value = State()
+12
View File
@@ -34,6 +34,18 @@ def get_local_timezone() -> ZoneInfo:
return ZoneInfo('UTC')
def panel_datetime_to_naive_utc(dt: datetime) -> datetime:
"""Convert a panel datetime to naive UTC.
Panel API returns local time with a misleading UTC offset (+00:00 / Z).
This strips the offset, interprets the raw value as panel-local time,
then converts to naive UTC for database storage.
"""
naive = dt.replace(tzinfo=None)
localized = naive.replace(tzinfo=get_local_timezone())
return localized.astimezone(ZoneInfo('UTC')).replace(tzinfo=None)
def to_local_datetime(dt: datetime | None) -> datetime | None:
"""Convert a datetime value to the configured local timezone."""
+6
View File
@@ -287,6 +287,12 @@ async def main():
traffic_monitoring_scheduler.set_bot(bot)
daily_subscription_service.set_bot(bot)
# Initialize email broadcast service
from app.cabinet.services.email_service import email_service
from app.services.broadcast_service import email_broadcast_service
email_broadcast_service.set_email_service(email_service)
from app.services.admin_notification_service import AdminNotificationService
async with timeline.stage(
-658
View File
@@ -1,658 +0,0 @@
{
"config": {
"additionalLocales": [
"ru",
"zh",
"fa"
],
"branding": {
"name": "Subscription",
"logoUrl": "https://raw.githubusercontent.com/Fr1ngg/remnawave-bedolaga-telegram-bot/bf0c1ce711a26fa2f24559e7e4443820e68d758b/assets/bedolaga_app3.svg",
"supportUrl": "https://t.me"
}
},
"platforms": {
"ios": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
"buttonText": {
"en": "Open in App Store [EU]",
"fa": "باز کردن در App Store [EU]",
"ru": "Открыть в App Store [EU]",
"zh": "在 App Store 中打开 [EU]"
}
},
{
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
"buttonText": {
"en": "Open in App Store [RU]",
"fa": "باز کردن در App Store [RU]",
"ru": "Открыть в App Store [RU]",
"zh": "在 App Store 中打开 [RU]"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
},
{
"id": "streisand",
"name": "Streisand",
"isFeatured": false,
"urlScheme": "streisand://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/ru/app/streisand/id6450534064",
"buttonText": {
"en": "Open in App Store",
"fa": "باز کردن در App Store",
"ru": "Открыть в App Store",
"zh": "在 App Store 中打开"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
},
{
"id": "shadowrocket",
"name": "Shadowrocket",
"isFeatured": false,
"urlScheme": "sub://",
"isNeedBase64Encoding": true,
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/ru/app/shadowrocket/id932747118",
"buttonText": {
"en": "Open in App Store",
"fa": "باز کردن در App Store",
"ru": "Открыть в App Store",
"zh": "在 App Store 中打开"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"android": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
"buttonText": {
"en": "Open in Google Play",
"fa": "باز کردن در Google Play",
"ru": "Открыть в Google Play",
"zh": "在 Google Play 中打开"
}
},
{
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
"buttonText": {
"en": "Download APK",
"fa": "دانلود APK",
"ru": "Скачать APK",
"zh": "下载 APK"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"fa": "صفحه را در Google Play باز کنید و برنامه را نصب کنید. یا برنامه را مستقیماً از فایل APK نصب کنید، اگر Google Play کار نمی کند.",
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает.",
"zh": "在 Google Play 中打开页面并安装应用。如果 Google Play 无法使用,也可以直接从 APK 文件安装应用。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"fa": "برنامه را باز کنید و به سرور متصل شوید",
"ru": "Откройте приложение и подключитесь к серверу",
"zh": "打开应用并连接到服务器"
}
}
},
{
"id": "clash-meta",
"name": "Clash Meta",
"isFeatured": false,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/MetaCubeX/ClashMetaForAndroid/releases/download/v2.11.7/cmfa-2.11.7-meta-universal-release.apk",
"buttonText": {
"en": "Download APK",
"fa": "دانلود APK",
"ru": "Скачать APK",
"zh": "下载 APK"
}
},
{
"buttonLink": "https://f-droid.org/packages/com.github.metacubex.clash.meta/",
"buttonText": {
"en": "Open in F-Droid",
"fa": "در F-Droid باز کنید",
"ru": "Открыть в F-Droid",
"zh": "在 F-Droid 中打开"
}
}
],
"description": {
"en": "Download and install Clash Meta APK",
"fa": "دانلود و نصب Clash Meta APK",
"ru": "Скачайте и установите Clash Meta APK",
"zh": "下载并安装 Clash Meta APK"
}
},
"addSubscriptionStep": {
"description": {
"en": "Tap the button to import configuration",
"fa": "برای وارد کردن پیکربندی روی دکمه ضربه بزنید",
"ru": "Нажмите кнопку, чтобы импортировать конфигурацию",
"zh": "点击按钮导入配置"
}
},
"connectAndUseStep": {
"description": {
"en": "Open Clash Meta and tap on Connect",
"fa": "Clash Meta را باز کنید و روی اتصال ضربه بزنید",
"ru": "Откройте Clash Meta и нажмите Подключиться",
"zh": "打开 Clash Meta 并点击连接"
}
}
}
],
"macos": [
{
"id": "clash-verge",
"name": "Clash Verge",
"isFeatured": true,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
"buttonText": {
"en": "macOS (Intel)",
"fa": "مک (اینتل)",
"ru": "macOS (Intel)",
"zh": "macOS (Intel)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
"buttonText": {
"en": "macOS (Apple Silicon)",
"fa": "مک (Apple Silicon)",
"ru": "macOS (Apple Silicon)",
"zh": "macOS (Apple Silicon)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
}
},
"additionalBeforeAddSubscriptionStep": {
"buttons": [],
"description": {
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
"fa": "پس از راه‌اندازی برنامه، می‌توانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
},
"title": {
"en": "Change language",
"fa": "تغییر زبان",
"ru": "Смена языка",
"zh": "更改语言"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"additionalAfterAddSubscriptionStep": {
"buttons": [],
"title": {
"en": "If the subscription is not added",
"fa": "اگر اشتراک در برنامه نصب نشده است",
"ru": "Если подписка не добавилась",
"zh": "如果订阅未添加"
},
"description": {
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایل‌ها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
"fa": "می‌توانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
"ru": "Выбрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
}
}
},
{
"id": "hiddify",
"name": "Hiddify",
"isFeatured": false,
"urlScheme": "hiddify://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
"buttonText": {
"en": "macOS",
"fa": "مک",
"ru": "macOS",
"zh": "macOS"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"windows": [
{
"id": "clash-verge",
"name": "Clash Verge",
"isFeatured": true,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
"buttonText": {
"en": "macOS (Intel)",
"fa": "مک (اینتل)",
"ru": "macOS (Intel)",
"zh": "macOS (Intel)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
"buttonText": {
"en": "macOS (Apple Silicon)",
"fa": "مک (Apple Silicon)",
"ru": "macOS (Apple Silicon)",
"zh": "macOS (Apple Silicon)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
}
},
"additionalBeforeAddSubscriptionStep": {
"buttons": [],
"description": {
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
"fa": "پس از راه‌اندازی برنامه، می‌توانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
},
"title": {
"en": "Change language",
"fa": "تغییر زبان",
"ru": "Смена языка",
"zh": "更改语言"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"additionalAfterAddSubscriptionStep": {
"buttons": [],
"title": {
"en": "If the subscription is not added",
"fa": "اگر اشتراک در برنامه نصب نشده است",
"ru": "Если подписка не добавилась",
"zh": "如果订阅未添加"
},
"description": {
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایل‌ها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
"fa": "می‌توانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
"ru": "Выبрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
}
}
},
{
"id": "hiddify",
"name": "Hiddify",
"isFeatured": false,
"urlScheme": "hiddify://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
"buttonText": {
"en": "macOS",
"fa": "مک",
"ru": "macOS",
"zh": "macOS"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"linux": [],
"androidTV": [
{
"id": "new-app-androidtv-1760203310792",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Button TextGoogle Play",
"zh": "Button Text",
"fa": "Button Text"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение",
"zh": "-",
"fa": "-"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
"zh": "-",
"fa": "-"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
"zh": "-",
"fa": "-"
}
}
}
],
"appleTV": [
{
"id": "new-app-appletv-1760203488851",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Google Play",
"zh": "Button Text",
"fa": "Button Text"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение",
"zh": "-",
"fa": "-"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
"zh": "-",
"fa": "-"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
"zh": "-",
"fa": "-"
}
}
}
]
}
}
-21531
View File
File diff suppressed because it is too large Load Diff
-282
View File
@@ -1,282 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Оплата не прошла</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@700&display=swap');
:root {
--cyber-grid-color: rgba(0, 200, 255, 0.1);
--cyber-line-color: rgba(0, 255, 200, 0.1);
--brand-neon-color: #00aaff;
--error-neon-color: #ff4d4d;
--bg-color: #1a1a2e;
--container-bg-color: rgba(20, 20, 35, 0.85);
--text-primary: #ffffff;
--text-secondary: #c0c0d0;
}
body {
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background-color: var(--bg-color);
margin: 0;
/* [!] ИСПРАВЛЕНИЕ: Уменьшен горизонтальный отступ для body */
padding: 20px 15px;
display: flex;
justify-content: center;
align-items: center;
/* [!] ИСПРАВЛЕНИЕ: height -> min-height для лучшей совместимости */
min-height: 100vh;
box-sizing: border-box;
text-align: center;
color: var(--text-secondary);
overflow: hidden;
position: relative;
}
body::before,
body::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: -1;
}
body::before {
background:
linear-gradient(to right, var(--cyber-grid-color) 1px, transparent 1px),
linear-gradient(to bottom, var(--cyber-grid-color) 1px, transparent 1px);
background-size: 50px 50px;
animation: gridScroll 20s linear infinite;
}
@keyframes gridScroll {
from { background-position: 0 0; }
to { background-position: -50px -50px; }
}
body::after {
background:
linear-gradient(45deg, transparent 49%, var(--cyber-line-color) 50%, transparent 51%),
linear-gradient(-45deg, transparent 49%, var(--cyber-line-color) 50%, transparent 51%);
background-size: 100% 100%;
animation: cyberScan 10s linear infinite alternate;
opacity: 0.3;
}
@keyframes cyberScan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
.container {
background-color: var(--container-bg-color);
border-radius: 16px;
/* [!] ИСПРАВЛЕНИЕ: Уменьшен горизонтальный отступ для .container */
padding: 30px 25px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
max-width: 400px;
width: 100%;
transform: scale(0.95);
opacity: 0;
animation: fadeInScale 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.2s forwards;
position: relative;
z-index: 1;
border: 1px solid rgba(0, 200, 255, 0.2);
backdrop-filter: blur(5px);
}
@keyframes fadeInScale {
to {
transform: scale(1);
opacity: 1;
}
}
.logo-container {
margin: 0 auto 25px auto;
line-height: 0;
overflow: hidden;
position: relative;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.logo-text {
font-family: 'Orbitron', sans-serif;
font-size: 48px;
color: var(--brand-neon-color);
text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color);
opacity: 0;
transform: translateY(-20px);
animation: logoSlideIn 0.8s ease-out 0.4s forwards, logoPulse 2s infinite ease-in-out 1.5s forwards;
white-space: nowrap;
letter-spacing: 2px;
}
@keyframes logoSlideIn {
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes logoPulse {
0% { transform: scale(1); text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color); }
50% { transform: scale(1.02); text-shadow: 0 0 15px var(--brand-neon-color), 0 0 25px var(--brand-neon-color), 0 0 35px var(--brand-neon-color); }
100% { transform: scale(1); text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color); }
}
.error-icon {
width: 80px;
height: 80px;
margin: 0 auto 20px auto;
}
.error-icon svg {
width: 100%;
height: 100%;
filter: drop-shadow(0 0 8px rgba(255, 77, 77, 0.7));
}
.error-circle {
stroke-dasharray: 166;
stroke-dashoffset: 166;
stroke-width: 3;
stroke-miterlimit: 10;
stroke: var(--error-neon-color);
fill: none;
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) 0.5s forwards;
}
.error-line {
transform-origin: 50% 50%;
stroke-dasharray: 48;
stroke-dashoffset: 48;
stroke-width: 4;
stroke: var(--error-neon-color);
fill: none;
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 1s forwards;
}
@keyframes stroke {
100% {
stroke-dashoffset: 0;
}
}
h1 {
font-size: 24px;
font-weight: 700;
margin-bottom: 15px;
color: var(--text-primary);
text-shadow: none;
}
.info-text, .instruction-text {
font-size: 16px;
line-height: 1.6;
color: var(--text-secondary);
margin-bottom: 15px;
text-shadow: none;
}
.instruction-text {
margin-bottom: 30px;
}
.cta-button {
display: inline-block;
width: 100%;
padding: 14px;
font-size: 16px;
font-weight: 700;
color: var(--bg-color);
background-color: var(--brand-neon-color);
border: none;
border-radius: 10px;
text-decoration: none;
cursor: pointer;
transition: all 0.3s ease;
box-sizing: border-box;
box-shadow: 0 0 15px rgba(0, 170, 255, 0.6);
}
.cta-button:hover {
background-color: #00c0ff;
transform: translateY(-2px);
box-shadow: 0 0 20px rgba(0, 170, 255, 0.8), 0 0 30px rgba(0, 170, 255, 0.4);
}
.cta-button:active {
transform: translateY(0);
box-shadow: 0 0 10px rgba(0, 170, 255, 0.5);
}
</style>
</head>
<body>
<div class="container">
<div class="logo-container">
<span class="logo-text">Bedolaga</span>
</div>
<div class="error-icon">
<svg class="error-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52">
<circle class="error-circle" cx="26" cy="26" r="25" fill="none"/>
<path class="error-line" fill="none" d="M16 16 36 36"/>
<path class="error-line" fill="none" d="M36 16 16 36"/>
</svg>
</div>
<h1>Оплата не прошла</h1>
<p class="info-text">
К сожалению, не удалось обработать ваш платеж. Средства не были списаны.
</p>
<p class="instruction-text">
Пожалуйста, вернитесь в бота, проверьте данные и попробуйте еще раз.
</p>
<button id="close-btn" class="cta-button">
Вернуться в бота
</button>
</div>
<script>
try {
if (window.Telegram && window.Telegram.WebApp) {
Telegram.WebApp.ready();
var closeButton = document.getElementById('close-btn');
closeButton.addEventListener('click', function() {
Telegram.WebApp.close();
});
} else {
console.warn('Telegram WebApp JS not loaded or not in TWA environment.');
}
} catch (e) {
console.error(e);
}
</script>
</body>
</html>
Binary file not shown.
-295
View File
@@ -1,295 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Оплата прошла успешно</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@700&display=swap');
:root {
--cyber-grid-color: rgba(0, 200, 255, 0.1);
--cyber-line-color: rgba(0, 255, 200, 0.1);
--brand-neon-color: #00aaff;
--success-neon-color: #00e676;
--bg-color: #1a1a2e;
--container-bg-color: rgba(20, 20, 35, 0.85);
--text-primary: #ffffff;
--text-secondary: #c0c0d0;
}
body {
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background-color: var(--bg-color);
margin: 0;
padding: 20px 15px; /* Исправлено для мобильных */
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh; /* Исправлено для мобильных */
box-sizing: border-box;
text-align: center;
color: var(--text-secondary);
overflow: hidden;
position: relative;
}
body::before,
body::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: -1;
}
body::before {
background:
linear-gradient(to right, var(--cyber-grid-color) 1px, transparent 1px),
linear-gradient(to bottom, var(--cyber-grid-color) 1px, transparent 1px);
background-size: 50px 50px;
animation: gridScroll 20s linear infinite;
}
@keyframes gridScroll {
from { background-position: 0 0; }
to { background-position: -50px -50px; }
}
body::after {
background:
linear-gradient(45deg, transparent 49%, var(--cyber-line-color) 50%, transparent 51%),
linear-gradient(-45deg, transparent 49%, var(--cyber-line-color) 50%, transparent 51%);
background-size: 100% 100%;
animation: cyberScan 10s linear infinite alternate;
opacity: 0.3;
}
@keyframes cyberScan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
.container {
background-color: var(--container-bg-color);
border-radius: 16px;
padding: 30px 25px; /* Исправлено для мобильных */
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
max-width: 400px;
width: 100%;
transform: scale(0.95);
opacity: 0;
animation: fadeInScale 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.2s forwards;
position: relative;
z-index: 1;
border: 1px solid rgba(0, 200, 255, 0.2);
backdrop-filter: blur(5px);
}
@keyframes fadeInScale {
to {
transform: scale(1);
opacity: 1;
}
}
.logo-container {
margin: 0 auto 25px auto;
line-height: 0;
overflow: hidden;
position: relative;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.logo-text {
font-family: 'Orbitron', sans-serif;
font-size: 48px;
color: var(--brand-neon-color);
text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color);
opacity: 0;
transform: translateY(-20px);
animation: logoSlideIn 0.8s ease-out 0.4s forwards, logoPulse 2s infinite ease-in-out 1.5s forwards;
white-space: nowrap;
letter-spacing: 2px;
}
@keyframes logoSlideIn {
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes logoPulse {
0% { transform: scale(1); text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color); }
50% { transform: scale(1.02); text-shadow: 0 0 15px var(--brand-neon-color), 0 0 25px var(--brand-neon-color), 0 0 35px var(--brand-neon-color); }
100% { transform: scale(1); text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color); }
}
.success-icon {
width: 80px;
height: 80px;
margin: 0 auto 20px auto;
}
.success-icon svg {
width: 100%;
height: 100%;
filter: drop-shadow(0 0 8px rgba(0, 230, 118, 0.7));
}
.checkmark__circle {
stroke-dasharray: 166;
stroke-dashoffset: 166;
stroke-width: 3;
stroke-miterlimit: 10;
stroke: var(--success-neon-color);
fill: none;
/* [!] Анимация галочки (круга) начинается с задержкой 0.5с */
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) 0.5s forwards;
}
.checkmark__check {
transform-origin: 50% 50%;
stroke-dasharray: 48;
stroke-dashoffset: 48;
stroke-width: 4;
stroke: var(--success-neon-color);
fill: none;
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 1s forwards;
}
@keyframes stroke {
100% {
stroke-dashoffset: 0;
}
}
h1 {
font-size: 24px;
font-weight: 700;
margin-bottom: 15px;
color: var(--text-primary);
text-shadow: none;
}
.info-text, .instruction-text {
font-size: 16px;
line-height: 1.6;
color: var(--text-secondary);
margin-bottom: 15px;
text-shadow: none;
}
.instruction-text {
margin-bottom: 30px;
}
.cta-button {
display: inline-block;
width: 100%;
padding: 14px;
font-size: 16px;
font-weight: 700;
color: var(--bg-color);
background-color: var(--brand-neon-color);
border: none;
border-radius: 10px;
text-decoration: none;
cursor: pointer;
transition: all 0.3s ease;
box-sizing: border-box;
box-shadow: 0 0 15px rgba(0, 170, 255, 0.6);
}
.cta-button:hover {
background-color: #00c0ff;
transform: translateY(-2px);
box-shadow: 0 0 20px rgba(0, 170, 255, 0.8), 0 0 30px rgba(0, 170, 255, 0.4);
}
.cta-button:active {
transform: translateY(0);
box-shadow: 0 0 10px rgba(0, 170, 255, 0.5);
}
</style>
</head>
<body>
<audio id="success-sound" src="/sound.mp3" preload="auto"></audio>
<div class="container">
<div class="logo-container">
<span class="logo-text">Bedolaga</span>
</div>
<div class="success-icon">
<svg class="checkmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52">
<circle class="checkmark__circle" cx="26" cy="26" r="25" fill="none"/>
<path class="checkmark__check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
</svg>
</div>
<h1>Оплата прошла успешно!</h1>
<p class="info-text">
Ваша подписка на VPN активирована. Наслаждайтесь безопасным и анонимным доступом в Интернет.
</p>
<p class="instruction-text">
Теперь вы можете вернуться в бота, чтобы получить доступ к вашим настройкам и управлять подпиской.
</p>
<button id="close-btn" class="cta-button">
Вернуться в бота
</button>
</div>
<script>
try {
if (window.Telegram && window.Telegram.WebApp) {
Telegram.WebApp.ready();
var closeButton = document.getElementById('close-btn');
closeButton.addEventListener('click', function() {
Telegram.WebApp.close();
});
} else {
console.warn('Telegram WebApp JS not loaded or not in TWA environment.');
}
// --- [!] 2. КОД ДЛЯ ВОСПРОИЗВЕДЕНИЯ ЗВУКА [!] ---
// Находим аудиофайл
const audio = document.getElementById('success-sound');
// Устанавливаем таймер. Анимация круга (.checkmark__circle)
// имеет задержку 0.5с (500мс). Мы запускаем звук в тот же момент.
setTimeout(() => {
// Пытаемся проиграть звук
var playPromise = audio.play();
if (playPromise !== undefined) {
playPromise.catch(error => {
// Блокировка авто-воспроизведения - это нормально.
console.warn('Audio autoplay blocked:', error);
});
}
}, 500); // 500 миллисекунд = 0.5с
} catch (e) {
console.error(e);
}
</script>
</body>
</html>
-703
View File
@@ -1,703 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#2481cc">
<title>Connecting to VPN...</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
:root {
--tg-theme-bg-color: #ffffff;
--tg-theme-text-color: #000000;
--tg-theme-hint-color: #999999;
--tg-theme-link-color: #2481cc;
--tg-theme-button-color: #2481cc;
--tg-theme-button-text-color: #ffffff;
--tg-theme-secondary-bg-color: #f0f0f0;
--primary: var(--tg-theme-button-color);
--primary-rgb: 36, 129, 204;
--text-primary: var(--tg-theme-text-color);
--text-secondary: var(--tg-theme-hint-color);
--bg-primary: var(--tg-theme-bg-color);
--bg-secondary: var(--tg-theme-secondary-bg-color);
--border-color: rgba(0, 0, 0, 0.08);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12);
--radius-sm: 8px;
--radius: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--info: #3b82f6;
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg-primary: #0f172a;
--bg-secondary: rgba(30, 41, 59, 0.85);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--border-color: rgba(148, 163, 184, 0.25);
--shadow-sm: 0 2px 8px rgba(2, 6, 23, 0.3);
--shadow-md: 0 4px 16px rgba(2, 6, 23, 0.45);
--shadow-lg: 0 8px 32px rgba(2, 6, 23, 0.55);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
line-height: 1.6;
display: flex;
align-items: center;
justify-content: center;
}
.container {
max-width: 480px;
width: 100%;
margin: 0 auto;
padding: 16px;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes shimmer {
to { transform: translateX(100%); }
}
.animate-in {
animation: fadeIn 0.5s ease-out forwards;
}
/* Language Switcher */
.top-bar {
position: fixed;
top: 16px;
right: 16px;
z-index: 100;
display: flex;
gap: 12px;
}
.language-select {
padding: 8px 32px 8px 12px;
border-radius: var(--radius);
border: 2px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
appearance: none;
transition: all 0.3s ease;
position: relative;
min-width: 120px;
}
.language-select:hover {
border-color: var(--primary);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
/* Theme Toggle */
.theme-toggle {
width: 44px;
height: 44px;
border-radius: var(--radius);
border: 2px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.theme-toggle:hover {
border-color: var(--primary);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
/* Header */
.header {
text-align: center;
margin-bottom: 32px;
position: relative;
}
.logo-container {
width: 80px;
height: 80px;
margin: 0 auto 16px;
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
border-radius: var(--radius-xl);
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-md);
position: relative;
overflow: hidden;
animation: pulse 2s infinite;
}
.logo-container::after {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.3), transparent);
transform: rotate(45deg);
animation: shimmer 3s infinite;
}
.logo-icon {
font-size: 40px;
color: white;
z-index: 1;
font-weight: 700;
}
.logo {
font-size: 28px;
font-weight: 800;
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 8px;
}
.subtitle {
font-size: 15px;
color: var(--text-secondary);
font-weight: 500;
}
/* Main Card */
.card {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: 32px;
box-shadow: var(--shadow-sm);
transition: all 0.3s ease;
overflow: hidden;
text-align: center;
}
.card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
/* Error State */
.card.error {
border: 2px solid var(--danger);
background: linear-gradient(135deg, var(--bg-secondary), rgba(239, 68, 68, 0.05));
}
.card.error .logo-container {
background: linear-gradient(135deg, var(--danger), rgba(239, 68, 68, 0.7));
}
/* Loading State */
.loading {
text-align: center;
padding: 24px;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid var(--bg-secondary);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 20px;
}
.loading-text {
font-size: 16px;
color: var(--text-secondary);
font-weight: 500;
margin-bottom: 8px;
}
/* Timer */
.timer-container {
background: var(--bg-primary);
border-radius: var(--radius);
padding: 24px;
margin: 24px 0;
border: 2px solid var(--border-color);
}
.timer-label {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.timer-display {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 16px;
}
.timer-number {
font-size: 48px;
font-weight: 800;
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1;
}
.timer-unit {
font-size: 16px;
color: var(--text-secondary);
font-weight: 500;
}
.progress-track {
width: 100%;
height: 6px;
background: var(--border-color);
border-radius: 3px;
overflow: hidden;
position: relative;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), rgba(var(--primary-rgb), 0.6));
border-radius: 3px;
transition: width 1s linear;
width: 100%;
position: relative;
overflow: hidden;
}
.progress-fill::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
animation: shimmer 2s infinite;
}
/* Status Text */
.status-text {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.status-description {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 24px;
}
.error-text {
color: var(--danger);
}
/* Button */
.btn {
width: 100%;
padding: 16px 24px;
border: none;
border-radius: var(--radius);
font-size: 15px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
text-transform: uppercase;
letter-spacing: 0.5px;
text-decoration: none;
}
.btn::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.btn:active::before {
width: 300px;
height: 300px;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.8));
color: white;
box-shadow: var(--shadow-md);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn-primary:active {
transform: scale(0.98);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
.btn-icon {
width: 20px;
height: 20px;
stroke-width: 2.5;
}
/* Hidden */
.hidden {
display: none !important;
}
/* Mobile Optimizations */
@media (max-width: 480px) {
.container {
padding: 12px;
}
.timer-number {
font-size: 40px;
}
.logo {
font-size: 24px;
}
}
/* Dark Mode Adjustments */
:root[data-theme="dark"] .timer-container {
background: var(--bg-secondary);
}
:root[data-theme="dark"] .btn-primary {
box-shadow: 0 10px 30px rgba(37, 99, 235, 0.45);
}
</style>
</head>
<body>
<!-- Top Bar -->
<div class="top-bar">
<select id="languageSelect" class="language-select">
<option value="en">🇬🇧 English</option>
<option value="ru">🇷🇺 Русский</option>
</select>
<button class="theme-toggle" id="themeToggle">
<svg class="icon-sun" width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 18a6 6 0 100-12 6 6 0 000 12zM12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
<svg class="icon-moon" width="20" height="20" fill="currentColor" viewBox="0 0 24 24" style="display: none;">
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
</svg>
</button>
</div>
<div class="container">
<!-- Header -->
<div class="header animate-in">
<div class="logo-container">
<div class="logo-icon" id="appIcon"></div>
</div>
<div class="logo" data-i18n="app.name">VPN</div>
<div class="subtitle" id="appSubtitle" data-i18n="app.connecting">Connecting to VPN...</div>
</div>
<!-- Main Card -->
<div class="card animate-in" id="mainCard">
<div class="loading" id="loadingState">
<div class="spinner"></div>
<div class="loading-text" id="statusText" data-i18n="status.connecting">Establishing secure connection...</div>
<div class="timer-container" id="timerContainer">
<div class="timer-label" data-i18n="timer.label">Redirecting in</div>
<div class="timer-display">
<span class="timer-number" id="timer">10</span>
<span class="timer-unit" data-i18n="timer.seconds">seconds</span>
</div>
<div class="progress-track">
<div class="progress-fill" id="progressFill"></div>
</div>
</div>
<p class="status-description" data-i18n="status.manual">If nothing happens, click the button below.</p>
</div>
<!-- Error State (hidden by default) -->
<div class="error-state hidden" id="errorState">
<div class="status-text error-text" data-i18n="error.title">Connection Error</div>
<p class="status-description" data-i18n="error.description">The connection link is missing or invalid.</p>
</div>
<!-- Action Button -->
<a class="btn btn-primary" id="actionButton" href="#" target="_blank" rel="noopener">
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span data-i18n="button.connect">Connect to VPN</span>
</a>
</div>
</div>
<script>
// App schemes configuration
const appSchemes = [
{ scheme: 'happ://', id: 'happ', icon: 'H', name: { en: 'Happ', ru: 'Happ' } },
{ scheme: 'flclash://', id: 'flclash', icon: 'F', name: { en: 'FlClash', ru: 'FlClash' } },
{ scheme: 'clash://', id: 'clash-meta', icon: 'C', name: { en: 'Clash Meta', ru: 'Clash Meta' } },
{ scheme: 'sing-box://', id: 'sing-box', icon: 'S', name: { en: 'Sing-box', ru: 'Sing-box' } },
{ scheme: 'v2rayng://', id: 'v2rayng', icon: 'V', name: { en: 'v2rayNG', ru: 'v2rayNG' } },
{ scheme: 'sub://', id: 'shadowrocket', icon: 'R', name: { en: 'Shadowrocket', ru: 'Shadowrocket' } },
{ scheme: 'hiddify://', id: 'hiddify', icon: 'H', name: { en: 'Hiddify', ru: 'Hiddify' } }
];
// Translations
const translations = {
en: {
'app.name': 'VPN',
'app.connecting': 'Connecting to VPN...',
'status.connecting': 'Establishing secure connection...',
'timer.label': 'Redirecting in',
'timer.seconds': 'seconds',
'status.manual': 'If nothing happens, click the button below.',
'button.connect': 'Connect to VPN',
'error.title': 'Connection Error',
'error.description': 'The connection link is missing or invalid. Please contact support.'
},
ru: {
'app.name': 'VPN',
'app.connecting': 'Подключение к VPN...',
'status.connecting': 'Установка безопасного соединения...',
'timer.label': 'Перенаправление через',
'timer.seconds': 'секунд',
'status.manual': 'Если ничего не происходит, нажмите кнопку ниже.',
'button.connect': 'Подключиться к VPN',
'error.title': 'Ошибка подключения',
'error.description': 'Ссылка для подключения отсутствует или недействительна. Обратитесь в поддержку.'
}
};
// Global variables
let currentLanguage = 'en';
let currentTheme = 'light';
let timerValue = 10;
let timerId = null;
let redirectTo = '';
let appInfo = null;
// Initialize
document.addEventListener('DOMContentLoaded', function() {
loadSettings();
// Get redirect URL from URL parameters
const urlParams = new URLSearchParams(window.location.search);
redirectTo = urlParams.get('redirect_to') || '';
// Detect app from URL scheme
if (redirectTo) {
appInfo = appSchemes.find(a => redirectTo.startsWith(a.scheme));
}
updateUI();
if (redirectTo) {
startTimer();
} else {
showError();
}
});
// Load saved settings
function loadSettings() {
const savedTheme = localStorage.getItem('remnawave-miniapp-theme') || 'light';
const savedLang = localStorage.getItem('remnawave-miniapp-language') || getBrowserLanguage();
currentTheme = savedTheme;
currentLanguage = savedLang;
applyTheme(savedTheme);
document.getElementById('languageSelect').value = currentLanguage;
}
// Get browser language
function getBrowserLanguage() {
const lang = navigator.language || navigator.userLanguage;
if (lang.startsWith('ru')) return 'ru';
return 'en';
}
// Apply theme
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
updateThemeToggle();
}
// Update theme toggle icon
function updateThemeToggle() {
const sunIcon = document.querySelector('.icon-sun');
const moonIcon = document.querySelector('.icon-moon');
if (currentTheme === 'dark') {
sunIcon.style.display = 'none';
moonIcon.style.display = 'block';
} else {
sunIcon.style.display = 'block';
moonIcon.style.display = 'none';
}
}
// Toggle theme
document.getElementById('themeToggle').addEventListener('click', function() {
currentTheme = currentTheme === 'dark' ? 'light' : 'dark';
localStorage.setItem('remnawave-miniapp-theme', currentTheme);
applyTheme(currentTheme);
});
// Change language
document.getElementById('languageSelect').addEventListener('change', function(e) {
currentLanguage = e.target.value;
localStorage.setItem('remnawave-miniapp-language', currentLanguage);
updateUI();
});
// Get translation
function t(key) {
return translations[currentLanguage]?.[key] || translations.en[key] || key;
}
// Update UI with translations
function updateUI() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
// Update app-specific info
if (appInfo && redirectTo) {
const appName = appInfo.name[currentLanguage] || appInfo.name.en;
document.getElementById('appIcon').textContent = appInfo.icon;
document.getElementById('appSubtitle').textContent = `${t('app.connecting').replace('VPN', appName)}`;
const statusText = document.getElementById('statusText');
if (statusText) {
statusText.textContent = `${t('status.connecting').replace('...', '')} ${appName}...`;
}
}
// Update action button
const actionButton = document.getElementById('actionButton');
if (actionButton && redirectTo) {
actionButton.href = redirectTo;
}
}
// Start countdown timer
function startTimer() {
if (!redirectTo || timerId) return;
const timerEl = document.getElementById('timer');
const progressEl = document.getElementById('progressFill');
const totalTime = 10;
timerEl.textContent = timerValue;
timerId = setInterval(() => {
timerValue--;
timerEl.textContent = timerValue;
// Update progress bar
const progress = ((totalTime - timerValue) / totalTime) * 100;
progressEl.style.width = `${100 - progress}%`;
if (timerValue <= 0) {
clearInterval(timerId);
window.location.href = redirectTo;
}
}, 1000);
}
// Show error state
function showError() {
document.getElementById('loadingState').classList.add('hidden');
document.getElementById('errorState').classList.remove('hidden');
document.getElementById('actionButton').classList.add('hidden');
document.getElementById('mainCard').classList.add('error');
document.getElementById('appIcon').textContent = '!';
// Update subtitle
document.getElementById('appSubtitle').textContent = t('error.title');
}
</script>
</body>
</html>