Compare commits

..
12 Commits
Author SHA1 Message Date
Machka PaslaandGitHub 4d8cf45bda Merge pull request #31 from machka-pasla/codex/remove-auto-renew-for-attribute
Disable tribute auto-renew and add admin alerts
2025-07-06 13:31:14 +07:00
Machka Pasla df987fc52a Remove tribute auto renew and add admin notifications 2025-07-06 13:31:04 +07:00
Machka PaslaandGitHub 07cb68457f Merge pull request #30 from machka-pasla/codex/уточнить-работу-автопродления-с-tribute
Implement auto-renewal for Tribute
2025-07-05 00:05:51 +07:00
Machka Pasla 1e666f90d3 Improve Tribute auto-renew 2025-07-05 00:05:33 +07:00
Machka PaslaandGitHub 6241f766a2 Merge pull request #29 from machka-pasla/codex/add-description-support-for-/start-command
Fix mini app registration
2025-07-04 22:45:19 +07:00
Machka Pasla 451581f792 Fix mini app registration 2025-07-04 22:43:04 +07:00
Machka PaslaandGitHub a015946b20 Merge pull request #28 from machka-pasla/codex/add-description-support-for-/start-command
Enable mini app via API without menu change
2025-07-04 22:35:48 +07:00
Machka Pasla d1d701bff3 feat: allow /start description and keep menu default 2025-07-04 22:35:13 +07:00
Machka PaslaandGitHub ea50567a42 Merge pull request #26 from machka-pasla/codex/добавить-поддержку-mini-app-для-подписки
Support subscription mini app
2025-07-04 20:45:59 +07:00
Machka Pasla a8b62ccc03 Add subscription mini app support 2025-07-04 20:45:27 +07:00
Machka PaslaandGitHub 0e03d72dd3 Merge pull request #25 from machka-pasla/codex/refactor-.env-variables-for-consistency
Fix panel user sync and clarify traffic settings
2025-06-29 00:53:44 +03:00
Machka Pasla aa5eb74d75 Rename user traffic env vars 2025-06-29 00:52:27 +03:00
14 changed files with 218 additions and 31 deletions
+8 -6
View File
@@ -16,6 +16,8 @@ DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
SUPPORT_LINK=https://t.me/your_support_link SUPPORT_LINK=https://t.me/your_support_link
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service
TERMS_OF_SERVICE_URL=https://example.com/tos TERMS_OF_SERVICE_URL=https://example.com/tos
SUBSCRIPTION_MINI_APP_URL=
START_COMMAND_DESCRIPTION=
# YooKassa Payment Gateway Configuration # YooKassa Payment Gateway Configuration
YOOKASSA_SHOP_ID=your_shop_id YOOKASSA_SHOP_ID=your_shop_id
@@ -78,13 +80,13 @@ REFEREE_BONUS_DAYS_12_MONTHS=15
PANEL_API_URL=http://your_panel_api_url/api PANEL_API_URL=http://your_panel_api_url/api
PANEL_API_KEY=your_panel_api_key PANEL_API_KEY=your_panel_api_key
# Default settings for NEW panel users # User traffic limits (applied for all users)
PANEL_USER_DEFAULT_EXPIRE_DAYS=1 # 0 means unlimited
PANEL_USER_DEFAULT_TRAFFIC_BYTES=0 USER_TRAFFIC_LIMIT_GB=0
PANEL_USER_DEFAULT_TRAFFIC_STRATEGY="NO_RESET" USER_TRAFFIC_STRATEGY="NO_RESET"
# Default Inbounds for Panel Users (Optional, comma-separated UUIDs) # Default Inbounds for Users (Optional, comma-separated UUIDs)
PANEL_USER_DEFAULT_INBOUND_UUIDS=uuid1,uuid2,uuid3 USER_INBOUND_UUIDS=uuid1,uuid2,uuid3
# Trial Settings # Trial Settings
TRIAL_ENABLED=True TRIAL_ENABLED=True
+4 -1
View File
@@ -83,6 +83,8 @@ This Telegram bot is designed to automate the sale and management of subscriptio
* `DEFAULT_CURRENCY_SYMBOL`: e.g., `RUB`, `USD`. * `DEFAULT_CURRENCY_SYMBOL`: e.g., `RUB`, `USD`.
* `SUPPORT_LINK`: (Optional) URL for a support chat/contact (e.g., `https://t.me/your_support`). * `SUPPORT_LINK`: (Optional) URL for a support chat/contact (e.g., `https://t.me/your_support`).
* `SERVER_STATUS_URL`: (Optional) URL to a server status page (e.g., Uptime Kuma). * `SERVER_STATUS_URL`: (Optional) URL to a server status page (e.g., Uptime Kuma).
* `SUBSCRIPTION_MINI_APP_URL`: (Optional) URL of the Telegram mini app for viewing subscription details. If set, the "My Subscription" button will open this mini app and the bot will register it automatically via API.
* `START_COMMAND_DESCRIPTION`: (Optional) Description for the `/start` command shown in the bot's menu.
* **YooKassa Settings:** * **YooKassa Settings:**
* `YOOKASSA_SHOP_ID`: Your shop ID from YooKassa. * `YOOKASSA_SHOP_ID`: Your shop ID from YooKassa.
* `YOOKASSA_SECRET_KEY`: Your secret key from YooKassa. * `YOOKASSA_SECRET_KEY`: Your secret key from YooKassa.
@@ -100,7 +102,8 @@ This Telegram bot is designed to automate the sale and management of subscriptio
* **Panel API Settings:** * **Panel API Settings:**
* `PANEL_API_URL`: Full URL to your Remnawave panel's API (e.g., `http://remnawave:3000/api` or `https://panel.yourdomain.com/api`). * `PANEL_API_URL`: Full URL to your Remnawave panel's API (e.g., `http://remnawave:3000/api` or `https://panel.yourdomain.com/api`).
* `PANEL_API_KEY`: API Key for authenticating with the Remnawave panel. * `PANEL_API_KEY`: API Key for authenticating with the Remnawave panel.
* `PANEL_USER_DEFAULT_INBOUND_UUIDS`: (Optional) Comma-separated list of inbound UUIDs from your panel to assign to users. If empty, `activateAllInbounds: true` (panel default) is used for new users. * `USER_INBOUND_UUIDS`: (Optional) Comma-separated list of inbound UUIDs from your panel to assign to users. If empty, `activateAllInbounds: true` (panel default) is used for new users.
* `USER_TRAFFIC_LIMIT_GB` and `USER_TRAFFIC_STRATEGY`: Default traffic limit in gigabytes (0 for unlimited) and the reset strategy applied when updating users on the panel.
* `TRIAL_ENABLED`, `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`: Settings for the trial period. * `TRIAL_ENABLED`, `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`: Settings for the trial period.
* `WEB_SERVER_HOST`, `WEB_SERVER_PORT`: Host and port for the bot's internal webhook server. * `WEB_SERVER_HOST`, `WEB_SERVER_PORT`: Host and port for the bot's internal webhook server.
* `LOGS_PAGE_SIZE`: For admin panel log pagination. * `LOGS_PAGE_SIZE`: For admin panel log pagination.
+10
View File
@@ -20,6 +20,7 @@ from bot.services.panel_api_service import PanelApiService
from bot.services.yookassa_service import YooKassaService from bot.services.yookassa_service import YooKassaService
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from config.settings import Settings from config.settings import Settings
from bot.services.notification_service import notify_admin_new_payment
payment_processing_lock = asyncio.Lock() payment_processing_lock = asyncio.Lock()
@@ -175,6 +176,15 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
f"Failed to send final payment success message to user {user_id}: {e_notify}" f"Failed to send final payment success message to user {user_id}: {e_notify}"
) )
await notify_admin_new_payment(
bot,
settings,
i18n,
user_id,
subscription_months,
payment_value,
)
except Exception as e_process: except Exception as e_process:
logging.error( logging.error(
f"Error during process_successful_payment main try block for user {user_id}: {e_process}", f"Error during process_successful_payment main try block for user {user_id}: {e_process}",
+10
View File
@@ -7,6 +7,7 @@ from datetime import datetime
from config.settings import Settings from config.settings import Settings
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.services.notification_service import notify_admin_new_trial
from bot.keyboards.inline.user_keyboards import ( from bot.keyboards.inline.user_keyboards import (
get_trial_confirmation_keyboard, get_trial_confirmation_keyboard,
get_main_menu_inline_keyboard, get_main_menu_inline_keyboard,
@@ -189,6 +190,15 @@ async def confirm_activate_trial_handler(
disable_web_page_preview=True, disable_web_page_preview=True,
) )
if activation_result and activation_result.get("activated") and end_date_obj:
await notify_admin_new_trial(
callback.bot,
settings,
i18n,
user_id,
end_date_obj,
)
@router.callback_query(F.data == "main_action:cancel_trial") @router.callback_query(F.data == "main_action:cancel_trial")
async def cancel_trial_activation( async def cancel_trial_activation(
+15 -4
View File
@@ -1,5 +1,5 @@
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from aiogram.types import InlineKeyboardMarkup from aiogram.types import InlineKeyboardMarkup, WebAppInfo
from typing import Dict, Optional, List from typing import Dict, Optional, List
from config.settings import Settings from config.settings import Settings
@@ -21,9 +21,20 @@ def get_main_menu_inline_keyboard(
builder.row( builder.row(
InlineKeyboardButton(text=_(key="menu_subscribe_inline"), InlineKeyboardButton(text=_(key="menu_subscribe_inline"),
callback_data="main_action:subscribe")) callback_data="main_action:subscribe"))
builder.row( if settings.SUBSCRIPTION_MINI_APP_URL:
InlineKeyboardButton(text=_(key="menu_my_subscription_inline"), builder.row(
callback_data="main_action:my_subscription")) InlineKeyboardButton(
text=_(key="menu_my_subscription_inline"),
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
)
else:
builder.row(
InlineKeyboardButton(
text=_(key="menu_my_subscription_inline"),
callback_data="main_action:my_subscription",
)
)
referral_button = InlineKeyboardButton( referral_button = InlineKeyboardButton(
text=_(key="menu_referral_inline"), text=_(key="menu_referral_inline"),
+37 -1
View File
@@ -3,7 +3,13 @@ import asyncio
from typing import Callable, Dict, Any, Awaitable, Optional from typing import Callable, Dict, Any, Awaitable, Optional
from aiogram import Bot, Dispatcher, BaseMiddleware, Router, F from aiogram import Bot, Dispatcher, BaseMiddleware, Router, F
from aiogram.types import Update from aiogram.types import (
Update,
MenuButtonDefault,
MenuButtonWebApp,
WebAppInfo,
BotCommand,
)
from aiogram.enums import ParseMode from aiogram.enums import ParseMode
from aiogram.filters import CommandStart, Command from aiogram.filters import CommandStart, Command
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
@@ -171,6 +177,36 @@ async def on_startup_configured(dispatcher: Dispatcher):
) )
await bot.delete_webhook(drop_pending_updates=True) await bot.delete_webhook(drop_pending_updates=True)
if settings.SUBSCRIPTION_MINI_APP_URL:
try:
menu_text = i18n_instance.gettext(
settings.DEFAULT_LANGUAGE,
"menu_my_subscription_inline",
)
await bot.set_chat_menu_button(
menu_button=MenuButtonWebApp(
text=menu_text,
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
)
await bot.set_chat_menu_button(menu_button=MenuButtonDefault())
logging.info(
"STARTUP: Mini app domain registered and default menu button restored."
)
except Exception as e:
logging.error(
f"STARTUP: Failed to register mini app domain: {e}", exc_info=True
)
if settings.START_COMMAND_DESCRIPTION:
try:
await bot.set_my_commands([
BotCommand(command="start", description=settings.START_COMMAND_DESCRIPTION)
])
logging.info("STARTUP: /start command description set.")
except Exception as e:
logging.error(f"STARTUP: Failed to set bot commands: {e}", exc_info=True)
logging.info("STARTUP: Bot on_startup_configured completed.") logging.info("STARTUP: Bot on_startup_configured completed.")
+43
View File
@@ -133,3 +133,46 @@ async def schedule_subscription_notifications(
logging.info( logging.info(
f"Subscription expiration warning job scheduled daily at {notification_hour:02d}:{notification_minute:02d} UTC." f"Subscription expiration warning job scheduled daily at {notification_hour:02d}:{notification_minute:02d} UTC."
) )
async def notify_admins(bot: Bot, settings: Settings, i18n: JsonI18n,
message_key: str, parse_mode: str | None = None,
**kwargs) -> None:
if not settings.ADMIN_IDS:
return
admin_lang = settings.DEFAULT_LANGUAGE
msg = i18n.gettext(admin_lang, message_key, **kwargs)
for admin_id in settings.ADMIN_IDS:
try:
await bot.send_message(admin_id, msg, parse_mode=parse_mode)
except Exception as e:
logging.error(f"Failed to send admin notification to {admin_id}: {e}")
async def notify_admin_new_trial(bot: Bot, settings: Settings, i18n: JsonI18n,
user_id: int, end_date: datetime) -> None:
end_date_str = end_date.strftime('%Y-%m-%d') if isinstance(end_date, datetime) else str(end_date)
await notify_admins(
bot,
settings,
i18n,
"admin_new_trial_notification",
user_id=user_id,
end_date=end_date_str,
)
async def notify_admin_new_payment(bot: Bot, settings: Settings, i18n: JsonI18n,
user_id: int, months: int, amount: float,
currency: str | None = None) -> None:
currency_symbol = currency or settings.DEFAULT_CURRENCY_SYMBOL
await notify_admins(
bot,
settings,
i18n,
"admin_new_payment_notification",
user_id=user_id,
months=months,
amount=f"{amount:.2f}",
currency=currency_symbol,
)
+1 -2
View File
@@ -137,8 +137,7 @@ class ReferralService:
"status_from_panel": "status_from_panel":
"ACTIVE_BONUS", "ACTIVE_BONUS",
"traffic_limit_bytes": "traffic_limit_bytes":
self.settings. self.settings.user_traffic_limit_bytes,
PANEL_USER_DEFAULT_TRAFFIC_BYTES,
} }
try: try:
await subscription_dal.deactivate_other_active_subscriptions( await subscription_dal.deactivate_other_active_subscriptions(
+32 -10
View File
@@ -99,6 +99,26 @@ class SubscriptionService:
logging.warning( logging.warning(
f"Local panel_uuid {current_local_panel_uuid} for TG user {user_id} also not found on panel. User might be deleted from panel or UUID desynced." f"Local panel_uuid {current_local_panel_uuid} for TG user {user_id} also not found on panel. User might be deleted from panel or UUID desynced."
) )
logging.info(
f"Creating new panel user '{panel_username_on_panel_standard}' for TG user {user_id}."
)
creation_response = await self.panel_service.create_panel_user(
username_on_panel=panel_username_on_panel_standard,
telegram_id=user_id,
specific_inbound_uuids=self.settings.parsed_user_inbound_uuids,
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
)
if (
creation_response
and not creation_response.get("error")
and creation_response.get("response")
):
panel_user_obj_from_api = creation_response.get("response")
panel_user_created_or_linked_now = True
else:
await self._notify_admin_panel_user_creation_failed(user_id)
return None, None, None, False
else: else:
@@ -108,7 +128,9 @@ class SubscriptionService:
creation_response = await self.panel_service.create_panel_user( creation_response = await self.panel_service.create_panel_user(
username_on_panel=panel_username_on_panel_standard, username_on_panel=panel_username_on_panel_standard,
telegram_id=user_id, telegram_id=user_id,
specific_inbound_uuids=self.settings.parsed_default_panel_user_inbound_uuids, specific_inbound_uuids=self.settings.parsed_user_inbound_uuids,
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
) )
if ( if (
creation_response creation_response
@@ -333,11 +355,11 @@ class SubscriptionService:
), ),
"status": "ACTIVE", "status": "ACTIVE",
"trafficLimitBytes": self.settings.trial_traffic_limit_bytes, "trafficLimitBytes": self.settings.trial_traffic_limit_bytes,
"trafficLimitStrategy": self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY, "trafficLimitStrategy": self.settings.USER_TRAFFIC_STRATEGY,
} }
if self.settings.parsed_default_panel_user_inbound_uuids: if self.settings.parsed_user_inbound_uuids:
panel_update_payload["activeUserInbounds"] = ( panel_update_payload["activeUserInbounds"] = (
self.settings.parsed_default_panel_user_inbound_uuids self.settings.parsed_user_inbound_uuids
) )
elif panel_user_created_now: elif panel_user_created_now:
panel_update_payload["activateAllInbounds"] = True panel_update_payload["activateAllInbounds"] = True
@@ -460,7 +482,7 @@ class SubscriptionService:
"duration_months": months, "duration_months": months,
"is_active": True, "is_active": True,
"status_from_panel": "ACTIVE", "status_from_panel": "ACTIVE",
"traffic_limit_bytes": self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES, "traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
"provider": provider, "provider": provider,
"skip_notifications": provider == "tribute", "skip_notifications": provider == "tribute",
} }
@@ -481,12 +503,12 @@ class SubscriptionService:
"+00:00", "Z" "+00:00", "Z"
), ),
"status": "ACTIVE", "status": "ACTIVE",
"trafficLimitBytes": self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES, "trafficLimitBytes": self.settings.user_traffic_limit_bytes,
"trafficLimitStrategy": self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY, "trafficLimitStrategy": self.settings.USER_TRAFFIC_STRATEGY,
} }
if self.settings.parsed_default_panel_user_inbound_uuids: if self.settings.parsed_user_inbound_uuids:
panel_update_payload["activeUserInbounds"] = ( panel_update_payload["activeUserInbounds"] = (
self.settings.parsed_default_panel_user_inbound_uuids self.settings.parsed_user_inbound_uuids
) )
elif panel_user_created_now: elif panel_user_created_now:
panel_update_payload["activateAllInbounds"] = True panel_update_payload["activateAllInbounds"] = True
@@ -554,7 +576,7 @@ class SubscriptionService:
"duration_months": 0, "duration_months": 0,
"is_active": True, "is_active": True,
"status_from_panel": "ACTIVE_BONUS", "status_from_panel": "ACTIVE_BONUS",
"traffic_limit_bytes": self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES, "traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
} }
await subscription_dal.deactivate_other_active_subscriptions( await subscription_dal.deactivate_other_active_subscriptions(
session, panel_uuid, panel_sub_uuid session, panel_uuid, panel_sub_uuid
+17 -7
View File
@@ -86,10 +86,9 @@ class Settings(BaseSettings):
PANEL_API_URL: Optional[str] = None PANEL_API_URL: Optional[str] = None
PANEL_API_KEY: Optional[str] = None PANEL_API_KEY: Optional[str] = None
PANEL_USER_DEFAULT_EXPIRE_DAYS: int = Field(default=1) USER_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=0.0)
PANEL_USER_DEFAULT_TRAFFIC_BYTES: int = Field(default=0) USER_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
PANEL_USER_DEFAULT_TRAFFIC_STRATEGY: str = Field(default="NO_RESET") USER_INBOUND_UUIDS: Optional[str] = Field(
PANEL_USER_DEFAULT_INBOUND_UUIDS: Optional[str] = Field(
default=None, default=None,
description= description=
"Comma-separated UUIDs of inbounds to activate for new panel users") "Comma-separated UUIDs of inbounds to activate for new panel users")
@@ -102,6 +101,10 @@ class Settings(BaseSettings):
WEB_SERVER_PORT: int = Field(default=8080) WEB_SERVER_PORT: int = Field(default=8080)
LOGS_PAGE_SIZE: int = Field(default=10) LOGS_PAGE_SIZE: int = Field(default=10)
SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None)
START_COMMAND_DESCRIPTION: Optional[str] = Field(default=None)
@computed_field @computed_field
@property @property
def DATABASE_URL(self) -> str: def DATABASE_URL(self) -> str:
@@ -139,11 +142,18 @@ class Settings(BaseSettings):
@computed_field @computed_field
@property @property
def parsed_default_panel_user_inbound_uuids(self) -> Optional[List[str]]: def user_traffic_limit_bytes(self) -> int:
if self.PANEL_USER_DEFAULT_INBOUND_UUIDS: if self.USER_TRAFFIC_LIMIT_GB is None or self.USER_TRAFFIC_LIMIT_GB <= 0:
return 0
return int(self.USER_TRAFFIC_LIMIT_GB * (1024**3))
@computed_field
@property
def parsed_user_inbound_uuids(self) -> Optional[List[str]]:
if self.USER_INBOUND_UUIDS:
return [ return [
uuid.strip() uuid.strip()
for uuid in self.PANEL_USER_DEFAULT_INBOUND_UUIDS.split(',') for uuid in self.USER_INBOUND_UUIDS.split(',')
if uuid.strip() if uuid.strip()
] ]
return None return None
+13
View File
@@ -96,6 +96,19 @@ async def update_payment_status_by_db_id(
return payment return payment
async def user_has_successful_payment_for_provider(
session: AsyncSession, user_id: int, provider: str) -> bool:
"""Check if a user has at least one successful payment for the provider."""
stmt = (select(Payment.payment_id)
.where(Payment.user_id == user_id,
Payment.provider == provider,
Payment.status == 'succeeded')
.limit(1))
result = await session.execute(stmt)
return result.scalar_one_or_none() is not None
async def update_payment_status_by_yk_id(session: AsyncSession, async def update_payment_status_by_yk_id(session: AsyncSession,
yookassa_payment_id: str, yookassa_payment_id: str,
new_status: str) -> Optional[Payment]: new_status: str) -> Optional[Payment]:
+22
View File
@@ -231,3 +231,25 @@ async def set_skip_notifications_for_provider(
Subscription.provider == provider).values(skip_notifications=skip)) Subscription.provider == provider).values(skip_notifications=skip))
result = await session.execute(stmt) result = await session.execute(stmt)
return result.rowcount return result.rowcount
async def get_active_subscriptions_for_autorenew(
session: AsyncSession, provider: str,
days_threshold: int = 1,
require_skip_flag: bool = True) -> List[Subscription]:
"""Fetch active subscriptions nearing expiration for auto-renew logic."""
now_utc = datetime.now(timezone.utc)
threshold_date = now_utc + timedelta(days=days_threshold)
conditions = [
Subscription.provider == provider,
Subscription.is_active == True,
Subscription.end_date <= threshold_date,
]
if require_skip_flag:
conditions.append(Subscription.skip_notifications == True)
stmt = select(Subscription).where(*conditions)
result = await session.execute(stmt)
return result.scalars().all()
+3
View File
@@ -214,5 +214,8 @@
"subscription_ending_soon_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription ends on {end_date} (in {days_left} days).\n\nTo avoid interruption, please renew it in the main menu.", "subscription_ending_soon_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription ends on {end_date} (in {days_left} days).\n\nTo avoid interruption, please renew it in the main menu.",
"subscription_cancelled_notification": "Your recurring subscription was cancelled. You will keep access until the paid period ends.", "subscription_cancelled_notification": "Your recurring subscription was cancelled. You will keep access until the paid period ends.",
"admin_new_trial_notification": "\ud83c\udf21 User {user_id} activated a free trial until {end_date}.",
"admin_new_payment_notification": "\ud83d\udcb3 Payment received from user {user_id}: {months} mo. for {amount} {currency}.",
"error_unknown": "An unknown error occurred." "error_unknown": "An unknown error occurred."
} }
+3
View File
@@ -214,5 +214,8 @@
"subscription_ending_soon_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает {end_date} (через {days_left} дн.).\n\nЧтобы не потерять доступ, пожалуйста, продлите ее заранее в главном меню бота.", "subscription_ending_soon_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает {end_date} (через {days_left} дн.).\n\nЧтобы не потерять доступ, пожалуйста, продлите ее заранее в главном меню бота.",
"subscription_cancelled_notification": "Ваша подписка отменена. Доступ сохранится до конца оплаченного периода.", "subscription_cancelled_notification": "Ваша подписка отменена. Доступ сохранится до конца оплаченного периода.",
"admin_new_trial_notification": "\ud83c\udf21 Пользователь {user_id} активировал пробный период до {end_date}.",
"admin_new_payment_notification": "\ud83d\udcb3 Получен платеж от пользователя {user_id}: {months} мес. за {amount} {currency}.",
"error_unknown": "Произошла неизвестная ошибка." "error_unknown": "Произошла неизвестная ошибка."
} }