Compare commits

..

No commits in common. "d593e35921a277e74e2cd8c1d709b5a931ae8d9a" and "511499a86621f560b4e2745c7f8b8a5a624ac926" have entirely different histories.

13 changed files with 28 additions and 544 deletions

View File

@ -2,9 +2,7 @@
BOT_TOKEN= BOT_TOKEN=
# Прокси для Telegram API. Пусто — без прокси. # Прокси для Telegram API. Пусто — без прокси.
# В Docker на Linux рекомендуется SOCKS5: # В Docker на Linux: http://host.docker.internal:20171
# socks5h://host.docker.internal:20170
# HTTP-вариант: http://host.docker.internal:20171
TELEGRAM_PROXY= TELEGRAM_PROXY=
# 0 — повторять до успеха; иначе число попыток # 0 — повторять до успеха; иначе число попыток
TELEGRAM_RETRY_ATTEMPTS=0 TELEGRAM_RETRY_ATTEMPTS=0

View File

@ -15,7 +15,6 @@ Telegram-бот для генерации и редактирования изо
- **Баланс и рефералы**: 3 стартовые генерации, +1 ежедневно в 00:00 МСК, +3 за первую оплату друга. - **Баланс и рефералы**: 3 стартовые генерации, +1 ежедневно в 00:00 МСК, +3 за первую оплату друга.
- **Управление пользователями**: в веб-интерфейсе можно выдавать админку, менять баланс и блокировать. - **Управление пользователями**: в веб-интерфейсе можно выдавать админку, менять баланс и блокировать.
- **Подтверждение 18+**: без подтверждения возраста бот не принимает запросы. - **Подтверждение 18+**: без подтверждения возраста бот не принимает запросы.
- **Контекст**: следующий текстовый запрос продолжает предыдущую сцену; `/reset` начинает новую.
- Глобальная очередь с одной активной задачей на пользователя. - Глобальная очередь с одной активной задачей на пользователя.
- Позиция в очереди и прогресс генерации через WebSocket ComfyUI. - Позиция в очереди и прогресс генерации через WebSocket ComfyUI.
- PostgreSQL: запросы, статусы, seed, пути к файлам. - PostgreSQL: запросы, статусы, seed, пути к файлам.
@ -49,17 +48,14 @@ Copy-Item .env.example .env
Если сервер не имеет прямого доступа к Telegram, укажи прокси V2RayA/HTTP/SOCKS5: Если сервер не имеет прямого доступа к Telegram, укажи прокси V2RayA/HTTP/SOCKS5:
```dotenv ```dotenv
# Docker на Linux, рекомендуется SOCKS5 # Docker на Linux (V2RayA слушает на хосте)
TELEGRAM_PROXY=socks5h://host.docker.internal:20170 TELEGRAM_PROXY=http://host.docker.internal:20171
# HTTP-вариант
# TELEGRAM_PROXY=http://host.docker.internal:20171
# Запуск без Docker, на том же хосте # Запуск без Docker, на том же хосте
# TELEGRAM_PROXY=socks5h://127.0.0.1:20170 # TELEGRAM_PROXY=http://127.0.0.1:20171
``` ```
SOCKS5 стабильнее держит long polling; зависимость `socksio` уже включена в `requirements.txt`. Для SOCKS5 используй `socks5h://host.docker.internal:20170`; зависимость `socksio` уже включена в `requirements.txt`.
## Запуск ## Запуск
@ -181,7 +177,6 @@ TELEGRAM_HEALTH_INTERVAL=60
- Backoff растёт до `TELEGRAM_RETRY_MAX_DELAY`. - Backoff растёт до `TELEGRAM_RETRY_MAX_DELAY`.
- Критичные отправки (результат, счёт, подтверждение) повторяются бесконечно; обновления статуса — ограниченно, чтобы не плодить зависшие задачи. - Критичные отправки (результат, счёт, подтверждение) повторяются бесконечно; обновления статуса — ограниченно, чтобы не плодить зависшие задачи.
- Раз в `TELEGRAM_HEALTH_INTERVAL` выполняется `getMe`; при сбое проверка повторяется с backoff, пока Telegram не ответит. - Раз в `TELEGRAM_HEALTH_INTERVAL` выполняется `getMe`; при сбое проверка повторяется с backoff, пока Telegram не ответит.
- Watchdog проверяет pending updates: если они не обрабатываются 5 минут, polling перезапускается, а при неудаче контейнер перезапускается автоматически.
## Подтверждение возраста ## Подтверждение возраста
@ -202,22 +197,10 @@ TELEGRAM_HEALTH_INTERVAL=60
- Премиум-очередь всегда оплачивается 15 звёздами; баланс при этом не расходуется. - Премиум-очередь всегда оплачивается 15 звёздами; баланс при этом не расходуется.
- `/balance` — текущий баланс. - `/balance` — текущий баланс.
- `/invite` — реферальная ссылка. - `/invite` — реферальная ссылка.
- `/reset` — сбросить контекст и начать новую сцену.
- За первую оплату приглашённого друга начисляется +3 генерации, реферер получает уведомление. - За первую оплату приглашённого друга начисляется +3 генерации, реферер получает уведомление.
Баланс и рефералы хранятся в таблице `users`. Пропущенные дни начисляются при следующем запуске/полуночи. Баланс и рефералы хранятся в таблице `users`. Пропущенные дни начисляются при следующем запуске/полуночи.
## Контекст
- Первый текстовый запрос создаёт контекст: базовую сцену и текущий улучшенный промпт.
- Следующий текст обрабатывается вместе с предыдущим промптом: бот сохраняет сцену и применяет только запрошенное изменение.
- `/reset` очищает контекст; следующий запрос начнёт новую сцену.
- Новое фото с подписью автоматически начинает новую цепочку.
- Для точного редактирования конкретной картинки ответь на сообщение бота с изображением.
- `/reset` не отменяет уже запущенную генерацию: она завершится со своим снимком контекста.
Контекст хранится в таблице `user_contexts` и обновляется только после успешной отправки результата.
## Оплата ## Оплата
Генерация стоит 5 Telegram Stars в обычной очереди и 15 звёзд в премиум-очереди: Генерация стоит 5 Telegram Stars в обычной очереди и 15 звёзд в премиум-очереди:

