Compare commits

..

37 Commits

Author SHA1 Message Date
dependabot[bot] 7bc29c3421 deps(deps): bump the python-dependencies group across 1 directory with 10 updates
---
updated-dependencies:
- dependency-name: aiogram
  dependency-version: 3.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: qrcode[pil]
  dependency-version: '8.2'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: aiohttp
  dependency-version: 3.13.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic
  dependency-version: 2.12.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic-settings
  dependency-version: 2.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: python-dotenv
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: uvicorn
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: python-multipart
  dependency-version: 0.0.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: apscheduler
  dependency-version: 3.11.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: email-validator
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-16 04:34:17 +00:00
Egor 448be6e512 Merge pull request #2606 from BEDOLAGA-DEV/dev
Dev
2026-02-16 07:31:48 +03:00
Fringg 871ceb866c fix: replace deprecated Query(regex=) with pattern= 2026-02-16 07:11:58 +03:00
Fringg 8e61fe4774 fix: handle TelegramBadRequest in ticket edit_message_text calls
Wrap all edit_message_text calls in ticket handlers with try/except
TelegramBadRequest fallback to message.answer(). Fixes crash when
the prompt message was deleted or has no text (e.g. photo message).
2026-02-16 07:09:06 +03:00
Fringg d4dfa235e5 chore: update all dependencies to latest stable versions
Security: cryptography 41.0→44.0+ (4 CVEs patched)
Major: redis 5.0→7.1, fastapi 0.115→0.129, bcrypt 4.2→5.0
Minor: sqlalchemy 2.0.46, alembic 1.18.4, asyncpg 0.31,
  aiosqlite 0.22, qrcode 8.0, packaging 26.0, pyjwt 2.11,
  yookassa 3.10, pyyaml 6.0.3
2026-02-16 06:59:48 +03:00
Fringg 97ec39aa80 fix: add promo code anti-abuse protections
- Rate-limit on brute-force: 5 failed attempts per 5 min blocks user
- Daily stacking limit: max 5 promo activations per 24h (in-memory + DB)
- Format validation: only alphanumeric/hyphen/underscore, 3-50 chars
2026-02-16 06:52:45 +03:00
Fringg 61a97220d3 fix: add /start burst rate-limit to prevent spam abuse
Sliding window limiter: max 3 /start calls per 60 seconds per user.
Runs before the general 0.5s throttle. Shows cooldown timer on block.
Lazy cleanup of start_buckets when size exceeds 500 entries.
2026-02-16 06:41:14 +03:00
Egor 2d04f2aa28 Merge pull request #2605 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.12.0
2026-02-16 02:20:45 +03:00
github-actions[bot] d6e79161e7 chore(main): release 3.12.0 2026-02-15 23:20:25 +00:00
Egor 45fd543206 Merge pull request #2604 from BEDOLAGA-DEV/dev
Dev
2026-02-16 02:20:02 +03:00
Fringg ba0a5e9abd fix: handle tariff_extend callback without period (back button crash)
The 'Back' button on tariff extend confirmation sends
tariff_extend:{id} without a period segment, which crashed
select_tariff_extend_period with IndexError on parts[2].
Now redirects to show_tariff_extend when period is missing.
2026-02-16 01:38:04 +03:00
Fringg d712ab8301 fix: remove redundant trial inactivity monitoring checks
Remnawave already sends user.not_connected webhooks, making the
monitoring service's 1h/24h trial inactivity checks redundant.
The monitoring checks caused false positives because they relied on
traffic_used_gb which may not be synced in real-time.

Removed:
- _check_trial_inactivity_notifications from monitoring cycle
- _send_trial_inactive_notification method
- trial_inactive_1h / trial_inactive_24h notification settings
- Admin UI toggles and preview buttons for these notifications
2026-02-16 00:58:24 +03:00
Fringg 1e2a7e3096 fix: webhook notification 'My Subscription' button uses unregistered callback_data
Changed callback_data from 'subscription' (no handler) to 'menu_subscription'
(registered handler) in _get_subscription_keyboard and _get_traffic_keyboard.
In cabinet mode the button opens a WebApp URL so the bug was invisible,
but in default MAIN_MENU_MODE the callback went unhandled.
2026-02-16 00:30:17 +03:00
Fringg 64a684cd2f fix: filter out traffic packages with zero price from purchase options 2026-02-15 23:32:15 +03:00
Fringg e4c207ecff chore: format files with ruff 2026-02-15 23:18:44 +03:00
Fringg 80914c1af7 fix: daily tariff subscriptions stuck in expired/disabled with no resume path
- Keyboard now shows "Возобновить" for disabled/expired daily tariffs
  instead of useless "Приостановить"
- resume_daily_subscription handles EXPIRED→ACTIVE (not only DISABLED)
- Pause handler detects inactive status and calls resume directly
- subscription_extend redirects daily tariffs to subscription info
  (daily tariffs have no period_prices, so extend page was empty)
2026-02-15 23:17:45 +03:00
Fringg e1822800ab fix: handle photo message in ticket creation flow
Ticket creation crashed with "there is no text in the message to edit"
when initiated from the tickets list (rendered as photo with logo).
2026-02-15 22:58:31 +03:00
Fringg 68773b7e77 feat: add per-button enable/disable toggle and custom labels per locale
- Add enabled flag to hide/show each button section in main menu
- Add per-locale custom labels (ru, en, ua, zh, fa) for button text
- Deep-copy nested labels dict in cache to prevent reference leaks
- Validate label entries from DB (type + locale key checks)
- Use selective merge in PATCH handler instead of blind .update()
2026-02-12 23:42:55 +03:00
Fringg 10538e7351 feat: add 'default' (no color) option for button styles
Allow admins to set buttons to Telegram's default style with no color
override. Refactors style resolution from or-chain to explicit if/elif/else
so that 'default' does not fall through to global config or hardcoded defaults.
2026-02-12 23:25:42 +03:00
Fringg a9687912df feat: add per-section button style and emoji customization via admin API
Add cabinet admin API for configuring button colors (primary/success/danger)
and custom emoji IDs per menu section (home, subscription, balance, referral,
support, info, admin). Styles are stored as JSON in system_settings and cached
in-process for fast resolution.

Style resolution chain: explicit param > per-section DB > global config > defaults.
2026-02-12 23:15:58 +03:00
Fringg 46c1a69456 fix: pre-validate CABINET_BUTTON_STYLE to prevent invalid values from suppressing per-section defaults 2026-02-12 22:43:30 +03:00
Fringg bf2b2f1c56 feat: add button style and emoji support for cabinet mode (Bot API 9.4)
- Upgrade aiogram to 3.25.0 for style/icon_custom_emoji_id support
- Add CABINET_BUTTON_STYLE config for global color override
- Per-section default styles: subscription (green), balance (blue),
  referral (green), admin (red), home (blue)
- Style priority: explicit > CABINET_BUTTON_STYLE > per-section default
- Add icon_custom_emoji_id pass-through for Premium bot owners
- Admin panel setting for button style with color picker
2026-02-12 22:34:38 +03:00
Fringg 9ac6da490d feat: add web admin button for admins in cabinet mode 2026-02-12 22:22:28 +03:00
Fringg ad87c5fb5e feat: rename MAIN_MENU_MODE=text to cabinet with deep-linking to frontend sections
- Rename mode from 'text' to 'cabinet' (text/text_only/minimal kept as aliases)
- Add build_cabinet_url() for joining MINIAPP_CUSTOM_URL with section paths
- Cabinet main menu now has section-specific buttons: subscription, balance,
  referral, support, info — each opens the corresponding cabinet page
- Add CALLBACK_TO_CABINET_PATH mapping for automatic deep-linking from
  callback_data to cabinet routes (/subscription, /balance, /referral, etc.)
- Unmapped callback_data gracefully falls back to regular Telegram callbacks
- Add startup validation warning when cabinet mode is active without MINIAPP_CUSTOM_URL
- Update admin broadcast buttons with section-specific routing
- Backward compatible: is_text_main_menu_mode() kept as alias for is_cabinet_mode()
2026-02-12 22:21:08 +03:00
Egor 7ac73e5745 Merge pull request #2600 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.11.0
2026-02-12 21:12:59 +03:00
github-actions[bot] 61be89743d chore(main): release 3.11.0 2026-02-12 18:12:13 +00:00
Egor d174d9a927 Merge pull request #2599 from BEDOLAGA-DEV/dev
Dev
2026-02-12 21:11:44 +03:00
Fringg 4048aebb9f chore: format models.py 2026-02-12 21:08:05 +03:00
Fringg bfd66c42c1 fix: add passive_deletes to Subscription relationships to prevent NOT NULL violation on cascade delete 2026-02-12 20:59:28 +03:00
Fringg 351c95bac1 chore: change SALES_MODE default to tariffs 2026-02-12 20:55:52 +03:00
Fringg 1d43ae5e25 fix: add startup warning for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE in guide mode 2026-02-12 20:43:12 +03:00
Fringg 476b89fe8e feat: add startup warnings for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE and MINIAPP_CUSTOM_URL 2026-02-12 20:38:33 +03:00
Fringg 14e13177b5 chore: change CONNECT_BUTTON_MODE default to miniapp_subscription 2026-02-12 20:35:34 +03:00
Fringg 760c833b74 fix: ticket creation crash and webhook PendingRollbackError
- tickets.py: remove ENABLE_LOGO_MODE branches that used edit_message_caption
  on text messages (prompt is always text, not photo with caption)
- webhook_service: add db.rollback() before retrying DB ops in _handle_user_deleted
  when subscription was cascade-deleted, catch PendingRollbackError alongside StaleDataError
