Merge pull request #2604 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-02-16 02:20:02 +03:00
committed by GitHub
22 changed files with 765 additions and 387 deletions
+12 -1
View File
@@ -697,8 +697,19 @@ CLOUDPAYMENTS_TEST_MODE=false
ENABLE_LOGO_MODE=true
LOGO_FILE=vpn_logo.png
# Режим главного меню (default - классический режим работы бота, text - режим работы с активным ЛК MiniApp, отключает покупку/управление подпиской в меню, заменяет все кнопками открытия в MiniApp ЛК)
# Режим главного меню:
# default - классический режим работы бота (все кнопки внутри Telegram)
# cabinet - режим Cabinet с активным ЛК MiniApp, кнопки ведут на конкретные
# разделы кабинета (/balance, /subscription, /referral и т.д.)
# Требует MINIAPP_CUSTOM_URL
# Алиасы для обратной совместимости: text, text_only, minimal
MAIN_MENU_MODE=default
# Стиль кнопок в режиме Cabinet (Bot API 9.4):
# primary - синий
# success - зелёный
# danger - красный
# (пустое) - цвета по умолчанию для каждой секции
CABINET_BUTTON_STYLE=
# Включить управление меню через API (позволяет динамически менять структуру кнопок)
MENU_LAYOUT_ENABLED=false
+17
View File
@@ -229,6 +229,23 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
'⚠️ CONNECT_BUTTON_MODE=miniapp_custom, но MINIAPP_CUSTOM_URL не задан! '
'Кнопка "Подключиться" не будет работать.'
)
if settings.is_cabinet_mode() and not settings.MINIAPP_CUSTOM_URL:
logger.warning(
'⚠️ MAIN_MENU_MODE=cabinet, но MINIAPP_CUSTOM_URL не задан! '
'Кнопки кабинета не смогут открывать разделы MiniApp. '
'Установите MINIAPP_CUSTOM_URL.'
)
elif settings.is_cabinet_mode():
logger.info(f'🏠 Режим Cabinet активен, базовый URL: {settings.MINIAPP_CUSTOM_URL}')
# Load per-section button styles cache
if settings.is_cabinet_mode():
try:
from app.utils.button_styles_cache import load_button_styles_cache
await load_button_styles_cache()
except Exception as e:
logger.warning(f'Failed to load button styles cache: {e}')
logger.info('Бот успешно настроен')
+2
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter
from .admin_apps import router as admin_apps_router
from .admin_ban_system import router as admin_ban_system_router
from .admin_broadcasts import router as admin_broadcasts_router
from .admin_button_styles import router as admin_button_styles_router
from .admin_campaigns import router as admin_campaigns_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_payment_methods import router as admin_payment_methods_router
@@ -91,6 +92,7 @@ router.include_router(admin_email_templates_router)
router.include_router(admin_updates_router)
router.include_router(admin_traffic_router)
router.include_router(admin_pinned_messages_router)
router.include_router(admin_button_styles_router)
# WebSocket route
router.include_router(websocket_router)
+253
View File
@@ -0,0 +1,253 @@
"""Admin routes for per-section cabinet button style configuration."""
import json
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.utils.button_styles_cache import (
ALLOWED_STYLE_VALUES,
BOT_LOCALES,
BUTTON_STYLES_KEY,
DEFAULT_BUTTON_STYLES,
SECTIONS,
load_button_styles_cache,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/button-styles', tags=['Admin Button Styles'])
# ---- Schemas ---------------------------------------------------------------
class ButtonSectionConfig(BaseModel):
"""Configuration for a single button section."""
style: str = 'primary'
icon_custom_emoji_id: str = ''
enabled: bool = True
labels: dict[str, str] = {}
class ButtonStylesResponse(BaseModel):
"""Full button styles configuration (all 7 sections)."""
home: ButtonSectionConfig = ButtonSectionConfig()
subscription: ButtonSectionConfig = ButtonSectionConfig()
balance: ButtonSectionConfig = ButtonSectionConfig()
referral: ButtonSectionConfig = ButtonSectionConfig()
support: ButtonSectionConfig = ButtonSectionConfig()
info: ButtonSectionConfig = ButtonSectionConfig()
admin: ButtonSectionConfig = ButtonSectionConfig()
MAX_LABEL_LENGTH = 100
class ButtonSectionUpdate(BaseModel):
"""Partial update for a single section (None = keep current)."""
style: str | None = None
icon_custom_emoji_id: str | None = None
enabled: bool | None = None
labels: dict[str, str] | None = None
class ButtonStylesUpdate(BaseModel):
"""Partial update — only include sections you want to change."""
home: ButtonSectionUpdate | None = None
subscription: ButtonSectionUpdate | None = None
balance: ButtonSectionUpdate | None = None
referral: ButtonSectionUpdate | None = None
support: ButtonSectionUpdate | None = None
info: ButtonSectionUpdate | None = None
admin: ButtonSectionUpdate | None = None
# ---- Helpers ---------------------------------------------------------------
async def _get_setting_value(db: AsyncSession, key: str) -> str | None:
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
return setting.value if setting else None
async def _set_setting_value(db: AsyncSession, key: str, value: str) -> None:
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = value
else:
setting = SystemSetting(key=key, value=value)
db.add(setting)
await db.commit()
def _build_response(styles: dict[str, dict]) -> ButtonStylesResponse:
return ButtonStylesResponse(
**{section: ButtonSectionConfig(**cfg) for section, cfg in styles.items() if section in SECTIONS},
)
# ---- Routes ----------------------------------------------------------------
@router.get('', response_model=ButtonStylesResponse)
async def get_button_styles(
_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Return current per-section button styles. Admin only."""
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
merged = {section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()}
if raw:
try:
db_data = json.loads(raw)
for section, overrides in db_data.items():
if section in merged and isinstance(overrides, dict):
if overrides.get('style') in ALLOWED_STYLE_VALUES:
merged[section]['style'] = overrides['style']
if isinstance(overrides.get('icon_custom_emoji_id'), str):
merged[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
if isinstance(overrides.get('enabled'), bool):
merged[section]['enabled'] = overrides['enabled']
if isinstance(overrides.get('labels'), dict):
merged[section]['labels'] = {
k: v
for k, v in overrides['labels'].items()
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
}
except (json.JSONDecodeError, TypeError):
pass
return _build_response(merged)
@router.patch('', response_model=ButtonStylesResponse)
async def update_button_styles(
payload: ButtonStylesUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Partially update per-section button styles. Admin only."""
# Load current state
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
current: dict[str, dict] = {
section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()
}
if raw:
try:
db_data = json.loads(raw)
for section, overrides in db_data.items():
if section in current and isinstance(overrides, dict):
if overrides.get('style') in ALLOWED_STYLE_VALUES:
current[section]['style'] = overrides['style']
if isinstance(overrides.get('icon_custom_emoji_id'), str):
current[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
if isinstance(overrides.get('enabled'), bool):
current[section]['enabled'] = overrides['enabled']
if isinstance(overrides.get('labels'), dict):
current[section]['labels'] = {
k: v
for k, v in overrides['labels'].items()
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
}
except (json.JSONDecodeError, TypeError):
pass
# Apply updates
update_data = payload.model_dump(exclude_none=True)
changed_sections: list[str] = []
for section, updates in update_data.items():
if section not in current or not isinstance(updates, dict):
continue
if 'style' in updates:
style_val = updates['style']
if style_val not in ALLOWED_STYLE_VALUES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid style "{style_val}" for section "{section}". '
f'Allowed: {", ".join(sorted(ALLOWED_STYLE_VALUES))}',
)
current[section]['style'] = style_val
if 'icon_custom_emoji_id' in updates:
emoji_val = (updates['icon_custom_emoji_id'] or '').strip()
current[section]['icon_custom_emoji_id'] = emoji_val
if 'enabled' in updates:
current[section]['enabled'] = updates['enabled']
if 'labels' in updates:
raw_labels = updates['labels'] or {}
sanitized: dict[str, str] = {}
for locale_key, label_val in raw_labels.items():
if locale_key not in BOT_LOCALES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid locale "{locale_key}" for section "{section}". '
f'Allowed: {", ".join(BOT_LOCALES)}',
)
if not isinstance(label_val, str):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Label value for locale "{locale_key}" must be a string.',
)
stripped = label_val.strip()
if len(stripped) > MAX_LABEL_LENGTH:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Label for locale "{locale_key}" exceeds {MAX_LABEL_LENGTH} characters.',
)
# Empty string = remove custom label (use default)
if stripped:
sanitized[locale_key] = stripped
current[section]['labels'] = sanitized
changed_sections.append(section)
# Persist
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(current))
# Refresh in-process cache
await load_button_styles_cache()
logger.info('Admin %s updated button styles for sections: %s', admin.telegram_id, changed_sections)
return _build_response(current)
@router.post('/reset', response_model=ButtonStylesResponse)
async def reset_button_styles(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset all button styles to defaults. Admin only."""
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES))
await load_button_styles_cache()
logger.info('Admin %s reset button styles to defaults', admin.telegram_id)
return _build_response(DEFAULT_BUTTON_STYLES)
+14
View File
@@ -598,6 +598,8 @@ async def get_traffic_packages(
result = []
for gb, price in packages.items():
if price <= 0:
continue
result.append(
TrafficPackageResponse(
gb=gb,
@@ -625,6 +627,8 @@ async def get_traffic_packages(
for pkg in packages:
if not pkg.get('enabled', True):
continue
if pkg['price'] <= 0:
continue
result.append(
TrafficPackageResponse(
@@ -705,6 +709,11 @@ async def purchase_traffic(
detail=f'Traffic package {request.gb}GB is not available',
)
base_price_kopeks = packages[request.gb]
if base_price_kopeks <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Traffic package {request.gb}GB has no price configured',
)
else:
# Classic режим
@@ -732,6 +741,11 @@ async def purchase_traffic(
detail='Invalid traffic package',
)
base_price_kopeks = matching_pkg['price']
if base_price_kopeks <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Traffic package has no price configured',
)
# На тарифах пакеты трафика покупаются на 1 месяц (30 дней),
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки.
+15 -8
View File
@@ -516,7 +516,9 @@ class Settings(BaseSettings):
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
MAIN_MENU_MODE: str = 'default'
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
CABINET_BUTTON_STYLE: str = ''
CONNECT_BUTTON_MODE: str = 'miniapp_subscription'
MINIAPP_CUSTOM_URL: str = ''
MINIAPP_STATIC_PATH: str = 'miniapp'
@@ -750,15 +752,16 @@ class Settings(BaseSettings):
'default': 'default',
'full': 'default',
'standard': 'default',
'text': 'text',
'text_only': 'text',
'textual': 'text',
'minimal': 'text',
'cabinet': 'cabinet',
'text': 'cabinet',
'text_only': 'cabinet',
'textual': 'cabinet',
'minimal': 'cabinet',
}
mode = aliases.get(normalized, normalized)
if mode not in {'default', 'text'}:
raise ValueError('MAIN_MENU_MODE must be one of: default, text')
if mode not in {'default', 'cabinet'}:
raise ValueError('MAIN_MENU_MODE must be one of: default, cabinet')
return mode
@field_validator('SERVER_STATUS_MODE', mode='before')
@@ -1365,8 +1368,12 @@ class Settings(BaseSettings):
def get_main_menu_mode(self) -> str:
return getattr(self, 'MAIN_MENU_MODE', 'default')
def is_cabinet_mode(self) -> bool:
return self.get_main_menu_mode() == 'cabinet'
def is_text_main_menu_mode(self) -> bool:
return self.get_main_menu_mode() == 'text'
"""Backward-compatible alias for :meth:`is_cabinet_mode`."""
return self.is_cabinet_mode()
def get_main_menu_miniapp_url(self) -> str | None:
for candidate in [self.MINIAPP_CUSTOM_URL, self.MINIAPP_PURCHASE_URL]:
+4 -3
View File
@@ -1982,13 +1982,14 @@ async def resume_daily_subscription(
subscription.is_daily_paused = False
# Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств)
if subscription.status == SubscriptionStatus.DISABLED.value:
# Восстанавливаем статус ACTIVE если подписка была DISABLED/EXPIRED
if subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value):
previous_status = subscription.status
subscription.status = SubscriptionStatus.ACTIVE.value
# Обновляем время последнего списания для корректного расчёта следующего
subscription.last_daily_charge_at = datetime.utcnow()
subscription.end_date = datetime.utcnow() + timedelta(days=1)
logger.info(f'✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE')
logger.info(f'✅ Суточная подписка {subscription.id} восстановлена из {previous_status} в ACTIVE')
await db.commit()
await db.refresh(subscription)
+6 -3
View File
@@ -45,7 +45,7 @@ from app.services.pinned_message_service import (
)
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.utils.miniapp_buttons import BUTTON_KEY_TO_CABINET_PATH, build_miniapp_or_callback_button
logger = logging.getLogger(__name__)
@@ -76,12 +76,14 @@ async def safe_edit_or_send_text(callback: types.CallbackQuery, text: str, reply
BUTTON_ROWS = BROADCAST_BUTTON_ROWS
DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS
TEXT_MENU_MINIAPP_BUTTON_KEYS = {
CABINET_MINIAPP_BUTTON_KEYS = {
'balance',
'referrals',
'promocode',
'connect',
'subscription',
'support',
'home',
}
@@ -106,11 +108,12 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> t
if button_key not in selected_buttons:
continue
button_config = button_config_map[button_key]
if settings.is_text_main_menu_mode() and button_key in TEXT_MENU_MINIAPP_BUTTON_KEYS:
if settings.is_cabinet_mode() and button_key in CABINET_MINIAPP_BUTTON_KEYS:
row_buttons.append(
build_miniapp_or_callback_button(
text=button_config['text'],
callback_data=button_config['callback'],
cabinet_path=BUTTON_KEY_TO_CABINET_PATH.get(button_key, ''),
)
)
else:
+2 -139
View File
@@ -41,17 +41,13 @@ def _build_notification_settings_view(language: str):
third_hours = NotificationSettingsService.get_third_wave_valid_hours()
third_days = NotificationSettingsService.get_third_wave_trigger_days()
trial_1h_status = _format_toggle(config['trial_inactive_1h'].get('enabled', True))
trial_24h_status = _format_toggle(config['trial_inactive_24h'].get('enabled', True))
trial_channel_status = _format_toggle(config['trial_channel_unsubscribed'].get('enabled', True))
trial_channel_status = _format_toggle(config.get('trial_channel_unsubscribed', {}).get('enabled', True))
expired_1d_status = _format_toggle(config['expired_1d'].get('enabled', True))
second_wave_status = _format_toggle(config['expired_second_wave'].get('enabled', True))
third_wave_status = _format_toggle(config['expired_third_wave'].get('enabled', True))
summary_text = (
'🔔 <b>Уведомления пользователям</b>\n\n'
f'• 1 час после триала: {trial_1h_status}\n'
f'• 24 часа после триала: {trial_24h_status}\n'
f'• Отписка от канала: {trial_channel_status}\n'
f'• 1 день после истечения: {expired_1d_status}\n'
f'• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n'
@@ -62,26 +58,6 @@ def _build_notification_settings_view(language: str):
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=f'{trial_1h_status} • 1 час после триала', callback_data='admin_mon_notify_toggle_trial_1h'
)
],
[
InlineKeyboardButton(
text='🧪 Тест: 1 час после триала', callback_data='admin_mon_notify_preview_trial_1h'
)
],
[
InlineKeyboardButton(
text=f'{trial_24h_status} • 24 часа после триала', callback_data='admin_mon_notify_toggle_trial_24h'
)
],
[
InlineKeyboardButton(
text='🧪 Тест: 24 часа после триала', callback_data='admin_mon_notify_preview_trial_24h'
)
],
[
InlineKeyboardButton(
text=f'{trial_channel_status} • Отписка от канала',
@@ -170,76 +146,7 @@ def _build_notification_preview_message(language: str, notification_type: str):
header = '🧪 <b>Тестовое уведомление мониторинга</b>\n\n'
if notification_type == 'trial_inactive_1h':
template = texts.get(
'TRIAL_INACTIVE_1H',
(
'⏳ <b>Прошёл час, а подключения нет</b>\n\n'
'Если возникли сложности с запуском — воспользуйтесь инструкциями.'
),
)
message = template.format(
price=price_30_days,
end_date=(now + timedelta(days=settings.TRIAL_DURATION_DAYS)).strftime('%d.%m.%Y %H:%M'),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
callback_data='subscription_connect',
)
],
[
InlineKeyboardButton(
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
callback_data='menu_subscription',
)
],
[
InlineKeyboardButton(
text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'),
callback_data='menu_support',
)
],
]
)
elif notification_type == 'trial_inactive_24h':
template = texts.get(
'TRIAL_INACTIVE_24H',
(
'⏳ <b>Вы ещё не подключились к VPN</b>\n\n'
'Прошли сутки с активации тестового периода, но трафик не зафиксирован.'
'\n\nНажмите кнопку ниже, чтобы подключиться.'
),
)
message = template.format(
price=price_30_days,
end_date=(now + timedelta(days=1)).strftime('%d.%m.%Y %H:%M'),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
callback_data='subscription_connect',
)
],
[
InlineKeyboardButton(
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
callback_data='menu_subscription',
)
],
[
InlineKeyboardButton(
text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'),
callback_data='menu_support',
)
],
]
)
elif notification_type == 'trial_channel_unsubscribed':
if notification_type == 'trial_channel_unsubscribed':
template = texts.get(
'TRIAL_CHANNEL_UNSUBSCRIBED',
(
@@ -535,48 +442,6 @@ async def admin_notify_settings(callback: CallbackQuery):
await callback.answer('❌ Не удалось загрузить настройки', show_alert=True)
@router.callback_query(F.data == 'admin_mon_notify_toggle_trial_1h')
@admin_required
async def toggle_trial_1h_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_trial_inactive_1h_enabled()
NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled)
await callback.answer('✅ Включено' if not enabled else '⏸️ Отключено')
await _render_notification_settings(callback)
@router.callback_query(F.data == 'admin_mon_notify_preview_trial_1h')
@admin_required
async def preview_trial_1h_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, 'trial_inactive_1h')
await callback.answer('✅ Пример отправлен')
except Exception as exc:
logger.error('Failed to send trial 1h preview: %s', exc)
await callback.answer('❌ Не удалось отправить тест', show_alert=True)
@router.callback_query(F.data == 'admin_mon_notify_toggle_trial_24h')
@admin_required
async def toggle_trial_24h_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_trial_inactive_24h_enabled()
NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled)
await callback.answer('✅ Включено' if not enabled else '⏸️ Отключено')
await _render_notification_settings(callback)
@router.callback_query(F.data == 'admin_mon_notify_preview_trial_24h')
@admin_required
async def preview_trial_24h_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, 'trial_inactive_24h')
await callback.answer('✅ Пример отправлен')
except Exception as exc:
logger.error('Failed to send trial 24h preview: %s', exc)
await callback.answer('❌ Не удалось отправить тест', show_alert=True)
@router.callback_query(F.data == 'admin_mon_notify_toggle_trial_channel')
@admin_required
async def toggle_trial_channel_notification(callback: CallbackQuery):
@@ -668,8 +533,6 @@ async def preview_all_notifications(callback: CallbackQuery):
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
chat_id = callback.from_user.id
for notification_type in [
'trial_inactive_1h',
'trial_inactive_24h',
'trial_channel_unsubscribed',
'expired_1d',
'expired_2d',
+21 -8
View File
@@ -1537,6 +1537,15 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
# В режиме тарифов проверяем наличие tariff_id
if settings.is_tariffs_mode():
if subscription.tariff_id:
# Проверяем, суточный ли тариф — у суточных нет period_prices, продление через resume
from app.database.crud.tariff import get_tariff_by_id
tariff = getattr(subscription, 'tariff', None) or await get_tariff_by_id(db, subscription.tariff_id)
if tariff and getattr(tariff, 'is_daily', False):
# Суточный тариф: перенаправляем на страницу подписки (там кнопка «Возобновить»)
await show_subscription_info(callback, db_user, db)
return
# У подписки есть тариф - перенаправляем на продление по тарифу
from .tariff_purchase import show_tariff_extend
@@ -3111,11 +3120,15 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
# Прикрепляем тариф к подписке для CRUD функций
subscription.tariff = tariff
# Переключаем статус паузы
# Определяем, нужно ли возобновление: пауза пользователя ИЛИ остановка системой (disabled/expired)
from app.database.models import SubscriptionStatus
was_paused = getattr(subscription, 'is_daily_paused', False)
is_inactive = subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value)
needs_resume = was_paused or is_inactive
# При возобновлении проверяем баланс
if was_paused:
if needs_resume:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price > 0 and db_user.balance_kopeks < daily_price:
await callback.answer(
@@ -3127,10 +3140,11 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
)
return
subscription = await toggle_daily_subscription_pause(db, subscription)
if needs_resume:
# Принудительный resume: снимаем паузу + восстанавливаем статус ACTIVE
from app.database.crud.subscription import resume_daily_subscription
if was_paused:
# Была пауза, теперь возобновили
subscription = await resume_daily_subscription(db, subscription)
message = texts.t('DAILY_SUBSCRIPTION_RESUMED', '▶️ Подписка возобновлена!')
# Синхронизируем с Remnawave - активируем пользователя
try:
@@ -3147,10 +3161,9 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
except Exception as e:
logger.error(f'Ошибка синхронизации с Remnawave при возобновлении: {e}')
else:
# Была активна, теперь на паузе
# Подписка активна, ставим на паузу
subscription = await toggle_daily_subscription_pause(db, subscription)
message = texts.t('DAILY_SUBSCRIPTION_PAUSED', '⏸️ Подписка приостановлена!')
# При паузе можно отключить пользователя в Remnawave (опционально)
# Пока оставляем активным, т.к. пауза - это только остановка списания
await callback.answer(message, show_alert=True)
@@ -1552,6 +1552,12 @@ async def select_tariff_extend_period(
texts = get_texts(db_user.language)
parts = callback.data.split(':')
tariff_id = int(parts[1])
# Кнопка «Назад» шлёт tariff_extend:{id} без периода — показываем экран выбора периода
if len(parts) < 3:
await show_tariff_extend(callback, db_user, db)
return
period = int(parts[2])
tariff = await get_tariff_by_id(db, tariff_id)
+14 -5
View File
@@ -3,6 +3,7 @@ import logging
import time
from aiogram import Bot, Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import InaccessibleMessage
@@ -62,12 +63,20 @@ async def show_ticket_priority_selection(
)
return
await callback.message.edit_text(
texts.t('TICKET_TITLE_INPUT', 'Введите заголовок тикета:'),
reply_markup=get_ticket_cancel_keyboard(db_user.language),
)
prompt_text = texts.t('TICKET_TITLE_INPUT', 'Введите заголовок тикета:')
cancel_kb = get_ticket_cancel_keyboard(db_user.language)
prompt_msg = callback.message
try:
await callback.message.edit_text(prompt_text, reply_markup=cancel_kb)
except TelegramBadRequest:
# Предыдущее сообщение — фото (нет текста для edit_text), удаляем и шлём новое
try:
await callback.message.delete()
except Exception:
pass
prompt_msg = await callback.message.answer(prompt_text, reply_markup=cancel_kb)
# Запоминаем исходное сообщение бота, чтобы далее редактировать его, а не слать новые
await state.update_data(prompt_chat_id=callback.message.chat.id, prompt_message_id=callback.message.message_id)
await state.update_data(prompt_chat_id=prompt_msg.chat.id, prompt_message_id=prompt_msg.message_id)
await state.set_state(TicketStates.waiting_for_title)
await callback.answer()
+123 -25
View File
@@ -362,32 +362,101 @@ def get_language_selection_keyboard(
return InlineKeyboardMarkup(inline_keyboard=buttons)
def _build_text_main_menu_keyboard(
def _build_cabinet_main_menu_keyboard(
language: str,
texts,
*,
is_admin: bool,
is_moderator: bool,
balance_kopeks: int = 0,
) -> InlineKeyboardMarkup:
profile_text = texts.t('MENU_PROFILE', '👤 Личный кабинет')
miniapp_url = settings.get_main_menu_miniapp_url()
"""Build the main-menu keyboard for Cabinet mode.
if miniapp_url:
profile_button = InlineKeyboardButton(
text=profile_text,
web_app=types.WebAppInfo(url=miniapp_url),
)
Each button opens the corresponding section of the cabinet frontend
via ``MINIAPP_CUSTOM_URL`` + path (e.g. ``/subscription``, ``/balance``).
"""
from app.utils.button_styles_cache import CALLBACK_TO_SECTION, get_cached_button_styles
from app.utils.miniapp_buttons import (
CALLBACK_TO_CABINET_STYLE,
_resolve_style,
build_cabinet_url,
)
global_style = _resolve_style((settings.CABINET_BUTTON_STYLE or '').strip())
cached_styles = get_cached_button_styles()
def _cabinet_button(
text: str,
path: str,
callback_fallback: str,
*,
style: str | None = None,
icon_custom_emoji_id: str | None = None,
) -> InlineKeyboardButton:
url = build_cabinet_url(path)
if url:
section = CALLBACK_TO_SECTION.get(callback_fallback)
section_cfg = cached_styles.get(section or '', {}) if section else {}
# 'default' in per-section config means "no color" — do not fall through.
if style:
resolved = _resolve_style(style)
elif section_cfg.get('style'):
resolved = _resolve_style(section_cfg['style'])
else:
resolved = global_style or _resolve_style(CALLBACK_TO_CABINET_STYLE.get(callback_fallback))
resolved_emoji = icon_custom_emoji_id or section_cfg.get('icon_custom_emoji_id') or None
return InlineKeyboardButton(
text=text,
web_app=types.WebAppInfo(url=url),
style=resolved,
icon_custom_emoji_id=resolved_emoji or None,
)
return InlineKeyboardButton(text=text, callback_data=callback_fallback)
# -- Primary action row: Cabinet home --
home_cfg = cached_styles.get('home', {})
if home_cfg.get('enabled', True):
profile_text = home_cfg.get('labels', {}).get(language, '') or texts.t('MENU_PROFILE', '👤 Личный кабинет')
keyboard_rows: list[list[InlineKeyboardButton]] = [
[_cabinet_button(profile_text, '/', 'menu_profile_unavailable')],
]
else:
profile_button = InlineKeyboardButton(
text=profile_text,
callback_data='menu_profile_unavailable',
)
keyboard_rows: list[list[InlineKeyboardButton]] = []
keyboard_rows: list[list[InlineKeyboardButton]] = [[profile_button]]
# -- Section buttons as paired rows --
paired: list[InlineKeyboardButton] = []
if settings.is_language_selection_enabled():
keyboard_rows.append([InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data='menu_language')])
# Subscription (green — main action)
sub_cfg = cached_styles.get('subscription', {})
if sub_cfg.get('enabled', True):
sub_text = sub_cfg.get('labels', {}).get(language, '') or texts.MENU_SUBSCRIPTION
paired.append(_cabinet_button(sub_text, '/subscription', 'menu_subscription'))
# Balance
bal_cfg = cached_styles.get('balance', {})
if bal_cfg.get('enabled', True):
safe_balance = balance_kopeks or 0
# Custom label overrides the whole text including balance amount
custom_bal = bal_cfg.get('labels', {}).get(language, '')
if custom_bal:
balance_text = custom_bal
elif hasattr(texts, 'BALANCE_BUTTON') and safe_balance > 0:
balance_text = texts.BALANCE_BUTTON.format(balance=texts.format_price(safe_balance))
else:
balance_text = texts.t('BALANCE_BUTTON_DEFAULT', '💰 Баланс: {balance}').format(
balance=texts.format_price(safe_balance),
)
paired.append(_cabinet_button(balance_text, '/balance', 'menu_balance'))
# Referrals (if enabled)
ref_cfg = cached_styles.get('referral', {})
if settings.is_referral_program_enabled() and ref_cfg.get('enabled', True):
ref_text = ref_cfg.get('labels', {}).get(language, '') or texts.MENU_REFERRALS
paired.append(_cabinet_button(ref_text, '/referral', 'menu_referrals'))
# Support
support_enabled = False
try:
from app.services.support_settings_service import SupportSettingsService
@@ -396,11 +465,33 @@ def _build_text_main_menu_keyboard(
except Exception:
support_enabled = settings.SUPPORT_MENU_ENABLED
if support_enabled:
keyboard_rows.append([InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data='menu_support')])
sup_cfg = cached_styles.get('support', {})
if support_enabled and sup_cfg.get('enabled', True):
sup_text = sup_cfg.get('labels', {}).get(language, '') or texts.MENU_SUPPORT
paired.append(_cabinet_button(sup_text, '/support', 'menu_support'))
# Info
info_cfg = cached_styles.get('info', {})
if info_cfg.get('enabled', True):
info_text = info_cfg.get('labels', {}).get(language, '') or texts.t('MENU_INFO', '️ Инфо')
paired.append(_cabinet_button(info_text, '/info', 'menu_info'))
# Language selection (stays as callback — not a cabinet section)
if settings.is_language_selection_enabled():
paired.append(InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data='menu_language'))
# Lay out in pairs
for i in range(0, len(paired), 2):
keyboard_rows.append(paired[i : i + 2])
# Admin / Moderator
admin_cfg = cached_styles.get('admin', {})
if is_admin:
keyboard_rows.append([InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel')])
admin_buttons = [InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel')]
if admin_cfg.get('enabled', True):
admin_web_text = admin_cfg.get('labels', {}).get(language, '') or '🖥 Веб-Админка'
admin_buttons.append(_cabinet_button(admin_web_text, '/admin', 'admin_panel'))
keyboard_rows.append(admin_buttons)
elif is_moderator:
keyboard_rows.append([InlineKeyboardButton(text='🧑‍⚖️ Модерация', callback_data='moderator_panel')])
@@ -423,12 +514,13 @@ def get_main_menu_keyboard(
) -> InlineKeyboardMarkup:
texts = get_texts(language)
if settings.is_text_main_menu_mode():
return _build_text_main_menu_keyboard(
if settings.is_cabinet_mode():
return _build_cabinet_main_menu_keyboard(
language,
texts,
is_admin=is_admin,
is_moderator=is_moderator,
balance_kopeks=balance_kopeks,
)
if settings.DEBUG:
@@ -1000,9 +1092,15 @@ def get_subscription_keyboard(
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
if is_daily_tariff:
# Для суточного тарифа показываем кнопку паузы/возобновления
# Для суточного тарифа: проверяем статус подписки
from app.database.models import SubscriptionStatus
sub_status = getattr(subscription, 'status', None)
is_paused = getattr(subscription, 'is_daily_paused', False)
if is_paused:
is_inactive = sub_status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value)
if is_inactive or is_paused:
# Подписка остановлена (системой или пользователем) — показываем «Возобновить»
pause_text = texts.t('RESUME_DAILY_BUTTON', '▶️ Возобновить подписку')
else:
pause_text = texts.t('PAUSE_DAILY_BUTTON', '⏸️ Приостановить подписку')
@@ -1800,7 +1898,7 @@ def get_add_traffic_keyboard(
period_text = f' (за {months_multiplier} мес)'
packages = settings.get_traffic_topup_packages()
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
enabled_packages = [pkg for pkg in packages if pkg['enabled'] and pkg['price'] > 0]
if not enabled_packages:
return InlineKeyboardMarkup(
@@ -1884,8 +1982,8 @@ def get_add_traffic_keyboard_from_tariff(
buttons = []
# Сортируем пакеты по размеру
sorted_packages = sorted(packages.items(), key=lambda x: x[0])
# Сортируем пакеты по размеру, исключаем пакеты с нулевой ценой
sorted_packages = sorted(((gb, p) for gb, p in packages.items() if p > 0), key=lambda x: x[0])
# Пакеты трафика на тарифах покупаются на 1 месяц (30 дней),
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки
-154
View File
@@ -221,7 +221,6 @@ class MonitoringService:
await self._check_expired_subscriptions(db)
await self._check_expiring_subscriptions(db)
await self._check_trial_expiring_soon(db)
await self._check_trial_inactivity_notifications(db)
await self._check_trial_channel_subscriptions(db)
await self._check_expired_subscription_followups(db)
if settings.ENABLE_AUTOPAY:
@@ -504,77 +503,6 @@ class MonitoringService:
except Exception as e:
logger.error(f'Ошибка проверки истекающих тестовых подписок: {e}')
async def _check_trial_inactivity_notifications(self, db: AsyncSession):
if not NotificationSettingsService.are_notifications_globally_enabled():
return
if not self.bot:
return
try:
now = datetime.utcnow()
one_hour_ago = now - timedelta(hours=1)
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.is_trial == True,
Subscription.start_date.isnot(None),
Subscription.start_date <= one_hour_ago,
Subscription.end_date > now,
)
)
)
subscriptions = result.scalars().all()
sent_1h = 0
sent_24h = 0
for subscription in subscriptions:
user = subscription.user
if not user:
continue
if (subscription.traffic_used_gb or 0) > 0:
continue
start_date = subscription.start_date
if not start_date:
continue
time_since_start = now - start_date
if NotificationSettingsService.is_trial_inactive_1h_enabled() and timedelta(
hours=1
) <= time_since_start < timedelta(hours=24):
if not await notification_sent(db, user.id, subscription.id, 'trial_inactive_1h'):
success = await self._send_trial_inactive_notification(user, subscription, 1)
if success:
await record_notification(db, user.id, subscription.id, 'trial_inactive_1h')
sent_1h += 1
if NotificationSettingsService.is_trial_inactive_24h_enabled() and time_since_start >= timedelta(
hours=24
):
if not await notification_sent(db, user.id, subscription.id, 'trial_inactive_24h'):
success = await self._send_trial_inactive_notification(user, subscription, 24)
if success:
await record_notification(db, user.id, subscription.id, 'trial_inactive_24h')
sent_24h += 1
if sent_1h or sent_24h:
await self._log_monitoring_event(
db,
'trial_inactivity_notifications',
f'Отправлено {sent_1h} уведомлений спустя 1 час и {sent_24h} спустя 24 часа',
{'sent_1h': sent_1h, 'sent_24h': sent_24h},
)
except Exception as e:
logger.error(f'Ошибка проверки неактивных тестовых подписок: {e}')
async def _check_trial_channel_subscriptions(self, db: AsyncSession):
from app.database.crud.subscription import is_recently_updated_by_webhook
@@ -1357,88 +1285,6 @@ class MonitoringService:
)
return False
async def _send_trial_inactive_notification(self, user: User, subscription: Subscription, hours: int) -> bool:
try:
texts = get_texts(user.language)
if hours >= 24:
template = texts.get(
'TRIAL_INACTIVE_24H',
(
'⏳ <b>Вы ещё не подключились к VPN</b>\n\n'
'Прошли сутки с активации тестового периода, но трафик не зафиксирован.'
'\n\nНажмите кнопку ниже, чтобы подключиться.'
),
)
else:
template = texts.get(
'TRIAL_INACTIVE_1H',
(
'⏳ <b>Прошёл час, а подключения нет</b>\n\n'
'Если возникли сложности с запуском — воспользуйтесь инструкциями.'
),
)
message = template.format(
price=settings.format_price(settings.PRICE_30_DAYS),
end_date=format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M'),
)
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
build_miniapp_or_callback_button(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
callback_data='subscription_connect',
)
],
[
build_miniapp_or_callback_button(
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
callback_data='menu_subscription',
)
],
[
InlineKeyboardButton(
text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'), callback_data='menu_support'
)
],
]
)
await self._send_message_with_logo(
chat_id=user.telegram_id,
text=message,
parse_mode='HTML',
reply_markup=keyboard,
)
return True
except (TelegramForbiddenError, TelegramBadRequest) as exc:
if self._handle_unreachable_user(user, exc, 'уведомление о бездействии на тесте'):
return True
logger.error(
'Ошибка Telegram API при отправке уведомления об отсутствии подключения пользователю %s: %s',
user.telegram_id,
exc,
)
return False
except TelegramNetworkError as e:
logger.warning(
'Таймаут отправки уведомления об отсутствии подключения пользователю %s: %s',
user.telegram_id,
e,
)
return False
except Exception as e:
logger.error(
'Ошибка отправки уведомления об отсутствии подключения пользователю %s: %s',
user.telegram_id,
e,
)
return False
async def _send_trial_channel_unsubscribed_notification(self, user: User) -> bool:
try:
texts = get_texts(user.language)
@@ -18,8 +18,6 @@ class NotificationSettingsService:
_loaded: bool = False
_DEFAULTS: dict[str, dict[str, Any]] = {
'trial_inactive_1h': {'enabled': True},
'trial_inactive_24h': {'enabled': True},
'trial_channel_unsubscribed': {'enabled': True},
'expired_1d': {'enabled': True},
'expired_second_wave': {
@@ -122,23 +120,6 @@ class NotificationSettingsService:
def is_enabled(cls, key: str) -> bool:
return bool(cls._get(key).get('enabled', True))
# Trial inactivity helpers
@classmethod
def is_trial_inactive_1h_enabled(cls) -> bool:
return cls.is_enabled('trial_inactive_1h')
@classmethod
def set_trial_inactive_1h_enabled(cls, enabled: bool) -> bool:
return cls.set_enabled('trial_inactive_1h', enabled)
@classmethod
def is_trial_inactive_24h_enabled(cls) -> bool:
return cls.is_enabled('trial_inactive_24h')
@classmethod
def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool:
return cls.set_enabled('trial_inactive_24h', enabled)
@classmethod
def is_trial_channel_unsubscribed_enabled(cls) -> bool:
return cls.is_enabled('trial_channel_unsubscribed')
+2 -2
View File
@@ -356,7 +356,7 @@ class RemnaWaveWebhookService:
button_text = texts.get('MY_SUBSCRIPTION_BUTTON', 'My subscription')
return InlineKeyboardMarkup(
inline_keyboard=[
[build_miniapp_or_callback_button(text=button_text, callback_data='subscription')],
[build_miniapp_or_callback_button(text=button_text, callback_data='menu_subscription')],
]
)
@@ -376,7 +376,7 @@ class RemnaWaveWebhookService:
return InlineKeyboardMarkup(
inline_keyboard=[
[build_miniapp_or_callback_button(text=buy_text, callback_data='buy_traffic')],
[build_miniapp_or_callback_button(text=sub_text, callback_data='subscription')],
[build_miniapp_or_callback_button(text=sub_text, callback_data='menu_subscription')],
]
)
+8 -1
View File
@@ -294,6 +294,7 @@ class BotConfigurationService:
'LOGO_FILE': 'INTERFACE_BRANDING',
'HIDE_SUBSCRIPTION_LINK': 'INTERFACE_SUBSCRIPTION',
'MAIN_MENU_MODE': 'INTERFACE',
'CABINET_BUTTON_STYLE': 'INTERFACE',
'CONNECT_BUTTON_MODE': 'CONNECT_BUTTON',
'MINIAPP_CUSTOM_URL': 'CONNECT_BUTTON',
'APP_CONFIG_PATH': 'ADDITIONAL',
@@ -406,7 +407,13 @@ class BotConfigurationService:
],
'MAIN_MENU_MODE': [
ChoiceOption('default', '📋 Полное меню'),
ChoiceOption('text', '📝 Текстовое меню'),
ChoiceOption('cabinet', '🏠 Cabinet (МиниАпп)'),
],
'CABINET_BUTTON_STYLE': [
ChoiceOption('', '🎨 По секциям (авто)'),
ChoiceOption('primary', '🔵 Синий'),
ChoiceOption('success', '🟢 Зелёный'),
ChoiceOption('danger', '🔴 Красный'),
],
'SALES_MODE': [
ChoiceOption('classic', '📋 Классический (периоды из .env)'),
+120
View File
@@ -0,0 +1,120 @@
"""Lightweight in-process cache for per-section cabinet button styles.
Avoids circular imports between ``cabinet.routes`` and ``app.utils.miniapp_buttons``
by keeping the cache and its helpers in a dedicated module.
"""
import json
import logging
from app.database.database import AsyncSessionLocal
logger = logging.getLogger(__name__)
# ---- Defaults per section ------------------------------------------------
DEFAULT_BUTTON_STYLES: dict[str, dict] = {
'home': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'subscription': {'style': 'success', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'balance': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'referral': {'style': 'success', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'support': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'info': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'admin': {'style': 'danger', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
}
BOT_LOCALES = ('ru', 'en', 'ua', 'zh', 'fa')
SECTIONS = list(DEFAULT_BUTTON_STYLES.keys())
# Map callback_data values to their logical section name.
CALLBACK_TO_SECTION: dict[str, str] = {
'menu_profile_unavailable': 'home',
'back_to_menu': 'home',
'menu_subscription': 'subscription',
'subscription': 'subscription',
'subscription_extend': 'subscription',
'subscription_upgrade': 'subscription',
'subscription_connect': 'subscription',
'subscription_resume_checkout': 'subscription',
'return_to_saved_cart': 'subscription',
'menu_buy': 'subscription',
'buy_traffic': 'subscription',
'menu_balance': 'balance',
'balance_topup': 'balance',
'menu_referrals': 'referral',
'menu_referral': 'referral',
'menu_support': 'support',
'menu_info': 'info',
'admin_panel': 'admin',
}
# DB key used for storage.
BUTTON_STYLES_KEY = 'CABINET_BUTTON_STYLES'
# Valid Telegram Bot API style values.
VALID_STYLES = frozenset({'primary', 'success', 'danger'})
# All style values accepted by the admin API ('default' = no color, Telegram default).
ALLOWED_STYLE_VALUES = VALID_STYLES | {'default'}
# ---- Module-level cache ---------------------------------------------------
_cached_styles: dict[str, dict] | None = None
def _deep_copy_styles(source: dict[str, dict]) -> dict[str, dict]:
"""Return a deep copy of styles dict (copies nested ``labels`` dicts)."""
return {section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in source.items()}
def get_cached_button_styles() -> dict[str, dict]:
"""Return the current merged config (DB overrides + defaults).
If the cache has not been loaded yet, returns defaults.
"""
if _cached_styles is not None:
return _deep_copy_styles(_cached_styles)
return _deep_copy_styles(DEFAULT_BUTTON_STYLES)
async def load_button_styles_cache() -> dict[str, dict]:
"""Load button styles from DB and refresh the module cache.
Called at bot startup and after admin updates via the cabinet API.
"""
global _cached_styles
merged = _deep_copy_styles(DEFAULT_BUTTON_STYLES)
try:
from sqlalchemy import select
from app.database.models import SystemSetting
async with AsyncSessionLocal() as session:
result = await session.execute(select(SystemSetting).where(SystemSetting.key == BUTTON_STYLES_KEY))
setting = result.scalar_one_or_none()
if setting and setting.value:
db_data: dict = json.loads(setting.value)
for section, overrides in db_data.items():
if section in merged and isinstance(overrides, dict):
if overrides.get('style') in ALLOWED_STYLE_VALUES:
merged[section]['style'] = overrides['style']
if isinstance(overrides.get('icon_custom_emoji_id'), str):
merged[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
if isinstance(overrides.get('enabled'), bool):
merged[section]['enabled'] = overrides['enabled']
if isinstance(overrides.get('labels'), dict):
merged[section]['labels'] = {
k: v
for k, v in overrides['labels'].items()
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
}
except Exception:
logger.exception('Failed to load button styles from DB, using defaults')
_cached_styles = merged
logger.info('Button styles cache loaded: %s', list(merged.keys()))
return merged
+138 -11
View File
@@ -2,30 +2,157 @@ from aiogram import types
from aiogram.types import InlineKeyboardButton
from app.config import settings
from app.utils.button_styles_cache import CALLBACK_TO_SECTION, get_cached_button_styles
# Mapping from callback_data to cabinet frontend paths.
# Used for automatic deep-linking when explicit ``cabinet_path`` is not provided.
# If callback_data is NOT in this mapping, the button falls back to a regular callback.
CALLBACK_TO_CABINET_PATH: dict[str, str] = {
'menu_balance': '/balance',
'balance_topup': '/balance/top-up',
'menu_subscription': '/subscription',
'subscription': '/subscription',
'subscription_extend': '/subscription',
'subscription_upgrade': '/subscription',
'subscription_connect': '/subscription',
'subscription_resume_checkout': '/subscription',
'return_to_saved_cart': '/subscription',
'menu_buy': '/subscription',
'buy_traffic': '/subscription',
'menu_referrals': '/referral',
'menu_referral': '/referral',
'menu_support': '/support',
'menu_info': '/info',
'menu_profile': '/profile',
'back_to_menu': '/',
}
# Default button styles per callback_data for cabinet mode.
# Values: 'primary' (blue), 'success' (green), 'danger' (red), None (default).
CALLBACK_TO_CABINET_STYLE: dict[str, str] = {
'menu_balance': 'primary',
'balance_topup': 'primary',
'menu_subscription': 'success',
'subscription': 'success',
'subscription_extend': 'success',
'subscription_upgrade': 'success',
'subscription_connect': 'success',
'subscription_resume_checkout': 'success',
'return_to_saved_cart': 'success',
'menu_buy': 'success',
'buy_traffic': 'success',
'menu_referrals': 'success',
'menu_referral': 'success',
'menu_support': 'primary',
'menu_info': 'primary',
'menu_profile': 'primary',
'back_to_menu': 'primary',
}
# Mapping from broadcast button keys to cabinet paths.
BUTTON_KEY_TO_CABINET_PATH: dict[str, str] = {
'balance': '/balance/top-up',
'referrals': '/referral',
'promocode': '/subscription',
'connect': '/subscription',
'subscription': '/subscription',
'support': '/support',
'home': '/',
}
# Valid style values accepted by the Telegram Bot API.
_VALID_STYLES = frozenset({'primary', 'success', 'danger'})
def _resolve_style(style: str | None) -> str | None:
"""Return a validated style or ``None``."""
if style and style in _VALID_STYLES:
return style
return None
def build_cabinet_url(path: str = '') -> str:
"""Join ``MINIAPP_CUSTOM_URL`` with an optional *path* segment.
Handles trailing-slash normalization so that both
``https://example.com`` and ``https://example.com/`` produce
correct URLs like ``https://example.com/balance``.
Returns an empty string when the base URL is not configured
or when *path* is empty (no known section).
"""
base = (settings.MINIAPP_CUSTOM_URL or '').strip().rstrip('/')
if not base:
return ''
if not path:
return ''
if path == '/':
return base
if not path.startswith('/'):
path = f'/{path}'
return f'{base}{path}'
def build_miniapp_or_callback_button(
text: str,
*,
callback_data: str,
cabinet_path: str | None = None,
style: str | None = None,
icon_custom_emoji_id: str | None = None,
) -> InlineKeyboardButton:
"""Create a button that opens the miniapp or falls back to a callback.
"""Create a button that opens the cabinet miniapp or falls back to a callback.
In text menu mode, if ``MINIAPP_CUSTOM_URL`` is configured the button
opens the full cabinet miniapp. Otherwise (or outside text menu mode)
the regular ``callback_data`` is used so the user stays in the bot.
In cabinet menu mode, if ``MINIAPP_CUSTOM_URL`` is configured the button
opens the relevant section of the cabinet. The target section is determined
by ``cabinet_path`` (explicit) or inferred from ``callback_data`` via
``CALLBACK_TO_CABINET_PATH``.
Button styling (Bot API 9.4):
- ``style`` overrides the button color: ``'primary'`` (blue),
``'success'`` (green), ``'danger'`` (red). When omitted the style is
resolved from ``CABINET_BUTTON_STYLE`` config or per-section defaults.
- ``icon_custom_emoji_id`` shows a custom emoji before the button text
(requires bot owner to have Telegram Premium).
When ``callback_data`` is not found in the mapping and no explicit
``cabinet_path`` is given, the button falls back to a regular Telegram
callback this keeps actions like ``claim_discount_*`` working correctly.
Only ``MINIAPP_CUSTOM_URL`` is considered here the purchase-only URL
(``MINIAPP_PURCHASE_URL``) is intentionally excluded because it cannot
display subscription details and would load indefinitely.
"""
if settings.is_text_main_menu_mode():
miniapp_url = (settings.MINIAPP_CUSTOM_URL or '').strip()
if miniapp_url:
return InlineKeyboardButton(
text=text,
web_app=types.WebAppInfo(url=miniapp_url),
)
if settings.is_cabinet_mode():
path = cabinet_path or CALLBACK_TO_CABINET_PATH.get(callback_data)
if path:
url = build_cabinet_url(path)
if url:
# Resolve per-section config from cache
section = CALLBACK_TO_SECTION.get(callback_data)
section_cfg = get_cached_button_styles().get(section or '', {}) if section else {}
# Style chain: explicit param > per-section DB > global config > hardcoded default
# 'default' in per-section config means "no color" — do not fall through.
if style:
resolved_style = _resolve_style(style)
elif section_cfg.get('style'):
resolved_style = _resolve_style(section_cfg['style'])
else:
resolved_style = _resolve_style((settings.CABINET_BUTTON_STYLE or '').strip()) or _resolve_style(
CALLBACK_TO_CABINET_STYLE.get(callback_data)
)
# Emoji chain: explicit param > per-section DB
resolved_emoji = icon_custom_emoji_id or section_cfg.get('icon_custom_emoji_id') or None
return InlineKeyboardButton(
text=text,
web_app=types.WebAppInfo(url=url),
style=resolved_style,
icon_custom_emoji_id=resolved_emoji or None,
)
return InlineKeyboardButton(text=text, callback_data=callback_data)
+2 -2
View File
@@ -329,7 +329,7 @@
Функции: `_t` — Helper for localized button labels with fallbacks., `get_admin_main_keyboard`, `get_admin_users_submenu_keyboard`, `get_admin_promo_submenu_keyboard`, `get_admin_communications_submenu_keyboard`, `get_admin_support_submenu_keyboard`, `get_admin_settings_submenu_keyboard`, `get_admin_system_submenu_keyboard`, `get_admin_reports_keyboard`, `get_admin_report_result_keyboard`, `get_admin_users_keyboard`, `get_admin_users_filters_keyboard`, `get_admin_subscriptions_keyboard`, `get_admin_promocodes_keyboard`, `get_admin_campaigns_keyboard`, `get_campaign_management_keyboard`, `get_campaign_edit_keyboard`, `get_campaign_bonus_type_keyboard`, `get_promocode_management_keyboard`, `get_admin_messages_keyboard`, `get_admin_monitoring_keyboard`, `get_admin_remnawave_keyboard`, `get_admin_statistics_keyboard`, `get_user_management_keyboard`, `get_user_promo_group_keyboard`, `get_confirmation_keyboard`, `get_promocode_type_keyboard`, `get_promocode_list_keyboard`, `get_broadcast_target_keyboard`, `get_custom_criteria_keyboard`, `get_broadcast_history_keyboard`, `get_sync_options_keyboard`, `get_sync_confirmation_keyboard`, `get_sync_result_keyboard`, `get_period_selection_keyboard`, `get_node_management_keyboard`, `get_squad_management_keyboard`, `get_squad_edit_keyboard`, `get_monitoring_keyboard`, `get_monitoring_logs_keyboard`, `get_monitoring_logs_navigation_keyboard`, `get_log_detail_keyboard`, `get_monitoring_clear_confirm_keyboard`, `get_monitoring_status_keyboard`, `get_monitoring_settings_keyboard`, `get_log_type_filter_keyboard`, `get_admin_servers_keyboard`, `get_server_edit_keyboard`, `get_admin_pagination_keyboard`, `get_maintenance_keyboard`, `get_sync_simplified_keyboard`, `get_welcome_text_keyboard`, `get_broadcast_button_config`, `get_broadcast_button_labels`, `get_message_buttons_selector_keyboard`, `get_broadcast_media_keyboard`, `get_media_confirm_keyboard`, `get_updated_message_buttons_selector_keyboard_with_media`
- `app/keyboards/inline.py` — Python-модуль
Классы: нет
Функции: `_get_localized_value`, `_build_additional_buttons`, `get_rules_keyboard`, `get_channel_sub_keyboard`, `get_post_registration_keyboard`, `get_language_selection_keyboard`, `_build_text_main_menu_keyboard`, `get_main_menu_keyboard`, `get_info_menu_keyboard`, `get_happ_download_button_row`, `get_happ_cryptolink_keyboard`, `get_happ_download_platform_keyboard`, `get_happ_download_link_keyboard`, `get_back_keyboard`, `get_server_status_keyboard`, `get_insufficient_balance_keyboard`, `get_subscription_keyboard`, `get_payment_methods_keyboard_with_cart`, `get_subscription_confirm_keyboard_with_cart`, `get_insufficient_balance_keyboard_with_cart`, `get_trial_keyboard`, `get_subscription_period_keyboard`, `get_traffic_packages_keyboard`, `get_countries_keyboard`, `get_devices_keyboard`, `_get_device_declension`, `get_subscription_confirm_keyboard`, `get_balance_keyboard`, `get_payment_methods_keyboard`, `get_yookassa_payment_keyboard`, `get_autopay_notification_keyboard`, `get_subscription_expiring_keyboard`, `get_referral_keyboard`, `get_support_keyboard`, `get_pagination_keyboard`, `get_confirmation_keyboard`, `get_autopay_keyboard`, `get_autopay_days_keyboard`, `_get_days_word`, `get_extend_subscription_keyboard`, `get_add_traffic_keyboard`, `get_change_devices_keyboard`, `get_confirm_change_devices_keyboard`, `get_reset_traffic_confirm_keyboard`, `get_manage_countries_keyboard`, `get_device_selection_keyboard`, `get_connection_guide_keyboard`, `get_app_selection_keyboard`, `get_specific_app_keyboard`, `get_extend_subscription_keyboard_with_prices`, `get_cryptobot_payment_keyboard`, `get_devices_management_keyboard`, `get_updated_subscription_settings_keyboard`, `get_device_reset_confirm_keyboard`, `get_device_management_help_keyboard`, `get_ticket_cancel_keyboard`, `get_my_tickets_keyboard`, `get_ticket_view_keyboard`, `get_ticket_reply_cancel_keyboard`, `get_admin_tickets_keyboard`, `get_admin_ticket_view_keyboard`, `get_admin_ticket_reply_cancel_keyboard`
Функции: `_get_localized_value`, `_build_additional_buttons`, `get_rules_keyboard`, `get_channel_sub_keyboard`, `get_post_registration_keyboard`, `get_language_selection_keyboard`, `_build_cabinet_main_menu_keyboard`, `get_main_menu_keyboard`, `get_info_menu_keyboard`, `get_happ_download_button_row`, `get_happ_cryptolink_keyboard`, `get_happ_download_platform_keyboard`, `get_happ_download_link_keyboard`, `get_back_keyboard`, `get_server_status_keyboard`, `get_insufficient_balance_keyboard`, `get_subscription_keyboard`, `get_payment_methods_keyboard_with_cart`, `get_subscription_confirm_keyboard_with_cart`, `get_insufficient_balance_keyboard_with_cart`, `get_trial_keyboard`, `get_subscription_period_keyboard`, `get_traffic_packages_keyboard`, `get_countries_keyboard`, `get_devices_keyboard`, `_get_device_declension`, `get_subscription_confirm_keyboard`, `get_balance_keyboard`, `get_payment_methods_keyboard`, `get_yookassa_payment_keyboard`, `get_autopay_notification_keyboard`, `get_subscription_expiring_keyboard`, `get_referral_keyboard`, `get_support_keyboard`, `get_pagination_keyboard`, `get_confirmation_keyboard`, `get_autopay_keyboard`, `get_autopay_days_keyboard`, `_get_days_word`, `get_extend_subscription_keyboard`, `get_add_traffic_keyboard`, `get_change_devices_keyboard`, `get_confirm_change_devices_keyboard`, `get_reset_traffic_confirm_keyboard`, `get_manage_countries_keyboard`, `get_device_selection_keyboard`, `get_connection_guide_keyboard`, `get_app_selection_keyboard`, `get_specific_app_keyboard`, `get_extend_subscription_keyboard_with_prices`, `get_cryptobot_payment_keyboard`, `get_devices_management_keyboard`, `get_updated_subscription_settings_keyboard`, `get_device_reset_confirm_keyboard`, `get_device_management_help_keyboard`, `get_ticket_cancel_keyboard`, `get_my_tickets_keyboard`, `get_ticket_view_keyboard`, `get_ticket_reply_cancel_keyboard`, `get_admin_tickets_keyboard`, `get_admin_ticket_view_keyboard`, `get_admin_ticket_reply_cancel_keyboard`
- `app/keyboards/reply.py` — Python-модуль
Классы: нет
Функции: `get_main_reply_keyboard`, `get_admin_reply_keyboard`, `get_cancel_keyboard`, `get_confirmation_reply_keyboard`, `get_skip_keyboard`, `remove_keyboard`, `get_contact_keyboard`, `get_location_keyboard`
@@ -512,7 +512,7 @@
Функции: `is_qr_message`, `_get_language`, `_default_privacy_hint`, `append_privacy_hint`, `prepare_privacy_safe_kwargs`, `is_privacy_restricted_error`, `patch_message_methods`
- `app/utils/miniapp_buttons.py` — Python-модуль
Классы: нет
Функции: `build_miniapp_or_callback_button` — Create a button that opens the miniapp in text menu mode.
Функции: `build_cabinet_url`, `build_miniapp_or_callback_button` — Create a button that opens the cabinet miniapp section or falls back to a callback.
- `app/utils/pagination.py` — Python-модуль
Классы: `PaginationResult` (1 методов)
Функции: `paginate_list`, `get_pagination_info`, `get_page_numbers`
+1 -1
View File
@@ -6,7 +6,7 @@ readme = 'README.md'
license = { text = 'MIT' }
requires-python = '==3.13.*'
dependencies = [
'aiogram>=3.22.0',
"aiogram>=3.25.0",
'sqlalchemy>=2.0.43',
'alembic>=1.16.5',
'asyncpg>=0.30.0',
Generated
+5 -5
View File
@@ -13,7 +13,7 @@ wheels = [
[[package]]
name = "aiogram"
version = "3.24.0"
version = "3.25.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -23,9 +23,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fd/2f/04f47e81def8f2168679b1551e665e7ee02cf063e7bddace9fb5d1ce2f35/aiogram-3.24.0.tar.gz", hash = "sha256:ec547ede5bfa8a7a4f5fb02c75391333fc43b6f3de6a6d3f00a32e27628df5f6", size = 1713321, upload-time = "2026-01-02T00:56:55.3Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ff/31/360c4ce76e60e9e7bcdda1af1ab4331d78837fbb22847a62121ad32b7672/aiogram-3.25.0.tar.gz", hash = "sha256:8a8b0c34f8c4ca8a6501b954abb0eeba26743449e35e20b70c0d810347354c3c", size = 1721010, upload-time = "2026-02-10T21:50:25.473Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/a5/7ba5f75b56f87a956b9e5a3e823bcbb5b55fc968914a16f3c7aa659cfc89/aiogram-3.24.0-py3-none-any.whl", hash = "sha256:eb3cc05b0ec53c7e24d7eada5c069aee2f431332e2e7bc2c8adf30d13b02f715", size = 706866, upload-time = "2026-01-02T00:56:53.115Z" },
{ url = "https://files.pythonhosted.org/packages/cf/be/1090252415e192687985517162dbdcee2ec4150cda1fa52bf57ae1f1c2a8/aiogram-3.25.0-py3-none-any.whl", hash = "sha256:0243966e93fbde14e90c0dfd0b3776c637ebf7ddcca2c7ee81ecbd68d9490cce", size = 713972, upload-time = "2026-02-10T21:50:23.253Z" },
]
[[package]]
@@ -1114,7 +1114,7 @@ wheels = [
[[package]]
name = "remnawave-bedolaga-telegram-bot"
version = "3.8.0"
version = "3.10.0"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },
@@ -1145,7 +1145,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiogram", specifier = ">=3.22.0" },
{ name = "aiogram", specifier = ">=3.25.0" },
{ name = "aiosqlite", specifier = ">=0.21.0" },
{ name = "alembic", specifier = ">=1.16.5" },
{ name = "asyncpg", specifier = ">=0.30.0" },