Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae710f41fc | |||
| f86b8614b1 | |||
| 61b2fcc2aa | |||
| 928e3e98f8 | |||
| 7dc5e4ab94 | |||
| 5ebe1072c9 | |||
| 20727b1017 | |||
| f4eeb9a503 | |||
| 1f664a9083 | |||
| 330d1cb6fe | |||
| 9ba61a0879 | |||
| d7f05ae409 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.25.0"
|
||||
".": "3.26.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## [3.26.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.25.0...v3.26.0) (2026-03-08)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add telegram gift notification with inline activation button ([9ba61a0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ba61a08796fbc06e0dea2ee9cb02edc4126b335))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* auto-purchase classic extend missing device_limit and traffic_limit_gb ([7dc5e4a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7dc5e4ab94a415dc739a49c74cde511aad0cbb29))
|
||||
* gift purchase notification and activation flow ([330d1cb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/330d1cb6fe2eee81a3e8f841de75d41e8b4cde40))
|
||||
* multiple payment and notification bugs ([f4eeb9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f4eeb9a503d6da8a152f8cb60b89f7ebbdf41c4a))
|
||||
* quick topup buttons include device/server/traffic costs, broadcast button crash on media messages ([5ebe107](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ebe1072c9c8a1dcb6ee4cbbea2dc55211b534c4))
|
||||
* remove is_active_paid_subscription guard from admin deactivation ([1f664a9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f664a9083d81bc4462c30bf0626a44b5a30f03e))
|
||||
* respect send_before_menu flag for pinned messages during new user registration ([20727b1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20727b1017457769feccccf83e99523c19026a7e))
|
||||
|
||||
## [3.25.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.24.0...v3.25.0) (2026-03-07)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.25.0" # x-release-please-version
|
||||
ARG VERSION="v3.26.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ from app.handlers.admin import (
|
||||
welcome_text as admin_welcome_text,
|
||||
)
|
||||
from app.handlers.channel_member import register_handlers as register_channel_member_handlers
|
||||
from app.handlers.gift_activation import register_handlers as register_gift_activation_handlers
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
from app.middlewares.auth import AuthMiddleware
|
||||
from app.middlewares.blacklist import BlacklistMiddleware
|
||||
@@ -199,6 +200,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_blocked_users.register_handlers(dp)
|
||||
admin_required_channels.register_handlers(dp)
|
||||
register_channel_member_handlers(dp)
|
||||
register_gift_activation_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
user_contests.register_handlers(dp)
|
||||
|
||||
@@ -188,7 +188,12 @@ async def _process_campaign_bonus(
|
||||
user.referred_by_id = campaign.partner_user_id
|
||||
await db.flush()
|
||||
try:
|
||||
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=None)
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
|
||||
logger.info(
|
||||
'Referral set from campaign partner',
|
||||
user_id=user.id,
|
||||
@@ -242,7 +247,12 @@ async def _process_referral_code(
|
||||
return
|
||||
user.referred_by_id = referrer.id
|
||||
await db.flush()
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=None)
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=bot)
|
||||
logger.info('Referral applied from code', user_id=user.id, referrer_id=referrer.id, referral_code=referral_code)
|
||||
except Exception as e:
|
||||
logger.error('Failed to process referral code', error=e, referral_code=referral_code)
|
||||
@@ -917,7 +927,12 @@ async def register_email_standalone(
|
||||
# Обработать реферальную регистрацию (если есть реферер)
|
||||
if referrer:
|
||||
try:
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=None)
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=bot)
|
||||
logger.info(
|
||||
'Processed referral registration: user_id=, referrer_id', user_id=user.id, referrer_id=referrer.id
|
||||
)
|
||||
|
||||
@@ -102,7 +102,7 @@ class LandingConfigResponse(BaseModel):
|
||||
|
||||
|
||||
_EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')
|
||||
_TELEGRAM_RE = re.compile(r'^@?[a-zA-Z][a-zA-Z0-9_]{3,31}$')
|
||||
_TELEGRAM_RE = re.compile(r'^@?[a-zA-Z][a-zA-Z0-9_]{4,31}$')
|
||||
|
||||
|
||||
def _validate_contact(contact_type: str, contact_value: str) -> None:
|
||||
@@ -153,6 +153,8 @@ class PurchaseStatusResponse(BaseModel):
|
||||
cabinet_email: str | None = None
|
||||
cabinet_password: str | None = None
|
||||
auto_login_token: str | None = None
|
||||
recipient_in_bot: bool | None = None
|
||||
bot_link: str | None = None
|
||||
|
||||
|
||||
# ============ Helpers ============
|
||||
@@ -180,7 +182,7 @@ def _build_purchase_status_response(purchase: GuestPurchase) -> PurchaseStatusRe
|
||||
within_ttl = False
|
||||
subscription_url = None
|
||||
subscription_crypto_link = None
|
||||
if purchase.delivered_at and purchase.subscription_url:
|
||||
if purchase.delivered_at and purchase.subscription_url and not purchase.is_gift:
|
||||
age = datetime.now(UTC) - purchase.delivered_at
|
||||
if age < timedelta(hours=_SUBSCRIPTION_URL_EXPIRY_HOURS):
|
||||
within_ttl = True
|
||||
@@ -221,6 +223,16 @@ def _build_purchase_status_response(purchase: GuestPurchase) -> PurchaseStatusRe
|
||||
cabinet_password = purchase.cabinet_password
|
||||
auto_login_token = purchase.auto_login_token
|
||||
|
||||
# For telegram gifts: indicate whether recipient is known to the bot
|
||||
recipient_in_bot: bool | None = None
|
||||
bot_link: str | None = None
|
||||
if purchase.is_gift and effective_contact_type == 'telegram':
|
||||
recipient_in_bot = purchase.user is not None and purchase.user.telegram_id is not None
|
||||
if not recipient_in_bot:
|
||||
bot_username = settings.get_bot_username()
|
||||
if bot_username:
|
||||
bot_link = f'https://t.me/{bot_username}'
|
||||
|
||||
return PurchaseStatusResponse(
|
||||
status=purchase.status,
|
||||
subscription_url=subscription_url,
|
||||
@@ -235,6 +247,8 @@ def _build_purchase_status_response(purchase: GuestPurchase) -> PurchaseStatusRe
|
||||
cabinet_email=cabinet_email,
|
||||
cabinet_password=cabinet_password,
|
||||
auto_login_token=auto_login_token,
|
||||
recipient_in_bot=recipient_in_bot,
|
||||
bot_link=bot_link,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -510,12 +510,16 @@ async def renew_subscription(
|
||||
'source': 'cabinet',
|
||||
}
|
||||
|
||||
# Add tariff parameters for tariffs mode
|
||||
# Add subscription parameters for auto-purchase
|
||||
if tariff_id:
|
||||
cart_data['traffic_limit_gb'] = tariff_traffic_limit_gb
|
||||
# Сохраняем актуальный device_limit подписки (включая докупленные устройства)
|
||||
cart_data['device_limit'] = user.subscription.device_limit
|
||||
cart_data['allowed_squads'] = tariff_allowed_squads
|
||||
else:
|
||||
# Classic mode: сохраняем текущие параметры подписки для корректной автопокупки
|
||||
cart_data['device_limit'] = user.subscription.device_limit
|
||||
cart_data['traffic_limit_gb'] = user.subscription.traffic_limit_gb
|
||||
|
||||
try:
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
|
||||
+1
-1
@@ -1738,7 +1738,7 @@ class Settings(BaseSettings):
|
||||
return info.get('title') or info.get('name') or f'Platega {method_code}'
|
||||
|
||||
def is_wata_enabled(self) -> bool:
|
||||
return self.WATA_ENABLED and self.WATA_ACCESS_TOKEN is not None and self.WATA_TERMINAL_PUBLIC_ID is not None
|
||||
return self.WATA_ENABLED and self.WATA_ACCESS_TOKEN is not None
|
||||
|
||||
def get_wata_display_name(self) -> str:
|
||||
name = (self.WATA_DISPLAY_NAME or '').strip()
|
||||
|
||||
@@ -122,6 +122,30 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> User |
|
||||
return user
|
||||
|
||||
|
||||
async def find_phantom_user_by_username(db: AsyncSession, username: str) -> User | None:
|
||||
"""Find a phantom user created by guest purchase (no telegram_id, auth_type=telegram).
|
||||
|
||||
Used during /start to reconcile phantom users with real Telegram accounts.
|
||||
"""
|
||||
if not username:
|
||||
return None
|
||||
|
||||
normalized = username.lower()
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
)
|
||||
.where(
|
||||
User.telegram_id.is_(None),
|
||||
User.auth_type == 'telegram',
|
||||
func.lower(User.username) == normalized,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
|
||||
if not username:
|
||||
return None
|
||||
|
||||
@@ -4023,7 +4023,6 @@ async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_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
|
||||
|
||||
@@ -4032,13 +4031,6 @@ 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)
|
||||
|
||||
@@ -145,6 +145,8 @@ async def get_quick_amount_buttons(language: str, user: User) -> list:
|
||||
"""
|
||||
Generate quick amount buttons with user-specific pricing and discounts.
|
||||
|
||||
Includes full subscription cost: base period price + devices + servers + traffic.
|
||||
|
||||
Args:
|
||||
language: User's language for formatting
|
||||
user: User object to calculate personalized discounts
|
||||
@@ -156,25 +158,63 @@ async def get_quick_amount_buttons(language: str, user: User) -> list:
|
||||
return []
|
||||
|
||||
from app.config import PERIOD_PRICES
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.utils.pricing_utils import apply_percentage_discount, calculate_months_from_days
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
# В режиме тарифов получаем цены из тарифа пользователя
|
||||
tariff = None
|
||||
tariff_prices = None
|
||||
tariff_periods = None
|
||||
if settings.is_tariffs_mode():
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
devices_price_per_month = 0
|
||||
servers_per_month_prices: list[int] = []
|
||||
traffic_price_per_month = 0
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if subscription and subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and tariff.period_prices:
|
||||
tariff_prices = {int(k): v for k, v in tariff.period_prices.items()}
|
||||
tariff_periods = sorted(tariff_prices.keys())
|
||||
async with AsyncSessionLocal() as db:
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
# В режиме тарифов получаем цены из тарифа пользователя
|
||||
if settings.is_tariffs_mode() and subscription and subscription.tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and tariff.period_prices:
|
||||
tariff_prices = {int(k): v for k, v in tariff.period_prices.items()}
|
||||
tariff_periods = sorted(tariff_prices.keys())
|
||||
|
||||
# Получаем стоимость устройств, серверов и трафика из подписки
|
||||
if subscription and not subscription.is_trial:
|
||||
# Устройства: в режиме тарифов используем цену и базовый лимит из тарифа
|
||||
if settings.is_tariffs_mode() and tariff and tariff_prices:
|
||||
tariff_device_price = getattr(tariff, 'device_price_kopeks', None)
|
||||
if tariff_device_price and tariff_device_price > 0:
|
||||
device_unit_price = tariff_device_price
|
||||
base_device_limit = tariff.device_limit or 0
|
||||
else:
|
||||
device_unit_price = settings.PRICE_PER_DEVICE
|
||||
base_device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
device_unit_price = settings.PRICE_PER_DEVICE
|
||||
base_device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
device_limit = subscription.device_limit or base_device_limit
|
||||
additional_devices = max(0, device_limit - base_device_limit)
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * device_unit_price
|
||||
|
||||
# Серверы
|
||||
connected_squads = subscription.connected_squads or []
|
||||
if connected_squads:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
_, servers_per_month_prices = await subscription_service.get_countries_price_by_uuids(
|
||||
connected_squads, db, promo_group_id=user.promo_group_id
|
||||
)
|
||||
|
||||
# Трафик
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
|
||||
buttons = []
|
||||
|
||||
@@ -192,23 +232,58 @@ async def get_quick_amount_buttons(language: str, user: User) -> list:
|
||||
base_price_kopeks = PERIOD_PRICES.get(period, 0)
|
||||
|
||||
if base_price_kopeks > 0:
|
||||
# Calculate price with user's promo group discount using unified system
|
||||
# Базовая цена периода с промо-скидками
|
||||
price_info = calculate_user_price(user, base_price_kopeks, period, 'period')
|
||||
|
||||
callback_data = f'quick_amount_{price_info.final_price}'
|
||||
months = calculate_months_from_days(period)
|
||||
|
||||
# Стоимость устройств со скидкой
|
||||
devices_addon = 0
|
||||
if devices_price_per_month > 0:
|
||||
devices_discount = user.get_promo_discount('devices', period)
|
||||
devices_discounted, _ = apply_percentage_discount(devices_price_per_month, devices_discount)
|
||||
devices_addon = devices_discounted * months
|
||||
|
||||
# Стоимость серверов со скидкой
|
||||
servers_addon = 0
|
||||
if servers_per_month_prices:
|
||||
servers_discount = user.get_promo_discount('servers', period)
|
||||
for server_price in servers_per_month_prices:
|
||||
discounted, _ = apply_percentage_discount(server_price, servers_discount)
|
||||
servers_addon += discounted
|
||||
servers_addon *= months
|
||||
|
||||
# Стоимость трафика со скидкой
|
||||
traffic_addon = 0
|
||||
if traffic_price_per_month > 0:
|
||||
traffic_discount = user.get_promo_discount('traffic', period)
|
||||
traffic_discounted, _ = apply_percentage_discount(traffic_price_per_month, traffic_discount)
|
||||
traffic_addon = traffic_discounted * months
|
||||
|
||||
total_price = price_info.final_price + devices_addon + servers_addon + traffic_addon
|
||||
callback_data = f'quick_amount_{total_price}'
|
||||
|
||||
# Format button text with discount display
|
||||
period_label = f'{period} дней'
|
||||
|
||||
# For balance buttons, use simpler format without emoji and period label prefix
|
||||
if price_info.has_discount:
|
||||
button_text = (
|
||||
f'{texts.format_price(price_info.base_price)} ➜ '
|
||||
f'{texts.format_price(price_info.final_price)} '
|
||||
f'(-{price_info.discount_percent}%) • {period_label}'
|
||||
)
|
||||
# Скидка считается от полной базовой стоимости (период + аддоны без скидок)
|
||||
total_base = (
|
||||
base_price_kopeks
|
||||
+ (devices_price_per_month + sum(servers_per_month_prices) + traffic_price_per_month) * months
|
||||
)
|
||||
has_discount = total_base > total_price and total_base > 0
|
||||
|
||||
if has_discount:
|
||||
discount_pct = round((total_base - total_price) * 100 / total_base)
|
||||
if discount_pct > 0:
|
||||
button_text = (
|
||||
f'{texts.format_price(total_base)} ➜ '
|
||||
f'{texts.format_price(total_price)} '
|
||||
f'(-{discount_pct}%) • {period_label}'
|
||||
)
|
||||
else:
|
||||
button_text = f'{texts.format_price(total_price)} • {period_label}'
|
||||
else:
|
||||
button_text = f'{texts.format_price(price_info.final_price)} • {period_label}'
|
||||
button_text = f'{texts.format_price(total_price)} • {period_label}'
|
||||
|
||||
buttons.append(types.InlineKeyboardButton(text=button_text, callback_data=callback_data))
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Handler for gift subscription activation via inline callback button."""
|
||||
|
||||
import html as html_mod
|
||||
|
||||
import structlog
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.types import InaccessibleMessage
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import GuestPurchase
|
||||
from app.services.guest_purchase_service import GuestPurchaseError, activate_purchase
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
_GIFT_NOT_FOUND = 'Подарок не найден или недоступен.'
|
||||
|
||||
|
||||
async def handle_gift_activate(callback: types.CallbackQuery) -> None:
|
||||
"""Handle gift_activate:{purchase_id} callback from Telegram notification."""
|
||||
if isinstance(callback.message, InaccessibleMessage):
|
||||
await callback.answer('Сообщение устарело. Попробуйте /start.', show_alert=True)
|
||||
return
|
||||
|
||||
if not callback.data:
|
||||
return
|
||||
|
||||
parts = callback.data.split(':', 1)
|
||||
if len(parts) != 2:
|
||||
await callback.answer(_GIFT_NOT_FOUND, show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
purchase_id = int(parts[1])
|
||||
except ValueError:
|
||||
await callback.answer(_GIFT_NOT_FOUND, show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
await callback.message.edit_text('⏳ Активируем подарок...', parse_mode=None)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(GuestPurchase).where(GuestPurchase.id == purchase_id))
|
||||
purchase = result.scalars().first()
|
||||
|
||||
if not purchase or purchase.user_id is None or purchase.user is None:
|
||||
await callback.message.edit_text(_GIFT_NOT_FOUND, parse_mode=None)
|
||||
return
|
||||
|
||||
# Verify the callback sender is the actual recipient
|
||||
if purchase.user.telegram_id != callback.from_user.id:
|
||||
await callback.message.edit_text(_GIFT_NOT_FOUND, parse_mode=None)
|
||||
return
|
||||
|
||||
# Resolve tariff info inside session (selectin-loaded relationships)
|
||||
tariff_name = html_mod.escape(purchase.tariff.name) if purchase.tariff and purchase.tariff.name else ''
|
||||
period_days = purchase.period_days
|
||||
|
||||
try:
|
||||
await activate_purchase(db, purchase.token, skip_notification=True)
|
||||
except GuestPurchaseError as exc:
|
||||
logger.warning(
|
||||
'Gift activation via callback failed',
|
||||
purchase_id=purchase_id,
|
||||
telegram_id=callback.from_user.id,
|
||||
error=exc.message,
|
||||
)
|
||||
if exc.status_code >= 500:
|
||||
await callback.message.edit_text('Произошла ошибка при активации. Попробуйте позже.', parse_mode=None)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
f'Не удалось активировать подарок: {html_mod.escape(exc.message)}',
|
||||
parse_mode=None,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'Unexpected error during gift activation via callback',
|
||||
purchase_id=purchase_id,
|
||||
telegram_id=callback.from_user.id,
|
||||
)
|
||||
await callback.message.edit_text('Произошла ошибка при активации. Попробуйте позже.', parse_mode=None)
|
||||
return
|
||||
|
||||
period_text = f'{period_days} дн.' if period_days else ''
|
||||
tariff_text = f'{tariff_name} — {period_text}' if tariff_name else period_text
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✅ <b>Подарок активирован!</b>\n{tariff_text}\n\nВаша подписка обновлена.',
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(handle_gift_activate, F.data.startswith('gift_activate:'))
|
||||
@@ -1,5 +1,6 @@
|
||||
import structlog
|
||||
from aiogram import Bot, Dispatcher, F, types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InaccessibleMessage
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -24,7 +25,14 @@ async def show_promocode_menu(callback: types.CallbackQuery, db_user: User, stat
|
||||
if isinstance(callback.message, InaccessibleMessage):
|
||||
await callback.message.answer(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language))
|
||||
else:
|
||||
await callback.message.edit_text(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language))
|
||||
try:
|
||||
await callback.message.edit_text(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language))
|
||||
except TelegramBadRequest as error:
|
||||
error_message = str(error).lower()
|
||||
if 'there is no text in the message to edit' in error_message:
|
||||
await callback.message.answer(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language))
|
||||
else:
|
||||
raise
|
||||
|
||||
await state.set_state(PromoCodeStates.waiting_for_code)
|
||||
await callback.answer()
|
||||
|
||||
+236
-50
@@ -5,6 +5,7 @@ from aiogram import Bot, Dispatcher, F, types
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -15,6 +16,7 @@ from app.database.crud.campaign import (
|
||||
from app.database.crud.subscription import decrement_subscription_server_counts
|
||||
from app.database.crud.user import (
|
||||
create_user,
|
||||
find_phantom_user_by_username,
|
||||
get_user_by_referral_code,
|
||||
get_user_by_telegram_id,
|
||||
)
|
||||
@@ -58,6 +60,73 @@ from app.utils.user_utils import generate_unique_referral_code
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def _claim_phantom_user(
|
||||
db: AsyncSession,
|
||||
phantom: 'User',
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language: str,
|
||||
referrer_id: int | None,
|
||||
) -> tuple[bool, 'User | None']:
|
||||
"""Claim a phantom user by backfilling Telegram profile data.
|
||||
|
||||
Returns (success, user). On IntegrityError falls back to existing user lookup.
|
||||
|
||||
Note: Phantom users created when Bot.get_chat() fails at purchase time are matched
|
||||
by username only. Since Telegram usernames are changeable and reassignable, this is
|
||||
inherently vulnerable to username change attacks. When Bot.get_chat() succeeds at
|
||||
purchase time, telegram_id is stored on the user and the phantom path is not used.
|
||||
"""
|
||||
from app.utils.validators import sanitize_telegram_name
|
||||
|
||||
phantom.telegram_id = telegram_id
|
||||
phantom.username = username
|
||||
phantom.first_name = sanitize_telegram_name(first_name)
|
||||
phantom.last_name = sanitize_telegram_name(last_name)
|
||||
phantom.language = language
|
||||
phantom.status = UserStatus.ACTIVE.value
|
||||
if referrer_id and referrer_id != phantom.id:
|
||||
phantom.referred_by_id = referrer_id
|
||||
if not phantom.referral_code:
|
||||
phantom.referral_code = await generate_unique_referral_code(db, telegram_id)
|
||||
phantom.updated_at = datetime.now(UTC)
|
||||
phantom.last_activity = datetime.now(UTC)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
logger.warning(
|
||||
'IntegrityError claiming phantom user, falling back to existing user lookup',
|
||||
phantom_user_id=phantom.id,
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
existing = await get_user_by_telegram_id(db, telegram_id)
|
||||
return False, existing
|
||||
await db.refresh(phantom, ['subscription'])
|
||||
logger.info(
|
||||
'Claimed phantom user from guest purchase',
|
||||
phantom_user_id=phantom.id,
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
|
||||
# Sync Remnawave panel with updated user data (telegram_id, username, etc.)
|
||||
if phantom.subscription:
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, phantom.subscription)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
'Failed to update Remnawave panel after phantom claim',
|
||||
phantom_user_id=phantom.id,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
return True, phantom
|
||||
|
||||
|
||||
def _calculate_subscription_flags(subscription):
|
||||
if not subscription:
|
||||
return False, False
|
||||
@@ -1136,6 +1205,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
pinned_message = await get_active_pinned_message(db)
|
||||
try:
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
@@ -1150,8 +1220,11 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
if pinned_message and pinned_message.send_before_menu:
|
||||
await _send_pinned_message(callback.bot, db, existing_user, pinned_message)
|
||||
await callback.message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
await _send_pinned_message(callback.bot, db, existing_user)
|
||||
if pinned_message and not pinned_message.send_before_menu:
|
||||
await _send_pinned_message(callback.bot, db, existing_user, pinned_message)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка при показе главного меню существующему пользователю', error=e)
|
||||
await callback.message.answer(
|
||||
@@ -1199,21 +1272,50 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
logger.info('✅ Пользователь восстановлен', from_user_id=callback.from_user.id)
|
||||
|
||||
elif not existing_user:
|
||||
logger.info('🆕 Создаем нового пользователя', from_user_id=callback.from_user.id)
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, callback.from_user.id)
|
||||
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=callback.from_user.id,
|
||||
username=callback.from_user.username,
|
||||
first_name=callback.from_user.first_name,
|
||||
last_name=callback.from_user.last_name,
|
||||
language=language,
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code,
|
||||
# Check for phantom user created by guest purchase (gift by @username)
|
||||
phantom = (
|
||||
await find_phantom_user_by_username(db, callback.from_user.username)
|
||||
if callback.from_user.username
|
||||
else None
|
||||
)
|
||||
await db.refresh(user, ['subscription'])
|
||||
if phantom:
|
||||
claimed, user = await _claim_phantom_user(
|
||||
db,
|
||||
phantom,
|
||||
telegram_id=callback.from_user.id,
|
||||
username=callback.from_user.username,
|
||||
first_name=callback.from_user.first_name,
|
||||
last_name=callback.from_user.last_name,
|
||||
language=language,
|
||||
referrer_id=referrer_id,
|
||||
)
|
||||
if not claimed and user:
|
||||
# IntegrityError fallback — use existing user
|
||||
await db.refresh(user, ['subscription'])
|
||||
elif not claimed:
|
||||
logger.critical(
|
||||
'Phantom claim failed with no fallback user, proceeding to normal registration',
|
||||
telegram_id=callback.from_user.id,
|
||||
phantom_user_id=phantom.id,
|
||||
)
|
||||
phantom = None
|
||||
|
||||
if not phantom:
|
||||
logger.info('🆕 Создаем нового пользователя', from_user_id=callback.from_user.id)
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, callback.from_user.id)
|
||||
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=callback.from_user.id,
|
||||
username=callback.from_user.username,
|
||||
first_name=callback.from_user.first_name,
|
||||
last_name=callback.from_user.last_name,
|
||||
language=language,
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code,
|
||||
)
|
||||
await db.refresh(user, ['subscription'])
|
||||
else:
|
||||
logger.info('🔄 Обновляем существующего пользователя', from_user_id=callback.from_user.id)
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
@@ -1273,15 +1375,19 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
from app.database.crud.welcome_text import get_welcome_text_for_user
|
||||
|
||||
offer_text = await get_welcome_text_for_user(db, callback.from_user)
|
||||
pinned_message = await get_active_pinned_message(db)
|
||||
|
||||
if offer_text:
|
||||
try:
|
||||
if pinned_message and pinned_message.send_before_menu:
|
||||
await _send_pinned_message(callback.bot, db, user, pinned_message)
|
||||
await callback.message.answer(
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(user.language),
|
||||
)
|
||||
logger.info('✅ Приветственное сообщение отправлено пользователю', telegram_id=user.telegram_id)
|
||||
await _send_pinned_message(callback.bot, db, user)
|
||||
if pinned_message and not pinned_message.send_before_menu:
|
||||
await _send_pinned_message(callback.bot, db, user, pinned_message)
|
||||
except TelegramBadRequest as e:
|
||||
if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower():
|
||||
logger.warning('HTML parse error в приветственном сообщении, повтор без parse_mode', error=e)
|
||||
@@ -1291,7 +1397,8 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
reply_markup=get_post_registration_keyboard(user.language),
|
||||
parse_mode=None,
|
||||
)
|
||||
await _send_pinned_message(callback.bot, db, user)
|
||||
if pinned_message and not pinned_message.send_before_menu:
|
||||
await _send_pinned_message(callback.bot, db, user, pinned_message)
|
||||
except Exception as fallback_err:
|
||||
logger.error('Ошибка при повторной отправке приветственного сообщения', fallback_err=fallback_err)
|
||||
else:
|
||||
@@ -1336,8 +1443,11 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
if pinned_message and pinned_message.send_before_menu:
|
||||
await _send_pinned_message(callback.bot, db, user, pinned_message)
|
||||
await callback.message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
await _send_pinned_message(callback.bot, db, user)
|
||||
if pinned_message and not pinned_message.send_before_menu:
|
||||
await _send_pinned_message(callback.bot, db, user, pinned_message)
|
||||
logger.info('✅ Главное меню показано пользователю', telegram_id=user.telegram_id)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка при показе главного меню', error=e)
|
||||
@@ -1387,6 +1497,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
pinned_message = await get_active_pinned_message(db)
|
||||
try:
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
@@ -1401,8 +1512,11 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
if pinned_message and pinned_message.send_before_menu:
|
||||
await _send_pinned_message(message.bot, db, existing_user, pinned_message)
|
||||
await message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
await _send_pinned_message(message.bot, db, existing_user)
|
||||
if pinned_message and not pinned_message.send_before_menu:
|
||||
await _send_pinned_message(message.bot, db, existing_user, pinned_message)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка при показе главного меню существующему пользователю', error=e)
|
||||
await message.answer(
|
||||
@@ -1450,21 +1564,47 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
logger.info('✅ Пользователь восстановлен', from_user_id=message.from_user.id)
|
||||
|
||||
elif not existing_user:
|
||||
logger.info('🆕 Создаем нового пользователя', from_user_id=message.from_user.id)
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, message.from_user.id)
|
||||
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=message.from_user.id,
|
||||
username=message.from_user.username,
|
||||
first_name=message.from_user.first_name,
|
||||
last_name=message.from_user.last_name,
|
||||
language=language,
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code,
|
||||
# Check for phantom user created by guest purchase (gift by @username)
|
||||
phantom = (
|
||||
await find_phantom_user_by_username(db, message.from_user.username) if message.from_user.username else None
|
||||
)
|
||||
await db.refresh(user, ['subscription'])
|
||||
if phantom:
|
||||
claimed, user = await _claim_phantom_user(
|
||||
db,
|
||||
phantom,
|
||||
telegram_id=message.from_user.id,
|
||||
username=message.from_user.username,
|
||||
first_name=message.from_user.first_name,
|
||||
last_name=message.from_user.last_name,
|
||||
language=language,
|
||||
referrer_id=referrer_id,
|
||||
)
|
||||
if not claimed and user:
|
||||
await db.refresh(user, ['subscription'])
|
||||
elif not claimed:
|
||||
logger.critical(
|
||||
'Phantom claim failed with no fallback user, proceeding to normal registration',
|
||||
telegram_id=message.from_user.id,
|
||||
phantom_user_id=phantom.id,
|
||||
)
|
||||
phantom = None
|
||||
|
||||
if not phantom:
|
||||
logger.info('🆕 Создаем нового пользователя', from_user_id=message.from_user.id)
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, message.from_user.id)
|
||||
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=message.from_user.id,
|
||||
username=message.from_user.username,
|
||||
first_name=message.from_user.first_name,
|
||||
last_name=message.from_user.last_name,
|
||||
language=language,
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code,
|
||||
)
|
||||
await db.refresh(user, ['subscription'])
|
||||
else:
|
||||
logger.info('🔄 Обновляем существующего пользователя', from_user_id=message.from_user.id)
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
@@ -1553,6 +1693,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
from app.database.crud.welcome_text import get_welcome_text_for_user
|
||||
|
||||
offer_text = await get_welcome_text_for_user(db, message.from_user)
|
||||
pinned_message = await get_active_pinned_message(db)
|
||||
|
||||
if offer_text:
|
||||
try:
|
||||
@@ -1563,12 +1704,15 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
else:
|
||||
keyboard = get_post_registration_keyboard(user.language)
|
||||
|
||||
if pinned_message and pinned_message.send_before_menu:
|
||||
await _send_pinned_message(message.bot, db, user, pinned_message)
|
||||
await message.answer(
|
||||
offer_text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info('✅ Приветственное сообщение отправлено пользователю', telegram_id=user.telegram_id)
|
||||
await _send_pinned_message(message.bot, db, user)
|
||||
if pinned_message and not pinned_message.send_before_menu:
|
||||
await _send_pinned_message(message.bot, db, user, pinned_message)
|
||||
except TelegramBadRequest as e:
|
||||
if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower():
|
||||
logger.warning('HTML parse error в приветственном сообщении, повтор без parse_mode', error=e)
|
||||
@@ -1578,7 +1722,8 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
reply_markup=keyboard,
|
||||
parse_mode=None,
|
||||
)
|
||||
await _send_pinned_message(message.bot, db, user)
|
||||
if pinned_message and not pinned_message.send_before_menu:
|
||||
await _send_pinned_message(message.bot, db, user, pinned_message)
|
||||
except Exception as fallback_err:
|
||||
logger.error('Ошибка при повторной отправке приветственного сообщения', fallback_err=fallback_err)
|
||||
else:
|
||||
@@ -1623,9 +1768,12 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
if pinned_message and pinned_message.send_before_menu:
|
||||
await _send_pinned_message(message.bot, db, user, pinned_message)
|
||||
await message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
logger.info('✅ Главное меню показано пользователю', telegram_id=user.telegram_id)
|
||||
await _send_pinned_message(message.bot, db, user)
|
||||
if pinned_message and not pinned_message.send_before_menu:
|
||||
await _send_pinned_message(message.bot, db, user, pinned_message)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка при показе главного меню', error=e)
|
||||
await message.answer(
|
||||
@@ -1977,6 +2125,10 @@ async def required_sub_channel_check(
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
|
||||
pinned_message = await get_active_pinned_message(db)
|
||||
if pinned_message and pinned_message.send_before_menu:
|
||||
await _send_pinned_message(bot, db, user, pinned_message)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE and len(menu_text) <= 900:
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
@@ -1993,7 +2145,8 @@ async def required_sub_channel_check(
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await _send_pinned_message(bot, db, user)
|
||||
if pinned_message and not pinned_message.send_before_menu:
|
||||
await _send_pinned_message(bot, db, user, pinned_message)
|
||||
else:
|
||||
from app.keyboards.inline import get_rules_keyboard
|
||||
|
||||
@@ -2014,19 +2167,47 @@ async def required_sub_channel_check(
|
||||
referrer_id = referrer.id
|
||||
logger.info('✅ CHANNEL CHECK: Реферер найден из ссылки', referrer_id=referrer.id)
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, query.from_user.id)
|
||||
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=query.from_user.id,
|
||||
username=query.from_user.username,
|
||||
first_name=query.from_user.first_name,
|
||||
last_name=query.from_user.last_name,
|
||||
language=language,
|
||||
referral_code=referral_code,
|
||||
referred_by_id=referrer_id,
|
||||
# Check for phantom user created by guest purchase (gift by @username)
|
||||
phantom = (
|
||||
await find_phantom_user_by_username(db, query.from_user.username)
|
||||
if query.from_user.username
|
||||
else None
|
||||
)
|
||||
await db.refresh(user, ['subscription'])
|
||||
if phantom:
|
||||
claimed, user = await _claim_phantom_user(
|
||||
db,
|
||||
phantom,
|
||||
telegram_id=query.from_user.id,
|
||||
username=query.from_user.username,
|
||||
first_name=query.from_user.first_name,
|
||||
last_name=query.from_user.last_name,
|
||||
language=language,
|
||||
referrer_id=referrer_id,
|
||||
)
|
||||
if not claimed and user:
|
||||
await db.refresh(user, ['subscription'])
|
||||
elif not claimed:
|
||||
logger.critical(
|
||||
'Phantom claim failed with no fallback user, proceeding to normal registration',
|
||||
telegram_id=query.from_user.id,
|
||||
phantom_user_id=phantom.id,
|
||||
)
|
||||
phantom = None
|
||||
|
||||
if not phantom:
|
||||
referral_code = await generate_unique_referral_code(db, query.from_user.id)
|
||||
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=query.from_user.id,
|
||||
username=query.from_user.username,
|
||||
first_name=query.from_user.first_name,
|
||||
last_name=query.from_user.last_name,
|
||||
language=language,
|
||||
referral_code=referral_code,
|
||||
referred_by_id=referrer_id,
|
||||
)
|
||||
await db.refresh(user, ['subscription'])
|
||||
|
||||
# ИСПРАВЛЕНИЕ БАГА: Очищаем pending_start_payload из state после создания пользователя
|
||||
state_data.pop('pending_start_payload', None)
|
||||
@@ -2070,6 +2251,10 @@ async def required_sub_channel_check(
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
|
||||
pinned_message = await get_active_pinned_message(db)
|
||||
if pinned_message and pinned_message.send_before_menu:
|
||||
await _send_pinned_message(bot, db, user, pinned_message)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE and len(menu_text) <= 900:
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
@@ -2086,7 +2271,8 @@ async def required_sub_channel_check(
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await _send_pinned_message(bot, db, user)
|
||||
if pinned_message and not pinned_message.send_before_menu:
|
||||
await _send_pinned_message(bot, db, user, pinned_message)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
|
||||
@@ -27,7 +27,6 @@ from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.pricing_utils import (
|
||||
apply_percentage_discount,
|
||||
calculate_prorated_price,
|
||||
get_remaining_months,
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
@@ -339,9 +338,10 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
total_discount = int(discount_per_month * days_left / 30)
|
||||
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
|
||||
else:
|
||||
# Для обычных тарифов - по месяцам
|
||||
months_hint = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_hint * 30 if months_hint > 0 else None
|
||||
# Для обычных тарифов - по дням (как в кабинете)
|
||||
now = datetime.now(UTC)
|
||||
days_left = max(1, (subscription.end_date - now).days)
|
||||
period_hint_days = days_left
|
||||
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
@@ -352,12 +352,11 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
price, charged_months = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
total_discount = discount_per_month * charged_months
|
||||
period_label = f'{charged_months} мес'
|
||||
# Цена = месячная_цена * days_left / 30
|
||||
price = int(discounted_per_month * days_left / 30)
|
||||
price = max(100, price) # Минимум 1 рубль
|
||||
total_discount = int(discount_per_month * days_left / 30)
|
||||
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
|
||||
|
||||
if price > 0 and db_user.balance_kopeks < price:
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
@@ -1161,9 +1160,10 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
total_discount = int(discount_per_month * days_left / 30)
|
||||
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
|
||||
else:
|
||||
# Для обычных тарифов - по месяцам
|
||||
months_hint = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_hint * 30 if months_hint > 0 else None
|
||||
# Для обычных тарифов - по дням (как в кабинете)
|
||||
now = datetime.now(UTC)
|
||||
days_left = max(1, (subscription.end_date - now).days)
|
||||
period_hint_days = days_left
|
||||
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
@@ -1174,12 +1174,11 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
price, charged_months = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
total_discount = discount_per_month * charged_months
|
||||
period_label = f'{charged_months} мес'
|
||||
# Цена = месячная_цена * days_left / 30
|
||||
price = int(discounted_per_month * days_left / 30)
|
||||
price = max(100, price) # Минимум 1 рубль
|
||||
total_discount = int(discount_per_month * days_left / 30)
|
||||
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
|
||||
|
||||
logger.info(
|
||||
'Добавление устройств: ₽/мес × = ₽ (скидка ₽)',
|
||||
|
||||
@@ -1952,6 +1952,8 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
|
||||
'return_to_cart': True,
|
||||
'description': f'Продление подписки на {days} дней',
|
||||
'consume_promo_offer': bool(promo_component['discount'] > 0),
|
||||
'device_limit': device_limit,
|
||||
'traffic_limit_gb': renewal_traffic_gb,
|
||||
}
|
||||
|
||||
await user_cart_service.save_user_cart(db_user.id, cart_data)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Service for guest (unauthenticated) purchases via landing pages."""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -22,6 +23,8 @@ from app.services.subscription_service import SubscriptionService
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
_TELEGRAM_USERNAME_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9_]{4,31}$')
|
||||
|
||||
|
||||
async def _send_admin_notification(
|
||||
purchase: GuestPurchase,
|
||||
@@ -216,14 +219,14 @@ async def fulfill_purchase(db: AsyncSession, purchase_token: str) -> GuestPurcha
|
||||
|
||||
# Check if user already has a subscription
|
||||
existing_subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if existing_subscription is not None and existing_subscription.is_active:
|
||||
# Active subscription — hold for manual activation
|
||||
if existing_subscription is not None and (existing_subscription.is_active or purchase.is_gift):
|
||||
# Active subscription or gift with any existing subscription — hold for manual activation
|
||||
purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
|
||||
purchase.user_id = user.id
|
||||
if recipient_type == 'email' and not purchase.is_gift:
|
||||
purchase.auto_login_token = create_auto_login_token(user.id)
|
||||
await db.commit()
|
||||
await db.refresh(purchase, attribute_names=['landing'])
|
||||
await db.refresh(purchase, attribute_names=['landing', 'user'])
|
||||
|
||||
try:
|
||||
await send_guest_notification(
|
||||
@@ -292,7 +295,7 @@ async def fulfill_purchase(db: AsyncSession, purchase_token: str) -> GuestPurcha
|
||||
purchase.auto_login_token = create_auto_login_token(user.id)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(purchase, attribute_names=['landing'])
|
||||
await db.refresh(purchase, attribute_names=['landing', 'user'])
|
||||
|
||||
try:
|
||||
await send_guest_notification(
|
||||
@@ -429,30 +432,86 @@ async def _find_or_create_user(
|
||||
if contact_type != 'telegram':
|
||||
raise GuestPurchaseError(f'Unsupported contact type: {contact_type}', status_code=500)
|
||||
|
||||
username = contact_value.lstrip('@').lower()
|
||||
result = await db.execute(
|
||||
select(User).where(User.username == username),
|
||||
)
|
||||
user = result.scalars().first()
|
||||
username = contact_value.lstrip('@')
|
||||
if not _TELEGRAM_USERNAME_RE.match(username):
|
||||
raise GuestPurchaseError('Invalid Telegram username format', status_code=400)
|
||||
normalized = username.lower()
|
||||
|
||||
# Try to resolve telegram_id via Bot API (works if user has interacted with the bot)
|
||||
resolved_telegram_id: int | None = None
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
async with Bot(token=settings.BOT_TOKEN) as bot:
|
||||
chat = await asyncio.wait_for(
|
||||
bot.get_chat(chat_id=f'@{username}'),
|
||||
timeout=5.0,
|
||||
)
|
||||
resolved_telegram_id = chat.id
|
||||
# Use the canonical username from Telegram if available
|
||||
if chat.username:
|
||||
username = chat.username
|
||||
normalized = username.lower()
|
||||
except Exception as exc:
|
||||
logger.debug('Could not resolve telegram_id for username', username=username, error=str(exc))
|
||||
|
||||
# Search by telegram_id first (most reliable), then by username (case-insensitive)
|
||||
user = None
|
||||
if resolved_telegram_id:
|
||||
result = await db.execute(
|
||||
select(User).where(User.telegram_id == resolved_telegram_id),
|
||||
)
|
||||
user = result.scalars().first()
|
||||
|
||||
if not user:
|
||||
result = await db.execute(
|
||||
select(User).where(func.lower(User.username) == normalized),
|
||||
)
|
||||
user = result.scalars().first()
|
||||
|
||||
if user:
|
||||
# Backfill telegram_id if we resolved it and user doesn't have it yet
|
||||
if resolved_telegram_id and not user.telegram_id:
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
user.telegram_id = resolved_telegram_id
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
logger.warning(
|
||||
'Could not backfill telegram_id (unique constraint)',
|
||||
user_id=user.id,
|
||||
resolved_telegram_id=resolved_telegram_id,
|
||||
)
|
||||
await db.refresh(user)
|
||||
return user, False
|
||||
|
||||
# Create new telegram user (without telegram_id — will be linked later)
|
||||
# Create new telegram user
|
||||
user = User(
|
||||
auth_type='telegram',
|
||||
username=username,
|
||||
telegram_id=resolved_telegram_id,
|
||||
)
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
result = await db.execute(select(User).where(User.username == username))
|
||||
if resolved_telegram_id:
|
||||
result = await db.execute(select(User).where(User.telegram_id == resolved_telegram_id))
|
||||
user = result.scalars().first()
|
||||
if user:
|
||||
return user, False
|
||||
result = await db.execute(select(User).where(func.lower(User.username) == normalized))
|
||||
user = result.scalars().first()
|
||||
if user:
|
||||
return user, False
|
||||
raise
|
||||
logger.info('Created new telegram user for guest purchase', user_id=user.id, username=username)
|
||||
logger.info(
|
||||
'Created new telegram user for guest purchase',
|
||||
user_id=user.id,
|
||||
username=username,
|
||||
has_telegram_id=resolved_telegram_id is not None,
|
||||
)
|
||||
return user, False
|
||||
|
||||
|
||||
@@ -463,6 +522,82 @@ def _get_recipient_contact(purchase: GuestPurchase) -> tuple[str, str]:
|
||||
return purchase.contact_type, purchase.contact_value
|
||||
|
||||
|
||||
async def _send_telegram_gift_notification(
|
||||
purchase: GuestPurchase,
|
||||
*,
|
||||
is_pending_activation: bool = False,
|
||||
tariff_name: str = '',
|
||||
) -> None:
|
||||
"""Send Telegram bot message to gift recipient if they have a telegram_id."""
|
||||
if not settings.BOT_TOKEN:
|
||||
return
|
||||
user = purchase.user
|
||||
if not user or not user.telegram_id:
|
||||
return
|
||||
|
||||
try:
|
||||
import html as html_mod
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
gift_from = ''
|
||||
if purchase.contact_value:
|
||||
safe_name = html_mod.escape(purchase.contact_value)
|
||||
gift_from = f'\nОт: {safe_name}'
|
||||
|
||||
gift_msg = ''
|
||||
if purchase.gift_message:
|
||||
safe_msg = html_mod.escape(purchase.gift_message)
|
||||
gift_msg = f'\n\n"{safe_msg}"'
|
||||
|
||||
safe_tariff = html_mod.escape(tariff_name) if tariff_name else ''
|
||||
period_text = f'{purchase.period_days} дн.' if purchase.period_days else ''
|
||||
tariff_text = f'{safe_tariff} — {period_text}' if safe_tariff else period_text
|
||||
|
||||
text = f'🎁 <b>Вам подарили VPN подписку!</b>\n{tariff_text}{gift_from}{gift_msg}'
|
||||
|
||||
keyboard = None
|
||||
if is_pending_activation:
|
||||
text += '\n\nУ вас уже есть активная подписка. Нажмите кнопку ниже, чтобы активировать подарок (текущая подписка будет заменена).'
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='Активировать подарок',
|
||||
callback_data=f'gift_activate:{purchase.id}',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
async with Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
) as bot:
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Telegram gift notification sent',
|
||||
purchase_id=purchase.id,
|
||||
recipient_telegram_id=user.telegram_id,
|
||||
is_pending_activation=is_pending_activation,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
'Failed to send Telegram gift notification',
|
||||
purchase_id=purchase.id,
|
||||
recipient_telegram_id=user.telegram_id if user else None,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def send_guest_notification(
|
||||
purchase: GuestPurchase,
|
||||
*,
|
||||
@@ -471,9 +606,11 @@ async def send_guest_notification(
|
||||
language: str = 'ru',
|
||||
is_new_account: bool = False,
|
||||
) -> None:
|
||||
"""Send email notification for guest purchase delivery or activation requirement.
|
||||
"""Send notification for guest purchase delivery or activation requirement.
|
||||
|
||||
For telegram contacts, no notification is sent (success page only).
|
||||
For telegram gift contacts, sends a Telegram bot message to the recipient.
|
||||
For telegram non-gift contacts, no notification is sent (success page only).
|
||||
For email contacts, sends an email notification.
|
||||
For gifts, notification goes to the recipient, not the buyer.
|
||||
|
||||
Args:
|
||||
@@ -488,8 +625,16 @@ async def send_guest_notification(
|
||||
from app.cabinet.services.email_templates import EmailNotificationTemplates
|
||||
from app.services.notification_delivery_service import NotificationType
|
||||
|
||||
recipient_type, recipient_email = _get_recipient_contact(purchase)
|
||||
recipient_type, recipient_value = _get_recipient_contact(purchase)
|
||||
|
||||
if recipient_type == 'telegram':
|
||||
if purchase.is_gift:
|
||||
await _send_telegram_gift_notification(
|
||||
purchase, is_pending_activation=is_pending_activation, tariff_name=tariff_name
|
||||
)
|
||||
return
|
||||
|
||||
recipient_email = recipient_value
|
||||
if recipient_type != 'email':
|
||||
return
|
||||
|
||||
@@ -600,7 +745,7 @@ async def send_guest_notification(
|
||||
logger.warning('Failed to send cabinet credentials email', purchase_id=purchase.id)
|
||||
|
||||
|
||||
async def activate_purchase(db: AsyncSession, purchase_token: str) -> GuestPurchase:
|
||||
async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notification: bool = False) -> GuestPurchase:
|
||||
"""Activate a PENDING_ACTIVATION purchase by replacing or creating a subscription.
|
||||
|
||||
Uses SELECT ... FOR UPDATE to prevent concurrent activation.
|
||||
@@ -687,18 +832,19 @@ async def activate_purchase(db: AsyncSession, purchase_token: str) -> GuestPurch
|
||||
if user.auth_type == 'email' and not purchase.is_gift:
|
||||
purchase.auto_login_token = create_auto_login_token(user.id)
|
||||
await db.commit()
|
||||
await db.refresh(purchase, attribute_names=['landing'])
|
||||
await db.refresh(purchase, attribute_names=['landing', 'user'])
|
||||
|
||||
try:
|
||||
await send_guest_notification(
|
||||
purchase,
|
||||
is_pending_activation=False,
|
||||
tariff_name=notification_tariff_name,
|
||||
language=notification_language,
|
||||
is_new_account=is_new_account,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to send delivery notification after activation', purchase_id=purchase.id)
|
||||
if not skip_notification:
|
||||
try:
|
||||
await send_guest_notification(
|
||||
purchase,
|
||||
is_pending_activation=False,
|
||||
tariff_name=notification_tariff_name,
|
||||
language=notification_language,
|
||||
is_new_account=is_new_account,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to send delivery notification after activation', purchase_id=purchase.id)
|
||||
|
||||
await _send_admin_notification(purchase, notification_tariff_name, is_pending_activation=False)
|
||||
|
||||
|
||||
@@ -258,6 +258,20 @@ class CloudPaymentsPaymentMixin:
|
||||
logger.error('Пользователь не найден: id', user_id=payment.user_id)
|
||||
return False
|
||||
|
||||
# Загружаем промогруппы и данные для уведомлений
|
||||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||||
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
# Credit balance directly (not via add_user_balance which commits)
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
@@ -313,6 +327,44 @@ class CloudPaymentsPaymentMixin:
|
||||
except Exception as error:
|
||||
logger.exception('Ошибка отправки уведомления CloudPayments', error=error)
|
||||
|
||||
# Начисляем реферальную комиссию
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения CloudPayments', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение'
|
||||
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления CloudPayments', error=error)
|
||||
|
||||
# Автопокупка из сохранённой корзины и уведомление о корзине
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
@@ -194,7 +194,7 @@ class WataPaymentMixin:
|
||||
return False
|
||||
|
||||
order_id_raw = payload.get('orderId')
|
||||
payment_link_raw = payload.get('paymentLinkId') or payload.get('id')
|
||||
payment_link_raw = payload.get('paymentLinkId') or payload.get('id') or payload.get('transactionId')
|
||||
transaction_status_raw = payload.get('transactionStatus')
|
||||
|
||||
order_id = str(order_id_raw) if order_id_raw else None
|
||||
|
||||
@@ -346,15 +346,20 @@ def _apply_extension_updates(context: AutoExtendContext) -> None:
|
||||
# subscription.is_trial = False # УДАЛЕНО: преждевременное удаление триала
|
||||
if context.traffic_limit_gb is not None:
|
||||
subscription.traffic_limit_gb = context.traffic_limit_gb
|
||||
# При конвертации триала device_limit должен быть не ниже DEFAULT_DEVICE_LIMIT
|
||||
if context.device_limit is not None:
|
||||
subscription.device_limit = max(subscription.device_limit, context.device_limit)
|
||||
subscription.device_limit = max(
|
||||
subscription.device_limit or 0, context.device_limit, settings.DEFAULT_DEVICE_LIMIT
|
||||
)
|
||||
else:
|
||||
subscription.device_limit = max(subscription.device_limit or 0, settings.DEFAULT_DEVICE_LIMIT)
|
||||
if context.squad_uuid and context.squad_uuid not in (subscription.connected_squads or []):
|
||||
subscription.connected_squads = (subscription.connected_squads or []) + [context.squad_uuid]
|
||||
else:
|
||||
# Обновляем лимиты для платной подписки
|
||||
if context.traffic_limit_gb not in (None, 0):
|
||||
subscription.traffic_limit_gb = context.traffic_limit_gb
|
||||
if context.device_limit is not None and context.device_limit > subscription.device_limit:
|
||||
if context.device_limit is not None and context.device_limit > (subscription.device_limit or 0):
|
||||
subscription.device_limit = context.device_limit
|
||||
if context.squad_uuid and context.squad_uuid not in (subscription.connected_squads or []):
|
||||
subscription.connected_squads = (subscription.connected_squads or []) + [context.squad_uuid]
|
||||
|
||||
@@ -248,7 +248,8 @@ class SubscriptionService:
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
|
||||
email=user.email, # Обновляем email в панели RemnaWave
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
@@ -403,7 +404,8 @@ class SubscriptionService:
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
|
||||
email=user.email, # Обновляем email в панели RemnaWave
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
|
||||
@@ -180,7 +180,9 @@ class WataService:
|
||||
'orderId': order_id,
|
||||
}
|
||||
|
||||
payload['type'] = link_type or settings.WATA_PAYMENT_TYPE or 'OneTime'
|
||||
payment_type = link_type or settings.WATA_PAYMENT_TYPE
|
||||
if payment_type:
|
||||
payload['type'] = payment_type
|
||||
|
||||
if success_url or settings.WATA_SUCCESS_REDIRECT_URL:
|
||||
payload['successRedirectUrl'] = success_url or settings.WATA_SUCCESS_REDIRECT_URL
|
||||
|
||||
@@ -189,6 +189,19 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
# Уважаем флаг в рантайме: если логотип выключен — не подменяем редактирование
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
kwargs.setdefault('disable_web_page_preview', True)
|
||||
# Медиа-сообщения (фото/видео из рассылки и т.д.) не имеют text — edit_text упадёт.
|
||||
# Удаляем старое сообщение и отправляем новое.
|
||||
if self.text is None:
|
||||
try:
|
||||
await self.delete()
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
try:
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
except TelegramBadRequest as error:
|
||||
if is_topic_required_error(error):
|
||||
return None
|
||||
raise
|
||||
return await _original_edit_text(self, text, **kwargs)
|
||||
if self.photo:
|
||||
language = _get_language(self)
|
||||
@@ -242,6 +255,19 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
if is_topic_required_error(inner_error):
|
||||
return None
|
||||
raise
|
||||
# Не-фото медиа (видео, анимация и т.д.) с включённым логотипом — удаляем и отправляем с фото
|
||||
if self.text is None:
|
||||
try:
|
||||
await self.delete()
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
try:
|
||||
return await _answer_with_photo(self, text, **kwargs)
|
||||
except TelegramBadRequest as error:
|
||||
if is_topic_required_error(error):
|
||||
return None
|
||||
raise
|
||||
|
||||
# Обработка ошибок MESSAGE_ID_INVALID для сообщений без фото
|
||||
try:
|
||||
return await _text_edit(self, text, **kwargs)
|
||||
|
||||
@@ -329,16 +329,6 @@ 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,20 +453,10 @@ 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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.25.0"
|
||||
version = "3.26.0"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
|
||||
Reference in New Issue
Block a user