178
bot.py
View File

@ -2,14 +2,12 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os
import time import time
import uuid import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from datetime import time as dt_time from datetime import time as dt_time
from pathlib import Path from pathlib import Path
import httpx
from telegram import ( from telegram import (
CopyTextButton, CopyTextButton,
InlineKeyboardButton, InlineKeyboardButton,
@ -27,7 +25,6 @@ from telegram.ext import (
ContextTypes, ContextTypes,
MessageHandler, MessageHandler,
PreCheckoutQueryHandler, PreCheckoutQueryHandler,
TypeHandler,
filters, filters,
) )
@ -41,21 +38,6 @@ from workflows import WorkflowBuilder
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MSK = timezone(timedelta(hours=3)) MSK = timezone(timedelta(hours=3))
POLLING_WATCHDOG_INTERVAL = 60.0
POLLING_WATCHDOG_STUCK_AFTER = 300.0
def polling_is_stuck(
*,
now: float,
last_update_at: float,
pending_update_count: int,
stuck_after: float,
) -> bool:
return (
pending_update_count > 0
and now - last_update_at >= stuck_after
)
START_TEXT = ( START_TEXT = (
"Пришли текст — сгенерирую картинку.\n" "Пришли текст — сгенерирую картинку.\n"
@ -63,8 +45,7 @@ START_TEXT = (
"Или ответь текстом на готовую картинку — возьму её как исходник.\n\n" "Или ответь текстом на готовую картинку — возьму её как исходник.\n\n"
"/balance — баланс генераций.\n" "/balance — баланс генераций.\n"
"/invite — пригласить друга.\n" "/invite — пригласить друга.\n"
"/status — состояние текущей задачи.\n" "/status — состояние текущей задачи."
"/reset — начать новую сцену."
) )
BLOCKED_TEXT = "Запрос нарушает правила. Звёзды не списаны." BLOCKED_TEXT = "Запрос нарушает правила. Звёзды не списаны."
AGE_TEXT = ( AGE_TEXT = (
@ -90,24 +71,17 @@ class MpakoBot:
self._last_edit: dict[int, tuple[float, int]] = {} self._last_edit: dict[int, tuple[float, int]] = {}
self._daily_task: asyncio.Task[None] | None = None self._daily_task: asyncio.Task[None] | None = None
self._health_task: asyncio.Task[None] | None = None self._health_task: asyncio.Task[None] | None = None
self._watchdog_task: asyncio.Task[None] | None = None
self._last_update_at = time.monotonic()
self._application: Application | None = None self._application: Application | None = None
def run(self) -> None: def run(self) -> None:
builder = ( builder = (
Application.builder() Application.builder()
.token(self._settings.bot_token) .token(self._settings.bot_token)
.connect_timeout(10) .connect_timeout(20)
.read_timeout(30) .read_timeout(60)
.write_timeout(30) .write_timeout(60)
.pool_timeout(5) .get_updates_connect_timeout(20)
.connection_pool_size(10) .get_updates_read_timeout(60)
.get_updates_connect_timeout(10)
.get_updates_read_timeout(15)
.get_updates_write_timeout(15)
.get_updates_pool_timeout(5)
.get_updates_connection_pool_size(10)
.post_init(self._post_init) .post_init(self._post_init)
.post_shutdown(self._post_shutdown) .post_shutdown(self._post_shutdown)
) )
@ -117,15 +91,11 @@ class MpakoBot:
) )
application = builder.build() application = builder.build()
self._application = application self._application = application
application.add_handler(
TypeHandler(Update, self._track_update), group=-1
)
application.add_handler(CommandHandler("start", self._start)) application.add_handler(CommandHandler("start", self._start))
application.add_handler(CommandHandler("help", self._help)) application.add_handler(CommandHandler("help", self._help))
application.add_handler(CommandHandler("status", self._status)) application.add_handler(CommandHandler("status", self._status))
application.add_handler(CommandHandler("balance", self._balance)) application.add_handler(CommandHandler("balance", self._balance))
application.add_handler(CommandHandler("invite", self._invite)) application.add_handler(CommandHandler("invite", self._invite))
application.add_handler(CommandHandler("reset", self._reset))
application.add_handler( application.add_handler(
PreCheckoutQueryHandler(self._pre_checkout) PreCheckoutQueryHandler(self._pre_checkout)
) )
@ -166,9 +136,6 @@ class MpakoBot:
self._health_task = asyncio.create_task( self._health_task = asyncio.create_task(
self._telegram_health_loop(), name="telegram-health" self._telegram_health_loop(), name="telegram-health"
) )
self._watchdog_task = asyncio.create_task(
self._polling_watchdog_loop(), name="polling-watchdog"
)
enhancer = ( enhancer = (
PromptEnhancer(self._comfy, self._settings) PromptEnhancer(self._comfy, self._settings)
if self._settings.prompt_enhance_enabled if self._settings.prompt_enhance_enabled
@ -186,10 +153,6 @@ class MpakoBot:
logger.info("Бот запущен, ComfyUI: %s", self._settings.comfy_base_url) logger.info("Бот запущен, ComfyUI: %s", self._settings.comfy_base_url)
async def _post_shutdown(self, application: Application) -> None: async def _post_shutdown(self, application: Application) -> None:
if self._watchdog_task is not None:
self._watchdog_task.cancel()
await asyncio.gather(self._watchdog_task, return_exceptions=True)
self._watchdog_task = None
if self._health_task is not None: if self._health_task is not None:
self._health_task.cancel() self._health_task.cancel()
await asyncio.gather(self._health_task, return_exceptions=True) await asyncio.gather(self._health_task, return_exceptions=True)
@ -205,79 +168,6 @@ class MpakoBot:
await self._db.close() await self._db.close()
logger.info("Бот остановлен") logger.info("Бот остановлен")
async def _track_update(
self, update: Update, context: ContextTypes.DEFAULT_TYPE
) -> None:
self._last_update_at = time.monotonic()
async def _polling_watchdog_loop(self) -> None:
while True:
await asyncio.sleep(POLLING_WATCHDOG_INTERVAL)
pending = await self._pending_updates_count()
if pending is None:
continue
if not polling_is_stuck(
now=time.monotonic(),
last_update_at=self._last_update_at,
pending_update_count=pending,
stuck_after=POLLING_WATCHDOG_STUCK_AFTER,
):
continue
logger.warning(
"Polling watchdog: %s обновлений не обрабатываются,"
" перезапускаю updater",
pending,
)
await self._restart_polling()
async def _pending_updates_count(self) -> int | None:
url = (
"https://api.telegram.org/bot"
f"{self._settings.bot_token}/getWebhookInfo"
)
proxy = self._settings.telegram_proxy or None
try:
async with httpx.AsyncClient(
proxy=proxy,
timeout=httpx.Timeout(10.0, connect=5.0),
) as client:
response = await client.get(url)
data = response.json()
except (httpx.HTTPError, ValueError) as exc:
logger.warning(
"Polling watchdog: проверка не удалась (%s)",
type(exc).__name__,
)
return None
if not data.get("ok"):
return None
result = data.get("result") or {}
return int(result.get("pending_update_count") or 0)
async def _restart_polling(self) -> None:
application = self._application
if application is None or application.updater is None:
return
try:
await asyncio.wait_for(
application.updater.stop(), timeout=15
)
await asyncio.wait_for(
application.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=False,
),
timeout=30,
)
except Exception:
logger.exception(
"Polling watchdog: не удалось перезапустить updater,"
" выхожу для рестарта контейнера"
)
os._exit(1)
self._last_update_at = time.monotonic()
logger.warning("Polling watchdog: updater перезапущен")
async def _daily_credits_loop(self) -> None: async def _daily_credits_loop(self) -> None:
while True: while True:
now = datetime.now(MSK) now = datetime.now(MSK)
@ -493,23 +383,6 @@ class MpakoBot:
reply_markup=keyboard, reply_markup=keyboard,
) )
async def _reset(
self, update: Update, context: ContextTypes.DEFAULT_TYPE
) -> None:
if not await self._check_access(update):
return
message = update.effective_message
user = update.effective_user
if message is None or user is None:
return
cleared = await self._db.clear_user_context(user.id)
text = (
"Контекст сброшен. Следующий запрос начнёт новую сцену."
if cleared
else "Контекст уже пуст."
)
await self._retry(message.reply_text, text)
async def _status( async def _status(
self, update: Update, context: ContextTypes.DEFAULT_TYPE self, update: Update, context: ContextTypes.DEFAULT_TYPE
) -> None: ) -> None:
@ -651,9 +524,6 @@ class MpakoBot:
input_path=pending.input_file_path, input_path=pending.input_file_path,
skip_payment=True, skip_payment=True,
is_premium=False, is_premium=False,
use_context=bool(pending.context_prompt),
context_base_prompt=pending.context_base_prompt,
context_prompt=pending.context_prompt,
) )
if not accepted: if not accepted:
await self._db.add_credits(query.from_user.id, 1) await self._db.add_credits(query.from_user.id, 1)
@ -762,9 +632,6 @@ class MpakoBot:
payment_charge_id=charge_id, payment_charge_id=charge_id,
payment_payload=payload, payment_payload=payload,
is_premium=pending.is_premium, is_premium=pending.is_premium,
use_context=bool(pending.context_prompt),
context_base_prompt=pending.context_base_prompt,
context_prompt=pending.context_prompt,
) )
if accepted: if accepted:
await self._reward_referrer(user) await self._reward_referrer(user)
@ -855,7 +722,6 @@ class MpakoBot:
mode="i2i", mode="i2i",
prompt=caption, prompt=caption,
input_path=str(input_path), input_path=str(input_path),
use_context=False,
) )
if not accepted: if not accepted:
input_path.unlink(missing_ok=True) input_path.unlink(missing_ok=True)
@ -921,9 +787,6 @@ class MpakoBot:
payment_charge_id: str | None = None, payment_charge_id: str | None = None,
payment_payload: str | None = None, payment_payload: str | None = None,
is_premium: bool = False, is_premium: bool = False,
use_context: bool = True,
context_base_prompt: str | None = None,
context_prompt: str | None = None,
) -> bool: ) -> bool:
message = update.effective_message message = update.effective_message
user = update.effective_user user = update.effective_user
@ -943,14 +806,6 @@ class MpakoBot:
if user_row.is_blocked: if user_row.is_blocked:
await message.reply_text("Доступ заблокирован.") await message.reply_text("Доступ заблокирован.")
return False return False
if not use_context:
context_base_prompt = None
context_prompt = None
elif context_prompt is None:
user_context = await self._db.get_user_context(user.id)
if user_context is not None:
context_base_prompt = user_context.base_prompt
context_prompt = user_context.current_prompt
credits = user_row.credits credits = user_row.credits
has_person = False has_person = False
if ( if (
@ -1003,8 +858,6 @@ class MpakoBot:
username=user.username, username=user.username,
input_file_path=input_path, input_file_path=input_path,
amount=0, amount=0,
context_base_prompt=context_base_prompt,
context_prompt=context_prompt,
) )
keyboard = InlineKeyboardMarkup( keyboard = InlineKeyboardMarkup(
[ [
@ -1062,8 +915,6 @@ class MpakoBot:
is_admin=is_admin, is_admin=is_admin,
payment_charge_id=payment_charge_id, payment_charge_id=payment_charge_id,
is_premium=is_premium, is_premium=is_premium,
context_base_prompt=context_base_prompt,
context_prompt=context_prompt,
) )
except PromptBlocked: except PromptBlocked:
note = await self._refund_if_paid( note = await self._refund_if_paid(
@ -1319,23 +1170,6 @@ class MpakoBot:
return return
await self._clear_status(request.id, delete=True) await self._clear_status(request.id, delete=True)
await self._save_context(request)
async def _save_context(self, request: Request) -> None:
current = request.enhanced_prompt or request.prompt_text
base = request.context_base_prompt or current
try:
await self._db.save_user_context(
user_id=request.user_id,
base_prompt=base,
current_prompt=current,
last_request_id=request.id,
last_mode=request.mode,
)
except Exception:
logger.exception(
"Не удалось сохранить контекст запроса %s", request.id
)
async def _finish_failed(self, request: Request, error: str) -> None: async def _finish_failed(self, request: Request, error: str) -> None:
target = self._status_messages.get(request.id) target = self._status_messages.get(request.id)

View File

@ -8,7 +8,7 @@
- Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11), README (12), веб-история (13), улучшение промптов (14), модерация (15), оплата Stars (16), баланс/рефералы (17), управление пользователями (18), возрастной gate (19) и надёжность Telegram (20). - Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11), README (12), веб-история (13), улучшение промптов (14), модерация (15), оплата Stars (16), баланс/рефералы (17), управление пользователями (18), возрастной gate (19) и надёжность Telegram (20).
- Вживую проверены: T2I (Z-Image), I2I (Krea2), WebSocket-прогресс, улучшение промптов, Docker, CI и веб-история. - Вживую проверены: T2I (Z-Image), I2I (Krea2), WebSocket-прогресс, улучшение промптов, Docker, CI и веб-история.
- 62 теста: 38 проходят локально без БД, 24 теста БД выполняются в CI на PostgreSQL через `TEST_DATABASE_URL`. Покрыты workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями, retry, миграция, watchdog, контекст. - 56 тестов: 34 проходят локально без БД, 22 теста БД выполняются в CI на PostgreSQL через `TEST_DATABASE_URL`. Покрыты workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями, retry, миграция.
- Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото. PostgreSQL-миграция и тесты БД ещё не запускались вживую — это сделает CI и первый деплой. - Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото. PostgreSQL-миграция и тесты БД ещё не запускались вживую — это сделает CI и первый деплой.
## 0. Допущения и решения по умолчанию ## 0. Допущения и решения по умолчанию
@ -376,22 +376,10 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
- [x] `_retry` с настраиваемыми попытками, backoff и режимом «до успеха». - [x] `_retry` с настраиваемыми попытками, backoff и режимом «до успеха».
- [x] Health-check `getMe` с повторами. - [x] Health-check `getMe` с повторами.
- [x] Ретраи критичных отправок и загрузок файлов. - [x] Ретраи критичных отправок и загрузок файлов.
- [x] Watchdog polling: если pending updates не убывают 5 минут, updater перезапускается, при неудаче перезапускается контейнер. - [x] Тесты retry.
- [x] Тесты retry и watchdog.
**DoD:** при таймаутах прокси операции повторяются, health-check фиксирует восстановление связи. **DoD:** при таймаутах прокси операции повторяются, health-check фиксирует восстановление связи.
## 21. Контекст между сообщениями
- [x] Таблица `user_contexts`: `base_prompt`, `current_prompt`, `last_request_id`, `last_mode`, `updated_at`.
- [x] Снимок контекста сохраняется в `requests` и `pending_payments`.
- [x] Следующий текстовый запрос обрабатывается вместе с предыдущим промптом.
- [x] Контекст обновляется только после успешной отправки результата.
- [x] `/reset` очищает контекст; новое фото начинает новую цепочку.
- [x] Тесты БД, enhancer и очереди.
**DoD:** продолжение сцены работает по тексту, `/reset` возвращает к новой сцене.
## Порядок работ ## Порядок работ
1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow. 1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow.

113
db.py
View File

@ -61,8 +61,6 @@ class Request:
timestamp: str timestamp: str
prompt_text: str prompt_text: str
enhanced_prompt: str | None enhanced_prompt: str | None
context_base_prompt: str | None
context_prompt: str | None
input_file_path: str | None input_file_path: str | None
output_file_path: str | None output_file_path: str | None
status: str status: str
@ -88,8 +86,6 @@ class Request:
timestamp=_as_iso(row["timestamp"]) or "", timestamp=_as_iso(row["timestamp"]) or "",
prompt_text=row["prompt_text"], prompt_text=row["prompt_text"],
enhanced_prompt=row["enhanced_prompt"], enhanced_prompt=row["enhanced_prompt"],
context_base_prompt=row["context_base_prompt"],
context_prompt=row["context_prompt"],
input_file_path=row["input_file_path"], input_file_path=row["input_file_path"],
output_file_path=row["output_file_path"], output_file_path=row["output_file_path"],
status=row["status"], status=row["status"],
@ -181,8 +177,6 @@ class PendingPayment:
timestamp: str timestamp: str
mode: str mode: str
prompt: str prompt: str
context_base_prompt: str | None
context_prompt: str | None
username: str | None username: str | None
input_file_path: str | None input_file_path: str | None
amount: int amount: int
@ -199,8 +193,6 @@ class PendingPayment:
timestamp=_as_iso(row["timestamp"]) or "", timestamp=_as_iso(row["timestamp"]) or "",
mode=row["mode"], mode=row["mode"],
prompt=row["prompt"], prompt=row["prompt"],
context_base_prompt=row["context_base_prompt"],
context_prompt=row["context_prompt"],
username=row["username"], username=row["username"],
input_file_path=row["input_file_path"], input_file_path=row["input_file_path"],
amount=int(row["amount"]), amount=int(row["amount"]),
@ -211,27 +203,6 @@ class PendingPayment:
) )
@dataclass(frozen=True)
class UserContext:
user_id: int
base_prompt: str
current_prompt: str
last_request_id: int | None
last_mode: str | None
updated_at: str
@classmethod
def from_row(cls, row: Mapping[str, Any]) -> UserContext:
return cls(
user_id=int(row["user_id"]),
base_prompt=row["base_prompt"],
current_prompt=row["current_prompt"],
last_request_id=row["last_request_id"],
last_mode=row["last_mode"],
updated_at=_as_iso(row["updated_at"]) or "",
)
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS requests ( CREATE TABLE IF NOT EXISTS requests (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
@ -240,8 +211,6 @@ CREATE TABLE IF NOT EXISTS requests (
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
prompt_text TEXT NOT NULL, prompt_text TEXT NOT NULL,
enhanced_prompt TEXT, enhanced_prompt TEXT,
context_base_prompt TEXT,
context_prompt TEXT,
input_file_path TEXT, input_file_path TEXT,
output_file_path TEXT, output_file_path TEXT,
status TEXT NOT NULL DEFAULT 'queued' status TEXT NOT NULL DEFAULT 'queued'
@ -290,8 +259,6 @@ CREATE TABLE IF NOT EXISTS pending_payments (
status TEXT NOT NULL DEFAULT 'pending' status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'paid', 'cancelled', 'refunded')), CHECK (status IN ('pending', 'paid', 'cancelled', 'refunded')),
is_premium BOOLEAN NOT NULL DEFAULT FALSE, is_premium BOOLEAN NOT NULL DEFAULT FALSE,
context_base_prompt TEXT,
context_prompt TEXT,
telegram_payment_charge_id TEXT, telegram_payment_charge_id TEXT,
request_id BIGINT request_id BIGINT
); );
@ -316,15 +283,6 @@ CREATE TABLE IF NOT EXISTS users (
CREATE INDEX IF NOT EXISTS idx_users_referrer CREATE INDEX IF NOT EXISTS idx_users_referrer
ON users(referrer_id); ON users(referrer_id);
CREATE TABLE IF NOT EXISTS user_contexts (
user_id BIGINT PRIMARY KEY,
base_prompt TEXT NOT NULL,
current_prompt TEXT NOT NULL,
last_request_id BIGINT,
last_mode TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS app_settings ( CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT value TEXT
@ -336,8 +294,6 @@ SCHEMA_STATEMENTS = [part.strip() for part in SCHEMA.split(";") if part.strip()]
MIGRATIONS = ( MIGRATIONS = (
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS username TEXT", "ALTER TABLE requests ADD COLUMN IF NOT EXISTS username TEXT",
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS enhanced_prompt TEXT", "ALTER TABLE requests ADD COLUMN IF NOT EXISTS enhanced_prompt TEXT",
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS context_base_prompt TEXT",
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS context_prompt TEXT",
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS payment_charge_id TEXT", "ALTER TABLE requests ADD COLUMN IF NOT EXISTS payment_charge_id TEXT",
( (
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS payment_refunded BOOLEAN" "ALTER TABLE requests ADD COLUMN IF NOT EXISTS payment_refunded BOOLEAN"
@ -351,8 +307,6 @@ MIGRATIONS = (
"ALTER TABLE pending_payments ADD COLUMN IF NOT EXISTS is_premium BOOLEAN" "ALTER TABLE pending_payments ADD COLUMN IF NOT EXISTS is_premium BOOLEAN"
" NOT NULL DEFAULT FALSE" " NOT NULL DEFAULT FALSE"
), ),
"ALTER TABLE pending_payments ADD COLUMN IF NOT EXISTS context_base_prompt TEXT",
"ALTER TABLE pending_payments ADD COLUMN IF NOT EXISTS context_prompt TEXT",
( (
"ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN" "ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN"
" NOT NULL DEFAULT FALSE" " NOT NULL DEFAULT FALSE"
@ -448,8 +402,6 @@ class Database:
seed: int, seed: int,
payment_charge_id: str | None = None, payment_charge_id: str | None = None,
is_premium: bool = False, is_premium: bool = False,
context_base_prompt: str | None = None,
context_prompt: str | None = None,
) -> Request: ) -> Request:
try: try:
async with self._connection_pool().connection() as conn: async with self._connection_pool().connection() as conn:
@ -457,9 +409,9 @@ class Database:
""" """
INSERT INTO requests INSERT INTO requests
(user_id, username, timestamp, prompt_text, (user_id, username, timestamp, prompt_text,
context_base_prompt, context_prompt, input_file_path, input_file_path, status, mode, seed, payment_charge_id,
status, mode, seed, payment_charge_id, is_premium) is_premium)
VALUES (%s, %s, %s, %s, %s, %s, %s, 'queued', %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, 'queued', %s, %s, %s, %s)
RETURNING * RETURNING *
""", """,
( (
@ -467,8 +419,6 @@ class Database:
username, username,
utc_now(), utc_now(),
prompt_text, prompt_text,
context_base_prompt,
context_prompt,
input_file_path, input_file_path,
mode, mode,
seed, seed,
@ -512,54 +462,6 @@ class Database:
row = await cursor.fetchone() row = await cursor.fetchone()
return User.from_row(row) if row is not None else None return User.from_row(row) if row is not None else None
async def get_user_context(self, user_id: int) -> UserContext | None:
async with self._connection_pool().connection() as conn:
cursor = await conn.execute(
"SELECT * FROM user_contexts WHERE user_id = %s", (user_id,)
)
row = await cursor.fetchone()
return UserContext.from_row(row) if row is not None else None
async def save_user_context(
self,
*,
user_id: int,
base_prompt: str,
current_prompt: str,
last_request_id: int | None,
last_mode: str | None,
) -> None:
async with self._connection_pool().connection() as conn:
await conn.execute(
"""
INSERT INTO user_contexts
(user_id, base_prompt, current_prompt, last_request_id,
last_mode, updated_at)
VALUES (%s, %s, %s, %s, %s, now())
ON CONFLICT (user_id) DO UPDATE
SET base_prompt = EXCLUDED.base_prompt,
current_prompt = EXCLUDED.current_prompt,
last_request_id = EXCLUDED.last_request_id,
last_mode = EXCLUDED.last_mode,
updated_at = now()
""",
(
user_id,
base_prompt,
current_prompt,
last_request_id,
last_mode,
),
)
async def clear_user_context(self, user_id: int) -> bool:
async with self._connection_pool().connection() as conn:
cursor = await conn.execute(
"DELETE FROM user_contexts WHERE user_id = %s RETURNING user_id",
(user_id,),
)
return await cursor.fetchone() is not None
async def ensure_user( async def ensure_user(
self, user_id: int, username: str | None = None self, user_id: int, username: str | None = None
) -> User: ) -> User:
@ -960,17 +862,14 @@ class Database:
input_file_path: str | None, input_file_path: str | None,
amount: int, amount: int,
is_premium: bool = False, is_premium: bool = False,
context_base_prompt: str | None = None,
context_prompt: str | None = None,
) -> PendingPayment: ) -> PendingPayment:
async with self._connection_pool().connection() as conn: async with self._connection_pool().connection() as conn:
cursor = await conn.execute( cursor = await conn.execute(
""" """
INSERT INTO pending_payments INSERT INTO pending_payments
(payload, user_id, timestamp, mode, prompt, username, (payload, user_id, timestamp, mode, prompt, username,
input_file_path, amount, status, is_premium, input_file_path, amount, status, is_premium)
context_base_prompt, context_prompt) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', %s, %s, %s)
RETURNING * RETURNING *
""", """,
( (
@ -983,8 +882,6 @@ class Database:
input_file_path, input_file_path,
amount, amount,
is_premium, is_premium,
context_base_prompt,
context_prompt,
), ),
) )
row = await cursor.fetchone() row = await cursor.fetchone()

View File

@ -31,23 +31,6 @@ SYSTEM_PROMPTS = {
), ),
} }
REFINE_SYSTEM_PROMPTS = {
"t2i": (
"You are editing an existing image prompt. The previous prompt describes "
"the current image. Apply only the user's requested changes. Keep all "
"subjects, style, colors, composition and constraints from the previous "
"prompt unless the user explicitly asks to change them. Return only the "
"final English prompt, without quotes or explanations."
),
"i2i": (
"You are updating an image-editing instruction. The previous prompt "
"describes the current image. Apply only the user's requested changes. "
"Keep the existing composition, subjects, style, lighting and constraints "
"unless the user explicitly asks to change them. Return only the final "
"English instruction, without quotes or explanations."
),
}
class PromptEnhanceError(RuntimeError): class PromptEnhanceError(RuntimeError):
pass pass
@ -97,35 +80,20 @@ class PromptEnhancer:
) )
return str(node_ids[0]) return str(node_ids[0])
async def enhance( async def enhance(self, prompt: str, mode: str) -> str:
self,
prompt: str,
mode: str,
*,
previous_prompt: str | None = None,
base_prompt: str | None = None,
) -> str:
if not self.enabled: if not self.enabled:
return prompt return prompt
user_prompt = self._sanitize(prompt) user_prompt = prompt.strip()
for token in ("<|im_start|>", "<|im_end|>"):
user_prompt = user_prompt.replace(token, "")
if not user_prompt: if not user_prompt:
return prompt return prompt
previous = self._sanitize(previous_prompt or "")
base = self._sanitize(base_prompt or "")
if previous:
chat_prompt = self._refine_chat_prompt(
user_prompt,
mode,
previous_prompt=previous,
base_prompt=base or previous,
)
else:
chat_prompt = self._chat_prompt(user_prompt, mode)
workflow = copy.deepcopy(self._template) workflow = copy.deepcopy(self._template)
workflow[self._text_node_id]["inputs"]["prompt"] = chat_prompt workflow[self._text_node_id]["inputs"]["prompt"] = self._chat_prompt(
user_prompt, mode
)
prompt_id = await self._comfy.submit(workflow, uuid.uuid4().hex) prompt_id = await self._comfy.submit(workflow, uuid.uuid4().hex)
entry = await self._comfy.wait_for_history( entry = await self._comfy.wait_for_history(
prompt_id, prompt_id,
@ -140,13 +108,6 @@ class PromptEnhancer:
raise PromptEnhanceError("Не удалось очистить сгенерированный промпт") raise PromptEnhanceError("Не удалось очистить сгенерированный промпт")
return cleaned[: self._settings.max_prompt_length] return cleaned[: self._settings.max_prompt_length]
@staticmethod
def _sanitize(text: str) -> str:
value = text.strip()
for token in ("<|im_start|>", "<|im_end|>"):
value = value.replace(token, "")
return value
@staticmethod @staticmethod
def _chat_prompt(prompt: str, mode: str) -> str: def _chat_prompt(prompt: str, mode: str) -> str:
system = SYSTEM_PROMPTS.get(mode, SYSTEM_PROMPTS["t2i"]) system = SYSTEM_PROMPTS.get(mode, SYSTEM_PROMPTS["t2i"])
@ -156,27 +117,6 @@ class PromptEnhancer:
"<|im_start|>assistant\n" "<|im_start|>assistant\n"
) )
@staticmethod
def _refine_chat_prompt(
prompt: str,
mode: str,
*,
previous_prompt: str,
base_prompt: str,
) -> str:
system = REFINE_SYSTEM_PROMPTS.get(
mode, REFINE_SYSTEM_PROMPTS["t2i"]
)
return (
f"<|im_start|>system\n{system}<|im_end|>\n"
"<|im_start|>user\n"
f"Base scene: {base_prompt}\n"
f"Current prompt: {previous_prompt}\n"
f"Requested change: {prompt}\n"
"<|im_end|>\n"
"<|im_start|>assistant\n"
)
@staticmethod @staticmethod
def _clean(text: str) -> str: def _clean(text: str) -> str:
text = " ".join(text.split()).strip() text = " ".join(text.split()).strip()

View File

@ -112,8 +112,6 @@ class QueueManager:
is_admin: bool = False, is_admin: bool = False,
payment_charge_id: str | None = None, payment_charge_id: str | None = None,
is_premium: bool = False, is_premium: bool = False,
context_base_prompt: str | None = None,
context_prompt: str | None = None,
) -> tuple[Request, int]: ) -> tuple[Request, int]:
async with self._lock: async with self._lock:
if self._stopping: if self._stopping:
@ -150,8 +148,6 @@ class QueueManager:
seed=seed, seed=seed,
payment_charge_id=payment_charge_id, payment_charge_id=payment_charge_id,
is_premium=is_premium, is_premium=is_premium,
context_base_prompt=context_base_prompt,
context_prompt=context_prompt,
) )
target = self._premium if is_premium else self._normal target = self._premium if is_premium else self._normal
target.append(request.id) target.append(request.id)
@ -254,31 +250,16 @@ class QueueManager:
) )
try: try:
enhanced = await self._enhancer.enhance( enhanced = await self._enhancer.enhance(
request.prompt_text, request.prompt_text, request.mode
request.mode,
previous_prompt=request.context_prompt,
base_prompt=request.context_base_prompt,
) )
except Exception: except Exception:
logger.exception( logger.exception(
"Не удалось улучшить промпт запроса %s", request_id "Не удалось улучшить промпт запроса %s", request_id
) )
if request.context_prompt:
fallback = (
f"{request.context_prompt}. {request.prompt_text}"
)
await self._db.set_enhanced_prompt(
request_id, fallback
)
request = await self._refresh(request_id)
else: else:
if enhanced and enhanced != request.prompt_text: if enhanced and enhanced != request.prompt_text:
await self._db.set_enhanced_prompt(request_id, enhanced) await self._db.set_enhanced_prompt(request_id, enhanced)
request = await self._refresh(request_id) request = await self._refresh(request_id)
elif request.context_prompt:
combined = f"{request.context_prompt}. {request.prompt_text}"
await self._db.set_enhanced_prompt(request_id, combined)
request = await self._refresh(request_id)
workflow = await self._build_workflow(request) workflow = await self._build_workflow(request)
progress = await self._comfy.open_progress(client_id) progress = await self._comfy.open_progress(client_id)