2026-02-12 20:32:52 +03:00
Fringg 1a476c49c1 feat: add cabinet admin API for pinned messages management
- Full CRUD + broadcast/unpin/activate/deactivate endpoints
- Admin auth required on all endpoints (get_current_admin_user)
- Broadcast cooldown (60s) on all mass operation endpoints
- Cached Bot singleton to prevent aiohttp session leaks
- Guard against deleting active pinned messages (409 Conflict)
- Route ordering: /active/* before /{message_id}/* to prevent path conflicts
- Pydantic schemas with proper validation (file_id max_length=255)
2026-02-12 19:13:51 +03:00
Fringg 454b83138e fix: flood control handling in pinned messages and XSS hardening in HTML sanitizer
- Add retry loop with backoff to _unpin_message_for_user (max 3 attempts)
- Add TelegramRetryAfter handling in _send_and_pin_message (unpin + send phases)
- Fix missing failed_count increment when all broadcast retries exhaust (for/else)
- Remove dead code in unpin_active_pinned_message (unreachable TelegramRetryAfter catch)
- Harden sanitize_html: allowlist URI schemes (http/https/tg/mailto/tel), whitelist
  tag attributes, strip all attrs from tags without explicit whitelist, full HTML
  entity decoding via html.unescape
2026-02-12 19:13:40 +03:00
Fringg 2de438426a fix: suppress expired callback query error in AuthMiddleware
Catch TelegramBadRequest with "query is too old" before generic Exception handler
to prevent it from being logged as error and triggering error reports.
2026-02-12 18:43:16 +03:00
39 changed files with 1795 additions and 628 deletions
+13 -2
View File
@@ -697,8 +697,19 @@ CLOUDPAYMENTS_TEST_MODE=false
ENABLE_LOGO_MODE=true
LOGO_FILE=vpn_logo.png
# Режим главного меню (default - классический режим работы бота, text - режим работы с активным ЛК MiniApp, отключает покупку/управление подпиской в меню, заменяет все кнопками открытия в MiniApp ЛК)
# Режим главного меню:
# default - классический режим работы бота (все кнопки внутри Telegram)
# cabinet - режим Cabinet с активным ЛК MiniApp, кнопки ведут на конкретные
# разделы кабинета (/balance, /subscription, /referral и т.д.)
# Требует MINIAPP_CUSTOM_URL
# Алиасы для обратной совместимости: text, text_only, minimal
MAIN_MENU_MODE=default
# Стиль кнопок в режиме Cabinet (Bot API 9.4):
# primary - синий
# success - зелёный
# danger - красный
# (пустое) - цвета по умолчанию для каждой секции
CABINET_BUTTON_STYLE=
# Включить управление меню через API (позволяет динамически менять структуру кнопок)
MENU_LAYOUT_ENABLED=false
@@ -711,7 +722,7 @@ HIDE_SUBSCRIPTION_LINK=false
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
# link - Открывает ссылку напрямую в браузере (режим 4)
# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5)
CONNECT_BUTTON_MODE=guide
CONNECT_BUTTON_MODE=miniapp_subscription
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.10.3"
".": "3.12.0"
}
+40
View File
@@ -1,5 +1,45 @@
# 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)
### New Features
* add cabinet admin API for pinned messages management ([1a476c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1a476c49c19d1ec2ab2cda1c2ffb5fd242288bb6))
* add startup warnings for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE and MINIAPP_CUSTOM_URL ([476b89f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/476b89fe8e613c505acfc58a9554d31ccf92718a))
### Bug Fixes
* add passive_deletes to Subscription relationships to prevent NOT NULL violation on cascade delete ([bfd66c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfd66c42c1fba3763f41d641cea1bd101ec8c10c))
* add startup warning for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE in guide mode ([1d43ae5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1d43ae5e25ffcf0e4fe6fec13319d393717e1e50))
* flood control handling in pinned messages and XSS hardening in HTML sanitizer ([454b831](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/454b83138e4db8dc4f07171ee6fe262d2cd6d311))
* suppress expired callback query error in AuthMiddleware ([2de4384](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2de438426a647e2bcae9b4d99eef4093ff8b5429))
* ticket creation crash and webhook PendingRollbackError ([760c833](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/760c833b7402541d3c7cf2ed7fc0418119e75042))
## [3.10.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.2...v3.10.3) (2026-02-12)
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v3.10.3" # x-release-please-version
ARG VERSION="v3.12.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+37
View File
@@ -210,6 +210,43 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
logger.info('Мониторинг техработ отключен настройками')
logger.info('🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries')
# Validate CONNECT_BUTTON_MODE dependencies
if not settings.get_happ_cryptolink_redirect_template():
if settings.CONNECT_BUTTON_MODE == 'happ_cryptolink':
logger.warning(
'⚠️ CONNECT_BUTTON_MODE=happ_cryptolink, но HAPP_CRYPTOLINK_REDIRECT_TEMPLATE не задан! '
'Кнопка "Подключиться" не будет отображаться.'
)
elif settings.CONNECT_BUTTON_MODE == 'guide':
logger.warning(
'⚠️ CONNECT_BUTTON_MODE=guide, но HAPP_CRYPTOLINK_REDIRECT_TEMPLATE не задан! '
'Кнопка "Подключиться" в гайдах не будет работать — Telegram не поддерживает '
'кастомные схемы (happ://, v2ray://) в inline-кнопках без HTTPS-редиректа.'
)
if settings.CONNECT_BUTTON_MODE == 'miniapp_custom' and not settings.MINIAPP_CUSTOM_URL:
logger.warning(
'⚠️ 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('Бот успешно настроен')
return bot, dp
+4
View File
@@ -5,10 +5,12 @@ 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
from .admin_payments import router as admin_payments_router
from .admin_pinned_messages import router as admin_pinned_messages_router
from .admin_promo_offers import router as admin_promo_offers_router
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
from .admin_remnawave import router as admin_remnawave_router
@@ -89,6 +91,8 @@ router.include_router(admin_remnawave_router)
router.include_router(admin_email_templates_router)
router.include_router(admin_updates_router)
router.include_router(admin_traffic_router)
router.include_router(admin_pinned_messages_router)
router.include_router(admin_button_styles_router)
# WebSocket route
router.include_router(websocket_router)
+253
View File
@@ -0,0 +1,253 @@
"""Admin routes for per-section cabinet button style configuration."""
import json
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.utils.button_styles_cache import (
ALLOWED_STYLE_VALUES,
BOT_LOCALES,
BUTTON_STYLES_KEY,
DEFAULT_BUTTON_STYLES,
SECTIONS,
load_button_styles_cache,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/button-styles', tags=['Admin Button Styles'])
# ---- Schemas ---------------------------------------------------------------
class ButtonSectionConfig(BaseModel):
"""Configuration for a single button section."""
style: str = 'primary'
icon_custom_emoji_id: str = ''
enabled: bool = True
labels: dict[str, str] = {}
class ButtonStylesResponse(BaseModel):
"""Full button styles configuration (all 7 sections)."""
home: ButtonSectionConfig = ButtonSectionConfig()
subscription: ButtonSectionConfig = ButtonSectionConfig()
balance: ButtonSectionConfig = ButtonSectionConfig()
referral: ButtonSectionConfig = ButtonSectionConfig()
support: ButtonSectionConfig = ButtonSectionConfig()
info: ButtonSectionConfig = ButtonSectionConfig()
admin: ButtonSectionConfig = ButtonSectionConfig()
MAX_LABEL_LENGTH = 100
class ButtonSectionUpdate(BaseModel):
"""Partial update for a single section (None = keep current)."""
style: str | None = None
icon_custom_emoji_id: str | None = None
enabled: bool | None = None
labels: dict[str, str] | None = None
class ButtonStylesUpdate(BaseModel):
"""Partial update — only include sections you want to change."""
home: ButtonSectionUpdate | None = None
subscription: ButtonSectionUpdate | None = None
balance: ButtonSectionUpdate | None = None
referral: ButtonSectionUpdate | None = None
support: ButtonSectionUpdate | None = None
info: ButtonSectionUpdate | None = None
admin: ButtonSectionUpdate | None = None
# ---- Helpers ---------------------------------------------------------------
async def _get_setting_value(db: AsyncSession, key: str) -> str | None:
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
return setting.value if setting else None
async def _set_setting_value(db: AsyncSession, key: str, value: str) -> None:
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = value
else:
setting = SystemSetting(key=key, value=value)
db.add(setting)
await db.commit()
def _build_response(styles: dict[str, dict]) -> ButtonStylesResponse:
return ButtonStylesResponse(
**{section: ButtonSectionConfig(**cfg) for section, cfg in styles.items() if section in SECTIONS},
)
# ---- Routes ----------------------------------------------------------------
@router.get('', response_model=ButtonStylesResponse)
async def get_button_styles(
_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Return current per-section button styles. Admin only."""
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
merged = {section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()}
if raw:
try:
db_data = json.loads(raw)
for section, overrides in db_data.items():
if section in merged and isinstance(overrides, dict):
if overrides.get('style') in ALLOWED_STYLE_VALUES:
merged[section]['style'] = overrides['style']
if isinstance(overrides.get('icon_custom_emoji_id'), str):
merged[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
if isinstance(overrides.get('enabled'), bool):
merged[section]['enabled'] = overrides['enabled']
if isinstance(overrides.get('labels'), dict):
merged[section]['labels'] = {
k: v
for k, v in overrides['labels'].items()
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
}
except (json.JSONDecodeError, TypeError):
pass
return _build_response(merged)
@router.patch('', response_model=ButtonStylesResponse)
async def update_button_styles(
payload: ButtonStylesUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Partially update per-section button styles. Admin only."""
# Load current state
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
current: dict[str, dict] = {
section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()
}
if raw:
try:
db_data = json.loads(raw)
for section, overrides in db_data.items():
if section in current and isinstance(overrides, dict):
if overrides.get('style') in ALLOWED_STYLE_VALUES:
current[section]['style'] = overrides['style']
if isinstance(overrides.get('icon_custom_emoji_id'), str):
current[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
if isinstance(overrides.get('enabled'), bool):
current[section]['enabled'] = overrides['enabled']
if isinstance(overrides.get('labels'), dict):
current[section]['labels'] = {
k: v
for k, v in overrides['labels'].items()
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
}
except (json.JSONDecodeError, TypeError):
pass
# Apply updates
update_data = payload.model_dump(exclude_none=True)
changed_sections: list[str] = []
for section, updates in update_data.items():
if section not in current or not isinstance(updates, dict):
continue
if 'style' in updates:
style_val = updates['style']
if style_val not in ALLOWED_STYLE_VALUES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid style "{style_val}" for section "{section}". '
f'Allowed: {", ".join(sorted(ALLOWED_STYLE_VALUES))}',
)
current[section]['style'] = style_val
if 'icon_custom_emoji_id' in updates:
emoji_val = (updates['icon_custom_emoji_id'] or '').strip()
current[section]['icon_custom_emoji_id'] = emoji_val
if 'enabled' in updates:
current[section]['enabled'] = updates['enabled']
if 'labels' in updates:
raw_labels = updates['labels'] or {}
sanitized: dict[str, str] = {}
for locale_key, label_val in raw_labels.items():
if locale_key not in BOT_LOCALES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid locale "{locale_key}" for section "{section}". '
f'Allowed: {", ".join(BOT_LOCALES)}',
)
if not isinstance(label_val, str):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Label value for locale "{locale_key}" must be a string.',
)
stripped = label_val.strip()
if len(stripped) > MAX_LABEL_LENGTH:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Label for locale "{locale_key}" exceeds {MAX_LABEL_LENGTH} characters.',
)
# Empty string = remove custom label (use default)
if stripped:
sanitized[locale_key] = stripped
current[section]['labels'] = sanitized
changed_sections.append(section)
# Persist
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(current))
# Refresh in-process cache
await load_button_styles_cache()
logger.info('Admin %s updated button styles for sections: %s', admin.telegram_id, changed_sections)
return _build_response(current)
@router.post('/reset', response_model=ButtonStylesResponse)
async def reset_button_styles(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset all button styles to defaults. Admin only."""
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES))
await load_button_styles_cache()
logger.info('Admin %s reset button styles to defaults', admin.telegram_id)
return _build_response(DEFAULT_BUTTON_STYLES)
+397
View File
@@ -0,0 +1,397 @@
"""Admin routes for pinned messages in cabinet."""
import logging
import time
from datetime import datetime
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PinnedMessage, User
from app.services.pinned_message_service import (
broadcast_pinned_message,
deactivate_active_pinned_message,
get_active_pinned_message,
set_active_pinned_message,
unpin_active_pinned_message,
)
from app.utils.validators import sanitize_html, validate_html_tags
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.pinned_messages import (
PinnedMessageBroadcastResponse,
PinnedMessageCreateRequest,
PinnedMessageListResponse,
PinnedMessageResponse,
PinnedMessageSettingsRequest,
PinnedMessageUnpinResponse,
PinnedMessageUpdateRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/pinned-messages', tags=['Cabinet Admin Pinned Messages'])
# Broadcast cooldown: min 60 seconds between mass operations
_BROADCAST_COOLDOWN_SECONDS = 60
_last_broadcast_time: float = 0.0
def _check_broadcast_cooldown() -> None:
global _last_broadcast_time
now = time.monotonic()
elapsed = now - _last_broadcast_time
if _last_broadcast_time > 0 and elapsed < _BROADCAST_COOLDOWN_SECONDS:
remaining = int(_BROADCAST_COOLDOWN_SECONDS - elapsed)
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS,
f'Broadcast cooldown active. Try again in {remaining} seconds.',
)
_last_broadcast_time = now
def _serialize_pinned_message(msg: PinnedMessage) -> PinnedMessageResponse:
return PinnedMessageResponse(
id=msg.id,
content=msg.content,
media_type=msg.media_type,
media_file_id=msg.media_file_id,
send_before_menu=msg.send_before_menu,
send_on_every_start=msg.send_on_every_start,
is_active=msg.is_active,
created_by=msg.created_by,
created_at=msg.created_at,
updated_at=msg.updated_at,
)
_cached_bot: Bot | None = None
def _get_bot() -> Bot:
global _cached_bot
if _cached_bot is None:
_cached_bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
return _cached_bot
# ============ List / Get Endpoints ============
@router.get('', response_model=PinnedMessageListResponse)
async def list_pinned_messages(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
active_only: bool = Query(False),
) -> PinnedMessageListResponse:
"""Get list of pinned messages with pagination."""
query = select(PinnedMessage).order_by(PinnedMessage.created_at.desc())
count_query = select(func.count(PinnedMessage.id))
if active_only:
query = query.where(PinnedMessage.is_active.is_(True))
count_query = count_query.where(PinnedMessage.is_active.is_(True))
total = await db.scalar(count_query) or 0
result = await db.execute(query.offset(offset).limit(limit))
items = result.scalars().all()
return PinnedMessageListResponse(
items=[_serialize_pinned_message(msg) for msg in items],
total=int(total),
limit=limit,
offset=offset,
)
@router.get('/active', response_model=PinnedMessageResponse | None)
async def get_active_message(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse | None:
"""Get current active pinned message."""
msg = await get_active_pinned_message(db)
if not msg:
return None
return _serialize_pinned_message(msg)
@router.get('/{message_id}', response_model=PinnedMessageResponse)
async def get_pinned_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Get pinned message by ID."""
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
return _serialize_pinned_message(msg)
# ============ Create / Update Endpoints ============
@router.post('', response_model=PinnedMessageBroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_pinned_message(
payload: PinnedMessageCreateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""
Create a new pinned message.
Automatically deactivates previous active message.
If broadcast=true, sends to all active users immediately.
"""
# Проверяем cooldown ДО мутации в БД
if payload.broadcast:
_check_broadcast_cooldown()
content = payload.content.strip()
if not content and not payload.media:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Either content or media must be provided')
media_type = payload.media.type if payload.media else None
media_file_id = payload.media.file_id if payload.media else None
try:
msg = await set_active_pinned_message(
db=db,
content=content,
created_by=admin.id,
media_type=media_type,
media_file_id=media_file_id,
send_before_menu=payload.send_before_menu,
send_on_every_start=payload.send_on_every_start,
)
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e))
sent_count = 0
failed_count = 0
if payload.broadcast:
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
logger.info(f'Admin {admin.id} created pinned message #{msg.id} (broadcast={payload.broadcast})')
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
sent_count=sent_count,
failed_count=failed_count,
)
@router.patch('/{message_id}', response_model=PinnedMessageResponse)
async def update_pinned_message(
message_id: int,
payload: PinnedMessageUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Update a pinned message content, media, or settings."""
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
if payload.content is not None:
sanitized = sanitize_html(payload.content)
is_valid, error = validate_html_tags(sanitized)
if not is_valid:
raise HTTPException(status.HTTP_400_BAD_REQUEST, error)
msg.content = sanitized
if payload.media is not None:
msg.media_type = payload.media.type
msg.media_file_id = payload.media.file_id
if payload.send_before_menu is not None:
msg.send_before_menu = payload.send_before_menu
if payload.send_on_every_start is not None:
msg.send_on_every_start = payload.send_on_every_start
msg.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(msg)
logger.info(f'Admin {admin.id} updated pinned message #{message_id}')
return _serialize_pinned_message(msg)
@router.patch('/{message_id}/settings', response_model=PinnedMessageResponse)
async def update_pinned_message_settings(
message_id: int,
payload: PinnedMessageSettingsRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Update only pinned message display settings."""
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
if payload.send_before_menu is not None:
msg.send_before_menu = payload.send_before_menu
if payload.send_on_every_start is not None:
msg.send_on_every_start = payload.send_on_every_start
msg.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(msg)
return _serialize_pinned_message(msg)
# ============ Active Message Actions (before /{message_id} POST routes) ============
@router.post('/active/deactivate', response_model=PinnedMessageResponse | None)
async def deactivate_active_message(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse | None:
"""Deactivate the current active pinned message without unpinning from users."""
msg = await deactivate_active_pinned_message(db)
if not msg:
return None
logger.info(f'Admin {admin.id} deactivated pinned message #{msg.id}')
return _serialize_pinned_message(msg)
@router.post('/active/unpin', response_model=PinnedMessageUnpinResponse)
async def unpin_active_message(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageUnpinResponse:
"""Unpin messages from all users and deactivate the active pinned message."""
_check_broadcast_cooldown()
unpinned_count, failed_count, was_active = await unpin_active_pinned_message(_get_bot(), db)
if was_active:
logger.info(f'Admin {admin.id} unpinned active message: unpinned={unpinned_count}, failed={failed_count}')
return PinnedMessageUnpinResponse(
unpinned_count=unpinned_count,
failed_count=failed_count,
was_active=was_active,
)
# ============ Per-Message Actions ============
@router.post('/{message_id}/activate', response_model=PinnedMessageBroadcastResponse)
async def activate_pinned_message(
message_id: int,
broadcast: bool = Query(False),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""
Activate a pinned message.
Deactivates the current active message and activates the specified one.
If broadcast=true, sends to all active users immediately.
"""
# Проверяем cooldown ДО мутации в БД
if broadcast:
_check_broadcast_cooldown()
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
await db.execute(
update(PinnedMessage)
.where(PinnedMessage.is_active.is_(True))
.values(is_active=False, updated_at=datetime.utcnow())
)
msg.is_active = True
msg.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(msg)
sent_count = 0
failed_count = 0
if broadcast:
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
logger.info(f'Admin {admin.id} activated pinned message #{message_id} (broadcast={broadcast})')
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
sent_count=sent_count,
failed_count=failed_count,
)
@router.post('/{message_id}/broadcast', response_model=PinnedMessageBroadcastResponse)
async def broadcast_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""Broadcast a pinned message to all active users."""
_check_broadcast_cooldown()
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
logger.info(f'Admin {admin.id} broadcast pinned message #{message_id}: sent={sent_count}, failed={failed_count}')
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
sent_count=sent_count,
failed_count=failed_count,
)
@router.delete('/{message_id}', status_code=status.HTTP_204_NO_CONTENT, response_model=None)
async def delete_pinned_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete a pinned message. Active messages must be deactivated first."""
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
if msg.is_active:
raise HTTPException(
status.HTTP_409_CONFLICT,
'Cannot delete active pinned message. Deactivate it first.',
)
await db.delete(msg)
await db.commit()
logger.info(f'Admin {admin.id} deleted pinned message #{message_id}')
+14
View File
@@ -598,6 +598,8 @@ async def get_traffic_packages(
result = []
for gb, price in packages.items():
if price <= 0:
continue
result.append(
TrafficPackageResponse(
gb=gb,
@@ -625,6 +627,8 @@ async def get_traffic_packages(
for pkg in packages:
if not pkg.get('enabled', True):
continue
if pkg['price'] <= 0:
continue
result.append(
TrafficPackageResponse(
@@ -705,6 +709,11 @@ async def purchase_traffic(
detail=f'Traffic package {request.gb}GB is not available',
)
base_price_kopeks = packages[request.gb]
if base_price_kopeks <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Traffic package {request.gb}GB has no price configured',
)
else:
# Classic режим
@@ -732,6 +741,11 @@ async def purchase_traffic(
detail='Invalid traffic package',
)
base_price_kopeks = matching_pkg['price']
if base_price_kopeks <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Traffic package has no price configured',
)
# На тарифах пакеты трафика покупаются на 1 месяц (30 дней),
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки.
+64
View File
@@ -0,0 +1,64 @@
"""Pydantic schemas for cabinet pinned messages."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class PinnedMessageMedia(BaseModel):
type: str = Field(pattern=r'^(photo|video)$')
file_id: str = Field(..., min_length=1, max_length=255)
class PinnedMessageCreateRequest(BaseModel):
content: str = Field(..., min_length=1, max_length=4000)
media: PinnedMessageMedia | None = None
send_before_menu: bool = True
send_on_every_start: bool = True
broadcast: bool = False
class PinnedMessageUpdateRequest(BaseModel):
content: str | None = Field(None, max_length=4000)
send_before_menu: bool | None = None
send_on_every_start: bool | None = None
media: PinnedMessageMedia | None = None
class PinnedMessageSettingsRequest(BaseModel):
send_before_menu: bool | None = None
send_on_every_start: bool | None = None
class PinnedMessageResponse(BaseModel):
id: int
content: str | None
media_type: str | None = None
media_file_id: str | None = None
send_before_menu: bool
send_on_every_start: bool
is_active: bool
created_by: int | None = None
created_at: datetime
updated_at: datetime | None = None
class PinnedMessageBroadcastResponse(BaseModel):
message: PinnedMessageResponse
sent_count: int
failed_count: int
class PinnedMessageUnpinResponse(BaseModel):
unpinned_count: int
failed_count: int
was_active: bool
class PinnedMessageListResponse(BaseModel):
items: list[PinnedMessageResponse]
total: int
limit: int
offset: int
+18 -11
View File
@@ -195,7 +195,7 @@ class Settings(BaseSettings):
# Режим продаж подписок:
# - classic: классический режим (выбор серверов, трафика, устройств, периода отдельно)
# - tariffs: режим тарифов (готовые пакеты с фиксированными параметрами)
SALES_MODE: str = 'classic'
SALES_MODE: str = 'tariffs'
# ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала)
# Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads)
@@ -516,8 +516,10 @@ class Settings(BaseSettings):
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
MAIN_MENU_MODE: str = 'default'
CONNECT_BUTTON_MODE: str = 'guide'
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'
MINIAPP_PURCHASE_URL: str = ''
@@ -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]:
@@ -1548,7 +1555,7 @@ class Settings(BaseSettings):
def get_sales_mode(self) -> str:
"""Возвращает текущий режим продаж."""
return self.SALES_MODE if self.SALES_MODE in ('classic', 'tariffs') else 'classic'
return self.SALES_MODE if self.SALES_MODE in ('classic', 'tariffs') else 'tariffs'
def get_trial_tariff_id(self) -> int:
"""Возвращает ID тарифа для триала (0 = использовать стандартные настройки)."""
+11
View File
@@ -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())
+4 -3
View File
@@ -1982,13 +1982,14 @@ async def resume_daily_subscription(
subscription.is_daily_paused = False
# Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств)
if subscription.status == SubscriptionStatus.DISABLED.value:
# Восстанавливаем статус ACTIVE если подписка была DISABLED/EXPIRED
if subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value):
previous_status = subscription.status
subscription.status = SubscriptionStatus.ACTIVE.value
# Обновляем время последнего списания для корректного расчёта следующего
subscription.last_daily_charge_at = datetime.utcnow()
subscription.end_date = datetime.utcnow() + timedelta(days=1)
logger.info(f'✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE')
logger.info(f'✅ Суточная подписка {subscription.id} восстановлена из {previous_status} в ACTIVE')
await db.commit()
await db.refresh(subscription)
+9 -5
View File
@@ -19,7 +19,7 @@ from sqlalchemy import (
UniqueConstraint,
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import Mapped, backref, mapped_column, relationship
from sqlalchemy.sql import func
@@ -1153,8 +1153,12 @@ class Subscription(Base):
user = relationship('User', back_populates='subscription')
tariff = relationship('Tariff', back_populates='subscriptions')
discount_offers = relationship('DiscountOffer', back_populates='subscription')
temporary_accesses = relationship('SubscriptionTemporaryAccess', back_populates='subscription')
traffic_purchases = relationship('TrafficPurchase', back_populates='subscription', cascade='all, delete-orphan')
temporary_accesses = relationship(
'SubscriptionTemporaryAccess', back_populates='subscription', passive_deletes=True
)
traffic_purchases = relationship(
'TrafficPurchase', back_populates='subscription', passive_deletes=True, cascade='all, delete-orphan'
)
@property
def is_active(self) -> bool:
@@ -1779,7 +1783,7 @@ class SentNotification(Base):
created_at = Column(DateTime, default=func.now())
user = relationship('User', backref='sent_notifications')
subscription = relationship('Subscription', backref='sent_notifications')
subscription = relationship('Subscription', backref=backref('sent_notifications', passive_deletes=True))
class SubscriptionEvent(Base):
@@ -2069,7 +2073,7 @@ class SubscriptionServer(Base):
paid_price_kopeks = Column(Integer, default=0)
subscription = relationship('Subscription', backref='subscription_servers')
subscription = relationship('Subscription', backref=backref('subscription_servers', passive_deletes=True))
server_squad = relationship('ServerSquad', backref='subscription_servers')
+6 -3
View File
@@ -45,7 +45,7 @@ from app.services.pinned_message_service import (
)
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.utils.miniapp_buttons import BUTTON_KEY_TO_CABINET_PATH, build_miniapp_or_callback_button
logger = logging.getLogger(__name__)
@@ -76,12 +76,14 @@ async def safe_edit_or_send_text(callback: types.CallbackQuery, text: str, reply
BUTTON_ROWS = BROADCAST_BUTTON_ROWS
DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS
TEXT_MENU_MINIAPP_BUTTON_KEYS = {
CABINET_MINIAPP_BUTTON_KEYS = {
'balance',
'referrals',
'promocode',
'connect',
'subscription',
'support',
'home',
}
@@ -106,11 +108,12 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> t
if button_key not in selected_buttons:
continue
button_config = button_config_map[button_key]
if settings.is_text_main_menu_mode() and button_key in TEXT_MENU_MINIAPP_BUTTON_KEYS:
if settings.is_cabinet_mode() and button_key in CABINET_MINIAPP_BUTTON_KEYS:
row_buttons.append(
build_miniapp_or_callback_button(
text=button_config['text'],
callback_data=button_config['callback'],
cabinet_path=BUTTON_KEY_TO_CABINET_PATH.get(button_key, ''),
)
)
else:
+2 -139
View File
@@ -41,17 +41,13 @@ def _build_notification_settings_view(language: str):
third_hours = NotificationSettingsService.get_third_wave_valid_hours()
third_days = NotificationSettingsService.get_third_wave_trigger_days()
trial_1h_status = _format_toggle(config['trial_inactive_1h'].get('enabled', True))
trial_24h_status = _format_toggle(config['trial_inactive_24h'].get('enabled', True))
trial_channel_status = _format_toggle(config['trial_channel_unsubscribed'].get('enabled', True))
trial_channel_status = _format_toggle(config.get('trial_channel_unsubscribed', {}).get('enabled', True))
expired_1d_status = _format_toggle(config['expired_1d'].get('enabled', True))
second_wave_status = _format_toggle(config['expired_second_wave'].get('enabled', True))
third_wave_status = _format_toggle(config['expired_third_wave'].get('enabled', True))
summary_text = (
'🔔 <b>Уведомления пользователям</b>\n\n'
f'• 1 час после триала: {trial_1h_status}\n'
f'• 24 часа после триала: {trial_24h_status}\n'
f'• Отписка от канала: {trial_channel_status}\n'
f'• 1 день после истечения: {expired_1d_status}\n'
f'• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n'
@@ -62,26 +58,6 @@ def _build_notification_settings_view(language: str):
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=f'{trial_1h_status} • 1 час после триала', callback_data='admin_mon_notify_toggle_trial_1h'
)
],
[
InlineKeyboardButton(
text='🧪 Тест: 1 час после триала', callback_data='admin_mon_notify_preview_trial_1h'
)
],
[
InlineKeyboardButton(
text=f'{trial_24h_status} • 24 часа после триала', callback_data='admin_mon_notify_toggle_trial_24h'
)
],
[
InlineKeyboardButton(
text='🧪 Тест: 24 часа после триала', callback_data='admin_mon_notify_preview_trial_24h'
)
],
[
InlineKeyboardButton(
text=f'{trial_channel_status} • Отписка от канала',
@@ -170,76 +146,7 @@ def _build_notification_preview_message(language: str, notification_type: str):
header = '🧪 <b>Тестовое уведомление мониторинга</b>\n\n'
if notification_type == 'trial_inactive_1h':
template = texts.get(
'TRIAL_INACTIVE_1H',
(
'⏳ <b>Прошёл час, а подключения нет</b>\n\n'
'Если возникли сложности с запуском — воспользуйтесь инструкциями.'
),
)
message = template.format(
price=price_30_days,
end_date=(now + timedelta(days=settings.TRIAL_DURATION_DAYS)).strftime('%d.%m.%Y %H:%M'),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
callback_data='subscription_connect',
)
],
[
InlineKeyboardButton(
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
callback_data='menu_subscription',
)
],
[
InlineKeyboardButton(
text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'),
callback_data='menu_support',
)
],
]
)
elif notification_type == 'trial_inactive_24h':
template = texts.get(
'TRIAL_INACTIVE_24H',
(
'⏳ <b>Вы ещё не подключились к VPN</b>\n\n'
'Прошли сутки с активации тестового периода, но трафик не зафиксирован.'
'\n\nНажмите кнопку ниже, чтобы подключиться.'
),
)
message = template.format(
price=price_30_days,
end_date=(now + timedelta(days=1)).strftime('%d.%m.%Y %H:%M'),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
callback_data='subscription_connect',
)
],
[
InlineKeyboardButton(
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
callback_data='menu_subscription',
)
],
[
InlineKeyboardButton(
text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'),
callback_data='menu_support',
)
],
]
)
elif notification_type == 'trial_channel_unsubscribed':
if notification_type == 'trial_channel_unsubscribed':
template = texts.get(
'TRIAL_CHANNEL_UNSUBSCRIBED',
(
@@ -535,48 +442,6 @@ async def admin_notify_settings(callback: CallbackQuery):
await callback.answer('❌ Не удалось загрузить настройки', show_alert=True)
@router.callback_query(F.data == 'admin_mon_notify_toggle_trial_1h')
@admin_required
async def toggle_trial_1h_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_trial_inactive_1h_enabled()
NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled)
await callback.answer('✅ Включено' if not enabled else '⏸️ Отключено')
await _render_notification_settings(callback)
@router.callback_query(F.data == 'admin_mon_notify_preview_trial_1h')
@admin_required
async def preview_trial_1h_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, 'trial_inactive_1h')
await callback.answer('✅ Пример отправлен')
except Exception as exc:
logger.error('Failed to send trial 1h preview: %s', exc)
await callback.answer('❌ Не удалось отправить тест', show_alert=True)
@router.callback_query(F.data == 'admin_mon_notify_toggle_trial_24h')
@admin_required
async def toggle_trial_24h_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_trial_inactive_24h_enabled()
NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled)
await callback.answer('✅ Включено' if not enabled else '⏸️ Отключено')
await _render_notification_settings(callback)
@router.callback_query(F.data == 'admin_mon_notify_preview_trial_24h')
@admin_required
async def preview_trial_24h_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, 'trial_inactive_24h')
await callback.answer('✅ Пример отправлен')
except Exception as exc:
logger.error('Failed to send trial 24h preview: %s', exc)
await callback.answer('❌ Не удалось отправить тест', show_alert=True)
@router.callback_query(F.data == 'admin_mon_notify_toggle_trial_channel')
@admin_required
async def toggle_trial_channel_notification(callback: CallbackQuery):
@@ -668,8 +533,6 @@ async def preview_all_notifications(callback: CallbackQuery):
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
chat_id = callback.from_user.id
for notification_type in [
'trial_inactive_1h',
'trial_inactive_24h',
'trial_channel_unsubscribed',
'expired_1d',
'expired_2d',
+42
View File
@@ -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
View File
@@ -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
+21 -8
View File
@@ -1537,6 +1537,15 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
# В режиме тарифов проверяем наличие tariff_id
if settings.is_tariffs_mode():
if subscription.tariff_id:
# Проверяем, суточный ли тариф — у суточных нет period_prices, продление через resume
from app.database.crud.tariff import get_tariff_by_id
tariff = getattr(subscription, 'tariff', None) or await get_tariff_by_id(db, subscription.tariff_id)
if tariff and getattr(tariff, 'is_daily', False):
# Суточный тариф: перенаправляем на страницу подписки (там кнопка «Возобновить»)
await show_subscription_info(callback, db_user, db)
return
# У подписки есть тариф - перенаправляем на продление по тарифу
from .tariff_purchase import show_tariff_extend
@@ -3111,11 +3120,15 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
# Прикрепляем тариф к подписке для CRUD функций
subscription.tariff = tariff
# Переключаем статус паузы
# Определяем, нужно ли возобновление: пауза пользователя ИЛИ остановка системой (disabled/expired)
from app.database.models import SubscriptionStatus
was_paused = getattr(subscription, 'is_daily_paused', False)
is_inactive = subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value)
needs_resume = was_paused or is_inactive
# При возобновлении проверяем баланс
if was_paused:
if needs_resume:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price > 0 and db_user.balance_kopeks < daily_price:
await callback.answer(
@@ -3127,10 +3140,11 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
)
return
subscription = await toggle_daily_subscription_pause(db, subscription)
if needs_resume:
# Принудительный resume: снимаем паузу + восстанавливаем статус ACTIVE
from app.database.crud.subscription import resume_daily_subscription
if was_paused:
# Была пауза, теперь возобновили
subscription = await resume_daily_subscription(db, subscription)
message = texts.t('DAILY_SUBSCRIPTION_RESUMED', '▶️ Подписка возобновлена!')
# Синхронизируем с Remnawave - активируем пользователя
try:
@@ -3147,10 +3161,9 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
except Exception as e:
logger.error(f'Ошибка синхронизации с Remnawave при возобновлении: {e}')
else:
# Была активна, теперь на паузе
# Подписка активна, ставим на паузу
subscription = await toggle_daily_subscription_pause(db, subscription)
message = texts.t('DAILY_SUBSCRIPTION_PAUSED', '⏸️ Подписка приостановлена!')
# При паузе можно отключить пользователя в Remnawave (опционально)
# Пока оставляем активным, т.к. пауза - это только остановка списания
await callback.answer(message, show_alert=True)
@@ -1552,6 +1552,12 @@ async def select_tariff_extend_period(
texts = get_texts(db_user.language)
parts = callback.data.split(':')
tariff_id = int(parts[1])
# Кнопка «Назад» шлёт tariff_extend:{id} без периода — показываем экран выбора периода
if len(parts) < 3:
await show_tariff_extend(callback, db_user, db)
return
period = int(parts[2])
tariff = await get_tariff_by_id(db, tariff_id)
+67 -113
View File
@@ -3,12 +3,12 @@ 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
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
from app.database.crud.user import get_user_by_id
from app.database.models import Ticket, TicketStatus, User
@@ -63,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()
@@ -92,58 +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 символов. Попробуйте еще раз:'
)
if settings.ENABLE_LOGO_MODE:
await message.bot.edit_message_caption(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
caption=text_val,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
parse_mode=None,
)
else:
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 символов. Попробуйте еще раз:'
)
if settings.ENABLE_LOGO_MODE:
await message.bot.edit_message_caption(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
caption=text_val,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
parse_mode=None,
)
else:
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
# Глобальный блок
@@ -166,29 +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 символов) или отправьте фото с подписью:')
if settings.ENABLE_LOGO_MODE:
await message.bot.edit_message_caption(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
caption=text_val,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
parse_mode=None,
)
else:
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)
@@ -263,12 +210,10 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
)
)
if prompt_chat_id and prompt_message_id:
if settings.ENABLE_LOGO_MODE:
await message.bot.edit_message_caption(
chat_id=prompt_chat_id, message_id=prompt_message_id, caption=text_msg, parse_mode=None
)
else:
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()
@@ -285,24 +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:
if settings.ENABLE_LOGO_MODE:
await message.bot.edit_message_caption(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
caption=err_text,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
parse_mode=None,
)
else:
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()
@@ -356,15 +284,7 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
]
)
if prompt_chat_id and prompt_message_id:
if settings.ENABLE_LOGO_MODE:
await message.bot.edit_message_caption(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
caption=creation_text,
reply_markup=keyboard,
parse_mode='HTML',
)
else:
try:
await message.bot.edit_message_text(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
@@ -372,6 +292,8 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
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')
@@ -765,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)
@@ -782,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
View File
@@ -362,32 +362,101 @@ def get_language_selection_keyboard(
return InlineKeyboardMarkup(inline_keyboard=buttons)
def _build_text_main_menu_keyboard(
def _build_cabinet_main_menu_keyboard(
language: str,
texts,
*,
is_admin: bool,
is_moderator: bool,
balance_kopeks: int = 0,
) -> InlineKeyboardMarkup:
profile_text = texts.t('MENU_PROFILE', '👤 Личный кабинет')
miniapp_url = settings.get_main_menu_miniapp_url()
"""Build the main-menu keyboard for Cabinet mode.
if miniapp_url:
profile_button = InlineKeyboardButton(
text=profile_text,
web_app=types.WebAppInfo(url=miniapp_url),
)
Each button opens the corresponding section of the cabinet frontend
via ``MINIAPP_CUSTOM_URL`` + path (e.g. ``/subscription``, ``/balance``).
"""
from app.utils.button_styles_cache import CALLBACK_TO_SECTION, get_cached_button_styles
from app.utils.miniapp_buttons import (
CALLBACK_TO_CABINET_STYLE,
_resolve_style,
build_cabinet_url,
)
global_style = _resolve_style((settings.CABINET_BUTTON_STYLE or '').strip())
cached_styles = get_cached_button_styles()
def _cabinet_button(
text: str,
path: str,
callback_fallback: str,
*,
style: str | None = None,
icon_custom_emoji_id: str | None = None,
) -> InlineKeyboardButton:
url = build_cabinet_url(path)
if url:
section = CALLBACK_TO_SECTION.get(callback_fallback)
section_cfg = cached_styles.get(section or '', {}) if section else {}
# 'default' in per-section config means "no color" — do not fall through.
if style:
resolved = _resolve_style(style)
elif section_cfg.get('style'):
resolved = _resolve_style(section_cfg['style'])
else:
resolved = global_style or _resolve_style(CALLBACK_TO_CABINET_STYLE.get(callback_fallback))
resolved_emoji = icon_custom_emoji_id or section_cfg.get('icon_custom_emoji_id') or None
return InlineKeyboardButton(
text=text,
web_app=types.WebAppInfo(url=url),
style=resolved,
icon_custom_emoji_id=resolved_emoji or None,
)
return InlineKeyboardButton(text=text, callback_data=callback_fallback)
# -- Primary action row: Cabinet home --
home_cfg = cached_styles.get('home', {})
if home_cfg.get('enabled', True):
profile_text = home_cfg.get('labels', {}).get(language, '') or texts.t('MENU_PROFILE', '👤 Личный кабинет')
keyboard_rows: list[list[InlineKeyboardButton]] = [
[_cabinet_button(profile_text, '/', 'menu_profile_unavailable')],
]
else:
profile_button = InlineKeyboardButton(
text=profile_text,
callback_data='menu_profile_unavailable',
)
keyboard_rows: list[list[InlineKeyboardButton]] = []
keyboard_rows: list[list[InlineKeyboardButton]] = [[profile_button]]
# -- Section buttons as paired rows --
paired: list[InlineKeyboardButton] = []
if settings.is_language_selection_enabled():
keyboard_rows.append([InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data='menu_language')])
# Subscription (green — main action)
sub_cfg = cached_styles.get('subscription', {})
if sub_cfg.get('enabled', True):
sub_text = sub_cfg.get('labels', {}).get(language, '') or texts.MENU_SUBSCRIPTION
paired.append(_cabinet_button(sub_text, '/subscription', 'menu_subscription'))
# Balance
bal_cfg = cached_styles.get('balance', {})
if bal_cfg.get('enabled', True):
safe_balance = balance_kopeks or 0
# Custom label overrides the whole text including balance amount
custom_bal = bal_cfg.get('labels', {}).get(language, '')
if custom_bal:
balance_text = custom_bal
elif hasattr(texts, 'BALANCE_BUTTON') and safe_balance > 0:
balance_text = texts.BALANCE_BUTTON.format(balance=texts.format_price(safe_balance))
else:
balance_text = texts.t('BALANCE_BUTTON_DEFAULT', '💰 Баланс: {balance}').format(
balance=texts.format_price(safe_balance),
)
paired.append(_cabinet_button(balance_text, '/balance', 'menu_balance'))
# Referrals (if enabled)
ref_cfg = cached_styles.get('referral', {})
if settings.is_referral_program_enabled() and ref_cfg.get('enabled', True):
ref_text = ref_cfg.get('labels', {}).get(language, '') or texts.MENU_REFERRALS
paired.append(_cabinet_button(ref_text, '/referral', 'menu_referrals'))
# Support
support_enabled = False
try:
from app.services.support_settings_service import SupportSettingsService
@@ -396,11 +465,33 @@ def _build_text_main_menu_keyboard(
except Exception:
support_enabled = settings.SUPPORT_MENU_ENABLED
if support_enabled:
keyboard_rows.append([InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data='menu_support')])
sup_cfg = cached_styles.get('support', {})
if support_enabled and sup_cfg.get('enabled', True):
sup_text = sup_cfg.get('labels', {}).get(language, '') or texts.MENU_SUPPORT
paired.append(_cabinet_button(sup_text, '/support', 'menu_support'))
# Info
info_cfg = cached_styles.get('info', {})
if info_cfg.get('enabled', True):
info_text = info_cfg.get('labels', {}).get(language, '') or texts.t('MENU_INFO', '️ Инфо')
paired.append(_cabinet_button(info_text, '/info', 'menu_info'))
# Language selection (stays as callback — not a cabinet section)
if settings.is_language_selection_enabled():
paired.append(InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data='menu_language'))
# Lay out in pairs
for i in range(0, len(paired), 2):
keyboard_rows.append(paired[i : i + 2])
# Admin / Moderator
admin_cfg = cached_styles.get('admin', {})
if is_admin:
keyboard_rows.append([InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel')])
admin_buttons = [InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel')]
if admin_cfg.get('enabled', True):
admin_web_text = admin_cfg.get('labels', {}).get(language, '') or '🖥 Веб-Админка'
admin_buttons.append(_cabinet_button(admin_web_text, '/admin', 'admin_panel'))
keyboard_rows.append(admin_buttons)
elif is_moderator:
keyboard_rows.append([InlineKeyboardButton(text='🧑‍⚖️ Модерация', callback_data='moderator_panel')])
@@ -423,12 +514,13 @@ def get_main_menu_keyboard(
) -> InlineKeyboardMarkup:
texts = get_texts(language)
if settings.is_text_main_menu_mode():
return _build_text_main_menu_keyboard(
if settings.is_cabinet_mode():
return _build_cabinet_main_menu_keyboard(
language,
texts,
is_admin=is_admin,
is_moderator=is_moderator,
balance_kopeks=balance_kopeks,
)
if settings.DEBUG:
@@ -1000,9 +1092,15 @@ def get_subscription_keyboard(
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
if is_daily_tariff:
# Для суточного тарифа показываем кнопку паузы/возобновления
# Для суточного тарифа: проверяем статус подписки
from app.database.models import SubscriptionStatus
sub_status = getattr(subscription, 'status', None)
is_paused = getattr(subscription, 'is_daily_paused', False)
if is_paused:
is_inactive = sub_status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value)
if is_inactive or is_paused:
# Подписка остановлена (системой или пользователем) — показываем «Возобновить»
pause_text = texts.t('RESUME_DAILY_BUTTON', '▶️ Возобновить подписку')
else:
pause_text = texts.t('PAUSE_DAILY_BUTTON', '⏸️ Приостановить подписку')
@@ -1800,7 +1898,7 @@ def get_add_traffic_keyboard(
period_text = f' (за {months_multiplier} мес)'
packages = settings.get_traffic_topup_packages()
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
enabled_packages = [pkg for pkg in packages if pkg['enabled'] and pkg['price'] > 0]
if not enabled_packages:
return InlineKeyboardMarkup(
@@ -1884,8 +1982,8 @@ def get_add_traffic_keyboard_from_tariff(
buttons = []
# Сортируем пакеты по размеру
sorted_packages = sorted(packages.items(), key=lambda x: x[0])
# Сортируем пакеты по размеру, исключаем пакеты с нулевой ценой
sorted_packages = sorted(((gb, p) for gb, p in packages.items() if p > 0), key=lambda x: x[0])
# Пакеты трафика на тарифах покупаются на 1 месяц (30 дней),
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки
+6 -1
View File
@@ -5,7 +5,7 @@ from datetime import datetime
from typing import Any
from aiogram import BaseMiddleware
from aiogram.exceptions import TelegramForbiddenError
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message, TelegramObject, User as TgUser
from sqlalchemy.exc import InterfaceError, OperationalError
@@ -224,6 +224,11 @@ class AuthMiddleware(BaseMiddleware):
# User blocked the bot — normal, not an error
logger.debug('AuthMiddleware: bot blocked by user, skipping')
return None
except TelegramBadRequest as e:
if 'query is too old' in str(e):
logger.debug('AuthMiddleware: callback query expired, skipping')
return None
raise
except Exception as e:
logger.error(f'Ошибка в AuthMiddleware: {e}')
logger.error(f'Event type: {type(event)}')
+53 -2
View File
@@ -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)
-154
View File
@@ -221,7 +221,6 @@ class MonitoringService:
await self._check_expired_subscriptions(db)
await self._check_expiring_subscriptions(db)
await self._check_trial_expiring_soon(db)
await self._check_trial_inactivity_notifications(db)
await self._check_trial_channel_subscriptions(db)
await self._check_expired_subscription_followups(db)
if settings.ENABLE_AUTOPAY:
@@ -504,77 +503,6 @@ class MonitoringService:
except Exception as e:
logger.error(f'Ошибка проверки истекающих тестовых подписок: {e}')
async def _check_trial_inactivity_notifications(self, db: AsyncSession):
if not NotificationSettingsService.are_notifications_globally_enabled():
return
if not self.bot:
return
try:
now = datetime.utcnow()
one_hour_ago = now - timedelta(hours=1)
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.is_trial == True,
Subscription.start_date.isnot(None),
Subscription.start_date <= one_hour_ago,
Subscription.end_date > now,
)
)
)
subscriptions = result.scalars().all()
sent_1h = 0
sent_24h = 0
for subscription in subscriptions:
user = subscription.user
if not user:
continue
if (subscription.traffic_used_gb or 0) > 0:
continue
start_date = subscription.start_date
if not start_date:
continue
time_since_start = now - start_date
if NotificationSettingsService.is_trial_inactive_1h_enabled() and timedelta(
hours=1
) <= time_since_start < timedelta(hours=24):
if not await notification_sent(db, user.id, subscription.id, 'trial_inactive_1h'):
success = await self._send_trial_inactive_notification(user, subscription, 1)
if success:
await record_notification(db, user.id, subscription.id, 'trial_inactive_1h')
sent_1h += 1
if NotificationSettingsService.is_trial_inactive_24h_enabled() and time_since_start >= timedelta(
hours=24
):
if not await notification_sent(db, user.id, subscription.id, 'trial_inactive_24h'):
success = await self._send_trial_inactive_notification(user, subscription, 24)
if success:
await record_notification(db, user.id, subscription.id, 'trial_inactive_24h')
sent_24h += 1
if sent_1h or sent_24h:
await self._log_monitoring_event(
db,
'trial_inactivity_notifications',
f'Отправлено {sent_1h} уведомлений спустя 1 час и {sent_24h} спустя 24 часа',
{'sent_1h': sent_1h, 'sent_24h': sent_24h},
)
except Exception as e:
logger.error(f'Ошибка проверки неактивных тестовых подписок: {e}')
async def _check_trial_channel_subscriptions(self, db: AsyncSession):
from app.database.crud.subscription import is_recently_updated_by_webhook
@@ -1357,88 +1285,6 @@ class MonitoringService:
)
return False
async def _send_trial_inactive_notification(self, user: User, subscription: Subscription, hours: int) -> bool:
try:
texts = get_texts(user.language)
if hours >= 24:
template = texts.get(
'TRIAL_INACTIVE_24H',
(
'⏳ <b>Вы ещё не подключились к VPN</b>\n\n'
'Прошли сутки с активации тестового периода, но трафик не зафиксирован.'
'\n\nНажмите кнопку ниже, чтобы подключиться.'
),
)
else:
template = texts.get(
'TRIAL_INACTIVE_1H',
(
'⏳ <b>Прошёл час, а подключения нет</b>\n\n'
'Если возникли сложности с запуском — воспользуйтесь инструкциями.'
),
)
message = template.format(
price=settings.format_price(settings.PRICE_30_DAYS),
end_date=format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M'),
)
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
build_miniapp_or_callback_button(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
callback_data='subscription_connect',
)
],
[
build_miniapp_or_callback_button(
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
callback_data='menu_subscription',
)
],
[
InlineKeyboardButton(
text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'), callback_data='menu_support'
)
],
]
)
await self._send_message_with_logo(
chat_id=user.telegram_id,
text=message,
parse_mode='HTML',
reply_markup=keyboard,
)
return True
except (TelegramForbiddenError, TelegramBadRequest) as exc:
if self._handle_unreachable_user(user, exc, 'уведомление о бездействии на тесте'):
return True
logger.error(
'Ошибка Telegram API при отправке уведомления об отсутствии подключения пользователю %s: %s',
user.telegram_id,
exc,
)
return False
except TelegramNetworkError as e:
logger.warning(
'Таймаут отправки уведомления об отсутствии подключения пользователю %s: %s',
user.telegram_id,
e,
)
return False
except Exception as e:
logger.error(
'Ошибка отправки уведомления об отсутствии подключения пользователю %s: %s',
user.telegram_id,
e,
)
return False
async def _send_trial_channel_unsubscribed_notification(self, user: User) -> bool:
try:
texts = get_texts(user.language)
@@ -18,8 +18,6 @@ class NotificationSettingsService:
_loaded: bool = False
_DEFAULTS: dict[str, dict[str, Any]] = {
'trial_inactive_1h': {'enabled': True},
'trial_inactive_24h': {'enabled': True},
'trial_channel_unsubscribed': {'enabled': True},
'expired_1d': {'enabled': True},
'expired_second_wave': {
@@ -122,23 +120,6 @@ class NotificationSettingsService:
def is_enabled(cls, key: str) -> bool:
return bool(cls._get(key).get('enabled', True))
# Trial inactivity helpers
@classmethod
def is_trial_inactive_1h_enabled(cls) -> bool:
return cls.is_enabled('trial_inactive_1h')
@classmethod
def set_trial_inactive_1h_enabled(cls, enabled: bool) -> bool:
return cls.set_enabled('trial_inactive_1h', enabled)
@classmethod
def is_trial_inactive_24h_enabled(cls) -> bool:
return cls.is_enabled('trial_inactive_24h')
@classmethod
def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool:
return cls.set_enabled('trial_inactive_24h', enabled)
@classmethod
def is_trial_channel_unsubscribed_enabled(cls) -> bool:
return cls.is_enabled('trial_channel_unsubscribed')
+47 -32
View File
@@ -189,6 +189,9 @@ async def broadcast_pinned_message(
)
failed_count += 1
break
else:
# All retry attempts exhausted (TelegramRetryAfter on every attempt)
failed_count += 1
for i in range(0, len(recipient_telegram_ids), 30):
batch = recipient_telegram_ids[i : i + 30]
@@ -251,23 +254,6 @@ async def unpin_active_pinned_message(
unpinned_count += 1
else:
failed_count += 1
except TelegramRetryAfter as retry_error:
delay = min(retry_error.retry_after + 1, 30)
logger.warning(
'RetryAfter while unpinning for user %s, waiting %s seconds',
telegram_id,
delay,
)
await asyncio.sleep(delay)
# Повторная попытка после ожидания
try:
success = await _unpin_message_for_user(bot, telegram_id)
if success:
unpinned_count += 1
else:
failed_count += 1
except Exception:
failed_count += 1
except Exception as error:
logger.error(
'Ошибка открепления сообщения у пользователя %s: %s',
@@ -311,6 +297,12 @@ async def _send_and_pin_message(bot: Bot, chat_id: int, pinned_message: PinnedMe
pass
except TelegramForbiddenError:
return False
except TelegramRetryAfter as e:
await asyncio.sleep(min(e.retry_after + 1, 30))
try:
await bot.unpin_all_chat_messages(chat_id=chat_id)
except (TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter):
pass
try:
if pinned_message.media_type == 'photo' and pinned_message.media_file_id:
@@ -345,6 +337,9 @@ async def _send_and_pin_message(bot: Bot, chat_id: int, pinned_message: PinnedMe
return True
except TelegramForbiddenError:
return False
except TelegramRetryAfter as e:
await asyncio.sleep(min(e.retry_after + 1, 30))
raise # Propagate to caller's retry loop
except TelegramBadRequest as error:
logger.warning(
'Некорректный запрос при отправке закрепленного сообщения в чат %s: %s',
@@ -361,18 +356,38 @@ async def _send_and_pin_message(bot: Bot, chat_id: int, pinned_message: PinnedMe
return False
async def _unpin_message_for_user(bot: Bot, chat_id: int) -> bool:
try:
await bot.unpin_all_chat_messages(chat_id=chat_id)
return True
except TelegramForbiddenError:
return False
except TelegramBadRequest:
return False
except Exception as error:
logger.error(
'Не удалось открепить сообщение у пользователя %s: %s',
chat_id,
error,
)
return False
async def _unpin_message_for_user(bot: Bot, chat_id: int, max_retries: int = 3) -> bool:
for attempt in range(max_retries):
try:
await bot.unpin_all_chat_messages(chat_id=chat_id)
return True
except TelegramRetryAfter as e:
if attempt < max_retries - 1:
delay = min(e.retry_after + 1, 30)
logger.warning(
'RetryAfter при откреплении для %s, ожидание %s сек (попытка %d/%d)',
chat_id,
delay,
attempt + 1,
max_retries,
)
await asyncio.sleep(delay)
else:
logger.warning(
'Не удалось открепить сообщение у %s после %d попыток (flood control)',
chat_id,
max_retries,
)
return False
except TelegramForbiddenError:
return False
except TelegramBadRequest:
return False
except Exception as error:
logger.error(
'Не удалось открепить сообщение у пользователя %s: %s',
chat_id,
error,
)
return False
return False
+12
View File
@@ -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):
+14 -9
View File
@@ -17,6 +17,7 @@ from typing import Any
from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy import delete
from sqlalchemy.exc import PendingRollbackError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.exc import StaleDataError
@@ -29,7 +30,7 @@ from app.database.crud.subscription import (
reactivate_subscription,
update_subscription_usage,
)
from app.database.crud.user import get_user_by_remnawave_uuid, get_user_by_telegram_id
from app.database.crud.user import get_user_by_id, get_user_by_remnawave_uuid, get_user_by_telegram_id
from app.database.models import Subscription, SubscriptionServer, SubscriptionStatus, User
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
@@ -192,7 +193,7 @@ class RemnaWaveWebhookService:
try:
await handler(db, user, subscription, data)
return True
except StaleDataError:
except (StaleDataError, PendingRollbackError):
logger.warning(
'RemnaWave webhook %s: entity already deleted for user %s (concurrent deletion)',
event_name,
@@ -355,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')],
]
)
@@ -375,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')],
]
)
@@ -628,14 +629,18 @@ class RemnaWaveWebhookService:
)
subscription = None
try:
await db.refresh(user)
await db.rollback()
except Exception:
from app.database.crud.user import get_user_by_id
pass
try:
user = await get_user_by_id(db, user_id)
if not user:
logger.error('Webhook: user %s not found after rollback', user_id)
return
except Exception:
logger.error('Webhook: user %s not found after rollback', user_id)
return
if not user:
logger.error('Webhook: user %s not found after rollback', user_id)
return
if subscription:
if subscription.status != SubscriptionStatus.EXPIRED.value:
+8 -1
View File
@@ -294,6 +294,7 @@ class BotConfigurationService:
'LOGO_FILE': 'INTERFACE_BRANDING',
'HIDE_SUBSCRIPTION_LINK': 'INTERFACE_SUBSCRIPTION',
'MAIN_MENU_MODE': 'INTERFACE',
'CABINET_BUTTON_STYLE': 'INTERFACE',
'CONNECT_BUTTON_MODE': 'CONNECT_BUTTON',
'MINIAPP_CUSTOM_URL': 'CONNECT_BUTTON',
'APP_CONFIG_PATH': 'ADDITIONAL',
@@ -406,7 +407,13 @@ class BotConfigurationService:
],
'MAIN_MENU_MODE': [
ChoiceOption('default', '📋 Полное меню'),
ChoiceOption('text', '📝 Текстовое меню'),
ChoiceOption('cabinet', '🏠 Cabinet (МиниАпп)'),
],
'CABINET_BUTTON_STYLE': [
ChoiceOption('', '🎨 По секциям (авто)'),
ChoiceOption('primary', '🔵 Синий'),
ChoiceOption('success', '🟢 Зелёный'),
ChoiceOption('danger', '🔴 Красный'),
],
'SALES_MODE': [
ChoiceOption('classic', '📋 Классический (периоды из .env)'),
+120
View File
@@ -0,0 +1,120 @@
"""Lightweight in-process cache for per-section cabinet button styles.
Avoids circular imports between ``cabinet.routes`` and ``app.utils.miniapp_buttons``
by keeping the cache and its helpers in a dedicated module.
"""
import json
import logging
from app.database.database import AsyncSessionLocal
logger = logging.getLogger(__name__)
# ---- Defaults per section ------------------------------------------------
DEFAULT_BUTTON_STYLES: dict[str, dict] = {
'home': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'subscription': {'style': 'success', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'balance': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'referral': {'style': 'success', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'support': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'info': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'admin': {'style': 'danger', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
}
BOT_LOCALES = ('ru', 'en', 'ua', 'zh', 'fa')
SECTIONS = list(DEFAULT_BUTTON_STYLES.keys())
# Map callback_data values to their logical section name.
CALLBACK_TO_SECTION: dict[str, str] = {
'menu_profile_unavailable': 'home',
'back_to_menu': 'home',
'menu_subscription': 'subscription',
'subscription': 'subscription',
'subscription_extend': 'subscription',
'subscription_upgrade': 'subscription',
'subscription_connect': 'subscription',
'subscription_resume_checkout': 'subscription',
'return_to_saved_cart': 'subscription',
'menu_buy': 'subscription',
'buy_traffic': 'subscription',
'menu_balance': 'balance',
'balance_topup': 'balance',
'menu_referrals': 'referral',
'menu_referral': 'referral',
'menu_support': 'support',
'menu_info': 'info',
'admin_panel': 'admin',
}
# DB key used for storage.
BUTTON_STYLES_KEY = 'CABINET_BUTTON_STYLES'
# Valid Telegram Bot API style values.
VALID_STYLES = frozenset({'primary', 'success', 'danger'})
# All style values accepted by the admin API ('default' = no color, Telegram default).
ALLOWED_STYLE_VALUES = VALID_STYLES | {'default'}
# ---- Module-level cache ---------------------------------------------------
_cached_styles: dict[str, dict] | None = None
def _deep_copy_styles(source: dict[str, dict]) -> dict[str, dict]:
"""Return a deep copy of styles dict (copies nested ``labels`` dicts)."""
return {section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in source.items()}
def get_cached_button_styles() -> dict[str, dict]:
"""Return the current merged config (DB overrides + defaults).
If the cache has not been loaded yet, returns defaults.
"""
if _cached_styles is not None:
return _deep_copy_styles(_cached_styles)
return _deep_copy_styles(DEFAULT_BUTTON_STYLES)
async def load_button_styles_cache() -> dict[str, dict]:
"""Load button styles from DB and refresh the module cache.
Called at bot startup and after admin updates via the cabinet API.
"""
global _cached_styles
merged = _deep_copy_styles(DEFAULT_BUTTON_STYLES)
try:
from sqlalchemy import select
from app.database.models import SystemSetting
async with AsyncSessionLocal() as session:
result = await session.execute(select(SystemSetting).where(SystemSetting.key == BUTTON_STYLES_KEY))
setting = result.scalar_one_or_none()
if setting and setting.value:
db_data: dict = json.loads(setting.value)
for section, overrides in db_data.items():
if section in merged and isinstance(overrides, dict):
if overrides.get('style') in ALLOWED_STYLE_VALUES:
merged[section]['style'] = overrides['style']
if isinstance(overrides.get('icon_custom_emoji_id'), str):
merged[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
if isinstance(overrides.get('enabled'), bool):
merged[section]['enabled'] = overrides['enabled']
if isinstance(overrides.get('labels'), dict):
merged[section]['labels'] = {
k: v
for k, v in overrides['labels'].items()
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
}
except Exception:
logger.exception('Failed to load button styles from DB, using defaults')
_cached_styles = merged
logger.info('Button styles cache loaded: %s', list(merged.keys()))
return merged
+138 -11
View File
@@ -2,30 +2,157 @@ from aiogram import types
from aiogram.types import InlineKeyboardButton
from app.config import settings
from app.utils.button_styles_cache import CALLBACK_TO_SECTION, get_cached_button_styles
# Mapping from callback_data to cabinet frontend paths.
# Used for automatic deep-linking when explicit ``cabinet_path`` is not provided.
# If callback_data is NOT in this mapping, the button falls back to a regular callback.
CALLBACK_TO_CABINET_PATH: dict[str, str] = {
'menu_balance': '/balance',
'balance_topup': '/balance/top-up',
'menu_subscription': '/subscription',
'subscription': '/subscription',
'subscription_extend': '/subscription',
'subscription_upgrade': '/subscription',
'subscription_connect': '/subscription',
'subscription_resume_checkout': '/subscription',
'return_to_saved_cart': '/subscription',
'menu_buy': '/subscription',
'buy_traffic': '/subscription',
'menu_referrals': '/referral',
'menu_referral': '/referral',
'menu_support': '/support',
'menu_info': '/info',
'menu_profile': '/profile',
'back_to_menu': '/',
}
# Default button styles per callback_data for cabinet mode.
# Values: 'primary' (blue), 'success' (green), 'danger' (red), None (default).
CALLBACK_TO_CABINET_STYLE: dict[str, str] = {
'menu_balance': 'primary',
'balance_topup': 'primary',
'menu_subscription': 'success',
'subscription': 'success',
'subscription_extend': 'success',
'subscription_upgrade': 'success',
'subscription_connect': 'success',
'subscription_resume_checkout': 'success',
'return_to_saved_cart': 'success',
'menu_buy': 'success',
'buy_traffic': 'success',
'menu_referrals': 'success',
'menu_referral': 'success',
'menu_support': 'primary',
'menu_info': 'primary',
'menu_profile': 'primary',
'back_to_menu': 'primary',
}
# Mapping from broadcast button keys to cabinet paths.
BUTTON_KEY_TO_CABINET_PATH: dict[str, str] = {
'balance': '/balance/top-up',
'referrals': '/referral',
'promocode': '/subscription',
'connect': '/subscription',
'subscription': '/subscription',
'support': '/support',
'home': '/',
}
# Valid style values accepted by the Telegram Bot API.
_VALID_STYLES = frozenset({'primary', 'success', 'danger'})
def _resolve_style(style: str | None) -> str | None:
"""Return a validated style or ``None``."""
if style and style in _VALID_STYLES:
return style
return None
def build_cabinet_url(path: str = '') -> str:
"""Join ``MINIAPP_CUSTOM_URL`` with an optional *path* segment.
Handles trailing-slash normalization so that both
``https://example.com`` and ``https://example.com/`` produce
correct URLs like ``https://example.com/balance``.
Returns an empty string when the base URL is not configured
or when *path* is empty (no known section).
"""
base = (settings.MINIAPP_CUSTOM_URL or '').strip().rstrip('/')
if not base:
return ''
if not path:
return ''
if path == '/':
return base
if not path.startswith('/'):
path = f'/{path}'
return f'{base}{path}'
def build_miniapp_or_callback_button(
text: str,
*,
callback_data: str,
cabinet_path: str | None = None,
style: str | None = None,
icon_custom_emoji_id: str | None = None,
) -> InlineKeyboardButton:
"""Create a button that opens the miniapp or falls back to a callback.
"""Create a button that opens the cabinet miniapp or falls back to a callback.
In text menu mode, if ``MINIAPP_CUSTOM_URL`` is configured the button
opens the full cabinet miniapp. Otherwise (or outside text menu mode)
the regular ``callback_data`` is used so the user stays in the bot.
In cabinet menu mode, if ``MINIAPP_CUSTOM_URL`` is configured the button
opens the relevant section of the cabinet. The target section is determined
by ``cabinet_path`` (explicit) or inferred from ``callback_data`` via
``CALLBACK_TO_CABINET_PATH``.
Button styling (Bot API 9.4):
- ``style`` overrides the button color: ``'primary'`` (blue),
``'success'`` (green), ``'danger'`` (red). When omitted the style is
resolved from ``CABINET_BUTTON_STYLE`` config or per-section defaults.
- ``icon_custom_emoji_id`` shows a custom emoji before the button text
(requires bot owner to have Telegram Premium).
When ``callback_data`` is not found in the mapping and no explicit
``cabinet_path`` is given, the button falls back to a regular Telegram
callback this keeps actions like ``claim_discount_*`` working correctly.
Only ``MINIAPP_CUSTOM_URL`` is considered here the purchase-only URL
(``MINIAPP_PURCHASE_URL``) is intentionally excluded because it cannot
display subscription details and would load indefinitely.
"""
if settings.is_text_main_menu_mode():
miniapp_url = (settings.MINIAPP_CUSTOM_URL or '').strip()
if miniapp_url:
return InlineKeyboardButton(
text=text,
web_app=types.WebAppInfo(url=miniapp_url),
)
if settings.is_cabinet_mode():
path = cabinet_path or CALLBACK_TO_CABINET_PATH.get(callback_data)
if path:
url = build_cabinet_url(path)
if url:
# Resolve per-section config from cache
section = CALLBACK_TO_SECTION.get(callback_data)
section_cfg = get_cached_button_styles().get(section or '', {}) if section else {}
# Style chain: explicit param > per-section DB > global config > hardcoded default
# 'default' in per-section config means "no color" — do not fall through.
if style:
resolved_style = _resolve_style(style)
elif section_cfg.get('style'):
resolved_style = _resolve_style(section_cfg['style'])
else:
resolved_style = _resolve_style((settings.CABINET_BUTTON_STYLE or '').strip()) or _resolve_style(
CALLBACK_TO_CABINET_STYLE.get(callback_data)
)
# Emoji chain: explicit param > per-section DB
resolved_emoji = icon_custom_emoji_id or section_cfg.get('icon_custom_emoji_id') or None
return InlineKeyboardButton(
text=text,
web_app=types.WebAppInfo(url=url),
style=resolved_style,
icon_custom_emoji_id=resolved_emoji or None,
)
return InlineKeyboardButton(text=text, callback_data=callback_data)
+105
View File
@@ -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()
+42 -13
View File
@@ -1,3 +1,4 @@
import html as html_module
import re
from datetime import datetime
@@ -23,6 +24,16 @@ ALLOWED_HTML_TAGS = {
SELF_CLOSING_TAGS = {'br', 'hr', 'img'}
# Разрешённые атрибуты для HTML-тегов
ALLOWED_TAG_ATTRIBUTES = {
'a': {'href'},
'tg-emoji': {'emoji-id'},
'span': {'class'},
}
# Разрешённые URI-схемы в href (allowlist вместо blocklist)
SAFE_URI_SCHEMES = re.compile(r'^(https?://|tg://|mailto:|tel:)', re.IGNORECASE)
def validate_email(email: str) -> bool:
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
@@ -140,25 +151,43 @@ def sanitize_html(text: str) -> str:
# Обработка всех разрешенных тегов
for tag in allowed_tags:
# Паттерн: захватываем &lt;tag&gt;, &lt;/tag&gt;, или &lt;tag атрибуты&gt;
# Используем более сложный паттерн, чтобы захватить атрибуты до закрывающего &gt;
# (?s) - позволяет . захватывать новую строку
# [^>]*? - ленивый захват до >
pattern = rf'(&lt;)(/?{tag}\b)([^>]*?)(&gt;)'
def replace_tag(match):
match.group(1) # &lt;
tag_lower = tag.lower()
def replace_tag(match, _tag=tag_lower):
full_tag_content = match.group(2) # /?tagname
attrs_part = match.group(3) # атрибуты (без >)
match.group(4) # &gt;
attrs_part = match.group(3).removeprefix(' ') # атрибуты (без >)
# Убираем начальный пробел, если есть
attrs_part = attrs_part.removeprefix(' ')
if not attrs_part:
return f'<{full_tag_content}>'
# Формируем результат
if attrs_part:
# Безопасно обрабатываем атрибуты, заменяя только безопасные сущности
# Не разворачиваем &lt; и &gt; внутри атрибутов, чтобы избежать XSS
processed_attrs = attrs_part.replace('&quot;', '"').replace('&#x27;', "'")
# Полное декодирование HTML-сущностей для корректной проверки атрибутов
processed_attrs = html_module.unescape(attrs_part)
# Проверяем whitelist атрибутов для данного тега
allowed_attrs = ALLOWED_TAG_ATTRIBUTES.get(_tag)
if allowed_attrs is None:
# Тег без whitelist — удаляем ВСЕ атрибуты
return f'<{full_tag_content}>'
filtered_parts = []
for attr_match in re.finditer(r'([a-zA-Z][\w-]*)\s*=\s*(?:"([^"]*)"|\'([^\']*)\')', processed_attrs):
attr_name = attr_match.group(1).lower()
attr_value = attr_match.group(2) if attr_match.group(2) is not None else attr_match.group(3)
if attr_name not in allowed_attrs:
continue
# href: allowlist безопасных URI-схем
if attr_name == 'href':
# Нормализуем: убираем control chars и пробелы из начала значения
normalized = re.sub(r'[\x00-\x1f\x7f\s]+', '', attr_value)
if not SAFE_URI_SCHEMES.match(normalized):
continue
filtered_parts.append(f'{attr_name}="{attr_value}"')
processed_attrs = ' '.join(filtered_parts)
if processed_attrs:
return f'<{full_tag_content} {processed_attrs}>'
return f'<{full_tag_content}>'
+1 -1
View File
@@ -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),
+2 -2
View File
@@ -329,7 +329,7 @@
Функции: `_t` — Helper for localized button labels with fallbacks., `get_admin_main_keyboard`, `get_admin_users_submenu_keyboard`, `get_admin_promo_submenu_keyboard`, `get_admin_communications_submenu_keyboard`, `get_admin_support_submenu_keyboard`, `get_admin_settings_submenu_keyboard`, `get_admin_system_submenu_keyboard`, `get_admin_reports_keyboard`, `get_admin_report_result_keyboard`, `get_admin_users_keyboard`, `get_admin_users_filters_keyboard`, `get_admin_subscriptions_keyboard`, `get_admin_promocodes_keyboard`, `get_admin_campaigns_keyboard`, `get_campaign_management_keyboard`, `get_campaign_edit_keyboard`, `get_campaign_bonus_type_keyboard`, `get_promocode_management_keyboard`, `get_admin_messages_keyboard`, `get_admin_monitoring_keyboard`, `get_admin_remnawave_keyboard`, `get_admin_statistics_keyboard`, `get_user_management_keyboard`, `get_user_promo_group_keyboard`, `get_confirmation_keyboard`, `get_promocode_type_keyboard`, `get_promocode_list_keyboard`, `get_broadcast_target_keyboard`, `get_custom_criteria_keyboard`, `get_broadcast_history_keyboard`, `get_sync_options_keyboard`, `get_sync_confirmation_keyboard`, `get_sync_result_keyboard`, `get_period_selection_keyboard`, `get_node_management_keyboard`, `get_squad_management_keyboard`, `get_squad_edit_keyboard`, `get_monitoring_keyboard`, `get_monitoring_logs_keyboard`, `get_monitoring_logs_navigation_keyboard`, `get_log_detail_keyboard`, `get_monitoring_clear_confirm_keyboard`, `get_monitoring_status_keyboard`, `get_monitoring_settings_keyboard`, `get_log_type_filter_keyboard`, `get_admin_servers_keyboard`, `get_server_edit_keyboard`, `get_admin_pagination_keyboard`, `get_maintenance_keyboard`, `get_sync_simplified_keyboard`, `get_welcome_text_keyboard`, `get_broadcast_button_config`, `get_broadcast_button_labels`, `get_message_buttons_selector_keyboard`, `get_broadcast_media_keyboard`, `get_media_confirm_keyboard`, `get_updated_message_buttons_selector_keyboard_with_media`
- `app/keyboards/inline.py` — Python-модуль
Классы: нет
Функции: `_get_localized_value`, `_build_additional_buttons`, `get_rules_keyboard`, `get_channel_sub_keyboard`, `get_post_registration_keyboard`, `get_language_selection_keyboard`, `_build_text_main_menu_keyboard`, `get_main_menu_keyboard`, `get_info_menu_keyboard`, `get_happ_download_button_row`, `get_happ_cryptolink_keyboard`, `get_happ_download_platform_keyboard`, `get_happ_download_link_keyboard`, `get_back_keyboard`, `get_server_status_keyboard`, `get_insufficient_balance_keyboard`, `get_subscription_keyboard`, `get_payment_methods_keyboard_with_cart`, `get_subscription_confirm_keyboard_with_cart`, `get_insufficient_balance_keyboard_with_cart`, `get_trial_keyboard`, `get_subscription_period_keyboard`, `get_traffic_packages_keyboard`, `get_countries_keyboard`, `get_devices_keyboard`, `_get_device_declension`, `get_subscription_confirm_keyboard`, `get_balance_keyboard`, `get_payment_methods_keyboard`, `get_yookassa_payment_keyboard`, `get_autopay_notification_keyboard`, `get_subscription_expiring_keyboard`, `get_referral_keyboard`, `get_support_keyboard`, `get_pagination_keyboard`, `get_confirmation_keyboard`, `get_autopay_keyboard`, `get_autopay_days_keyboard`, `_get_days_word`, `get_extend_subscription_keyboard`, `get_add_traffic_keyboard`, `get_change_devices_keyboard`, `get_confirm_change_devices_keyboard`, `get_reset_traffic_confirm_keyboard`, `get_manage_countries_keyboard`, `get_device_selection_keyboard`, `get_connection_guide_keyboard`, `get_app_selection_keyboard`, `get_specific_app_keyboard`, `get_extend_subscription_keyboard_with_prices`, `get_cryptobot_payment_keyboard`, `get_devices_management_keyboard`, `get_updated_subscription_settings_keyboard`, `get_device_reset_confirm_keyboard`, `get_device_management_help_keyboard`, `get_ticket_cancel_keyboard`, `get_my_tickets_keyboard`, `get_ticket_view_keyboard`, `get_ticket_reply_cancel_keyboard`, `get_admin_tickets_keyboard`, `get_admin_ticket_view_keyboard`, `get_admin_ticket_reply_cancel_keyboard`
Функции: `_get_localized_value`, `_build_additional_buttons`, `get_rules_keyboard`, `get_channel_sub_keyboard`, `get_post_registration_keyboard`, `get_language_selection_keyboard`, `_build_cabinet_main_menu_keyboard`, `get_main_menu_keyboard`, `get_info_menu_keyboard`, `get_happ_download_button_row`, `get_happ_cryptolink_keyboard`, `get_happ_download_platform_keyboard`, `get_happ_download_link_keyboard`, `get_back_keyboard`, `get_server_status_keyboard`, `get_insufficient_balance_keyboard`, `get_subscription_keyboard`, `get_payment_methods_keyboard_with_cart`, `get_subscription_confirm_keyboard_with_cart`, `get_insufficient_balance_keyboard_with_cart`, `get_trial_keyboard`, `get_subscription_period_keyboard`, `get_traffic_packages_keyboard`, `get_countries_keyboard`, `get_devices_keyboard`, `_get_device_declension`, `get_subscription_confirm_keyboard`, `get_balance_keyboard`, `get_payment_methods_keyboard`, `get_yookassa_payment_keyboard`, `get_autopay_notification_keyboard`, `get_subscription_expiring_keyboard`, `get_referral_keyboard`, `get_support_keyboard`, `get_pagination_keyboard`, `get_confirmation_keyboard`, `get_autopay_keyboard`, `get_autopay_days_keyboard`, `_get_days_word`, `get_extend_subscription_keyboard`, `get_add_traffic_keyboard`, `get_change_devices_keyboard`, `get_confirm_change_devices_keyboard`, `get_reset_traffic_confirm_keyboard`, `get_manage_countries_keyboard`, `get_device_selection_keyboard`, `get_connection_guide_keyboard`, `get_app_selection_keyboard`, `get_specific_app_keyboard`, `get_extend_subscription_keyboard_with_prices`, `get_cryptobot_payment_keyboard`, `get_devices_management_keyboard`, `get_updated_subscription_settings_keyboard`, `get_device_reset_confirm_keyboard`, `get_device_management_help_keyboard`, `get_ticket_cancel_keyboard`, `get_my_tickets_keyboard`, `get_ticket_view_keyboard`, `get_ticket_reply_cancel_keyboard`, `get_admin_tickets_keyboard`, `get_admin_ticket_view_keyboard`, `get_admin_ticket_reply_cancel_keyboard`
- `app/keyboards/reply.py` — Python-модуль
Классы: нет
Функции: `get_main_reply_keyboard`, `get_admin_reply_keyboard`, `get_cancel_keyboard`, `get_confirmation_reply_keyboard`, `get_skip_keyboard`, `remove_keyboard`, `get_contact_keyboard`, `get_location_keyboard`
@@ -512,7 +512,7 @@
Функции: `is_qr_message`, `_get_language`, `_default_privacy_hint`, `append_privacy_hint`, `prepare_privacy_safe_kwargs`, `is_privacy_restricted_error`, `patch_message_methods`
- `app/utils/miniapp_buttons.py` — Python-модуль
Классы: нет
Функции: `build_miniapp_or_callback_button` — Create a button that opens the miniapp in text menu mode.
Функции: `build_cabinet_url`, `build_miniapp_or_callback_button` — Create a button that opens the cabinet miniapp section or falls back to a callback.
- `app/utils/pagination.py` — Python-модуль
Классы: `PaginationResult` (1 методов)
Функции: `paginate_list`, `get_pagination_info`, `get_page_numbers`
+15 -15
View File
@@ -1,26 +1,26 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.10.3"
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
View File
@@ -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
Generated
+33 -32
View File
@@ -13,7 +13,7 @@ wheels = [
[[package]]
name = "aiogram"
version = "3.24.0"
version = "3.25.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -23,9 +23,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fd/2f/04f47e81def8f2168679b1551e665e7ee02cf063e7bddace9fb5d1ce2f35/aiogram-3.24.0.tar.gz", hash = "sha256:ec547ede5bfa8a7a4f5fb02c75391333fc43b6f3de6a6d3f00a32e27628df5f6", size = 1713321, upload-time = "2026-01-02T00:56:55.3Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ff/31/360c4ce76e60e9e7bcdda1af1ab4331d78837fbb22847a62121ad32b7672/aiogram-3.25.0.tar.gz", hash = "sha256:8a8b0c34f8c4ca8a6501b954abb0eeba26743449e35e20b70c0d810347354c3c", size = 1721010, upload-time = "2026-02-10T21:50:25.473Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/a5/7ba5f75b56f87a956b9e5a3e823bcbb5b55fc968914a16f3c7aa659cfc89/aiogram-3.24.0-py3-none-any.whl", hash = "sha256:eb3cc05b0ec53c7e24d7eada5c069aee2f431332e2e7bc2c8adf30d13b02f715", size = 706866, upload-time = "2026-01-02T00:56:53.115Z" },
{ url = "https://files.pythonhosted.org/packages/cf/be/1090252415e192687985517162dbdcee2ec4150cda1fa52bf57ae1f1c2a8/aiogram-3.25.0-py3-none-any.whl", hash = "sha256:0243966e93fbde14e90c0dfd0b3776c637ebf7ddcca2c7ee81ecbd68d9490cce", size = 713972, upload-time = "2026-02-10T21:50:23.253Z" },
]
[[package]]
@@ -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" }