Подтверждение 18+
Без подтверждения возраста запросы и оплата недоступны.
This commit is contained in:
parent
4339e126a2
commit
3a605ad820
12
README.md
12
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 генерации.
|
||||
|
|
|
|||
115
bot.py
115
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:
|
||||
|
|
|
|||
12
checklist.md
12
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.
|
||||
|
|
|
|||
38
db.py
38
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue