Подтверждение 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 за первую оплату друга.
|
- **Баланс и рефералы**: 3 стартовые генерации, +1 ежедневно в 00:00 МСК, +3 за первую оплату друга.
|
||||||
- **Управление пользователями**: в веб-интерфейсе можно выдавать админку, менять баланс и блокировать.
|
- **Управление пользователями**: в веб-интерфейсе можно выдавать админку, менять баланс и блокировать.
|
||||||
|
- **Подтверждение 18+**: без подтверждения возраста бот не принимает запросы.
|
||||||
- Глобальная очередь с одной активной задачей на пользователя.
|
- Глобальная очередь с одной активной задачей на пользователя.
|
||||||
- Позиция в очереди и прогресс генерации через WebSocket ComfyUI.
|
- Позиция в очереди и прогресс генерации через WebSocket ComfyUI.
|
||||||
- SQLite: запросы, статусы, seed, пути к файлам.
|
- SQLite: запросы, статусы, seed, пути к файлам.
|
||||||
|
|
@ -138,6 +139,17 @@ BLOCKED_PUBLIC_FIGURES=
|
||||||
|
|
||||||
`ADMIN_USER_IDS` используется только для первичной инициализации админов; после первого запуска админы хранятся в БД и управляются через веб.
|
`ADMIN_USER_IDS` используется только для первичной инициализации админов; после первого запуска админы хранятся в БД и управляются через веб.
|
||||||
|
|
||||||
|
## Подтверждение возраста
|
||||||
|
|
||||||
|
Перед использованием бота нужно подтвердить 18+:
|
||||||
|
|
||||||
|
- в `/start` показываются кнопки «Мне 18+» и «Мне меньше 18»;
|
||||||
|
- без подтверждения текстовые и фото-запросы отклоняются;
|
||||||
|
- отказ «мне меньше 18» сохраняется в БД и блокирует доступ;
|
||||||
|
- статус 18+ виден в веб-разделе «Пользователи».
|
||||||
|
|
||||||
|
Существующие пользователи проходят подтверждение один раз после обновления.
|
||||||
|
|
||||||
## Баланс и рефералы
|
## Баланс и рефералы
|
||||||
|
|
||||||
- Новому пользователю начисляется 3 генерации.
|
- Новому пользователю начисляется 3 генерации.
|
||||||
|
|
|
||||||
115
bot.py
115
bot.py
|
|
@ -48,6 +48,11 @@ START_TEXT = (
|
||||||
"/status — состояние текущей задачи."
|
"/status — состояние текущей задачи."
|
||||||
)
|
)
|
||||||
BLOCKED_TEXT = "Запрос нарушает правила. Звёзды не списаны."
|
BLOCKED_TEXT = "Запрос нарушает правила. Звёзды не списаны."
|
||||||
|
AGE_TEXT = (
|
||||||
|
"🔞 Бот доступен только пользователям 18+.\n\n"
|
||||||
|
"Подтверди, что тебе исполнилось 18 лет."
|
||||||
|
)
|
||||||
|
AGE_DENIED = "Доступ к боту разрешён только с 18 лет."
|
||||||
|
|
||||||
|
|
||||||
class MpakoBot:
|
class MpakoBot:
|
||||||
|
|
@ -90,6 +95,9 @@ class MpakoBot:
|
||||||
application.add_handler(
|
application.add_handler(
|
||||||
CallbackQueryHandler(self._queue_choice, pattern=r"^queue:")
|
CallbackQueryHandler(self._queue_choice, pattern=r"^queue:")
|
||||||
)
|
)
|
||||||
|
application.add_handler(
|
||||||
|
CallbackQueryHandler(self._age_choice, pattern=r"^age:")
|
||||||
|
)
|
||||||
application.add_handler(
|
application.add_handler(
|
||||||
MessageHandler(filters.SUCCESSFUL_PAYMENT, self._successful_payment)
|
MessageHandler(filters.SUCCESSFUL_PAYMENT, self._successful_payment)
|
||||||
)
|
)
|
||||||
|
|
@ -199,7 +207,7 @@ class MpakoBot:
|
||||||
async def _start(
|
async def _start(
|
||||||
self, update: Update, context: ContextTypes.DEFAULT_TYPE
|
self, update: Update, context: ContextTypes.DEFAULT_TYPE
|
||||||
) -> None:
|
) -> None:
|
||||||
if not await self._check_access(update):
|
if not await self._check_base_access(update):
|
||||||
return
|
return
|
||||||
message = update.effective_message
|
message = update.effective_message
|
||||||
user = update.effective_user
|
user = update.effective_user
|
||||||
|
|
@ -214,8 +222,64 @@ class MpakoBot:
|
||||||
):
|
):
|
||||||
await self._db.set_referrer(user.id, referrer_id)
|
await self._db.set_referrer(user.id, referrer_id)
|
||||||
row = await self._db.ensure_user(user.id, user.username)
|
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))
|
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(
|
async def _help(
|
||||||
self, update: Update, context: ContextTypes.DEFAULT_TYPE
|
self, update: Update, context: ContextTypes.DEFAULT_TYPE
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -332,7 +396,10 @@ class MpakoBot:
|
||||||
and pending.status == "pending"
|
and pending.status == "pending"
|
||||||
and query.currency == "XTR"
|
and query.currency == "XTR"
|
||||||
and query.total_amount == pending.amount
|
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:
|
try:
|
||||||
await query.answer(
|
await query.answer(
|
||||||
|
|
@ -380,6 +447,15 @@ class MpakoBot:
|
||||||
if user_row is not None and user_row.is_blocked:
|
if user_row is not None and user_row.is_blocked:
|
||||||
await query.answer("Доступ заблокирован.", show_alert=True)
|
await query.answer("Доступ заблокирован.", show_alert=True)
|
||||||
return
|
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":
|
if tier == "credit":
|
||||||
remaining = await self._db.consume_credit(query.from_user.id)
|
remaining = await self._db.consume_credit(query.from_user.id)
|
||||||
|
|
@ -474,7 +550,12 @@ class MpakoBot:
|
||||||
return
|
return
|
||||||
|
|
||||||
user_row = await self._db.get_user(user.id)
|
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(
|
refunded = await self._refund_stars(
|
||||||
user.id, payment.telegram_payment_charge_id
|
user.id, payment.telegram_payment_charge_id
|
||||||
)
|
)
|
||||||
|
|
@ -661,6 +742,14 @@ class MpakoBot:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
user_row = await self._db.ensure_user(user.id, user.username)
|
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
|
is_admin = user_row.is_admin
|
||||||
if user_row.is_blocked:
|
if user_row.is_blocked:
|
||||||
await message.reply_text("Доступ заблокирован.")
|
await message.reply_text("Доступ заблокирован.")
|
||||||
|
|
@ -1074,7 +1163,7 @@ class MpakoBot:
|
||||||
except TelegramError:
|
except TelegramError:
|
||||||
logger.debug("Не удалось удалить статус %s", request_id)
|
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
|
message = update.effective_message
|
||||||
chat = update.effective_chat
|
chat = update.effective_chat
|
||||||
user = update.effective_user
|
user = update.effective_user
|
||||||
|
|
@ -1095,6 +1184,24 @@ class MpakoBot:
|
||||||
return False
|
return False
|
||||||
return True
|
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(
|
async def _on_error(
|
||||||
self, update: object, context: ContextTypes.DEFAULT_TYPE
|
self, update: object, context: ContextTypes.DEFAULT_TYPE
|
||||||
) -> None:
|
) -> 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 и веб-история.
|
- Вживую проверены: T2I (Z-Image), I2I (Krea2), WebSocket-прогресс, улучшение промптов, Docker, CI и веб-история.
|
||||||
- 52 теста проходят: БД, workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями.
|
- 52 теста проходят: БД, workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями.
|
||||||
- Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото.
|
- Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото.
|
||||||
|
|
@ -361,6 +361,16 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
|
||||||
|
|
||||||
**DoD:** изменения в веб-интерфейсе применяются к боту без рестарта.
|
**DoD:** изменения в веб-интерфейсе применяются к боту без рестарта.
|
||||||
|
|
||||||
|
## 19. Подтверждение 18+
|
||||||
|
|
||||||
|
- [x] Возрастной gate в `/start` с кнопками «18+»/«меньше 18».
|
||||||
|
- [x] Флаг `age_confirmed`/`age_declined` в `users`.
|
||||||
|
- [x] Без подтверждения запросы, оплата и выбор очереди недоступны.
|
||||||
|
- [x] Существующие пользователи проходят подтверждение один раз.
|
||||||
|
- [x] Статус 18+ отображается в веб-управлении пользователями.
|
||||||
|
|
||||||
|
**DoD:** несовершеннолетний пользователь не может пользоваться ботом; подтверждение сохраняется.
|
||||||
|
|
||||||
## Порядок работ
|
## Порядок работ
|
||||||
|
|
||||||
1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow.
|
1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow.
|
||||||
|
|
|
||||||
38
db.py
38
db.py
|
|
@ -123,6 +123,8 @@ class User:
|
||||||
created_at: str
|
created_at: str
|
||||||
is_admin: bool
|
is_admin: bool
|
||||||
is_blocked: bool
|
is_blocked: bool
|
||||||
|
age_confirmed: bool
|
||||||
|
age_declined: bool
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def display_user(self) -> str:
|
def display_user(self) -> str:
|
||||||
|
|
@ -140,6 +142,8 @@ class User:
|
||||||
created_at=row["created_at"],
|
created_at=row["created_at"],
|
||||||
is_admin=bool(row["is_admin"]),
|
is_admin=bool(row["is_admin"]),
|
||||||
is_blocked=bool(row["is_blocked"]),
|
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,
|
first_paid_at TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
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
|
CREATE INDEX IF NOT EXISTS idx_users_referrer
|
||||||
|
|
@ -316,6 +322,14 @@ class Database:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"ALTER TABLE users ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0"
|
"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(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO users
|
INSERT OR IGNORE INTO users
|
||||||
|
|
@ -484,6 +498,28 @@ class Database:
|
||||||
"is_blocked", user_id, enabled
|
"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:
|
async def set_credits(self, user_id: int, credits: int) -> bool:
|
||||||
conn = self._connection()
|
conn = self._connection()
|
||||||
async with self._write_lock:
|
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 True
|
||||||
assert await db.mark_first_payment(2) is False
|
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)
|
friend = await db.get_user(2)
|
||||||
assert friend is not None
|
assert friend is not None
|
||||||
assert friend.referrer_id == 1
|
assert friend.referrer_id == 1
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
<th>Баланс</th>
|
<th>Баланс</th>
|
||||||
<th>Админ</th>
|
<th>Админ</th>
|
||||||
<th>Блокировка</th>
|
<th>Блокировка</th>
|
||||||
|
<th>18+</th>
|
||||||
<th>Реферер</th>
|
<th>Реферер</th>
|
||||||
<th>Первая оплата</th>
|
<th>Первая оплата</th>
|
||||||
<th>Создан</th>
|
<th>Создан</th>
|
||||||
|
|
@ -48,6 +49,11 @@
|
||||||
<button type="submit">{{ "Разблокировать" if user.is_blocked else "Заблокировать" }}</button>
|
<button type="submit">{{ "Разблокировать" if user.is_blocked else "Заблокировать" }}</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</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>
|
<td>
|
||||||
{% if user.referrer_id %}
|
{% if user.referrer_id %}
|
||||||
<a href="/users?q={{ user.referrer_id }}">{{ user.referrer_id }}</a>
|
<a href="/users?q={{ user.referrer_id }}">{{ user.referrer_id }}</a>
|
||||||
|
|
@ -57,7 +63,7 @@
|
||||||
<td>{{ user.created_at|fmt_dt }}</td>
|
<td>{{ user.created_at|fmt_dt }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="8" class="empty">Пользователей нет</td></tr>
|
<tr><td colspan="9" class="empty">Пользователей нет</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue