Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4eaaf06a17 | |||
| 1930a9dcde | |||
| b876c6dd0b | |||
| d15b69710c | |||
| 708bb9eec7 | |||
| 97b3f899d1 | |||
| 5ee45f97d1 | |||
| d4c4a8a211 | |||
| 205c8d987d | |||
| ebe508302b | |||
| c20355b06d | |||
| 50a931ec36 | |||
| 115c0c84c0 | |||
| 973b3d3d3f | |||
| 2ef6185715 | |||
| ed4624c664 | |||
| 1b6bbc7131 | |||
| 1f4430f3af | |||
| 67f3547ae2 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.17.0"
|
||||
".": "3.17.1"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)'
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
+11
@@ -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:
|
||||
|
||||
@@ -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')],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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' }
|
||||
|
||||
Reference in New Issue
Block a user