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
This commit is contained in:
kavore
2026-02-06 23:49:46 +03:00
parent 31e59f6339
commit 0d637340f5
7 changed files with 109 additions and 35 deletions
+19 -17
View File
@@ -17,14 +17,15 @@ DEFAULT_CURRENCY_SYMBOL="RUB" #
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat 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 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 TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app SUBSCRIPTION_MINI_APP_URL="" # URL of the subscription mini-app
START_COMMAND_DESCRIPTION= # Description of the /start command START_COMMAND_DESCRIPTION="" # Description of the /start command
DISABLE_WELCOME_MESSAGE= # Disable the welcome message DISABLE_WELCOME_MESSAGE=False # Disable the welcome message
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu 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) USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited)
# Required channel subscription # 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 REQUIRED_CHANNEL_LINK=https://t.me/your_channel # Optional: public link/invite button text opens
# Webhook Base URL (used for Telegram and payment providers) # Webhook Base URL (used for Telegram and payment providers)
@@ -71,21 +72,21 @@ CRYPTOPAY_ASSET=RUB #
# Platega Payment Gateway Configuration # Platega Payment Gateway Configuration
PLATEGA_BASE_URL=https://app.platega.io # Base API URL PLATEGA_BASE_URL=https://app.platega.io # Base API URL
PLATEGA_MERCHANT_ID= # Your MerchantId from Platega PLATEGA_MERCHANT_ID="" # Your MerchantId from Platega
PLATEGA_SECRET= # API secret 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_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_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_FAILED_URL="" # Optional: redirect after failed/cancelled payment (defaults to return URL)
# SeverPay Payment Gateway Configuration # SeverPay Payment Gateway Configuration
SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL
SEVERPAY_MID= # Your MID from SeverPay SEVERPAY_MID="" # Your MID from SeverPay
SEVERPAY_TOKEN= # API token/secret for signing requests SEVERPAY_TOKEN="" # API token/secret for signing requests
SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link) 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_LIFETIME_MINUTES="" # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
# Tribute Payment Gateway Configuration # 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_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation 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 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 REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
# Referral Bonus Days # Referral Bonus Days
@@ -138,7 +140,7 @@ REFEREE_BONUS_DAYS_12_MONTHS=15
# Panel API Configuration # Panel API Configuration
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API 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_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) # User traffic limits (applied for all users)
# 0 means unlimited # 0 means unlimited
@@ -148,7 +150,7 @@ USER_TRAFFIC_STRATEGY="NO_RESET" #
# Default Internal Squads for Users (Optional, comma-separated UUIDs) # Default Internal Squads for Users (Optional, comma-separated UUIDs)
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3 USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
# Default External Squad for Users (Optional, single UUID) # 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 Settings
TRIAL_ENABLED=True # Enable the trial period TRIAL_ENABLED=True # Enable the trial period
@@ -158,7 +160,7 @@ TRIAL_TRAFFIC_STRATEGY="NO_RESET" #
# Connection link handling (happ crypt4) # Connection link handling (happ crypt4)
CRYPT4_ENABLED=False # Enable happ crypt4 encryption for subscription URLs 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 Settings (for handling webhooks)
WEB_SERVER_HOST="0.0.0.0" WEB_SERVER_HOST="0.0.0.0"
@@ -170,7 +172,7 @@ LOG_LEVEL=INFO #
# Admin Logging Configuration # Admin Logging Configuration
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications 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_NEW_USERS=True # Log new user registrations
LOG_PAYMENTS=True # Log payments LOG_PAYMENTS=True # Log payments
LOG_PROMO_ACTIVATIONS=True # Log promo code activations LOG_PROMO_ACTIVATIONS=True # Log promo code activations
+4 -2
View File
@@ -68,8 +68,10 @@
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` | | `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` | | `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` | | `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` | | `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
| `REFERRAL_ENABLED` | Включить/выключить реферальную систему полностью (`true`/`false`). | `true` |
</details> </details>
<details> <details>
@@ -175,7 +177,7 @@
docker compose logs -f remnawave-tg-shop docker compose logs -f remnawave-tg-shop
``` ```
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут. > 💡 Если включена проверка подписки (`REQUIRED_CHANNEL_SUBSCRIBE_TO_USE=true`), добавьте бота администратором в канал из `REQUIRED_CHANNEL_ID`. Пользователь увидит кнопку «Проверить подписку», и после успешного подтверждения доступ продолжится.
## Подробная инструкция для развертывания на сервере с панелью Remnawave ## Подробная инструкция для развертывания на сервере с панелью Remnawave
+12
View File
@@ -43,6 +43,15 @@ async def referral_command_handler(event: Union[types.Message,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = 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: try:
bot_info = await bot.get_me() bot_info = await bot.get_me()
bot_username = bot_info.username 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) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if action == "share_message": if action == "share_message":
if not settings.REFERRAL_ENABLED:
await callback.answer(_("referral_no_bonuses_configured"), show_alert=True)
return
try: try:
bot_info = await bot.get_me() bot_info = await bot.get_me()
bot_username = bot_info.username bot_username = bot_info.username
+21 -4
View File
@@ -134,8 +134,15 @@ async def ensure_required_channel_subscription(
Verify that the user is a member of the required channel (if configured). 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. 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 required_channel_id = settings.REQUIRED_CHANNEL_ID
if not 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 return True
if isinstance(event, types.CallbackQuery): if isinstance(event, types.CallbackQuery):
@@ -327,7 +334,7 @@ async def start_command_handler(message: types.Message,
promo_code_to_apply: Optional[str] = None promo_code_to_apply: Optional[str] = None
ad_start_param: 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) raw_ref_value = ref_match.group(1)
if raw_ref_value.isdigit(): if raw_ref_value.isdigit():
if settings.LEGACY_REFS: if settings.LEGACY_REFS:
@@ -345,6 +352,11 @@ async def start_command_handler(message: types.Message,
session, normalized_code) session, normalized_code)
if ref_user and ref_user.user_id != user_id: if ref_user and ref_user.user_id != user_id:
referred_by_user_id = ref_user.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: elif promo_match:
promo_code_to_apply = promo_match.group(1) promo_code_to_apply = promo_match.group(1)
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}") 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): promo_code_service: PromoCodeService, session: AsyncSession):
action = callback.data.split(":")[1] action = callback.data.split(":")[1]
user_id = callback.from_user.id 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 subscription as user_subscription_handlers
from . import referral as user_referral_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, callback, i18n_data, settings, panel_service, subscription_service,
session, bot) session, bot)
elif action == "referral": 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( await user_referral_handlers.referral_command_handler(
callback, settings, i18n_data, referral_service, bot, session) callback, settings, i18n_data, referral_service, bot, session)
elif action == "apply_promo": elif action == "apply_promo":
@@ -711,7 +731,4 @@ async def main_action_callback_handler(
session, session,
is_edit=False) is_edit=False)
else: 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) await callback.answer(_("main_menu_unknown_action"), show_alert=True)
+6 -3
View File
@@ -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( promo_button = InlineKeyboardButton(
text=_(key="menu_apply_promo_button"), text=_(key="menu_apply_promo_button"),
callback_data="main_action:apply_promo") callback_data="main_action:apply_promo")
if settings.REFERRAL_ENABLED:
referral_button = InlineKeyboardButton(
text=_(key="menu_referral_inline"),
callback_data="main_action:referral")
builder.row(referral_button, promo_button) builder.row(referral_button, promo_button)
else:
builder.row(promo_button)
language_button = InlineKeyboardButton( language_button = InlineKeyboardButton(
text=_(key="menu_language_settings_inline"), text=_(key="menu_language_settings_inline"),
+10
View File
@@ -32,6 +32,13 @@ class ReferralService:
current_payment_db_id: Optional[int] = None, current_payment_db_id: Optional[int] = None,
skip_if_active_before_payment: bool = True) -> Dict[str, Any]: 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_final_end_date: Optional[datetime] = None
referee_bonus_applied_days: Optional[int] = None referee_bonus_applied_days: Optional[int] = None
inviter_bonus_successfully_applied = False inviter_bonus_successfully_applied = False
@@ -260,6 +267,9 @@ class ReferralService:
async def generate_referral_link(self, session: AsyncSession, async def generate_referral_link(self, session: AsyncSession,
bot_username: str, bot_username: str,
inviter_user_id: int) -> Optional[str]: inviter_user_id: int) -> Optional[str]:
if not getattr(self.settings, "REFERRAL_ENABLED", True):
return None
try: try:
user = await user_dal.get_user_by_id(session, inviter_user_id) user = await user_dal.get_user_by_id(session, inviter_user_id)
if not user: if not user:
+36 -8
View File
@@ -1,6 +1,6 @@
import logging import logging
from pydantic_settings import BaseSettings, SettingsConfigDict 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 from typing import Optional, List, Dict, Any
@@ -23,6 +23,10 @@ class Settings(BaseSettings):
SUPPORT_LINK: Optional[str] = Field(default=None) SUPPORT_LINK: Optional[str] = Field(default=None)
SERVER_STATUS_URL: Optional[str] = Field(default=None) SERVER_STATUS_URL: Optional[str] = Field(default=None)
TERMS_OF_SERVICE_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( REQUIRED_CHANNEL_ID: Optional[int] = Field(
default=None, default=None,
description="Telegram channel ID the user must join to access the bot") description="Telegram channel ID the user must join to access the bot")
@@ -168,6 +172,10 @@ class Settings(BaseSettings):
default=True, default=True,
description="When true, referral bonuses (for inviter and referee) are applied only once per invited user - on their first successful payment." 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( LEGACY_REFS: bool = Field(
default=True, default=True,
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset." description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset."
@@ -530,13 +538,24 @@ class Settings(BaseSettings):
return "INFO" return "INFO"
return v return v
@field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before') @model_validator(mode='before')
@classmethod @classmethod
def validate_optional_int_fields(cls, v): def drop_comment_placeholder_values(cls, values: Any):
"""Convert empty strings to None for optional integer fields""" """
if isinstance(v, str) and v.strip() == '': dotenv parses lines like `KEY= # comment` as `"# comment"`.
return None Treat such values as unset so defaults/optionals work as expected.
return v """
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( @field_validator(
'REQUIRED_CHANNEL_LINK', 'REQUIRED_CHANNEL_LINK',
@@ -552,7 +571,16 @@ class Settings(BaseSettings):
return None return None
return v 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 @classmethod
def validate_optional_int(cls, v): def validate_optional_int(cls, v):
if isinstance(v, str): if isinstance(v, str):