Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bc29c3421 | |||
| 448be6e512 | |||
| 871ceb866c | |||
| 8e61fe4774 | |||
| d4dfa235e5 | |||
| 97ec39aa80 | |||
| 61a97220d3 | |||
| 2d04f2aa28 | |||
| d6e79161e7 | |||
| 45fd543206 | |||
| ba0a5e9abd | |||
| d712ab8301 | |||
| 1e2a7e3096 | |||
| 64a684cd2f | |||
| e4c207ecff | |||
| 80914c1af7 | |||
| e1822800ab | |||
| 68773b7e77 | |||
| 10538e7351 | |||
| a9687912df | |||
| 46c1a69456 | |||
| bf2b2f1c56 | |||
| 9ac6da490d | |||
| ad87c5fb5e |
+12
-1
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.11.0"
|
||||
".": "3.12.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## [3.12.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.11.0...v3.12.0) (2026-02-15)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add 'default' (no color) option for button styles ([10538e7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10538e735149bf3f3f2029ff44b94d11d48c478e))
|
||||
* add button style and emoji support for cabinet mode (Bot API 9.4) ([bf2b2f1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf2b2f1c5650e527fcac0fb3e72b4e6e19bef406))
|
||||
* add per-button enable/disable toggle and custom labels per locale ([68773b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68773b7e77aa344d18b0f304fa561c91d7631c05))
|
||||
* add per-section button style and emoji customization via admin API ([a968791](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9687912dfe756e7d772d96cc253f78f2e97185c))
|
||||
* add web admin button for admins in cabinet mode ([9ac6da4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ac6da490dffa03ce823009c6b4e5014b7d2bdfb))
|
||||
* rename MAIN_MENU_MODE=text to cabinet with deep-linking to frontend sections ([ad87c5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad87c5fb5e1a4dd0ef7691f12764d3df1530f643))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* daily tariff subscriptions stuck in expired/disabled with no resume path ([80914c1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/80914c1af739aa0ee1ea75b0e5871bf391b9020d))
|
||||
* filter out traffic packages with zero price from purchase options ([64a684c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/64a684cd2ff51e663a1f70e61c07ca6b4f6bfc91))
|
||||
* handle photo message in ticket creation flow ([e182280](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e1822800aba3ea5eee721846b1e0d8df0a9398d1))
|
||||
* handle tariff_extend callback without period (back button crash) ([ba0a5e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba0a5e9abd9bd582968d69a5c6e57f336094c782))
|
||||
* pre-validate CABINET_BUTTON_STYLE to prevent invalid values from suppressing per-section defaults ([46c1a69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46c1a69456036cb1be784b8d952f27110e9124eb))
|
||||
* remove redundant trial inactivity monitoring checks ([d712ab8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d712ab830166cab61ce38dd32498a8a9e3e602b0))
|
||||
* webhook notification 'My Subscription' button uses unregistered callback_data ([1e2a7e3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e2a7e3096af11540184d60885b8c08d73506c4a))
|
||||
|
||||
## [3.11.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.3...v3.11.0) (2026-02-12)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.11.0" # x-release-please-version
|
||||
ARG VERSION="v3.12.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
+17
@@ -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('Бот успешно настроен')
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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]:
|
||||
|
||||
@@ -131,6 +131,17 @@ async def get_promocode_use_by_user_and_code(db: AsyncSession, user_id: int, pro
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def count_user_recent_activations(db: AsyncSession, user_id: int, hours: int = 24) -> int:
|
||||
"""Подсчитывает количество активаций промокодов пользователем за последние N часов."""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff = datetime.utcnow() - timedelta(hours=hours)
|
||||
result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id)).where(and_(PromoCodeUse.user_id == user_id, PromoCodeUse.used_at >= cutoff))
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_user_promocodes(db: AsyncSession, user_id: int) -> list[PromoCodeUse]:
|
||||
result = await db.execute(
|
||||
select(PromoCodeUse).where(PromoCodeUse.user_id == user_id).order_by(PromoCodeUse.used_at.desc())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -84,14 +84,52 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
|
||||
)
|
||||
return
|
||||
|
||||
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
|
||||
|
||||
# Валидация формата
|
||||
if not validate_promo_format(code):
|
||||
await message.answer(texts.PROMOCODE_INVALID, reply_markup=get_back_keyboard(db_user.language))
|
||||
return
|
||||
|
||||
# Rate-limit на перебор
|
||||
if promo_limiter.is_blocked(message.from_user.id):
|
||||
cooldown = promo_limiter.get_block_cooldown(message.from_user.id)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PROMO_RATE_LIMITED',
|
||||
'⏳ Слишком много попыток. Попробуйте через {cooldown} сек.',
|
||||
).format(cooldown=cooldown),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Лимит на стакинг (макс активаций в день)
|
||||
if not promo_limiter.can_activate(message.from_user.id):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PROMO_DAILY_LIMIT',
|
||||
'❌ Достигнут лимит активаций промокодов на сегодня. Попробуйте завтра.',
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
result = await activate_promocode_for_registration(db, db_user.id, code, message.bot)
|
||||
|
||||
if result['success']:
|
||||
promo_limiter.record_activation(message.from_user.id)
|
||||
await message.answer(
|
||||
texts.PROMOCODE_SUCCESS.format(description=result['description']),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
else:
|
||||
# Записываем неудачную попытку только для not_found (перебор)
|
||||
if result['error'] == 'not_found':
|
||||
promo_limiter.record_failed_attempt(message.from_user.id)
|
||||
promo_limiter.cleanup()
|
||||
|
||||
error_messages = {
|
||||
'not_found': texts.PROMOCODE_INVALID,
|
||||
'expired': texts.PROMOCODE_EXPIRED,
|
||||
@@ -104,6 +142,10 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
|
||||
'PROMOCODE_ACTIVE_DISCOUNT_EXISTS',
|
||||
'❌ У вас уже есть активная скидка. Используйте её перед активацией новой.',
|
||||
),
|
||||
'daily_limit': texts.t(
|
||||
'PROMO_DAILY_LIMIT',
|
||||
'❌ Достигнут лимит активаций промокодов на сегодня. Попробуйте завтра.',
|
||||
),
|
||||
'server_error': texts.ERROR,
|
||||
}
|
||||
|
||||
|
||||
+44
-3
@@ -159,10 +159,27 @@ async def handle_potential_referral_code(message: types.Message, state: FSMConte
|
||||
language = data.get('language') or (getattr(user, 'language', None) if user else None) or DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
|
||||
|
||||
potential_code = message.text.strip()
|
||||
if len(potential_code) < 4 or len(potential_code) > 20:
|
||||
if len(potential_code) < 3 or len(potential_code) > 50:
|
||||
return False
|
||||
|
||||
# Валидация формата (только буквы, цифры, дефис, подчёркивание)
|
||||
if not validate_promo_format(potential_code):
|
||||
return False
|
||||
|
||||
# Rate-limit на перебор промокодов
|
||||
if promo_limiter.is_blocked(message.from_user.id):
|
||||
cooldown = promo_limiter.get_block_cooldown(message.from_user.id)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PROMO_RATE_LIMITED',
|
||||
'⏳ Слишком много попыток. Попробуйте через {cooldown} сек.',
|
||||
).format(cooldown=cooldown)
|
||||
)
|
||||
return True
|
||||
|
||||
# Сначала проверяем реферальный код
|
||||
referrer = await get_user_by_referral_code(db, potential_code)
|
||||
if referrer:
|
||||
@@ -217,7 +234,10 @@ async def handle_potential_referral_code(message: types.Message, state: FSMConte
|
||||
|
||||
return True
|
||||
|
||||
# Ни реферальный код, ни промокод не найдены
|
||||
# Ни реферальный код, ни промокод не найдены — записываем неудачную попытку
|
||||
promo_limiter.record_failed_attempt(message.from_user.id)
|
||||
promo_limiter.cleanup()
|
||||
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'REFERRAL_OR_PROMO_CODE_INVALID_HELP',
|
||||
@@ -969,8 +989,26 @@ async def process_referral_code_input(message: types.Message, state: FSMContext,
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
|
||||
|
||||
code = message.text.strip()
|
||||
|
||||
# Валидация формата
|
||||
if not validate_promo_format(code):
|
||||
await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод'))
|
||||
return
|
||||
|
||||
# Rate-limit на перебор
|
||||
if promo_limiter.is_blocked(message.from_user.id):
|
||||
cooldown = promo_limiter.get_block_cooldown(message.from_user.id)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PROMO_RATE_LIMITED',
|
||||
'⏳ Слишком много попыток. Попробуйте через {cooldown} сек.',
|
||||
).format(cooldown=cooldown)
|
||||
)
|
||||
return
|
||||
|
||||
# Сначала проверяем, является ли это реферальным кодом
|
||||
referrer = await get_user_by_referral_code(db, code)
|
||||
if referrer:
|
||||
@@ -1000,7 +1038,10 @@ async def process_referral_code_input(message: types.Message, state: FSMContext,
|
||||
await complete_registration(message, state, db)
|
||||
return
|
||||
|
||||
# Ни реферальный код, ни промокод не найдены
|
||||
# Ни реферальный код, ни промокод не найдены — записываем неудачу
|
||||
promo_limiter.record_failed_attempt(message.from_user.id)
|
||||
promo_limiter.cleanup()
|
||||
|
||||
await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод'))
|
||||
logger.info(f'❌ Неверный код (ни реферальный, ни промокод): {code}')
|
||||
return
|
||||
|
||||
@@ -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)
|
||||
|
||||
+75
-70
@@ -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()
|
||||
|
||||
@@ -91,40 +100,18 @@ async def handle_ticket_title_input(message: types.Message, state: FSMContext, d
|
||||
asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0))
|
||||
if len(title) < 5:
|
||||
texts = get_texts(db_user.language)
|
||||
if prompt_chat_id and prompt_message_id:
|
||||
text_val = texts.t(
|
||||
'TICKET_TITLE_TOO_SHORT', 'Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:'
|
||||
)
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
text=text_val,
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
texts.t('TICKET_TITLE_TOO_SHORT', 'Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:')
|
||||
)
|
||||
text_val = texts.t(
|
||||
'TICKET_TITLE_TOO_SHORT', 'Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:'
|
||||
)
|
||||
await _edit_or_send(message, prompt_chat_id, prompt_message_id, text_val, db_user.language)
|
||||
return
|
||||
|
||||
if len(title) > 255:
|
||||
texts = get_texts(db_user.language)
|
||||
if prompt_chat_id and prompt_message_id:
|
||||
text_val = texts.t(
|
||||
'TICKET_TITLE_TOO_LONG', 'Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:'
|
||||
)
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
text=text_val,
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'TICKET_TITLE_TOO_LONG', 'Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:'
|
||||
)
|
||||
)
|
||||
text_val = texts.t(
|
||||
'TICKET_TITLE_TOO_LONG', 'Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:'
|
||||
)
|
||||
await _edit_or_send(message, prompt_chat_id, prompt_message_id, text_val, db_user.language)
|
||||
return
|
||||
|
||||
# Глобальный блок
|
||||
@@ -147,20 +134,8 @@ async def handle_ticket_title_input(message: types.Message, state: FSMContext, d
|
||||
await state.update_data(title=title)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if prompt_chat_id and prompt_message_id:
|
||||
text_val = texts.t('TICKET_MESSAGE_INPUT', 'Опишите проблему (до 500 символов) или отправьте фото с подписью:')
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
text=text_val,
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
texts.t('TICKET_MESSAGE_INPUT', 'Опишите проблему (до 500 символов) или отправьте фото с подписью:'),
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
)
|
||||
text_val = texts.t('TICKET_MESSAGE_INPUT', 'Опишите проблему (до 500 символов) или отправьте фото с подписью:')
|
||||
await _edit_or_send(message, prompt_chat_id, prompt_message_id, text_val, db_user.language)
|
||||
|
||||
await state.set_state(TicketStates.waiting_for_message)
|
||||
|
||||
@@ -235,7 +210,10 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
|
||||
)
|
||||
)
|
||||
if prompt_chat_id and prompt_message_id:
|
||||
await message.bot.edit_message_text(chat_id=prompt_chat_id, message_id=prompt_message_id, text=text_msg)
|
||||
try:
|
||||
await message.bot.edit_message_text(chat_id=prompt_chat_id, message_id=prompt_message_id, text=text_msg)
|
||||
except TelegramBadRequest:
|
||||
await message.answer(text_msg)
|
||||
else:
|
||||
await message.answer(text_msg)
|
||||
await state.clear()
|
||||
@@ -252,15 +230,7 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
|
||||
err_text = texts.t(
|
||||
'TICKET_MESSAGE_TOO_SHORT', 'Сообщение слишком короткое. Опишите проблему подробнее или отправьте фото:'
|
||||
)
|
||||
if prompt_chat_id and prompt_message_id:
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
text=err_text,
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
)
|
||||
else:
|
||||
await message.answer(err_text)
|
||||
await _edit_or_send(message, prompt_chat_id, prompt_message_id, err_text, db_user.language)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
@@ -314,13 +284,16 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
|
||||
]
|
||||
)
|
||||
if prompt_chat_id and prompt_message_id:
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
text=creation_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
try:
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
text=creation_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await message.answer(creation_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
else:
|
||||
await message.answer(creation_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
@@ -714,6 +687,28 @@ async def user_delete_message(callback: types.CallbackQuery):
|
||||
await callback.answer('✅')
|
||||
|
||||
|
||||
async def _edit_or_send(
|
||||
message: types.Message,
|
||||
chat_id: int | None,
|
||||
message_id: int | None,
|
||||
text: str,
|
||||
language: str,
|
||||
) -> None:
|
||||
"""Попытаться отредактировать prompt-сообщение, при неудаче — отправить новое."""
|
||||
if chat_id and message_id:
|
||||
try:
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
text=text,
|
||||
reply_markup=get_ticket_cancel_keyboard(language),
|
||||
)
|
||||
return
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
await message.answer(text, reply_markup=get_ticket_cancel_keyboard(language))
|
||||
|
||||
|
||||
async def _try_delete_message_later(bot: Bot, chat_id: int, message_id: int, delay_seconds: float = 1.0):
|
||||
try:
|
||||
await asyncio.sleep(delay_seconds)
|
||||
@@ -731,10 +726,20 @@ async def reply_to_ticket(callback: types.CallbackQuery, state: FSMContext, db_u
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t('TICKET_REPLY_INPUT', 'Введите ваш ответ:'),
|
||||
reply_markup=get_ticket_reply_cancel_keyboard(db_user.language),
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
texts.t('TICKET_REPLY_INPUT', 'Введите ваш ответ:'),
|
||||
reply_markup=get_ticket_reply_cancel_keyboard(db_user.language),
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
await callback.message.answer(
|
||||
texts.t('TICKET_REPLY_INPUT', 'Введите ваш ответ:'),
|
||||
reply_markup=get_ticket_reply_cancel_keyboard(db_user.language),
|
||||
)
|
||||
|
||||
await state.set_state(TicketStates.waiting_for_reply)
|
||||
await callback.answer()
|
||||
|
||||
+123
-25
@@ -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 дней),
|
||||
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки
|
||||
|
||||
@@ -12,10 +12,26 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ThrottlingMiddleware(BaseMiddleware):
|
||||
def __init__(self, rate_limit: float = 0.5):
|
||||
"""
|
||||
Двухуровневый rate-limiter:
|
||||
1. Общий троттлинг — 0.5 сек между любыми сообщениями (UX)
|
||||
2. /start burst-лимит — макс N вызовов за окно (anti-spam)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rate_limit: float = 0.5,
|
||||
start_max_calls: int = 3,
|
||||
start_window: float = 60.0,
|
||||
):
|
||||
self.rate_limit = rate_limit
|
||||
self.user_buckets: dict[int, float] = {}
|
||||
|
||||
# /start anti-spam: sliding window per user
|
||||
self.start_max_calls = start_max_calls
|
||||
self.start_window = start_window
|
||||
self.start_buckets: dict[int, list[float]] = {}
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||
@@ -30,10 +46,37 @@ class ThrottlingMiddleware(BaseMiddleware):
|
||||
return await handler(event, data)
|
||||
|
||||
now = time.time()
|
||||
|
||||
# --- /start burst rate-limit ---
|
||||
if isinstance(event, Message) and event.text and event.text.startswith('/start'):
|
||||
timestamps = self.start_buckets.get(user_id, [])
|
||||
# Оставляем только вызовы внутри окна
|
||||
timestamps = [ts for ts in timestamps if now - ts < self.start_window]
|
||||
|
||||
if len(timestamps) >= self.start_max_calls:
|
||||
cooldown = int(self.start_window - (now - timestamps[0])) + 1
|
||||
logger.warning(
|
||||
'Rate-limit /start для %s: %d вызовов за %ds (лимит %d)',
|
||||
user_id,
|
||||
len(timestamps),
|
||||
int(self.start_window),
|
||||
self.start_max_calls,
|
||||
)
|
||||
try:
|
||||
await event.answer(f'⏳ Слишком много запросов. Попробуйте через {cooldown} сек.')
|
||||
except Exception:
|
||||
pass
|
||||
self.start_buckets[user_id] = timestamps
|
||||
return None
|
||||
|
||||
timestamps.append(now)
|
||||
self.start_buckets[user_id] = timestamps
|
||||
|
||||
# --- Общий троттлинг (0.5 сек) ---
|
||||
last_call = self.user_buckets.get(user_id, 0)
|
||||
|
||||
if now - last_call < self.rate_limit:
|
||||
logger.warning(f'🚫 Throttling для пользователя {user_id}')
|
||||
logger.warning(f'Throttling для пользователя {user_id}')
|
||||
|
||||
# Для сообщений: молчим только если это состояние работы с тикетами; иначе показываем блок
|
||||
if isinstance(event, Message):
|
||||
@@ -61,9 +104,17 @@ class ThrottlingMiddleware(BaseMiddleware):
|
||||
|
||||
self.user_buckets[user_id] = now
|
||||
|
||||
# Периодическая очистка старых записей
|
||||
cleanup_threshold = now - 60
|
||||
self.user_buckets = {
|
||||
uid: timestamp for uid, timestamp in self.user_buckets.items() if timestamp > cleanup_threshold
|
||||
}
|
||||
# Очистка /start бакетов (раз в ~60 сек, лениво)
|
||||
if len(self.start_buckets) > 500:
|
||||
self.start_buckets = {
|
||||
uid: [ts for ts in tss if now - ts < self.start_window]
|
||||
for uid, tss in self.start_buckets.items()
|
||||
if any(now - ts < self.start_window for ts in tss)
|
||||
}
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -54,6 +54,18 @@ class PromoCodeService:
|
||||
if existing_use:
|
||||
return {'success': False, 'error': 'already_used_by_user'}
|
||||
|
||||
# Лимит на количество активаций за день (анти-стакинг)
|
||||
from app.database.crud.promocode import count_user_recent_activations
|
||||
|
||||
recent_count = await count_user_recent_activations(db, user_id, hours=24)
|
||||
if recent_count >= 5:
|
||||
logger.warning(
|
||||
'Promo stacking limit: user %s has %d activations in 24h',
|
||||
self._format_user_log(user),
|
||||
recent_count,
|
||||
)
|
||||
return {'success': False, 'error': 'daily_limit'}
|
||||
|
||||
# Проверка "только для первой покупки"
|
||||
if getattr(promocode, 'first_purchase_only', False):
|
||||
if getattr(user, 'has_had_paid_subscription', False):
|
||||
|
||||
@@ -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')],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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)'),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Только буквы, цифры, дефис, подчёркивание
|
||||
PROMO_CODE_PATTERN = re.compile(r'^[A-Za-z0-9_-]+$')
|
||||
|
||||
# Лимиты
|
||||
MAX_FAILED_ATTEMPTS = 5
|
||||
FAILED_WINDOW_SECONDS = 300 # 5 минут
|
||||
MAX_ACTIVATIONS_PER_DAY = 5
|
||||
ACTIVATION_WINDOW_SECONDS = 86400 # 24 часа
|
||||
|
||||
|
||||
class PromoRateLimiter:
|
||||
"""
|
||||
In-memory rate limiter для промокодов:
|
||||
1. Лимит на неудачные попытки (перебор)
|
||||
2. Лимит на количество активаций за день (стакинг)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# user_id → list[timestamp] неудачных попыток
|
||||
self._failed_attempts: dict[int, list[float]] = {}
|
||||
# user_id → list[timestamp] успешных активаций
|
||||
self._activations: dict[int, list[float]] = {}
|
||||
|
||||
def record_failed_attempt(self, user_id: int) -> None:
|
||||
now = time.time()
|
||||
attempts = self._failed_attempts.get(user_id, [])
|
||||
attempts = [ts for ts in attempts if now - ts < FAILED_WINDOW_SECONDS]
|
||||
attempts.append(now)
|
||||
self._failed_attempts[user_id] = attempts
|
||||
|
||||
if len(attempts) >= MAX_FAILED_ATTEMPTS:
|
||||
logger.warning(
|
||||
'Promo brute-force: user %s — %d failed attempts in %ds',
|
||||
user_id,
|
||||
len(attempts),
|
||||
FAILED_WINDOW_SECONDS,
|
||||
)
|
||||
|
||||
def is_blocked(self, user_id: int) -> bool:
|
||||
now = time.time()
|
||||
attempts = self._failed_attempts.get(user_id, [])
|
||||
attempts = [ts for ts in attempts if now - ts < FAILED_WINDOW_SECONDS]
|
||||
self._failed_attempts[user_id] = attempts
|
||||
return len(attempts) >= MAX_FAILED_ATTEMPTS
|
||||
|
||||
def get_block_cooldown(self, user_id: int) -> int:
|
||||
attempts = self._failed_attempts.get(user_id, [])
|
||||
if not attempts:
|
||||
return 0
|
||||
oldest = attempts[0]
|
||||
remaining = int(FAILED_WINDOW_SECONDS - (time.time() - oldest)) + 1
|
||||
return max(remaining, 0)
|
||||
|
||||
def record_activation(self, user_id: int) -> None:
|
||||
now = time.time()
|
||||
activations = self._activations.get(user_id, [])
|
||||
activations = [ts for ts in activations if now - ts < ACTIVATION_WINDOW_SECONDS]
|
||||
activations.append(now)
|
||||
self._activations[user_id] = activations
|
||||
|
||||
def can_activate(self, user_id: int) -> bool:
|
||||
now = time.time()
|
||||
activations = self._activations.get(user_id, [])
|
||||
activations = [ts for ts in activations if now - ts < ACTIVATION_WINDOW_SECONDS]
|
||||
self._activations[user_id] = activations
|
||||
return len(activations) < MAX_ACTIVATIONS_PER_DAY
|
||||
|
||||
def get_activations_left(self, user_id: int) -> int:
|
||||
now = time.time()
|
||||
activations = self._activations.get(user_id, [])
|
||||
activations = [ts for ts in activations if now - ts < ACTIVATION_WINDOW_SECONDS]
|
||||
return max(0, MAX_ACTIVATIONS_PER_DAY - len(activations))
|
||||
|
||||
def cleanup(self) -> None:
|
||||
now = time.time()
|
||||
if len(self._failed_attempts) > 500:
|
||||
self._failed_attempts = {
|
||||
uid: [ts for ts in tss if now - ts < FAILED_WINDOW_SECONDS]
|
||||
for uid, tss in self._failed_attempts.items()
|
||||
if any(now - ts < FAILED_WINDOW_SECONDS for ts in tss)
|
||||
}
|
||||
if len(self._activations) > 500:
|
||||
self._activations = {
|
||||
uid: [ts for ts in tss if now - ts < ACTIVATION_WINDOW_SECONDS]
|
||||
for uid, tss in self._activations.items()
|
||||
if any(now - ts < ACTIVATION_WINDOW_SECONDS for ts in tss)
|
||||
}
|
||||
|
||||
|
||||
def validate_promo_format(code: str) -> bool:
|
||||
"""Проверяет формат промокода: 3-50 символов, только буквы/цифры/дефис/подчёркивание."""
|
||||
if not code or len(code) < 3 or len(code) > 50:
|
||||
return False
|
||||
return bool(PROMO_CODE_PATTERN.match(code))
|
||||
|
||||
|
||||
# Глобальный синглтон
|
||||
promo_limiter = PromoRateLimiter()
|
||||
@@ -335,7 +335,7 @@ async def start_round_now(
|
||||
tags=['contests'],
|
||||
)
|
||||
async def list_rounds(
|
||||
status_filter: str = Query('active', regex='^(active|finished|any)$'),
|
||||
status_filter: str = Query('active', pattern='^(active|finished|any)$'),
|
||||
template_id: int | None = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
|
||||
@@ -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`
|
||||
|
||||
+15
-15
@@ -1,26 +1,26 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.11.0"
|
||||
version = "3.12.0"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
requires-python = '==3.13.*'
|
||||
dependencies = [
|
||||
'aiogram>=3.22.0',
|
||||
'sqlalchemy>=2.0.43',
|
||||
'alembic>=1.16.5',
|
||||
'asyncpg>=0.30.0',
|
||||
'aiosqlite>=0.21.0',
|
||||
'fastapi[standard]>=0.115.6',
|
||||
'redis>=5.0.1',
|
||||
'pyyaml>=6.0.2',
|
||||
'yookassa>=3.9.0',
|
||||
"aiogram>=3.25.0",
|
||||
'sqlalchemy>=2.0.46',
|
||||
'alembic>=1.18.4',
|
||||
'asyncpg>=0.31.0',
|
||||
'aiosqlite>=0.22.1',
|
||||
'fastapi[standard]>=0.129.0',
|
||||
'redis>=7.1.1',
|
||||
'pyyaml>=6.0.3',
|
||||
'yookassa>=3.10.0',
|
||||
'python-dateutil>=2.9.0.post0',
|
||||
'cryptography>=41.0.0',
|
||||
'qrcode[pil]>=7.4.2',
|
||||
'packaging>=23.2',
|
||||
'bcrypt>=4.2.0',
|
||||
'pyjwt>=2.8.0',
|
||||
'cryptography>=44.0.1',
|
||||
'qrcode[pil]>=8.0',
|
||||
'packaging>=26.0',
|
||||
'bcrypt>=5.0.0',
|
||||
'pyjwt>=2.11.0',
|
||||
'pyzipper>=0.3.6',
|
||||
]
|
||||
|
||||
|
||||
+22
-22
@@ -1,24 +1,24 @@
|
||||
# Основные зависимости
|
||||
aiogram==3.22.0
|
||||
aiohttp==3.12.15
|
||||
asyncpg==0.30.0
|
||||
SQLAlchemy==2.0.43
|
||||
alembic==1.16.5
|
||||
aiosqlite==0.21.0
|
||||
aiogram==3.25.0
|
||||
aiohttp==3.13.3
|
||||
asyncpg==0.31.0
|
||||
SQLAlchemy==2.0.46
|
||||
alembic==1.18.4
|
||||
aiosqlite==0.22.1
|
||||
|
||||
# Дополнительные зависимости
|
||||
pydantic==2.11.9
|
||||
pydantic-settings==2.10.1
|
||||
python-dotenv==1.1.1
|
||||
redis==5.0.1
|
||||
PyYAML==6.0.2
|
||||
fastapi==0.115.6
|
||||
uvicorn==0.32.1
|
||||
pydantic==2.12.5
|
||||
pydantic-settings==2.13.0
|
||||
python-dotenv==1.2.1
|
||||
redis==7.1.1
|
||||
PyYAML==6.0.3
|
||||
fastapi==0.129.0
|
||||
uvicorn==0.40.0
|
||||
websockets>=12.0
|
||||
python-multipart==0.0.9
|
||||
python-multipart==0.0.22
|
||||
|
||||
# YooKassa SDK
|
||||
yookassa==3.9.0
|
||||
yookassa==3.10.0
|
||||
|
||||
# NaloGO для чеков в налоговую
|
||||
# nalogo - используем локальную исправленную версию в app/lib/nalogo/
|
||||
@@ -28,21 +28,21 @@ httpx # зависимость для nalogo
|
||||
structlog==23.2.0
|
||||
|
||||
# Планировщик задач для техработ
|
||||
APScheduler==3.11.0
|
||||
APScheduler==3.11.2
|
||||
|
||||
# Утилиты
|
||||
python-dateutil==2.9.0.post0
|
||||
pytz==2023.4
|
||||
cryptography>=41.0.0
|
||||
qrcode[pil]==7.4.2
|
||||
cryptography>=44.0.1
|
||||
qrcode[pil]==8.2
|
||||
|
||||
# Личный кабинет (Cabinet)
|
||||
bcrypt==4.2.0
|
||||
PyJWT==2.8.0
|
||||
email-validator==2.1.0
|
||||
bcrypt==5.0.0
|
||||
PyJWT==2.11.0
|
||||
email-validator==2.3.0
|
||||
|
||||
# Для работы с версиями
|
||||
packaging==23.2
|
||||
packaging==26.0
|
||||
|
||||
aiofiles==23.2.1
|
||||
|
||||
|
||||
@@ -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]]
|
||||
@@ -94,16 +94,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
version = "1.18.1"
|
||||
version = "1.18.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mako" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/cc/aca263693b2ece99fa99a09b6d092acb89973eb2bb575faef1777e04f8b4/alembic-1.18.1.tar.gz", hash = "sha256:83ac6b81359596816fb3b893099841a0862f2117b2963258e965d70dc62fb866", size = 2044319, upload-time = "2026-01-14T18:53:14.907Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/36/cd9cb6101e81e39076b2fbe303bfa3c85ca34e55142b0324fcbf22c5c6e2/alembic-1.18.1-py3-none-any.whl", hash = "sha256:f1c3b0920b87134e851c25f1f7f236d8a332c34b75416802d06971df5d1b7810", size = 260973, upload-time = "2026-01-14T18:53:17.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -413,17 +413,18 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.128.0"
|
||||
version = "0.129.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -977,11 +978,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.10.1"
|
||||
version = "2.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1105,16 +1106,16 @@ pil = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "7.1.0"
|
||||
version = "7.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/80/2971931d27651affa88a44c0ad7b8c4a19dc29c998abb20b23868d319b59/redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43", size = 4800064, upload-time = "2026-02-09T18:39:40.292Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/55/1de1d812ba1481fa4b37fb03b4eec0fcb71b6a0d44c04ea3482eb017600f/redis-7.1.1-py3-none-any.whl", hash = "sha256:f77817f16071c2950492c67d40b771fa493eb3fccc630a424a10976dbb794b7a", size = 356057, upload-time = "2026-02-09T18:39:38.602Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "remnawave-bedolaga-telegram-bot"
|
||||
version = "3.8.0"
|
||||
version = "3.10.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiogram" },
|
||||
@@ -1145,22 +1146,22 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiogram", specifier = ">=3.22.0" },
|
||||
{ name = "aiosqlite", specifier = ">=0.21.0" },
|
||||
{ name = "alembic", specifier = ">=1.16.5" },
|
||||
{ name = "asyncpg", specifier = ">=0.30.0" },
|
||||
{ name = "bcrypt", specifier = ">=4.2.0" },
|
||||
{ name = "cryptography", specifier = ">=41.0.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.6" },
|
||||
{ name = "packaging", specifier = ">=23.2" },
|
||||
{ name = "pyjwt", specifier = ">=2.8.0" },
|
||||
{ name = "aiogram", specifier = ">=3.25.0" },
|
||||
{ name = "aiosqlite", specifier = ">=0.22.1" },
|
||||
{ name = "alembic", specifier = ">=1.18.4" },
|
||||
{ name = "asyncpg", specifier = ">=0.31.0" },
|
||||
{ name = "bcrypt", specifier = ">=5.0.0" },
|
||||
{ name = "cryptography", specifier = ">=44.0.1" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.129.0" },
|
||||
{ name = "packaging", specifier = ">=26.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.11.0" },
|
||||
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.2" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||
{ name = "pyzipper", specifier = ">=0.3.6" },
|
||||
{ name = "qrcode", extras = ["pil"], specifier = ">=7.4.2" },
|
||||
{ name = "redis", specifier = ">=5.0.1" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.43" },
|
||||
{ name = "yookassa", specifier = ">=3.9.0" },
|
||||
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" },
|
||||
{ name = "redis", specifier = ">=7.1.1" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.46" },
|
||||
{ name = "yookassa", specifier = ">=3.10.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -1545,7 +1546,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "yookassa"
|
||||
version = "3.9.0"
|
||||
version = "3.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "deprecated" },
|
||||
@@ -1554,4 +1555,4 @@ dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/05/cbacdfd7d5478956e184cb9e1e99321e1ae14f325cb8eadaf6e7a64206ab/yookassa-3.9.0.tar.gz", hash = "sha256:e8a78fcd96543a5700a4ae9e1411b54f729998f4c051c06c182b9946926e67f5", size = 139247, upload-time = "2025-12-17T09:11:55.657Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/d7/d5ee3948cbf0379a6af4e0a6da2c39e2e455a84b10651c8ea75c75fea50f/yookassa-3.10.0.tar.gz", hash = "sha256:5d138fa568129989688f632122719741a8ad983bb7c1f1f1b6294e7a83d67915", size = 140883, upload-time = "2026-01-28T12:23:50.413Z" }
|
||||
|
||||
Reference in New Issue
Block a user