View File

@ -1,5 +1,5 @@
python-telegram-bot>=21.6,<23 python-telegram-bot>=21.6,<23
httpx[socks]>=0.28,<1 httpx[socks]>=0.27,<1
psycopg[binary]>=3.2,<4 psycopg[binary]>=3.2,<4
psycopg-pool>=3.2,<4 psycopg-pool>=3.2,<4
websockets>=13,<16 websockets>=13,<16

View File

@ -18,7 +18,6 @@ TABLES = (
"blocked_requests", "blocked_requests",
"pending_payments", "pending_payments",
"users", "users",
"user_contexts",
"app_settings", "app_settings",
) )

View File

@ -273,41 +273,6 @@ async def test_user_credits_referral_and_daily_accrual(db: Database) -> None:
assert await db.accrue_daily_credits(future) == 0 assert await db.accrue_daily_credits(future) == 0
async def test_user_context_lifecycle(db: Database) -> None:
assert await db.get_user_context(1) is None
await db.save_user_context(
user_id=1,
base_prompt="A red apple",
current_prompt="A red apple on a table",
last_request_id=10,
last_mode="t2i",
)
context = await db.get_user_context(1)
assert context is not None
assert context.base_prompt == "A red apple"
assert context.current_prompt == "A red apple on a table"
assert context.last_request_id == 10
assert context.last_mode == "t2i"
await db.save_user_context(
user_id=1,
base_prompt="A red apple",
current_prompt="A red apple on a blue table",
last_request_id=11,
last_mode="t2i",
)
context = await db.get_user_context(1)
assert context is not None
assert context.base_prompt == "A red apple"
assert context.current_prompt == "A red apple on a blue table"
assert context.last_request_id == 11
assert await db.clear_user_context(1) is True
assert await db.get_user_context(1) is None
assert await db.clear_user_context(1) is False
async def test_ensure_user_updates_username_without_resetting_credits( async def test_ensure_user_updates_username_without_resetting_credits(
db: Database, db: Database,
) -> None: ) -> None:

