channel require and db update
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user