Compare commits

..

2 Commits

Author SHA1 Message Date
MpakobecEx 6bbd9cc04a Команда /context
Build and push Docker image / tests (push) Successful in 14s Details
Build and push Docker image / docker (push) Successful in 9s Details
Показывает текущий промпт, базовую сцену, последний запрос и время обновления контекста.
2026-10-20 01:25:42 +05:00
MpakobecEx bb096dec47 Команды и меню при старте
Проверяю getMyCommands, обновляю список при расхождении и включаю MenuButtonCommands. Ошибки настройки не блокируют запуск бота.
2026-10-20 01:24:43 +05:00
5 changed files with 185 additions and 3 deletions

View File

@ -204,7 +204,9 @@ TELEGRAM_HEALTH_INTERVAL=60
- `/balance` — текущий баланс. - `/balance` — текущий баланс.
- `/invite` — реферальная ссылка. - `/invite` — реферальная ссылка.
- `/restart` — сбросить контекст и начать новую сцену (алиас `/reset`). - `/restart` — сбросить контекст и начать новую сцену (алиас `/reset`).
- `/context` — показать текущий контекст: промпт, базовую сцену и последний запрос.
- `/settings` — выбор модели Krea2: стандартная Q4 или улучшенная Q6. - `/settings` — выбор модели Krea2: стандартная Q4 или улучшенная Q6.
- При старте бот проверяет `setMyCommands` и включает кнопку меню с командами.
- За первую оплату приглашённого друга начисляется +3 генерации, реферер получает уведомление. - За первую оплату приглашённого друга начисляется +3 генерации, реферер получает уведомление.
Баланс и рефералы хранятся в таблице `users`. Пропущенные дни начисляются при следующем запуске/полуночи. Баланс и рефералы хранятся в таблице `users`. Пропущенные дни начисляются при следующем запуске/полуночи.
@ -214,6 +216,7 @@ TELEGRAM_HEALTH_INTERVAL=60
- Первый текстовый запрос создаёт контекст: базовую сцену и текущий улучшенный промпт. - Первый текстовый запрос создаёт контекст: базовую сцену и текущий улучшенный промпт.
- Следующий текст обрабатывается вместе с предыдущим промптом: бот сохраняет сцену и применяет только запрошенное изменение. - Следующий текст обрабатывается вместе с предыдущим промптом: бот сохраняет сцену и применяет только запрошенное изменение.
- `/restart` очищает контекст; следующий запрос начнёт новую сцену. - `/restart` очищает контекст; следующий запрос начнёт новую сцену.
- `/context` показывает текущий промпт, базовую сцену и последний запрос.
- Новое фото с подписью автоматически начинает новую цепочку. - Новое фото с подписью автоматически начинает новую цепочку.
- Для точного редактирования конкретной картинки ответь на сообщение бота с изображением. - Для точного редактирования конкретной картинки ответь на сообщение бота с изображением.
- `/restart` не отменяет уже запущенную генерацию: она завершится со своим снимком контекста. - `/restart` не отменяет уже запущенную генерацию: она завершится со своим снимком контекста.

99
bot.py
View File

