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:
+19
-17
@@ -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_<tg_id> 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
|
||||
|
||||
@@ -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` |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -175,7 +177,7 @@
|
||||
docker compose logs -f remnawave-tg-shop
|
||||
```
|
||||
|
||||
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
|
||||
> 💡 Если включена проверка подписки (`REQUIRED_CHANNEL_SUBSCRIBE_TO_USE=true`), добавьте бота администратором в канал из `REQUIRED_CHANNEL_ID`. Пользователь увидит кнопку «Проверить подписку», и после успешного подтверждения доступ продолжится.
|
||||
|
||||
## Подробная инструкция для развертывания на сервере с панелью Remnawave
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
+36
-8
@@ -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_<telegram_id> 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):
|
||||
|
||||
Reference in New Issue
Block a user