diff --git a/README.md b/README.md index 18c3561..6b5dfc0 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Telegram-бот для генерации и редактирования изо - **Премиум-очередь**: чередуется с обычной (одна премиум, одна обычная); если одна пуста — работает другая. - **Баланс и рефералы**: 3 стартовые генерации, +1 ежедневно в 00:00 МСК, +3 за первую оплату друга. - **Управление пользователями**: в веб-интерфейсе можно выдавать админку, менять баланс и блокировать. +- **Подтверждение 18+**: без подтверждения возраста бот не принимает запросы. - Глобальная очередь с одной активной задачей на пользователя. - Позиция в очереди и прогресс генерации через WebSocket ComfyUI. - SQLite: запросы, статусы, seed, пути к файлам. @@ -138,6 +139,17 @@ BLOCKED_PUBLIC_FIGURES= `ADMIN_USER_IDS` используется только для первичной инициализации админов; после первого запуска админы хранятся в БД и управляются через веб. +## Подтверждение возраста + +Перед использованием бота нужно подтвердить 18+: + +- в `/start` показываются кнопки «Мне 18+» и «Мне меньше 18»; +- без подтверждения текстовые и фото-запросы отклоняются; +- отказ «мне меньше 18» сохраняется в БД и блокирует доступ; +- статус 18+ виден в веб-разделе «Пользователи». + +Существующие пользователи проходят подтверждение один раз после обновления. + ## Баланс и рефералы - Новому пользователю начисляется 3 генерации. diff --git a/bot.py b/bot.py index 1a0eeff..4ec02fe 100644 --- a/bot.py +++ b/bot.py @@ -48,6 +48,11 @@ START_TEXT = ( "/status — состояние текущей задачи." ) BLOCKED_TEXT = "Запрос нарушает правила. Звёзды не списаны." +AGE_TEXT = ( + "🔞 Бот доступен только пользователям 18+.\n\n" + "Подтверди, что тебе исполнилось 18 лет." +) +AGE_DENIED = "Доступ к боту разрешён только с 18 лет." class MpakoBot: @@ -90,6 +95,9 @@ class MpakoBot: application.add_handler( CallbackQueryHandler(self._queue_choice, pattern=r"^queue:") ) + application.add_handler( + CallbackQueryHandler(self._age_choice, pattern=r"^age:") + ) application.add_handler( MessageHandler(filters.SUCCESSFUL_PAYMENT, self._successful_payment) ) @@ -199,7 +207,7 @@ class MpakoBot: async def _start( self, update: Update, context: ContextTypes.DEFAULT_TYPE ) -> None: - if not await self._check_access(update): + if not await self._check_base_access(update): return message = update.effective_message user = update.effective_user @@ -214,8 +222,64 @@ class MpakoBot: ): await self._db.set_referrer(user.id, referrer_id) row = await self._db.ensure_user(user.id, user.username) + if row.age_declined: + await message.reply_text(AGE_DENIED) + return + if not row.age_confirmed: + await message.reply_text( + AGE_TEXT, reply_markup=self._age_keyboard() + ) + return await message.reply_text(self._start_text(row.credits)) + @staticmethod + def _age_keyboard() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton( + "Мне 18+", callback_data="age:yes" + ), + InlineKeyboardButton( + "Мне меньше 18", callback_data="age:no" + ), + ] + ] + ) + + async def _age_choice( + self, update: Update, context: ContextTypes.DEFAULT_TYPE + ) -> None: + query = update.callback_query + user = update.effective_user + if query is None or query.data is None or user is None: + return + row = await self._db.ensure_user(user.id, user.username) + if row.is_blocked: + await query.answer("Доступ заблокирован.", show_alert=True) + return + if query.data == "age:yes": + await self._db.set_age_confirmed(user.id, True) + await query.answer("Возраст подтверждён") + row = await self._db.get_user(user.id) + try: + await query.edit_message_text("Возраст подтверждён ✅") + except TelegramError: + pass + if row is not None: + await self._bot.send_message( + chat_id=user.id, + text=self._start_text(row.credits), + ) + return + if query.data == "age:no": + await self._db.set_age_declined(user.id) + await query.answer() + try: + await query.edit_message_text(AGE_DENIED) + except TelegramError: + pass + async def _help( self, update: Update, context: ContextTypes.DEFAULT_TYPE ) -> None: @@ -332,7 +396,10 @@ class MpakoBot: and pending.status == "pending" and query.currency == "XTR" and query.total_amount == pending.amount - and not (user_row is not None and user_row.is_blocked) + and user_row is not None + and user_row.age_confirmed + and not user_row.age_declined + and not user_row.is_blocked ) try: await query.answer( @@ -380,6 +447,15 @@ class MpakoBot: if user_row is not None and user_row.is_blocked: await query.answer("Доступ заблокирован.", show_alert=True) return + if user_row is None or not user_row.age_confirmed: + await query.answer( + "Сначала подтверди возраст.", show_alert=True + ) + if query.message is not None: + await query.message.reply_text( + AGE_TEXT, reply_markup=self._age_keyboard() + ) + return if tier == "credit": remaining = await self._db.consume_credit(query.from_user.id) @@ -474,7 +550,12 @@ class MpakoBot: return user_row = await self._db.get_user(user.id) - if user_row is not None and user_row.is_blocked: + if ( + user_row is None + or not user_row.age_confirmed + or user_row.age_declined + or user_row.is_blocked + ): refunded = await self._refund_stars( user.id, payment.telegram_payment_charge_id ) @@ -661,6 +742,14 @@ class MpakoBot: return False user_row = await self._db.ensure_user(user.id, user.username) + if user_row.age_declined: + await message.reply_text(AGE_DENIED) + return False + if not user_row.age_confirmed: + await message.reply_text( + AGE_TEXT, reply_markup=self._age_keyboard() + ) + return False is_admin = user_row.is_admin if user_row.is_blocked: await message.reply_text("Доступ заблокирован.") @@ -1074,7 +1163,7 @@ class MpakoBot: except TelegramError: logger.debug("Не удалось удалить статус %s", request_id) - async def _check_access(self, update: Update) -> bool: + async def _check_base_access(self, update: Update) -> bool: message = update.effective_message chat = update.effective_chat user = update.effective_user @@ -1095,6 +1184,24 @@ class MpakoBot: return False return True + async def _check_access(self, update: Update) -> bool: + if not await self._check_base_access(update): + return False + message = update.effective_message + user = update.effective_user + if message is None or user is None: + return False + row = await self._db.get_user(user.id) + if row is not None and row.age_declined: + await message.reply_text(AGE_DENIED) + return False + if row is None or not row.age_confirmed: + await message.reply_text( + AGE_TEXT, reply_markup=self._age_keyboard() + ) + return False + return True + async def _on_error( self, update: object, context: ContextTypes.DEFAULT_TYPE ) -> None: diff --git a/checklist.md b/checklist.md index a1481f8..035325a 100644 --- a/checklist.md +++ b/checklist.md @@ -6,7 +6,7 @@ ## Текущий статус -- Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11), README (12), веб-история (13), улучшение промптов (14), модерация (15), оплата Stars (16), баланс/рефералы (17) и управление пользователями (18). +- Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11), README (12), веб-история (13), улучшение промптов (14), модерация (15), оплата Stars (16), баланс/рефералы (17), управление пользователями (18) и возрастной gate (19). - Вживую проверены: T2I (Z-Image), I2I (Krea2), WebSocket-прогресс, улучшение промптов, Docker, CI и веб-история. - 52 теста проходят: БД, workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями. - Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото. @@ -361,6 +361,16 @@ ON requests(user_id) WHERE status IN ('queued', 'running'); **DoD:** изменения в веб-интерфейсе применяются к боту без рестарта. +## 19. Подтверждение 18+ + +- [x] Возрастной gate в `/start` с кнопками «18+»/«меньше 18». +- [x] Флаг `age_confirmed`/`age_declined` в `users`. +- [x] Без подтверждения запросы, оплата и выбор очереди недоступны. +- [x] Существующие пользователи проходят подтверждение один раз. +- [x] Статус 18+ отображается в веб-управлении пользователями. + +**DoD:** несовершеннолетний пользователь не может пользоваться ботом; подтверждение сохраняется. + ## Порядок работ 1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow. diff --git a/db.py b/db.py index a475d61..46170ff 100644 --- a/db.py +++ b/db.py @@ -123,6 +123,8 @@ class User: created_at: str is_admin: bool is_blocked: bool + age_confirmed: bool + age_declined: bool @property def display_user(self) -> str: @@ -140,6 +142,8 @@ class User: created_at=row["created_at"], is_admin=bool(row["is_admin"]), is_blocked=bool(row["is_blocked"]), + age_confirmed=bool(row["age_confirmed"]), + age_declined=bool(row["age_declined"]), ) @@ -248,7 +252,9 @@ CREATE TABLE IF NOT EXISTS users ( first_paid_at TEXT, created_at TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, - is_blocked INTEGER NOT NULL DEFAULT 0 + is_blocked INTEGER NOT NULL DEFAULT 0, + age_confirmed INTEGER NOT NULL DEFAULT 0, + age_declined INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_users_referrer @@ -316,6 +322,14 @@ class Database: await conn.execute( "ALTER TABLE users ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0" ) + if "age_confirmed" not in user_columns: + await conn.execute( + "ALTER TABLE users ADD COLUMN age_confirmed INTEGER NOT NULL DEFAULT 0" + ) + if "age_declined" not in user_columns: + await conn.execute( + "ALTER TABLE users ADD COLUMN age_declined INTEGER NOT NULL DEFAULT 0" + ) await conn.execute( """ INSERT OR IGNORE INTO users @@ -484,6 +498,28 @@ class Database: "is_blocked", user_id, enabled ) + async def set_age_confirmed(self, user_id: int, confirmed: bool) -> bool: + conn = self._connection() + async with self._write_lock: + cursor = await conn.execute( + "UPDATE users SET age_confirmed = ?, age_declined = 0" + " WHERE user_id = ?", + (int(confirmed), user_id), + ) + await conn.commit() + return cursor.rowcount > 0 + + async def set_age_declined(self, user_id: int) -> bool: + conn = self._connection() + async with self._write_lock: + cursor = await conn.execute( + "UPDATE users SET age_declined = 1, age_confirmed = 0" + " WHERE user_id = ?", + (user_id,), + ) + await conn.commit() + return cursor.rowcount > 0 + async def set_credits(self, user_id: int, credits: int) -> bool: conn = self._connection() async with self._write_lock: diff --git a/tests/test_db.py b/tests/test_db.py index 3182f4e..7b13182 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -259,6 +259,17 @@ async def test_user_credits_referral_and_daily_accrual(db: Database) -> None: assert await db.mark_first_payment(2) is True assert await db.mark_first_payment(2) is False + assert await db.set_age_confirmed(1, True) is True + user = await db.get_user(1) + assert user is not None + assert user.age_confirmed is True + assert user.age_declined is False + assert await db.set_age_declined(1) is True + user = await db.get_user(1) + assert user is not None + assert user.age_confirmed is False + assert user.age_declined is True + friend = await db.get_user(2) assert friend is not None assert friend.referrer_id == 1 diff --git a/web/templates/users.html b/web/templates/users.html index 2d635c5..61ea479 100644 --- a/web/templates/users.html +++ b/web/templates/users.html @@ -18,6 +18,7 @@ Баланс Админ Блокировка + 18+ Реферер Первая оплата Создан @@ -48,6 +49,11 @@ + + {% if user.age_confirmed %}да + {% elif user.age_declined %}нет + {% else %}не подтверждён{% endif %} + {% if user.referrer_id %} {{ user.referrer_id }} @@ -57,7 +63,7 @@ {{ user.created_at|fmt_dt }} {% else %} - Пользователей нет + Пользователей нет {% endfor %}