diff --git a/README.md b/README.md
index df7838f..18c3561 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ Telegram-бот для генерации и редактирования изо
- **Оплата**: 5 Telegram Stars за обычную очередь, 15 за премиум; админам бесплатно, возврат при ошибке.
- **Премиум-очередь**: чередуется с обычной (одна премиум, одна обычная); если одна пуста — работает другая.
- **Баланс и рефералы**: 3 стартовые генерации, +1 ежедневно в 00:00 МСК, +3 за первую оплату друга.
+- **Управление пользователями**: в веб-интерфейсе можно выдавать админку, менять баланс и блокировать.
- Глобальная очередь с одной активной задачей на пользователя.
- Позиция в очереди и прогресс генерации через WebSocket ComfyUI.
- SQLite: запросы, статусы, seed, пути к файлам.
@@ -124,6 +125,19 @@ BLOCKED_PUBLIC_FIGURES=
При блокировке пользователь получает «Запрос нарушает правила. Звёзды не списаны.», а запись с `user_id`, временем, промптом и причиной сохраняется в таблицу `blocked_requests`.
+## Управление пользователями
+
+В веб-интерфейсе есть раздел **«Пользователи»** (`/users`):
+
+- поиск по ID или username;
+- установка точного баланса генераций;
+- назначение/снятие админа;
+- блокировка/разблокировка.
+
+Заблокированный пользователь не может отправлять запросы, получать счета и оплачивать. Админ-флаг из веб-интерфейса действует сразу.
+
+`ADMIN_USER_IDS` используется только для первичной инициализации админов; после первого запуска админы хранятся в БД и управляются через веб.
+
## Баланс и рефералы
- Новому пользователю начисляется 3 генерации.
@@ -149,7 +163,7 @@ PREMIUM_STARS_PRICE=15
- Перед оплатой бот показывает количество людей в обычной и премиум-очередях.
- Пользователь выбирает очередь кнопкой, после чего получает счёт.
- Премиум- и обычные задачи чередуются: одна премиум, одна обычная; если одна очередь пуста, берётся из другой.
-- Админы из `ADMIN_USER_IDS` генерируют бесплатно и попадают в премиум-очередь.
+- Админы из `ADMIN_USER_IDS` генерируют бесплатно в обычной очереди.
- Не-админу выставляется счёт через Telegram Stars (`XTR`).
- Фильтрация промпта выполняется до выставления счёта.
- Если поставить задачу в очередь не удалось, звёзды возвращаются.
diff --git a/bot.py b/bot.py
index 5dd3b48..1a0eeff 100644
--- a/bot.py
+++ b/bot.py
@@ -113,6 +113,7 @@ class MpakoBot:
self._settings.comfy_ws_url,
)
await self._db.connect()
+ await self._db.seed_admins(self._settings.admin_user_ids)
await self._db.accrue_daily_credits()
self._daily_task = asyncio.create_task(
self._daily_credits_loop(), name="daily-credits"
@@ -324,12 +325,14 @@ class MpakoBot:
if query is None:
return
pending = await self._db.get_pending_payment(query.invoice_payload)
+ user_row = await self._db.get_user(query.from_user.id)
allowed = (
pending is not None
and pending.user_id == query.from_user.id
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)
)
try:
await query.answer(
@@ -373,6 +376,11 @@ class MpakoBot:
)
return
+ user_row = await self._db.get_user(query.from_user.id)
+ if user_row is not None and user_row.is_blocked:
+ await query.answer("Доступ заблокирован.", show_alert=True)
+ return
+
if tier == "credit":
remaining = await self._db.consume_credit(query.from_user.id)
if remaining is None:
@@ -465,6 +473,17 @@ class MpakoBot:
if pending.status != "pending":
return
+ user_row = await self._db.get_user(user.id)
+ if user_row is not None and user_row.is_blocked:
+ refunded = await self._refund_stars(
+ user.id, payment.telegram_payment_charge_id
+ )
+ if refunded:
+ await self._db.mark_pending_refunded(payload)
+ note = " Звёзды возвращены." if refunded else ""
+ await message.reply_text("Доступ заблокирован." + note)
+ return
+
charge_id = payment.telegram_payment_charge_id
if payment.currency != "XTR" or payment.total_amount != pending.amount:
refunded = await self._refund_stars(user.id, charge_id)
@@ -641,8 +660,11 @@ class MpakoBot:
if message is None or user is None or self._queue is None:
return False
- is_admin = user.id in self._settings.admin_user_ids
user_row = await self._db.ensure_user(user.id, user.username)
+ is_admin = user_row.is_admin
+ if user_row.is_blocked:
+ await message.reply_text("Доступ заблокирован.")
+ return False
credits = user_row.credits
has_person = False
if (
@@ -1067,6 +1089,10 @@ class MpakoBot:
):
await message.reply_text("Доступ к боту ограничен.")
return False
+ row = await self._db.get_user(user.id)
+ if row is not None and row.is_blocked:
+ await message.reply_text("Доступ заблокирован.")
+ return False
return True
async def _on_error(
diff --git a/checklist.md b/checklist.md
index 5063a2f..a1481f8 100644
--- a/checklist.md
+++ b/checklist.md
@@ -6,9 +6,9 @@
## Текущий статус
-- Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11), README (12), веб-история (13), улучшение промптов (14), модерация (15), оплата Stars (16) и баланс/рефералы (17).
+- Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11), README (12), веб-история (13), улучшение промптов (14), модерация (15), оплата Stars (16), баланс/рефералы (17) и управление пользователями (18).
- Вживую проверены: T2I (Z-Image), I2I (Krea2), WebSocket-прогресс, улучшение промптов, Docker, CI и веб-история.
-- 50 тестов проходят: БД, workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, вспомогательные функции ComfyUI.
+- 52 теста проходят: БД, workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями.
- Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото.
## 0. Допущения и решения по умолчанию
@@ -351,6 +351,16 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
**DoD:** новый пользователь получает 3 генерации, ежедневно +1, друг после первой оплаты приносит рефереру +3.
+## 18. Управление пользователями в веб
+
+- [x] Страница `/users`: поиск, баланс, админ-флаг, блокировка.
+- [x] POST-действия: изменить баланс, назначить/снять админа, заблокировать.
+- [x] Блокировка проверяется ботом при запросах, оплате и callback-выборе.
+- [x] Админы из env сидируются один раз; дальше источник истины — БД.
+- [x] Тесты страницы и действий.
+
+**DoD:** изменения в веб-интерфейсе применяются к боту без рестарта.
+
## Порядок работ
1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow.
diff --git a/db.py b/db.py
index 89e68ce..a475d61 100644
--- a/db.py
+++ b/db.py
@@ -121,6 +121,8 @@ class User:
referrer_id: int | None
first_paid_at: str | None
created_at: str
+ is_admin: bool
+ is_blocked: bool
@property
def display_user(self) -> str:
@@ -136,6 +138,8 @@ class User:
referrer_id=row["referrer_id"],
first_paid_at=row["first_paid_at"],
created_at=row["created_at"],
+ is_admin=bool(row["is_admin"]),
+ is_blocked=bool(row["is_blocked"]),
)
@@ -242,11 +246,18 @@ CREATE TABLE IF NOT EXISTS users (
last_daily_credit TEXT NOT NULL,
referrer_id INTEGER,
first_paid_at TEXT,
- created_at TEXT NOT NULL
+ created_at TEXT NOT NULL,
+ is_admin INTEGER NOT NULL DEFAULT 0,
+ is_blocked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_users_referrer
ON users(referrer_id);
+
+CREATE TABLE IF NOT EXISTS app_settings (
+ key TEXT PRIMARY KEY,
+ value TEXT
+);
"""
@@ -295,6 +306,16 @@ class Database:
await conn.execute(
"ALTER TABLE pending_payments ADD COLUMN is_premium INTEGER NOT NULL DEFAULT 0"
)
+ async with conn.execute("PRAGMA table_info(users)") as cursor:
+ user_columns = {row["name"] async for row in cursor}
+ if "is_admin" not in user_columns:
+ await conn.execute(
+ "ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0"
+ )
+ if "is_blocked" not in user_columns:
+ await conn.execute(
+ "ALTER TABLE users ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0"
+ )
await conn.execute(
"""
INSERT OR IGNORE INTO users
@@ -415,6 +436,78 @@ class Database:
raise RuntimeError("Не удалось прочитать пользователя")
return user
+ async def seed_admins(self, user_ids: frozenset[int] | set[int]) -> None:
+ if not user_ids:
+ return
+ conn = self._connection()
+ async with self._write_lock:
+ async with conn.execute(
+ "SELECT value FROM app_settings WHERE key = 'admins_seeded'"
+ ) as cursor:
+ if await cursor.fetchone() is not None:
+ return
+ today = msk_today().isoformat()
+ for user_id in user_ids:
+ await conn.execute(
+ """
+ INSERT OR IGNORE INTO users
+ (user_id, username, credits, last_daily_credit, created_at)
+ VALUES (?, NULL, 3, ?, ?)
+ """,
+ (user_id, today, utc_now()),
+ )
+ await conn.execute(
+ "UPDATE users SET is_admin = 1 WHERE user_id = ?",
+ (user_id,),
+ )
+ await conn.execute(
+ "INSERT OR REPLACE INTO app_settings (key, value)"
+ " VALUES ('admins_seeded', '1')"
+ )
+ await conn.commit()
+
+ async def list_users_admin(self, limit: int = 500) -> list[User]:
+ conn = self._connection()
+ async with conn.execute(
+ "SELECT * FROM users ORDER BY user_id LIMIT ?", (limit,)
+ ) as cursor:
+ rows = await cursor.fetchall()
+ return [User.from_row(row) for row in rows]
+
+ async def set_admin(self, user_id: int, enabled: bool) -> bool:
+ return await self._update_user_flag(
+ "is_admin", user_id, enabled
+ )
+
+ async def set_blocked(self, user_id: int, enabled: bool) -> bool:
+ return await self._update_user_flag(
+ "is_blocked", user_id, enabled
+ )
+
+ async def set_credits(self, user_id: int, credits: int) -> bool:
+ conn = self._connection()
+ async with self._write_lock:
+ cursor = await conn.execute(
+ "UPDATE users SET credits = ? WHERE user_id = ?",
+ (max(0, credits), user_id),
+ )
+ await conn.commit()
+ return cursor.rowcount > 0
+
+ async def _update_user_flag(
+ self, column: str, user_id: int, enabled: bool
+ ) -> bool:
+ if column not in {"is_admin", "is_blocked"}:
+ raise ValueError("Недопустимое поле")
+ conn = self._connection()
+ async with self._write_lock:
+ cursor = await conn.execute(
+ f"UPDATE users SET {column} = ? WHERE user_id = ?",
+ (int(enabled), user_id),
+ )
+ await conn.commit()
+ return cursor.rowcount > 0
+
async def add_credits(self, user_id: int, amount: int) -> int | None:
conn = self._connection()
async with self._write_lock:
diff --git a/requirements.txt b/requirements.txt
index 76f3e41..3e349ba 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,3 +7,4 @@ fastapi>=0.115,<1
uvicorn>=0.30,<1
jinja2>=3.1,<4
pillow>=10,<13
+python-multipart>=0.0.9,<1
diff --git a/tests/test_web.py b/tests/test_web.py
index 5d664ac..083153a 100644
--- a/tests/test_web.py
+++ b/tests/test_web.py
@@ -48,6 +48,31 @@ def insert_request(
connection.close()
+def insert_user(
+ path: Path,
+ *,
+ user_id: int,
+ username: str | None = "alice",
+ credits: int = 3,
+ is_admin: int = 0,
+ is_blocked: int = 0,
+) -> None:
+ connection = sqlite3.connect(path)
+ try:
+ connection.execute(
+ """
+ INSERT INTO users
+ (user_id, username, credits, last_daily_credit, created_at,
+ is_admin, is_blocked)
+ VALUES (?, ?, ?, '2026-10-18', '2026-10-18T12:00:00+00:00', ?, ?)
+ """,
+ (user_id, username, credits, is_admin, is_blocked),
+ )
+ connection.commit()
+ finally:
+ connection.close()
+
+
def test_history_page_and_user_filter(tmp_path: Path) -> None:
settings = make_settings(tmp_path)
app = create_app(settings)
@@ -112,3 +137,46 @@ def test_detail_and_media(tmp_path: Path) -> None:
missing = client.get(f"/media/{request_id}/input")
assert missing.status_code == 404
+
+
+def test_user_management(tmp_path: Path) -> None:
+ settings = make_settings(tmp_path)
+ app = create_app(settings)
+ with TestClient(app) as client:
+ insert_user(settings.db_path, user_id=1, username="alice")
+ insert_user(settings.db_path, user_id=2, username="bob", credits=0)
+
+ page = client.get("/users")
+ assert page.status_code == 200
+ assert "@alice" in page.text
+ assert "@bob" in page.text
+
+ assert client.post("/users/1/credits", data={"credits": "9"}).status_code == 200
+ assert client.post("/users/1/admin").status_code == 200
+ assert client.post("/users/2/block").status_code == 200
+
+ connection = sqlite3.connect(settings.db_path)
+ try:
+ first = connection.execute(
+ "SELECT credits, is_admin, is_blocked FROM users WHERE user_id = 1"
+ ).fetchone()
+ second = connection.execute(
+ "SELECT is_blocked FROM users WHERE user_id = 2"
+ ).fetchone()
+ finally:
+ connection.close()
+ assert first == (9, 1, 0)
+ assert second == (1,)
+
+
+def test_user_search(tmp_path: Path) -> None:
+ settings = make_settings(tmp_path)
+ app = create_app(settings)
+ with TestClient(app) as client:
+ insert_user(settings.db_path, user_id=1, username="alice")
+ insert_user(settings.db_path, user_id=2, username="bob")
+
+ page = client.get("/users?q=alice")
+ assert page.status_code == 200
+ assert "@alice" in page.text
+ assert "@bob" not in page.text
diff --git a/web.py b/web.py
index fa272ee..2de967c 100644
--- a/web.py
+++ b/web.py
@@ -7,12 +7,12 @@ import os
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
-from typing import Any
+from typing import Annotated, Any
from urllib.parse import urlencode
from uuid import uuid4
-from fastapi import FastAPI, HTTPException, Request
-from fastapi.responses import FileResponse, HTMLResponse
+from fastapi import FastAPI, Form, HTTPException, Request
+from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from PIL import Image, ImageOps
@@ -96,6 +96,53 @@ def create_app(settings: Settings | None = None) -> FastAPI:
async def healthz() -> dict[str, bool]:
return {"ok": True}
+ @app.get("/users", response_class=HTMLResponse)
+ async def users(request: Request, q: str = ""):
+ db: Database = request.app.state.db
+ rows = await db.list_users_admin(1000)
+ needle = q.strip().lower().lstrip("@")
+ if needle:
+ rows = [
+ user
+ for user in rows
+ if needle in str(user.user_id)
+ or (user.username and needle in user.username.lower())
+ ]
+ return templates.TemplateResponse(
+ request=request,
+ name="users.html",
+ context={"users": rows, "q": q},
+ )
+
+ @app.post("/users/{user_id}/credits")
+ async def user_credits(
+ request: Request,
+ user_id: int,
+ credits: Annotated[int, Form()],
+ ) -> RedirectResponse:
+ db: Database = request.app.state.db
+ if not await db.set_credits(user_id, max(0, credits)):
+ raise HTTPException(status_code=404, detail="Пользователь не найден")
+ return RedirectResponse("/users", status_code=303)
+
+ @app.post("/users/{user_id}/admin")
+ async def user_admin(request: Request, user_id: int) -> RedirectResponse:
+ db: Database = request.app.state.db
+ user = await db.get_user(user_id)
+ if user is None:
+ raise HTTPException(status_code=404, detail="Пользователь не найден")
+ await db.set_admin(user_id, not user.is_admin)
+ return RedirectResponse("/users", status_code=303)
+
+ @app.post("/users/{user_id}/block")
+ async def user_block(request: Request, user_id: int) -> RedirectResponse:
+ db: Database = request.app.state.db
+ user = await db.get_user(user_id)
+ if user is None:
+ raise HTTPException(status_code=404, detail="Пользователь не найден")
+ await db.set_blocked(user_id, not user.is_blocked)
+ return RedirectResponse("/users", status_code=303)
+
@app.get("/", response_class=HTMLResponse)
async def index(
request: Request,
diff --git a/web/static/style.css b/web/static/style.css
index b28400e..b130c18 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -35,6 +35,25 @@ a:hover { text-decoration: underline; }
.brand { font-weight: 700; font-size: 18px; color: var(--text); }
.muted { color: var(--muted); font-size: 13px; }
+.nav { display: flex; gap: 16px; margin-left: 8px; }
+.nav a { color: var(--muted); font-size: 14px; }
+.nav a:hover { color: var(--text); }
+
+.page-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+}
+.search { display: flex; gap: 8px; }
+.search input { min-width: 220px; }
+.inline-form { display: inline-flex; gap: 6px; align-items: center; margin-top: 6px; }
+.inline-form button { padding: 4px 8px; font-size: 12px; }
+.credits-input { width: 80px; min-width: 0; padding: 4px 6px; }
+.users-table { min-width: 1050px; }
+.users-table td { vertical-align: top; }
.page { max-width: 1280px; margin: 0 auto; padding: 24px 28px 48px; }
diff --git a/web/templates/base.html b/web/templates/base.html
index 9e6fc00..9818017 100644
--- a/web/templates/base.html
+++ b/web/templates/base.html
@@ -9,6 +9,10 @@
diff --git a/web/templates/users.html b/web/templates/users.html
new file mode 100644
index 0000000..2d635c5
--- /dev/null
+++ b/web/templates/users.html
@@ -0,0 +1,65 @@
+{% extends "base.html" %}
+{% block title %}Пользователи — MpakoImageAIbot{% endblock %}
+{% block content %}
+
+
Пользователи
+
+
+
+
+
+
+
+ | ID |
+ Username |
+ Баланс |
+ Админ |
+ Блокировка |
+ Реферер |
+ Первая оплата |
+ Создан |
+
+
+
+ {% for user in users %}
+
+ |
+ {{ user.user_id }}
+ |
+ {{ user.display_user }} |
+
+
+ |
+
+ {% if user.is_admin %}да{% else %}нет{% endif %}
+
+ |
+
+ {% if user.is_blocked %}заблокирован{% else %}активен{% endif %}
+
+ |
+
+ {% if user.referrer_id %}
+ {{ user.referrer_id }}
+ {% else %}—{% endif %}
+ |
+ {{ user.first_paid_at|fmt_dt }} |
+ {{ user.created_at|fmt_dt }} |
+
+ {% else %}
+ | Пользователей нет |
+ {% endfor %}
+
+
+
+{% endblock %}