channel require and db update
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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` |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -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: .` для локальной сборки.
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
+242
-1
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
+85
-53
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "🚀 Купить",
|
||||
|
||||
Reference in New Issue
Block a user