Compare commits

...

12 Commits

Author SHA1 Message Date
Egor ae710f41fc Merge pull request #2700 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.26.0
2026-03-08 15:47:30 +03:00
github-actions[bot] f86b8614b1 chore(main): release 3.26.0 2026-03-08 12:46:24 +00:00
Egor 61b2fcc2aa Merge pull request #2699 from BEDOLAGA-DEV/dev
Dev
2026-03-08 15:46:01 +03:00
Fringg 928e3e98f8 chore: format balance/main.py and promocode.py 2026-03-08 15:41:59 +03:00
Fringg 7dc5e4ab94 fix: auto-purchase classic extend missing device_limit and traffic_limit_gb
- Add device_limit and traffic_limit_gb to classic extend cart data in
  confirm_extend_subscription handler
- Add classic mode branch in cabinet renew_subscription to save
  device_limit and traffic_limit_gb (previously only saved for tariffs)
- Ensure device_limit >= DEFAULT_DEVICE_LIMIT when converting trial
  subscription to paid via auto-extend
- Add None guards for subscription.device_limit in both trial and
  non-trial branches of _apply_extension_updates
2026-03-08 15:31:58 +03:00
Fringg 5ebe1072c9 fix: quick topup buttons include device/server/traffic costs, broadcast button crash on media messages
- Quick amount buttons now calculate full renewal cost (base + devices + servers + traffic with discounts)
- Tariff mode uses tariff-specific device pricing (device_price_kopeks, device_limit)
- Broadcast inline buttons no longer crash with "no text in message to edit" on photo/video messages
- Media messages are now handled in _edit_with_photo: delete old message + send new text
2026-03-08 14:58:17 +03:00
Fringg 20727b1017 fix: respect send_before_menu flag for pinned messages during new user registration
All 6 registration paths now check pinned_message.send_before_menu
to send pinned message before or after the menu, matching the
existing user flow behavior.
2026-03-08 14:25:54 +03:00
Fringg f4eeb9a503 fix: multiple payment and notification bugs
- CloudPayments: add missing process_referral_topup, has_made_first_topup flag, and admin notification (matching other adapters)
- Promocode: handle TelegramBadRequest for broadcast messages without text (fallback to answer())
- Devices: unify price prorating to day-based calculation (matching cabinet behavior)
- Auth: pass Bot instance to process_referral_registration for campaign referral notifications
- Wata: remove WATA_TERMINAL_PUBLIC_ID from is_wata_enabled() (not used in API calls), make type field conditional, add transactionId webhook fallback
2026-03-08 14:17:44 +03:00
Fringg 1f664a9083 fix: remove is_active_paid_subscription guard from admin deactivation
The guard silently blocked admins from deactivating active paid
subscriptions, returning a generic error with no explanation.
Admin deactivation is intentional (with confirmation step) and
should not be prevented. The guard remains in automated processes
(monitoring, broadcast, user_service) where it makes sense.
2026-03-08 13:05:52 +03:00
Fringg 330d1cb6fe fix: gift purchase notification and activation flow
- Refresh purchase.user relationship after setting user_id to fix
  stale None value that prevented Telegram gift notifications
- Route gift purchases with expired subscriptions through
  PENDING_ACTIVATION instead of auto-activating
- Hide subscription URL from gift buyer in API response
2026-03-08 12:47:25 +03:00
Fringg 9ba61a0879 feat: add telegram gift notification with inline activation button
- New gift_activation handler for gift_activate:{id} callback buttons
- Send Telegram notification to gift recipients with activate button
- Add skip_notification param to activate_purchase to prevent duplicates
- Fix telegram username regex minimum length (4→5 chars) in landing routes
- Add BOT_TOKEN guard in telegram gift notification sender
- Pre-resolve notification params before commit to avoid DetachedInstanceError
2026-03-07 20:39:04 +03:00
Egor d7f05ae409 Merge pull request #2696 from BEDOLAGA-DEV/main
w
2026-03-07 17:42:20 +03:00
26 changed files with 812 additions and 166 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.25.0"
".": "3.26.0"
}
+17
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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)
+18 -3
View File
@@ -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
)
+16 -2
View File
@@ -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,
)
+5 -1
View File
@@ -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
View File
@@ -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()
+24
View File
@@ -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
-8
View File
@@ -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)
+99 -24
View File
@@ -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))
+95
View File
@@ -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:'))
+9 -1
View File
@@ -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
View File
@@ -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,
+18 -19
View File
@@ -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(
'Добавление устройств: ₽/мес × = ₽ (скидка ₽)',
+2
View File
@@ -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)
+174 -28
View File
@@ -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)
+52
View File
@@ -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
+1 -1
View File
@@ -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]
+4 -2
View File
@@ -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,
+3 -1
View File
@@ -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
+26
View File
@@ -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)
-10
View File
@@ -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
-10
View File
@@ -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
View File
@@ -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' }