From c3622d9c2b000e895de08b394b8f469712de044b Mon Sep 17 00:00:00 2001 From: machka pasla Date: Fri, 17 Oct 2025 09:36:25 +0300 Subject: [PATCH] channel require and db update --- .env.example | 4 + README.md | 4 + bot/app/controllers/dispatcher_controller.py | 3 +- bot/handlers/user/start.py | 243 ++++++++++++++++++- bot/keyboards/inline/user_keyboards.py | 37 +++ bot/middlewares/channel_subscription.py | 138 +++++++++++ config/settings.py | 13 + db/database_setup.py | 5 +- db/migrator.py | 138 +++++++---- db/models.py | 4 + locales/en.json | 5 + locales/ru.json | 5 + 12 files changed, 541 insertions(+), 58 deletions(-) create mode 100644 bot/middlewares/channel_subscription.py diff --git a/.env.example b/.env.example index 687ea9d..9fb5389 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,10 @@ SUBSCRIPTION_MINI_APP_URL= # START_COMMAND_DESCRIPTION= # Description of the /start command DISABLE_WELCOME_MESSAGE= # Disable the welcome message +# Required channel subscription +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) WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld diff --git a/README.md b/README.md index 9785f83..a29c948 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ | `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` | | `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` | | `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` | + | `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` | + | `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
@@ -150,6 +152,8 @@ docker compose logs -f remnawave-tg-shop ``` + > 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут. + ## 🐳 Docker Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки. diff --git a/bot/app/controllers/dispatcher_controller.py b/bot/app/controllers/dispatcher_controller.py index f9de972..1982e95 100644 --- a/bot/app/controllers/dispatcher_controller.py +++ b/bot/app/controllers/dispatcher_controller.py @@ -13,6 +13,7 @@ from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n from bot.middlewares.ban_check_middleware import BanCheckMiddleware from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware from bot.middlewares.profile_sync import ProfileSyncMiddleware +from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) -> tuple[Dispatcher, Bot, Dict]: @@ -31,8 +32,8 @@ def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) -> dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings)) dp.update.outer_middleware(ProfileSyncMiddleware()) dp.update.outer_middleware(BanCheckMiddleware(settings=settings, i18n_instance=i18n_instance)) + dp.update.outer_middleware(ChannelSubscriptionMiddleware(settings=settings, i18n_instance=i18n_instance)) dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings)) return dp, bot, {"i18n_instance": i18n_instance} - diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index e3bc368..7adb43a 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -7,10 +7,16 @@ from aiogram.fsm.context import FSMContext from typing import Optional, Union from sqlalchemy.ext.asyncio import AsyncSession from datetime import datetime, timezone +from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError from db.dal import user_dal +from db.models import User -from bot.keyboards.inline.user_keyboards import get_main_menu_inline_keyboard, get_language_selection_keyboard +from bot.keyboards.inline.user_keyboards import ( + get_main_menu_inline_keyboard, + get_language_selection_keyboard, + get_channel_subscription_keyboard, +) from bot.services.subscription_service import SubscriptionService from bot.services.panel_api_service import PanelApiService from bot.services.referral_service import ReferralService @@ -51,6 +57,7 @@ async def send_main_menu(target_event: Union[types.Message, pass return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) show_trial_button_in_menu = False @@ -116,6 +123,184 @@ async def send_main_menu(target_event: Union[types.Message, pass +async def ensure_required_channel_subscription( + event: Union[types.Message, types.CallbackQuery], + settings: Settings, + i18n: Optional[JsonI18n], + current_lang: str, + session: AsyncSession, + db_user: Optional[User] = None) -> bool: + """ + 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. + """ + required_channel_id = settings.REQUIRED_CHANNEL_ID + if not required_channel_id: + return True + + if isinstance(event, types.CallbackQuery): + user_id = event.from_user.id + bot_instance: Optional[Bot] = getattr(event, "bot", None) + if bot_instance is None and event.message: + bot_instance = event.message.bot + message_obj: Optional[types.Message] = event.message + else: + user_id = event.from_user.id + bot_instance = event.bot if hasattr(event, "bot") else None + message_obj = event + + if bot_instance is None: + logging.error( + "Channel subscription check: bot instance missing for user %s.", user_id + ) + return False + + if user_id in settings.ADMIN_IDS: + return True + + if db_user is None: + try: + db_user = await user_dal.get_user_by_id(session, user_id) + except Exception as fetch_error: + logging.error( + "Channel subscription check: failed to fetch user %s: %s", + user_id, + fetch_error, + exc_info=True, + ) + return False + + if not db_user: + logging.warning( + "Required channel check skipped because user %s is not persisted yet.", + user_id, + ) + return True + + if (db_user.channel_subscription_verified + and db_user.channel_subscription_verified_for + == required_channel_id): + return True + + def translate(key: str, **kwargs) -> str: + if i18n: + return i18n.gettext(current_lang, key, **kwargs) + return key + + now = datetime.now(timezone.utc) + is_member = False + status_value = None + + try: + member = await bot_instance.get_chat_member(required_channel_id, user_id) + status = getattr(member, "status", None) + status_value = getattr(status, "value", status) + allowed_statuses = {"creator", "administrator", "member", "restricted"} + if status_value in allowed_statuses: + is_member = True + except TelegramBadRequest as bad_request: + logging.info( + "Required channel check: user %s not subscribed (details: %s)", + user_id, + bad_request, + ) + except TelegramForbiddenError as forbidden_error: + logging.error( + "Required channel check failed due to insufficient permissions: %s", + forbidden_error, + ) + error_text = translate("channel_subscription_check_failed") + if isinstance(event, types.CallbackQuery): + try: + await event.answer(error_text, show_alert=True) + except Exception: + pass + if message_obj: + try: + await message_obj.answer(error_text) + except Exception: + pass + else: + await event.answer(error_text) + return False + except TelegramAPIError as api_error: + logging.error( + "Required channel check failed for user %s: %s", + user_id, + api_error, + exc_info=True, + ) + error_text = translate("channel_subscription_check_failed") + if isinstance(event, types.CallbackQuery): + try: + await event.answer(error_text, show_alert=True) + except Exception: + pass + if message_obj: + try: + await message_obj.answer(error_text) + except Exception: + pass + else: + await event.answer(error_text) + return False + + update_payload = { + "channel_subscription_checked_at": now, + "channel_subscription_verified_for": required_channel_id, + "channel_subscription_verified": is_member, + } + try: + await user_dal.update_user(session, user_id, update_payload) + except Exception as update_error: + logging.error( + "Failed to persist channel verification result for user %s: %s", + user_id, + update_error, + exc_info=True, + ) + + if is_member: + logging.info( + "User %s confirmed as member of required channel %s (status=%s).", + user_id, + required_channel_id, + status_value, + ) + return True + + keyboard = (get_channel_subscription_keyboard( + current_lang, i18n, settings.REQUIRED_CHANNEL_LINK + ) + if i18n else None) + + prompt_text = translate("channel_subscription_required") + + if isinstance(event, types.CallbackQuery): + if keyboard and event.message: + try: + await event.message.edit_text(prompt_text, reply_markup=keyboard) + except Exception as edit_error: + logging.debug( + "Failed to edit prompt message for user %s: %s", + user_id, + edit_error, + ) + if keyboard is None and message_obj: + try: + await message_obj.answer(prompt_text) + except Exception: + pass + try: + await event.answer(prompt_text, show_alert=True) + except Exception: + pass + else: + await event.answer(prompt_text, reply_markup=keyboard) + + return False + + @router.message(CommandStart()) @router.message(CommandStart(magic=F.args.regexp(r"^ref_(\d+)$").as_("ref_match"))) @router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match"))) @@ -243,6 +428,11 @@ async def start_command_handler(message: types.Message, except Exception: pass + if not await ensure_required_channel_subscription(message, settings, i18n, + current_lang, session, + db_user): + return + # Send welcome message if not disabled if not settings.DISABLE_WELCOME_MESSAGE: await message.answer(_(key="welcome", user_name=hd.quote(user.full_name))) @@ -300,6 +490,57 @@ async def start_command_handler(message: types.Message, is_edit=False) +@router.callback_query(F.data == "channel_subscription:verify") +async def verify_channel_subscription_callback( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + subscription_service: SubscriptionService, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + + db_user = await user_dal.get_user_by_id(session, callback.from_user.id) + + verified = await ensure_required_channel_subscription( + callback, settings, i18n, current_lang, session, db_user) + if not verified: + return + + if db_user and db_user.language_code: + current_lang = db_user.language_code + i18n_data["current_language"] = current_lang + + if i18n: + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + else: + _ = lambda key, **kwargs: key + + if not settings.DISABLE_WELCOME_MESSAGE: + welcome_text = _(key="welcome", + user_name=hd.quote(callback.from_user.full_name)) + if callback.message: + await callback.message.answer(welcome_text) + else: + fallback_bot: Optional[Bot] = getattr(callback, "bot", None) + if fallback_bot: + await fallback_bot.send_message(callback.from_user.id, + welcome_text) + + try: + await callback.answer(_(key="channel_subscription_verified_success"), + show_alert=True) + except Exception: + pass + + await send_main_menu(callback, + settings, + i18n_data, + subscription_service, + session, + is_edit=bool(callback.message)) + + @router.message(Command("language")) @router.callback_query(F.data == "main_action:language") async def language_command_handler( diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 0fbde20..61a1953 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -286,6 +286,43 @@ def get_user_banned_keyboard(support_link: Optional[str], lang: str, return builder.as_markup() +def get_channel_subscription_keyboard( + lang: str, + i18n_instance, + channel_link: Optional[str], + include_check_button: bool = True) -> Optional[InlineKeyboardMarkup]: + """ + Return keyboard with buttons to open the required channel and trigger a subscription re-check. + """ + if i18n_instance is None: + return None + + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + + has_buttons = False + + if channel_link: + builder.button( + text=_(key="channel_subscription_join_button"), + url=channel_link, + ) + has_buttons = True + + if include_check_button: + builder.button( + text=_(key="channel_subscription_verify_button"), + callback_data="channel_subscription:verify", + ) + has_buttons = True + + if not has_buttons: + return None + + builder.adjust(1) + return builder.as_markup() + + def get_connect_and_main_keyboard( lang: str, i18n_instance, diff --git a/bot/middlewares/channel_subscription.py b/bot/middlewares/channel_subscription.py new file mode 100644 index 0000000..3af434b --- /dev/null +++ b/bot/middlewares/channel_subscription.py @@ -0,0 +1,138 @@ +import logging +from typing import Any, Awaitable, Callable, Dict, Optional + +from aiogram import BaseMiddleware +from aiogram.types import ( + CallbackQuery, + Message, + Update, +) +from sqlalchemy.ext.asyncio import AsyncSession + +from config.settings import Settings +from db.dal import user_dal +from bot.middlewares.i18n import JsonI18n +from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard + + +class ChannelSubscriptionMiddleware(BaseMiddleware): + """ + Blocks access to handlers for users who have not yet passed the required channel subscription check. + The /start command is allowed through so that the handler can re-run the verification. + """ + + def __init__(self, settings: Settings, i18n_instance: JsonI18n): + super().__init__() + self.settings = settings + self.i18n_main_instance = i18n_instance + + async def __call__( + self, + handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]], + event: Update, + data: Dict[str, Any], + ) -> Any: + required_channel_id = self.settings.REQUIRED_CHANNEL_ID + if not required_channel_id: + return await handler(event, data) + + event_user = data.get("event_from_user") + if not event_user or event_user.id in self.settings.ADMIN_IDS: + return await handler(event, data) + + # Allow /start to reach the handler so the check can be re-run. + message_object: Optional[Message] = event.message + if ( + message_object + and message_object.text + and message_object.text.startswith("/start") + ): + return await handler(event, data) + + session: AsyncSession = data["session"] + try: + db_user = await user_dal.get_user_by_id(session, event_user.id) + except Exception as db_error: + logging.error( + "ChannelSubscriptionMiddleware: failed to fetch user %s: %s", + event_user.id, + db_error, + exc_info=True, + ) + return await handler(event, data) + + if not db_user: + return await handler(event, data) + + if ( + db_user.channel_subscription_verified + and db_user.channel_subscription_verified_for == required_channel_id + ): + return await handler(event, data) + + i18n_payload: Dict[str, Any] = data.get("i18n_data", {}) + current_lang: str = i18n_payload.get( + "current_language", self.settings.DEFAULT_LANGUAGE + ) + i18n_instance: Optional[JsonI18n] = i18n_payload.get( + "i18n_instance", self.i18n_main_instance + ) + + def translate(key: str) -> str: + if i18n_instance: + return i18n_instance.gettext(current_lang, key) + return key + + keyboard = ( + get_channel_subscription_keyboard( + current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK + ) + if i18n_instance + else None + ) + prompt_text = translate("channel_subscription_required") + + if event.callback_query: + await self._handle_callback(event.callback_query, prompt_text, keyboard, data) + return + + if message_object: + await message_object.answer(prompt_text, reply_markup=keyboard) + else: + bot_instance = data["bot"] + await bot_instance.send_message( + chat_id=event_user.id, + text=prompt_text, + reply_markup=keyboard, + ) + return + + async def _handle_callback( + self, + callback: CallbackQuery, + prompt_text: str, + keyboard, + data: Dict[str, Any], + ) -> None: + try: + await callback.answer(prompt_text, show_alert=True) + except Exception: + pass + + if callback.message: + try: + await callback.message.answer(prompt_text, reply_markup=keyboard) + except Exception as send_error: + logging.error( + "ChannelSubscriptionMiddleware: failed to send prompt for callback in chat %s: %s", + callback.message.chat.id, + send_error, + exc_info=True, + ) + else: + bot_instance = data["bot"] + await bot_instance.send_message( + chat_id=callback.from_user.id, + text=prompt_text, + reply_markup=keyboard, + ) diff --git a/config/settings.py b/config/settings.py index 198bede..d1a4273 100644 --- a/config/settings.py +++ b/config/settings.py @@ -23,6 +23,12 @@ 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_ID: Optional[int] = Field( + default=None, + description="Telegram channel ID the user must join to access the bot") + REQUIRED_CHANNEL_LINK: Optional[str] = Field( + default=None, + description="Public username or invite link to the required channel for join button") YOOKASSA_SHOP_ID: Optional[str] = None YOOKASSA_SECRET_KEY: Optional[str] = None @@ -352,6 +358,13 @@ class Settings(BaseSettings): if isinstance(v, str) and v.strip() == '': return None return v + + @field_validator('REQUIRED_CHANNEL_LINK', mode='before') + @classmethod + def sanitize_optional_link(cls, v): + if isinstance(v, str) and not v.strip(): + return None + return v # Notification types LOG_NEW_USERS: bool = Field(default=True, description="Send notifications for new user registrations") diff --git a/db/database_setup.py b/db/database_setup.py index 4c1fbf8..711c11b 100644 --- a/db/database_setup.py +++ b/db/database_setup.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import sessionmaker from config.settings import Settings from .models import Base -from .migrator import run_simple_migrations +from .migrator import run_database_migrations async_engine = None @@ -63,8 +63,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker): async with async_engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) - # Run lightweight, idempotent migrations to add any missing columns - await conn.run_sync(run_simple_migrations) + await conn.run_sync(run_database_migrations) logging.info( "PostgreSQL database initialized/checked successfully using SQLAlchemy." ) diff --git a/db/migrator.py b/db/migrator.py index 3f37cc0..e91cb53 100644 --- a/db/migrator.py +++ b/db/migrator.py @@ -1,66 +1,98 @@ import logging -from typing import Set +from dataclasses import dataclass +from typing import Callable, List, Set from sqlalchemy import inspect, text from sqlalchemy.engine import Connection -from .models import Base + +@dataclass(frozen=True) +class Migration: + id: str + description: str + upgrade: Callable[[Connection], None] -def _add_missing_columns(connection: Connection) -> None: +def _ensure_migrations_table(connection: Connection) -> None: + connection.execute( + text( + """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + id VARCHAR(255) PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """ + ) + ) + + +def _migration_0001_add_channel_subscription_fields(connection: Connection) -> None: inspector = inspect(connection) - metadata = Base.metadata + columns: Set[str] = {col["name"] for col in inspector.get_columns("users")} + statements: List[str] = [] - existing_tables: Set[str] = set(inspector.get_table_names()) + if "channel_subscription_verified" not in columns: + statements.append( + "ALTER TABLE users ADD COLUMN channel_subscription_verified BOOLEAN" + ) + if "channel_subscription_checked_at" not in columns: + statements.append( + "ALTER TABLE users ADD COLUMN channel_subscription_checked_at TIMESTAMPTZ" + ) + if "channel_subscription_verified_for" not in columns: + statements.append( + "ALTER TABLE users ADD COLUMN channel_subscription_verified_for BIGINT" + ) - for table in metadata.tables.values(): - table_name = table.name - if table_name not in existing_tables: - # Tables are created elsewhere via create_all; skip here. + for stmt in statements: + connection.execute(text(stmt)) + + +MIGRATIONS: List[Migration] = [ + Migration( + id="0001_add_channel_subscription_fields", + description="Add columns to track required channel subscription verification", + upgrade=_migration_0001_add_channel_subscription_fields, + ), +] + + +def run_database_migrations(connection: Connection) -> None: + """ + Apply pending migrations sequentially. Already applied revisions are skipped. + """ + _ensure_migrations_table(connection) + + applied_revisions: Set[str] = { + row[0] + for row in connection.execute( + text("SELECT id FROM schema_migrations") + ) + } + + for migration in MIGRATIONS: + if migration.id in applied_revisions: continue - existing_columns = {col_info["name"] for col_info in inspector.get_columns(table_name)} - - for desired_column in table.columns: - if desired_column.name in existing_columns: - continue - - # Build ADD COLUMN DDL - preparer = connection.dialect.identifier_preparer - table_quoted = preparer.format_table(table) - column_name_quoted = preparer.quote(desired_column.name) - column_type_sql = desired_column.type.compile(dialect=connection.dialect) - - default_clause = "" - server_default = getattr(desired_column, "server_default", None) - if server_default is not None and getattr(server_default, "arg", None) is not None: - try: - compiled_default = str( - server_default.arg.compile(dialect=connection.dialect) - ) - default_clause = f" DEFAULT {compiled_default}" - except Exception: # best-effort - pass - - # For safety, add new columns as NULLable to avoid failures on existing rows - # If strict NOT NULL is needed, it can be enforced manually later. - ddl = f"ALTER TABLE {table_quoted} ADD COLUMN {column_name_quoted} {column_type_sql}{default_clause}" - - logging.info( - f"Migrator: adding missing column {desired_column.name} to table {table_name}" + logging.info( + "Migrator: applying %s – %s", migration.id, migration.description + ) + try: + with connection.begin_nested(): + migration.upgrade(connection) + connection.execute( + text( + "INSERT INTO schema_migrations (id) VALUES (:revision)" + ), + {"revision": migration.id}, + ) + except Exception as exc: + logging.error( + "Migrator: failed to apply %s (%s)", + migration.id, + migration.description, + exc_info=True, ) - connection.execute(text(ddl)) - - -def run_simple_migrations(connection: Connection) -> None: - """ - Run lightweight, idempotent migrations: - - Ensure missing columns are added to existing tables to match models in db/models.py - Note: Table creation is handled separately via Base.metadata.create_all. - """ - try: - _add_missing_columns(connection) - logging.info("Migrator: schema synchronized (columns added as needed).") - except Exception as e: - logging.error(f"Migrator: failed to run simple migrations: {e}", exc_info=True) - raise + raise exc + else: + logging.info("Migrator: migration %s applied successfully", migration.id) diff --git a/db/models.py b/db/models.py index d33dfdf..5e8d079 100644 --- a/db/models.py +++ b/db/models.py @@ -24,6 +24,10 @@ class User(Base): referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True) + channel_subscription_verified = Column(Boolean, nullable=True) + channel_subscription_checked_at = Column(DateTime(timezone=True), + nullable=True) + channel_subscription_verified_for = Column(BigInteger, nullable=True) referrer = relationship("User", remote_side=[user_id], backref="referrals") subscriptions = relationship("Subscription", diff --git a/locales/en.json b/locales/en.json index 283a886..f306d89 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,5 +1,10 @@ { "welcome": "Welcome, {user_name}!", + "channel_subscription_required": "To use the bot, join the channel and tap \"Check subscription\".", + "channel_subscription_join_button": "Open channel", + "channel_subscription_verify_button": "Check subscription", + "channel_subscription_check_failed": "Couldn't verify the subscription. Please try again later or contact support.", + "channel_subscription_verified_success": "✅ Subscription confirmed! You're good to go.", "main_menu_greeting": "Hi, {user_name}! 👋\nWhat would you like to do?", "menu_activate_trial_button": "🆓 Free Trial", "menu_subscribe_inline": "🚀 Purchase", diff --git a/locales/ru.json b/locales/ru.json index c2d5260..8e731e6 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1,5 +1,10 @@ { "welcome": "Добро пожаловать, {user_name}!", + "channel_subscription_required": "Чтобы пользоваться ботом, подпишитесь на канал и нажмите \"Проверить подписку\".", + "channel_subscription_join_button": "Перейти в канал", + "channel_subscription_verify_button": "Проверить подписку", + "channel_subscription_check_failed": "Не удалось проверить подписку. Попробуйте позже или обратитесь в поддержку.", + "channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.", "main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?", "menu_activate_trial_button": "🆓 Пробный период", "menu_subscribe_inline": "🚀 Купить",