@ -11,11 +11,13 @@ from pathlib import Path
import httpx import httpx
from telegram import ( from telegram import (
BotCommand,
CopyTextButton, CopyTextButton,
InlineKeyboardButton, InlineKeyboardButton,
InlineKeyboardMarkup, InlineKeyboardMarkup,
InputFile, InputFile,
LabeledPrice, LabeledPrice,
MenuButtonCommands,
Update, Update,
) )
from telegram.constants import ChatAction, ChatType from telegram.constants import ChatAction, ChatType
@ -33,7 +35,7 @@ from telegram.ext import (
from comfy_client import ComfyClient from comfy_client import ComfyClient
from config import Settings, setup_logging from config import Settings, setup_logging
from db import Database, Request, UserAlreadyQueued from db import Database, Request, UserAlreadyQueued, UserContext
from moderation import PromptBlocked, detect_person, is_prompt_allowed from moderation import PromptBlocked, detect_person, is_prompt_allowed
from prompt_enhancer import PromptEnhancer from prompt_enhancer import PromptEnhancer
from queue_manager import QueueEvent, QueueFull, QueueManager from queue_manager import QueueEvent, QueueFull, QueueManager
@ -47,6 +49,47 @@ RESTART_HINT = "/restart — сброс контекста"
CAPTION_LIMIT = 1024 CAPTION_LIMIT = 1024
def commands_need_update(
current: tuple[BotCommand, ...] | list[BotCommand],
desired: tuple[BotCommand, ...] | list[BotCommand],
) -> bool:
current_pairs = [
(command.command, command.description) for command in current
]
desired_pairs = [
(command.command, command.description) for command in desired
]
return current_pairs != desired_pairs
def format_context(context: UserContext | None) -> str:
if context is None:
return "Контекст пуст. Следующий запрос начнёт новую сцену."
parts = ["Текущий контекст:", "", f"Промпт: {context.current_prompt}"]
if (
context.base_prompt
and context.base_prompt != context.current_prompt
):
parts.extend(["", f"Базовая сцена: {context.base_prompt}"])
if context.last_request_id is not None:
mode = f" ({context.last_mode})" if context.last_mode else ""
parts.append(
f"Последний запрос: #{context.last_request_id}{mode}"
)
if context.updated_at:
try:
updated = datetime.fromisoformat(
context.updated_at
).astimezone(MSK)
except ValueError:
pass
else:
parts.append(f"Обновлён: {updated.strftime('%d.%m.%Y %H:%M')}")
parts.extend(["", "Сбросить: /restart"])
return "\n".join(parts)
def result_caption(prompt: str, *, limit: int = CAPTION_LIMIT) -> str: def result_caption(prompt: str, *, limit: int = CAPTION_LIMIT) -> str:
value = (prompt or "").strip() value = (prompt or "").strip()
suffix = f"\n\n{RESTART_HINT}" suffix = f"\n\n{RESTART_HINT}"
@ -78,8 +121,19 @@ START_TEXT = (
"/invite — пригласить друга.\n" "/invite — пригласить друга.\n"
"/status — состояние текущей задачи.\n" "/status — состояние текущей задачи.\n"
"/restart — сброс контекста.\n" "/restart — сброс контекста.\n"
"/context — текущий контекст.\n"
"/settings — качество Krea2." "/settings — качество Krea2."
) )
BOT_COMMANDS = (
BotCommand("start", "Запуск и баланс"),
BotCommand("help", "Инструкция"),
BotCommand("balance", "Баланс генераций"),
BotCommand("invite", "Пригласить друга"),
BotCommand("status", "Статус текущей задачи"),
BotCommand("context", "Текущий контекст"),
BotCommand("settings", "Качество Krea2"),
BotCommand("restart", "Сброс контекста"),
)
BLOCKED_TEXT = "Запрос нарушает правила. Звёзды не списаны." BLOCKED_TEXT = "Запрос нарушает правила. Звёзды не списаны."
AGE_TEXT = ( AGE_TEXT = (
"🔞 Бот доступен только пользователям 18+.\n\n" "🔞 Бот доступен только пользователям 18+.\n\n"
@ -139,6 +193,9 @@ class MpakoBot:
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("context", self._context_command)
)
application.add_handler( application.add_handler(
CommandHandler("settings", self._settings_command) CommandHandler("settings", self._settings_command)
) )
@ -170,6 +227,7 @@ class MpakoBot:
async def _post_init(self, application: Application) -> None: async def _post_init(self, application: Application) -> None:
self._bot = application.bot self._bot = application.bot
await self._setup_commands()
self._settings.storage_dir.mkdir(parents=True, exist_ok=True) self._settings.storage_dir.mkdir(parents=True, exist_ok=True)
self._workflows = WorkflowBuilder( self._workflows = WorkflowBuilder(
self._settings.workflow_t2i, self._settings.workflow_t2i,
@ -228,6 +286,31 @@ class MpakoBot:
await self._db.close() await self._db.close()
logger.info("Бот остановлен") logger.info("Бот остановлен")
async def _setup_commands(self) -> None:
if self._bot is None:
return
try:
current = await self._retry(
self._bot.get_my_commands, attempts=3
)
if commands_need_update(current, BOT_COMMANDS):
await self._retry(
self._bot.set_my_commands, BOT_COMMANDS, attempts=3
)
logger.info("Команды бота обновлены")
menu = await self._retry(
self._bot.get_chat_menu_button, attempts=3
)
if not isinstance(menu, MenuButtonCommands):
await self._retry(
self._bot.set_chat_menu_button,
menu_button=MenuButtonCommands(),
attempts=3,
)
logger.info("Кнопка меню с командами включена")
except TelegramError:
logger.exception("Не удалось настроить команды бота")
async def _track_update( async def _track_update(
self, update: Update, context: ContextTypes.DEFAULT_TYPE self, update: Update, context: ContextTypes.DEFAULT_TYPE
) -> None: ) -> None:
@ -533,6 +616,20 @@ class MpakoBot:
) )
await self._retry(message.reply_text, text) await self._retry(message.reply_text, text)
async def _context_command(
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
user_context = await self._db.get_user_context(user.id)
await self._retry(
message.reply_text, format_context(user_context)
)
@staticmethod @staticmethod
def _quality_keyboard(quality: str) -> InlineKeyboardMarkup: def _quality_keyboard(quality: str) -> InlineKeyboardMarkup:
def label(name: str, value: str) -> str: def label(name: str, value: str) -> str:

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 и веб-история.
- 75 тестов: 47 проходят локально без БД, 28 тестов БД выполняются в CI на PostgreSQL через `TEST_DATABASE_URL`. Покрыты workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями, retry, миграция, watchdog, контекст, админ-панель, скрытие истории, настройки Krea2. - 80 тестов: 52 проходят локально без БД, 28 тестов БД выполняются в CI на PostgreSQL через `TEST_DATABASE_URL`. Покрыты workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями, retry, миграция, watchdog, контекст, админ-панель, скрытие истории, настройки Krea2.
- Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото. PostgreSQL-миграция и тесты БД ещё не запускались вживую — это сделает CI и первый деплой. - Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото. PostgreSQL-миграция и тесты БД ещё не запускались вживую — это сделает CI и первый деплой.
## 0. Допущения и решения по умолчанию ## 0. Допущения и решения по умолчанию
@ -388,6 +388,7 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
- [x] Следующий текстовый запрос обрабатывается вместе с предыдущим промптом. - [x] Следующий текстовый запрос обрабатывается вместе с предыдущим промптом.
- [x] Контекст обновляется только после успешной отправки результата. - [x] Контекст обновляется только после успешной отправки результата.
- [x] `/restart` очищает контекст; новое фото начинает новую цепочку. - [x] `/restart` очищает контекст; новое фото начинает новую цепочку.
- [x] `/context` показывает текущий промпт, базовую сцену и последний запрос.
- [x] В подписи результата итоговый промпт и подсказка `/restart`. - [x] В подписи результата итоговый промпт и подсказка `/restart`.
- [x] Тесты БД, enhancer, очереди и подписи. - [x] Тесты БД, enhancer, очереди и подписи.
@ -416,6 +417,16 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
**DoD:** выбор сохраняется между запросами, стандартная использует Q4, улучшенная — Q6. **DoD:** выбор сохраняется между запросами, стандартная использует Q4, улучшенная — Q6.
## 24. Команды и меню
- [x] При старте бот получает `getMyCommands` и обновляет список при расхождении.
- [x] `setMyCommands` для `/start`, `/help`, `/balance`, `/invite`, `/status`, `/context`, `/settings`, `/restart`.
- [x] `setChatMenuButton` включает `MenuButtonCommands` — кнопка меню слева снизу.
- [x] Ошибки настройки команд не блокируют запуск бота.
- [x] Тесты сравнения команд и идемпотентной настройки.
**DoD:** после старта в меню бота видны команды, повторный запуск не дублирует настройку.
## Порядок работ ## Порядок работ
1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow. 1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow.

28
tests/test_context.py Normal file
View File

@ -0,0 +1,28 @@
from __future__ import annotations
from bot import format_context
from db import UserContext
def test_format_context_empty() -> None:
text = format_context(None)
assert "Контекст пуст" in text
def test_format_context_shows_prompt_and_reset_hint() -> None:
context = UserContext(
user_id=1,
base_prompt="A red apple on a table",
current_prompt="A blue apple on a table",
last_request_id=42,
last_mode="t2i",
updated_at="2026-10-19T12:00:00+00:00",
)
text = format_context(context)
assert "A blue apple on a table" in text
assert "A red apple on a table" in text
assert "Последний запрос: #42 (t2i)" in text
assert "Обновлён: 19.10.2026 15:00" in text
assert "/restart" in text

View File

@ -1,9 +1,52 @@
from __future__ import annotations from __future__ import annotations
from bot import MpakoBot from telegram import BotCommand, MenuButtonCommands, MenuButtonDefault
from bot import BOT_COMMANDS, MpakoBot, commands_need_update
from config import Settings from config import Settings
class FakeCommandsBot:
def __init__(self) -> None:
self.commands: tuple[BotCommand, ...] = ()
self.menu = MenuButtonDefault()
self.set_commands_calls = 0
async def get_my_commands(self):
return self.commands
async def set_my_commands(self, commands, **kwargs):
self.commands = tuple(commands)
self.set_commands_calls += 1
return True
async def get_chat_menu_button(self):
return self.menu
async def set_chat_menu_button(self, menu_button=None, **kwargs):
self.menu = menu_button
return True
def test_commands_need_update() -> None:
assert commands_need_update(BOT_COMMANDS, BOT_COMMANDS) is False
assert commands_need_update((), BOT_COMMANDS) is True
async def test_setup_commands_sets_commands_and_menu_once() -> None:
bot = MpakoBot(Settings(bot_token=""))
fake = FakeCommandsBot()
bot._bot = fake # type: ignore[assignment]
await bot._setup_commands()
assert fake.commands == BOT_COMMANDS
assert isinstance(fake.menu, MenuButtonCommands)
assert fake.set_commands_calls == 1
await bot._setup_commands()
assert fake.set_commands_calls == 1
def test_settings_command_is_not_shadowed_by_settings_attribute() -> None: def test_settings_command_is_not_shadowed_by_settings_attribute() -> None:
bot = MpakoBot(Settings(bot_token="")) bot = MpakoBot(Settings(bot_token=""))
assert callable(bot._settings_command) assert callable(bot._settings_command)