channel require and db update

This commit is contained in:
machka pasla
2025-10-17 09:36:25 +03:00
parent 51ffbbfa1d
commit c3622d9c2b
12 changed files with 541 additions and 58 deletions
+4
View File
@@ -21,6 +21,10 @@ SUBSCRIPTION_MINI_APP_URL= #
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= # 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 (used for Telegram and payment providers)
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
+4
View File
@@ -66,6 +66,8 @@
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` | | `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
| `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` |
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
</details> </details>
<details> <details>
@@ -150,6 +152,8 @@
docker compose logs -f remnawave-tg-shop docker compose logs -f remnawave-tg-shop
``` ```
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
## 🐳 Docker ## 🐳 Docker
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки. Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
+2 -1
View File
@@ -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.ban_check_middleware import BanCheckMiddleware
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
from bot.middlewares.profile_sync import ProfileSyncMiddleware 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]: 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(I18nMiddleware(i18n=i18n_instance, settings=settings))
dp.update.outer_middleware(ProfileSyncMiddleware()) dp.update.outer_middleware(ProfileSyncMiddleware())
dp.update.outer_middleware(BanCheckMiddleware(settings=settings, i18n_instance=i18n_instance)) 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)) dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings))
return dp, bot, {"i18n_instance": i18n_instance} return dp, bot, {"i18n_instance": i18n_instance}
+242 -1
View File
@@ -7,10 +7,16 @@ from aiogram.fsm.context import FSMContext
from typing import Optional, Union from typing import Optional, Union
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timezone from datetime import datetime, timezone
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError
from db.dal import user_dal 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.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService from bot.services.referral_service import ReferralService
@@ -51,6 +57,7 @@ async def send_main_menu(target_event: Union[types.Message,
pass pass
return return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
show_trial_button_in_menu = False show_trial_button_in_menu = False
@@ -116,6 +123,184 @@ async def send_main_menu(target_event: Union[types.Message,
pass 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())
@router.message(CommandStart(magic=F.args.regexp(r"^ref_(\d+)$").as_("ref_match"))) @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"))) @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: except Exception:
pass pass
if not await ensure_required_channel_subscription(message, settings, i18n,
current_lang, session,
db_user):
return
# Send welcome message if not disabled # Send welcome message if not disabled
if not settings.DISABLE_WELCOME_MESSAGE: if not settings.DISABLE_WELCOME_MESSAGE:
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name))) 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) 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.message(Command("language"))
@router.callback_query(F.data == "main_action:language") @router.callback_query(F.data == "main_action:language")
async def language_command_handler( async def language_command_handler(
+37
View File
@@ -286,6 +286,43 @@ def get_user_banned_keyboard(support_link: Optional[str], lang: str,
return builder.as_markup() 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( def get_connect_and_main_keyboard(
lang: str, lang: str,
i18n_instance, i18n_instance,
+138
View File
@@ -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,
)
+13
View File
@@ -23,6 +23,12 @@ 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_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_SHOP_ID: Optional[str] = None
YOOKASSA_SECRET_KEY: Optional[str] = None YOOKASSA_SECRET_KEY: Optional[str] = None
@@ -352,6 +358,13 @@ class Settings(BaseSettings):
if isinstance(v, str) and v.strip() == '': if isinstance(v, str) and v.strip() == '':
return None return None
return v 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 # Notification types
LOG_NEW_USERS: bool = Field(default=True, description="Send notifications for new user registrations") LOG_NEW_USERS: bool = Field(default=True, description="Send notifications for new user registrations")
+2 -3
View File
@@ -4,7 +4,7 @@ from sqlalchemy.orm import sessionmaker
from config.settings import Settings from config.settings import Settings
from .models import Base from .models import Base
from .migrator import run_simple_migrations from .migrator import run_database_migrations
async_engine = None async_engine = None
@@ -63,8 +63,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
async with async_engine.begin() as conn: async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
# Run lightweight, idempotent migrations to add any missing columns await conn.run_sync(run_database_migrations)
await conn.run_sync(run_simple_migrations)
logging.info( logging.info(
"PostgreSQL database initialized/checked successfully using SQLAlchemy." "PostgreSQL database initialized/checked successfully using SQLAlchemy."
) )
+85 -53
View File
@@ -1,66 +1,98 @@
import logging import logging
from typing import Set from dataclasses import dataclass
from typing import Callable, List, Set
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
from sqlalchemy.engine import Connection 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) 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(): for stmt in statements:
table_name = table.name connection.execute(text(stmt))
if table_name not in existing_tables:
# Tables are created elsewhere via create_all; skip here.
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 continue
existing_columns = {col_info["name"] for col_info in inspector.get_columns(table_name)} logging.info(
"Migrator: applying %s %s", migration.id, migration.description
for desired_column in table.columns: )
if desired_column.name in existing_columns: try:
continue with connection.begin_nested():
migration.upgrade(connection)
# Build ADD COLUMN DDL connection.execute(
preparer = connection.dialect.identifier_preparer text(
table_quoted = preparer.format_table(table) "INSERT INTO schema_migrations (id) VALUES (:revision)"
column_name_quoted = preparer.quote(desired_column.name) ),
column_type_sql = desired_column.type.compile(dialect=connection.dialect) {"revision": migration.id},
)
default_clause = "" except Exception as exc:
server_default = getattr(desired_column, "server_default", None) logging.error(
if server_default is not None and getattr(server_default, "arg", None) is not None: "Migrator: failed to apply %s (%s)",
try: migration.id,
compiled_default = str( migration.description,
server_default.arg.compile(dialect=connection.dialect) exc_info=True,
)
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}"
) )
connection.execute(text(ddl)) raise exc
else:
logging.info("Migrator: migration %s applied successfully", migration.id)
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
+4
View File
@@ -24,6 +24,10 @@ class User(Base):
referred_by_id = Column(BigInteger, referred_by_id = Column(BigInteger,
ForeignKey("users.user_id"), ForeignKey("users.user_id"),
nullable=True) 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") referrer = relationship("User", remote_side=[user_id], backref="referrals")
subscriptions = relationship("Subscription", subscriptions = relationship("Subscription",
+5
View File
@@ -1,5 +1,10 @@
{ {
"welcome": "Welcome, {user_name}!", "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?", "main_menu_greeting": "Hi, {user_name}! 👋\nWhat would you like to do?",
"menu_activate_trial_button": "🆓 Free Trial", "menu_activate_trial_button": "🆓 Free Trial",
"menu_subscribe_inline": "🚀 Purchase", "menu_subscribe_inline": "🚀 Purchase",
+5
View File
@@ -1,5 +1,10 @@
{ {
"welcome": "Добро пожаловать, {user_name}!", "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Что бы вы хотели сделать?", "main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?",
"menu_activate_trial_button": "🆓 Пробный период", "menu_activate_trial_button": "🆓 Пробный период",
"menu_subscribe_inline": "🚀 Купить", "menu_subscribe_inline": "🚀 Купить",