Подтверждение возраста
Build and push Docker image / docker (push) Successful in 10s Details

This commit is contained in:
MpakobecEx 2026-10-18 22:07:27 +05:00
parent 99cea7cb8f
commit 927cf927f5
6 changed files with 189 additions and 7 deletions

View File

@ -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 генерации.

115
bot.py
View File

@ -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:

View File

@ -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.

38
db.py
View File

@ -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:

View File

@ -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

View File

@ -18,6 +18,7 @@
<th>Баланс</th>
<th>Админ</th>
<th>Блокировка</th>
<th>18+</th>
<th>Реферер</th>
<th>Первая оплата</th>
<th>Создан</th>
@ -48,6 +49,11 @@
<button type="submit">{{ "Разблокировать" if user.is_blocked else "Заблокировать" }}</button>
</form>
</td>
<td>
{% if user.age_confirmed %}<span class="badge done">да</span>
{% elif user.age_declined %}<span class="badge failed">нет</span>
{% else %}<span class="badge">не подтверждён</span>{% endif %}
</td>
<td>
{% if user.referrer_id %}
<a href="/users?q={{ user.referrer_id }}">{{ user.referrer_id }}</a>
@ -57,7 +63,7 @@
<td>{{ user.created_at|fmt_dt }}</td>
</tr>
{% else %}
<tr><td colspan="8" class="empty">Пользователей нет</td></tr>
<tr><td colspan="9" class="empty">Пользователей нет</td></tr>
{% endfor %}
</tbody>
</table>