From 0d637340f5b101cba164d8125174a18b30be1daa Mon Sep 17 00:00:00 2001 From: kavore <161734431+kavore@users.noreply.github.com> Date: Fri, 6 Feb 2026 23:49:46 +0300 Subject: [PATCH] fix(env): stabilize .env parsing and add feature toggles - Handle dotenv placeholders like 'KEY= # comment' without validation crashes - Add REQUIRED_CHANNEL_SUBSCRIBE_TO_USE to explicitly enable channel gate - Add REFERRAL_ENABLED to fully disable referral flow and bonuses - Hide referral UI/actions when disabled and ignore ref start params - Update .env.example defaults and README docs for new flags --- .env.example | 36 +++++++++++---------- README.md | 6 ++-- bot/handlers/user/referral.py | 12 +++++++ bot/handlers/user/start.py | 25 ++++++++++++--- bot/keyboards/inline/user_keyboards.py | 11 ++++--- bot/services/referral_service.py | 10 ++++++ config/settings.py | 44 +++++++++++++++++++++----- 7 files changed, 109 insertions(+), 35 deletions(-) diff --git a/.env.example b/.env.example index e4459c4..b1b0845 100644 --- a/.env.example +++ b/.env.example @@ -17,14 +17,15 @@ DEFAULT_CURRENCY_SYMBOL="RUB" # SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service -SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app -START_COMMAND_DESCRIPTION= # Description of the /start command -DISABLE_WELCOME_MESSAGE= # Disable the welcome message +SUBSCRIPTION_MINI_APP_URL="" # URL of the subscription mini-app +START_COMMAND_DESCRIPTION="" # Description of the /start command +DISABLE_WELCOME_MESSAGE=False # Disable the welcome message MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited) # Required channel subscription -REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join +REQUIRED_CHANNEL_SUBSCRIBE_TO_USE=False # Enable/disable the required channel gate +REQUIRED_CHANNEL_ID="" # Telegram channel ID (e.g. -1001234567890) the user must join REQUIRED_CHANNEL_LINK=https://t.me/your_channel # Optional: public link/invite button text opens # Webhook Base URL (used for Telegram and payment providers) @@ -71,21 +72,21 @@ CRYPTOPAY_ASSET=RUB # # Platega Payment Gateway Configuration PLATEGA_BASE_URL=https://app.platega.io # Base API URL -PLATEGA_MERCHANT_ID= # Your MerchantId from Platega -PLATEGA_SECRET= # API secret from Platega +PLATEGA_MERCHANT_ID="" # Your MerchantId from Platega +PLATEGA_SECRET="" # API secret from Platega PLATEGA_PAYMENT_METHOD=2 # Payment method ID (2=SBP QR, 10=RU cards, 12=International, 13=Crypto) -PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link) -PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL) +PLATEGA_RETURN_URL="" # Optional: redirect after successful payment (defaults to bot link) +PLATEGA_FAILED_URL="" # Optional: redirect after failed/cancelled payment (defaults to return URL) # SeverPay Payment Gateway Configuration SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL -SEVERPAY_MID= # Your MID from SeverPay -SEVERPAY_TOKEN= # API token/secret for signing requests -SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link) -SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default) +SEVERPAY_MID="" # Your MID from SeverPay +SEVERPAY_TOKEN="" # API token/secret for signing requests +SEVERPAY_RETURN_URL="" # Optional: redirect URL after payment (defaults to bot link) +SEVERPAY_LIFETIME_MINUTES="" # Optional: payment link lifetime in minutes (30-4320, leave empty for default) # Tribute Payment Gateway Configuration -TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures +TRIBUTE_API_KEY="" # API key for verifying Tribute webhook signatures TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments @@ -121,6 +122,7 @@ SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify +REFERRAL_ENABLED=True # Enable/disable the referral system REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee LEGACY_REFS=true # Allow ref_ links. Leave unset/true unless you want to disable old links # Referral Bonus Days @@ -138,7 +140,7 @@ REFEREE_BONUS_DAYS_12_MONTHS=15 # Panel API Configuration PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API PANEL_API_KEY=your_panel_api_key # Panel API key -PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures +PANEL_WEBHOOK_SECRET="" # secret used to verify panel webhook signatures # User traffic limits (applied for all users) # 0 means unlimited @@ -148,7 +150,7 @@ USER_TRAFFIC_STRATEGY="NO_RESET" # # Default Internal Squads for Users (Optional, comma-separated UUIDs) USER_SQUAD_UUIDS=uuid1,uuid2,uuid3 # Default External Squad for Users (Optional, single UUID) -USER_EXTERNAL_SQUAD_UUID= # Optional: UUID from Remnawave External Squads to auto-link new panel users +USER_EXTERNAL_SQUAD_UUID="" # Optional: UUID from Remnawave External Squads to auto-link new panel users # Trial Settings TRIAL_ENABLED=True # Enable the trial period @@ -158,7 +160,7 @@ TRIAL_TRAFFIC_STRATEGY="NO_RESET" # # Connection link handling (happ crypt4) CRYPT4_ENABLED=False # Enable happ crypt4 encryption for subscription URLs -CRYPT4_REDIRECT_URL= # Base redirect to wrap the connect button, e.g. https://redir.example.com?url= +CRYPT4_REDIRECT_URL="" # Base redirect to wrap the connect button, e.g. https://redir.example.com?url= # Web Server Settings (for handling webhooks) WEB_SERVER_HOST="0.0.0.0" @@ -170,7 +172,7 @@ LOG_LEVEL=INFO # # Admin Logging Configuration LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications -LOG_THREAD_ID= # Optional: Thread ID for supergroup messages +LOG_THREAD_ID="" # Optional: Thread ID for supergroup messages LOG_NEW_USERS=True # Log new user registrations LOG_PAYMENTS=True # Log payments LOG_PROMO_ACTIVATIONS=True # Log promo code activations diff --git a/README.md b/README.md index 2368874..0d8ea13 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,10 @@ | `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` | | `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` | | `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` | - | `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` | + | `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | Включить/выключить обязательную проверку подписки на канал (`true`/`false`). | `false` | + | `REQUIRED_CHANNEL_ID` | ID канала для проверки подписки. Используется, только если `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE=true`. | `-1001234567890` | | `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` | + | `REFERRAL_ENABLED` | Включить/выключить реферальную систему полностью (`true`/`false`). | `true` |
@@ -175,7 +177,7 @@ docker compose logs -f remnawave-tg-shop ``` - > 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут. + > 💡 Если включена проверка подписки (`REQUIRED_CHANNEL_SUBSCRIBE_TO_USE=true`), добавьте бота администратором в канал из `REQUIRED_CHANNEL_ID`. Пользователь увидит кнопку «Проверить подписку», и после успешного подтверждения доступ продолжится. ## Подробная инструкция для развертывания на сервере с панелью Remnawave diff --git a/bot/handlers/user/referral.py b/bot/handlers/user/referral.py index 7b2207d..586e137 100644 --- a/bot/handlers/user/referral.py +++ b/bot/handlers/user/referral.py @@ -43,6 +43,15 @@ async def referral_command_handler(event: Union[types.Message, _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + if not settings.REFERRAL_ENABLED: + await target_message_obj.answer( + _("referral_no_bonuses_configured"), + reply_markup=get_back_to_main_menu_markup(current_lang, i18n), + ) + if isinstance(event, types.CallbackQuery): + await event.answer() + return + try: bot_info = await bot.get_me() bot_username = bot_info.username @@ -136,6 +145,9 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if action == "share_message": + if not settings.REFERRAL_ENABLED: + await callback.answer(_("referral_no_bonuses_configured"), show_alert=True) + return try: bot_info = await bot.get_me() bot_username = bot_info.username diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index 5df4bce..fb8eae5 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -134,8 +134,15 @@ async def ensure_required_channel_subscription( Verify that the user is a member of the required channel (if configured). Returns True when access can proceed, False when user must subscribe first. """ + if not settings.REQUIRED_CHANNEL_SUBSCRIBE_TO_USE: + return True + required_channel_id = settings.REQUIRED_CHANNEL_ID if not required_channel_id: + logging.warning( + "REQUIRED_CHANNEL_SUBSCRIBE_TO_USE is enabled but REQUIRED_CHANNEL_ID is not set. " + "Channel gate is skipped." + ) return True if isinstance(event, types.CallbackQuery): @@ -327,7 +334,7 @@ async def start_command_handler(message: types.Message, promo_code_to_apply: Optional[str] = None ad_start_param: Optional[str] = None - if ref_match: + if ref_match and settings.REFERRAL_ENABLED: raw_ref_value = ref_match.group(1) if raw_ref_value.isdigit(): if settings.LEGACY_REFS: @@ -345,6 +352,11 @@ async def start_command_handler(message: types.Message, session, normalized_code) if ref_user and ref_user.user_id != user_id: referred_by_user_id = ref_user.user_id + elif ref_match and not settings.REFERRAL_ENABLED: + logging.info( + "User %s started with referral parameter while referral system is disabled.", + user_id, + ) elif promo_match: promo_code_to_apply = promo_match.group(1) logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}") @@ -663,6 +675,10 @@ async def main_action_callback_handler( promo_code_service: PromoCodeService, session: AsyncSession): action = callback.data.split(":")[1] user_id = callback.from_user.id + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs + ) if i18n else key from . import subscription as user_subscription_handlers from . import referral as user_referral_handlers @@ -685,6 +701,10 @@ async def main_action_callback_handler( callback, i18n_data, settings, panel_service, subscription_service, session, bot) elif action == "referral": + if not settings.REFERRAL_ENABLED: + await callback.answer(_("referral_no_bonuses_configured"), + show_alert=True) + return await user_referral_handlers.referral_command_handler( callback, settings, i18n_data, referral_service, bot, session) elif action == "apply_promo": @@ -711,7 +731,4 @@ async def main_action_callback_handler( session, is_edit=False) else: - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - _ = lambda key, **kwargs: i18n.gettext( - i18n_data.get("current_language"), key, **kw) if i18n else key await callback.answer(_("main_menu_unknown_action"), show_alert=True) diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 5f9020e..10e514c 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -28,13 +28,16 @@ def get_main_menu_inline_keyboard( ) ) - referral_button = InlineKeyboardButton( - text=_(key="menu_referral_inline"), - callback_data="main_action:referral") promo_button = InlineKeyboardButton( text=_(key="menu_apply_promo_button"), callback_data="main_action:apply_promo") - builder.row(referral_button, promo_button) + if settings.REFERRAL_ENABLED: + referral_button = InlineKeyboardButton( + text=_(key="menu_referral_inline"), + callback_data="main_action:referral") + builder.row(referral_button, promo_button) + else: + builder.row(promo_button) language_button = InlineKeyboardButton( text=_(key="menu_language_settings_inline"), diff --git a/bot/services/referral_service.py b/bot/services/referral_service.py index bbed41a..beaaf8b 100644 --- a/bot/services/referral_service.py +++ b/bot/services/referral_service.py @@ -32,6 +32,13 @@ class ReferralService: current_payment_db_id: Optional[int] = None, skip_if_active_before_payment: bool = True) -> Dict[str, Any]: + if not getattr(self.settings, "REFERRAL_ENABLED", True): + return { + "referee_bonus_applied_days": None, + "referee_new_end_date": None, + "inviter_bonus_applied_flag": False, + } + referee_final_end_date: Optional[datetime] = None referee_bonus_applied_days: Optional[int] = None inviter_bonus_successfully_applied = False @@ -260,6 +267,9 @@ class ReferralService: async def generate_referral_link(self, session: AsyncSession, bot_username: str, inviter_user_id: int) -> Optional[str]: + if not getattr(self.settings, "REFERRAL_ENABLED", True): + return None + try: user = await user_dal.get_user_by_id(session, inviter_user_id) if not user: diff --git a/config/settings.py b/config/settings.py index 49d0fb4..bd254b0 100644 --- a/config/settings.py +++ b/config/settings.py @@ -1,6 +1,6 @@ import logging from pydantic_settings import BaseSettings, SettingsConfigDict -from pydantic import Field, ValidationError, computed_field, field_validator +from pydantic import Field, ValidationError, computed_field, field_validator, model_validator from typing import Optional, List, Dict, Any @@ -23,6 +23,10 @@ class Settings(BaseSettings): SUPPORT_LINK: Optional[str] = Field(default=None) SERVER_STATUS_URL: Optional[str] = Field(default=None) TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None) + REQUIRED_CHANNEL_SUBSCRIBE_TO_USE: bool = Field( + default=False, + description="Require users to subscribe to REQUIRED_CHANNEL_ID before using the bot", + ) REQUIRED_CHANNEL_ID: Optional[int] = Field( default=None, description="Telegram channel ID the user must join to access the bot") @@ -168,6 +172,10 @@ class Settings(BaseSettings): default=True, description="When true, referral bonuses (for inviter and referee) are applied only once per invited user - on their first successful payment." ) + REFERRAL_ENABLED: bool = Field( + default=True, + description="Enable referral links, referral menu and referral bonuses", + ) LEGACY_REFS: bool = Field( default=True, description="Allow legacy referral links like ref_ to continue working. Defaults to True when unset." @@ -530,13 +538,24 @@ class Settings(BaseSettings): return "INFO" return v - @field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before') + @model_validator(mode='before') @classmethod - def validate_optional_int_fields(cls, v): - """Convert empty strings to None for optional integer fields""" - if isinstance(v, str) and v.strip() == '': - return None - return v + def drop_comment_placeholder_values(cls, values: Any): + """ + dotenv parses lines like `KEY= # comment` as `"# comment"`. + Treat such values as unset so defaults/optionals work as expected. + """ + if not isinstance(values, dict): + return values + + sanitized: Dict[str, Any] = {} + for key, value in values.items(): + if isinstance(value, str): + trimmed = value.strip() + if trimmed == "#" or trimmed.startswith("# "): + continue + sanitized[key] = value + return sanitized @field_validator( 'REQUIRED_CHANNEL_LINK', @@ -552,7 +571,16 @@ class Settings(BaseSettings): return None return v - @field_validator('USER_HWID_DEVICE_LIMIT', 'SEVERPAY_MID', 'SEVERPAY_LIFETIME_MINUTES', mode='before') + @field_validator( + 'REQUIRED_CHANNEL_ID', + 'FREEKASSA_PAYMENT_METHOD_ID', + 'USER_HWID_DEVICE_LIMIT', + 'SEVERPAY_MID', + 'SEVERPAY_LIFETIME_MINUTES', + 'LOG_CHAT_ID', + 'LOG_THREAD_ID', + mode='before' + ) @classmethod def validate_optional_int(cls, v): if isinstance(v, str):