Compare commits

...

49 Commits

Author SHA1 Message Date
Egor c7ce80e882 Merge pull request #2643 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.18.0
2026-02-24 06:38:48 +03:00
github-actions[bot] 83e04a2e93 chore(main): release 3.18.0 2026-02-24 03:38:23 +00:00
Egor 351ebcf9eb Merge pull request #2642 from BEDOLAGA-DEV/dev
Dev
2026-02-24 06:37:51 +03:00
Fringg e5fa45f74f fix: correct broadcast button deep-links for cabinet mode
- promocode button now opens /balance instead of /subscription
- add menu_promocode to CALLBACK_TO_CABINET_PATH and style mappings
2026-02-24 06:33:56 +03:00
Fringg 25f014fd89 feat: add ChatTypeFilterMiddleware to ignore group/forum messages
Drop all messages and callback queries from non-private chats
(groups, supergroups with forum topics, channels) before they
reach any handler or heavy middleware (DB, throttle, blacklist).

- Registered after ContextVarsMiddleware, before GlobalErrorMiddleware
- chat_member events intentionally excluded (needed for channel tracking)
- pre_checkout_query excluded (no chat context, always private)
- Uses ChatType.PRIVATE enum for type safety
- Debug logging on dropped events for observability
2026-02-24 06:19:03 +03:00
Fringg 6f473defef fix: restore RemnaWave config management endpoints
The previous refactoring accidentally deleted RemnaWave API routes
(/remnawave/status, /uuid, /config, /configs) along with the legacy
file-based CRUD routes. Restore only the RemnaWave endpoints that
the cabinet frontend depends on.
2026-02-24 06:02:36 +03:00
Fringg 59fb08c3ea style: format 5 files with ruff 2026-02-24 05:59:08 +03:00
Fringg 295d2e877e refactor: remove legacy app-config.json system
Replace dual-configuration architecture (Remnawave API + local file fallback)
with Remnawave-only approach. When config is unavailable, show explicit
"not configured" message instead of silent file fallback.

- Delete app-config.json and admin_apps.py CRUD module (~1260 lines)
- Remove sync loaders, legacy step format handlers, device_mapping dicts
- Remove miniapp /app-config.json endpoint and filesystem search
- Remove backup service app-config.json snapshot/restore
- Remove APP_CONFIG_PATH setting, env var, docker volume mount
- Remove hardcoded 6-device keyboard fallback
- Remove legacy step-based keyboard rendering (installationStep etc.)
- Add "config not configured" message when Remnawave config is missing
- Update admin UI: "clear config" disables guide mode instead of reverting
2026-02-24 05:58:25 +03:00
Fringg 711ec344c6 fix: HTML-escape all externally-sourced text in guide messages
- Escape app names, device names, and other_app_names in
  handle_device_guide, handle_app_selection, handle_specific_app_guide
- Redact internal paths and exception details from cabinet API
  error responses in _load_config, _save_config, and Remnawave
  fetch endpoints
2026-02-24 05:27:59 +03:00
Fringg 978726a785 fix: invalidate app config cache on local file saves
_save_config() in admin_apps.py now calls invalidate_app_config_cache()
after writing app-config.json, so changes via cabinet API are immediately
visible in guide mode without waiting for TTL expiry.
2026-02-24 05:26:27 +03:00
Fringg 6a50013c21 fix: callback routing safety and cache invalidation order
- Add explicit negative filter for app_ vs app_list_ callback routing
  to prevent fragile registration-order dependency
- Reorder invalidate_app_config_cache to set timestamp to 0 first,
  ensuring fast-path check fails immediately without lock
- Add debug logging to _get_remnawave_config_uuid fallback path
2026-02-24 05:26:02 +03:00
Fringg 1bb939f63a fix: pre-existing bugs found during review
- Fix NameError: texts used before assignment in handle_single_device_reset
  (crash on malformed callback_data)
- HTML-escape subscription_link in all <code> tag interpolations
  (3 locations in devices.py)
2026-02-24 05:24:55 +03:00
Fringg 6feec1eaa8 fix: address security review findings
- Replace format_map with regex-based placeholder substitution to
  prevent format string injection via attribute traversal (CRITICAL)
- Add UUID format validation in select_remna_config handler
- Redact exception details from user-facing callback answers
- HTML-escape current_uuid in admin config menu
- HTML-escape title/description in format_additional_section
2026-02-24 05:19:57 +03:00
Fringg fae6f71def fix: address code review issues in guide mode rework
- Add fallback else branch for subscriptionLink in blocks format
  (prevents silent button drop when deep link resolution fails)
- Extract render_guide_blocks() helper to eliminate duplicated
  block-rendering logic between handle_device_guide and
  handle_specific_app_guide
- Add HTML escaping for admin-controlled config text in guide blocks
- Remove unused get_localized_value import from devices.py
2026-02-24 05:18:25 +03:00
Fringg 5a269b249e feat: rework guide mode with Remnawave API integration
- Add async Remnawave config loader with TTL cache and asyncio.Lock
- Normalize both legacy (steps) and Remnawave (blocks) formats to unified structure
- Build dynamic platform selection keyboard from config instead of hardcoded 6-device layout
- Add colored buttons via Bot API 9.4 (green for connect, blue for download)
- Add admin panel handler for selecting Remnawave subscription page config
- Add cache invalidation from both bot admin and cabinet API
- Fix callback data parsing for app IDs with underscores
- Add Linux platform support across all device mappings
2026-02-24 05:16:18 +03:00
Fringg 0b3b2e5dc5 feat: colored channel subscription buttons via Bot API 9.4 style
- Subscribed channels shown as green (style=success) with checkmark
- Unsubscribed channels shown as blue (style=primary)
- Clicking "I subscribed" now updates keyboard with colored status
  instead of just showing error alert
- Extracted _normalize_channels helper for DRY
2026-02-24 03:58:11 +03:00
Fringg 314c892c4d style: format monitoring_service.py 2026-02-24 03:30:00 +03:00
Fringg 1bc9074c1b fix: translate required channels handler to Russian, add localization keys
- All bot handler strings translated from English to Russian
- Back button now correctly navigates to admin_submenu_settings
- Added ADMIN_SETTINGS_REQUIRED_CHANNELS key to all 5 locales
2026-02-24 03:22:39 +03:00
Fringg 3af07ff627 feat: add required channels button to admin settings submenu in bot 2026-02-24 03:18:21 +03:00
Fringg 2aead9a68b fix: improve deduplication log message wording in monitoring service 2026-02-24 03:16:03 +03:00
Fringg a7db469fd7 fix: remove @username channel ID input, auto-prefix -100 for bare digits
@username resolution via bot.get_chat() was unreliable for subscription
checking. Now only numeric channel IDs are accepted with automatic -100
prefix when entering bare digits (e.g. 1234567890 -> -1001234567890).
2026-02-24 03:06:57 +03:00
Fringg a47ef67090 fix: add missing CHANNEL_CHECK_NOT_SUBSCRIBED localization key
Added to all 5 locales (en, ru, fa, ua, zh) to fix runtime warning
when user clicks subscription check button in middleware.
2026-02-24 03:00:08 +03:00
Fringg 8375d7ecc5 feat: add multi-channel mandatory subscription system
- Multi-channel subscription enforcement via middleware, events, and cabinet API
- 3-layer cache architecture: Redis -> PostgreSQL -> rate-limited Telegram API
- ChatMemberUpdated event-driven tracking with automatic VPN access control
- Admin management via bot FSM handler and REST API with full CRUD
- Channel ID normalization: @username resolved to numeric ID at creation time
- Fail-closed error handling: API errors deny access (security-first)
- Background reconciliation with keyset pagination (100 per batch)
- Per-user rate limiting on subscription check button (5s cooldown)
- Redis connection pooling via cache singleton (no per-request connections)
- Database: channel_id index, multi-row upsert optimization
- Localization: en, ru, zh, fa, ua translations for all new strings
- Frontend blocking UI with channel list and subscription status
- Admin channel management page with toggle, delete, and create
2026-02-24 02:50:31 +03:00
Egor 751e312f28 Merge pull request #2641 from BEDOLAGA-DEV/main
dev
2026-02-23 23:39:09 +03:00
Egor 4eaaf06a17 Merge pull request #2640 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.17.1
2026-02-23 21:33:25 +03:00
github-actions[bot] 1930a9dcde chore(main): release 3.17.1 2026-02-23 18:33:00 +00:00
Egor b876c6dd0b Merge pull request #2639 from BEDOLAGA-DEV/dev
Dev
2026-02-23 21:32:13 +03:00
Fringg d15b69710c style: ruff format 2026-02-23 21:29:54 +03:00
Fringg 708bb9eec7 fix: migrate all remaining naive timestamp columns to timestamptz
Old universal_migration.py created some tables (including email_templates)
with `timestamp` (naive) columns and had a catch-all that converted all
naive columns to `timestamptz` on each startup. After switching to Alembic,
that catch-all stopped running.

Users whose email_templates table was created by universal_migration.py
before the catch-all ran still have naive `timestamp` columns. The code
uses `datetime.now(UTC)` (timezone-aware), causing asyncpg to raise:
  "can't subtract offset-naive and offset-aware datetimes"

Migration 0007 finds and converts ALL remaining naive timestamp columns
in public schema to timestamptz, assuming UTC for existing data.

Fixes: email template save returning 503 with DataError
2026-02-23 21:26:16 +03:00
Fringg 97b3f899d1 fix: add diagnostic logging for device_limit sync to RemnaWave
Users report tariff change doesn't update device count and device
purchase doesn't sync to panel. Added structured logging to trace:
- resolve_hwid_device_limit: forced limit vs subscription limit
- PATCH /api/users: payload hwidDeviceLimit vs response value
2026-02-23 19:45:00 +03:00
Fringg 5ee45f97d1 fix: show negative amounts for withdrawals in admin transaction list
Admin endpoints returned amount_kopeks as always-positive from DB,
causing withdrawals and subscription payments to display as credits
in the admin panel. User-facing balance.py already handled this correctly.
2026-02-23 19:12:51 +03:00
Fringg d4c4a8a211 fix: add missing broadcast_history columns and harden subscription logic
- Add migration 0006 for blocked_count, channel, email_subject,
  email_html_content columns missing from broadcast_history table
- Fix infinite trial reactivation loop in monitoring service
- Prevent webhook from overwriting freshly extended end_date
- Use tariff-specific pricing for auto-renewal instead of global config
2026-02-23 19:07:59 +03:00
Fringg 205c8d987d fix: use aiogram 3.x bot.download() instead of document.download() 2026-02-23 18:31:31 +03:00
Fringg ebe508302b fix: uploaded backup restore button not triggering handler
Callback data prefix was 'backup_restore_uploaded_' but the handler
listens for 'backup_restore_execute_' and 'backup_restore_clear_'.
2026-02-23 18:29:10 +03:00
Fringg c20355b06d fix: repair missing DB columns and make backup resilient to schema mismatches
- Add migration 0005 to re-apply missing columns from 0002-0004
  (fixes DBs that were auto-stamped to head without running migrations)
- Add per-table error handling in backup ORM export so one table
  failure doesn't break the entire backup
- Escape HTML in error notifications to prevent Telegram parse errors
2026-02-23 18:22:32 +03:00
Fringg 50a931ec36 fix: add int32 overflow guards and strengthen auth validation
- Add le= bounds to all user-facing Pydantic int fields (balance, subscription, traffic, devices)
- Add self-referral guard in process_referral_registration
- Add Telegram identity cross-validation to get_optional_cabinet_user
- Log when initData validation fails but header is present
2026-02-23 18:12:58 +03:00
Fringg 115c0c84c0 fix: prevent partner self-referral via own campaign link
When a partner clicks their own campaign link (any bonus_type), they get
attributed as their own referral — their purchases counted as campaign
revenue and they earn referral commissions on their own payments.

Add self-referral guards in three layers:
- auth.py: early return in _process_campaign_bonus if user is campaign partner
- campaign_service.py: defense-in-depth check in apply_campaign_bonus
- start.py: guards on all referrer_id assignments and process_referral calls
2026-02-23 18:02:25 +03:00
Fringg 973b3d3d3f fix: cross-validate Telegram identity on every authenticated request
Telegram Mini App WebView shares localStorage across accounts on the
same device. This allows refresh tokens from user A to be reused by
user B if they open the same Mini App.

Add server-side defense: read X-Telegram-Init-Data header (already sent
by the frontend), validate it cryptographically, and reject requests
where the Telegram user ID doesn't match the JWT user's telegram_id.
2026-02-23 17:53:44 +03:00
Fringg 2ef6185715 fix: cap expected_monthly_referrals to prevent int32 overflow
Add le=2_000_000_000 constraint to Pydantic schema so PostgreSQL Integer
column doesn't receive values outside int32 range.
2026-02-23 17:27:33 +03:00
Fringg ed4624c664 fix: handle RemnaWave API errors in traffic aggregation
Catch exceptions from get_all_nodes() in _aggregate_traffic() to prevent
unhandled ASGI errors when RemnaWave returns HTTP 502. Cache empty result
on failure to avoid request storms from parallel frontend calls.
2026-02-23 17:25:01 +03:00
Fringg 1b6bbc7131 fix: protect active paid subscriptions from being disabled in RemnaWave
Add is_active_paid_subscription() helper that checks if subscription is
non-trial, active, and not expired. Use it across all disable_remnawave_user
call sites to prevent disabling VPN access for users with paid subscriptions.

Protected paths: block_user, delete_user_account, broadcast cleanup,
channel unsubscribe, admin deactivation, webapi endpoints, cabinet
reset-trial, reset-subscription, and disable-user endpoints.
2026-02-23 16:49:31 +03:00
Fringg 1f4430f3af fix: suppress web page preview when logo mode is disabled
When ENABLE_LOGO_MODE is on, messages are sent as photos which
naturally don't show URL previews. When off, messages are sent as
text but disable_web_page_preview was never set, causing link
previews in menu, welcome, and other messages.

Always patch Message.answer/edit_text and inject
disable_web_page_preview=True for all text message paths.
2026-02-23 15:55:53 +03:00
Fringg 67f3547ae2 fix: allow tariff switch when less than 1 day remains
Check subscription.end_date <= now instead of remaining_days == 0 to
allow switching when hours remain. The .days property truncates to whole
days, blocking users with a few hours left from switching tariffs.
2026-02-23 15:49:08 +03:00
Egor 49f64cacd7 Merge pull request #2634 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.17.0
2026-02-19 02:14:24 +03:00
github-actions[bot] 9101c98244 chore(main): release 3.17.0 2026-02-18 23:14:04 +00:00
Egor 311f278123 Merge pull request #2633 from BEDOLAGA-DEV/dev
Dev
2026-02-19 02:13:31 +03:00
Fringg 493f315a65 fix: skip blocked users in trial notifications and broadcasts without DB status change
- Add User.status filter to trial notification SQL queries
- Add pre-send blocked/deleted user check in _send_message_with_logo
- Fix UserStatus import shadowing (alias RemnaWaveUserStatus)
- Remove broadcast cleanup that marked users as BLOCKED in DB
- Remove dead _background_tasks variable
2026-02-19 02:08:39 +03:00
Fringg 18c2477173 feat: add referral code tracking to all cabinet auth methods + email_templates migration
Referral links from cabinet (?ref=CODE) were only tracked for email registration.
Now referral_code is accepted and processed in Telegram initData, Telegram Widget,
and OAuth authentication endpoints. Includes self-referral protection by email
for OAuth, proper error logging, and the missing email_templates table migration.
2026-02-18 23:59:29 +03:00
Fringg 6e28a1a22b fix: prevent 'caption is too long' error in logo mode
Telegram limits photo captions to 1024 characters. When menu_text or
rules_text exceeds 900 chars (with promo hints, random messages etc),
bot.send_photo fails with TelegramBadRequest.

Added len() check before each of 3 send_photo calls in
required_sub_channel_check — falls back to send_message when text
is too long, consistent with _answer_with_photo in message_patch.py.
2026-02-18 18:26:26 +03:00
84 changed files with 3584 additions and 2714 deletions
+1 -5
View File
@@ -116,10 +116,8 @@ BLACKLIST_UPDATE_INTERVAL_HOURS=24 # Интервал обновле
BLACKLIST_IGNORE_ADMINS=true # Игнорировать администраторов (из ADMIN_IDS) при проверке черного списка
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланса (в копейках) для фильтра «готовы к продлению»
# Обязательная подписка на канал
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
# Channel subscription settings (channels are managed via admin panel)
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
CHANNEL_LINK= # Опционально ссылка на канал
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
CHANNEL_REQUIRED_FOR_ALL=false # Требовать подписку на канал для ВСЕХ пользователей (платных и триальных)
@@ -805,8 +803,6 @@ PRICE_ROUNDING_ENABLED=true
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Конфигурация приложений для гайда подключения
APP_CONFIG_PATH=app-config.json
ENABLE_DEEP_LINKS=true
APP_CONFIG_CACHE_TTL=3600
-1
View File
@@ -16,7 +16,6 @@
!uv.lock
!requirements.txt
!alembic.ini
!app-config.json
!release-please-config.json
!.release-please-manifest.json
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.16.3"
".": "3.18.0"
}
+66
View File
@@ -1,5 +1,71 @@
# Changelog
## [3.18.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.17.1...v3.18.0) (2026-02-24)
### New Features
* add ChatTypeFilterMiddleware to ignore group/forum messages ([25f014f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25f014fd8988b5513fba8fec4483981384687e96))
* add multi-channel mandatory subscription system ([8375d7e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8375d7ecc5e54ea935a00175dd26f667eab95346))
* add required channels button to admin settings submenu in bot ([3af07ff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3af07ff627fc354da4f8c41b0bd0575dddd9afa5))
* colored channel subscription buttons via Bot API 9.4 style ([0b3b2e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b3b2e5dc54d8b6b3ede883d5c0f5b91791b7b9b))
* rework guide mode with Remnawave API integration ([5a269b2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5a269b249e8e6cad266822095676937481613f5f))
### Bug Fixes
* add missing CHANNEL_CHECK_NOT_SUBSCRIBED localization key ([a47ef67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a47ef67090c4e48f466286f7c676eeee0c61a4fb))
* address code review issues in guide mode rework ([fae6f71](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fae6f71def421e319733e4edcf1ca80a2831b2ec))
* address security review findings ([6feec1e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6feec1eaa847644ba3402763a2ffefd8f770cc01))
* callback routing safety and cache invalidation order ([6a50013](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a50013c21de199df0ba0dab3600b693548b6c1e))
* correct broadcast button deep-links for cabinet mode ([e5fa45f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e5fa45f74f969b84f9f1388f8d4888d22c46d7e8))
* HTML-escape all externally-sourced text in guide messages ([711ec34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/711ec344c646844401f355695a7e8c0d4fb401ee))
* improve deduplication log message wording in monitoring service ([2aead9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2aead9a68b6bf274c8d1497c85f2ed4d4fc9c70b))
* invalidate app config cache on local file saves ([978726a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/978726a7856cf56257c49491afe569fa8c395eac))
* pre-existing bugs found during review ([1bb939f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1bb939f63a360a687fafba26bc363024df0f6be0))
* remove [@username](https://github.com/username) channel ID input, auto-prefix -100 for bare digits ([a7db469](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7db469fd7603e7d8dac3076f5d633da654a3a57))
* restore RemnaWave config management endpoints ([6f473de](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6f473defef32a6d81cee55ef2cd397d536a784a7))
* translate required channels handler to Russian, add localization keys ([1bc9074](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1bc9074c1bcdaba7215065c77aac9dd51db4d7c8))
### Refactoring
* remove legacy app-config.json system ([295d2e8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/295d2e877e43f48e9319ba0b01be959904637000))
## [3.17.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.17.0...v3.17.1) (2026-02-23)
### Bug Fixes
* add diagnostic logging for device_limit sync to RemnaWave ([97b3f89](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97b3f899d12c4bf32b6229a3b595f1b9ad611096))
* add int32 overflow guards and strengthen auth validation ([50a931e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/50a931ec363d1842126b90098f93c6cae47a9fac))
* add missing broadcast_history columns and harden subscription logic ([d4c4a8a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d4c4a8a211eaf836024f8d9dcb725f25f514f05e))
* allow tariff switch when less than 1 day remains ([67f3547](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/67f3547ae2f40153229d71c1abe7e1213466e5c3))
* cap expected_monthly_referrals to prevent int32 overflow ([2ef6185](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2ef618571570edb6011a365af8aa9cd7e3348c2e))
* cross-validate Telegram identity on every authenticated request ([973b3d3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/973b3d3d3ff80376c0fd19c531d7aac3ae751df8))
* handle RemnaWave API errors in traffic aggregation ([ed4624c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed4624c6649bdbc04bc850ef63e5c86e26a37ce4))
* migrate all remaining naive timestamp columns to timestamptz ([708bb9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/708bb9eec7ea4360b26709fb2a3f82dd139ed600))
* prevent partner self-referral via own campaign link ([115c0c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/115c0c84c0698591da75d7d3b8fbd8e0fc8541ea))
* protect active paid subscriptions from being disabled in RemnaWave ([1b6bbc7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b6bbc7131341b4afd739e4195f02aa956ead616))
* repair missing DB columns and make backup resilient to schema mismatches ([c20355b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c20355b06df13328f85cc5a6045b3e490419a30a))
* show negative amounts for withdrawals in admin transaction list ([5ee45f9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ee45f97d179ce2d32b3f19eeb6fd01989a30ca7))
* suppress web page preview when logo mode is disabled ([1f4430f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f4430f3af8f3efcc58ef7b562904adcb1640a44))
* uploaded backup restore button not triggering handler ([ebe5083](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebe508302b906f8b56cb230b934fb8566990c684))
* use aiogram 3.x bot.download() instead of document.download() ([205c8d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/205c8d987d93151a17aa0793cb51bd99917aea97))
## [3.17.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.3...v3.17.0) (2026-02-18)
### New Features
* add referral code tracking to all cabinet auth methods + email_templates migration ([18c2477](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/18c24771737994f3ae1f832435ed2247ca625aab))
### Bug Fixes
* prevent 'caption is too long' error in logo mode ([6e28a1a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6e28a1a22b02055b357051dfecbee7fefbebc774))
* skip blocked users in trial notifications and broadcasts without DB status change ([493f315](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/493f315a65610826a04e04c3d2065e0b395426ed))
## [3.16.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.2...v3.16.3) (2026-02-18)
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v3.16.3" # x-release-please-version
ARG VERSION="v3.18.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
-658
View File
@@ -1,658 +0,0 @@
{
"config": {
"additionalLocales": [
"ru",
"zh",
"fa"
],
"branding": {
"name": "Subscription",
"logoUrl": "https://raw.githubusercontent.com/Fr1ngg/remnawave-bedolaga-telegram-bot/bf0c1ce711a26fa2f24559e7e4443820e68d758b/assets/bedolaga_app3.svg",
"supportUrl": "https://t.me"
}
},
"platforms": {
"ios": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
"buttonText": {
"en": "Open in App Store [EU]",
"fa": "باز کردن در App Store [EU]",
"ru": "Открыть в App Store [EU]",
"zh": "在 App Store 中打开 [EU]"
}
},
{
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
"buttonText": {
"en": "Open in App Store [RU]",
"fa": "باز کردن در App Store [RU]",
"ru": "Открыть в App Store [RU]",
"zh": "在 App Store 中打开 [RU]"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
},
{
"id": "streisand",
"name": "Streisand",
"isFeatured": false,
"urlScheme": "streisand://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/ru/app/streisand/id6450534064",
"buttonText": {
"en": "Open in App Store",
"fa": "باز کردن در App Store",
"ru": "Открыть в App Store",
"zh": "在 App Store 中打开"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
},
{
"id": "shadowrocket",
"name": "Shadowrocket",
"isFeatured": false,
"urlScheme": "sub://",
"isNeedBase64Encoding": true,
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/ru/app/shadowrocket/id932747118",
"buttonText": {
"en": "Open in App Store",
"fa": "باز کردن در App Store",
"ru": "Открыть в App Store",
"zh": "在 App Store 中打开"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"android": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
"buttonText": {
"en": "Open in Google Play",
"fa": "باز کردن در Google Play",
"ru": "Открыть в Google Play",
"zh": "在 Google Play 中打开"
}
},
{
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
"buttonText": {
"en": "Download APK",
"fa": "دانلود APK",
"ru": "Скачать APK",
"zh": "下载 APK"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"fa": "صفحه را در Google Play باز کنید و برنامه را نصب کنید. یا برنامه را مستقیماً از فایل APK نصب کنید، اگر Google Play کار نمی کند.",
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает.",
"zh": "在 Google Play 中打开页面并安装应用。如果 Google Play 无法使用,也可以直接从 APK 文件安装应用。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"fa": "برنامه را باز کنید و به سرور متصل شوید",
"ru": "Откройте приложение и подключитесь к серверу",
"zh": "打开应用并连接到服务器"
}
}
},
{
"id": "clash-meta",
"name": "Clash Meta",
"isFeatured": false,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/MetaCubeX/ClashMetaForAndroid/releases/download/v2.11.7/cmfa-2.11.7-meta-universal-release.apk",
"buttonText": {
"en": "Download APK",
"fa": "دانلود APK",
"ru": "Скачать APK",
"zh": "下载 APK"
}
},
{
"buttonLink": "https://f-droid.org/packages/com.github.metacubex.clash.meta/",
"buttonText": {
"en": "Open in F-Droid",
"fa": "در F-Droid باز کنید",
"ru": "Открыть в F-Droid",
"zh": "在 F-Droid 中打开"
}
}
],
"description": {
"en": "Download and install Clash Meta APK",
"fa": "دانلود و نصب Clash Meta APK",
"ru": "Скачайте и установите Clash Meta APK",
"zh": "下载并安装 Clash Meta APK"
}
},
"addSubscriptionStep": {
"description": {
"en": "Tap the button to import configuration",
"fa": "برای وارد کردن پیکربندی روی دکمه ضربه بزنید",
"ru": "Нажмите кнопку, чтобы импортировать конфигурацию",
"zh": "点击按钮导入配置"
}
},
"connectAndUseStep": {
"description": {
"en": "Open Clash Meta and tap on Connect",
"fa": "Clash Meta را باز کنید و روی اتصال ضربه بزنید",
"ru": "Откройте Clash Meta и нажмите Подключиться",
"zh": "打开 Clash Meta 并点击连接"
}
}
}
],
"macos": [
{
"id": "clash-verge",
"name": "Clash Verge",
"isFeatured": true,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
"buttonText": {
"en": "macOS (Intel)",
"fa": "مک (اینتل)",
"ru": "macOS (Intel)",
"zh": "macOS (Intel)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
"buttonText": {
"en": "macOS (Apple Silicon)",
"fa": "مک (Apple Silicon)",
"ru": "macOS (Apple Silicon)",
"zh": "macOS (Apple Silicon)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
}
},
"additionalBeforeAddSubscriptionStep": {
"buttons": [],
"description": {
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
"fa": "پس از راه‌اندازی برنامه، می‌توانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
},
"title": {
"en": "Change language",
"fa": "تغییر زبان",
"ru": "Смена языка",
"zh": "更改语言"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"additionalAfterAddSubscriptionStep": {
"buttons": [],
"title": {
"en": "If the subscription is not added",
"fa": "اگر اشتراک در برنامه نصب نشده است",
"ru": "Если подписка не добавилась",
"zh": "如果订阅未添加"
},
"description": {
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایل‌ها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
"fa": "می‌توانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
"ru": "Выбрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
}
}
},
{
"id": "hiddify",
"name": "Hiddify",
"isFeatured": false,
"urlScheme": "hiddify://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
"buttonText": {
"en": "macOS",
"fa": "مک",
"ru": "macOS",
"zh": "macOS"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"windows": [
{
"id": "clash-verge",
"name": "Clash Verge",
"isFeatured": true,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
"buttonText": {
"en": "macOS (Intel)",
"fa": "مک (اینتل)",
"ru": "macOS (Intel)",
"zh": "macOS (Intel)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
"buttonText": {
"en": "macOS (Apple Silicon)",
"fa": "مک (Apple Silicon)",
"ru": "macOS (Apple Silicon)",
"zh": "macOS (Apple Silicon)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
}
},
"additionalBeforeAddSubscriptionStep": {
"buttons": [],
"description": {
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
"fa": "پس از راه‌اندازی برنامه، می‌توانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
},
"title": {
"en": "Change language",
"fa": "تغییر زبان",
"ru": "Смена языка",
"zh": "更改语言"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"additionalAfterAddSubscriptionStep": {
"buttons": [],
"title": {
"en": "If the subscription is not added",
"fa": "اگر اشتراک در برنامه نصب نشده است",
"ru": "Если подписка не добавилась",
"zh": "如果订阅未添加"
},
"description": {
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایل‌ها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
"fa": "می‌توانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
"ru": "Выبрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
}
}
},
{
"id": "hiddify",
"name": "Hiddify",
"isFeatured": false,
"urlScheme": "hiddify://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
"buttonText": {
"en": "macOS",
"fa": "مک",
"ru": "macOS",
"zh": "macOS"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"linux": [],
"androidTV": [
{
"id": "new-app-androidtv-1760203310792",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Button TextGoogle Play",
"zh": "Button Text",
"fa": "Button Text"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение",
"zh": "-",
"fa": "-"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
"zh": "-",
"fa": "-"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
"zh": "-",
"fa": "-"
}
}
}
],
"appleTV": [
{
"id": "new-app-appletv-1760203488851",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Google Play",
"zh": "Button Text",
"fa": "Button Text"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение",
"zh": "-",
"fa": "-"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
"zh": "-",
"fa": "-"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
"zh": "-",
"fa": "-"
}
}
}
]
}
}
+12 -8
View File
@@ -45,6 +45,7 @@ from app.handlers.admin import (
referrals as admin_referrals,
remnawave as admin_remnawave,
reports as admin_reports,
required_channels as admin_required_channels,
rules as admin_rules,
servers as admin_servers,
statistics as admin_statistics,
@@ -58,10 +59,12 @@ from app.handlers.admin import (
users as admin_users,
welcome_text as admin_welcome_text,
)
from app.handlers.channel_member import register_handlers as register_channel_member_handlers
from app.handlers.stars_payments import register_stars_handlers
from app.middlewares.auth import AuthMiddleware
from app.middlewares.blacklist import BlacklistMiddleware
from app.middlewares.button_stats import ButtonStatsMiddleware
from app.middlewares.chat_type_filter import ChatTypeFilterMiddleware
from app.middlewares.context_binding import ContextVarsMiddleware
from app.middlewares.global_error import GlobalErrorMiddleware
from app.middlewares.logging import LoggingMiddleware
@@ -115,6 +118,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.message.middleware(ContextVarsMiddleware())
dp.callback_query.middleware(ContextVarsMiddleware())
dp.pre_checkout_query.middleware(ContextVarsMiddleware())
chat_type_filter = ChatTypeFilterMiddleware()
dp.message.middleware(chat_type_filter)
dp.callback_query.middleware(chat_type_filter)
dp.message.middleware(GlobalErrorMiddleware())
dp.callback_query.middleware(GlobalErrorMiddleware())
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
@@ -135,15 +141,11 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.callback_query.middleware(button_stats_middleware)
logger.info('📊 ButtonStatsMiddleware активирован')
if settings.CHANNEL_IS_REQUIRED_SUB:
from app.middlewares.channel_checker import ChannelCheckerMiddleware
from app.middlewares.channel_checker import ChannelCheckerMiddleware
channel_checker_middleware = ChannelCheckerMiddleware()
dp.message.middleware(channel_checker_middleware)
dp.callback_query.middleware(channel_checker_middleware)
logger.info('🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован')
else:
logger.info('🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован')
channel_checker = ChannelCheckerMiddleware()
dp.message.middleware(channel_checker)
dp.callback_query.middleware(channel_checker)
dp.message.middleware(AuthMiddleware())
dp.callback_query.middleware(AuthMiddleware())
dp.pre_checkout_query.middleware(AuthMiddleware())
@@ -194,6 +196,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_bulk_ban.register_bulk_ban_handlers(dp)
admin_blacklist.register_blacklist_handlers(dp)
admin_blocked_users.register_handlers(dp)
admin_required_channels.register_handlers(dp)
register_channel_member_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_contests.register_handlers(dp)
+64 -44
View File
@@ -1,10 +1,7 @@
"""FastAPI dependencies for cabinet module."""
import asyncio
import structlog
from aiogram import Bot
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,23 +13,13 @@ from app.services.blacklist_service import blacklist_service
from app.services.maintenance_service import maintenance_service
from .auth.jwt_handler import get_token_payload
from .auth.telegram_auth import validate_telegram_init_data
logger = structlog.get_logger(__name__)
security = HTTPBearer(auto_error=False)
# Кешированный Bot для проверки подписки на канал
_channel_check_bot: Bot | None = None
def _get_channel_check_bot() -> Bot:
"""Получить или создать Bot для проверки подписки на канал."""
global _channel_check_bot
if _channel_check_bot is None:
_channel_check_bot = Bot(token=settings.BOT_TOKEN)
return _channel_check_bot
async def get_cabinet_db() -> AsyncSession:
"""Get database session for cabinet operations."""
@@ -44,6 +31,7 @@ async def get_cabinet_db() -> AsyncSession:
async def get_current_cabinet_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> User:
@@ -51,6 +39,7 @@ async def get_current_cabinet_user(
Get current authenticated cabinet user from JWT token.
Args:
request: FastAPI request object (for reading X-Telegram-Init-Data header)
credentials: HTTP Bearer credentials
db: Database session
@@ -105,6 +94,34 @@ async def get_current_cabinet_user(
detail='User account is not active',
)
# Defense in depth: cross-validate Telegram identity.
# The frontend sends X-Telegram-Init-Data on every request.
# If the header is present and cryptographically valid, verify that
# the Telegram user ID matches the JWT user's telegram_id.
# This prevents cross-account token reuse when Telegram WebView
# shares localStorage across accounts on the same device.
init_data_raw = request.headers.get('X-Telegram-Init-Data')
if init_data_raw and user.telegram_id is not None:
# Use generous max_age: Telegram Desktop caches initData
tg_user = validate_telegram_init_data(init_data_raw, max_age_seconds=86400 * 30)
if tg_user is None:
logger.warning(
'Telegram initData validation failed but header was present',
jwt_user_id=user.id,
)
elif tg_user.get('id') != user.telegram_id:
logger.warning(
'Telegram identity mismatch: JWT belongs to different user than current Telegram account',
jwt_user_id=user.id,
jwt_telegram_id=user.telegram_id,
init_data_telegram_id=tg_user.get('id'),
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Session belongs to a different Telegram account. Please restart the app.',
headers={'WWW-Authenticate': 'Bearer'},
)
# Check blacklist
if user.telegram_id is not None:
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.telegram_id, user.username)
@@ -132,47 +149,37 @@ async def get_current_cabinet_user(
},
)
# Check required channel subscription - ТОЛЬКО для Telegram юзеров
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
# Пропускаем проверку для email-only юзеров (нет telegram_id)
# Check required channel subscription - Telegram users only
if settings.CHANNEL_IS_REQUIRED_SUB:
# Skip for email-only users (no telegram_id)
if user.telegram_id is not None:
# Проверяем админа по telegram_id ИЛИ email
# Skip admin check
is_admin = settings.is_admin(
telegram_id=user.telegram_id, email=user.email if user.email_verified else None
)
if not is_admin:
try:
bot = _get_channel_check_bot()
chat_member = await asyncio.wait_for(
bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=user.telegram_id),
timeout=10.0,
)
# Не закрываем сессию - бот переиспользуется
from app.services.channel_subscription_service import channel_subscription_service
if chat_member.status not in ['member', 'administrator', 'creator']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'channel_subscription_required',
'message': 'Please subscribe to our channel to continue',
'channel_link': settings.CHANNEL_LINK,
},
)
except HTTPException:
raise
except TimeoutError:
logger.warning('Timeout checking channel subscription for user', telegram_id=user.telegram_id)
# Don't block user if check times out
except Exception as e:
logger.warning(
'Failed to check channel subscription for user', telegram_id=user.telegram_id, error=e
channels_with_status = await channel_subscription_service.get_channels_with_status(user.telegram_id)
is_subscribed = (
all(ch['is_subscribed'] for ch in channels_with_status) if channels_with_status else True
)
if not is_subscribed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'channel_subscription_required',
'message': 'Please subscribe to the required channels to continue',
'channels': channels_with_status,
},
)
# Don't block user if check fails
return user
async def get_optional_cabinet_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> User | None:
@@ -200,6 +207,19 @@ async def get_optional_cabinet_user(
if not user or user.status != 'active':
return None
# Cross-validate Telegram identity (same as get_current_cabinet_user)
init_data_raw = request.headers.get('X-Telegram-Init-Data')
if init_data_raw and user.telegram_id is not None:
tg_user = validate_telegram_init_data(init_data_raw, max_age_seconds=86400 * 30)
if tg_user and tg_user.get('id') != user.telegram_id:
logger.warning(
'Telegram identity mismatch in optional auth',
jwt_user_id=user.id,
jwt_telegram_id=user.telegram_id,
init_data_telegram_id=tg_user.get('id'),
)
return None
return user
+3 -1
View File
@@ -7,6 +7,7 @@ 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_channels import router as admin_channels_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_partners import router as admin_partners_router
from .admin_payment_methods import router as admin_payment_methods_router
@@ -79,7 +80,6 @@ router.include_router(wheel_router)
router.include_router(admin_ticket_notifications_router)
router.include_router(admin_tickets_router)
router.include_router(admin_settings_router)
router.include_router(admin_apps_router)
router.include_router(admin_wheel_router)
router.include_router(admin_tariffs_router)
router.include_router(admin_servers_router)
@@ -101,6 +101,8 @@ 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)
router.include_router(admin_channels_router)
router.include_router(admin_apps_router)
# WebSocket route
router.include_router(websocket_router)
+25 -448
View File
@@ -1,7 +1,6 @@
"""Admin routes for managing VPN applications in app-config.json."""
"""Admin routes for managing RemnaWave app configuration."""
import json
from pathlib import Path
import re
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
@@ -24,431 +23,6 @@ router = APIRouter(prefix='/admin/apps', tags=['Cabinet Admin Apps'])
# ============ Schemas ============
class LocalizedText(BaseModel):
"""Localized text for multiple languages."""
en: str = ''
ru: str = ''
zh: str | None = ''
fa: str | None = ''
class AppButton(BaseModel):
"""Button with link and localized text."""
buttonLink: str
buttonText: LocalizedText
class AppStep(BaseModel):
"""Step with description and optional buttons/title."""
description: LocalizedText
buttons: list[AppButton] | None = None
title: LocalizedText | None = None
class AppDefinition(BaseModel):
"""VPN application definition."""
id: str
name: str
isFeatured: bool = False
urlScheme: str
isNeedBase64Encoding: bool | None = None
installationStep: AppStep
addSubscriptionStep: AppStep
connectAndUseStep: AppStep
additionalBeforeAddSubscriptionStep: AppStep | None = None
additionalAfterAddSubscriptionStep: AppStep | None = None
class PlatformApps(BaseModel):
"""Apps for a specific platform."""
platform: str
apps: list[AppDefinition]
class AppConfigBranding(BaseModel):
"""Branding configuration."""
name: str
logoUrl: str
supportUrl: str
class AppConfigConfig(BaseModel):
"""Top-level config section."""
additionalLocales: list[str]
branding: AppConfigBranding
class AppConfigResponse(BaseModel):
"""Full app config response."""
config: AppConfigConfig
platforms: dict[str, list[AppDefinition]]
class CreateAppRequest(BaseModel):
"""Request to create a new app."""
platform: str
app: AppDefinition
class UpdateAppRequest(BaseModel):
"""Request to update an app."""
app: AppDefinition
class ReorderAppsRequest(BaseModel):
"""Request to reorder apps in a platform."""
app_ids: list[str]
class UpdateBrandingRequest(BaseModel):
"""Request to update branding."""
branding: AppConfigBranding
# ============ Helpers ============
def _get_config_path() -> Path:
"""Get path to app-config.json."""
return Path(settings.get_app_config_path())
def _load_config() -> dict:
"""Load app config from file."""
config_path = _get_config_path()
if not config_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'App config file not found: {config_path}',
)
try:
with open(config_path, encoding='utf-8') as f:
return json.load(f)
except json.JSONDecodeError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to parse app config: {e}',
)
def _save_config(config: dict) -> None:
"""Save app config to file."""
config_path = _get_config_path()
try:
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=2, ensure_ascii=False)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to save app config: {e}',
)
VALID_PLATFORMS = ['ios', 'android', 'macos', 'windows', 'linux', 'androidTV', 'appleTV']
# ============ Routes ============
@router.get('', response_model=AppConfigResponse)
async def get_app_config(
admin: User = Depends(get_current_admin_user),
):
"""Get full app configuration."""
config = _load_config()
return config
@router.get('/platforms', response_model=list[str])
async def get_platforms(
admin: User = Depends(get_current_admin_user),
):
"""Get list of available platforms."""
return VALID_PLATFORMS
@router.get('/platforms/{platform}', response_model=list[AppDefinition])
async def get_platform_apps(
platform: str,
admin: User = Depends(get_current_admin_user),
):
"""Get apps for a specific platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}. Valid platforms: {VALID_PLATFORMS}',
)
config = _load_config()
platforms = config.get('platforms', {})
return platforms.get(platform, [])
@router.post('/platforms/{platform}', response_model=AppDefinition)
async def create_app(
platform: str,
request: CreateAppRequest,
admin: User = Depends(get_current_admin_user),
):
"""Create a new app for a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
if platform not in platforms:
platforms[platform] = []
# Check if app with same ID already exists
existing_ids = [app.get('id') for app in platforms[platform]]
if request.app.id in existing_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"App with ID '{request.app.id}' already exists in {platform}",
)
# Add new app
app_dict = request.app.model_dump(exclude_none=True)
platforms[platform].append(app_dict)
config['platforms'] = platforms
_save_config(config)
logger.info('Admin created app for platform', admin_id=admin.id, app_id=request.app.id, platform=platform)
return request.app
@router.put('/platforms/{platform}/{app_id}', response_model=AppDefinition)
async def update_app(
platform: str,
app_id: str,
request: UpdateAppRequest,
admin: User = Depends(get_current_admin_user),
):
"""Update an existing app."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Find and update app
app_index = None
for i, app in enumerate(apps):
if app.get('id') == app_id:
app_index = i
break
if app_index is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Update app
app_dict = request.app.model_dump(exclude_none=True)
apps[app_index] = app_dict
platforms[platform] = apps
config['platforms'] = platforms
_save_config(config)
logger.info('Admin updated app in platform', admin_id=admin.id, app_id=app_id, platform=platform)
return request.app
@router.delete('/platforms/{platform}/{app_id}')
async def delete_app(
platform: str,
app_id: str,
admin: User = Depends(get_current_admin_user),
):
"""Delete an app from a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Find and remove app
original_length = len(apps)
apps = [app for app in apps if app.get('id') != app_id]
if len(apps) == original_length:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
platforms[platform] = apps
config['platforms'] = platforms
_save_config(config)
logger.info('Admin deleted app from platform', admin_id=admin.id, app_id=app_id, platform=platform)
return {'status': 'deleted', 'app_id': app_id}
@router.post('/platforms/{platform}/reorder')
async def reorder_apps(
platform: str,
request: ReorderAppsRequest,
admin: User = Depends(get_current_admin_user),
):
"""Reorder apps in a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Create a map of apps by ID
apps_map = {app.get('id'): app for app in apps}
# Verify all IDs exist
for app_id in request.app_ids:
if app_id not in apps_map:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Reorder apps
reordered_apps = [apps_map[app_id] for app_id in request.app_ids]
# Add any apps that weren't in the reorder list (shouldn't happen but just in case)
for app in apps:
if app.get('id') not in request.app_ids:
reordered_apps.append(app)
platforms[platform] = reordered_apps
config['platforms'] = platforms
_save_config(config)
logger.info('Admin reordered apps in platform', admin_id=admin.id, platform=platform)
return {'status': 'reordered', 'order': request.app_ids}
@router.put('/branding', response_model=AppConfigBranding)
async def update_branding(
request: UpdateBrandingRequest,
admin: User = Depends(get_current_admin_user),
):
"""Update branding configuration."""
config = _load_config()
if 'config' not in config:
config['config'] = {}
config['config']['branding'] = request.branding.model_dump()
_save_config(config)
logger.info('Admin updated branding', admin_id=admin.id)
return request.branding
@router.get('/branding', response_model=AppConfigBranding)
async def get_branding(
admin: User = Depends(get_current_admin_user),
):
"""Get branding configuration."""
config = _load_config()
branding = config.get('config', {}).get('branding', {})
return branding
@router.post('/platforms/{platform}/copy/{app_id}')
async def copy_app_to_platform(
platform: str,
app_id: str,
target_platform: str,
admin: User = Depends(get_current_admin_user),
):
"""Copy an app from one platform to another."""
if platform not in VALID_PLATFORMS or target_platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid platform(s)',
)
config = _load_config()
platforms = config.get('platforms', {})
source_apps = platforms.get(platform, [])
# Find source app
source_app = None
for app in source_apps:
if app.get('id') == app_id:
source_app = app.copy()
break
if not source_app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Generate new ID for copied app
import time
new_id = f'{app_id}-copy-{int(time.time())}'
source_app['id'] = new_id
# Add to target platform
if target_platform not in platforms:
platforms[target_platform] = []
platforms[target_platform].append(source_app)
config['platforms'] = platforms
_save_config(config)
logger.info(
'Admin copied app from to as',
admin_id=admin.id,
app_id=app_id,
platform=platform,
target_platform=target_platform,
new_id=new_id,
)
return {'status': 'copied', 'new_id': new_id, 'target_platform': target_platform}
# ============ RemnaWave Config Routes ============
class RemnaWaveConfigStatus(BaseModel):
"""Status of RemnaWave config integration."""
@@ -462,6 +36,11 @@ class UpdateRemnaWaveUuidRequest(BaseModel):
uuid: str | None = None
# ============ Helpers ============
_UUID_PATTERN = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
def _get_remnawave_config_uuid() -> str | None:
"""Get RemnaWave config UUID from system settings or env."""
try:
@@ -470,6 +49,9 @@ def _get_remnawave_config_uuid() -> str | None:
return settings.CABINET_REMNA_SUB_CONFIG
# ============ Routes ============
@router.get('/remnawave/status', response_model=RemnaWaveConfigStatus)
async def get_remnawave_config_status(
admin: User = Depends(get_current_admin_user),
@@ -491,21 +73,20 @@ async def set_remnawave_config_uuid(
"""Set RemnaWave subscription config UUID."""
uuid_value = request.uuid.strip() if request.uuid else None
# Validate UUID format if provided
if uuid_value:
import re
uuid_pattern = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
if not uuid_pattern.match(uuid_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid UUID format',
)
if uuid_value and not _UUID_PATTERN.match(uuid_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid UUID format',
)
try:
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid_value)
await db.commit()
logger.info('Admin updated CABINET_REMNA_SUB_CONFIG to', admin_id=admin.id, uuid_value=uuid_value)
from app.handlers.subscription.common import invalidate_app_config_cache
invalidate_app_config_cache()
logger.info('Admin updated CABINET_REMNA_SUB_CONFIG', admin_id=admin.id, uuid_value=uuid_value)
except Exception as e:
logger.error('Error saving RemnaWave config UUID', error=e)
raise HTTPException(
@@ -523,15 +104,12 @@ async def set_remnawave_config_uuid(
async def get_remnawave_subscription_config(
admin: User = Depends(get_current_admin_user),
):
"""
Fetch subscription page config from RemnaWave panel.
Uses CABINET_REMNA_SUB_CONFIG setting for the config UUID.
"""
"""Fetch subscription page config from RemnaWave panel."""
config_uuid = _get_remnawave_config_uuid()
if not config_uuid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='CABINET_REMNA_SUB_CONFIG is not configured',
detail='RemnaWave subscription config is not configured',
)
try:
@@ -541,10 +119,9 @@ async def get_remnawave_subscription_config(
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Subscription config '{config_uuid}' not found in RemnaWave",
detail='Subscription config not found',
)
# Return the raw config data from RemnaWave
return {
'uuid': config.uuid,
'name': config.name,
@@ -557,7 +134,7 @@ async def get_remnawave_subscription_config(
logger.error('Error fetching RemnaWave config', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to fetch config from RemnaWave: {e!s}',
detail='Failed to fetch config from RemnaWave',
)
@@ -582,5 +159,5 @@ async def list_remnawave_subscription_configs(
logger.error('Error listing RemnaWave configs', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to fetch configs from RemnaWave: {e!s}',
detail='Failed to fetch configs from RemnaWave',
)
+91
View File
@@ -0,0 +1,91 @@
"""Admin API for managing required channels."""
import structlog
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.required_channel import (
add_channel,
delete_channel,
get_all_channels,
toggle_channel,
update_channel,
)
from app.database.models import User
from app.services.channel_subscription_service import channel_subscription_service
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.channel import (
ChannelCreateRequest,
ChannelListResponse,
ChannelResponse,
ChannelUpdateRequest,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/channel-subscriptions', tags=['Cabinet Admin Channels'])
@router.get('', response_model=ChannelListResponse)
async def list_channels(
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
) -> ChannelListResponse:
channels = await get_all_channels(db)
return ChannelListResponse(
items=[ChannelResponse.model_validate(ch) for ch in channels],
total=len(channels),
)
@router.post('', response_model=ChannelResponse, status_code=201)
async def create_channel(
data: ChannelCreateRequest,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
) -> ChannelResponse:
ch = await add_channel(db, channel_id=data.channel_id, channel_link=data.channel_link, title=data.title)
await channel_subscription_service.invalidate_channels_cache()
return ChannelResponse.model_validate(ch)
@router.patch('/{channel_db_id}', response_model=ChannelResponse)
async def update_channel_endpoint(
channel_db_id: int,
data: ChannelUpdateRequest,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
) -> ChannelResponse:
update_data = data.model_dump(exclude_unset=True)
ch = await update_channel(db, channel_db_id, **update_data)
if not ch:
raise HTTPException(status_code=404, detail='Channel not found')
await channel_subscription_service.invalidate_channels_cache()
return ChannelResponse.model_validate(ch)
@router.post('/{channel_db_id}/toggle', response_model=ChannelResponse)
async def toggle_channel_endpoint(
channel_db_id: int,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
) -> ChannelResponse:
ch = await toggle_channel(db, channel_db_id)
if not ch:
raise HTTPException(status_code=404, detail='Channel not found')
await channel_subscription_service.invalidate_channels_cache()
return ChannelResponse.model_validate(ch)
@router.delete('/{channel_db_id}', status_code=204)
async def delete_channel_endpoint(
channel_db_id: int,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
) -> None:
ok = await delete_channel(db, channel_db_id)
if not ok:
raise HTTPException(status_code=404, detail='Channel not found')
await channel_subscription_service.invalidate_channels_cache()
+7 -1
View File
@@ -99,7 +99,13 @@ async def _aggregate_traffic(
user_uuids_set = set(user_uuids)
async with service.get_api_client() as api:
nodes = await api.get_all_nodes()
try:
nodes = await api.get_all_nodes()
except Exception:
logger.warning('Failed to fetch nodes for traffic aggregation', exc_info=True)
# Cache empty result to avoid hammering the failing API
_traffic_cache[cache_key] = (now, {}, [])
return {}, []
# Fetch per-node user stats — O(nodes) calls instead of O(users)
semaphore = asyncio.Semaphore(_CONCURRENCY_LIMIT)
+59 -23
View File
@@ -575,12 +575,14 @@ async def get_user_detail(
transactions_result = await db.execute(transactions_q)
transactions = transactions_result.scalars().all()
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
recent_transactions = [
UserTransactionItem(
id=t.id,
type=t.type,
amount_kopeks=t.amount_kopeks,
amount_rubles=t.amount_kopeks / 100,
amount_kopeks=-t.amount_kopeks if t.type in _EXPENSE_TYPES else t.amount_kopeks,
amount_rubles=-t.amount_kopeks / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
description=t.description,
payment_method=t.payment_method,
is_completed=t.is_completed,
@@ -1819,24 +1821,33 @@ async def reset_user_trial(
# Delete subscription if exists
if user.subscription:
# Deactivate in Remnawave panel first
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
from app.database.crud.subscription import is_active_paid_subscription
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info('Disabled Remnawave user for trial reset', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
logger.warning('Failed to disable Remnawave user during trial reset', error=e)
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск удаления подписки и RemnaWave: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
else:
# Deactivate in Remnawave panel first
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
# Delete subscription from database
from sqlalchemy import delete
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info('Disabled Remnawave user for trial reset', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
logger.warning('Failed to disable Remnawave user during trial reset', error=e)
subscription_id = user.subscription.id
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
# Delete subscription from database
from sqlalchemy import delete
subscription_id = user.subscription.id
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
# Reset trial flag
user.has_used_trial = False
@@ -1889,6 +1900,21 @@ async def reset_user_subscription(
panel_deactivated=False,
)
from app.database.crud.subscription import is_active_paid_subscription
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск сброса подписки: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
return ResetSubscriptionResponse(
success=False,
message='Cannot reset active paid subscription. Subscription is still active and paid.',
subscription_deleted=False,
panel_deactivated=False,
)
# Deactivate in Remnawave panel if requested
if request.deactivate_in_panel and user.remnawave_uuid:
try:
@@ -1951,8 +1977,16 @@ async def disable_user(
panel_deactivated = False
panel_error: str | None = None
# Deactivate subscription in panel
if user.remnawave_uuid:
# Deactivate subscription in panel (skip if active paid subscription)
from app.database.crud.subscription import is_active_paid_subscription
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск отключения RemnaWave: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
elif user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
@@ -1964,8 +1998,8 @@ async def disable_user(
panel_error = str(e)
logger.warning('Failed to disable Remnawave user', error=e)
# Deactivate subscription in bot database
if user.subscription:
# Deactivate subscription in bot database (skip if active paid subscription)
if user.subscription and not is_active_paid_subscription(user.subscription):
from app.database.crud.subscription import deactivate_subscription
await deactivate_subscription(db, user.subscription)
@@ -2065,12 +2099,14 @@ async def get_user_transactions(
result = await db.execute(query)
transactions = result.scalars().all()
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
items = [
UserTransactionItem(
id=t.id,
type=t.type,
amount_kopeks=t.amount_kopeks,
amount_rubles=t.amount_kopeks / 100,
amount_kopeks=-t.amount_kopeks if t.type in _EXPENSE_TYPES else t.amount_kopeks,
amount_rubles=-t.amount_kopeks / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
description=t.description,
payment_method=t.payment_method,
is_completed=t.is_completed,
+62 -1
View File
@@ -151,6 +151,15 @@ async def _process_campaign_bonus(
if not campaign:
return None
# Skip if user IS the campaign partner — prevent self-referral
if campaign.partner_user_id and campaign.partner_user_id == user.id:
logger.debug(
'Skipping campaign attribution: user is the campaign partner',
user_id=user.id,
campaign_id=campaign.id,
)
return None
# Lock user row to prevent concurrent bonus application (race condition)
await db.execute(select(User).where(User.id == user.id).with_for_update())
@@ -200,6 +209,30 @@ async def _process_campaign_bonus(
return None
async def _process_referral_code(
db: AsyncSession,
user: User,
referral_code: str | None,
) -> None:
"""Set referred_by_id for user if referral_code is valid. Never raises."""
if not referral_code or user.referred_by_id:
return
try:
referrer = await get_user_by_referral_code(db, referral_code)
if not referrer:
return
if referrer.id == user.id:
return
if referrer.email and user.email and referrer.email.lower() == user.email.lower():
return
user.referred_by_id = referrer.id
await db.flush()
await process_referral_registration(db, user.id, referrer.id, bot=None)
logger.info('Referral applied from code', user_id=user.id, referrer_id=referrer.id, referral_code=referral_code)
except Exception as e:
logger.error('Failed to process referral code', error=e, referral_code=referral_code)
async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -> None:
"""
Check if user has subscription in RemnaWave panel by email and sync it.
@@ -341,6 +374,16 @@ async def auth_telegram(
tg_last_name = user_data.get('last_name')
tg_language = user_data.get('language_code', 'ru')
# Resolve referral code to referrer ID for new users
referrer_id = None
if request.referral_code and not user:
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
referrer_id = referrer.id
except Exception as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
if not user:
# Create new user from Telegram initData
logger.info('Creating new user from cabinet (initData): telegram_id', telegram_id=telegram_id)
@@ -351,6 +394,7 @@ async def auth_telegram(
first_name=tg_first_name,
last_name=tg_last_name,
language=tg_language,
referred_by_id=referrer_id,
)
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
else:
@@ -383,6 +427,9 @@ async def auth_telegram(
# Store refresh token
await _store_refresh_token(db, user.id, response.refresh_token)
# Process referral code (before campaign bonus, which may also set referrer)
await _process_referral_code(db, user, request.referral_code)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
@@ -402,7 +449,7 @@ async def auth_telegram_widget(
This endpoint validates data from Telegram Login Widget and returns
JWT tokens for authenticated access.
"""
widget_data = request.model_dump(exclude={'campaign_slug'})
widget_data = request.model_dump(exclude={'campaign_slug', 'referral_code'})
if not validate_telegram_login_widget(widget_data):
raise HTTPException(
@@ -412,6 +459,16 @@ async def auth_telegram_widget(
user = await get_user_by_telegram_id(db, request.id)
# Resolve referral code to referrer ID for new users
referrer_id = None
if request.referral_code and not user:
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
referrer_id = referrer.id
except Exception as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
if not user:
# Create new user from Telegram data
logger.info(
@@ -424,6 +481,7 @@ async def auth_telegram_widget(
first_name=request.first_name,
last_name=request.last_name,
language='ru',
referred_by_id=referrer_id,
)
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
@@ -447,6 +505,9 @@ async def auth_telegram_widget(
response = _create_auth_response(user)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process referral code (before campaign bonus, which may also set referrer)
await _process_referral_code(db, user, request.referral_code)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
+37 -6
View File
@@ -12,6 +12,7 @@ from app.database.crud.user import (
create_user_by_oauth,
get_user_by_email,
get_user_by_oauth_provider,
get_user_by_referral_code,
set_user_oauth_provider_id,
)
from app.database.models import User
@@ -37,16 +38,21 @@ async def _finalize_oauth_login(
user: User,
provider: str,
campaign_slug: str | None = None,
referral_code: str | None = None,
) -> AuthResponse:
"""Update last login, create tokens, store refresh token."""
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
auth_response = _create_auth_response(user)
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
# Process referral code (before campaign bonus, which may also set referrer)
from .auth import _process_referral_code, _user_to_response
await _process_referral_code(db, user, referral_code)
auth_response.campaign_bonus = await _process_campaign_bonus(db, user, campaign_slug)
if auth_response.campaign_bonus:
from .auth import _user_to_response
auth_response.user = _user_to_response(user)
return auth_response
@@ -74,6 +80,7 @@ class OAuthCallbackRequest(BaseModel):
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
# --- Endpoints ---
@@ -153,7 +160,7 @@ async def oauth_callback(
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
if user:
logger.info('OAuth login via for existing user', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
# 6. Find user by email (if verified) and link provider
if user_info.email and user_info.email_verified:
@@ -161,9 +168,32 @@ async def oauth_callback(
if user:
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
logger.info('OAuth login via linked to existing email user', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
# 7. Create new user
# 7. Resolve referral code for new user
referrer_id = None
if request.referral_code:
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
# Self-referral protection by email
if (
user_info.email
and user_info.email_verified
and referrer.email
and referrer.email.lower() == user_info.email.lower()
):
logger.warning(
'Self-referral attempt blocked via OAuth',
referral_code=request.referral_code,
email=user_info.email,
)
else:
referrer_id = referrer.id
except Exception as e:
logger.warning('Failed to resolve referral code during OAuth', referral_code=request.referral_code, error=e)
# 8. Create new user
user = await create_user_by_oauth(
db=db,
provider=provider,
@@ -173,6 +203,7 @@ async def oauth_callback(
first_name=user_info.first_name,
last_name=user_info.last_name,
username=user_info.username,
referred_by_id=referrer_id,
)
logger.info('OAuth new user created via with id', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
+55 -317
View File
@@ -1,7 +1,6 @@
"""Subscription management routes for cabinet."""
import base64
import json
import re
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -2681,19 +2680,6 @@ async def get_device_price(
# ============ App Config for Connection ============
def _load_app_config_from_file() -> dict[str, Any]:
"""Load app-config.json file."""
try:
config_path = settings.get_app_config_path()
with open(config_path, encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, dict):
return data
except Exception as e:
logger.error('Failed to load app-config.json', error=e)
return {}
def _get_remnawave_config_uuid() -> str | None:
"""Get RemnaWave config UUID from system settings or env."""
try:
@@ -2702,58 +2688,6 @@ def _get_remnawave_config_uuid() -> str | None:
return settings.CABINET_REMNA_SUB_CONFIG
def _is_subscription_link_template(url: str) -> bool:
"""Check if URL is a RemnaWave subscription link template."""
if not url:
return False
# RemnaWave uses templates like {{HAPP_CRYPT4_LINK}}, {{V2RAY_LINK}}, etc.
if url.startswith('{{') and url.endswith('}}'):
return True
# Also check for button type "subscriptionLink" indicator
return False
def _convert_remnawave_block_to_step(block: dict[str, Any], url_scheme: str = '') -> dict[str, Any]:
"""Convert RemnaWave block format to cabinet step format."""
step = {
'description': block.get('description', {}),
}
if block.get('title'):
step['title'] = block['title']
if block.get('buttons'):
buttons = []
for btn in block['buttons']:
btn_url = btn.get('url', '') or btn.get('link', '')
btn_type = btn.get('type', '')
# Replace subscription link templates with {{deepLink}} placeholder
# RemnaWave uses templates like {{HAPP_CRYPT4_LINK}} or type="subscriptionLink"
if (
_is_subscription_link_template(btn_url)
or btn_type == 'subscriptionLink'
or (
url_scheme
and btn_url
and (
btn_url.startswith(url_scheme)
or btn_url.endswith('://')
or btn_url.endswith('://add/')
or ('://' in btn_url and not btn_url.startswith('http'))
)
)
):
btn_url = '{{deepLink}}'
buttons.append(
{
'buttonLink': btn_url,
'buttonText': btn.get('text', {}),
}
)
step['buttons'] = buttons
return step
def _extract_scheme_from_buttons(buttons: list[dict[str, Any]]) -> tuple[str, bool]:
"""Extract URL scheme from buttons list.
@@ -2822,15 +2756,6 @@ def _get_url_scheme_for_app(app: dict[str, Any]) -> tuple[str, bool]:
if scheme:
return scheme, uses_crypto
# 4. Check in step structures (cabinet format)
for step_key in ['installationStep', 'addSubscriptionStep', 'connectAndUseStep']:
step = app.get(step_key, {})
if isinstance(step, dict):
step_buttons = step.get('buttons', [])
scheme, uses_crypto = _extract_scheme_from_buttons(step_buttons)
if scheme:
return scheme, uses_crypto
# No scheme found
logger.debug(
'_get_url_scheme_for_app: No scheme found for app has blocks: has buttons: has urlScheme',
@@ -2842,147 +2767,10 @@ def _get_url_scheme_for_app(app: dict[str, Any]) -> tuple[str, bool]:
return '', False
def _find_subscription_block(blocks: list[dict[str, Any]]) -> dict[str, Any] | None:
"""Find block that contains subscriptionLink button."""
for block in blocks:
if not isinstance(block, dict):
continue
buttons = block.get('buttons', [])
for btn in buttons:
if not isinstance(btn, dict):
continue
# Check for subscriptionLink type, {{SUBSCRIPTION_LINK}}, or {{HAPP_CRYPT4_LINK}} in link
btn_type = btn.get('type', '')
link = btn.get('link', '') or btn.get('url', '')
link_upper = link.upper() if link else ''
if btn_type == 'subscriptionLink' or 'SUBSCRIPTION_LINK' in link_upper or 'HAPP_CRYPT4_LINK' in link_upper:
return block
return None
async def _load_app_config_async() -> dict[str, Any] | None:
"""Load app config from RemnaWave API (if configured).
def _find_connect_block(blocks: list[dict[str, Any]]) -> dict[str, Any] | None:
"""Find block that is about connection/usage (usually last or has specific keywords)."""
# Look for block with "connect" or "use" in title
for block in blocks:
if not isinstance(block, dict):
continue
title = block.get('title', {})
title_en = title.get('en', '') if isinstance(title, dict) else ''
title_lower = title_en.lower()
if 'connect' in title_lower or 'use' in title_lower:
return block
# Fallback to last block if no match
return blocks[-1] if blocks else None
def _convert_remnawave_app_to_cabinet(app: dict[str, Any]) -> dict[str, Any]:
"""Convert RemnaWave app format to cabinet app format."""
blocks = app.get('blocks', [])
url_scheme, uses_crypto = _get_url_scheme_for_app(app)
# Debug log for conversion (не логируем отсутствие urlScheme - для Happ это нормально)
app_name = app.get('name', 'unknown')
if url_scheme:
logger.debug('_convert_remnawave_app_to_cabinet: app urlScheme', app_name=app_name, url_scheme=url_scheme)
# Smart block mapping: find blocks by their content, not just position
# 1. First block is usually installation
installation_block = blocks[0] if len(blocks) > 0 else None
# 2. Find subscription block (with subscriptionLink button)
subscription_block = _find_subscription_block(blocks)
# 3. Find connect/use block (usually last or has "connect" in title)
connect_block = _find_connect_block(blocks)
# Convert blocks to steps
installation_step = (
_convert_remnawave_block_to_step(installation_block, url_scheme) if installation_block else {'description': {}}
)
subscription_step = (
_convert_remnawave_block_to_step(subscription_block, url_scheme) if subscription_block else {'description': {}}
)
connect_step = _convert_remnawave_block_to_step(connect_block, url_scheme) if connect_block else {'description': {}}
# Ensure subscription step has a deepLink button if urlScheme exists
if url_scheme:
has_deeplink_button = False
if 'buttons' in subscription_step:
for btn in subscription_step['buttons']:
if btn.get('buttonLink') == '{{deepLink}}':
has_deeplink_button = True
break
if not has_deeplink_button:
# Add deepLink button at the beginning
deeplink_button = {
'buttonLink': '{{deepLink}}',
'buttonText': {
'en': 'Open app',
'ru': 'Открыть приложение',
'zh': '打开应用',
'fa': 'باز کردن برنامه',
},
}
if 'buttons' not in subscription_step:
subscription_step['buttons'] = []
subscription_step['buttons'].insert(0, deeplink_button)
return {
'id': app.get('name', '').lower().replace(' ', '-'),
'name': app.get('name', ''),
'isFeatured': app.get('featured', False),
'urlScheme': url_scheme,
'usesCryptoLink': uses_crypto,
'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False),
'installationStep': installation_step,
'addSubscriptionStep': subscription_step,
'connectAndUseStep': connect_step,
}
def _convert_remnawave_config_to_cabinet(config: dict[str, Any]) -> dict[str, Any]:
"""Convert RemnaWave config format to cabinet format."""
platforms = {}
remnawave_platforms = config.get('platforms', {})
for platform_key, platform_data in remnawave_platforms.items():
if not isinstance(platform_data, dict):
continue
apps = platform_data.get('apps', [])
if not isinstance(apps, list):
continue
cabinet_apps = []
for app in apps:
if isinstance(app, dict):
cabinet_apps.append(_convert_remnawave_app_to_cabinet(app))
if cabinet_apps:
platforms[platform_key] = cabinet_apps
# Convert branding
branding = {}
if config.get('brandingSettings'):
branding = {
'name': config['brandingSettings'].get('name', ''),
'logoUrl': config['brandingSettings'].get('logoUrl', ''),
'supportUrl': config['brandingSettings'].get('supportUrl', ''),
}
return {
'config': {
'additionalLocales': ['zh', 'fa'],
'branding': branding,
},
'platforms': platforms,
}
async def _load_app_config_async() -> dict[str, Any]:
"""Load app config from RemnaWave (if configured) or local file.
When config comes from RemnaWave, returns the original format with
``_isRemnawave`` flag so the caller can serve it as-is (enriched with
deep links) instead of converting to the legacy step-based format.
Returns None when no Remnawave config is set or API fails.
"""
remnawave_uuid = _get_remnawave_config_uuid()
@@ -2997,15 +2785,9 @@ async def _load_app_config_async() -> dict[str, Any]:
raw['_isRemnawave'] = True
return raw
except Exception as e:
logger.warning('Failed to load RemnaWave config, falling back to file', error=e)
logger.warning('Failed to load RemnaWave config', error=e)
# Fallback to local file
return _load_app_config_from_file()
def _load_app_config() -> dict[str, Any]:
"""Load app-config.json file (sync version for compatibility)."""
return _load_app_config_from_file()
return None
def _create_deep_link(
@@ -3420,18 +3202,22 @@ async def get_app_config(
subscription_url = user.subscription.subscription_url
subscription_crypto_link = user.subscription.subscription_crypto_link
# Load config from RemnaWave (if configured) or local file
config = await _load_app_config_async()
is_remnawave = config.pop('_isRemnawave', False)
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='App configuration not set up.',
)
config.pop('_isRemnawave', None)
hide_link = settings.should_hide_subscription_link()
# Строим platformNames из displayName каждой платформы RemnaWave
# Build platformNames from displayName of each platform
platform_names: dict[str, Any] = {}
for pk, pd in config.get('platforms', {}).items():
if isinstance(pd, dict) and 'displayName' in pd:
platform_names[pk] = pd['displayName']
# Фоллбэк для платформ без displayName (en достаточно)
fallback_names = {
'ios': {'en': 'iPhone/iPad'},
'android': {'en': 'Android'},
@@ -3445,111 +3231,63 @@ async def get_app_config(
if k not in platform_names:
platform_names[k] = v
if is_remnawave:
# ── RemnaWave original format ──
# Serve original blocks/svgLibrary enriched with deep links and resolved URLs.
platforms: dict[str, Any] = {}
for platform_key, platform_data in config.get('platforms', {}).items():
if not isinstance(platform_data, dict):
continue
apps = platform_data.get('apps', [])
if not isinstance(apps, list):
continue
enriched_apps = []
for app in apps:
if not isinstance(app, dict):
continue
# Generate deep link
deep_link = None
if subscription_url or subscription_crypto_link:
deep_link = _create_deep_link(app, subscription_url, subscription_crypto_link)
app['deepLink'] = deep_link
# Resolve templates only for subscriptionLink and copyButton (not external)
for block in app.get('blocks', []):
if not isinstance(block, dict):
continue
for btn in block.get('buttons', []):
if not isinstance(btn, dict):
continue
btn_type = btn.get('type', '')
if btn_type in ('subscriptionLink', 'copyButton'):
url = btn.get('url', '') or btn.get('link', '')
if url and '{{' in url:
btn['resolvedUrl'] = _resolve_button_url(
url,
subscription_url,
subscription_crypto_link,
)
enriched_apps.append(app)
if enriched_apps:
# Сохраняем platform-level поля (svgIconKey, displayName и т.д.)
platform_output = {k: v for k, v in platform_data.items() if k != 'apps'}
platform_output['apps'] = enriched_apps
platforms[platform_key] = platform_output
return {
'isRemnawave': True,
'platforms': platforms,
'svgLibrary': config.get('svgLibrary', {}),
'baseTranslations': config.get('baseTranslations'),
'baseSettings': config.get('baseSettings'),
'uiConfig': config.get('uiConfig', {}),
'platformNames': platform_names,
'hasSubscription': bool(subscription_url or subscription_crypto_link),
'subscriptionUrl': subscription_url,
'subscriptionCryptoLink': subscription_crypto_link,
'hideLink': hide_link,
'branding': config.get('brandingSettings', {}),
}
# ── Legacy file-based format ──
platforms_raw = config.get('platforms', {})
if not isinstance(platforms_raw, dict):
platforms_raw = {}
platforms_legacy: dict[str, Any] = {}
for platform_key, apps in platforms_raw.items():
# Serve original blocks/svgLibrary enriched with deep links and resolved URLs.
platforms: dict[str, Any] = {}
for platform_key, platform_data in config.get('platforms', {}).items():
if not isinstance(platform_data, dict):
continue
apps = platform_data.get('apps', [])
if not isinstance(apps, list):
continue
platform_apps = []
enriched_apps = []
for app in apps:
if not isinstance(app, dict):
continue
app_data = {
'id': app.get('id'),
'name': app.get('name'),
'isFeatured': app.get('isFeatured', False),
'installationStep': app.get('installationStep'),
'addSubscriptionStep': app.get('addSubscriptionStep'),
'connectAndUseStep': app.get('connectAndUseStep'),
'additionalBeforeAddSubscriptionStep': app.get('additionalBeforeAddSubscriptionStep'),
'additionalAfterAddSubscriptionStep': app.get('additionalAfterAddSubscriptionStep'),
}
# Add deep link if subscription exists
# Generate deep link
deep_link = None
if subscription_url or subscription_crypto_link:
app_data['deepLink'] = _create_deep_link(app, subscription_url, subscription_crypto_link)
deep_link = _create_deep_link(app, subscription_url, subscription_crypto_link)
app['deepLink'] = deep_link
platform_apps.append(app_data)
# Resolve templates only for subscriptionLink and copyButton (not external)
for block in app.get('blocks', []):
if not isinstance(block, dict):
continue
for btn in block.get('buttons', []):
if not isinstance(btn, dict):
continue
btn_type = btn.get('type', '')
if btn_type in ('subscriptionLink', 'copyButton'):
url = btn.get('url', '') or btn.get('link', '')
if url and '{{' in url:
btn['resolvedUrl'] = _resolve_button_url(
url,
subscription_url,
subscription_crypto_link,
)
if platform_apps:
platforms_legacy[platform_key] = platform_apps
enriched_apps.append(app)
if enriched_apps:
platform_output = {k: v for k, v in platform_data.items() if k != 'apps'}
platform_output['apps'] = enriched_apps
platforms[platform_key] = platform_output
return {
'platforms': platforms_legacy,
'isRemnawave': True,
'platforms': platforms,
'svgLibrary': config.get('svgLibrary', {}),
'baseTranslations': config.get('baseTranslations'),
'baseSettings': config.get('baseSettings'),
'uiConfig': config.get('uiConfig', {}),
'platformNames': platform_names,
'hasSubscription': bool(subscription_url or subscription_crypto_link),
'subscriptionUrl': subscription_url if not hide_link else None,
'subscriptionCryptoLink': subscription_crypto_link if not hide_link else None,
'subscriptionUrl': subscription_url,
'subscriptionCryptoLink': subscription_crypto_link,
'hideLink': hide_link,
'branding': config.get('config', {}).get('branding', {}),
'branding': config.get('brandingSettings', {}),
}
+2
View File
@@ -12,6 +12,7 @@ class TelegramAuthRequest(BaseModel):
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
class TelegramWidgetAuthRequest(BaseModel):
@@ -27,6 +28,7 @@ class TelegramWidgetAuthRequest(BaseModel):
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
class EmailRegisterRequest(BaseModel):
+2 -2
View File
@@ -63,7 +63,7 @@ class PaymentMethodResponse(BaseModel):
class TopUpRequest(BaseModel):
"""Request to create payment for balance top-up."""
amount_kopeks: int = Field(..., ge=1000, description='Amount in kopeks (min 10 rubles)')
amount_kopeks: int = Field(..., ge=1000, le=2_000_000_000, description='Amount in kopeks (min 10 rubles)')
payment_method: str = Field(..., description='Payment method ID')
payment_option: str | None = Field(None, description='Payment option (e.g. Platega method code)')
@@ -82,7 +82,7 @@ class TopUpResponse(BaseModel):
class StarsInvoiceRequest(BaseModel):
"""Request to create Telegram Stars invoice for balance top-up."""
amount_kopeks: int = Field(..., ge=100, description='Amount in kopeks (min 1 ruble)')
amount_kopeks: int = Field(..., ge=100, le=2_000_000_000, description='Amount in kopeks (min 1 ruble)')
class StarsInvoiceResponse(BaseModel):
+78
View File
@@ -0,0 +1,78 @@
"""Pydantic v2 schemas for channel subscription management."""
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.database.crud.required_channel import validate_channel_id as _validate_channel_id_format
def _validate_channel_link_value(v: str | None) -> str | None:
"""Shared channel_link validation: t.me URL, @username auto-convert, http->https upgrade."""
if v is None:
return v
v = v.strip()
if v.startswith('http://t.me/'):
v = v.replace('http://', 'https://', 1)
if v.startswith('https://t.me/'):
return v
if v.startswith('@'):
return f'https://t.me/{v[1:]}'
raise ValueError('channel_link must be a t.me URL or @username')
class ChannelResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
channel_id: str
channel_link: str | None
title: str | None
is_active: bool
sort_order: int
class ChannelListResponse(BaseModel):
items: list[ChannelResponse]
total: int
class ChannelCreateRequest(BaseModel):
channel_id: str
channel_link: str | None = None
title: str | None = Field(None, max_length=255)
@field_validator('channel_id')
@classmethod
def validate_channel_id(cls, v: str) -> str:
return _validate_channel_id_format(v)
@field_validator('channel_link')
@classmethod
def validate_channel_link(cls, v: str | None) -> str | None:
return _validate_channel_link_value(v)
class ChannelUpdateRequest(BaseModel):
channel_id: str | None = None
channel_link: str | None = None
title: str | None = Field(None, max_length=255)
is_active: bool | None = None
sort_order: int | None = None
@field_validator('channel_id')
@classmethod
def validate_channel_id(cls, v: str | None) -> str | None:
if v is None:
return v
return _validate_channel_id_format(v)
@field_validator('channel_link')
@classmethod
def validate_channel_link(cls, v: str | None) -> str | None:
return _validate_channel_link_value(v)
class ChannelSubscriptionStatus(BaseModel):
channel_id: str
channel_link: str | None
title: str | None
is_subscribed: bool
+1 -1
View File
@@ -15,7 +15,7 @@ class PartnerApplicationRequest(BaseModel):
website_url: str | None = Field(None, max_length=500)
telegram_channel: str | None = Field(None, max_length=255)
description: str | None = Field(None, max_length=2000)
expected_monthly_referrals: int | None = Field(None, ge=0)
expected_monthly_referrals: int | None = Field(None, ge=0, le=2_000_000_000)
class PartnerApplicationInfo(BaseModel):
+10 -8
View File
@@ -86,7 +86,7 @@ class RenewalOptionResponse(BaseModel):
class RenewalRequest(BaseModel):
"""Request to renew subscription."""
period_days: int = Field(..., description='Renewal period in days')
period_days: int = Field(..., ge=1, le=3650, description='Renewal period in days')
class TrafficPackageResponse(BaseModel):
@@ -101,13 +101,13 @@ class TrafficPackageResponse(BaseModel):
class TrafficPurchaseRequest(BaseModel):
"""Request to purchase additional traffic."""
gb: int = Field(..., ge=0, description='GB to purchase (0 = unlimited)')
gb: int = Field(..., ge=0, le=100_000, description='GB to purchase (0 = unlimited)')
class DevicePurchaseRequest(BaseModel):
"""Request to purchase additional device slots."""
devices: int = Field(..., ge=1, description='Number of additional devices')
devices: int = Field(..., ge=1, le=100, description='Number of additional devices')
class AutopayUpdateRequest(BaseModel):
@@ -137,10 +137,10 @@ class PurchaseSelectionRequest(BaseModel):
"""User's selection for subscription purchase."""
period_id: str | None = Field(None, description="Period ID like 'days:30'")
period_days: int | None = Field(None, description='Period in days')
traffic_value: int | None = Field(None, description='Traffic in GB (0 = unlimited)')
period_days: int | None = Field(None, ge=1, le=3650, description='Period in days')
traffic_value: int | None = Field(None, ge=0, le=100_000, description='Traffic in GB (0 = unlimited)')
servers: list[str] | None = Field(default_factory=list, description='Server UUIDs')
devices: int | None = Field(None, description='Device limit')
devices: int | None = Field(None, ge=1, le=100, description='Device limit')
class PurchasePreviewRequest(BaseModel):
@@ -156,5 +156,7 @@ class TariffPurchaseRequest(BaseModel):
"""Request to purchase a tariff."""
tariff_id: int = Field(..., description='Tariff ID to purchase')
period_days: int = Field(..., description='Period in days')
traffic_gb: int | None = Field(None, ge=0, description='Custom traffic in GB (for custom_traffic_enabled tariffs)')
period_days: int = Field(..., ge=1, le=3650, description='Period in days')
traffic_gb: int | None = Field(
None, ge=0, le=100_000, description='Custom traffic in GB (for custom_traffic_enabled tariffs)'
)
+3 -1
View File
@@ -261,7 +261,9 @@ class UserNodeUsageResponse(BaseModel):
class UpdateBalanceRequest(BaseModel):
"""Request to update user balance."""
amount_kopeks: int = Field(..., description='Amount in kopeks (positive to add, negative to subtract)')
amount_kopeks: int = Field(
..., ge=-2_000_000_000, le=2_000_000_000, description='Amount in kopeks (positive to add, negative to subtract)'
)
description: str = Field(default='Admin balance adjustment', max_length=500)
create_transaction: bool = Field(default=True, description='Create transaction record')
-10
View File
@@ -66,8 +66,6 @@ class Settings(BaseSettings):
ADMIN_REPORTS_TOPIC_ID: int | None = None
ADMIN_REPORTS_SEND_TIME: str | None = None
CHANNEL_SUB_ID: str | None = None
CHANNEL_LINK: str | None = None
CHANNEL_IS_REQUIRED_SUB: bool = False
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: bool = True
CHANNEL_REQUIRED_FOR_ALL: bool = False
@@ -676,7 +674,6 @@ class Settings(BaseSettings):
WEB_API_TOKEN_HMAC_SECRET: str | None = None
WEB_API_REQUEST_LOGGING: bool = True
APP_CONFIG_PATH: str = 'app-config.json'
ENABLE_DEEP_LINKS: bool = True
APP_CONFIG_CACHE_TTL: int = 3600
@@ -1383,13 +1380,6 @@ class Settings(BaseSettings):
return value
return None
def get_app_config_path(self) -> str:
if os.path.isabs(self.APP_CONFIG_PATH):
return self.APP_CONFIG_PATH
project_root = Path(__file__).parent.parent
return str(project_root / self.APP_CONFIG_PATH)
def is_deep_links_enabled(self) -> bool:
return self.ENABLE_DEEP_LINKS
+212
View File
@@ -0,0 +1,212 @@
import re
from datetime import UTC, datetime
import structlog
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import RequiredChannel, UserChannelSubscription
logger = structlog.get_logger(__name__)
# Explicit allowlist of fields that can be updated via update_channel()
_UPDATABLE_FIELDS = frozenset({'channel_id', 'channel_link', 'title', 'is_active', 'sort_order'})
# Validation patterns for channel_id
_CHANNEL_ID_NUMERIC = re.compile(r'^-100\d{10,13}$')
_BARE_DIGITS = re.compile(r'^\d{10,13}$')
def validate_channel_id(channel_id: str) -> str:
"""Validate and normalize channel_id. Auto-prefixes -100 for bare digits.
Raises ValueError on invalid input.
"""
channel_id = channel_id.strip()
if _CHANNEL_ID_NUMERIC.match(channel_id):
return channel_id
if _BARE_DIGITS.match(channel_id):
return f'-100{channel_id}'
raise ValueError(
f'Invalid channel_id format: {channel_id!r}. '
'Enter numeric channel ID (e.g. 1234567890) — prefix -100 is added automatically'
)
# -- RequiredChannel CRUD --------------------------------------------------------
async def get_active_channels(db: AsyncSession) -> list[RequiredChannel]:
"""Get all active required channels (sorted by sort_order)."""
result = await db.execute(
select(RequiredChannel)
.where(RequiredChannel.is_active.is_(True))
.order_by(RequiredChannel.sort_order, RequiredChannel.id)
)
return list(result.scalars().all())
async def get_all_channels(db: AsyncSession) -> list[RequiredChannel]:
"""Get all required channels (including inactive)."""
result = await db.execute(select(RequiredChannel).order_by(RequiredChannel.sort_order, RequiredChannel.id))
return list(result.scalars().all())
async def get_channel_by_id(db: AsyncSession, channel_db_id: int) -> RequiredChannel | None:
result = await db.execute(select(RequiredChannel).where(RequiredChannel.id == channel_db_id))
return result.scalar_one_or_none()
async def get_channel_by_channel_id(db: AsyncSession, channel_id: str) -> RequiredChannel | None:
result = await db.execute(select(RequiredChannel).where(RequiredChannel.channel_id == channel_id))
return result.scalar_one_or_none()
async def add_channel(
db: AsyncSession,
channel_id: str,
channel_link: str | None = None,
title: str | None = None,
) -> RequiredChannel:
channel_id = validate_channel_id(channel_id)
channel = RequiredChannel(
channel_id=channel_id,
channel_link=channel_link,
title=title,
)
db.add(channel)
await db.commit()
await db.refresh(channel)
return channel
async def update_channel(
db: AsyncSession,
channel_db_id: int,
**kwargs,
) -> RequiredChannel | None:
"""Update channel fields. Only fields in _UPDATABLE_FIELDS are accepted."""
channel = await get_channel_by_id(db, channel_db_id)
if not channel:
return None
for key, value in kwargs.items():
if key not in _UPDATABLE_FIELDS:
logger.warning('Rejected update of non-updatable field', field=key)
continue
if key == 'channel_id' and value is not None:
value = validate_channel_id(value)
setattr(channel, key, value)
channel.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(channel)
return channel
async def delete_channel(db: AsyncSession, channel_db_id: int) -> bool:
channel = await get_channel_by_id(db, channel_db_id)
if not channel:
return False
# Also clean up user subscriptions for this channel
await db.execute(delete(UserChannelSubscription).where(UserChannelSubscription.channel_id == channel.channel_id))
await db.delete(channel)
await db.commit()
return True
async def toggle_channel(db: AsyncSession, channel_db_id: int) -> RequiredChannel | None:
channel = await get_channel_by_id(db, channel_db_id)
if not channel:
return None
channel.is_active = not channel.is_active
channel.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(channel)
return channel
# -- UserChannelSubscription CRUD ------------------------------------------------
async def upsert_user_channel_sub(
db: AsyncSession,
telegram_id: int,
channel_id: str,
is_member: bool,
) -> None:
"""Upsert user subscription status (PostgreSQL ON CONFLICT)."""
now = datetime.now(UTC) # Single timestamp for both INSERT and UPDATE
stmt = (
pg_insert(UserChannelSubscription)
.values(
telegram_id=telegram_id,
channel_id=channel_id,
is_member=is_member,
checked_at=now,
)
.on_conflict_do_update(
constraint='uq_user_channel_sub',
set_={
'is_member': is_member,
'checked_at': now,
},
)
)
await db.execute(stmt)
# NOTE: caller is responsible for commit (allows batching)
async def get_user_channel_subs(
db: AsyncSession,
telegram_id: int,
) -> list[UserChannelSubscription]:
"""Get all channel subscriptions for a user."""
result = await db.execute(select(UserChannelSubscription).where(UserChannelSubscription.telegram_id == telegram_id))
return list(result.scalars().all())
async def get_user_channel_sub(
db: AsyncSession,
telegram_id: int,
channel_id: str,
) -> UserChannelSubscription | None:
result = await db.execute(
select(UserChannelSubscription).where(
UserChannelSubscription.telegram_id == telegram_id,
UserChannelSubscription.channel_id == channel_id,
)
)
return result.scalar_one_or_none()
async def bulk_upsert_user_subs(
db: AsyncSession,
telegram_id: int,
subs: dict[str, bool], # {channel_id: is_member}
) -> None:
"""Batch upsert user subscriptions with single multi-row INSERT."""
if not subs:
return
now = datetime.now(UTC)
values = [
{
'telegram_id': telegram_id,
'channel_id': channel_id,
'is_member': is_member,
'checked_at': now,
}
for channel_id, is_member in subs.items()
]
stmt = pg_insert(UserChannelSubscription).values(values)
stmt = stmt.on_conflict_do_update(
constraint='uq_user_channel_sub',
set_={
'is_member': stmt.excluded.is_member,
'checked_at': stmt.excluded.checked_at,
},
)
await db.execute(stmt)
await db.commit()
+12
View File
@@ -36,6 +36,18 @@ def is_recently_updated_by_webhook(subscription: Subscription) -> bool:
return elapsed < _WEBHOOK_GUARD_SECONDS
def is_active_paid_subscription(subscription: Subscription | None) -> bool:
"""Return True if subscription is active, paid (non-trial), and not expired."""
if not subscription:
return False
return (
not subscription.is_trial
and subscription.status == SubscriptionStatus.ACTIVE.value
and subscription.end_date is not None
and subscription.end_date > datetime.now(UTC)
)
async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscription | None:
result = await db.execute(
select(Subscription)
+2
View File
@@ -1300,6 +1300,7 @@ async def create_user_by_oauth(
last_name: str | None = None,
username: str | None = None,
language: str = 'ru',
referred_by_id: int | None = None,
) -> User:
"""Create a new user via OAuth provider."""
referral_code = await create_unique_referral_code(db)
@@ -1319,6 +1320,7 @@ async def create_user_by_oauth(
first_name=sanitize_telegram_name(first_name) if first_name else None,
last_name=sanitize_telegram_name(last_name) if last_name else None,
language=normalized_language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False,
+45
View File
@@ -2810,3 +2810,48 @@ class PaymentMethodConfig(Base):
def __repr__(self) -> str:
return f"<PaymentMethodConfig method_id='{self.method_id}' order={self.sort_order} enabled={self.is_enabled}>"
class RequiredChannel(Base):
"""Channels that users must subscribe to in order to use the bot."""
__tablename__ = 'required_channels'
id = Column(Integer, primary_key=True, autoincrement=True)
channel_id = Column(String(100), unique=True, nullable=False) # -100xxx numeric format (always string)
channel_link = Column(String(500), nullable=True) # https://t.me/xxx
title = Column(String(255), nullable=True) # Display name
is_active = Column(Boolean, nullable=False, server_default='true')
sort_order = Column(Integer, nullable=False, server_default='0')
created_at = Column(AwareDateTime(), nullable=False, server_default=func.now())
updated_at = Column(AwareDateTime(), nullable=True, onupdate=func.now())
def __repr__(self) -> str:
return f'<RequiredChannel id={self.id} channel_id={self.channel_id!r} active={self.is_active}>'
class UserChannelSubscription(Base):
"""Cache of user subscription status per required channel."""
__tablename__ = 'user_channel_subscriptions'
id = Column(Integer, primary_key=True, autoincrement=True)
telegram_id = Column(BigInteger, nullable=False)
channel_id = Column(String(100), nullable=False) # matches RequiredChannel.channel_id
is_member = Column(Boolean, nullable=False, server_default='false')
checked_at = Column(AwareDateTime(), nullable=False, server_default=func.now())
__table_args__ = (
UniqueConstraint('telegram_id', 'channel_id', name='uq_user_channel_sub'),
# UniqueConstraint creates its own index; only add telegram_id index for
# "get all subs for user" queries
Index('ix_user_channel_sub_telegram_id', 'telegram_id'),
# Standalone channel_id index for delete_channel() bulk DELETE
Index('ix_user_channel_sub_channel_id', 'channel_id'),
)
def __repr__(self) -> str:
return (
f'<UserChannelSubscription telegram_id={self.telegram_id}'
f' channel={self.channel_id!r} member={self.is_member}>'
)
+11
View File
@@ -553,8 +553,19 @@ class RemnaWaveAPI:
if active_internal_squads is not None:
data['activeInternalSquads'] = active_internal_squads
logger.info(
'PATCH /api/users payload',
uuid=uuid,
hwidDeviceLimit=data.get('hwidDeviceLimit'),
status=data.get('status'),
)
response = await self._make_request('PATCH', '/api/users', data)
user = self._parse_user(response['response'])
logger.info(
'PATCH /api/users response',
uuid=uuid,
response_hwidDeviceLimit=user.hwid_device_limit,
)
return await self.enrich_user_with_happ_link(user)
async def delete_user(self, uuid: str) -> bool:
+1
View File
@@ -24,6 +24,7 @@ from . import (
referrals,
remnawave,
reports,
required_channels,
rules,
servers,
statistics,
+4 -3
View File
@@ -1,3 +1,4 @@
import html
from datetime import datetime
import structlog
@@ -154,7 +155,7 @@ async def create_backup_handler(callback: types.CallbackQuery, db_user: User, db
)
else:
await progress_msg.edit_text(
f'❌ <b>Ошибка создания бекапа</b>\n\n{message}',
f'❌ <b>Ошибка создания бекапа</b>\n\n{html.escape(message)}',
parse_mode='HTML',
reply_markup=get_backup_main_keyboard(db_user.language),
)
@@ -431,11 +432,11 @@ async def handle_backup_file_upload(message: types.Message, db_user: User, db: A
inline_keyboard=[
[
InlineKeyboardButton(
text='✅ Восстановить', callback_data=f'backup_restore_uploaded_{temp_path.name}'
text='✅ Восстановить', callback_data=f'backup_restore_execute_{temp_path.name}'
),
InlineKeyboardButton(
text='🗑️ Очистить и восстановить',
callback_data=f'backup_restore_uploaded_clear_{temp_path.name}',
callback_data=f'backup_restore_clear_{temp_path.name}',
),
],
[InlineKeyboardButton(text='❌ Отмена', callback_data='backup_panel')],
+140 -4
View File
@@ -318,10 +318,11 @@ def _get_group_status(group_key: str) -> tuple[str, str]:
if key == 'core':
token_ok = bool(getattr(settings, 'BOT_TOKEN', ''))
channel_ok = bool(settings.CHANNEL_LINK or not settings.CHANNEL_IS_REQUIRED_SUB)
if token_ok and channel_ok:
# Channel subscription channels are now managed via DB (admin panel),
# not a single CHANNEL_LINK setting. Dashboard cannot async-query DB here.
if token_ok:
return '🟢', 'Бот готов к работе'
return '🟡', 'Проверьте токен и обязательную подписку'
return '🟡', 'Проверьте токен бота'
if key == 'subscriptions':
price_ready = settings.PRICE_30_DAYS > 0 and settings.AVAILABLE_SUBSCRIPTION_PERIODS
@@ -807,7 +808,7 @@ async def handle_import_message(
content = ''
if message.document:
buffer = io.BytesIO()
await message.document.download(destination=buffer)
await message.bot.download(message.document, destination=buffer)
buffer.seek(0)
content = buffer.read().decode('utf-8', errors='ignore')
else:
@@ -2689,6 +2690,128 @@ async def apply_setting_choice(
await callback.answer('Значение обновлено')
# ── Remnawave App Config Selector ──
@admin_required
@error_handler
async def show_remna_config_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession, **kwargs):
"""Show available Remnawave subscription page configs for selection."""
current_uuid = bot_configuration_service.get_current_value('CABINET_REMNA_SUB_CONFIG')
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
configs = await api.get_subscription_page_configs()
except Exception as e:
logger.error('Failed to load Remnawave configs', error=e)
await callback.answer('Ошибка загрузки конфигов', show_alert=True)
return
keyboard: list[list[types.InlineKeyboardButton]] = []
if not configs:
text = (
'📱 <b>Конфиг приложений (Remnawave)</b>\n\n'
'В Remnawave не найдено конфигураций страниц подписки.\n\n'
'Создайте конфигурацию в панели Remnawave, затем вернитесь сюда для выбора.'
)
else:
text = '📱 <b>Конфиг приложений (Remnawave)</b>\n\n'
if current_uuid:
current_name = next((c.name for c in configs if c.uuid == current_uuid), None)
if current_name:
text += f'✅ Текущий: <b>{html.escape(current_name)}</b>\n\n'
else:
text += f'⚠️ Текущий UUID не найден: <code>{html.escape(str(current_uuid))}</code>\n\n'
else:
text += 'ℹ️ Конфиг не выбран (гайд-режим отключён)\n\n'
text += 'Выберите конфигурацию для гайд-режима:'
for config in configs:
prefix = '' if config.uuid == current_uuid else ''
keyboard.append(
[
types.InlineKeyboardButton(
text=f'{prefix}{config.name}',
callback_data=f'admin_remna_select_{config.uuid}',
)
]
)
if current_uuid:
keyboard.append(
[
types.InlineKeyboardButton(
text='🗑 Сбросить (отключить гайд-режим)',
callback_data='admin_remna_clear',
)
]
)
keyboard.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_submenu_settings')])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode='HTML',
)
await callback.answer()
@admin_required
@error_handler
async def select_remna_config(callback: types.CallbackQuery, db_user: User, db: AsyncSession, **kwargs):
"""Select a Remnawave subscription page config."""
uuid = callback.data.replace('admin_remna_select_', '')
# Validate UUID format
import re as _re
if not _re.match(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', uuid):
await callback.answer('Некорректный UUID конфигурации', show_alert=True)
return
try:
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid)
await db.commit()
except Exception as e:
logger.error('Failed to save Remnawave config UUID', error=e)
await callback.answer('Ошибка сохранения', show_alert=True)
return
# Invalidate app config cache
from app.handlers.subscription.common import invalidate_app_config_cache
invalidate_app_config_cache()
await callback.answer('✅ Конфиг выбран', show_alert=True)
# Re-render the menu
await show_remna_config_menu(callback, db_user=db_user, db=db)
@admin_required
@error_handler
async def clear_remna_config(callback: types.CallbackQuery, db_user: User, db: AsyncSession, **kwargs):
"""Clear the Remnawave config, disabling guide mode until new config is selected."""
try:
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', '')
await db.commit()
except Exception as e:
logger.error('Failed to clear Remnawave config', error=e)
await callback.answer('Ошибка сброса', show_alert=True)
return
from app.handlers.subscription.common import invalidate_app_config_cache
invalidate_app_config_cache()
await callback.answer('✅ Конфиг сброшен', show_alert=True)
await show_remna_config_menu(callback, db_user=db_user, db=db)
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(
show_bot_config_menu,
@@ -2788,3 +2911,16 @@ def register_handlers(dp: Dispatcher) -> None:
handle_import_message,
BotConfigStates.waiting_for_import_file,
)
# Remnawave app config selector
dp.callback_query.register(
show_remna_config_menu,
F.data == 'admin_remna_config',
)
dp.callback_query.register(
select_remna_config,
F.data.startswith('admin_remna_select_'),
)
dp.callback_query.register(
clear_remna_config,
F.data == 'admin_remna_clear',
)
-11
View File
@@ -1402,17 +1402,6 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
# Задержка между батчами для соблюдения rate limits
await asyncio.sleep(_BATCH_DELAY)
# Фоновая очистка заблокировавших бота пользователей
if blocked_telegram_ids:
from app.services.broadcast_service import _background_tasks, cleanup_blocked_broadcast_users
task = asyncio.create_task(
cleanup_blocked_broadcast_users(blocked_telegram_ids),
name=f'broadcast-{broadcast_id}-blocked-cleanup',
)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
# Учитываем пропущенных email-only пользователей
skipped_email_users = total_users_count - total_recipients
if skipped_email_users > 0:
+8 -21
View File
@@ -137,13 +137,16 @@ def _build_notification_settings_view(language: str):
return summary_text, keyboard
def _build_notification_preview_message(language: str, notification_type: str):
async def _build_notification_preview_message(language: str, notification_type: str):
texts = get_texts(language)
now = datetime.now(UTC)
price_30_days = settings.format_price(settings.PRICE_30_DAYS)
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from app.keyboards.inline import get_channel_sub_keyboard
from app.services.channel_subscription_service import channel_subscription_service
header = '🧪 <b>Тестовое уведомление мониторинга</b>\n\n'
if notification_type == 'trial_channel_unsubscribed':
@@ -157,25 +160,9 @@ def _build_notification_preview_message(language: str, notification_type: str):
)
check_button = texts.t('CHANNEL_CHECK_BUTTON', '✅ Я подписался')
message = template.format(check_button=check_button)
buttons: list[list[InlineKeyboardButton]] = []
if settings.CHANNEL_LINK:
buttons.append(
[
InlineKeyboardButton(
text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'),
url=settings.CHANNEL_LINK,
)
]
)
buttons.append(
[
InlineKeyboardButton(
text=check_button,
callback_data='sub_channel_check',
)
]
)
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
# Use all required channels for the preview keyboard
required_channels = await channel_subscription_service.get_required_channels()
keyboard = get_channel_sub_keyboard(required_channels, language=language)
elif notification_type == 'expired_1d':
template = texts.get(
'SUBSCRIPTION_EXPIRED_1D',
@@ -307,7 +294,7 @@ def _build_notification_preview_message(language: str, notification_type: str):
async def _send_notification_preview(bot, chat_id: int, language: str, notification_type: str) -> None:
message, keyboard = _build_notification_preview_message(language, notification_type)
message, keyboard = await _build_notification_preview_message(language, notification_type)
await bot.send_message(
chat_id,
message,
+273
View File
@@ -0,0 +1,273 @@
"""Admin handler for managing required channel subscriptions."""
import structlog
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from app.database.crud.required_channel import (
add_channel,
delete_channel,
get_all_channels,
get_channel_by_id,
toggle_channel,
validate_channel_id,
)
from app.database.database import AsyncSessionLocal
from app.services.channel_subscription_service import channel_subscription_service
from app.utils.decorators import admin_required
logger = structlog.get_logger(__name__)
router = Router(name='admin_required_channels')
class AddChannelStates(StatesGroup):
waiting_channel_id = State()
waiting_channel_link = State()
waiting_channel_title = State()
# -- List channels ----------------------------------------------------------------
def _channels_keyboard(channels: list) -> InlineKeyboardMarkup:
buttons = []
for ch in channels:
status = '' if ch.is_active else ''
title = ch.title or ch.channel_id
buttons.append(
[
InlineKeyboardButton(
text=f'{status} {title}',
callback_data=f'reqch:view:{ch.id}',
)
]
)
buttons.append([InlineKeyboardButton(text=' Добавить канал', callback_data='reqch:add')])
buttons.append([InlineKeyboardButton(text='◀️ Назад', callback_data='admin_submenu_settings')])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def _channel_detail_keyboard(channel_id: int, is_active: bool) -> InlineKeyboardMarkup:
toggle_text = '❌ Отключить' if is_active else '✅ Включить'
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=toggle_text, callback_data=f'reqch:toggle:{channel_id}')],
[InlineKeyboardButton(text='🗑 Удалить', callback_data=f'reqch:delete:{channel_id}')],
[InlineKeyboardButton(text='◀️ К списку', callback_data='reqch:list')],
]
)
@router.callback_query(F.data == 'reqch:list')
@admin_required
async def show_channels_list(callback: CallbackQuery, **kwargs) -> None:
async with AsyncSessionLocal() as db:
channels = await get_all_channels(db)
if not channels:
text = '<b>📢 Обязательные каналы</b>\n\nКаналы не настроены. Нажмите «Добавить» чтобы создать.'
else:
lines = ['<b>📢 Обязательные каналы</b>\n']
for ch in channels:
status = '' if ch.is_active else ''
title = ch.title or ch.channel_id
lines.append(f'{status} <code>{ch.channel_id}</code> — {title}')
text = '\n'.join(lines)
await callback.message.edit_text(text, reply_markup=_channels_keyboard(channels))
await callback.answer()
@router.callback_query(F.data.startswith('reqch:view:'))
@admin_required
async def view_channel(callback: CallbackQuery, **kwargs) -> None:
try:
channel_db_id = int(callback.data.split(':')[2])
except (ValueError, IndexError):
await callback.answer('Неверный ID канала', show_alert=True)
return
async with AsyncSessionLocal() as db:
ch = await get_channel_by_id(db, channel_db_id)
if not ch:
await callback.answer('Канал не найден', show_alert=True)
return
status = '✅ Активен' if ch.is_active else '❌ Отключён'
text = (
f'<b>{ch.title or "Без названия"}</b>\n\n'
f'<b>ID:</b> <code>{ch.channel_id}</code>\n'
f'<b>Ссылка:</b> {ch.channel_link or ""}\n'
f'<b>Статус:</b> {status}\n'
f'<b>Порядок:</b> {ch.sort_order}'
)
await callback.message.edit_text(text, reply_markup=_channel_detail_keyboard(ch.id, ch.is_active))
await callback.answer()
# -- Toggle / Delete ---------------------------------------------------------------
@router.callback_query(F.data.startswith('reqch:toggle:'))
@admin_required
async def toggle_channel_handler(callback: CallbackQuery, **kwargs) -> None:
try:
channel_db_id = int(callback.data.split(':')[2])
except (ValueError, IndexError):
await callback.answer('Неверный ID канала', show_alert=True)
return
async with AsyncSessionLocal() as db:
ch = await toggle_channel(db, channel_db_id)
if ch:
await channel_subscription_service.invalidate_channels_cache()
status = 'включён' if ch.is_active else 'отключён'
await callback.answer(f'Канал {status}', show_alert=True)
# Refresh list
async with AsyncSessionLocal() as db:
channels = await get_all_channels(db)
await callback.message.edit_text(
'<b>📢 Обязательные каналы</b>',
reply_markup=_channels_keyboard(channels),
)
@router.callback_query(F.data.startswith('reqch:delete:'))
@admin_required
async def delete_channel_handler(callback: CallbackQuery, **kwargs) -> None:
try:
channel_db_id = int(callback.data.split(':')[2])
except (ValueError, IndexError):
await callback.answer('Неверный ID канала', show_alert=True)
return
async with AsyncSessionLocal() as db:
ok = await delete_channel(db, channel_db_id)
if ok:
await channel_subscription_service.invalidate_channels_cache()
await callback.answer('Канал удалён', show_alert=True)
else:
await callback.answer('Ошибка удаления', show_alert=True)
async with AsyncSessionLocal() as db:
channels = await get_all_channels(db)
await callback.message.edit_text(
'<b>📢 Обязательные каналы</b>',
reply_markup=_channels_keyboard(channels),
)
# -- Add channel flow --------------------------------------------------------------
@router.callback_query(F.data == 'reqch:add')
@admin_required
async def start_add_channel(callback: CallbackQuery, state: FSMContext, **kwargs) -> None:
await state.set_state(AddChannelStates.waiting_channel_id)
await callback.message.edit_text(
'<b>➕ Добавить канал</b>\n\n'
'Отправьте числовой ID канала (например <code>1234567890</code>).\n'
'Префикс <code>-100</code> добавляется автоматически.'
)
await callback.answer()
@router.message(AddChannelStates.waiting_channel_id)
@admin_required
async def process_channel_id(message: Message, state: FSMContext, **kwargs) -> None:
if not message.text:
await message.answer('Отправьте текстовое сообщение.')
return
channel_id = message.text.strip()
# Validate and normalize channel_id (auto-prefixes -100 for bare digits)
try:
channel_id = validate_channel_id(channel_id)
except ValueError as e:
await message.answer(f'Неверный формат. {e}\n\nПопробуйте ещё раз:')
return
await state.update_data(channel_id=channel_id)
await state.set_state(AddChannelStates.waiting_channel_link)
await message.answer(
f'Канал: <code>{channel_id}</code>\n\n'
'Теперь отправьте ссылку на канал (например <code>https://t.me/mychannel</code>)\n'
'Или отправьте <code>-</code> чтобы пропустить:'
)
@router.message(AddChannelStates.waiting_channel_link)
@admin_required
async def process_channel_link(message: Message, state: FSMContext, **kwargs) -> None:
if not message.text:
await message.answer('Отправьте текстовое сообщение.')
return
link = message.text.strip()
if link == '-':
link = None
if link is not None:
# Validate and normalize channel link
if not link.startswith(('https://t.me/', 'http://t.me/', '@')):
await message.answer('Ссылка должна быть URL вида t.me или @username. Попробуйте ещё раз:')
return
if link.startswith('@'):
link = f'https://t.me/{link[1:]}'
if link.startswith('http://'):
link = link.replace('http://', 'https://', 1)
await state.update_data(channel_link=link)
await state.set_state(AddChannelStates.waiting_channel_title)
await message.answer(
'Отправьте название канала (например <code>Новости проекта</code>)\n'
'Или отправьте <code>-</code> чтобы пропустить:'
)
@router.message(AddChannelStates.waiting_channel_title)
@admin_required
async def process_channel_title(message: Message, state: FSMContext, **kwargs) -> None:
if not message.text:
await message.answer('Отправьте текстовое сообщение.')
return
title = message.text.strip()
if title == '-':
title = None
data = await state.get_data()
await state.clear()
async with AsyncSessionLocal() as db:
try:
ch = await add_channel(
db,
channel_id=data['channel_id'],
channel_link=data.get('channel_link'),
title=title,
)
await channel_subscription_service.invalidate_channels_cache()
text = (
'✅ Канал добавлен!\n\n'
f'<b>ID:</b> <code>{ch.channel_id}</code>\n'
f'<b>Ссылка:</b> {ch.channel_link or ""}\n'
f'<b>Название:</b> {ch.title or ""}'
)
except Exception as e:
text = '❌ Ошибка добавления канала. Попробуйте ещё раз.'
logger.error('Error adding channel', error=e)
async with AsyncSessionLocal() as db:
channels = await get_all_channels(db)
await message.answer(text, reply_markup=_channels_keyboard(channels))
def register_handlers(dp_router: Router) -> None:
dp_router.include_router(router)
+12 -1
View File
@@ -4013,7 +4013,11 @@ async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, adm
async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import deactivate_subscription, get_subscription_by_user_id
from app.database.crud.subscription import (
deactivate_subscription,
get_subscription_by_user_id,
is_active_paid_subscription,
)
from app.services.subscription_service import SubscriptionService
subscription = await get_subscription_by_user_id(db, user_id)
@@ -4021,6 +4025,13 @@ async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id
logger.error('Подписка не найдена для пользователя', user_id=user_id)
return False
if is_active_paid_subscription(subscription):
logger.info(
'⏭️ Пропуск деактивации: у пользователя активная оплаченная подписка',
user_id=user_id,
)
return False
await deactivate_subscription(db, subscription)
user = await get_user_by_id(db, user_id)
+184
View File
@@ -0,0 +1,184 @@
"""ChatMemberUpdated event handler for real-time channel subscription tracking.
KEY COMPONENT for scalability: the bot receives push notifications from Telegram
when users join/leave channels, instead of polling via getChatMember.
Requirement: bot must be admin in each required channel.
IMPORTANT: Events are FILTERED to only process required channels.
Without filtering, the bot would process events from ALL channels it admins.
"""
from datetime import UTC, datetime
import structlog
from aiogram import Bot, Router
from aiogram.filters import IS_MEMBER, IS_NOT_MEMBER, ChatMemberUpdatedFilter
from aiogram.types import ChatMemberUpdated
from app.config import settings
from app.database.crud.subscription import deactivate_subscription, is_active_paid_subscription, reactivate_subscription
from app.database.crud.user import get_user_by_telegram_id
from app.database.database import AsyncSessionLocal
from app.database.models import SubscriptionStatus, UserStatus
from app.keyboards.inline import get_channel_sub_keyboard
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
from app.services.channel_subscription_service import channel_subscription_service
from app.services.subscription_service import SubscriptionService
logger = structlog.get_logger(__name__)
router = Router(name='channel_member')
async def _is_required_channel(channel_id: str) -> bool:
"""Check if the channel_id is one of our required channels."""
required_ids = await channel_subscription_service.get_required_channel_ids()
return channel_id in required_ids
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=IS_NOT_MEMBER >> IS_MEMBER))
async def on_user_joined_channel(event: ChatMemberUpdated, bot: Bot) -> None:
"""User subscribed to a channel -- update cache and reactivate VPN if applicable."""
user = event.new_chat_member.user
channel_id = str(event.chat.id) # Normalize int to str (DB stores string)
# FILTER: Only process events for required channels
if not await _is_required_channel(channel_id):
return
await channel_subscription_service.on_user_joined(user.id, channel_id)
# Check if user is now subscribed to ALL required channels
if not settings.CHANNEL_IS_REQUIRED_SUB:
return
is_all_subscribed = await channel_subscription_service.is_user_subscribed_to_all(user.id)
if not is_all_subscribed:
return # Still missing some channels
# Reactivate subscription if it was disabled due to channel unsubscribe
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
return
async with AsyncSessionLocal() as db:
try:
db_user = await get_user_by_telegram_id(db, user.id)
if not db_user or not db_user.subscription:
return
if db_user.status == UserStatus.BLOCKED.value:
return
subscription = db_user.subscription
if subscription.status != SubscriptionStatus.DISABLED.value:
return
# Don't reactivate expired subscriptions
if subscription.end_date and subscription.end_date <= datetime.now(UTC):
return
await reactivate_subscription(db, subscription)
logger.info('Subscription reactivated via channel event', telegram_id=user.id)
# Re-enable in RemnaWave panel
if db_user.remnawave_uuid:
service = SubscriptionService()
try:
await service.enable_remnawave_user(db_user.remnawave_uuid)
except Exception as api_error:
logger.error('Failed to enable RemnaWave user', error=api_error)
# Notify the user
try:
texts = get_texts(db_user.language or DEFAULT_LANGUAGE)
notification_text = texts.t(
'SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE',
'Your subscription has been restored! Thank you for subscribing to the channels.',
)
await bot.send_message(user.id, notification_text)
except Exception as notify_error:
logger.warning('Failed to send notification', telegram_id=user.id, error=notify_error)
await db.commit()
except Exception as e:
logger.error('Error reactivating subscription on channel join', error=e)
await db.rollback()
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=IS_MEMBER >> IS_NOT_MEMBER))
async def on_user_left_channel(event: ChatMemberUpdated, bot: Bot) -> None:
"""User unsubscribed from a channel -- update cache and deactivate VPN if applicable."""
user = event.old_chat_member.user
channel_id = str(event.chat.id) # Normalize int to str (DB stores string)
# FILTER: Only process events for required channels
if not await _is_required_channel(channel_id):
return
await channel_subscription_service.on_user_left(user.id, channel_id)
if not settings.CHANNEL_IS_REQUIRED_SUB:
return
# Skip admins -- never deactivate admin subscriptions
if settings.is_admin(user.id):
return
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
return
async with AsyncSessionLocal() as db:
try:
db_user = await get_user_by_telegram_id(db, user.id)
if not db_user or not db_user.subscription:
return
subscription = db_user.subscription
if subscription.status != SubscriptionStatus.ACTIVE.value:
return
# CHANNEL_REQUIRED_FOR_ALL: deactivate regardless of trial status
# CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: only deactivate trial subscriptions
if settings.CHANNEL_REQUIRED_FOR_ALL:
pass # Deactivate any active subscription
elif not subscription.is_trial:
return # Not a trial -- skip
# Guard against paid subscriptions (user paid money, don't punish)
if is_active_paid_subscription(subscription):
return
await deactivate_subscription(db, subscription)
logger.info('Subscription deactivated via channel event', telegram_id=user.id)
# Disable in RemnaWave panel
if db_user.remnawave_uuid:
service = SubscriptionService()
try:
await service.disable_remnawave_user(db_user.remnawave_uuid)
except Exception as api_error:
logger.error('Failed to disable RemnaWave user', error=api_error)
# Notify the user with channel subscription keyboard
try:
texts = get_texts(db_user.language or DEFAULT_LANGUAGE)
unsub_channels = await channel_subscription_service.get_channels_with_status(user.id)
notification_text = texts.t(
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE',
'Your subscription has been paused because you left a required channel.',
)
channel_kb = get_channel_sub_keyboard(unsub_channels, language=db_user.language or DEFAULT_LANGUAGE)
await bot.send_message(user.id, notification_text, reply_markup=channel_kb)
except Exception as notify_error:
logger.warning('Failed to send notification', telegram_id=user.id, error=notify_error)
await db.commit()
except Exception as e:
logger.error('Error deactivating subscription on channel leave', error=e)
await db.rollback()
def register_handlers(dp_router: Router) -> None:
"""Register channel member event handlers on the dispatcher/router."""
dp_router.include_router(router)
+27 -19
View File
@@ -2,7 +2,6 @@ from datetime import UTC, datetime
import structlog
from aiogram import Bot, Dispatcher, F, types
from aiogram.enums import ChatMemberStatus
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
@@ -37,6 +36,7 @@ from app.middlewares.channel_checker import (
)
from app.services.admin_notification_service import AdminNotificationService
from app.services.campaign_service import AdvertisingCampaignService
from app.services.channel_subscription_service import channel_subscription_service
from app.services.main_menu_button_service import MainMenuButtonService
from app.services.pinned_message_service import (
deliver_pinned_message_to_user,
@@ -1174,11 +1174,14 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
if existing_user and existing_user.status == UserStatus.DELETED.value:
logger.info('🔄 Восстанавливаем удаленного пользователя', from_user_id=callback.from_user.id)
# Prevent self-referral when partner re-registers via own campaign link
safe_referrer_id = referrer_id if referrer_id != existing_user.id else None
existing_user.username = callback.from_user.username
existing_user.first_name = callback.from_user.first_name
existing_user.last_name = callback.from_user.last_name
existing_user.language = language
existing_user.referred_by_id = referrer_id
existing_user.referred_by_id = safe_referrer_id
existing_user.status = UserStatus.ACTIVE.value
existing_user.balance_kopeks = 0
existing_user.has_had_paid_subscription = False
@@ -1212,7 +1215,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
logger.info('🔄 Обновляем существующего пользователя', from_user_id=callback.from_user.id)
existing_user.status = UserStatus.ACTIVE.value
existing_user.language = language
if referrer_id and not existing_user.referred_by_id:
if referrer_id and referrer_id != existing_user.id and not existing_user.referred_by_id:
existing_user.referred_by_id = referrer_id
existing_user.updated_at = datetime.now(UTC)
@@ -1222,7 +1225,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
await db.refresh(existing_user, ['subscription'])
user = existing_user
if referrer_id:
if referrer_id and referrer_id != user.id:
try:
await process_referral_registration(db, user.id, referrer_id, callback.bot)
logger.info('✅ Реферальная регистрация обработана для', user_id=user.id)
@@ -1436,11 +1439,14 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
if existing_user and existing_user.status == UserStatus.DELETED.value:
logger.info('🔄 Восстанавливаем удаленного пользователя', from_user_id=message.from_user.id)
# Prevent self-referral when partner re-registers via own campaign link
safe_referrer_id = referrer_id if referrer_id != existing_user.id else None
existing_user.username = message.from_user.username
existing_user.first_name = message.from_user.first_name
existing_user.last_name = message.from_user.last_name
existing_user.language = language
existing_user.referred_by_id = referrer_id
existing_user.referred_by_id = safe_referrer_id
existing_user.status = UserStatus.ACTIVE.value
existing_user.balance_kopeks = 0
existing_user.has_had_paid_subscription = False
@@ -1474,7 +1480,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
logger.info('🔄 Обновляем существующего пользователя', from_user_id=message.from_user.id)
existing_user.status = UserStatus.ACTIVE.value
existing_user.language = language
if referrer_id and not existing_user.referred_by_id:
if referrer_id and referrer_id != existing_user.id and not existing_user.referred_by_id:
existing_user.referred_by_id = referrer_id
existing_user.updated_at = datetime.now(UTC)
@@ -1484,7 +1490,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
await db.refresh(existing_user, ['subscription'])
user = existing_user
if referrer_id:
if referrer_id and referrer_id != user.id:
try:
await process_referral_registration(db, user.id, referrer_id, message.bot)
logger.info('✅ Реферальная регистрация обработана для', user_id=user.id)
@@ -1860,20 +1866,22 @@ async def required_sub_channel_check(
texts = get_texts(language)
chat_member = await bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=query.from_user.id)
# Ensure bot is set on service
if not channel_subscription_service.bot:
channel_subscription_service.bot = bot
if chat_member.status not in [
ChatMemberStatus.MEMBER,
ChatMemberStatus.ADMINISTRATOR,
ChatMemberStatus.CREATOR,
]:
# Invalidate cache for fresh check (user just clicked "I subscribed")
await channel_subscription_service.invalidate_user_cache(query.from_user.id)
is_subscribed = await channel_subscription_service.is_user_subscribed_to_all(query.from_user.id)
if not is_subscribed:
# НЕ удаляем payload - пользователь может попробовать снова после подписки
logger.info(
"📦 CHANNEL CHECK: Подписка не подтверждена, payload '' сохранён для следующей попытки",
'CHANNEL CHECK: Подписка не подтверждена, payload сохранён для следующей попытки',
pending_start_payload=pending_start_payload,
)
return await query.answer(
texts.t('CHANNEL_SUBSCRIBE_REQUIRED_ALERT', '❌ Вы не подписались на канал!'),
texts.t('CHANNEL_SUBSCRIBE_REQUIRED_ALERT', 'Please subscribe to all required channels first!'),
show_alert=True,
)
@@ -1989,7 +1997,7 @@ async def required_sub_channel_check(
custom_buttons=custom_buttons,
)
if settings.ENABLE_LOGO_MODE:
if settings.ENABLE_LOGO_MODE and len(menu_text) <= 900:
_result = await bot.send_photo(
chat_id=query.from_user.id,
photo=get_logo_media(),
@@ -2046,7 +2054,7 @@ async def required_sub_channel_check(
logger.info('✅ CHANNEL CHECK: pending_start_payload удален из state после создания пользователя')
# Обрабатываем реферальную регистрацию
if referrer_id:
if referrer_id and referrer_id != user.id:
try:
await process_referral_registration(db, user.id, referrer_id, bot)
logger.info('✅ CHANNEL CHECK: Реферальная регистрация обработана для', user_id=user.id)
@@ -2082,7 +2090,7 @@ async def required_sub_channel_check(
custom_buttons=custom_buttons,
)
if settings.ENABLE_LOGO_MODE:
if settings.ENABLE_LOGO_MODE and len(menu_text) <= 900:
_result = await bot.send_photo(
chat_id=query.from_user.id,
photo=get_logo_media(),
@@ -2112,7 +2120,7 @@ async def required_sub_channel_check(
else:
rules_text = await get_rules(language)
if settings.ENABLE_LOGO_MODE:
if settings.ENABLE_LOGO_MODE and len(rules_text) <= 900:
_result = await bot.send_photo(
chat_id=query.from_user.id,
photo=get_logo_media(),
+14 -8
View File
@@ -11,16 +11,19 @@ from .autopay import (
from .common import (
build_redirect_link,
create_deep_link,
format_additional_section,
format_traffic_display,
get_apps_for_device,
get_apps_for_platform_async,
get_confirm_switch_traffic_keyboard,
get_device_name,
get_localized_value,
get_platforms_list,
get_reset_devices_confirm_keyboard,
get_step_description,
get_traffic_switch_keyboard,
load_app_config,
invalidate_app_config_cache,
load_app_config_async,
normalize_app,
render_guide_blocks,
resolve_button_url,
update_traffic_prices,
validate_traffic_price,
)
@@ -132,18 +135,17 @@ __all__ = [
'devices_continue',
'execute_change_devices',
'execute_switch_traffic',
'format_additional_section',
'format_traffic_display',
'get_apps_for_device',
'get_apps_for_platform_async',
'get_confirm_switch_traffic_keyboard',
'get_countries_price_by_uuids_fallback',
'get_current_devices_count',
'get_current_devices_detailed',
'get_device_name',
'get_localized_value',
'get_platforms_list',
'get_reset_devices_confirm_keyboard',
'get_servers_display_names',
'get_step_description',
'get_subscription_cost',
'get_subscription_info_text',
'get_traffic_packages_info',
@@ -176,9 +178,13 @@ __all__ = [
'handle_subscription_config_back',
'handle_subscription_settings',
'handle_switch_traffic',
'load_app_config',
'invalidate_app_config_cache',
'load_app_config_async',
'normalize_app',
'refresh_traffic_config',
'register_handlers',
'render_guide_blocks',
'resolve_button_url',
'resume_subscription_checkout',
'return_to_saved_cart',
'save_cart_and_redirect_to_topup',
+243 -78
View File
@@ -1,5 +1,8 @@
import asyncio
import base64
import json
import html as html_mod
import re
import time
from datetime import datetime
from typing import Any
from urllib.parse import quote
@@ -23,23 +26,34 @@ logger = structlog.get_logger(__name__)
TRAFFIC_PRICES = get_traffic_prices()
# ── App config cache ──
_app_config_cache: dict[str, Any] = {}
_app_config_cache_ts: float = 0.0
_app_config_lock = asyncio.Lock()
class _SafeFormatDict(dict):
def __missing__(self, key: str) -> str: # pragma: no cover - defensive fallback
return '{' + key + '}'
_PLACEHOLDER_RE = re.compile(r'\{(\w+)\}')
def _format_text_with_placeholders(template: str, values: dict[str, Any]) -> str:
"""Safe placeholder substitution — only replaces simple {key} patterns.
Unlike str.format_map, this does NOT allow attribute access ({key.attr})
or indexing ({key[0]}), preventing format string injection attacks.
"""
if not isinstance(template, str):
return template
safe_values = _SafeFormatDict()
safe_values.update(values)
def _replace(match: re.Match) -> str:
key = match.group(1)
if key in values:
return str(values[key])
return match.group(0)
try:
return template.format_map(safe_values)
return _PLACEHOLDER_RE.sub(_replace, template)
except Exception: # pragma: no cover - defensive logging
logger.warning("Failed to format template '' with values", template=template, values=values)
logger.warning('Failed to format template with values', template=template, values=values)
return template
@@ -152,23 +166,6 @@ def validate_traffic_price(gb: int) -> bool:
return price > 0
def load_app_config() -> dict[str, Any]:
try:
from app.config import settings
config_path = settings.get_app_config_path()
with open(config_path, encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, dict):
return data
logger.error('Некорректный формат app-config.json: ожидается объект')
except Exception as e:
logger.error('Ошибка загрузки конфига приложений', error=e)
return {}
def get_localized_value(values: Any, language: str, default_language: str = 'en') -> str:
if not isinstance(values, dict):
return ''
@@ -199,39 +196,29 @@ def get_localized_value(values: Any, language: str, default_language: str = 'en'
return ''
def get_step_description(app: dict[str, Any], step_key: str, language: str) -> str:
if not isinstance(app, dict):
return ''
step = app.get(step_key)
if not isinstance(step, dict):
return ''
description = step.get('description')
return get_localized_value(description, language)
def format_additional_section(additional: Any, texts, language: str) -> str:
if not isinstance(additional, dict):
return ''
title = get_localized_value(additional.get('title'), language)
description = get_localized_value(additional.get('description'), language)
def render_guide_blocks(blocks: list[dict], language: str) -> str:
"""Render block-format guide steps to HTML text."""
parts: list[str] = []
if title:
parts.append(
texts.t(
'SUBSCRIPTION_ADDITIONAL_STEP_TITLE',
'<b>{title}:</b>',
).format(title=title)
step_num = 1
for block in blocks:
if not isinstance(block, dict):
continue
title = block.get('title', {})
desc = block.get('description', {})
title_text = html_mod.escape(
get_localized_value(title, language) if isinstance(title, dict) else str(title or '')
)
if description:
parts.append(description)
return '\n'.join(parts)
desc_text = html_mod.escape(get_localized_value(desc, language) if isinstance(desc, dict) else str(desc or ''))
if title_text or desc_text:
step = f'<b>Шаг {step_num}'
if title_text:
step += f' - {title_text}'
step += ':</b>'
if desc_text:
step += f'\n{desc_text}'
parts.append(step)
step_num += 1
return '\n\n'.join(parts)
def build_redirect_link(target_link: str | None, template: str | None) -> str | None:
@@ -266,34 +253,13 @@ def build_redirect_link(target_link: str | None, template: str | None) -> str |
return result
def get_apps_for_device(device_type: str, language: str = 'ru') -> list[dict[str, Any]]:
config = load_app_config()
platforms = config.get('platforms', {}) if isinstance(config, dict) else {}
if not isinstance(platforms, dict):
return []
device_mapping = {
'ios': 'ios',
'android': 'android',
'windows': 'windows',
'mac': 'macos',
'tv': 'androidTV',
'appletv': 'appleTV',
'apple_tv': 'appleTV',
}
config_key = device_mapping.get(device_type, device_type)
apps = platforms.get(config_key, [])
return apps if isinstance(apps, list) else []
def get_device_name(device_type: str, language: str = 'ru') -> str:
names = {
'ios': 'iPhone/iPad',
'android': 'Android',
'windows': 'Windows',
'mac': 'macOS',
'linux': 'Linux',
'tv': 'Android TV',
'appletv': 'Apple TV',
'apple_tv': 'Apple TV',
@@ -302,6 +268,205 @@ def get_device_name(device_type: str, language: str = 'ru') -> str:
return names.get(device_type, device_type)
# ── Remnawave async config loader ──
_PLATFORM_DISPLAY = {
'ios': {'name': 'iPhone/iPad', 'emoji': '📱'},
'android': {'name': 'Android', 'emoji': '🤖'},
'windows': {'name': 'Windows', 'emoji': '💻'},
'macos': {'name': 'macOS', 'emoji': '🎯'},
'linux': {'name': 'Linux', 'emoji': '🐧'},
'androidTV': {'name': 'Android TV', 'emoji': '📺'},
'appleTV': {'name': 'Apple TV', 'emoji': '📺'},
}
# Map callback device_type keys to Remnawave platform keys
_DEVICE_TO_PLATFORM = {
'ios': 'ios',
'android': 'android',
'windows': 'windows',
'mac': 'macos',
'linux': 'linux',
'tv': 'androidTV',
'appletv': 'appleTV',
'apple_tv': 'appleTV',
}
# Reverse: Remnawave platform key → callback device_type
_PLATFORM_TO_DEVICE = {
'ios': 'ios',
'android': 'android',
'windows': 'windows',
'macos': 'mac',
'linux': 'linux',
'androidTV': 'tv',
'appleTV': 'appletv',
}
def _get_remnawave_config_uuid() -> str | None:
try:
from app.services.system_settings_service import bot_configuration_service
return bot_configuration_service.get_current_value('CABINET_REMNA_SUB_CONFIG')
except Exception as e:
logger.debug('Could not read CABINET_REMNA_SUB_CONFIG from service, using settings fallback', error=e)
return getattr(settings, 'CABINET_REMNA_SUB_CONFIG', None)
async def load_app_config_async() -> dict[str, Any] | None:
"""Load app config from Remnawave API (if configured), with TTL cache.
Returns None when no Remnawave config is set or API fails.
"""
global _app_config_cache, _app_config_cache_ts
ttl = settings.APP_CONFIG_CACHE_TTL
if _app_config_cache and (time.monotonic() - _app_config_cache_ts) < ttl:
return _app_config_cache
async with _app_config_lock:
# Double-check after acquiring lock
if _app_config_cache and (time.monotonic() - _app_config_cache_ts) < ttl:
return _app_config_cache
remnawave_uuid = _get_remnawave_config_uuid()
if remnawave_uuid:
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
async with service.get_api_client() as api:
config = await api.get_subscription_page_config(remnawave_uuid)
if config and config.config:
raw = dict(config.config)
raw['_isRemnawave'] = True
_app_config_cache = raw
_app_config_cache_ts = time.monotonic()
logger.debug('Loaded app config from Remnawave', remnawave_uuid=remnawave_uuid)
return raw
except Exception as e:
logger.warning('Failed to load Remnawave config', error=e)
return None
def invalidate_app_config_cache() -> None:
"""Clear the cached app config so next call re-fetches from Remnawave.
Note: This is intentionally sync (called from sync contexts in cabinet API).
Setting timestamp to 0 first ensures the fast-path check in load_app_config_async
fails immediately, even without acquiring _app_config_lock.
"""
global _app_config_cache, _app_config_cache_ts
_app_config_cache_ts = 0.0
_app_config_cache = {}
async def get_apps_for_platform_async(device_type: str, language: str = 'ru') -> list[dict[str, Any]]:
"""Get apps for a device type from Remnawave config."""
config = await load_app_config_async()
if not config:
return []
platforms = config.get('platforms', {})
if not isinstance(platforms, dict):
return []
platform_key = _DEVICE_TO_PLATFORM.get(device_type, device_type)
platform_data = platforms.get(platform_key)
if isinstance(platform_data, dict):
apps = platform_data.get('apps', [])
return [normalize_app(app) for app in apps if isinstance(app, dict)]
return []
def normalize_app(app: dict[str, Any]) -> dict[str, Any]:
"""Normalize Remnawave app dict to a unified format with blocks."""
return {
'id': app.get('id', app.get('name', 'unknown')),
'name': app.get('name', ''),
'isFeatured': app.get('featured', app.get('isFeatured', False)),
'urlScheme': app.get('urlScheme', ''),
'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False),
'blocks': app.get('blocks', []),
'_raw': app,
}
def get_platforms_list(config: dict[str, Any]) -> list[dict[str, Any]]:
"""Extract available platforms from config for keyboard generation.
Returns list of {key, displayName, icon_emoji, device_type} sorted by typical order.
"""
platforms = config.get('platforms', {})
if not isinstance(platforms, dict):
return []
# Desired order
order = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']
result = []
for pk in order:
if pk not in platforms:
continue
pd = platforms[pk]
if not isinstance(pd, dict) or not pd.get('apps'):
continue
display = _PLATFORM_DISPLAY.get(pk, {'name': pk, 'emoji': '📱'})
# Get displayName from Remnawave or fallback
display_name_data = pd.get('displayName', display['name'])
result.append(
{
'key': pk,
'displayName': display_name_data,
'icon_emoji': display['emoji'],
'device_type': _PLATFORM_TO_DEVICE.get(pk, pk),
}
)
# Also include any platforms in config not in our order list
for pk, pd in platforms.items():
if pk in order:
continue
if not isinstance(pd, dict) or not pd.get('apps'):
continue
display = _PLATFORM_DISPLAY.get(pk, {'name': pk, 'emoji': '📱'})
result.append(
{
'key': pk,
'displayName': display.get('name', pk),
'icon_emoji': display.get('emoji', '📱'),
'device_type': _PLATFORM_TO_DEVICE.get(pk, pk),
}
)
return result
def resolve_button_url(
url: str,
subscription_url: str | None,
crypto_link: str | None = None,
) -> str:
"""Resolve template variables in button URLs (port of cabinet's _resolve_button_url)."""
if not url:
return url
result = url
if subscription_url:
result = result.replace('{{SUBSCRIPTION_LINK}}', subscription_url)
if crypto_link:
result = result.replace('{{HAPP_CRYPT3_LINK}}', crypto_link)
result = result.replace('{{HAPP_CRYPT4_LINK}}', crypto_link)
return result
def create_deep_link(app: dict[str, Any], subscription_url: str) -> str | None:
if not subscription_url:
return None
+37 -81
View File
@@ -1,3 +1,4 @@
import html as html_mod
from datetime import UTC, datetime
from aiogram import types
@@ -36,11 +37,10 @@ from app.utils.subscription_utils import (
from .common import (
_get_addon_discount_percent_for_user,
_get_period_hint_from_subscription,
format_additional_section,
get_apps_for_device,
get_apps_for_platform_async,
get_device_name,
get_step_description,
logger,
render_guide_blocks,
)
from .countries import _get_available_countries
@@ -805,6 +805,7 @@ async def handle_devices_page(callback: types.CallbackQuery, db_user: User, db:
async def handle_single_device_reset(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
texts = get_texts(db_user.language)
try:
callback_parts = callback.data.split('_')
if len(callback_parts) < 4:
@@ -828,8 +829,6 @@ async def handle_single_device_reset(callback: types.CallbackQuery, db_user: Use
)
return
texts = get_texts(db_user.language)
try:
from app.services.remnawave_service import RemnaWaveService
@@ -1271,7 +1270,8 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db:
)
return
apps = get_apps_for_device(device_type, db_user.language)
apps = await get_apps_for_platform_async(device_type, db_user.language)
hide_subscription_link = settings.should_hide_subscription_link()
if not apps:
@@ -1286,7 +1286,7 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db:
other_apps = [app for app in apps if isinstance(app, dict) and app.get('id') and app.get('id') != featured_app_id]
other_app_names = ', '.join(
str(app.get('name')).strip()
html_mod.escape(str(app.get('name')).strip())
for app in other_apps
if isinstance(app.get('name'), str) and app.get('name').strip()
)
@@ -1304,34 +1304,20 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db:
else:
link_section = (
texts.t('SUBSCRIPTION_DEVICE_LINK_TITLE', '🔗 <b>Ссылка подписки:</b>')
+ f'\n<code>{subscription_link}</code>\n\n'
+ f'\n<code>{html_mod.escape(subscription_link)}</code>\n\n'
)
installation_description = get_step_description(featured_app, 'installationStep', db_user.language)
add_description = get_step_description(featured_app, 'addSubscriptionStep', db_user.language)
connect_description = get_step_description(featured_app, 'connectAndUseStep', db_user.language)
additional_before_text = format_additional_section(
featured_app.get('additionalBeforeAddSubscriptionStep'),
texts,
db_user.language,
)
additional_after_text = format_additional_section(
featured_app.get('additionalAfterAddSubscriptionStep'),
texts,
db_user.language,
)
guide_text = (
texts.t(
'SUBSCRIPTION_DEVICE_GUIDE_TITLE',
'📱 <b>Настройка для {device_name}</b>',
).format(device_name=get_device_name(device_type, db_user.language))
).format(device_name=html_mod.escape(get_device_name(device_type, db_user.language)))
+ '\n\n'
+ link_section
+ texts.t(
'SUBSCRIPTION_DEVICE_FEATURED_APP',
'📋 <b>Рекомендуемое приложение:</b> {app_name}',
).format(app_name=featured_app.get('name', ''))
).format(app_name=html_mod.escape(featured_app.get('name', '')))
)
if other_app_names:
@@ -1344,20 +1330,9 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db:
'Нажмите кнопку "Другие приложения" ниже, чтобы выбрать приложение.',
)
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE', '<b>Шаг 1 - Установка:</b>')
if installation_description:
guide_text += f'\n{installation_description}'
if additional_before_text:
guide_text += f'\n\n{additional_before_text}'
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_ADD_TITLE', '<b>Шаг 2 - Добавление подписки:</b>')
if add_description:
guide_text += f'\n{add_description}'
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE', '<b>Шаг 3 - Подключение:</b>')
if connect_description:
guide_text += f'\n{connect_description}'
blocks_text = render_guide_blocks(featured_app.get('blocks', []), db_user.language)
if blocks_text:
guide_text += '\n\n' + blocks_text
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_HOW_TO_TITLE', '💡 <b>Как подключить:</b>')
guide_text += '\n' + '\n'.join(
@@ -1381,9 +1356,6 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db:
]
)
if additional_after_text:
guide_text += f'\n\n{additional_after_text}'
await callback.message.edit_text(
guide_text,
reply_markup=get_connection_guide_keyboard(
@@ -1402,7 +1374,7 @@ async def handle_app_selection(callback: types.CallbackQuery, db_user: User, db:
device_type = callback.data.split('_')[2]
texts = get_texts(db_user.language)
apps = get_apps_for_device(device_type, db_user.language)
apps = await get_apps_for_platform_async(device_type, db_user.language)
if not apps:
await callback.answer(
@@ -1415,7 +1387,7 @@ async def handle_app_selection(callback: types.CallbackQuery, db_user: User, db:
texts.t(
'SUBSCRIPTION_APPS_TITLE',
'📱 <b>Приложения для {device_name}</b>',
).format(device_name=get_device_name(device_type, db_user.language))
).format(device_name=html_mod.escape(get_device_name(device_type, db_user.language)))
+ '\n\n'
+ texts.t('SUBSCRIPTION_APPS_PROMPT', 'Выберите приложение для подключения:')
)
@@ -1427,7 +1399,11 @@ async def handle_app_selection(callback: types.CallbackQuery, db_user: User, db:
async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
_, device_type, app_id = callback.data.split('_')
parts = callback.data.split('_', 2)
if len(parts) < 3:
await callback.answer('Invalid callback data', show_alert=True)
return
_, device_type, app_id = parts
texts = get_texts(db_user.language)
subscription = db_user.subscription
@@ -1440,8 +1416,8 @@ async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User
)
return
apps = get_apps_for_device(device_type, db_user.language)
app = next((a for a in apps if a['id'] == app_id), None)
apps = await get_apps_for_platform_async(device_type, db_user.language)
app = next((a for a in apps if a.get('id') == app_id), None) if apps else None
if not app:
await callback.answer(
@@ -1465,53 +1441,33 @@ async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User
else:
link_section = (
texts.t('SUBSCRIPTION_DEVICE_LINK_TITLE', '🔗 <b>Ссылка подписки:</b>')
+ f'\n<code>{subscription_link}</code>\n\n'
+ f'\n<code>{html_mod.escape(subscription_link)}</code>\n\n'
)
installation_description = get_step_description(app, 'installationStep', db_user.language)
add_description = get_step_description(app, 'addSubscriptionStep', db_user.language)
connect_description = get_step_description(app, 'connectAndUseStep', db_user.language)
additional_before_text = format_additional_section(
app.get('additionalBeforeAddSubscriptionStep'),
texts,
db_user.language,
)
additional_after_text = format_additional_section(
app.get('additionalAfterAddSubscriptionStep'),
texts,
db_user.language,
)
guide_text = (
texts.t(
'SUBSCRIPTION_SPECIFIC_APP_TITLE',
'📱 <b>{app_name} - {device_name}</b>',
).format(app_name=app.get('name', ''), device_name=get_device_name(device_type, db_user.language))
).format(
app_name=html_mod.escape(app.get('name', '')),
device_name=html_mod.escape(get_device_name(device_type, db_user.language)),
)
+ '\n\n'
+ link_section
)
guide_text += texts.t('SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE', '<b>Шаг 1 - Установка:</b>')
if installation_description:
guide_text += f'\n{installation_description}'
if additional_before_text:
guide_text += f'\n\n{additional_before_text}'
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_ADD_TITLE', '<b>Шаг 2 - Добавление подписки:</b>')
if add_description:
guide_text += f'\n{add_description}'
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE', '<b>Шаг 3 - Подключение:</b>')
if connect_description:
guide_text += f'\n{connect_description}'
if additional_after_text:
guide_text += f'\n\n{additional_after_text}'
blocks_text = render_guide_blocks(app.get('blocks', []), db_user.language)
if blocks_text:
guide_text += blocks_text + '\n\n'
await callback.message.edit_text(
guide_text,
reply_markup=get_specific_app_keyboard(subscription_link, app, device_type, db_user.language),
reply_markup=get_specific_app_keyboard(
subscription_link,
app,
device_type,
db_user.language,
),
parse_mode='HTML',
)
await callback.answer()
@@ -1543,7 +1499,7 @@ async def show_device_connection_help(callback: types.CallbackQuery, db_user: Us
Нажмите "Подключить"
<b>🔗 Ваша ссылка подписки:</b>
<code>{subscription_link}</code>
<code>{html_mod.escape(subscription_link)}</code>
💡 <b>Совет:</b> Сохраните эту ссылку - она понадобится для подключения новых устройств
"""
+32 -1
View File
@@ -16,6 +16,8 @@ from app.utils.subscription_utils import (
get_happ_cryptolink_redirect_link,
)
from .common import get_platforms_list, load_app_config_async, logger
async def handle_connect_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
# Проверяем, доступно ли сообщение для редактирования
@@ -144,6 +146,33 @@ async def handle_connect_subscription(callback: types.CallbackQuery, db_user: Us
parse_mode='HTML',
)
else:
# Guide mode: load config and build dynamic platform keyboard
platforms = None
try:
config = await load_app_config_async()
if config:
platforms = get_platforms_list(config) or None
except Exception as e:
logger.warning('Failed to load platforms for guide mode', error=e)
if not platforms:
await callback.message.edit_text(
texts.t(
'GUIDE_CONFIG_NOT_SET',
'⚠️ <b>Конфигурация не настроена</b>\n\n'
'Администратор ещё не настроил конфигурацию приложений.\n'
'Обратитесь к администратору.',
),
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')],
]
),
parse_mode='HTML',
)
await callback.answer()
return
if hide_subscription_link:
device_text = texts.t(
'SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN',
@@ -165,7 +194,9 @@ async def handle_connect_subscription(callback: types.CallbackQuery, db_user: Us
).format(subscription_url=subscription_link)
await callback.message.edit_text(
device_text, reply_markup=get_device_selection_keyboard(db_user.language), parse_mode='HTML'
device_text,
reply_markup=get_device_selection_keyboard(db_user.language, platforms=platforms),
parse_mode='HTML',
)
await callback.answer()
+1 -1
View File
@@ -4119,7 +4119,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(handle_app_selection, F.data.startswith('app_list_'))
dp.callback_query.register(handle_specific_app_guide, F.data.startswith('app_'))
dp.callback_query.register(handle_specific_app_guide, F.data.startswith('app_') & ~F.data.startswith('app_list_'))
dp.callback_query.register(handle_open_subscription_link, F.data == 'open_subscription_link')
+3 -2
View File
@@ -2702,11 +2702,12 @@ async def show_instant_switch_list(
return
# Рассчитываем оставшиеся дни
now = datetime.now(UTC)
remaining_days = 0
if subscription.end_date:
remaining_days = max(0, (subscription.end_date - datetime.now(UTC)).days)
remaining_days = max(0, (subscription.end_date - now).days)
if remaining_days == 0:
if not subscription.end_date or subscription.end_date <= now:
await callback.message.edit_text(
'❌ <b>Переключение недоступно</b>\n\n'
'У вашей подписки не осталось активных дней.\n'
+12
View File
@@ -217,6 +217,18 @@ def get_admin_settings_submenu_keyboard(language: str = 'ru') -> InlineKeyboardM
callback_data='admin_faq',
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_SETTINGS_REQUIRED_CHANNELS', '📢 Обязательные каналы'),
callback_data='reqch:list',
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_SETTINGS_APP_CONFIG', '📱 Конфиг приложений'),
callback_data='admin_remna_config',
)
],
[InlineKeyboardButton(text=texts.BACK, callback_data='admin_panel')],
]
)
+142 -222
View File
@@ -154,61 +154,6 @@ async def get_main_menu_keyboard_async(
)
def _get_localized_value(values, language: str, default_language: str = 'en') -> str:
if not isinstance(values, dict):
return ''
candidates = []
normalized_language = (language or '').strip().lower()
if normalized_language:
candidates.append(normalized_language)
if '-' in normalized_language:
candidates.append(normalized_language.split('-')[0])
default_language = (default_language or '').strip().lower()
if default_language and default_language not in candidates:
candidates.append(default_language)
for candidate in candidates:
if not candidate:
continue
value = values.get(candidate)
if isinstance(value, str) and value.strip():
return value
for value in values.values():
if isinstance(value, str) and value.strip():
return value
return ''
def _build_additional_buttons(additional_section, language: str) -> list[InlineKeyboardButton]:
if not isinstance(additional_section, dict):
return []
buttons = additional_section.get('buttons')
if not isinstance(buttons, list):
return []
localized_buttons: list[InlineKeyboardButton] = []
for button in buttons:
if not isinstance(button, dict):
continue
button_text = _get_localized_value(button.get('buttonText'), language)
button_link = button.get('buttonLink')
if not button_text or not button_link:
continue
localized_buttons.append(InlineKeyboardButton(text=button_text, url=button_link))
return localized_buttons
_LANGUAGE_DISPLAY_NAMES = {
'ru': '🇷🇺 Русский',
'ru-ru': '🇷🇺 Русский',
@@ -275,22 +220,47 @@ def get_privacy_policy_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeybo
def get_channel_sub_keyboard(
channel_link: str | None,
channels: list[dict] | str | None = None,
language: str = DEFAULT_LANGUAGE,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
"""Subscription keyboard for required channels.
Supports Bot API 9.4 colored buttons via ``style`` parameter:
- subscribed channels green (``style='success'``)
- unsubscribed channels blue (``style='primary'``)
Args:
channels: List of dicts with 'channel_link', 'title', and optional
'is_subscribed' keys, OR a string (legacy single channel_link).
language: Locale code for button text.
"""
texts = get_texts(language)
buttons: list[list[InlineKeyboardButton]] = []
if channel_link:
buttons.append(
[
InlineKeyboardButton(
text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'),
url=channel_link,
)
]
)
if isinstance(channels, str):
# Legacy: single channel link string
if channels:
buttons.append(
[
InlineKeyboardButton(
text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'),
url=channels,
style='primary',
)
]
)
elif isinstance(channels, list):
for ch in channels:
link = ch.get('channel_link')
title = ch.get('title')
is_subscribed = ch.get('is_subscribed', False)
if link:
if is_subscribed:
label = f'{title}' if title else ''
buttons.append([InlineKeyboardButton(text=label, url=link, style='success')])
else:
label = title or texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться')
buttons.append([InlineKeyboardButton(text=label, url=link, style='primary')])
buttons.append(
[
@@ -2270,35 +2240,35 @@ def get_manage_countries_keyboard(
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_device_selection_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
def get_device_selection_keyboard(
language: str = DEFAULT_LANGUAGE,
platforms: list[dict] | None = None,
) -> InlineKeyboardMarkup:
from app.config import settings
from app.handlers.subscription.common import get_localized_value
texts = get_texts(language)
keyboard = [
[
InlineKeyboardButton(
text=texts.t('DEVICE_GUIDE_IOS', '📱 iOS (iPhone/iPad)'), callback_data='device_guide_ios'
),
InlineKeyboardButton(
text=texts.t('DEVICE_GUIDE_ANDROID', '🤖 Android'), callback_data='device_guide_android'
),
],
[
InlineKeyboardButton(
text=texts.t('DEVICE_GUIDE_WINDOWS', '💻 Windows'), callback_data='device_guide_windows'
),
InlineKeyboardButton(text=texts.t('DEVICE_GUIDE_MAC', '🎯 macOS'), callback_data='device_guide_mac'),
],
[
InlineKeyboardButton(
text=texts.t('DEVICE_GUIDE_ANDROID_TV', '📺 Android TV'), callback_data='device_guide_tv'
),
InlineKeyboardButton(
text=texts.t('DEVICE_GUIDE_APPLE_TV', '📺 Apple TV'), callback_data='device_guide_appletv'
),
],
]
keyboard: list[list[InlineKeyboardButton]] = []
if platforms:
row: list[InlineKeyboardButton] = []
for p in platforms:
display_name = p.get('displayName', p['key'])
if isinstance(display_name, dict):
display_name = get_localized_value(display_name, language)
emoji = p.get('icon_emoji', '📱')
device_type = p.get('device_type', p['key'])
btn = InlineKeyboardButton(
text=f'{emoji} {display_name}',
callback_data=f'device_guide_{device_type}',
)
row.append(btn)
if len(row) == 2:
keyboard.append(row)
row = []
if row:
keyboard.append(row)
if settings.CONNECT_BUTTON_MODE == 'guide':
keyboard.append(
@@ -2322,64 +2292,84 @@ def get_connection_guide_keyboard(
language: str = DEFAULT_LANGUAGE,
has_other_apps: bool = False,
) -> InlineKeyboardMarkup:
from app.handlers.subscription import create_deep_link
from app.handlers.subscription.common import create_deep_link, get_localized_value, resolve_button_url
texts = get_texts(language)
keyboard = []
keyboard: list[list[InlineKeyboardButton]] = []
if 'installationStep' in app and 'buttons' in app['installationStep']:
app_buttons = []
for button in app['installationStep']['buttons']:
button_text = _get_localized_value(button.get('buttonText'), language)
button_link = button.get('buttonLink')
if not button_text or not button_link:
for block in app.get('blocks', []):
if not isinstance(block, dict):
continue
for btn in block.get('buttons', []):
if not isinstance(btn, dict):
continue
btn_type = btn.get('type', '')
btn_text = btn.get('text', {})
if isinstance(btn_text, dict):
btn_text = get_localized_value(btn_text, language)
if not btn_text:
continue
app_buttons.append(InlineKeyboardButton(text=f'📥 {button_text}', url=button_link))
if len(app_buttons) == 2:
keyboard.append(app_buttons)
app_buttons = []
btn_url = btn.get('url', '') or btn.get('link', '')
resolved_url = btn.get('resolvedUrl', '')
if app_buttons:
keyboard.append(app_buttons)
additional_before_buttons = _build_additional_buttons(
app.get('additionalBeforeAddSubscriptionStep'),
language,
)
for button in additional_before_buttons:
keyboard.append([button])
connect_link = create_deep_link(app, subscription_url)
if connect_link:
connect_button = InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
url=connect_link,
)
elif settings.is_happ_cryptolink_mode():
connect_button = InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
callback_data='open_subscription_link',
)
else:
connect_button = InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
url=subscription_url,
)
keyboard.append([connect_button])
additional_after_buttons = _build_additional_buttons(
app.get('additionalAfterAddSubscriptionStep'),
language,
)
for button in additional_after_buttons:
keyboard.append([button])
if btn_type == 'externalLink':
if btn_url:
keyboard.append(
[
InlineKeyboardButton(
text=f'📥 {btn_text}',
url=btn_url,
style='primary',
)
]
)
elif btn_type == 'subscriptionLink':
url = resolved_url or resolve_button_url(btn_url, subscription_url)
deep_link = create_deep_link(app.get('_raw', app), subscription_url)
final_url = deep_link or url or subscription_url
if final_url:
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
url=final_url,
style='success',
)
]
)
elif settings.is_happ_cryptolink_mode():
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
callback_data='open_subscription_link',
style='success',
)
]
)
else:
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
url=subscription_url,
style='success',
)
]
)
elif btn_type == 'copyButton':
url = resolved_url or resolve_button_url(btn_url, subscription_url)
if url:
keyboard.append(
[
InlineKeyboardButton(
text=f'📋 {btn_text}',
url=url,
)
]
)
if has_other_apps:
keyboard.append(
@@ -2441,90 +2431,20 @@ def get_app_selection_keyboard(device_type: str, apps: list, language: str = DEF
def get_specific_app_keyboard(
subscription_url: str, app: dict, device_type: str, language: str = DEFAULT_LANGUAGE
subscription_url: str,
app: dict,
device_type: str,
language: str = DEFAULT_LANGUAGE,
) -> InlineKeyboardMarkup:
from app.handlers.subscription import create_deep_link
texts = get_texts(language)
keyboard = []
if 'installationStep' in app and 'buttons' in app['installationStep']:
app_buttons = []
for button in app['installationStep']['buttons']:
button_text = _get_localized_value(button.get('buttonText'), language)
button_link = button.get('buttonLink')
if not button_text or not button_link:
continue
app_buttons.append(InlineKeyboardButton(text=f'📥 {button_text}', url=button_link))
if len(app_buttons) == 2:
keyboard.append(app_buttons)
app_buttons = []
if app_buttons:
keyboard.append(app_buttons)
additional_before_buttons = _build_additional_buttons(
app.get('additionalBeforeAddSubscriptionStep'),
# Reuse the connection guide keyboard logic — same buttons, just always shows "Other apps"
return get_connection_guide_keyboard(
subscription_url,
app,
device_type,
language,
has_other_apps=True,
)
for button in additional_before_buttons:
keyboard.append([button])
connect_link = create_deep_link(app, subscription_url)
if connect_link:
connect_button = InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
url=connect_link,
)
elif settings.is_happ_cryptolink_mode():
connect_button = InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
callback_data='open_subscription_link',
)
else:
connect_button = InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
url=subscription_url,
)
keyboard.append([connect_button])
additional_after_buttons = _build_additional_buttons(
app.get('additionalAfterAddSubscriptionStep'),
language,
)
for button in additional_after_buttons:
keyboard.append([button])
keyboard.extend(
[
[
InlineKeyboardButton(
text=texts.t('OTHER_APPS_BUTTON', '📋 Другие приложения'), callback_data=f'app_list_{device_type}'
)
],
[
InlineKeyboardButton(
text=texts.t('CHOOSE_ANOTHER_DEVICE', '📱 Выбрать другое устройство'),
callback_data='subscription_connect',
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_TO_SUBSCRIPTION', '⬅️ К подписке'), callback_data='menu_subscription'
)
],
]
)
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) -> InlineKeyboardMarkup:
texts = get_texts(language)
+6 -2
View File
@@ -663,6 +663,7 @@
"ADMIN_SETTINGS_MAINTENANCE": "🔧 Maintenance",
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Privacy policy",
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Public offer",
"ADMIN_SETTINGS_REQUIRED_CHANNELS": "📢 Required channels",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Manage Remnawave, monitoring and other settings:",
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **System settings**\n\n",
"ADMIN_SQUAD_ADD_ALL": "👥 Add all users",
@@ -930,9 +931,10 @@
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n",
"CHANGE_DEVICES_TITLE": "📱 Change device limit",
"CHANNEL_CHECK_BUTTON": "✅ I have joined",
"CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.",
"CHANNEL_CHECK_NOT_SUBSCRIBED": "You are not subscribed to all required channels. Please subscribe and try again.",
"CHANNEL_REQUIRED_TEXT": "Please subscribe to the required channels and then press the button below.",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "Please subscribe to all required channels first!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing",
"CHECK_STATUS_BUTTON": "📊 Check status",
"CHECK_STATUS_NO_CHANGES": "Status has not changed",
@@ -1431,10 +1433,12 @@
"SUBSCRIPTION_NO_SERVERS": "No servers",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}",
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE": "Your subscription has been paused because you left a required channel.\n\nSubscribe to all channels to restore VPN access.",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n<code>{bar}</code>",
"SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!",
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE": "Your subscription has been restored!\n\nThank you for subscribing to the channels. VPN is active again.",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Settings",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Subscription settings</b>\n\n📊 <b>Current parameters:</b>\n🌐 Countries: {countries_count}\n📈 Traffic: {traffic_used} / {traffic_limit}\n📱 Devices: {devices_used} / {devices_limit}\n\nChoose what you want to change:",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Settings are available only for paid subscriptions",
+6 -2
View File
@@ -666,6 +666,7 @@
"ADMIN_SETTINGS_MAINTENANCE": "🔧 تعمیرات",
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ حریم خصوصی",
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 شرایط استفاده",
"ADMIN_SETTINGS_REQUIRED_CHANNELS": "📢 کانال‌های اجباری",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "مدیریت Remnawave، مانیتورینگ و تنظیمات:",
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **تنظیمات سیستم**",
"ADMIN_SETTINGS_TARIFFS": "📦 تعرفه‌ها",
@@ -951,9 +952,10 @@
"CHANGE_DEVICES_TITLE": "📱 تغییر تعداد دستگاه",
"CHANGE_TARIFF_BUTTON": "📦 تعرفه",
"CHANNEL_CHECK_BUTTON": "✅ عضو شدم",
"CHANNEL_REQUIRED_TEXT": "🔒 برای استفاده از ربات در کانال خبری عضو شوید، سپس دکمه زیر بزنید.",
"CHANNEL_CHECK_NOT_SUBSCRIBED": "شما هنوز در همه کانال‌ها عضو نشده‌اید! لطفاً عضو شوید و دوباره امتحان کنید.",
"CHANNEL_REQUIRED_TEXT": "لطفاً در کانال‌های اجباری عضو شوید و سپس دکمه زیر را فشار دهید.",
"CHANNEL_SUBSCRIBE_BUTTON": "📢 عضویت در کانال",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ هنوز عضو کانال نشده‌اید!",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "لطفاً ابتدا در همه کانال‌های اجباری عضو شوید!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ ممنون از عضویت",
"CHECK_STATUS_BUTTON": "📊 بررسی وضعیت",
"CHECK_STATUS_NO_CHANGES": "وضعیت تغییر نکرده",
@@ -1452,10 +1454,12 @@
"SUBSCRIPTION_NO_SERVERS": "❌ سروری در دسترس نیست",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "📱 <b>اشتراک شما</b>\n\n📊 وضعیت: {status}\n📅 اعتبار: {expiry}\n📈 ترافیک: {traffic}\n📱 دستگاه‌ها: {devices}\n🌍 سرورها: {servers}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 موجودی: {balance}\n📱 اشتراک: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 اطلاعات اشتراک\n🎭 نوع: {subscription_type}\n📈 ترافیک: {traffic}\n🌍 سرورها: {servers}\n📱 دستگاه‌ها: {devices}\n🌍 کشورها: {countries}",
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE": "اشتراک شما متوقف شده است زیرا از کانال اجباری خارج شدید.\n\nبرای بازیابی دسترسی VPN در همه کانال‌ها عضو شوید.",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ تخفیف اضافی {percent}% فعال شد.\n\nبا سایر تخفیف‌ها جمع می‌شود!",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ تخفیف اضافی {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "\n⏳ اعتبار تخفیف: {time_left} <code>{bar}</code>",
"SUBSCRIPTION_PURCHASED": "🎉 اشتراک خریداری شد!",
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE": "اشتراک شما بازیابی شد!\n\nبا تشکر از عضویت در کانال‌ها. VPN دوباره فعال است.",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ تنظیمات",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>تنظیمات اشتراک</b>",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ تنظیمات فقط برای اشتراک پولی",
+6 -2
View File
@@ -666,6 +666,7 @@
"ADMIN_SETTINGS_MAINTENANCE": "🔧 Техработы",
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Политика конф.",
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта",
"ADMIN_SETTINGS_REQUIRED_CHANNELS": "📢 Обязательные каналы",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:",
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n",
"ADMIN_SETTINGS_TARIFFS": "📦 Тарифы",
@@ -951,9 +952,10 @@
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
"CHANGE_TARIFF_BUTTON": "📦 Тариф",
"CHANNEL_CHECK_BUTTON": "✅ Я подписался",
"CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.",
"CHANNEL_CHECK_NOT_SUBSCRIBED": "Вы ещё не подписались на все каналы! Подпишитесь и попробуйте снова.",
"CHANNEL_REQUIRED_TEXT": "Пожалуйста, подпишитесь на обязательные каналы и нажмите кнопку ниже.",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "Пожалуйста, подпишитесь на все обязательные каналы!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку",
"CHECK_STATUS_BUTTON": "📊 Проверить статус",
"CHECK_STATUS_NO_CHANGES": "Статус не изменился",
@@ -1452,10 +1454,12 @@
"SUBSCRIPTION_NO_SERVERS": "Нет серверов",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}",
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE": "Ваша подписка приостановлена, так как вы отписались от обязательного канала.\n\nПодпишитесь на все каналы для восстановления доступа к VPN.",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активирована доп. скидка {percent}%. \n\nСуммируется с другими скидками!",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n<code>{bar}</code>",
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE": "Ваша подписка восстановлена!\n\nСпасибо за подписку на каналы. VPN снова активен.",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Настройки подписки</b>\n\n📊 <b>Текущие параметры:</b>\n🌐 Стран: {countries_count}\n📈 Трафик: {traffic_used} / {traffic_limit}\n📱 Устройства: {devices_used} / {devices_limit}\n\nВыберите что хотите изменить:",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Настройки доступны только для платных подписок",
+6 -2
View File
@@ -593,6 +593,7 @@
"ADMIN_SETTINGS_MAINTENANCE": "🔧 Техроботи",
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Політика конф.",
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публічна оферта",
"ADMIN_SETTINGS_REQUIRED_CHANNELS": "📢 Обов'язкові канали",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Керування Remnawave, моніторингом та іншими налаштуваннями:",
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Налаштування системи**\n\n",
"ADMIN_SQUAD_ADD_ALL": "👥 Додати всіх користувачів",
@@ -871,9 +872,10 @@
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n  ✅ Кількість пристроїв збільшено!\n\n  📱 Було: {old_count} → Стало: {new_count}\n  💰 Списано: {amount}\n  ",
"CHANGE_DEVICES_TITLE": "📱 Зміна кількості пристроїв",
"CHANNEL_CHECK_BUTTON": "✅ Я підписався",
"CHANNEL_REQUIRED_TEXT": "🔒 Для використання бота підпишіться на канал новин, а потім натисніть кнопку нижче.",
"CHANNEL_CHECK_NOT_SUBSCRIBED": "Ви ще не підписалися на всі канали! Підпишіться і спробуйте знову.",
"CHANNEL_REQUIRED_TEXT": "Будь ласка, підпишіться на обов'язкові канали та натисніть кнопку нижче.",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Підписатися",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Ви не підписалися на канал!",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "Будь ласка, підпишіться на всі обов'язкові канали!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ Дякуємо за підписку",
"CHECK_STATUS_BUTTON": "📊 Перевірити статус",
"CHECK_STATUS_NO_CHANGES": "Статус не змінився",
@@ -1362,10 +1364,12 @@
"SUBSCRIPTION_NO_SERVERS": "Немає серверів",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📅 Діє до: {end_date}\n⏰ Залишилося: {time_left}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}",
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE": "Вашу підписку призупинено, оскільки ви відписались від обов'язкового каналу.\n\nПідпишіться на всі канали для відновлення доступу до VPN.",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активовано дод. знижку {percent}%. \n\nСумується з іншими знижками!",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Дод. знижка {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Знижка діє ще: {time_left}\n<code>{bar}</code>",
"SUBSCRIPTION_PURCHASED": "🎉 Підписку успішно придбано!",
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE": "Вашу підписку відновлено!\n\nДякуємо за підписку на канали. VPN знову активний.",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Налаштування підписки",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Налаштування підписки</b>\n\n📊 <b>Поточні параметри:</b>\n🌐 Країн: {countries_count}\n📈 Трафік: {traffic_used} / {traffic_limit}\n📱 Пристрої: {devices_used} / {devices_limit}\n\nОберіть що хочете змінити:",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Налаштування доступні лише для платних підписок",
+8 -2
View File
@@ -592,6 +592,7 @@
"ADMIN_SETTINGS_MAINTENANCE":"🔧维护",
"ADMIN_SETTINGS_PRIVACY_POLICY":"🛡️隐私政策",
"ADMIN_SETTINGS_PUBLIC_OFFER":"📄公开服务条款",
"ADMIN_SETTINGS_REQUIRED_CHANNELS":"📢必订频道",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION":"管理Remnawave、监控和其他设置:",
"ADMIN_SETTINGS_SUBMENU_TITLE":"⚙️**系统设置**\n\n",
"ADMIN_SQUAD_ADD_ALL":"👥添加所有用户",
@@ -869,9 +870,10 @@
"CHANGE_DEVICES_SUCCESS_INCREASE":"\n  ✅设备数量已增加!\n\n  📱之前:{old_count}→现在:{new_count}\n  💰已扣除:{amount}\n  ",
"CHANGE_DEVICES_TITLE":"📱更改设备数量",
"CHANNEL_CHECK_BUTTON":"✅我已订阅",
"CHANNEL_REQUIRED_TEXT":"🔒要使用机器人,请订阅新闻频道,然后点击下方按钮。",
"CHANNEL_CHECK_NOT_SUBSCRIBED":"您尚未订阅所有必需频道!请订阅后重试。",
"CHANNEL_REQUIRED_TEXT":"请订阅所有必需频道,然后点击下方按钮。",
"CHANNEL_SUBSCRIBE_BUTTON":"🔗订阅",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT":"❌您没有订阅该频道!",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT":"请先订阅所有必需频道!",
"CHANNEL_SUBSCRIBE_THANKS":"✅感谢您的订阅",
"CHECK_STATUS_BUTTON":"📊检查状态",
"CHECK_STATUS_NO_CHANGES":"状态未更改",
@@ -1360,10 +1362,12 @@
"SUBSCRIPTION_NO_SERVERS":"没有服务器",
"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}",
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE":"由于您退出了必需频道,您的订阅已暂停。\n\n请订阅所有频道以恢复VPN访问。",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT":"⚡已激活额外{percent}%折扣。\n\n可与其他折扣叠加!",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE":"⚡额外{percent}%折扣:-{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER":"⏳折扣剩余时间:{time_left}\n<code>{bar}</code>",
"SUBSCRIPTION_PURCHASED":"🎉订阅已成功购买!",
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE":"您的订阅已恢复!\n\n感谢您订阅频道。VPN已重新激活。",
"SUBSCRIPTION_SETTINGS_BUTTON":"⚙️订阅设置",
"SUBSCRIPTION_SETTINGS_OVERVIEW":"⚙️<b>订阅设置</b>\n\n📊<b>当前参数:</b>\n🌐国家:{countries_count}\n📈流量:{traffic_used}/{traffic_limit}\n📱设备:{devices_used}/{devices_limit}\n\n请选择要更改的内容:",
"SUBSCRIPTION_SETTINGS_PAID_ONLY":"⚠️设置仅适用于付费订阅",
@@ -1693,10 +1697,12 @@
"SUBSCRIPTION_NO_SERVERS":"没有服务器",
"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}",
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE":"由于您退出了必需频道,您的订阅已暂停。\n\n请订阅所有频道以恢复VPN访问。",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT":"⚡已激活额外{percent}%折扣。\n\n可与其他折扣叠加!",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE":"⚡额外{percent}%折扣:-{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER":"⏳折扣剩余时间:{time_left}\n<code>{bar}</code>",
"SUBSCRIPTION_PURCHASED":"🎉订阅已成功购买!",
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE":"您的订阅已恢复!\n\n感谢您订阅频道。VPN已重新激活。",
"SUBSCRIPTION_SETTINGS_BUTTON":"⚙️订阅设置",
"SUBSCRIPTION_SETTINGS_OVERVIEW":"⚙️<b>订阅设置</b>\n\n📊<b>当前参数:</b>\n🌐国家:{countries_count}\n📈流量:{traffic_used}/{traffic_limit}\n📱设备:{devices_used}/{devices_limit}\n\n请选择要更改的内容:",
"SUBSCRIPTION_SETTINGS_PAID_ONLY":"⚠️设置仅适用于付费订阅",
+196 -190
View File
@@ -2,11 +2,9 @@ from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from typing import Any
import redis.asyncio as aioredis
import structlog
from aiogram import BaseMiddleware, Bot, types
from aiogram.enums import ChatMemberStatus
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message, TelegramObject, Update
@@ -20,69 +18,62 @@ from app.keyboards.inline import get_channel_sub_keyboard
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.channel_subscription_service import channel_subscription_service
from app.services.subscription_service import SubscriptionService
from app.utils.cache import cache
from app.utils.check_reg_process import is_registration_process
logger = structlog.get_logger(__name__)
# Ключ для хранения pending_start_payload в Redis (резервный механизм)
# Redis key prefix and TTL for pending /start payload backup
REDIS_PAYLOAD_KEY_PREFIX = 'pending_start_payload:'
REDIS_PAYLOAD_TTL = 3600 # 1 час
REDIS_PAYLOAD_TTL = 3600 # 1 hour
async def save_pending_payload_to_redis(telegram_id: int, payload: str) -> bool:
"""Сохраняет pending_start_payload в Redis напрямую (резервный механизм)."""
"""Save pending_start_payload to Redis via the shared cache singleton."""
try:
redis_client = aioredis.from_url(settings.REDIS_URL)
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
await redis_client.set(key, payload, ex=REDIS_PAYLOAD_TTL)
await redis_client.aclose()
logger.info(
"💾 [Redis fallback] Сохранен payload '' для пользователя", payload=payload, telegram_id=telegram_id
)
return True
result = await cache.set(key, payload, expire=REDIS_PAYLOAD_TTL)
if result:
logger.info('Saved pending payload to Redis', payload=payload, telegram_id=telegram_id)
return result
except Exception as e:
logger.error('❌ [Redis fallback] Ошибка сохранения payload для', telegram_id=telegram_id, e=e)
logger.error('Failed to save payload to Redis', telegram_id=telegram_id, error=e)
return False
async def get_pending_payload_from_redis(telegram_id: int) -> str | None:
"""Получает pending_start_payload из Redis (резервный механизм)."""
"""Get pending_start_payload from Redis via the shared cache singleton."""
try:
redis_client = aioredis.from_url(settings.REDIS_URL)
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
payload = await redis_client.get(key)
await redis_client.aclose()
if payload:
return payload.decode('utf-8') if isinstance(payload, bytes) else payload
return None
return await cache.get(key)
except Exception as e:
logger.debug('❌ [Redis fallback] Ошибка получения payload для', telegram_id=telegram_id, e=e)
logger.debug('Failed to get payload from Redis', telegram_id=telegram_id, error=e)
return None
async def delete_pending_payload_from_redis(telegram_id: int) -> None:
"""Удаляет pending_start_payload из Redis."""
"""Delete pending_start_payload from Redis via the shared cache singleton."""
try:
redis_client = aioredis.from_url(settings.REDIS_URL)
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
await redis_client.delete(key)
await redis_client.aclose()
await cache.delete(key)
except Exception:
pass
class ChannelCheckerMiddleware(BaseMiddleware):
"""
Middleware для проверки подписки на канал.
ОПТИМИЗИРОВАНО: создаёт максимум одну сессию БД на запрос.
"""Middleware for checking required channel subscriptions.
OPTIMIZED FOR 100k+ USERS:
- Does NOT call Telegram API directly in the hot path
- Reads from Redis cache (TTL 600s) -> PostgreSQL -> rate-limited API fallback
- Updated in real-time via ChatMemberUpdated events
"""
def __init__(self):
self.BAD_MEMBER_STATUS = (ChatMemberStatus.LEFT, ChatMemberStatus.KICKED, ChatMemberStatus.RESTRICTED)
self.GOOD_MEMBER_STATUS = (ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.CREATOR)
logger.info('🔧 ChannelCheckerMiddleware инициализирован')
logger.info('ChannelCheckerMiddleware initialized (multi-channel mode)')
async def __call__(
self,
@@ -90,6 +81,11 @@ class ChannelCheckerMiddleware(BaseMiddleware):
event: TelegramObject,
data: dict[str, Any],
) -> Any:
# Runtime check (supports toggling without restart)
if not settings.CHANNEL_IS_REQUIRED_SUB:
return await handler(event, data)
# Fast-path bypasses
telegram_id = None
if isinstance(event, (Message, CallbackQuery)):
telegram_id = event.from_user.id
@@ -100,7 +96,6 @@ class ChannelCheckerMiddleware(BaseMiddleware):
telegram_id = event.callback_query.from_user.id
if telegram_id is None:
logger.debug('❌ telegram_id не найден, пропускаем')
return await handler(event, data)
# Skip channel check for lightweight UI callbacks (close/delete notifications)
@@ -112,110 +107,136 @@ class ChannelCheckerMiddleware(BaseMiddleware):
):
return await handler(event, data)
# Админам разрешаем пропускать проверку подписки
if settings.is_admin(telegram_id):
logger.debug(
'✅ Пользователь является администратором — пропускаем проверку подписки', telegram_id=telegram_id
)
return await handler(event, data)
state: FSMContext = data.get('state')
current_state = None
if state:
current_state = await state.get_state()
is_reg_process = is_registration_process(event, current_state)
if is_reg_process:
logger.debug('✅ Событие разрешено (процесс регистрации), пропускаем проверку')
current_state = await state.get_state() if state else None
if is_registration_process(event, current_state):
return await handler(event, data)
# Ensure service has bot reference for API fallback
bot: Bot = data['bot']
if not channel_subscription_service.bot:
channel_subscription_service.bot = bot
channel_id = settings.CHANNEL_SUB_ID
# Multi-channel check (Redis -> DB -> API)
all_channels = await channel_subscription_service.get_channels_with_status(telegram_id)
unsubscribed = [ch for ch in all_channels if not ch.get('is_subscribed', False)]
if not channel_id:
logger.warning('⚠️ CHANNEL_SUB_ID не установлен, пропускаем проверку')
if not unsubscribed:
# All subscribed -- reactivate if needed
if settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL:
await self._reactivate_subscription_on_subscribe(telegram_id, bot)
return await handler(event, data)
is_required = settings.CHANNEL_IS_REQUIRED_SUB
# User is NOT subscribed to all channels
if settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL:
await self._deactivate_subscription_on_unsubscribe(telegram_id, bot, all_channels)
if not is_required:
logger.debug('⚠️ Обязательная подписка отключена, пропускаем проверку')
return await handler(event, data)
await self._capture_start_payload(state, event, bot)
channel_link = self._normalize_channel_link(settings.CHANNEL_LINK, channel_id)
if isinstance(event, CallbackQuery) and event.data == 'sub_channel_check':
# Rate limit: max 1 check per 5 seconds per user
rate_key = f'sub_check_rate:{telegram_id}'
if await cache.exists(rate_key):
await event.answer()
return None
await cache.set(rate_key, 1, expire=5)
if not channel_link:
logger.warning('⚠️ CHANNEL_LINK не задан или невалиден, кнопка подписки будет скрыта')
# Re-check via API for immediate feedback (invalidate cache first)
await channel_subscription_service.invalidate_user_cache(telegram_id)
try:
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
all_channels_fresh = await channel_subscription_service.get_channels_with_status(telegram_id)
unsubscribed_fresh = [ch for ch in all_channels_fresh if not ch.get('is_subscribed', False)]
if member.status in self.GOOD_MEMBER_STATUS:
# Реактивируем подписку если была отключена из-за отписки от канала
if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL):
if not unsubscribed_fresh:
# Now subscribed to all channels
if settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL:
await self._reactivate_subscription_on_subscribe(telegram_id, bot)
return await handler(event, data)
if member.status in self.BAD_MEMBER_STATUS:
logger.info(
'❌ Пользователь не подписан на канал (статус: )', telegram_id=telegram_id, status=member.status
)
if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL):
await self._deactivate_subscription_on_unsubscribe(telegram_id, bot, channel_link)
# Still not all subscribed — update keyboard with colored buttons
# (subscribed = green, unsubscribed = blue) via Bot API 9.4 style
user_lang = (
event.from_user.language_code.split('-')[0]
if event.from_user and event.from_user.language_code
else DEFAULT_LANGUAGE
)
await self._capture_start_payload(state, event, bot)
normalized = _normalize_channels(all_channels_fresh)
texts = get_texts(user_lang)
channel_sub_kb = get_channel_sub_keyboard(normalized, language=user_lang)
text = texts.t(
'CHANNEL_REQUIRED_TEXT',
'🔒 Для использования бота подпишитесь на новостной канал, '
'чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!',
)
if isinstance(event, CallbackQuery) and event.data == 'sub_channel_check':
await event.answer(
'❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.', show_alert=True
)
return None
try:
await event.message.edit_text(text, reply_markup=channel_sub_kb)
except TelegramBadRequest as e:
if 'message is not modified' not in str(e).lower():
raise
return await self._deny_message(event, bot, channel_link, channel_id)
logger.warning('⚠️ Неожиданный статус пользователя', telegram_id=telegram_id, status=member.status)
await self._capture_start_payload(state, event, bot)
return await self._deny_message(event, bot, channel_link, channel_id)
await event.answer(
texts.t(
'CHANNEL_CHECK_NOT_SUBSCRIBED',
'You are not subscribed to all required channels. Please subscribe and try again.',
),
show_alert=True,
)
return None
except TelegramForbiddenError as e:
logger.error('❌ Бот заблокирован в канале', channel_id=channel_id, error=e)
await self._capture_start_payload(state, event, bot)
return await self._deny_message(event, bot, channel_link, channel_id)
except TelegramBadRequest as e:
if 'chat not found' in str(e).lower():
logger.error('❌ Канал не найден', channel_id=channel_id, error=e)
elif 'user not found' in str(e).lower():
logger.error('❌ Пользователь не найден', telegram_id=telegram_id, error=e)
else:
logger.error('❌ Ошибка запроса к каналу', channel_id=channel_id, error=e)
await self._capture_start_payload(state, event, bot)
return await self._deny_message(event, bot, channel_link, channel_id)
except TelegramNetworkError as e:
logger.warning('⚠️ Таймаут при проверке подписки на канал', error=e)
return await handler(event, data)
except Exception as e:
logger.error('❌ Неожиданная ошибка при проверке подписки', error=e)
return await handler(event, data)
return await self._deny_message(event, bot, all_channels)
# -- _deny_message (multi-channel) -----------------------------------------
@staticmethod
def _normalize_channel_link(channel_link: str | None, channel_id: str | None) -> str | None:
link = (channel_link or '').strip()
async def _deny_message(
event: TelegramObject,
bot: Bot,
channels: list[dict],
):
user = None
if isinstance(event, (Message, CallbackQuery)):
user = getattr(event, 'from_user', None)
elif isinstance(event, Update):
if event.message and event.message.from_user:
user = event.message.from_user
elif event.callback_query and event.callback_query.from_user:
user = event.callback_query.from_user
if link.startswith('@'): # raw username
return f'https://t.me/{link.lstrip("@")}'
language = DEFAULT_LANGUAGE
if user and user.language_code:
language = user.language_code.split('-')[0]
if link and not link.lower().startswith(('http://', 'https://', 'tg://')):
return f'https://{link}'
normalized = _normalize_channels(channels)
if link:
return link
texts = get_texts(language)
channel_sub_kb = get_channel_sub_keyboard(normalized, language=language)
text = texts.t(
'CHANNEL_REQUIRED_TEXT',
'🔒 Для использования бота подпишитесь на новостной канал, '
'чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!',
)
if channel_id and str(channel_id).startswith('@'):
return f'https://t.me/{str(channel_id).lstrip("@")}'
try:
if isinstance(event, Message):
return await event.answer(text, reply_markup=channel_sub_kb)
if isinstance(event, CallbackQuery):
try:
return await event.message.edit_text(text, reply_markup=channel_sub_kb)
except TelegramBadRequest as e:
if 'message is not modified' in str(e).lower():
return await event.answer(text, show_alert=True)
raise
elif isinstance(event, Update) and event.message:
return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb)
except Exception as e:
logger.error('Error sending subscription prompt', error=e)
return None
# -- _capture_start_payload ------------------------------------------------
async def _capture_start_payload(
self,
@@ -223,6 +244,11 @@ class ChannelCheckerMiddleware(BaseMiddleware):
event: TelegramObject,
bot: Bot | None = None,
) -> None:
"""Save /start payload to FSM + Redis so it can be restored after subscription.
This preserves referral codes, deep links, and other start parameters
when a user is blocked by the channel subscription requirement.
"""
telegram_id = None
if isinstance(event, (Message, CallbackQuery)):
telegram_id = event.from_user.id if event.from_user else None
@@ -246,23 +272,21 @@ class ChannelCheckerMiddleware(BaseMiddleware):
payload = parts[1]
# Сохраняем в FSM state
# Save to FSM state
if state:
state_data = await state.get_data() or {}
if state_data.get('pending_start_payload') != payload:
state_data['pending_start_payload'] = payload
await state.set_data(state_data)
logger.info(
"💾 Сохранен start payload '' для пользователя (FSM)", payload=payload, telegram_id=telegram_id
)
logger.info('Saved start payload for user (FSM)', payload=payload, telegram_id=telegram_id)
else:
logger.warning('⚠️ _capture_start_payload: state=None для пользователя', telegram_id=telegram_id)
logger.warning('_capture_start_payload: state=None for user', telegram_id=telegram_id)
# Также сохраняем в Redis как резерв (на случай потери FSM state)
# Also save to Redis as backup (in case FSM state is lost)
if telegram_id:
await save_pending_payload_to_redis(telegram_id, payload)
if bot and message.from_user:
if bot and message.from_user and state:
await self._try_send_campaign_visit_notification(
bot,
message.from_user,
@@ -280,9 +304,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
try:
state_data = await state.get_data() or {}
except Exception as error:
logger.error(
'❌ Не удалось получить данные состояния для уведомления по кампании', payload=payload, error=error
)
logger.error('Failed to get state data for campaign notification', payload=payload, error=error)
return
if state_data.get('campaign_notification_sent'):
@@ -311,13 +333,18 @@ class ChannelCheckerMiddleware(BaseMiddleware):
await state.update_data(campaign_notification_sent=True)
await db.commit()
except Exception as error:
logger.error('❌ Ошибка отправки уведомления о переходе по кампании', payload=payload, error=error)
logger.error('Error sending campaign visit notification', payload=payload, error=error)
await db.rollback()
# -- _deactivate (multi-channel) -------------------------------------------
async def _deactivate_subscription_on_unsubscribe(
self, telegram_id: int, bot: Bot, channel_link: str | None
self,
telegram_id: int,
bot: Bot,
channels: list[dict],
) -> None:
"""Деактивация подписки при отписке от канала."""
"""Deactivate subscription when user unsubscribes from required channels."""
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
return
@@ -332,15 +359,24 @@ class ChannelCheckerMiddleware(BaseMiddleware):
if subscription.status != SubscriptionStatus.ACTIVE.value:
return
from app.database.crud.subscription import is_active_paid_subscription
if settings.CHANNEL_REQUIRED_FOR_ALL:
pass
elif not subscription.is_trial:
return
if is_active_paid_subscription(subscription):
logger.info(
'Skipping deactivation: user has active paid subscription',
telegram_id=telegram_id,
)
return
await deactivate_subscription(db, subscription)
sub_type = 'Триальная' if subscription.is_trial else 'Платная'
sub_type = 'trial' if subscription.is_trial else 'paid'
logger.info(
'🚫 подписка пользователя отключена после отписки от канала',
'Subscription deactivated after channel unsubscribe',
sub_type=sub_type,
telegram_id=telegram_id,
)
@@ -351,38 +387,41 @@ class ChannelCheckerMiddleware(BaseMiddleware):
await service.disable_remnawave_user(user.remnawave_uuid)
except Exception as api_error:
logger.error(
'❌ Не удалось отключить пользователя RemnaWave',
'Failed to disable RemnaWave user',
remnawave_uuid=user.remnawave_uuid,
api_error=api_error,
)
# Уведомляем пользователя о деактивации
# Notify user about deactivation
try:
normalized = _normalize_channels(channels)
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE)
notification_text = texts.t(
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE',
'🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n'
'Подпишитесь на канал снова, чтобы восстановить доступ к VPN.',
)
channel_kb = get_channel_sub_keyboard(channel_link, language=user.language)
channel_kb = get_channel_sub_keyboard(normalized, language=user.language)
await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb)
except Exception as notify_error:
logger.error(
'❌ Не удалось отправить уведомление о деактивации пользователю',
'Failed to send deactivation notification to user',
telegram_id=telegram_id,
notify_error=notify_error,
)
await db.commit()
except Exception as db_error:
logger.error(
'❌ Ошибка деактивации подписки пользователя после отписки',
'Error deactivating subscription after channel unsubscribe',
telegram_id=telegram_id,
db_error=db_error,
)
await db.rollback()
# -- _reactivate -----------------------------------------------------------
async def _reactivate_subscription_on_subscribe(self, telegram_id: int, bot: Bot) -> None:
"""Реактивация подписки после повторной подписки на канал."""
"""Reactivate subscription after user subscribes to all required channels."""
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
return
@@ -392,43 +431,42 @@ class ChannelCheckerMiddleware(BaseMiddleware):
if not user or not user.subscription:
return
# НЕ реактивируем подписку заблокированным пользователям
# Do NOT reactivate for blocked users
if user.status == UserStatus.BLOCKED.value:
logger.info('🚫 Пропуск реактивации для заблокированного пользователя', telegram_id=telegram_id)
logger.info('Skipping reactivation for blocked user', telegram_id=telegram_id)
return
subscription = user.subscription
# Реактивируем только DISABLED подписки
# Only reactivate DISABLED subscriptions
if subscription.status != SubscriptionStatus.DISABLED.value:
return
# Проверяем что подписка ещё не истекла
# Check subscription has not expired
if subscription.end_date and subscription.end_date <= datetime.now(UTC):
return
# Реактивируем в БД
await reactivate_subscription(db, subscription)
sub_type = 'Триальная' if subscription.is_trial else 'Платная'
sub_type = 'trial' if subscription.is_trial else 'paid'
logger.info(
'✅ подписка пользователя реактивирована после подписки на канал',
'Subscription reactivated after channel subscribe',
sub_type=sub_type,
telegram_id=telegram_id,
)
# Включаем в RemnaWave
# Enable in RemnaWave
if user.remnawave_uuid:
service = SubscriptionService()
try:
await service.enable_remnawave_user(user.remnawave_uuid)
except Exception as api_error:
logger.error(
'❌ Не удалось включить пользователя RemnaWave',
'Failed to enable RemnaWave user',
remnawave_uuid=user.remnawave_uuid,
api_error=api_error,
)
# Уведомляем пользователя о реактивации
# Notify user about reactivation
try:
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE)
notification_text = texts.t(
@@ -438,65 +476,33 @@ class ChannelCheckerMiddleware(BaseMiddleware):
await bot.send_message(telegram_id, notification_text)
except Exception as notify_error:
logger.warning(
'Не удалось отправить уведомление о реактивации пользователю',
'Failed to send reactivation notification to user',
telegram_id=telegram_id,
notify_error=notify_error,
)
await db.commit()
except Exception as db_error:
logger.error('❌ Ошибка реактивации подписки пользователя', telegram_id=telegram_id, db_error=db_error)
logger.error('Error reactivating subscription', telegram_id=telegram_id, db_error=db_error)
await db.rollback()
@staticmethod
async def _deny_message(
event: TelegramObject,
bot: Bot,
channel_link: str | None,
channel_id: str | None,
):
logger.debug('🚫 Отправляем сообщение о необходимости подписки')
user = None
if isinstance(event, (Message, CallbackQuery)):
user = getattr(event, 'from_user', None)
elif isinstance(event, Update):
if event.message and event.message.from_user:
user = event.message.from_user
elif event.callback_query and event.callback_query.from_user:
user = event.callback_query.from_user
def _normalize_channel_link(link: str) -> str:
"""Normalize channel link: convert @username to https://t.me/username."""
if not link:
return link
link = link.strip()
if link.startswith('@'):
return f'https://t.me/{link[1:]}'
return link
language = DEFAULT_LANGUAGE
if user and user.language_code:
language = user.language_code.split('-')[0]
texts = get_texts(language)
channel_sub_kb = get_channel_sub_keyboard(channel_link, language=language)
text = texts.t(
'CHANNEL_REQUIRED_TEXT',
'🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!',
)
if not channel_link and channel_id:
channel_hint = None
if str(channel_id).startswith('@'): # username-based channel id
channel_hint = f'@{str(channel_id).lstrip("@")}'
if channel_hint:
text = f'{text}\n\n{channel_hint}'
try:
if isinstance(event, Message):
return await event.answer(text, reply_markup=channel_sub_kb)
if isinstance(event, CallbackQuery):
try:
return await event.message.edit_text(text, reply_markup=channel_sub_kb)
except TelegramBadRequest as e:
if 'message is not modified' in str(e).lower():
logger.debug('ℹ️ Сообщение уже содержит текст проверки подписки, пропускаем редактирование')
return await event.answer(text, show_alert=True)
raise
elif isinstance(event, Update) and event.message:
return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb)
except Exception as e:
logger.error('❌ Ошибка при отправке сообщения о подписке', error=e)
def _normalize_channels(channels: list[dict]) -> list[dict]:
"""Normalize channel links in a list of channel dicts (preserves is_subscribed)."""
normalized = []
for ch in channels:
ch_copy = dict(ch)
link = ch_copy.get('channel_link')
if link:
ch_copy['channel_link'] = _normalize_channel_link(link)
normalized.append(ch_copy)
return normalized
+47
View File
@@ -0,0 +1,47 @@
"""Middleware to ignore non-private chat messages.
When the bot is added as admin to a group or supergroup (including forums
with topics), it should silently drop all incoming messages and callback
queries from those chats. Only private (DM) interactions are processed.
Not registered on chat_member channel_member.py needs ChatMemberUpdated
events from groups/channels to track required channel subscriptions.
Not registered on pre_checkout_query no chat context, always private.
"""
from collections.abc import Awaitable, Callable
from typing import Any
import structlog
from aiogram import BaseMiddleware
from aiogram.enums import ChatType
from aiogram.types import CallbackQuery, Message, TelegramObject
logger = structlog.get_logger(__name__)
class ChatTypeFilterMiddleware(BaseMiddleware):
"""Drop messages and callback queries from non-private chats."""
async def __call__(
self,
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: dict[str, Any],
) -> Any:
chat = None
if isinstance(event, Message):
chat = event.chat
elif isinstance(event, CallbackQuery) and event.message:
chat = event.message.chat
if chat is not None and chat.type != ChatType.PRIVATE:
logger.debug(
'Dropping non-private chat event',
chat_id=chat.id,
chat_type=chat.type,
)
return None
return await handler(event, data)
+24 -61
View File
@@ -1,5 +1,6 @@
import asyncio
import gzip
import html as html_lib
import json as json_lib
import math
import os
@@ -575,17 +576,27 @@ class BackupService:
table_name = model.__tablename__
logger.info('📊 Экспортируем таблицу', table_name=table_name)
query = select(model)
try:
query = select(model)
if model == User:
query = query.options(selectinload(User.subscription))
elif model == Subscription:
query = query.options(selectinload(Subscription.user))
elif model == Transaction:
query = query.options(selectinload(Transaction.user))
if model == User:
query = query.options(selectinload(User.subscription))
elif model == Subscription:
query = query.options(selectinload(Subscription.user))
elif model == Transaction:
query = query.options(selectinload(Transaction.user))
result = await db.execute(query)
records = result.scalars().all()
result = await db.execute(query)
records = result.scalars().all()
except Exception as table_exc:
logger.warning(
'⚠️ Ошибка экспорта таблицы, пропускаем',
table_name=table_name,
error=str(table_exc),
)
await db.rollback()
backup_data[table_name] = []
continue
table_data: list[dict[str, Any]] = []
for record in records:
@@ -634,19 +645,6 @@ class BackupService:
files_dir = staging_dir / 'files'
files_dir.mkdir(parents=True, exist_ok=True)
app_config_path = settings.get_app_config_path()
if app_config_path:
src = Path(app_config_path)
if src.exists():
dest = files_dir / src.name
await asyncio.to_thread(shutil.copy2, src, dest)
files_info.append(
{
'path': str(src),
'relative_path': f'files/{src.name}',
}
)
if include_logs and settings.LOG_FILE:
log_path = Path(settings.LOG_FILE)
if log_path.exists():
@@ -1482,46 +1480,10 @@ class BackupService:
logger.warning('⚠️ Не удалось очистить таблицу', table_name=table_name, error=e)
async def _collect_file_snapshots(self) -> dict[str, dict[str, Any]]:
snapshots: dict[str, dict[str, Any]] = {}
app_config_path = settings.get_app_config_path()
if app_config_path:
path_obj = Path(app_config_path)
if path_obj.exists() and path_obj.is_file():
try:
async with aiofiles.open(path_obj, encoding='utf-8') as f:
content = await f.read()
snapshots['app_config'] = {
'path': str(path_obj),
'content': content,
'modified_at': datetime.fromtimestamp(path_obj.stat().st_mtime, tz=UTC).isoformat(),
}
logger.info('📁 Добавлен в бекап файл конфигурации', path_obj=path_obj)
except Exception as e:
logger.error('Ошибка чтения файла конфигурации', path_obj=path_obj, e=e)
return snapshots
return {}
async def _restore_file_snapshots(self, file_snapshots: dict[str, dict[str, Any]]) -> int:
restored_files = 0
if not file_snapshots:
return restored_files
app_config_snapshot = file_snapshots.get('app_config')
if app_config_snapshot:
target_path = Path(settings.get_app_config_path())
target_path.parent.mkdir(parents=True, exist_ok=True)
try:
async with aiofiles.open(target_path, 'w', encoding='utf-8') as f:
await f.write(app_config_snapshot.get('content', ''))
restored_files += 1
logger.info('📁 Файл app-config восстановлен по пути', target_path=target_path)
except Exception as e:
logger.error('Ошибка восстановления файла', target_path=target_path, e=e)
return restored_files
return 0
async def get_backup_list(self) -> list[dict[str, Any]]:
backups = []
@@ -1725,7 +1687,8 @@ class BackupService:
icons = {'success': '', 'error': '', 'restore_success': '🔥', 'restore_error': ''}
icon = icons.get(event_type, '')
notification_text = f'{icon} <b>СИСТЕМА БЕКАПОВ</b>\n\n{message}'
safe_message = html_lib.escape(message) if 'error' in event_type else message
notification_text = f'{icon} <b>СИСТЕМА БЕКАПОВ</b>\n\n{safe_message}'
if file_path:
notification_text += f'\n📁 <code>{Path(file_path).name}</code>'
+18 -15
View File
@@ -27,9 +27,6 @@ if TYPE_CHECKING:
logger = structlog.get_logger(__name__)
# Хранение ссылок на фоновые задачи, чтобы GC не удалил их
_background_tasks: set[asyncio.Task] = set()
VALID_MEDIA_TYPES = {'photo', 'video', 'document'}
@@ -359,15 +356,6 @@ class BroadcastService:
# Задержка между батчами для rate limiting
await asyncio.sleep(_TG_BATCH_DELAY)
# Фоновая очистка заблокировавших бота пользователей
if blocked_telegram_ids:
task = asyncio.create_task(
cleanup_blocked_broadcast_users(blocked_telegram_ids),
name=f'broadcast-{broadcast_id}-blocked-cleanup',
)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
return sent_count, failed_count, blocked_count, False
def _build_keyboard(self, selected_buttons: list[str] | None) -> InlineKeyboardMarkup | None:
@@ -547,8 +535,23 @@ async def cleanup_blocked_broadcast_users(blocked_telegram_ids: list[int]) -> No
user.status = UserStatus.BLOCKED.value
# Отключаем активные подписки
sub_result = await session.execute(
# Проверяем, есть ли активная оплаченная подписка
from app.database.crud.subscription import is_active_paid_subscription
sub_result = await session.execute(select(Subscription).where(Subscription.user_id == user.id))
user_subscription = sub_result.scalar_one_or_none()
if is_active_paid_subscription(user_subscription):
logger.info(
'⏭️ Пропуск отключения подписки: у пользователя активная оплаченная подписка',
telegram_id=telegram_id,
user_id=user.id,
)
await session.commit()
continue
# Отключаем активные подписки (только триальные или истёкшие)
active_sub_result = await session.execute(
select(Subscription).where(
Subscription.user_id == user.id,
Subscription.status.in_(
@@ -559,7 +562,7 @@ async def cleanup_blocked_broadcast_users(blocked_telegram_ids: list[int]) -> No
),
)
)
subscriptions = sub_result.scalars().all()
subscriptions = active_sub_result.scalars().all()
for sub in subscriptions:
sub.status = SubscriptionStatus.DISABLED.value
+9
View File
@@ -56,6 +56,15 @@ class AdvertisingCampaignService:
logger.warning('⚠️ Попытка выдать бонус по неактивной кампании', campaign_id=campaign.id)
return CampaignBonusResult(success=False)
# Prevent partner from being attributed to their own campaign
if campaign.partner_user_id and campaign.partner_user_id == user.id:
logger.info(
'Skipping campaign bonus: user is the campaign partner',
user_id=user.id,
campaign_id=campaign.id,
)
return CampaignBonusResult(success=False)
if campaign.is_balance_bonus:
return await self._apply_balance_bonus(db, user, campaign)
@@ -0,0 +1,272 @@
"""Channel subscription verification service.
Architecture for 100k+ users:
1. ChatMemberUpdated events -> update PostgreSQL (source of truth) + Redis in real-time
2. Middleware reads ONLY from Redis/PostgreSQL (never calls Telegram API directly)
3. Background reconciliation (~10 req/sec) corrects drift
"""
import asyncio
from datetime import UTC, datetime
import structlog
from aiogram import Bot
from aiogram.enums import ChatMemberStatus
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError, TelegramRetryAfter
from app.database.crud.required_channel import (
get_active_channels,
get_user_channel_subs,
upsert_user_channel_sub,
)
from app.database.database import AsyncSessionLocal
from app.utils.cache import ChannelSubCache
logger = structlog.get_logger(__name__)
# Rate limiting for Telegram API calls
_API_SEMAPHORE = asyncio.Semaphore(20) # max 20 concurrent getChatMember calls
_API_DELAY = 0.05 # 50ms between calls -> ~20/sec safe rate
GOOD_STATUSES = (ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.CREATOR)
# How long a DB record is considered fresh (no API call needed)
DB_FRESHNESS_SECONDS = 1800 # 30 min
class ChannelSubscriptionService:
"""Centralized service for channel subscription verification."""
def __init__(self, bot: Bot | None = None):
self.bot = bot
# -- Public API ---------------------------------------------------------------
async def get_required_channels(self) -> list[dict]:
"""Get the list of active required channels (cached)."""
cached = await ChannelSubCache.get_required_channels()
if cached is not None:
return cached
async with AsyncSessionLocal() as db:
channels = await get_active_channels(db)
result = [
{
'id': ch.id,
'channel_id': ch.channel_id,
'channel_link': ch.channel_link,
'title': ch.title,
'sort_order': ch.sort_order,
}
for ch in channels
]
await ChannelSubCache.set_required_channels(result)
return result
async def get_required_channel_ids(self) -> set[str]:
"""Get the set of active required channel_ids (for event filtering)."""
channels = await self.get_required_channels()
return {ch['channel_id'] for ch in channels}
async def check_user_subscriptions(self, telegram_id: int) -> dict[str, bool]:
"""Check user subscriptions to all required channels.
Returns {channel_id: is_member}.
Does NOT call Telegram API unless cache miss + stale DB.
Uses a SINGLE DB session for all channels (no N+1).
"""
channels = await self.get_required_channels()
return await self._check_user_subscriptions_for_channels(telegram_id, channels)
async def _check_user_subscriptions_for_channels(
self,
telegram_id: int,
channels: list[dict],
) -> dict[str, bool]:
"""Internal: check subscriptions for a given list of channels.
Avoids double-fetching required_channels when called from
get_unsubscribed_channels or get_channels_with_status.
"""
if not channels:
return {}
result: dict[str, bool] = {}
channels_needing_db: list[dict] = []
# Layer 1: Redis cache (single MGET round-trip)
all_channel_ids = [ch['channel_id'] for ch in channels]
cached_statuses = await ChannelSubCache.get_sub_statuses(telegram_id, all_channel_ids)
for ch in channels:
channel_id = ch['channel_id']
cached = cached_statuses.get(channel_id)
if cached is not None:
result[channel_id] = cached
else:
channels_needing_db.append(ch)
# Layer 2: PostgreSQL (single session for all channels)
channels_needing_api: list[dict] = []
if channels_needing_db:
async with AsyncSessionLocal() as db:
subs = await get_user_channel_subs(db, telegram_id)
sub_map = {s.channel_id: s for s in subs}
for ch in channels_needing_db:
channel_id = ch['channel_id']
sub = sub_map.get(channel_id)
if sub and sub.checked_at:
age = (datetime.now(UTC) - sub.checked_at).total_seconds()
if age < DB_FRESHNESS_SECONDS:
result[channel_id] = sub.is_member
await ChannelSubCache.set_sub_status(telegram_id, channel_id, sub.is_member)
continue
channels_needing_api.append(ch)
# Layer 3: Rate-limited API calls for channels without fresh data
if channels_needing_api and self.bot:
async with AsyncSessionLocal() as db:
for ch in channels_needing_api:
is_member = await self._rate_limited_check(telegram_id, ch['channel_id'])
result[ch['channel_id']] = is_member
# Write DB first (source of truth), then cache
await upsert_user_channel_sub(db, telegram_id, ch['channel_id'], is_member)
await ChannelSubCache.set_sub_status(telegram_id, ch['channel_id'], is_member)
await db.commit()
elif channels_needing_api:
# No bot available (e.g., cabinet API context) -- fail-closed
logger.warning(
'No bot instance for API check -- failing closed',
telegram_id=telegram_id,
channels=[ch['channel_id'] for ch in channels_needing_api],
)
for ch in channels_needing_api:
result[ch['channel_id']] = False
return result
async def is_user_subscribed_to_all(self, telegram_id: int) -> bool:
"""Quick check: is user subscribed to ALL required channels?"""
subs = await self.check_user_subscriptions(telegram_id)
if not subs:
return True # No required channels = subscribed
return all(subs.values())
async def get_unsubscribed_channels(self, telegram_id: int) -> list[dict]:
"""Get the list of channels the user is NOT subscribed to."""
channels = await self.get_required_channels()
subs = await self._check_user_subscriptions_for_channels(telegram_id, channels)
unsubscribed = []
for ch in channels:
if not subs.get(ch['channel_id'], False):
unsubscribed.append(ch)
return unsubscribed
async def get_channels_with_status(self, telegram_id: int) -> list[dict]:
"""Get all required channels with per-channel subscription status (for cabinet API)."""
channels = await self.get_required_channels()
subs = await self._check_user_subscriptions_for_channels(telegram_id, channels)
result = []
for ch in channels:
result.append(
{
'channel_id': ch['channel_id'],
'channel_link': ch.get('channel_link'),
'title': ch.get('title'),
'is_subscribed': subs.get(ch['channel_id'], False),
}
)
return result
async def get_first_channel_id(self) -> str | None:
"""Get the first active channel ID (for announcements, contest posts, etc.).
Channel IDs are always stored as strings in the DB.
Telegram API accepts string channel_id in chat_id parameters.
"""
channels = await self.get_required_channels()
if not channels:
return None
return channels[0]['channel_id']
# -- Event handlers (called from ChatMemberUpdated router) --------------------
async def on_user_joined(self, telegram_id: int, channel_id: str) -> None:
"""Called when ChatMemberUpdated fires: user subscribed."""
logger.info('Channel join event', telegram_id=telegram_id, channel_id=channel_id)
# Write DB first (source of truth), then cache
async with AsyncSessionLocal() as db:
await upsert_user_channel_sub(db, telegram_id, channel_id, True)
await db.commit()
await ChannelSubCache.set_sub_status(telegram_id, channel_id, True)
async def on_user_left(self, telegram_id: int, channel_id: str) -> None:
"""Called when ChatMemberUpdated fires: user unsubscribed."""
logger.info('Channel leave event', telegram_id=telegram_id, channel_id=channel_id)
# Write DB first (source of truth), then cache
async with AsyncSessionLocal() as db:
await upsert_user_channel_sub(db, telegram_id, channel_id, False)
await db.commit()
await ChannelSubCache.set_sub_status(telegram_id, channel_id, False)
# -- Channel list management --------------------------------------------------
async def invalidate_channels_cache(self) -> None:
"""Invalidate the channels list cache (call after CRUD)."""
await ChannelSubCache.invalidate_channels()
async def invalidate_user_cache(self, telegram_id: int) -> None:
"""Invalidate all cached subscription statuses for a user."""
channels = await self.get_required_channels()
channel_ids = [ch['channel_id'] for ch in channels]
await ChannelSubCache.invalidate_user_channels(telegram_id, channel_ids)
# -- Rate-limited Telegram API ------------------------------------------------
async def _rate_limited_check(self, telegram_id: int, channel_id: str) -> bool:
"""Check subscription via Telegram API with rate-limiting.
SECURITY: Fail-closed -- any error returns False (not subscribed).
For a VPN access control system, false negatives (temporary denial)
are preferable to false positives (unauthorized access).
"""
async with _API_SEMAPHORE:
try:
member = await self.bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
await asyncio.sleep(_API_DELAY)
return member.status in GOOD_STATUSES
except TelegramRetryAfter as e:
logger.warning('Rate limited by Telegram', retry_after=e.retry_after, channel_id=channel_id)
await asyncio.sleep(e.retry_after)
try:
member = await self.bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
return member.status in GOOD_STATUSES
except Exception:
logger.error('Double failure after rate-limit retry', channel_id=channel_id)
return False # Fail-closed on double failure
except TelegramForbiddenError:
logger.critical(
'Bot removed/blocked from channel -- all checks will fail-closed',
channel_id=channel_id,
)
return False # Fail-closed -- bot cannot verify membership
except TelegramBadRequest as e:
if 'user not found' in str(e).lower():
return False # User never interacted with bot in that context
logger.error('Bad request checking channel', channel_id=channel_id, error=str(e))
return False # Fail-closed
except TelegramNetworkError:
logger.warning('Network error checking channel', channel_id=channel_id)
return False # Fail-closed
except Exception as e:
logger.error('Unexpected error checking channel', channel_id=channel_id, error=str(e))
return False # Fail-closed
# Singleton instance (bot is set at startup)
channel_subscription_service = ChannelSubscriptionService()
+5 -7
View File
@@ -295,13 +295,11 @@ class ContestRotationService:
async def _send_channel_announce(self, text: str) -> None:
if not self.bot:
return
channel_id_raw = settings.CHANNEL_SUB_ID
if not channel_id_raw:
from app.services.channel_subscription_service import channel_subscription_service
channel_id = await channel_subscription_service.get_first_channel_id()
if not channel_id:
return
try:
channel_id = int(channel_id_raw)
except Exception:
channel_id = channel_id_raw
keyboard = InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text='🎲 Играть', callback_data='contests_menu')]]
@@ -315,7 +313,7 @@ class ContestRotationService:
reply_markup=keyboard,
)
except Exception as exc:
logger.error('Не удалось отправить анонс в канал', channel_id_raw=channel_id_raw, exc=exc)
logger.error('Не удалось отправить анонс в канал', channel_id=channel_id, exc=exc)
async def _broadcast_to_users(self, text: str) -> None:
"""Отправляет анонс всем пользователям с активной/триальной подпиской."""
+225 -159
View File
@@ -4,7 +4,6 @@ from pathlib import Path
from typing import Any
import structlog
from aiogram.enums import ChatMemberStatus
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -45,12 +44,13 @@ from app.database.models import (
TicketStatus,
User,
UserPromoGroup,
UserStatus,
)
from app.external.remnawave_api import (
RemnaWaveAPIError,
RemnaWaveUser,
TrafficLimitStrategy,
UserStatus,
UserStatus as RemnaWaveUserStatus,
)
from app.localization.texts import get_texts
from app.services.notification_delivery_service import (
@@ -71,6 +71,9 @@ from app.utils.timezone import format_local_datetime
# Кулдаун между повторными уведомлениями об автоплатеже с недостаточным балансом (6 часов)
AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS: int = 21600
# Размер батча для проверки подписок на каналы (keyset pagination)
_CHANNEL_CHECK_BATCH_SIZE: int = 100
logger = structlog.get_logger(__name__)
@@ -93,6 +96,7 @@ class MonitoringService:
text: str,
reply_markup=None,
parse_mode: str | None = 'HTML',
user: User | None = None,
):
"""Отправляет сообщение, добавляя логотип при необходимости."""
if not self.bot:
@@ -103,6 +107,11 @@ class MonitoringService:
logger.debug('Пропуск уведомления: chat_id не указан (email-пользователь)')
return None
# Skip blocked/deleted users to save Telegram rate limits
if user and user.status in (UserStatus.BLOCKED.value, UserStatus.DELETED.value):
logger.debug('Пропуск уведомления: пользователь недоступен', user_id=user.id, status=user.status)
return None
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and (text is None or len(text) <= 1000):
try:
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
@@ -144,11 +153,9 @@ class MonitoringService:
)
return any(marker in message for marker in unreachable_markers)
def _handle_unreachable_user(self, user: User, error: Exception, context: str) -> bool:
async def _handle_unreachable_user(self, user: User, error: Exception, context: str) -> bool:
if isinstance(error, TelegramForbiddenError):
logger.warning(
'⚠️ Пользователь недоступен : бот заблокирован', telegram_id=user.telegram_id, context=context
)
logger.warning('⚠️ Пользователь недоступен: бот заблокирован', telegram_id=user.telegram_id, context=context)
return True
if isinstance(error, TelegramBadRequest) and self._is_unreachable_error(error):
@@ -338,7 +345,7 @@ class MonitoringService:
update_kwargs = dict(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if is_active else UserStatus.EXPIRED,
status=RemnaWaveUserStatus.ACTIVE if is_active else RemnaWaveUserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
@@ -395,7 +402,7 @@ class MonitoringService:
or user_key in all_processed_users
):
logger.debug(
'🔄 Пропускаем дублирование для пользователя на дней',
'Уведомление уже отправлено, пропускаем',
user_identifier=user_identifier,
days=days,
)
@@ -469,6 +476,7 @@ class MonitoringService:
result = await db.execute(
select(Subscription)
.join(Subscription.user)
.options(
selectinload(Subscription.user).selectinload(User.promo_group),
selectinload(Subscription.user)
@@ -481,6 +489,7 @@ class MonitoringService:
Subscription.is_trial == True,
Subscription.end_date <= threshold_time,
Subscription.end_date > datetime.now(UTC),
User.status == UserStatus.ACTIVE.value,
)
)
)
@@ -515,187 +524,242 @@ class MonitoringService:
logger.error('Ошибка проверки истекающих тестовых подписок', error=e)
async def _check_trial_channel_subscriptions(self, db: AsyncSession):
from app.database.crud.subscription import is_recently_updated_by_webhook
"""Background reconciliation of channel subscriptions (rate-limited).
Processes subscriptions in batches using keyset pagination to avoid
loading all trial subscriptions into memory at once. Each batch gets
a fresh DB session to avoid holding a connection pool slot for hours.
When CHANNEL_REQUIRED_FOR_ALL is True, checks ALL active subscriptions
(not just trials). Otherwise only checks trial subscriptions.
"""
from app.database.crud.subscription import is_active_paid_subscription, is_recently_updated_by_webhook
if not settings.CHANNEL_IS_REQUIRED_SUB:
return
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
logger.debug('ℹ️ Проверка отписок от канала отключена — деактивация триальных подписок не требуется')
return
channel_id = settings.CHANNEL_SUB_ID
if not channel_id:
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
logger.debug('Channel unsubscribe check disabled')
return
if not self.bot:
logger.debug('⚠️ Пропускаем проверку подписки на канал — бот недоступен')
logger.debug('Skipping channel subscription check - bot unavailable')
return
from app.database.crud.required_channel import upsert_user_channel_sub
from app.services.channel_subscription_service import channel_subscription_service
from app.utils.cache import ChannelSubCache
channels = await channel_subscription_service.get_required_channels()
if not channels:
return
# Ensure bot is set on service
if not channel_subscription_service.bot:
channel_subscription_service.bot = self.bot
try:
now = datetime.now(UTC)
notifications_allowed = (
NotificationSettingsService.are_notifications_globally_enabled()
and NotificationSettingsService.is_trial_channel_unsubscribed_enabled()
)
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
)
.where(
and_(
Subscription.is_trial.is_(True),
Subscription.end_date > now,
Subscription.status.in_(
[
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.DISABLED.value,
]
),
)
)
)
subscriptions = result.scalars().all()
if not subscriptions:
return
disabled_count = 0
restored_count = 0
checked_count = 0
last_id = 0
for subscription in subscriptions:
user = subscription.user
if not user or not user.telegram_id:
continue
# Build the trial/all filter based on CHANNEL_REQUIRED_FOR_ALL setting
from sqlalchemy import true as sa_true
try:
member = await self.bot.get_chat_member(channel_id, user.telegram_id)
member_status = member.status
is_member = member_status in (
ChatMemberStatus.MEMBER,
ChatMemberStatus.ADMINISTRATOR,
ChatMemberStatus.CREATOR,
)
except TelegramForbiddenError as error:
logger.error(
'❌ Не удалось проверить подписку пользователя на канал : бот заблокирован',
telegram_id=user.telegram_id,
channel_id=channel_id,
error=error,
)
continue
except TelegramBadRequest as error:
# PARTICIPANT_ID_INVALID - пользователь никогда не был в канале, это нормально
logger.warning(
'⚠️ Ошибка Telegram при проверке подписки пользователя',
telegram_id=user.telegram_id,
error=error,
)
continue
except Exception as error:
logger.error(
'❌ Неожиданная ошибка при проверке подписки пользователя',
telegram_id=user.telegram_id,
error=error,
)
continue
is_trial_filter = sa_true() if settings.CHANNEL_REQUIRED_FOR_ALL else Subscription.is_trial.is_(True)
if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.is_trial and not is_member:
if is_recently_updated_by_webhook(subscription):
logger.debug(
'Пропуск деактивации trial подписки : обновлена вебхуком недавно',
subscription_id=subscription.id,
while True:
# Fresh session per batch to avoid long-running connections
async with AsyncSessionLocal() as batch_db:
result = await batch_db.execute(
select(Subscription)
.join(Subscription.user)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
)
continue
subscription = await deactivate_subscription(db, subscription)
disabled_count += 1
logger.info(
'🚫 Триальная подписка пользователя (ID) отключена из-за отписки от канала',
telegram_id=user.telegram_id,
subscription_id=subscription.id,
.where(
and_(
Subscription.id > last_id,
is_trial_filter,
Subscription.end_date > now,
Subscription.status.in_(
[
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.DISABLED.value,
]
),
User.status == UserStatus.ACTIVE.value,
)
)
.order_by(Subscription.id)
.limit(_CHANNEL_CHECK_BATCH_SIZE)
)
if user.remnawave_uuid:
try:
await self.subscription_service.disable_remnawave_user(user.remnawave_uuid)
except Exception as api_error:
logger.error(
'❌ Не удалось отключить пользователя RemnaWave',
remnawave_uuid=user.remnawave_uuid,
api_error=api_error,
subscriptions = result.scalars().all()
if not subscriptions:
break
last_id = subscriptions[-1].id
for subscription in subscriptions:
user = subscription.user
if not user or not user.telegram_id:
continue
# Existing guard: skip if recently updated by webhook
if is_recently_updated_by_webhook(subscription):
logger.debug(
'Skipping subscription: recently updated by webhook',
subscription_id=subscription.id,
)
continue
checked_count += 1
# Rate-limited check for ALL channels
all_subscribed = True
for ch in channels:
is_member = await channel_subscription_service._rate_limited_check(
user.telegram_id, ch['channel_id']
)
# Update DB + cache
await upsert_user_channel_sub(batch_db, user.telegram_id, ch['channel_id'], is_member)
await ChannelSubCache.set_sub_status(user.telegram_id, ch['channel_id'], is_member)
if not is_member:
all_subscribed = False
# DEACTIVATE: was active, now not subscribed to all
if subscription.status == SubscriptionStatus.ACTIVE.value and not all_subscribed:
# Guard: always skip paid subscriptions (user paid money)
if is_active_paid_subscription(subscription):
continue
subscription = await deactivate_subscription(batch_db, subscription)
disabled_count += 1
logger.info(
'Subscription deactivated (channel unsubscribe)',
telegram_id=user.telegram_id,
subscription_id=subscription.id,
is_trial=subscription.is_trial,
)
if notifications_allowed:
if not await notification_sent(
db,
user.id,
subscription.id,
'trial_channel_unsubscribed',
):
sent = await self._send_trial_channel_unsubscribed_notification(user)
if sent:
await record_notification(
db,
if user.remnawave_uuid:
try:
await self.subscription_service.disable_remnawave_user(user.remnawave_uuid)
except Exception as api_error:
logger.error(
'Failed to disable RemnaWave user',
remnawave_uuid=user.remnawave_uuid,
api_error=api_error,
)
if notifications_allowed:
if not await notification_sent(
batch_db,
user.id,
subscription.id,
'trial_channel_unsubscribed',
):
sent = await self._send_trial_channel_unsubscribed_notification(user)
if sent:
await record_notification(
batch_db,
user.id,
subscription.id,
'trial_channel_unsubscribed',
)
# REACTIVATE: was disabled, now subscribed to all
elif subscription.status == SubscriptionStatus.DISABLED.value and all_subscribed:
# Guard: traffic limit exhausted
if (
subscription.traffic_limit_gb
and subscription.traffic_used_gb is not None
and subscription.traffic_used_gb >= subscription.traffic_limit_gb
):
logger.debug(
'Skipping reactivation: traffic exhausted',
subscription_id=subscription.id,
traffic_used=subscription.traffic_used_gb,
traffic_limit=subscription.traffic_limit_gb,
)
elif subscription.status == SubscriptionStatus.DISABLED.value and subscription.is_trial and is_member:
if is_recently_updated_by_webhook(subscription):
logger.debug(
'Пропуск реактивации trial подписки : обновлена вебхуком недавно',
subscription_id=subscription.id,
)
continue
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(subscription)
restored_count += 1
continue
logger.info(
'✅ Триальная подписка пользователя (ID) восстановлена после повторной подписки на канал',
telegram_id=user.telegram_id,
subscription_id=subscription.id,
)
# Guard: disabled by webhook, not by monitoring
if (
subscription.last_webhook_update_at
and subscription.updated_at
and subscription.last_webhook_update_at
>= subscription.updated_at - timedelta(seconds=10)
):
logger.debug(
'Skipping reactivation: disabled by RemnaWave panel',
subscription_id=subscription.id,
last_webhook_at=subscription.last_webhook_update_at,
updated_at=subscription.updated_at,
)
continue
try:
if user.remnawave_uuid:
await self.subscription_service.update_remnawave_user(db, subscription)
else:
await self.subscription_service.create_remnawave_user(db, subscription)
except Exception as api_error:
logger.error(
'❌ Не удалось обновить RemnaWave пользователя',
telegram_id=user.telegram_id,
api_error=api_error,
)
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = datetime.now(UTC)
restored_count += 1
await clear_notification_by_type(
db,
subscription.id,
'trial_channel_unsubscribed',
)
logger.info(
'Subscription restored (channel resubscribe)',
telegram_id=user.telegram_id,
subscription_id=subscription.id,
is_trial=subscription.is_trial,
)
try:
if user.remnawave_uuid:
await self.subscription_service.update_remnawave_user(batch_db, subscription)
else:
await self.subscription_service.create_remnawave_user(batch_db, subscription)
except Exception as api_error:
logger.error(
'Failed to update RemnaWave user',
telegram_id=user.telegram_id,
api_error=api_error,
)
await clear_notification_by_type(
batch_db,
subscription.id,
'trial_channel_unsubscribed',
)
# Commit all changes for this batch
await batch_db.commit()
if disabled_count or restored_count:
check_scope = 'all' if settings.CHANNEL_REQUIRED_FOR_ALL else 'trial'
await self._log_monitoring_event(
db,
'trial_channel_subscription_check',
(
f'Проверено {len(subscriptions)} триальных подписок: отключено {disabled_count}, '
f'восстановлено {restored_count}'
f'Checked {checked_count} {check_scope} subscriptions: '
f'disabled {disabled_count}, restored {restored_count}'
),
{
'checked': len(subscriptions),
'checked': checked_count,
'disabled': disabled_count,
'restored': restored_count,
'scope': check_scope,
},
)
except Exception as error:
logger.error('Ошибка проверки подписки на канал для триальных пользователей', error=error)
logger.error('Error checking channel subscriptions', error=error)
async def _check_expired_subscription_followups(self, db: AsyncSession):
if not NotificationSettingsService.are_notifications_globally_enabled():
@@ -1142,7 +1206,7 @@ class MonitoringService:
return True
except (TelegramForbiddenError, TelegramBadRequest) as exc:
if self._handle_unreachable_user(user, exc, 'уведомление об истечении подписки'):
if await self._handle_unreachable_user(user, exc, 'уведомление об истечении подписки'):
return True
logger.error(
'Ошибка Telegram API при отправке уведомления об истечении подписки пользователю',
@@ -1209,7 +1273,7 @@ class MonitoringService:
return True
except (TelegramForbiddenError, TelegramBadRequest) as exc:
if self._handle_unreachable_user(user, exc, 'уведомление об истекающей подписке'):
if await self._handle_unreachable_user(user, exc, 'уведомление об истекающей подписке'):
return True
logger.error(
'Ошибка Telegram API при отправке уведомления об истечении подписки пользователю',
@@ -1257,11 +1321,12 @@ class MonitoringService:
text=message,
parse_mode='HTML',
reply_markup=keyboard,
user=user,
)
return True
except (TelegramForbiddenError, TelegramBadRequest) as exc:
if self._handle_unreachable_user(user, exc, 'уведомление о завершении тестовой подписки'):
if await self._handle_unreachable_user(user, exc, 'уведомление о завершении тестовой подписки'):
return True
logger.error(
'Ошибка Telegram API при отправке уведомления о завершении тестовой подписки пользователю',
@@ -1301,16 +1366,16 @@ class MonitoringService:
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from app.services.channel_subscription_service import channel_subscription_service
unsubscribed = await channel_subscription_service.get_unsubscribed_channels(user.telegram_id)
buttons = []
if settings.CHANNEL_LINK:
buttons.append(
[
InlineKeyboardButton(
text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'),
url=settings.CHANNEL_LINK,
)
]
)
for ch in unsubscribed:
link = ch.get('channel_link')
if link:
title = ch.get('title') or texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться')
buttons.append([InlineKeyboardButton(text=f'🔗 {title}', url=link)])
buttons.append(
[
InlineKeyboardButton(
@@ -1327,11 +1392,12 @@ class MonitoringService:
text=message,
parse_mode='HTML',
reply_markup=keyboard,
user=user,
)
return True
except (TelegramForbiddenError, TelegramBadRequest) as exc:
if self._handle_unreachable_user(user, exc, 'уведомление об отписке от канала'):
if await self._handle_unreachable_user(user, exc, 'уведомление об отписке от канала'):
return True
logger.error(
'Ошибка Telegram API при отправке уведомления об отписке от канала пользователю',
@@ -1402,7 +1468,7 @@ class MonitoringService:
return True
except (TelegramForbiddenError, TelegramBadRequest) as exc:
if self._handle_unreachable_user(user, exc, 'напоминание об истекшей подписке'):
if await self._handle_unreachable_user(user, exc, 'напоминание об истекшей подписке'):
return True
logger.error(
'Ошибка Telegram API при отправке напоминания об истекшей подписке пользователю',
@@ -1497,7 +1563,7 @@ class MonitoringService:
return True
except (TelegramForbiddenError, TelegramBadRequest) as exc:
if self._handle_unreachable_user(user, exc, 'скидочное уведомление'):
if await self._handle_unreachable_user(user, exc, 'скидочное уведомление'):
return True
logger.error(
'Ошибка Telegram API при отправке скидочного уведомления пользователю',
@@ -1522,7 +1588,7 @@ class MonitoringService:
parse_mode='HTML',
)
except (TelegramForbiddenError, TelegramBadRequest) as exc:
if not self._handle_unreachable_user(user, exc, 'уведомление об успешном автоплатеже'):
if not await self._handle_unreachable_user(user, exc, 'уведомление об успешном автоплатеже'):
logger.error(
'Ошибка Telegram API при отправке уведомления об автоплатеже пользователю',
telegram_id=user.telegram_id,
@@ -1559,7 +1625,7 @@ class MonitoringService:
)
except (TelegramForbiddenError, TelegramBadRequest) as exc:
if not self._handle_unreachable_user(user, exc, 'уведомление о неудачном автоплатеже'):
if not await self._handle_unreachable_user(user, exc, 'уведомление о неудачном автоплатеже'):
logger.error(
'Ошибка Telegram API при отправке уведомления о неудачном автоплатеже пользователю',
telegram_id=user.telegram_id,
+4 -3
View File
@@ -258,7 +258,7 @@ class YooKassaPaymentMixin:
status=yookassa_response['status'],
confirmation_url=yookassa_response.get('confirmation_url'), # Используем confirmation URL
metadata_json=payment_metadata,
payment_method_type='bank_card',
payment_method_type='sbp',
yookassa_created_at=None,
test_mode=yookassa_response.get('test_mode', False),
)
@@ -897,6 +897,7 @@ class YooKassaPaymentMixin:
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
f'подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}',
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info(
'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю',
@@ -1079,14 +1080,14 @@ class YooKassaPaymentMixin:
'Успешно обработан платеж YooKassa как покупка подписки: пользователь , сумма ₽',
yookassa_payment_id=payment.yookassa_payment_id,
user_id=payment.user_id,
amount_kopeks=payment.amount_kopeks / 100,
amount_rubles=payment.amount_kopeks / 100,
)
else:
logger.info(
'Успешно обработан платеж YooKassa : пользователь пополнил баланс на ₽',
yookassa_payment_id=payment.yookassa_payment_id,
user_id=payment.user_id,
amount_kopeks=payment.amount_kopeks / 100,
amount_rubles=payment.amount_kopeks / 100,
)
# Создаем чек через NaloGO (если NALOGO_ENABLED=true)
+6 -9
View File
@@ -323,14 +323,11 @@ class ReferralContestService:
if not self.bot:
return
channel_id_raw = settings.CHANNEL_SUB_ID
if not channel_id_raw:
return
from app.services.channel_subscription_service import channel_subscription_service
try:
channel_id = int(channel_id_raw)
except Exception:
channel_id = channel_id_raw
channel_id = await channel_subscription_service.get_first_channel_id()
if not channel_id:
return
lines = [
f'🏆 {contest.title}',
@@ -358,9 +355,9 @@ class ReferralContestService:
disable_web_page_preview=True,
)
except (TelegramForbiddenError, TelegramNotFound):
logger.info('Не удалось отправить сводку конкурса в канал', channel_id_raw=channel_id_raw)
logger.info('Не удалось отправить сводку конкурса в канал', channel_id=channel_id)
except Exception as exc:
logger.error('Ошибка отправки сводки конкурса в канал', channel_id_raw=channel_id_raw, exc=exc)
logger.error('Ошибка отправки сводки конкурса в канал', channel_id=channel_id, exc=exc)
def _build_participant_message(
self,
+4
View File
@@ -61,6 +61,10 @@ async def send_referral_notification(
async def process_referral_registration(db: AsyncSession, new_user_id: int, referrer_id: int, bot: Bot = None):
try:
if new_user_id == referrer_id:
logger.warning('Self-referral blocked in process_referral_registration', user_id=new_user_id)
return False
new_user = await get_user_by_id(db, new_user_id)
referrer = await get_user_by_id(db, referrer_id)
+12 -3
View File
@@ -564,15 +564,24 @@ class RemnaWaveWebhookService:
except (ValueError, TypeError):
pass
# Sync expire date
# Sync expire date (only if panel date is LATER than local to prevent race condition
# where webhook with stale expireAt overwrites a freshly extended subscription)
expire_at = data.get('expireAt')
if expire_at:
try:
parsed_dt = datetime.fromisoformat(expire_at.replace('Z', '+00:00'))
new_end_date = parsed_dt.astimezone(UTC)
if subscription.end_date != new_end_date:
subscription.end_date = new_end_date
changed = True
if not subscription.end_date or new_end_date > subscription.end_date:
subscription.end_date = new_end_date
changed = True
else:
logger.warning(
'Webhook: пропуск перезаписи end_date — локальная дата позже',
subscription_id=subscription.id,
local_end_date=subscription.end_date,
webhook_end_date=new_end_date,
)
except (ValueError, TypeError):
pass
+8 -2
View File
@@ -781,7 +781,10 @@ class SubscriptionService:
try:
from app.config import PERIOD_PRICES
base_price_original = PERIOD_PRICES.get(period_days, 0)
# Use subscription's tariff price if available, fall back to global PERIOD_PRICES
tariff = getattr(subscription, 'tariff', None)
tariff_price = tariff.get_price_for_period(period_days) if tariff else None
base_price_original = tariff_price if tariff_price is not None else PERIOD_PRICES.get(period_days, 0)
if user is None:
user = getattr(subscription, 'user', None)
@@ -1123,7 +1126,10 @@ class SubscriptionService:
months_in_period = calculate_months_from_days(period_days)
base_price_original = PERIOD_PRICES.get(period_days, 0)
# Use subscription's tariff price if available, fall back to global PERIOD_PRICES
tariff = getattr(subscription, 'tariff', None)
tariff_price = tariff.get_price_for_period(period_days) if tariff else None
base_price_original = tariff_price if tariff_price is not None else PERIOD_PRICES.get(period_days, 0)
if user is None:
user = getattr(subscription, 'user', None)
+1 -4
View File
@@ -171,7 +171,7 @@ class BotConfigurationService:
'MINIAPP': 'Mini App и кастомные ссылки.',
'HAPP': 'Интеграция Happ и связанные ссылки.',
'SKIP': 'Настройки быстрого старта и гайд по подключению.',
'ADDITIONAL': 'Конфигурация app-config.json, deep links и кеша.',
'ADDITIONAL': 'Конфигурация deep links и кеша.',
'DATABASE': 'Режим работы базы данных и пути до файлов.',
'POSTGRES': 'Параметры подключения к PostgreSQL.',
'SQLITE': 'Файл SQLite и резервные параметры.',
@@ -205,8 +205,6 @@ class BotConfigurationService:
'DATABASE_URL': 'DATABASE',
'DATABASE_MODE': 'DATABASE',
'LOCALES_PATH': 'LOCALIZATION',
'CHANNEL_SUB_ID': 'CHANNEL',
'CHANNEL_LINK': 'CHANNEL',
'CHANNEL_IS_REQUIRED_SUB': 'CHANNEL',
'BOT_USERNAME': 'CORE',
'DEFAULT_LANGUAGE': 'LOCALIZATION',
@@ -297,7 +295,6 @@ class BotConfigurationService:
'CABINET_BUTTON_STYLE': 'INTERFACE',
'CONNECT_BUTTON_MODE': 'CONNECT_BUTTON',
'MINIAPP_CUSTOM_URL': 'CONNECT_BUTTON',
'APP_CONFIG_PATH': 'ADDITIONAL',
'ENABLE_DEEP_LINKS': 'ADDITIONAL',
'APP_CONFIG_CACHE_TTL': 'ADDITIONAL',
'INACTIVE_USER_DELETE_MONTHS': 'MAINTENANCE',
+73 -54
View File
@@ -662,22 +662,30 @@ class UserService:
if not user:
return False
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
from app.database.crud.subscription import deactivate_subscription, is_active_paid_subscription
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(
'✅ RemnaWave пользователь деактивирован при блокировке', remnawave_uuid=user.remnawave_uuid
)
except Exception as e:
logger.error('❌ Ошибка деактивации RemnaWave пользователя при блокировке', error=e)
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск отключения RemnaWave и подписки: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
else:
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
if user.subscription:
from app.database.crud.subscription import deactivate_subscription
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(
'✅ RemnaWave пользователь деактивирован при блокировке',
remnawave_uuid=user.remnawave_uuid,
)
except Exception as e:
logger.error('❌ Ошибка деактивации RemnaWave пользователя при блокировке', error=e)
await deactivate_subscription(db, user.subscription)
if user.subscription:
await deactivate_subscription(db, user.subscription)
await update_user(db, user, status=UserStatus.BLOCKED.value)
@@ -741,56 +749,67 @@ class UserService:
if user.remnawave_uuid:
from app.config import settings
from app.database.crud.subscription import is_active_paid_subscription
delete_mode = settings.get_remnawave_user_delete_mode()
try:
from app.services.remnawave_service import RemnaWaveService
remnawave_service = RemnaWaveService()
if delete_mode == 'delete':
# Удаляем пользователя из панели Remnawave
async with remnawave_service.get_api_client() as api:
delete_success = await api.delete_user(user.remnawave_uuid)
if delete_success:
logger.info(
'✅ RemnaWave пользователь удален из панели', remnawave_uuid=user.remnawave_uuid
)
else:
logger.warning(
'⚠️ Не удалось удалить пользователя из панели Remnawave',
remnawave_uuid=user.remnawave_uuid,
)
else:
# Деактивируем пользователя в панели Remnawave
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(
'✅ RemnaWave пользователь деактивирован (режим: )',
remnawave_uuid=user.remnawave_uuid,
delete_mode=delete_mode,
)
except Exception as e:
logger.warning(
'⚠️ Ошибка обработки пользователя в Remnawave (режим: )', delete_mode=delete_mode, error=e
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск отключения RemnaWave при удалении: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
# Если основное действие не удалось, попытаемся хотя бы деактивировать
if delete_mode == 'delete':
try:
else:
delete_mode = settings.get_remnawave_user_delete_mode()
try:
from app.services.remnawave_service import RemnaWaveService
remnawave_service = RemnaWaveService()
if delete_mode == 'delete':
# Удаляем пользователя из панели Remnawave
async with remnawave_service.get_api_client() as api:
delete_success = await api.delete_user(user.remnawave_uuid)
if delete_success:
logger.info(
'✅ RemnaWave пользователь удален из панели',
remnawave_uuid=user.remnawave_uuid,
)
else:
logger.warning(
'⚠️ Не удалось удалить пользователя из панели Remnawave',
remnawave_uuid=user.remnawave_uuid,
)
else:
# Деактивируем пользователя в панели Remnawave
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(
'✅ RemnaWave пользователь деактивирован как fallback',
'✅ RemnaWave пользователь деактивирован (режим: )',
remnawave_uuid=user.remnawave_uuid,
delete_mode=delete_mode,
)
except Exception as fallback_e:
logger.error('❌ Ошибка деактивации RemnaWave как fallback', fallback_e=fallback_e)
except Exception as e:
logger.warning(
'⚠️ Ошибка обработки пользователя в Remnawave (режим: )',
delete_mode=delete_mode,
error=e,
)
# Если основное действие не удалось, попытаемся хотя бы деактивировать
if delete_mode == 'delete':
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(
'✅ RemnaWave пользователь деактивирован как fallback',
remnawave_uuid=user.remnawave_uuid,
)
except Exception as fallback_e:
logger.error('❌ Ошибка деактивации RemnaWave как fallback', fallback_e=fallback_e)
try:
async with db.begin_nested():
+6 -109
View File
@@ -102,7 +102,7 @@ class YooKassaService:
'description': description[:128],
'quantity': '1.00',
'amount': {'value': str(round(amount, 2)), 'currency': currency.upper()},
'vat_code': str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
'vat_code': int(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
'payment_mode': getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
'payment_subject': getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service'),
}
@@ -208,7 +208,7 @@ class YooKassaService:
'description': description[:128],
'quantity': '1.00',
'amount': {'value': str(round(amount, 2)), 'currency': currency.upper()},
'vat_code': str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
'vat_code': int(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
'payment_mode': getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
'payment_subject': getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service'),
}
@@ -223,7 +223,7 @@ class YooKassaService:
payment_request = builder.build()
logger.info(
"Создание платежа YooKassa СБП с подтверждением 'qr' (Idempotence-Key: ). Сумма: . Метаданные: . Чек",
'Создание платежа YooKassa СБП с подтверждением redirect (Idempotence-Key: ). Сумма: . Метаданные: . Чек',
idempotence_key=idempotence_key,
amount=amount,
currency=currency,
@@ -237,14 +237,14 @@ class YooKassaService:
)
logger.info(
'Ответ YooKassa Payment.create (СБП, qr): ID=, Status=, Paid',
'Ответ YooKassa Payment.create (СБП, redirect): ID=, Status=, Paid',
response_id=response.id,
status=response.status,
paid=response.paid,
)
# Возвращаем данные платежа с QR-подтверждением
# Пользователь может использовать QR-код или оплатить через приложение банка по ID платежа
# Возвращаем данные платежа с redirect-подтверждением
# YooKassa покажет QR на десктопе или список банков на мобильном
return {
'id': response.id,
'qr_confirmation_data': response.confirmation.confirmation_data
@@ -270,109 +270,6 @@ class YooKassaService:
logger.error('Ошибка создания платежа YooKassa СБП', error=e, exc_info=True)
return None
async def _create_sbp_payment_with_confirmation_type(
self,
amount: float,
currency: str,
description: str,
metadata: dict[str, Any],
customer_contact_for_receipt: dict[str, str],
confirmation_type: str,
) -> dict[str, Any] | None:
"""Создает SBP платеж с указанным типом подтверждения"""
try:
builder = PaymentRequestBuilder()
builder.set_amount({'value': str(round(amount, 2)), 'currency': currency.upper()})
builder.set_capture(True)
if confirmation_type == 'qr':
builder.set_confirmation({'type': 'qr'})
else: # redirect
builder.set_confirmation({'type': 'redirect', 'return_url': self.return_url})
builder.set_description(description)
builder.set_metadata(metadata)
builder.set_payment_method_data({'type': 'sbp'})
receipt_items_list: list[dict[str, Any]] = [
{
'description': description[:128],
'quantity': '1.00',
'amount': {'value': str(round(amount, 2)), 'currency': currency.upper()},
'vat_code': str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
'payment_mode': getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
'payment_subject': getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service'),
}
]
receipt_data_dict: dict[str, Any] = {'customer': customer_contact_for_receipt, 'items': receipt_items_list}
builder.set_receipt(receipt_data_dict)
idempotence_key = str(uuid.uuid4())
payment_request = builder.build()
logger.info(
'Создание платежа YooKassa СБП с подтверждением (Idempotence-Key: ). Сумма: . Метаданные: . Чек',
confirmation_type=confirmation_type,
idempotence_key=idempotence_key,
amount=amount,
currency=currency,
metadata=metadata,
receipt_data_dict=receipt_data_dict,
)
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None, lambda: YooKassaPayment.create(payment_request, idempotence_key)
)
logger.info(
'Ответ YooKassa Payment.create (СБП, ): ID=, Status=, Paid',
confirmation_type=confirmation_type,
response_id=response.id,
status=response.status,
paid=response.paid,
)
result = {
'id': response.id,
'status': response.status,
'metadata': response.metadata,
'amount_value': float(response.amount.value),
'amount_currency': response.amount.currency,
'idempotence_key_used': idempotence_key,
'paid': response.paid,
'refundable': response.refundable,
'created_at': response.created_at.isoformat()
if hasattr(response.created_at, 'isoformat')
else str(response.created_at),
'description_from_yk': response.description,
'test_mode': response.test if hasattr(response, 'test') else None,
}
# Добавляем данные подтверждения в зависимости от типа
if confirmation_type == 'qr':
if response.confirmation and hasattr(response.confirmation, 'confirmation_data'):
result['confirmation_data'] = response.confirmation.confirmation_data
elif response.confirmation and hasattr(response.confirmation, 'confirmation_url'):
result['confirmation_url'] = response.confirmation.confirmation_url
return result
except Exception as e:
logger.error(
'Ошибка создания платежа YooKassa СБП с подтверждением',
confirmation_type=confirmation_type,
error=e,
exc_info=True,
)
return None
async def get_payment_info(self, payment_id_in_yookassa: str) -> dict[str, Any] | None:
if not self.configured:
logger.error('YooKassa не сконфигурирован. Невозможно получить информацию о платеже.')
+94
View File
@@ -342,3 +342,97 @@ class RateLimitCache:
async def reset_rate_limit(user_id: int, action: str) -> bool:
key = cache_key('rate_limit', user_id, action)
return await cache.delete(key)
class ChannelSubCache:
"""Cache for user channel subscription statuses.
Redis keys:
- channel_sub:{telegram_id}:{channel_id} -> "1" or "0" (TTL 600s)
- required_channels:active -> JSON list of active channels (TTL 60s)
"""
SUB_TTL = 600 # 10 min -- individual user subscription status
CHANNELS_TTL = 60 # 1 min -- list of required channels
@staticmethod
async def get_sub_status(telegram_id: int, channel_id: str) -> bool | None:
"""Get subscription status from cache. None = cache miss."""
key = cache_key('channel_sub', telegram_id, channel_id)
result = await cache.get(key)
if result is None:
return None
return result == 1
@staticmethod
async def get_sub_statuses(telegram_id: int, channel_ids: list[str]) -> dict[str, bool | None]:
"""Batch-fetch subscription statuses via Redis MGET (single round-trip).
Returns {channel_id: True/False/None} where None = cache miss.
Falls back to sequential gets if Redis pipeline is unavailable.
"""
if not channel_ids:
return {}
if not cache._connected or cache.redis_client is None:
return dict.fromkeys(channel_ids, None)
keys = [cache_key('channel_sub', telegram_id, ch_id) for ch_id in channel_ids]
try:
raw_values = await cache.redis_client.mget(keys)
except Exception as e:
logger.warning('Redis MGET failed, falling back to sequential', error=str(e))
result: dict[str, bool | None] = {}
for ch_id in channel_ids:
result[ch_id] = await ChannelSubCache.get_sub_status(telegram_id, ch_id)
return result
statuses: dict[str, bool | None] = {}
for ch_id, raw in zip(channel_ids, raw_values, strict=True):
if raw is None:
statuses[ch_id] = None
else:
try:
parsed = json.loads(raw)
statuses[ch_id] = parsed == 1
except (ValueError, TypeError):
statuses[ch_id] = None
return statuses
@staticmethod
async def set_sub_status(telegram_id: int, channel_id: str, is_member: bool) -> None:
key = cache_key('channel_sub', telegram_id, channel_id)
await cache.set(key, 1 if is_member else 0, expire=ChannelSubCache.SUB_TTL)
@staticmethod
async def invalidate_sub(telegram_id: int, channel_id: str) -> None:
key = cache_key('channel_sub', telegram_id, channel_id)
await cache.delete(key)
@staticmethod
async def invalidate_user_channels(telegram_id: int, channel_ids: list[str]) -> None:
"""Invalidate specific channel keys for a user using single Redis DELETE.
Uses multi-key DELETE (O(K)) instead of delete_pattern() which uses KEYS (O(N)).
At 100k users * 5 channels = 500k keys, KEYS would block Redis for seconds.
"""
if not channel_ids or not cache._connected or not cache.redis_client:
return
keys = [cache_key('channel_sub', telegram_id, ch_id) for ch_id in channel_ids]
try:
await cache.redis_client.delete(*keys)
except Exception as e:
logger.warning('Failed to invalidate user channel cache', telegram_id=telegram_id, error=e)
@staticmethod
async def get_required_channels() -> list[dict] | None:
"""Get the list of required channels from cache."""
return await cache.get('required_channels:active')
@staticmethod
async def set_required_channels(channels: list[dict]) -> None:
await cache.set('required_channels:active', channels, expire=ChannelSubCache.CHANNELS_TTL)
@staticmethod
async def invalidate_channels() -> None:
await cache.delete('required_channels:active')
+25 -11
View File
@@ -50,6 +50,18 @@ _original_answer = Message.answer
_original_edit_text = Message.edit_text
async def _text_answer(self: Message, text: str = None, **kwargs):
"""Обёртка над оригинальным Message.answer с подавлением web page preview."""
kwargs.setdefault('disable_web_page_preview', True)
return await _original_answer(self, text, **kwargs)
async def _text_edit(self: Message, text: str, **kwargs):
"""Обёртка над оригинальным Message.edit_text с подавлением web page preview."""
kwargs.setdefault('disable_web_page_preview', True)
return await _original_edit_text(self, text, **kwargs)
def _get_language(message: Message) -> str | None:
try:
user = message.from_user
@@ -121,11 +133,14 @@ def is_topic_required_error(error: Exception) -> bool:
async def _answer_with_photo(self: Message, text: str = None, **kwargs):
# Уважаем флаг в рантайме: если логотип выключен — не подменяем ответ
if not settings.ENABLE_LOGO_MODE:
# Фото-сообщения не показывают web page preview, текстовые — показывают.
# Подавляем превью чтобы поведение не менялось при переключении режима логотипа.
kwargs.setdefault('disable_web_page_preview', True)
return await _original_answer(self, text, **kwargs)
# Если caption слишком длинный для фото — отправим как текст
try:
if text is not None and len(text) > 900:
return await _original_answer(self, text, **kwargs)
return await _text_answer(self, text, **kwargs)
except Exception:
pass
language = _get_language(self)
@@ -143,27 +158,27 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs):
fallback_text = append_privacy_hint(text, language)
safe_kwargs = prepare_privacy_safe_kwargs(kwargs)
try:
return await _original_answer(self, fallback_text, **safe_kwargs)
return await _text_answer(self, fallback_text, **safe_kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
raise
# Фоллбек, если Telegram ругается на caption или другое ограничение: отправим как текст
try:
return await _original_answer(self, text, **kwargs)
return await _text_answer(self, text, **kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
raise
except Exception:
try:
return await _original_answer(self, text, **kwargs)
return await _text_answer(self, text, **kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
raise
try:
return await _original_answer(self, text, **kwargs)
return await _text_answer(self, text, **kwargs)
except TelegramBadRequest as error:
if is_topic_required_error(error):
return None
@@ -173,6 +188,7 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs):
async def _edit_with_photo(self: Message, text: str, **kwargs):
# Уважаем флаг в рантайме: если логотип выключен — не подменяем редактирование
if not settings.ENABLE_LOGO_MODE:
kwargs.setdefault('disable_web_page_preview', True)
return await _original_edit_text(self, text, **kwargs)
if self.photo:
language = _get_language(self)
@@ -183,7 +199,7 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
await self.delete()
except Exception:
pass
return await _original_answer(self, text, **kwargs)
return await _text_answer(self, text, **kwargs)
except Exception:
pass
if LOGO_PATH.exists():
@@ -210,7 +226,7 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
except Exception:
pass
try:
return await _original_answer(self, fallback_text, **safe_kwargs)
return await _text_answer(self, fallback_text, **safe_kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
@@ -221,14 +237,14 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
except Exception:
pass
try:
return await _original_answer(self, text, **kwargs)
return await _text_answer(self, text, **kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
raise
# Обработка ошибок MESSAGE_ID_INVALID для сообщений без фото
try:
return await _original_edit_text(self, text, **kwargs)
return await _text_edit(self, text, **kwargs)
except TelegramBadRequest as error:
if is_topic_required_error(error):
return None
@@ -239,7 +255,5 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
def patch_message_methods():
if not settings.ENABLE_LOGO_MODE:
return
Message.answer = _answer_with_photo
Message.edit_text = _edit_with_photo
+3 -1
View File
@@ -22,6 +22,7 @@ CALLBACK_TO_CABINET_PATH: dict[str, str] = {
'buy_traffic': '/subscription',
'menu_referrals': '/referral',
'menu_referral': '/referral',
'menu_promocode': '/balance',
'menu_support': '/support',
'menu_info': '/info',
'menu_profile': '/profile',
@@ -44,6 +45,7 @@ CALLBACK_TO_CABINET_STYLE: dict[str, str] = {
'buy_traffic': 'success',
'menu_referrals': 'success',
'menu_referral': 'success',
'menu_promocode': 'primary',
'menu_support': 'primary',
'menu_info': 'primary',
'menu_profile': 'primary',
@@ -54,7 +56,7 @@ CALLBACK_TO_CABINET_STYLE: dict[str, str] = {
BUTTON_KEY_TO_CABINET_PATH: dict[str, str] = {
'balance': '/balance/top-up',
'referrals': '/referral',
'promocode': '/subscription',
'promocode': '/balance',
'connect': '/subscription',
'subscription': '/subscription',
'support': '/support',
+32
View File
@@ -172,6 +172,9 @@ def convert_subscription_link_to_happ_scheme(subscription_link: str | None) -> s
def resolve_hwid_device_limit(subscription: Subscription | None) -> int | None:
"""Return a device limit value for RemnaWave payloads when selection is enabled."""
import structlog
_logger = structlog.get_logger('resolve_hwid_device_limit')
if subscription is None:
return None
@@ -179,12 +182,23 @@ def resolve_hwid_device_limit(subscription: Subscription | None) -> int | None:
if not settings.is_devices_selection_enabled():
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is not None:
_logger.info(
'DEVICES_SELECTION disabled, using forced limit',
forced_limit=forced_limit,
subscription_device_limit=getattr(subscription, 'device_limit', None),
subscription_id=getattr(subscription, 'id', None),
)
return forced_limit
# Если forced_limit не задан, используем device_limit из подписки
# чтобы при смене тарифа лимит устройств обновлялся в панели
limit = getattr(subscription, 'device_limit', None)
if limit is None or limit <= 0:
_logger.warning(
'device_limit is None or <= 0, returning None',
device_limit=limit,
subscription_id=getattr(subscription, 'id', None),
)
return None
return limit
@@ -199,10 +213,18 @@ def resolve_hwid_device_limit_for_payload(
RemnaWave should continue receiving the subscription's stored limit so the
external panel stays aligned with the bot configuration.
"""
import structlog
_logger = structlog.get_logger('resolve_hwid_device_limit')
resolved_limit = resolve_hwid_device_limit(subscription)
if resolved_limit is not None:
_logger.info(
'hwid_device_limit resolved',
resolved_limit=resolved_limit,
subscription_id=getattr(subscription, 'id', None),
)
return resolved_limit
if subscription is None:
@@ -210,8 +232,18 @@ def resolve_hwid_device_limit_for_payload(
fallback_limit = getattr(subscription, 'device_limit', None)
if fallback_limit is None or fallback_limit <= 0:
_logger.warning(
'fallback device_limit is None or <= 0, NOT sending hwidDeviceLimit to RemnaWave',
fallback_limit=fallback_limit,
subscription_id=getattr(subscription, 'id', None),
)
return None
_logger.info(
'using fallback device_limit',
fallback_limit=fallback_limit,
subscription_id=getattr(subscription, 'id', None),
)
return fallback_limit
+14 -83
View File
@@ -1,12 +1,10 @@
from __future__ import annotations
import json
import math
import re
from collections.abc import Collection
from datetime import UTC, datetime, timedelta
from decimal import ROUND_FLOOR, ROUND_HALF_UP, ROUND_UP, Decimal, InvalidOperation
from pathlib import Path
from typing import Any
from uuid import uuid4
@@ -205,17 +203,6 @@ router = APIRouter()
promo_code_service = PromoCodeService()
renewal_service = SubscriptionRenewalService()
# Кешированный Bot для проверки подписки на канал (снижает нагрузку)
_channel_check_bot: Bot | None = None
def _get_channel_check_bot() -> Bot:
"""Получить или создать Bot для проверки подписки на канал."""
global _channel_check_bot
if _channel_check_bot is None:
_channel_check_bot = Bot(token=settings.BOT_TOKEN)
return _channel_check_bot
_CRYPTOBOT_MIN_USD = 1.0
_CRYPTOBOT_MAX_USD = 1000.0
@@ -239,57 +226,6 @@ def _get_tariff_monthly_price(tariff) -> int:
return 0
@router.get('/app-config.json')
async def get_app_config() -> dict[str, Any]:
data = _load_app_config_data()
if data is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='App config not found')
return data
def _get_app_config_candidate_files() -> list[Path]:
seen: set[Path] = set()
candidates: list[Path] = []
def _add_candidate(path: Path) -> None:
resolved = path.resolve()
if resolved not in seen:
seen.add(resolved)
candidates.append(resolved)
cwd = Path.cwd()
_add_candidate(cwd / 'miniapp' / 'app-config.json')
_add_candidate(cwd / 'app-config.json')
current = Path(__file__).resolve()
for parent in current.parents:
_add_candidate(parent / 'miniapp' / 'app-config.json')
_add_candidate(parent / 'app-config.json')
_add_candidate(Path('/var/www/remnawave-miniapp/app-config.json'))
return candidates
def _load_app_config_data() -> dict[str, Any] | None:
for path in _get_app_config_candidate_files():
if not path.is_file():
continue
try:
with path.open('r', encoding='utf-8') as file:
data = json.load(file)
except (OSError, json.JSONDecodeError) as error:
logger.warning('Failed to load app-config from', path=path, error=error)
continue
if isinstance(data, dict):
return data
return None
_DECIMAL_ONE_HUNDRED = Decimal(100)
_DECIMAL_CENT = Decimal('0.01')
@@ -3100,26 +3036,21 @@ async def get_subscription_details(
) from None
# Check required channel subscription
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
try:
bot = _get_channel_check_bot()
chat_member = await bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=telegram_id)
# Не закрываем сессию - бот переиспользуется
if settings.CHANNEL_IS_REQUIRED_SUB:
from app.services.channel_subscription_service import channel_subscription_service
if chat_member.status not in ['member', 'administrator', 'creator']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'channel_subscription_required',
'message': 'Please subscribe to our channel to continue',
'channel_link': settings.CHANNEL_LINK,
},
)
except HTTPException:
raise
except Exception as e:
logger.warning('Failed to check channel subscription for user', telegram_id=telegram_id, error=e)
# Don't block user if check fails
channels_with_status = await channel_subscription_service.get_channels_with_status(telegram_id)
is_subscribed = all(ch['is_subscribed'] for ch in channels_with_status) if channels_with_status else True
if not is_subscribed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'channel_subscription_required',
'message': 'Please subscribe to the required channels to continue',
'channels': channels_with_status,
},
)
user = await get_user_by_telegram_id(db, telegram_id)
purchase_url = (settings.MINIAPP_PURCHASE_URL or '').strip()
+10
View File
@@ -312,6 +312,16 @@ async def delete_subscription(
"""
subscription = await _get_subscription(db, subscription_id)
from app.database.crud.subscription import is_active_paid_subscription
if is_active_paid_subscription(subscription):
logger.info(
'⏭️ Пропуск деактивации: у пользователя активная оплаченная подписка',
subscription_id=subscription_id,
)
subscription = await _get_subscription(db, subscription.id)
return _serialize_subscription(subscription)
await deactivate_subscription(db, subscription)
# Деактивируем пользователя в RemnaWave, если есть UUID
+10
View File
@@ -453,10 +453,20 @@ async def delete_user_subscription(
"""
user = await _get_user_by_id_or_telegram_id(db, user_id)
from app.database.crud.subscription import is_active_paid_subscription
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User has no subscription')
if is_active_paid_subscription(subscription):
logger.info(
'⏭️ Пропуск деактивации: у пользователя активная оплаченная подписка',
user_id=user.id,
)
user = await get_user_by_id(db, user.id)
return _serialize_user(user)
await deactivate_subscription(db, subscription)
# Деактивируем пользователя в RemnaWave, если есть UUID
-2
View File
@@ -69,8 +69,6 @@ services:
# Данные приложения (для SQLite в случае переключения)
- ./data:/app/data:rw
- ./locales:/app/locales:rw
# Конфигурация приложения
# - ./app-config.json:/app/app-config.json:ro
# Timezone
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
+4
View File
@@ -287,6 +287,10 @@ async def main():
daily_subscription_service.set_bot(bot)
telegram_notifier.set_bot(bot)
from app.services.channel_subscription_service import channel_subscription_service
channel_subscription_service.bot = bot
# Initialize email broadcast service
from app.cabinet.services.email_service import email_service
from app.services.broadcast_service import email_broadcast_service
@@ -0,0 +1,50 @@
"""add email_templates table
Revision ID: 0004
Revises: 0003
Create Date: 2026-02-18
Adds email_templates table for custom email template overrides.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '0004'
down_revision: Union[str, None] = '0003'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_table(table: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return table in inspector.get_table_names()
def upgrade() -> None:
if _has_table('email_templates'):
return
op.create_table(
'email_templates',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('notification_type', sa.String(100), nullable=False),
sa.Column('language', sa.String(10), nullable=False),
sa.Column('subject', sa.String(500), nullable=False),
sa.Column('body_html', sa.Text(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('true')),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('notification_type', 'language', name='uq_email_templates_type_lang'),
)
op.create_index('ix_email_templates_notification_type', 'email_templates', ['notification_type'])
def downgrade() -> None:
op.drop_index('ix_email_templates_notification_type', table_name='email_templates')
op.drop_table('email_templates')
@@ -0,0 +1,167 @@
"""repair missing columns from skipped migrations
Revision ID: 0005
Revises: 0004
Create Date: 2026-02-23
Some databases had auto-stamp to 'head' applied before migrations 0002-0004
were actually executed, leaving the alembic_version at 0004 but missing
columns/tables that those migrations would have created. This migration
re-checks and applies any missing schema changes.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '0005'
down_revision: Union[str, None] = '0004'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_column(table: str, column: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return column in [c['name'] for c in inspector.get_columns(table)]
def _has_table(table: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return table in inspector.get_table_names()
def _has_index(table: str, index_name: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return index_name in [idx['name'] for idx in inspector.get_indexes(table)]
def _has_constraint(table: str, constraint_name: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return constraint_name in [fk['name'] for fk in inspector.get_foreign_keys(table)]
def upgrade() -> None:
# --- From 0002: referral_earnings.campaign_id ---
if _has_table('referral_earnings') and not _has_column('referral_earnings', 'campaign_id'):
op.add_column('referral_earnings', sa.Column('campaign_id', sa.Integer(), nullable=True))
if _has_table('advertising_campaigns'):
op.create_foreign_key(
'fk_referral_earnings_campaign_id',
'referral_earnings',
'advertising_campaigns',
['campaign_id'],
['id'],
ondelete='SET NULL',
)
op.create_index('ix_referral_earnings_campaign_id', 'referral_earnings', ['campaign_id'])
# Backfill from advertising_campaign_registrations
if _has_table('advertising_campaign_registrations'):
op.execute(
sa.text("""
UPDATE referral_earnings re
SET campaign_id = sub.campaign_id
FROM (
SELECT DISTINCT ON (user_id) user_id, campaign_id
FROM advertising_campaign_registrations
ORDER BY user_id, created_at ASC
) sub
WHERE sub.user_id = re.referral_id
AND re.campaign_id IS NULL
""")
)
# --- From 0003: users.partner_status ---
if not _has_column('users', 'partner_status'):
op.add_column('users', sa.Column('partner_status', sa.String(20), nullable=False, server_default='none'))
op.create_index('ix_users_partner_status', 'users', ['partner_status'])
# --- From 0003: broadcast_history.blocked_count ---
if _has_table('broadcast_history') and not _has_column('broadcast_history', 'blocked_count'):
op.add_column('broadcast_history', sa.Column('blocked_count', sa.Integer(), nullable=True, server_default='0'))
# --- From 0003: advertising_campaigns.partner_user_id ---
if _has_table('advertising_campaigns') and not _has_column('advertising_campaigns', 'partner_user_id'):
op.add_column('advertising_campaigns', sa.Column('partner_user_id', sa.Integer(), nullable=True))
op.create_foreign_key(
'fk_advertising_campaigns_partner_user_id',
'advertising_campaigns',
'users',
['partner_user_id'],
['id'],
ondelete='SET NULL',
)
op.create_index('ix_advertising_campaigns_partner_user_id', 'advertising_campaigns', ['partner_user_id'])
# --- From 0003: withdrawal_requests ---
if not _has_table('withdrawal_requests'):
op.create_table(
'withdrawal_requests',
sa.Column('id', sa.Integer(), primary_key=True, index=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False, index=True),
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
sa.Column('status', sa.String(50), nullable=False, server_default='pending', index=True),
sa.Column('payment_details', sa.Text(), nullable=True),
sa.Column('risk_score', sa.Integer(), server_default='0'),
sa.Column('risk_analysis', sa.Text(), nullable=True),
sa.Column('processed_by', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('admin_comment', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# --- From 0003: partner_applications ---
if not _has_table('partner_applications'):
op.create_table(
'partner_applications',
sa.Column('id', sa.Integer(), primary_key=True, index=True),
sa.Column(
'user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False
),
sa.Column('company_name', sa.String(255), nullable=True),
sa.Column('website_url', sa.String(500), nullable=True),
sa.Column('telegram_channel', sa.String(255), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('expected_monthly_referrals', sa.Integer(), nullable=True),
sa.Column('status', sa.String(20), nullable=False, server_default='pending'),
sa.Column('admin_comment', sa.Text(), nullable=True),
sa.Column('approved_commission_percent', sa.Integer(), nullable=True),
sa.Column(
'processed_by', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True
),
sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# --- From 0004: email_templates ---
if not _has_table('email_templates'):
op.create_table(
'email_templates',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('notification_type', sa.String(100), nullable=False),
sa.Column('language', sa.String(10), nullable=False),
sa.Column('subject', sa.String(500), nullable=False),
sa.Column('body_html', sa.Text(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('true')),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('notification_type', 'language', name='uq_email_templates_type_lang'),
)
op.create_index('ix_email_templates_notification_type', 'email_templates', ['notification_type'])
def downgrade() -> None:
# This is a repair migration — downgrade is a no-op.
# The original migrations handle their own downgrades.
pass
@@ -0,0 +1,69 @@
"""add missing broadcast_history columns
Revision ID: 0006
Revises: 0005
Create Date: 2026-02-23
Adds blocked_count, channel, email_subject, email_html_content
to broadcast_history. The blocked_count column was defined in 0003/0005
but may not have been applied. The email columns were never migrated.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '0006'
down_revision: Union[str, None] = '0005'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_column(table: str, column: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return column in [c['name'] for c in inspector.get_columns(table)]
def _has_table(table: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return table in inspector.get_table_names()
def upgrade() -> None:
if not _has_table('broadcast_history'):
return
if not _has_column('broadcast_history', 'blocked_count'):
op.add_column('broadcast_history', sa.Column('blocked_count', sa.Integer(), nullable=True, server_default='0'))
if not _has_column('broadcast_history', 'channel'):
op.add_column(
'broadcast_history',
sa.Column('channel', sa.String(20), nullable=False, server_default='telegram'),
)
if not _has_column('broadcast_history', 'email_subject'):
op.add_column('broadcast_history', sa.Column('email_subject', sa.String(255), nullable=True))
if not _has_column('broadcast_history', 'email_html_content'):
op.add_column('broadcast_history', sa.Column('email_html_content', sa.Text(), nullable=True))
def downgrade() -> None:
if not _has_table('broadcast_history'):
return
if _has_column('broadcast_history', 'email_html_content'):
op.drop_column('broadcast_history', 'email_html_content')
if _has_column('broadcast_history', 'email_subject'):
op.drop_column('broadcast_history', 'email_subject')
if _has_column('broadcast_history', 'channel'):
op.drop_column('broadcast_history', 'channel')
# blocked_count is not dropped here — it belongs to migration 0003
@@ -0,0 +1,67 @@
"""fix all remaining naive timestamp columns to timestamptz
Revision ID: 0007
Revises: 0006
Create Date: 2026-02-23
The old universal_migration.py created some tables with `timestamp` (naive)
columns and had a catch-all migration that converted ALL naive timestamp
columns to `timestamptz` on every startup. When universal_migration.py was
replaced with Alembic, that catch-all migration stopped running.
Databases where `email_templates` (and potentially other tables) were created
by universal_migration.py before the catch-all ran still have naive columns.
The code uses `datetime.now(UTC)` (timezone-aware), causing asyncpg to raise:
"can't subtract offset-naive and offset-aware datetimes"
This migration finds and converts ALL remaining naive timestamp columns
in public schema to timestamptz, assuming UTC for existing data.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '0007'
down_revision: Union[str, None] = '0006'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
# Find all naive timestamp columns in public schema
result = conn.execute(
sa.text("""
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND data_type = 'timestamp without time zone'
ORDER BY table_name, column_name
""")
)
columns = result.fetchall()
if not columns:
return
# Set timezone context for the conversion
conn.execute(sa.text("SET LOCAL timezone = 'UTC'"))
for table_name, column_name in columns:
op.execute(
sa.text(
f'ALTER TABLE "{table_name}" '
f'ALTER COLUMN "{column_name}" TYPE TIMESTAMPTZ '
f"USING \"{column_name}\" AT TIME ZONE 'UTC'"
)
)
def downgrade() -> None:
# No-op: converting back to naive timestamps would lose timezone info
# and re-introduce the original bug.
pass
@@ -0,0 +1,61 @@
"""add required_channels and user_channel_subscriptions tables
Revision ID: 0008
Revises: 0007
Create Date: 2026-02-24
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0008'
down_revision: Union[str, None] = '0007'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_table(table_name: str) -> bool:
"""Check if table already exists (idempotency guard)."""
conn = op.get_bind()
result = conn.execute(
sa.text(
'SELECT EXISTS (SELECT 1 FROM information_schema.tables '
"WHERE table_schema = 'public' AND table_name = :name)"
),
{'name': table_name},
)
return result.scalar()
def upgrade() -> None:
if not _has_table('required_channels'):
op.create_table(
'required_channels',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('channel_id', sa.String(100), unique=True, nullable=False),
sa.Column('channel_link', sa.String(500), nullable=True),
sa.Column('title', sa.String(255), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
)
if not _has_table('user_channel_subscriptions'):
op.create_table(
'user_channel_subscriptions',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('telegram_id', sa.BigInteger(), nullable=False),
sa.Column('channel_id', sa.String(100), nullable=False),
sa.Column('is_member', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('checked_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint('telegram_id', 'channel_id', name='uq_user_channel_sub'),
sa.Index('ix_user_channel_sub_telegram_id', 'telegram_id'),
)
def downgrade() -> None:
op.drop_table('user_channel_subscriptions')
op.drop_table('required_channels')
@@ -0,0 +1,28 @@
"""add channel_id index for user_channel_subscriptions
Revision ID: 0009
Revises: 0008
Create Date: 2026-02-24
"""
from typing import Sequence, Union
from alembic import op
revision: str = '0009'
down_revision: Union[str, None] = '0008'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
'ix_user_channel_sub_channel_id',
'user_channel_subscriptions',
['channel_id'],
if_not_exists=True,
)
def downgrade() -> None:
op.drop_index('ix_user_channel_sub_channel_id', table_name='user_channel_subscriptions')
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.16.3"
version = "3.18.0"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }
+11
View File
@@ -146,11 +146,22 @@ if 'yookassa' not in sys.modules:
payment_builder_module.PaymentRequestBuilder = _FakePaymentRequestBuilder
confirmation_module.ConfirmationType = _FakeConfirmationType
exceptions_module = types.ModuleType('yookassa.domain.exceptions')
not_found_module = types.ModuleType('yookassa.domain.exceptions.not_found_error')
class _FakeNotFoundError(Exception):
pass
not_found_module.NotFoundError = _FakeNotFoundError
exceptions_module.not_found_error = not_found_module
sys.modules['yookassa.domain'] = domain_module
sys.modules['yookassa.domain.request'] = request_module
sys.modules['yookassa.domain.request.payment_request_builder'] = payment_builder_module
sys.modules['yookassa.domain.common'] = common_module
sys.modules['yookassa.domain.common.confirmation_type'] = confirmation_module
sys.modules['yookassa.domain.exceptions'] = exceptions_module
sys.modules['yookassa.domain.exceptions.not_found_error'] = not_found_module
@pytest.fixture
@@ -212,7 +212,7 @@ async def test_create_yookassa_sbp_payment_success(monkeypatch: pytest.MonkeyPat
assert result is not None
assert result['confirmation_token'] == 'token123'
assert captured_args['payment_method_type'] == 'bank_card'
assert captured_args['payment_method_type'] == 'sbp'
assert captured_args['metadata_json']['type'] == 'balance_topup_sbp'