View File

@ -62,25 +62,6 @@ async def test_enhance_i2i_prompt() -> None:
assert "image-editing prompt translator" in prompt assert "image-editing prompt translator" in prompt
async def test_enhance_with_context_uses_refine_prompt() -> None:
comfy = FakeEnhanceComfy("A blue apple on a wooden table, soft light")
enhancer = PromptEnhancer(comfy, make_settings())
result = await enhancer.enhance(
"Сделай яблоко синим",
"t2i",
previous_prompt="A red apple on a wooden table, soft light",
base_prompt="A red apple on a wooden table",
)
assert result == "A blue apple on a wooden table, soft light"
assert comfy.workflow is not None
prompt = comfy.workflow["2"]["inputs"]["prompt"]
assert "editing an existing image prompt" in prompt
assert "A red apple on a wooden table, soft light" in prompt
assert "Requested change: Сделай яблоко синим" in prompt
async def test_disabled_returns_original_without_comfy_call() -> None: async def test_disabled_returns_original_without_comfy_call() -> None:
comfy = FakeEnhanceComfy() comfy = FakeEnhanceComfy()
enhancer = PromptEnhancer(comfy, make_settings(enabled=False)) enhancer = PromptEnhancer(comfy, make_settings(enabled=False))

View File

@ -50,18 +50,9 @@ class FakeEnhancer:
def __init__(self) -> None: def __init__(self) -> None:
self.calls: list[tuple[str, str]] = [] self.calls: list[tuple[str, str]] = []
self.contexts: list[tuple[str | None, str | None]] = []
async def enhance( async def enhance(self, prompt: str, mode: str) -> str:
self,
prompt: str,
mode: str,
*,
previous_prompt: str | None = None,
base_prompt: str | None = None,
) -> str:
self.calls.append((prompt, mode)) self.calls.append((prompt, mode))
self.contexts.append((previous_prompt, base_prompt))
return f"enhanced: {prompt}" return f"enhanced: {prompt}"
@ -222,40 +213,6 @@ async def test_prompt_enhancer_is_used(db: Database, tmp_path: Path) -> None:
await queue.stop() await queue.stop()
async def test_context_is_passed_to_enhancer(
db: Database, tmp_path: Path
) -> None:
events: list[QueueEvent] = []
enhancer = FakeEnhancer()
queue = make_queue(
db, make_settings(tmp_path), events, enhancer=enhancer
)
await queue.start()
try:
request, _ = await queue.enqueue(
user_id=1,
mode="t2i",
prompt="make it blue",
context_base_prompt="A red apple on a table",
context_prompt="A red apple on a table, soft light",
)
await wait_for_event(events, "done")
assert enhancer.contexts == [
(
"A red apple on a table, soft light",
"A red apple on a table",
)
]
stored = await db.get_request(request.id)
assert stored is not None
assert stored.context_prompt == "A red apple on a table, soft light"
assert stored.context_base_prompt == "A red apple on a table"
finally:
await queue.stop()
async def test_blocked_prompt_is_logged(db: Database, tmp_path: Path) -> None: async def test_blocked_prompt_is_logged(db: Database, tmp_path: Path) -> None:
events: list[QueueEvent] = [] events: list[QueueEvent] = []
queue = make_queue(db, make_settings(tmp_path), events) queue = make_queue(db, make_settings(tmp_path), events)

View File

@ -1,39 +0,0 @@
from __future__ import annotations
from bot import polling_is_stuck
def test_polling_is_stuck_for_unprocessed_updates() -> None:
assert (
polling_is_stuck(
now=1000.0,
last_update_at=100.0,
pending_update_count=2,
stuck_after=300.0,
)
is True
)
def test_polling_is_not_stuck_without_pending_updates() -> None:
assert (
polling_is_stuck(
now=1000.0,
last_update_at=100.0,
pending_update_count=0,
stuck_after=300.0,
)
is False
)
def test_polling_is_not_stuck_after_recent_update() -> None:
assert (
polling_is_stuck(
now=1000.0,
last_update_at=900.0,
pending_update_count=2,
stuck_after=300.0,
)
is False
)