Compare commits

...

19 Commits

Author SHA1 Message Date
Egor 4eaaf06a17 Merge pull request #2640 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.17.1
2026-02-23 21:33:25 +03:00
github-actions[bot] 1930a9dcde chore(main): release 3.17.1 2026-02-23 18:33:00 +00:00
Egor b876c6dd0b Merge pull request #2639 from BEDOLAGA-DEV/dev
Dev
2026-02-23 21:32:13 +03:00
Fringg d15b69710c style: ruff format 2026-02-23 21:29:54 +03:00
Fringg 708bb9eec7 fix: migrate all remaining naive timestamp columns to timestamptz
Old universal_migration.py created some tables (including email_templates)
with `timestamp` (naive) columns and had a catch-all that converted all
naive columns to `timestamptz` on each startup. After switching to Alembic,
that catch-all stopped running.

Users whose email_templates table was created by universal_migration.py
before the catch-all ran still have naive `timestamp` columns. The code
uses `datetime.now(UTC)` (timezone-aware), causing asyncpg to raise:
  "can't subtract offset-naive and offset-aware datetimes"

Migration 0007 finds and converts ALL remaining naive timestamp columns
in public schema to timestamptz, assuming UTC for existing data.

Fixes: email template save returning 503 with DataError
2026-02-23 21:26:16 +03:00
Fringg 97b3f899d1 fix: add diagnostic logging for device_limit sync to RemnaWave
Users report tariff change doesn't update device count and device
purchase doesn't sync to panel. Added structured logging to trace:
- resolve_hwid_device_limit: forced limit vs subscription limit
- PATCH /api/users: payload hwidDeviceLimit vs response value
2026-02-23 19:45:00 +03:00
Fringg 5ee45f97d1 fix: show negative amounts for withdrawals in admin transaction list
Admin endpoints returned amount_kopeks as always-positive from DB,
causing withdrawals and subscription payments to display as credits
in the admin panel. User-facing balance.py already handled this correctly.
2026-02-23 19:12:51 +03:00
Fringg d4c4a8a211 fix: add missing broadcast_history columns and harden subscription logic
- Add migration 0006 for blocked_count, channel, email_subject,
  email_html_content columns missing from broadcast_history table
- Fix infinite trial reactivation loop in monitoring service
- Prevent webhook from overwriting freshly extended end_date
- Use tariff-specific pricing for auto-renewal instead of global config
2026-02-23 19:07:59 +03:00
Fringg 205c8d987d fix: use aiogram 3.x bot.download() instead of document.download() 2026-02-23 18:31:31 +03:00
Fringg ebe508302b fix: uploaded backup restore button not triggering handler
Callback data prefix was 'backup_restore_uploaded_' but the handler
listens for 'backup_restore_execute_' and 'backup_restore_clear_'.
2026-02-23 18:29:10 +03:00
Fringg c20355b06d fix: repair missing DB columns and make backup resilient to schema mismatches
- Add migration 0005 to re-apply missing columns from 0002-0004
  (fixes DBs that were auto-stamped to head without running migrations)
- Add per-table error handling in backup ORM export so one table
  failure doesn't break the entire backup
- Escape HTML in error notifications to prevent Telegram parse errors
2026-02-23 18:22:32 +03:00
Fringg 50a931ec36 fix: add int32 overflow guards and strengthen auth validation
- Add le= bounds to all user-facing Pydantic int fields (balance, subscription, traffic, devices)
- Add self-referral guard in process_referral_registration
- Add Telegram identity cross-validation to get_optional_cabinet_user
- Log when initData validation fails but header is present
2026-02-23 18:12:58 +03:00
Fringg 115c0c84c0 fix: prevent partner self-referral via own campaign link
When a partner clicks their own campaign link (any bonus_type), they get
attributed as their own referral — their purchases counted as campaign
revenue and they earn referral commissions on their own payments.

Add self-referral guards in three layers:
- auth.py: early return in _process_campaign_bonus if user is campaign partner
- campaign_service.py: defense-in-depth check in apply_campaign_bonus
- start.py: guards on all referrer_id assignments and process_referral calls
2026-02-23 18:02:25 +03:00
Fringg 973b3d3d3f fix: cross-validate Telegram identity on every authenticated request
Telegram Mini App WebView shares localStorage across accounts on the
same device. This allows refresh tokens from user A to be reused by
user B if they open the same Mini App.

Add server-side defense: read X-Telegram-Init-Data header (already sent
by the frontend), validate it cryptographically, and reject requests
where the Telegram user ID doesn't match the JWT user's telegram_id.
2026-02-23 17:53:44 +03:00
Fringg 2ef6185715 fix: cap expected_monthly_referrals to prevent int32 overflow
Add le=2_000_000_000 constraint to Pydantic schema so PostgreSQL Integer
column doesn't receive values outside int32 range.
2026-02-23 17:27:33 +03:00
Fringg ed4624c664 fix: handle RemnaWave API errors in traffic aggregation
Catch exceptions from get_all_nodes() in _aggregate_traffic() to prevent
unhandled ASGI errors when RemnaWave returns HTTP 502. Cache empty result
on failure to avoid request storms from parallel frontend calls.
2026-02-23 17:25:01 +03:00
Fringg 1b6bbc7131 fix: protect active paid subscriptions from being disabled in RemnaWave
Add is_active_paid_subscription() helper that checks if subscription is
non-trial, active, and not expired. Use it across all disable_remnawave_user
call sites to prevent disabling VPN access for users with paid subscriptions.

Protected paths: block_user, delete_user_account, broadcast cleanup,
channel unsubscribe, admin deactivation, webapi endpoints, cabinet
reset-trial, reset-subscription, and disable-user endpoints.
2026-02-23 16:49:31 +03:00
Fringg 1f4430f3af fix: suppress web page preview when logo mode is disabled
When ENABLE_LOGO_MODE is on, messages are sent as photos which
naturally don't show URL previews. When off, messages are sent as
text but disable_web_page_preview was never set, causing link
previews in menu, welcome, and other messages.

Always patch Message.answer/edit_text and inject
disable_web_page_preview=True for all text message paths.
2026-02-23 15:55:53 +03:00
Fringg 67f3547ae2 fix: allow tariff switch when less than 1 day remains
Check subscription.end_date <= now instead of remaining_days == 0 to
allow switching when hours remain. The .days property truncates to whole
days, blocking users with a few hours left from switching tariffs.
2026-02-23 15:49:08 +03:00
35 changed files with 779 additions and 139 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.17.0"
".": "3.17.1"
}
+21
View File
@@ -1,5 +1,26 @@
# Changelog
## [3.17.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.17.0...v3.17.1) (2026-02-23)
### Bug Fixes
* add diagnostic logging for device_limit sync to RemnaWave ([97b3f89](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97b3f899d12c4bf32b6229a3b595f1b9ad611096))
* add int32 overflow guards and strengthen auth validation ([50a931e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/50a931ec363d1842126b90098f93c6cae47a9fac))
* add missing broadcast_history columns and harden subscription logic ([d4c4a8a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d4c4a8a211eaf836024f8d9dcb725f25f514f05e))
* allow tariff switch when less than 1 day remains ([67f3547](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/67f3547ae2f40153229d71c1abe7e1213466e5c3))
* cap expected_monthly_referrals to prevent int32 overflow ([2ef6185](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2ef618571570edb6011a365af8aa9cd7e3348c2e))
* cross-validate Telegram identity on every authenticated request ([973b3d3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/973b3d3d3ff80376c0fd19c531d7aac3ae751df8))
* handle RemnaWave API errors in traffic aggregation ([ed4624c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed4624c6649bdbc04bc850ef63e5c86e26a37ce4))
* migrate all remaining naive timestamp columns to timestamptz ([708bb9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/708bb9eec7ea4360b26709fb2a3f82dd139ed600))
* prevent partner self-referral via own campaign link ([115c0c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/115c0c84c0698591da75d7d3b8fbd8e0fc8541ea))
* protect active paid subscriptions from being disabled in RemnaWave ([1b6bbc7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b6bbc7131341b4afd739e4195f02aa956ead616))
* repair missing DB columns and make backup resilient to schema mismatches ([c20355b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c20355b06df13328f85cc5a6045b3e490419a30a))
* show negative amounts for withdrawals in admin transaction list ([5ee45f9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ee45f97d179ce2d32b3f19eeb6fd01989a30ca7))
* suppress web page preview when logo mode is disabled ([1f4430f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f4430f3af8f3efcc58ef7b562904adcb1640a44))
* uploaded backup restore button not triggering handler ([ebe5083](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebe508302b906f8b56cb230b934fb8566990c684))
* use aiogram 3.x bot.download() instead of document.download() ([205c8d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/205c8d987d93151a17aa0793cb51bd99917aea97))
## [3.17.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.3...v3.17.0) (2026-02-18)
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v3.17.0" # x-release-please-version
ARG VERSION="v3.17.1" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+46 -1
View File
@@ -4,7 +4,7 @@ import asyncio
import structlog
from aiogram import Bot
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,6 +16,7 @@ from app.services.blacklist_service import blacklist_service
from app.services.maintenance_service import maintenance_service
from .auth.jwt_handler import get_token_payload
from .auth.telegram_auth import validate_telegram_init_data
logger = structlog.get_logger(__name__)
@@ -44,6 +45,7 @@ async def get_cabinet_db() -> AsyncSession:
async def get_current_cabinet_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> User:
@@ -51,6 +53,7 @@ async def get_current_cabinet_user(
Get current authenticated cabinet user from JWT token.
Args:
request: FastAPI request object (for reading X-Telegram-Init-Data header)
credentials: HTTP Bearer credentials
db: Database session
@@ -105,6 +108,34 @@ async def get_current_cabinet_user(
detail='User account is not active',
)
# Defense in depth: cross-validate Telegram identity.
# The frontend sends X-Telegram-Init-Data on every request.
# If the header is present and cryptographically valid, verify that
# the Telegram user ID matches the JWT user's telegram_id.
# This prevents cross-account token reuse when Telegram WebView
# shares localStorage across accounts on the same device.
init_data_raw = request.headers.get('X-Telegram-Init-Data')
if init_data_raw and user.telegram_id is not None:
# Use generous max_age: Telegram Desktop caches initData
tg_user = validate_telegram_init_data(init_data_raw, max_age_seconds=86400 * 30)
if tg_user is None:
logger.warning(
'Telegram initData validation failed but header was present',
jwt_user_id=user.id,
)
elif tg_user.get('id') != user.telegram_id:
logger.warning(
'Telegram identity mismatch: JWT belongs to different user than current Telegram account',
jwt_user_id=user.id,
jwt_telegram_id=user.telegram_id,
init_data_telegram_id=tg_user.get('id'),
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Session belongs to a different Telegram account. Please restart the app.',
headers={'WWW-Authenticate': 'Bearer'},
)
# Check blacklist
if user.telegram_id is not None:
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.telegram_id, user.username)
@@ -173,6 +204,7 @@ async def get_current_cabinet_user(
async def get_optional_cabinet_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> User | None:
@@ -200,6 +232,19 @@ async def get_optional_cabinet_user(
if not user or user.status != 'active':
return None
# Cross-validate Telegram identity (same as get_current_cabinet_user)
init_data_raw = request.headers.get('X-Telegram-Init-Data')
if init_data_raw and user.telegram_id is not None:
tg_user = validate_telegram_init_data(init_data_raw, max_age_seconds=86400 * 30)
if tg_user and tg_user.get('id') != user.telegram_id:
logger.warning(
'Telegram identity mismatch in optional auth',
jwt_user_id=user.id,
jwt_telegram_id=user.telegram_id,
init_data_telegram_id=tg_user.get('id'),
)
return None
return user
+7 -1
View File
@@ -99,7 +99,13 @@ async def _aggregate_traffic(
user_uuids_set = set(user_uuids)
async with service.get_api_client() as api:
nodes = await api.get_all_nodes()
try:
nodes = await api.get_all_nodes()
except Exception:
logger.warning('Failed to fetch nodes for traffic aggregation', exc_info=True)
# Cache empty result to avoid hammering the failing API
_traffic_cache[cache_key] = (now, {}, [])
return {}, []
# Fetch per-node user stats — O(nodes) calls instead of O(users)
semaphore = asyncio.Semaphore(_CONCURRENCY_LIMIT)
+59 -23
View File
@@ -575,12 +575,14 @@ async def get_user_detail(
transactions_result = await db.execute(transactions_q)
transactions = transactions_result.scalars().all()
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
recent_transactions = [
UserTransactionItem(
id=t.id,
type=t.type,
amount_kopeks=t.amount_kopeks,
amount_rubles=t.amount_kopeks / 100,
amount_kopeks=-t.amount_kopeks if t.type in _EXPENSE_TYPES else t.amount_kopeks,
amount_rubles=-t.amount_kopeks / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
description=t.description,
payment_method=t.payment_method,
is_completed=t.is_completed,
@@ -1819,24 +1821,33 @@ async def reset_user_trial(
# Delete subscription if exists
if user.subscription:
# Deactivate in Remnawave panel first
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
from app.database.crud.subscription import is_active_paid_subscription
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info('Disabled Remnawave user for trial reset', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
logger.warning('Failed to disable Remnawave user during trial reset', error=e)
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск удаления подписки и RemnaWave: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
else:
# Deactivate in Remnawave panel first
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
# Delete subscription from database
from sqlalchemy import delete
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info('Disabled Remnawave user for trial reset', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
logger.warning('Failed to disable Remnawave user during trial reset', error=e)
subscription_id = user.subscription.id
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
# Delete subscription from database
from sqlalchemy import delete
subscription_id = user.subscription.id
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
# Reset trial flag
user.has_used_trial = False
@@ -1889,6 +1900,21 @@ async def reset_user_subscription(
panel_deactivated=False,
)
from app.database.crud.subscription import is_active_paid_subscription
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск сброса подписки: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
return ResetSubscriptionResponse(
success=False,
message='Cannot reset active paid subscription. Subscription is still active and paid.',
subscription_deleted=False,
panel_deactivated=False,
)
# Deactivate in Remnawave panel if requested
if request.deactivate_in_panel and user.remnawave_uuid:
try:
@@ -1951,8 +1977,16 @@ async def disable_user(
panel_deactivated = False
panel_error: str | None = None
# Deactivate subscription in panel
if user.remnawave_uuid:
# Deactivate subscription in panel (skip if active paid subscription)
from app.database.crud.subscription import is_active_paid_subscription
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск отключения RemnaWave: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
elif user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
@@ -1964,8 +1998,8 @@ async def disable_user(
panel_error = str(e)
logger.warning('Failed to disable Remnawave user', error=e)
# Deactivate subscription in bot database
if user.subscription:
# Deactivate subscription in bot database (skip if active paid subscription)
if user.subscription and not is_active_paid_subscription(user.subscription):
from app.database.crud.subscription import deactivate_subscription
await deactivate_subscription(db, user.subscription)
@@ -2065,12 +2099,14 @@ async def get_user_transactions(
result = await db.execute(query)
transactions = result.scalars().all()
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
items = [
UserTransactionItem(
id=t.id,
type=t.type,
amount_kopeks=t.amount_kopeks,
amount_rubles=t.amount_kopeks / 100,
amount_kopeks=-t.amount_kopeks if t.type in _EXPENSE_TYPES else t.amount_kopeks,
amount_rubles=-t.amount_kopeks / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
description=t.description,
payment_method=t.payment_method,
is_completed=t.is_completed,
+9
View File
@@ -151,6 +151,15 @@ async def _process_campaign_bonus(
if not campaign:
return None
# Skip if user IS the campaign partner — prevent self-referral
if campaign.partner_user_id and campaign.partner_user_id == user.id:
logger.debug(
'Skipping campaign attribution: user is the campaign partner',
user_id=user.id,
campaign_id=campaign.id,
)
return None
# Lock user row to prevent concurrent bonus application (race condition)
await db.execute(select(User).where(User.id == user.id).with_for_update())
+2 -2
View File
@@ -63,7 +63,7 @@ class PaymentMethodResponse(BaseModel):
class TopUpRequest(BaseModel):
"""Request to create payment for balance top-up."""
amount_kopeks: int = Field(..., ge=1000, description='Amount in kopeks (min 10 rubles)')
amount_kopeks: int = Field(..., ge=1000, le=2_000_000_000, description='Amount in kopeks (min 10 rubles)')
payment_method: str = Field(..., description='Payment method ID')
payment_option: str | None = Field(None, description='Payment option (e.g. Platega method code)')
@@ -82,7 +82,7 @@ class TopUpResponse(BaseModel):
class StarsInvoiceRequest(BaseModel):
"""Request to create Telegram Stars invoice for balance top-up."""
amount_kopeks: int = Field(..., ge=100, description='Amount in kopeks (min 1 ruble)')
amount_kopeks: int = Field(..., ge=100, le=2_000_000_000, description='Amount in kopeks (min 1 ruble)')
class StarsInvoiceResponse(BaseModel):
+1 -1
View File
@@ -15,7 +15,7 @@ class PartnerApplicationRequest(BaseModel):
website_url: str | None = Field(None, max_length=500)
telegram_channel: str | None = Field(None, max_length=255)
description: str | None = Field(None, max_length=2000)
expected_monthly_referrals: int | None = Field(None, ge=0)
expected_monthly_referrals: int | None = Field(None, ge=0, le=2_000_000_000)
class PartnerApplicationInfo(BaseModel):
+10 -8
View File
@@ -86,7 +86,7 @@ class RenewalOptionResponse(BaseModel):
class RenewalRequest(BaseModel):
"""Request to renew subscription."""
period_days: int = Field(..., description='Renewal period in days')
period_days: int = Field(..., ge=1, le=3650, description='Renewal period in days')
class TrafficPackageResponse(BaseModel):
@@ -101,13 +101,13 @@ class TrafficPackageResponse(BaseModel):
class TrafficPurchaseRequest(BaseModel):
"""Request to purchase additional traffic."""
gb: int = Field(..., ge=0, description='GB to purchase (0 = unlimited)')
gb: int = Field(..., ge=0, le=100_000, description='GB to purchase (0 = unlimited)')
class DevicePurchaseRequest(BaseModel):
"""Request to purchase additional device slots."""
devices: int = Field(..., ge=1, description='Number of additional devices')
devices: int = Field(..., ge=1, le=100, description='Number of additional devices')
class AutopayUpdateRequest(BaseModel):
@@ -137,10 +137,10 @@ class PurchaseSelectionRequest(BaseModel):
"""User's selection for subscription purchase."""
period_id: str | None = Field(None, description="Period ID like 'days:30'")
period_days: int | None = Field(None, description='Period in days')
traffic_value: int | None = Field(None, description='Traffic in GB (0 = unlimited)')
period_days: int | None = Field(None, ge=1, le=3650, description='Period in days')
traffic_value: int | None = Field(None, ge=0, le=100_000, description='Traffic in GB (0 = unlimited)')
servers: list[str] | None = Field(default_factory=list, description='Server UUIDs')
devices: int | None = Field(None, description='Device limit')
devices: int | None = Field(None, ge=1, le=100, description='Device limit')
class PurchasePreviewRequest(BaseModel):
@@ -156,5 +156,7 @@ class TariffPurchaseRequest(BaseModel):
"""Request to purchase a tariff."""
tariff_id: int = Field(..., description='Tariff ID to purchase')
period_days: int = Field(..., description='Period in days')
traffic_gb: int | None = Field(None, ge=0, description='Custom traffic in GB (for custom_traffic_enabled tariffs)')
period_days: int = Field(..., ge=1, le=3650, description='Period in days')
traffic_gb: int | None = Field(
None, ge=0, le=100_000, description='Custom traffic in GB (for custom_traffic_enabled tariffs)'
)
+3 -1
View File
@@ -261,7 +261,9 @@ class UserNodeUsageResponse(BaseModel):
class UpdateBalanceRequest(BaseModel):
"""Request to update user balance."""
amount_kopeks: int = Field(..., description='Amount in kopeks (positive to add, negative to subtract)')
amount_kopeks: int = Field(
..., ge=-2_000_000_000, le=2_000_000_000, description='Amount in kopeks (positive to add, negative to subtract)'
)
description: str = Field(default='Admin balance adjustment', max_length=500)
create_transaction: bool = Field(default=True, description='Create transaction record')
+12
View File
@@ -36,6 +36,18 @@ def is_recently_updated_by_webhook(subscription: Subscription) -> bool:
return elapsed < _WEBHOOK_GUARD_SECONDS
def is_active_paid_subscription(subscription: Subscription | None) -> bool:
"""Return True if subscription is active, paid (non-trial), and not expired."""
if not subscription:
return False
return (
not subscription.is_trial
and subscription.status == SubscriptionStatus.ACTIVE.value
and subscription.end_date is not None
and subscription.end_date > datetime.now(UTC)
)
async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscription | None:
result = await db.execute(
select(Subscription)
+11
View File
@@ -553,8 +553,19 @@ class RemnaWaveAPI:
if active_internal_squads is not None:
data['activeInternalSquads'] = active_internal_squads
logger.info(
'PATCH /api/users payload',
uuid=uuid,
hwidDeviceLimit=data.get('hwidDeviceLimit'),
status=data.get('status'),
)
response = await self._make_request('PATCH', '/api/users', data)
user = self._parse_user(response['response'])
logger.info(
'PATCH /api/users response',
uuid=uuid,
response_hwidDeviceLimit=user.hwid_device_limit,
)
return await self.enrich_user_with_happ_link(user)
async def delete_user(self, uuid: str) -> bool:
+4 -3
View File
@@ -1,3 +1,4 @@
import html
from datetime import datetime
import structlog
@@ -154,7 +155,7 @@ async def create_backup_handler(callback: types.CallbackQuery, db_user: User, db
)
else:
await progress_msg.edit_text(
f'❌ <b>Ошибка создания бекапа</b>\n\n{message}',
f'❌ <b>Ошибка создания бекапа</b>\n\n{html.escape(message)}',
parse_mode='HTML',
reply_markup=get_backup_main_keyboard(db_user.language),
)
@@ -431,11 +432,11 @@ async def handle_backup_file_upload(message: types.Message, db_user: User, db: A
inline_keyboard=[
[
InlineKeyboardButton(
text='✅ Восстановить', callback_data=f'backup_restore_uploaded_{temp_path.name}'
text='✅ Восстановить', callback_data=f'backup_restore_execute_{temp_path.name}'
),
InlineKeyboardButton(
text='🗑️ Очистить и восстановить',
callback_data=f'backup_restore_uploaded_clear_{temp_path.name}',
callback_data=f'backup_restore_clear_{temp_path.name}',
),
],
[InlineKeyboardButton(text='❌ Отмена', callback_data='backup_panel')],
+1 -1
View File
@@ -807,7 +807,7 @@ async def handle_import_message(
content = ''
if message.document:
buffer = io.BytesIO()
await message.document.download(destination=buffer)
await message.bot.download(message.document, destination=buffer)
buffer.seek(0)
content = buffer.read().decode('utf-8', errors='ignore')
else:
+12 -1
View File
@@ -4013,7 +4013,11 @@ async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, adm
async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import deactivate_subscription, get_subscription_by_user_id
from app.database.crud.subscription import (
deactivate_subscription,
get_subscription_by_user_id,
is_active_paid_subscription,
)
from app.services.subscription_service import SubscriptionService
subscription = await get_subscription_by_user_id(db, user_id)
@@ -4021,6 +4025,13 @@ async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id
logger.error('Подписка не найдена для пользователя', user_id=user_id)
return False
if is_active_paid_subscription(subscription):
logger.info(
'⏭️ Пропуск деактивации: у пользователя активная оплаченная подписка',
user_id=user_id,
)
return False
await deactivate_subscription(db, subscription)
user = await get_user_by_id(db, user_id)
+13 -7
View File
@@ -1174,11 +1174,14 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
if existing_user and existing_user.status == UserStatus.DELETED.value:
logger.info('🔄 Восстанавливаем удаленного пользователя', from_user_id=callback.from_user.id)
# Prevent self-referral when partner re-registers via own campaign link
safe_referrer_id = referrer_id if referrer_id != existing_user.id else None
existing_user.username = callback.from_user.username
existing_user.first_name = callback.from_user.first_name
existing_user.last_name = callback.from_user.last_name
existing_user.language = language
existing_user.referred_by_id = referrer_id
existing_user.referred_by_id = safe_referrer_id
existing_user.status = UserStatus.ACTIVE.value
existing_user.balance_kopeks = 0
existing_user.has_had_paid_subscription = False
@@ -1212,7 +1215,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
logger.info('🔄 Обновляем существующего пользователя', from_user_id=callback.from_user.id)
existing_user.status = UserStatus.ACTIVE.value
existing_user.language = language
if referrer_id and not existing_user.referred_by_id:
if referrer_id and referrer_id != existing_user.id and not existing_user.referred_by_id:
existing_user.referred_by_id = referrer_id
existing_user.updated_at = datetime.now(UTC)
@@ -1222,7 +1225,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
await db.refresh(existing_user, ['subscription'])
user = existing_user
if referrer_id:
if referrer_id and referrer_id != user.id:
try:
await process_referral_registration(db, user.id, referrer_id, callback.bot)
logger.info('✅ Реферальная регистрация обработана для', user_id=user.id)
@@ -1436,11 +1439,14 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
if existing_user and existing_user.status == UserStatus.DELETED.value:
logger.info('🔄 Восстанавливаем удаленного пользователя', from_user_id=message.from_user.id)
# Prevent self-referral when partner re-registers via own campaign link
safe_referrer_id = referrer_id if referrer_id != existing_user.id else None
existing_user.username = message.from_user.username
existing_user.first_name = message.from_user.first_name
existing_user.last_name = message.from_user.last_name
existing_user.language = language
existing_user.referred_by_id = referrer_id
existing_user.referred_by_id = safe_referrer_id
existing_user.status = UserStatus.ACTIVE.value
existing_user.balance_kopeks = 0
existing_user.has_had_paid_subscription = False
@@ -1474,7 +1480,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
logger.info('🔄 Обновляем существующего пользователя', from_user_id=message.from_user.id)
existing_user.status = UserStatus.ACTIVE.value
existing_user.language = language
if referrer_id and not existing_user.referred_by_id:
if referrer_id and referrer_id != existing_user.id and not existing_user.referred_by_id:
existing_user.referred_by_id = referrer_id
existing_user.updated_at = datetime.now(UTC)
@@ -1484,7 +1490,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
await db.refresh(existing_user, ['subscription'])
user = existing_user
if referrer_id:
if referrer_id and referrer_id != user.id:
try:
await process_referral_registration(db, user.id, referrer_id, message.bot)
logger.info('✅ Реферальная регистрация обработана для', user_id=user.id)
@@ -2046,7 +2052,7 @@ async def required_sub_channel_check(
logger.info('✅ CHANNEL CHECK: pending_start_payload удален из state после создания пользователя')
# Обрабатываем реферальную регистрацию
if referrer_id:
if referrer_id and referrer_id != user.id:
try:
await process_referral_registration(db, user.id, referrer_id, bot)
logger.info('✅ CHANNEL CHECK: Реферальная регистрация обработана для', user_id=user.id)
+3 -2
View File
@@ -2702,11 +2702,12 @@ async def show_instant_switch_list(
return
# Рассчитываем оставшиеся дни
now = datetime.now(UTC)
remaining_days = 0
if subscription.end_date:
remaining_days = max(0, (subscription.end_date - datetime.now(UTC)).days)
remaining_days = max(0, (subscription.end_date - now).days)
if remaining_days == 0:
if not subscription.end_date or subscription.end_date <= now:
await callback.message.edit_text(
'❌ <b>Переключение недоступно</b>\n\n'
'У вашей подписки не осталось активных дней.\n'
+9
View File
@@ -332,11 +332,20 @@ class ChannelCheckerMiddleware(BaseMiddleware):
if subscription.status != SubscriptionStatus.ACTIVE.value:
return
from app.database.crud.subscription import is_active_paid_subscription
if settings.CHANNEL_REQUIRED_FOR_ALL:
pass
elif not subscription.is_trial:
return
if is_active_paid_subscription(subscription):
logger.info(
'⏭️ Пропуск отключения: у пользователя активная оплаченная подписка',
telegram_id=telegram_id,
)
return
await deactivate_subscription(db, subscription)
sub_type = 'Триальная' if subscription.is_trial else 'Платная'
logger.info(
+22 -10
View File
@@ -1,5 +1,6 @@
import asyncio
import gzip
import html as html_lib
import json as json_lib
import math
import os
@@ -575,17 +576,27 @@ class BackupService:
table_name = model.__tablename__
logger.info('📊 Экспортируем таблицу', table_name=table_name)
query = select(model)
try:
query = select(model)
if model == User:
query = query.options(selectinload(User.subscription))
elif model == Subscription:
query = query.options(selectinload(Subscription.user))
elif model == Transaction:
query = query.options(selectinload(Transaction.user))
if model == User:
query = query.options(selectinload(User.subscription))
elif model == Subscription:
query = query.options(selectinload(Subscription.user))
elif model == Transaction:
query = query.options(selectinload(Transaction.user))
result = await db.execute(query)
records = result.scalars().all()
result = await db.execute(query)
records = result.scalars().all()
except Exception as table_exc:
logger.warning(
'⚠️ Ошибка экспорта таблицы, пропускаем',
table_name=table_name,
error=str(table_exc),
)
await db.rollback()
backup_data[table_name] = []
continue
table_data: list[dict[str, Any]] = []
for record in records:
@@ -1725,7 +1736,8 @@ class BackupService:
icons = {'success': '', 'error': '', 'restore_success': '🔥', 'restore_error': ''}
icon = icons.get(event_type, '')
notification_text = f'{icon} <b>СИСТЕМА БЕКАПОВ</b>\n\n{message}'
safe_message = html_lib.escape(message) if 'error' in event_type else message
notification_text = f'{icon} <b>СИСТЕМА БЕКАПОВ</b>\n\n{safe_message}'
if file_path:
notification_text += f'\n📁 <code>{Path(file_path).name}</code>'
+18 -3
View File
@@ -535,8 +535,23 @@ async def cleanup_blocked_broadcast_users(blocked_telegram_ids: list[int]) -> No
user.status = UserStatus.BLOCKED.value
# Отключаем активные подписки
sub_result = await session.execute(
# Проверяем, есть ли активная оплаченная подписка
from app.database.crud.subscription import is_active_paid_subscription
sub_result = await session.execute(select(Subscription).where(Subscription.user_id == user.id))
user_subscription = sub_result.scalar_one_or_none()
if is_active_paid_subscription(user_subscription):
logger.info(
'⏭️ Пропуск отключения подписки: у пользователя активная оплаченная подписка',
telegram_id=telegram_id,
user_id=user.id,
)
await session.commit()
continue
# Отключаем активные подписки (только триальные или истёкшие)
active_sub_result = await session.execute(
select(Subscription).where(
Subscription.user_id == user.id,
Subscription.status.in_(
@@ -547,7 +562,7 @@ async def cleanup_blocked_broadcast_users(blocked_telegram_ids: list[int]) -> No
),
)
)
subscriptions = sub_result.scalars().all()
subscriptions = active_sub_result.scalars().all()
for sub in subscriptions:
sub.status = SubscriptionStatus.DISABLED.value
+9
View File
@@ -56,6 +56,15 @@ class AdvertisingCampaignService:
logger.warning('⚠️ Попытка выдать бонус по неактивной кампании', campaign_id=campaign.id)
return CampaignBonusResult(success=False)
# Prevent partner from being attributed to their own campaign
if campaign.partner_user_id and campaign.partner_user_id == user.id:
logger.info(
'Skipping campaign bonus: user is the campaign partner',
user_id=user.id,
campaign_id=campaign.id,
)
return CampaignBonusResult(success=False)
if campaign.is_balance_bonus:
return await self._apply_balance_bonus(db, user, campaign)
+27 -2
View File
@@ -652,12 +652,37 @@ class MonitoringService:
'trial_channel_unsubscribed',
)
elif subscription.status == SubscriptionStatus.DISABLED.value and subscription.is_trial and is_member:
if is_recently_updated_by_webhook(subscription):
# Don't reactivate if traffic limit is exhausted (RemnaWave will just disable again)
if (
subscription.traffic_limit_gb
and subscription.traffic_used_gb is not None
and subscription.traffic_used_gb >= subscription.traffic_limit_gb
):
logger.debug(
'Пропуск реактивации trial подписки : обновлена вебхуком недавно',
'Пропуск реактивации trial подписки: трафик исчерпан',
subscription_id=subscription.id,
traffic_used=subscription.traffic_used_gb,
traffic_limit=subscription.traffic_limit_gb,
)
continue
# Don't reactivate if subscription was disabled by RemnaWave (webhook)
# rather than by monitoring (channel unsubscribe).
# When webhook disables: last_webhook_update_at ≈ updated_at (both set to now())
# When monitoring disables: updated_at is set, last_webhook_update_at stays old
if (
subscription.last_webhook_update_at
and subscription.updated_at
and subscription.last_webhook_update_at >= subscription.updated_at - timedelta(seconds=10)
):
logger.debug(
'Пропуск реактивации trial подписки: отключена RemnaWave панелью',
subscription_id=subscription.id,
last_webhook_at=subscription.last_webhook_update_at,
updated_at=subscription.updated_at,
)
continue
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = datetime.now(UTC)
await db.commit()
+4
View File
@@ -61,6 +61,10 @@ async def send_referral_notification(
async def process_referral_registration(db: AsyncSession, new_user_id: int, referrer_id: int, bot: Bot = None):
try:
if new_user_id == referrer_id:
logger.warning('Self-referral blocked in process_referral_registration', user_id=new_user_id)
return False
new_user = await get_user_by_id(db, new_user_id)
referrer = await get_user_by_id(db, referrer_id)
+12 -3
View File
@@ -564,15 +564,24 @@ class RemnaWaveWebhookService:
except (ValueError, TypeError):
pass
# Sync expire date
# Sync expire date (only if panel date is LATER than local to prevent race condition
# where webhook with stale expireAt overwrites a freshly extended subscription)
expire_at = data.get('expireAt')
if expire_at:
try:
parsed_dt = datetime.fromisoformat(expire_at.replace('Z', '+00:00'))
new_end_date = parsed_dt.astimezone(UTC)
if subscription.end_date != new_end_date:
subscription.end_date = new_end_date
changed = True
if not subscription.end_date or new_end_date > subscription.end_date:
subscription.end_date = new_end_date
changed = True
else:
logger.warning(
'Webhook: пропуск перезаписи end_date — локальная дата позже',
subscription_id=subscription.id,
local_end_date=subscription.end_date,
webhook_end_date=new_end_date,
)
except (ValueError, TypeError):
pass
+8 -2
View File
@@ -781,7 +781,10 @@ class SubscriptionService:
try:
from app.config import PERIOD_PRICES
base_price_original = PERIOD_PRICES.get(period_days, 0)
# Use subscription's tariff price if available, fall back to global PERIOD_PRICES
tariff = getattr(subscription, 'tariff', None)
tariff_price = tariff.get_price_for_period(period_days) if tariff else None
base_price_original = tariff_price if tariff_price is not None else PERIOD_PRICES.get(period_days, 0)
if user is None:
user = getattr(subscription, 'user', None)
@@ -1123,7 +1126,10 @@ class SubscriptionService:
months_in_period = calculate_months_from_days(period_days)
base_price_original = PERIOD_PRICES.get(period_days, 0)
# Use subscription's tariff price if available, fall back to global PERIOD_PRICES
tariff = getattr(subscription, 'tariff', None)
tariff_price = tariff.get_price_for_period(period_days) if tariff else None
base_price_original = tariff_price if tariff_price is not None else PERIOD_PRICES.get(period_days, 0)
if user is None:
user = getattr(subscription, 'user', None)
+73 -54
View File
@@ -662,22 +662,30 @@ class UserService:
if not user:
return False
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
from app.database.crud.subscription import deactivate_subscription, is_active_paid_subscription
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(
'✅ RemnaWave пользователь деактивирован при блокировке', remnawave_uuid=user.remnawave_uuid
)
except Exception as e:
logger.error('❌ Ошибка деактивации RemnaWave пользователя при блокировке', error=e)
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск отключения RemnaWave и подписки: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
else:
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
if user.subscription:
from app.database.crud.subscription import deactivate_subscription
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(
'✅ RemnaWave пользователь деактивирован при блокировке',
remnawave_uuid=user.remnawave_uuid,
)
except Exception as e:
logger.error('❌ Ошибка деактивации RemnaWave пользователя при блокировке', error=e)
await deactivate_subscription(db, user.subscription)
if user.subscription:
await deactivate_subscription(db, user.subscription)
await update_user(db, user, status=UserStatus.BLOCKED.value)
@@ -741,56 +749,67 @@ class UserService:
if user.remnawave_uuid:
from app.config import settings
from app.database.crud.subscription import is_active_paid_subscription
delete_mode = settings.get_remnawave_user_delete_mode()
try:
from app.services.remnawave_service import RemnaWaveService
remnawave_service = RemnaWaveService()
if delete_mode == 'delete':
# Удаляем пользователя из панели Remnawave
async with remnawave_service.get_api_client() as api:
delete_success = await api.delete_user(user.remnawave_uuid)
if delete_success:
logger.info(
'✅ RemnaWave пользователь удален из панели', remnawave_uuid=user.remnawave_uuid
)
else:
logger.warning(
'⚠️ Не удалось удалить пользователя из панели Remnawave',
remnawave_uuid=user.remnawave_uuid,
)
else:
# Деактивируем пользователя в панели Remnawave
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(
'✅ RemnaWave пользователь деактивирован (режим: )',
remnawave_uuid=user.remnawave_uuid,
delete_mode=delete_mode,
)
except Exception as e:
logger.warning(
'⚠️ Ошибка обработки пользователя в Remnawave (режим: )', delete_mode=delete_mode, error=e
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск отключения RemnaWave при удалении: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
# Если основное действие не удалось, попытаемся хотя бы деактивировать
if delete_mode == 'delete':
try:
else:
delete_mode = settings.get_remnawave_user_delete_mode()
try:
from app.services.remnawave_service import RemnaWaveService
remnawave_service = RemnaWaveService()
if delete_mode == 'delete':
# Удаляем пользователя из панели Remnawave
async with remnawave_service.get_api_client() as api:
delete_success = await api.delete_user(user.remnawave_uuid)
if delete_success:
logger.info(
'✅ RemnaWave пользователь удален из панели',
remnawave_uuid=user.remnawave_uuid,
)
else:
logger.warning(
'⚠️ Не удалось удалить пользователя из панели Remnawave',
remnawave_uuid=user.remnawave_uuid,
)
else:
# Деактивируем пользователя в панели Remnawave
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(
'✅ RemnaWave пользователь деактивирован как fallback',
'✅ RemnaWave пользователь деактивирован (режим: )',
remnawave_uuid=user.remnawave_uuid,
delete_mode=delete_mode,
)
except Exception as fallback_e:
logger.error('❌ Ошибка деактивации RemnaWave как fallback', fallback_e=fallback_e)
except Exception as e:
logger.warning(
'⚠️ Ошибка обработки пользователя в Remnawave (режим: )',
delete_mode=delete_mode,
error=e,
)
# Если основное действие не удалось, попытаемся хотя бы деактивировать
if delete_mode == 'delete':
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(
'✅ RemnaWave пользователь деактивирован как fallback',
remnawave_uuid=user.remnawave_uuid,
)
except Exception as fallback_e:
logger.error('❌ Ошибка деактивации RemnaWave как fallback', fallback_e=fallback_e)
try:
async with db.begin_nested():
+25 -11
View File
@@ -50,6 +50,18 @@ _original_answer = Message.answer
_original_edit_text = Message.edit_text
async def _text_answer(self: Message, text: str = None, **kwargs):
"""Обёртка над оригинальным Message.answer с подавлением web page preview."""
kwargs.setdefault('disable_web_page_preview', True)
return await _original_answer(self, text, **kwargs)
async def _text_edit(self: Message, text: str, **kwargs):
"""Обёртка над оригинальным Message.edit_text с подавлением web page preview."""
kwargs.setdefault('disable_web_page_preview', True)
return await _original_edit_text(self, text, **kwargs)
def _get_language(message: Message) -> str | None:
try:
user = message.from_user
@@ -121,11 +133,14 @@ def is_topic_required_error(error: Exception) -> bool:
async def _answer_with_photo(self: Message, text: str = None, **kwargs):
# Уважаем флаг в рантайме: если логотип выключен — не подменяем ответ
if not settings.ENABLE_LOGO_MODE:
# Фото-сообщения не показывают web page preview, текстовые — показывают.
# Подавляем превью чтобы поведение не менялось при переключении режима логотипа.
kwargs.setdefault('disable_web_page_preview', True)
return await _original_answer(self, text, **kwargs)
# Если caption слишком длинный для фото — отправим как текст
try:
if text is not None and len(text) > 900:
return await _original_answer(self, text, **kwargs)
return await _text_answer(self, text, **kwargs)
except Exception:
pass
language = _get_language(self)
@@ -143,27 +158,27 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs):
fallback_text = append_privacy_hint(text, language)
safe_kwargs = prepare_privacy_safe_kwargs(kwargs)
try:
return await _original_answer(self, fallback_text, **safe_kwargs)
return await _text_answer(self, fallback_text, **safe_kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
raise
# Фоллбек, если Telegram ругается на caption или другое ограничение: отправим как текст
try:
return await _original_answer(self, text, **kwargs)
return await _text_answer(self, text, **kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
raise
except Exception:
try:
return await _original_answer(self, text, **kwargs)
return await _text_answer(self, text, **kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
raise
try:
return await _original_answer(self, text, **kwargs)
return await _text_answer(self, text, **kwargs)
except TelegramBadRequest as error:
if is_topic_required_error(error):
return None
@@ -173,6 +188,7 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs):
async def _edit_with_photo(self: Message, text: str, **kwargs):
# Уважаем флаг в рантайме: если логотип выключен — не подменяем редактирование
if not settings.ENABLE_LOGO_MODE:
kwargs.setdefault('disable_web_page_preview', True)
return await _original_edit_text(self, text, **kwargs)
if self.photo:
language = _get_language(self)
@@ -183,7 +199,7 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
await self.delete()
except Exception:
pass
return await _original_answer(self, text, **kwargs)
return await _text_answer(self, text, **kwargs)
except Exception:
pass
if LOGO_PATH.exists():
@@ -210,7 +226,7 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
except Exception:
pass
try:
return await _original_answer(self, fallback_text, **safe_kwargs)
return await _text_answer(self, fallback_text, **safe_kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
@@ -221,14 +237,14 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
except Exception:
pass
try:
return await _original_answer(self, text, **kwargs)
return await _text_answer(self, text, **kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
raise
# Обработка ошибок MESSAGE_ID_INVALID для сообщений без фото
try:
return await _original_edit_text(self, text, **kwargs)
return await _text_edit(self, text, **kwargs)
except TelegramBadRequest as error:
if is_topic_required_error(error):
return None
@@ -239,7 +255,5 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
def patch_message_methods():
if not settings.ENABLE_LOGO_MODE:
return
Message.answer = _answer_with_photo
Message.edit_text = _edit_with_photo
+32
View File
@@ -172,6 +172,9 @@ def convert_subscription_link_to_happ_scheme(subscription_link: str | None) -> s
def resolve_hwid_device_limit(subscription: Subscription | None) -> int | None:
"""Return a device limit value for RemnaWave payloads when selection is enabled."""
import structlog
_logger = structlog.get_logger('resolve_hwid_device_limit')
if subscription is None:
return None
@@ -179,12 +182,23 @@ def resolve_hwid_device_limit(subscription: Subscription | None) -> int | None:
if not settings.is_devices_selection_enabled():
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is not None:
_logger.info(
'DEVICES_SELECTION disabled, using forced limit',
forced_limit=forced_limit,
subscription_device_limit=getattr(subscription, 'device_limit', None),
subscription_id=getattr(subscription, 'id', None),
)
return forced_limit
# Если forced_limit не задан, используем device_limit из подписки
# чтобы при смене тарифа лимит устройств обновлялся в панели
limit = getattr(subscription, 'device_limit', None)
if limit is None or limit <= 0:
_logger.warning(
'device_limit is None or <= 0, returning None',
device_limit=limit,
subscription_id=getattr(subscription, 'id', None),
)
return None
return limit
@@ -199,10 +213,18 @@ def resolve_hwid_device_limit_for_payload(
RemnaWave should continue receiving the subscription's stored limit so the
external panel stays aligned with the bot configuration.
"""
import structlog
_logger = structlog.get_logger('resolve_hwid_device_limit')
resolved_limit = resolve_hwid_device_limit(subscription)
if resolved_limit is not None:
_logger.info(
'hwid_device_limit resolved',
resolved_limit=resolved_limit,
subscription_id=getattr(subscription, 'id', None),
)
return resolved_limit
if subscription is None:
@@ -210,8 +232,18 @@ def resolve_hwid_device_limit_for_payload(
fallback_limit = getattr(subscription, 'device_limit', None)
if fallback_limit is None or fallback_limit <= 0:
_logger.warning(
'fallback device_limit is None or <= 0, NOT sending hwidDeviceLimit to RemnaWave',
fallback_limit=fallback_limit,
subscription_id=getattr(subscription, 'id', None),
)
return None
_logger.info(
'using fallback device_limit',
fallback_limit=fallback_limit,
subscription_id=getattr(subscription, 'id', None),
)
return fallback_limit
+10
View File
@@ -312,6 +312,16 @@ async def delete_subscription(
"""
subscription = await _get_subscription(db, subscription_id)
from app.database.crud.subscription import is_active_paid_subscription
if is_active_paid_subscription(subscription):
logger.info(
'⏭️ Пропуск деактивации: у пользователя активная оплаченная подписка',
subscription_id=subscription_id,
)
subscription = await _get_subscription(db, subscription.id)
return _serialize_subscription(subscription)
await deactivate_subscription(db, subscription)
# Деактивируем пользователя в RemnaWave, если есть UUID
+10
View File
@@ -453,10 +453,20 @@ async def delete_user_subscription(
"""
user = await _get_user_by_id_or_telegram_id(db, user_id)
from app.database.crud.subscription import is_active_paid_subscription
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User has no subscription')
if is_active_paid_subscription(subscription):
logger.info(
'⏭️ Пропуск деактивации: у пользователя активная оплаченная подписка',
user_id=user.id,
)
user = await get_user_by_id(db, user.id)
return _serialize_user(user)
await deactivate_subscription(db, subscription)
# Деактивируем пользователя в RemnaWave, если есть UUID
@@ -0,0 +1,167 @@
"""repair missing columns from skipped migrations
Revision ID: 0005
Revises: 0004
Create Date: 2026-02-23
Some databases had auto-stamp to 'head' applied before migrations 0002-0004
were actually executed, leaving the alembic_version at 0004 but missing
columns/tables that those migrations would have created. This migration
re-checks and applies any missing schema changes.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '0005'
down_revision: Union[str, None] = '0004'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_column(table: str, column: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return column in [c['name'] for c in inspector.get_columns(table)]
def _has_table(table: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return table in inspector.get_table_names()
def _has_index(table: str, index_name: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return index_name in [idx['name'] for idx in inspector.get_indexes(table)]
def _has_constraint(table: str, constraint_name: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return constraint_name in [fk['name'] for fk in inspector.get_foreign_keys(table)]
def upgrade() -> None:
# --- From 0002: referral_earnings.campaign_id ---
if _has_table('referral_earnings') and not _has_column('referral_earnings', 'campaign_id'):
op.add_column('referral_earnings', sa.Column('campaign_id', sa.Integer(), nullable=True))
if _has_table('advertising_campaigns'):
op.create_foreign_key(
'fk_referral_earnings_campaign_id',
'referral_earnings',
'advertising_campaigns',
['campaign_id'],
['id'],
ondelete='SET NULL',
)
op.create_index('ix_referral_earnings_campaign_id', 'referral_earnings', ['campaign_id'])
# Backfill from advertising_campaign_registrations
if _has_table('advertising_campaign_registrations'):
op.execute(
sa.text("""
UPDATE referral_earnings re
SET campaign_id = sub.campaign_id
FROM (
SELECT DISTINCT ON (user_id) user_id, campaign_id
FROM advertising_campaign_registrations
ORDER BY user_id, created_at ASC
) sub
WHERE sub.user_id = re.referral_id
AND re.campaign_id IS NULL
""")
)
# --- From 0003: users.partner_status ---
if not _has_column('users', 'partner_status'):
op.add_column('users', sa.Column('partner_status', sa.String(20), nullable=False, server_default='none'))
op.create_index('ix_users_partner_status', 'users', ['partner_status'])
# --- From 0003: broadcast_history.blocked_count ---
if _has_table('broadcast_history') and not _has_column('broadcast_history', 'blocked_count'):
op.add_column('broadcast_history', sa.Column('blocked_count', sa.Integer(), nullable=True, server_default='0'))
# --- From 0003: advertising_campaigns.partner_user_id ---
if _has_table('advertising_campaigns') and not _has_column('advertising_campaigns', 'partner_user_id'):
op.add_column('advertising_campaigns', sa.Column('partner_user_id', sa.Integer(), nullable=True))
op.create_foreign_key(
'fk_advertising_campaigns_partner_user_id',
'advertising_campaigns',
'users',
['partner_user_id'],
['id'],
ondelete='SET NULL',
)
op.create_index('ix_advertising_campaigns_partner_user_id', 'advertising_campaigns', ['partner_user_id'])
# --- From 0003: withdrawal_requests ---
if not _has_table('withdrawal_requests'):
op.create_table(
'withdrawal_requests',
sa.Column('id', sa.Integer(), primary_key=True, index=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False, index=True),
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
sa.Column('status', sa.String(50), nullable=False, server_default='pending', index=True),
sa.Column('payment_details', sa.Text(), nullable=True),
sa.Column('risk_score', sa.Integer(), server_default='0'),
sa.Column('risk_analysis', sa.Text(), nullable=True),
sa.Column('processed_by', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('admin_comment', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# --- From 0003: partner_applications ---
if not _has_table('partner_applications'):
op.create_table(
'partner_applications',
sa.Column('id', sa.Integer(), primary_key=True, index=True),
sa.Column(
'user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False
),
sa.Column('company_name', sa.String(255), nullable=True),
sa.Column('website_url', sa.String(500), nullable=True),
sa.Column('telegram_channel', sa.String(255), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('expected_monthly_referrals', sa.Integer(), nullable=True),
sa.Column('status', sa.String(20), nullable=False, server_default='pending'),
sa.Column('admin_comment', sa.Text(), nullable=True),
sa.Column('approved_commission_percent', sa.Integer(), nullable=True),
sa.Column(
'processed_by', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True
),
sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# --- From 0004: email_templates ---
if not _has_table('email_templates'):
op.create_table(
'email_templates',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('notification_type', sa.String(100), nullable=False),
sa.Column('language', sa.String(10), nullable=False),
sa.Column('subject', sa.String(500), nullable=False),
sa.Column('body_html', sa.Text(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('true')),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('notification_type', 'language', name='uq_email_templates_type_lang'),
)
op.create_index('ix_email_templates_notification_type', 'email_templates', ['notification_type'])
def downgrade() -> None:
# This is a repair migration — downgrade is a no-op.
# The original migrations handle their own downgrades.
pass
@@ -0,0 +1,69 @@
"""add missing broadcast_history columns
Revision ID: 0006
Revises: 0005
Create Date: 2026-02-23
Adds blocked_count, channel, email_subject, email_html_content
to broadcast_history. The blocked_count column was defined in 0003/0005
but may not have been applied. The email columns were never migrated.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '0006'
down_revision: Union[str, None] = '0005'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_column(table: str, column: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return column in [c['name'] for c in inspector.get_columns(table)]
def _has_table(table: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return table in inspector.get_table_names()
def upgrade() -> None:
if not _has_table('broadcast_history'):
return
if not _has_column('broadcast_history', 'blocked_count'):
op.add_column('broadcast_history', sa.Column('blocked_count', sa.Integer(), nullable=True, server_default='0'))
if not _has_column('broadcast_history', 'channel'):
op.add_column(
'broadcast_history',
sa.Column('channel', sa.String(20), nullable=False, server_default='telegram'),
)
if not _has_column('broadcast_history', 'email_subject'):
op.add_column('broadcast_history', sa.Column('email_subject', sa.String(255), nullable=True))
if not _has_column('broadcast_history', 'email_html_content'):
op.add_column('broadcast_history', sa.Column('email_html_content', sa.Text(), nullable=True))
def downgrade() -> None:
if not _has_table('broadcast_history'):
return
if _has_column('broadcast_history', 'email_html_content'):
op.drop_column('broadcast_history', 'email_html_content')
if _has_column('broadcast_history', 'email_subject'):
op.drop_column('broadcast_history', 'email_subject')
if _has_column('broadcast_history', 'channel'):
op.drop_column('broadcast_history', 'channel')
# blocked_count is not dropped here — it belongs to migration 0003
@@ -0,0 +1,67 @@
"""fix all remaining naive timestamp columns to timestamptz
Revision ID: 0007
Revises: 0006
Create Date: 2026-02-23
The old universal_migration.py created some tables with `timestamp` (naive)
columns and had a catch-all migration that converted ALL naive timestamp
columns to `timestamptz` on every startup. When universal_migration.py was
replaced with Alembic, that catch-all migration stopped running.
Databases where `email_templates` (and potentially other tables) were created
by universal_migration.py before the catch-all ran still have naive columns.
The code uses `datetime.now(UTC)` (timezone-aware), causing asyncpg to raise:
"can't subtract offset-naive and offset-aware datetimes"
This migration finds and converts ALL remaining naive timestamp columns
in public schema to timestamptz, assuming UTC for existing data.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '0007'
down_revision: Union[str, None] = '0006'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
# Find all naive timestamp columns in public schema
result = conn.execute(
sa.text("""
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND data_type = 'timestamp without time zone'
ORDER BY table_name, column_name
""")
)
columns = result.fetchall()
if not columns:
return
# Set timezone context for the conversion
conn.execute(sa.text("SET LOCAL timezone = 'UTC'"))
for table_name, column_name in columns:
op.execute(
sa.text(
f'ALTER TABLE "{table_name}" '
f'ALTER COLUMN "{column_name}" TYPE TIMESTAMPTZ '
f"USING \"{column_name}\" AT TIME ZONE 'UTC'"
)
)
def downgrade() -> None:
# No-op: converting back to naive timestamps would lose timezone info
# and re-introduce the original bug.
pass
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.17.0"
version = "3.17.1"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }