diff --git a/README.md b/README.md index 25d3b76..1f044cd 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,7 @@ TELEGRAM_HEALTH_INTERVAL=60 - `/invite` — реферальная ссылка. - `/restart` — сбросить контекст и начать новую сцену (алиас `/reset`). - `/settings` — выбор модели Krea2: стандартная Q4 или улучшенная Q6. +- При старте бот проверяет `setMyCommands` и включает кнопку меню с командами. - За первую оплату приглашённого друга начисляется +3 генерации, реферер получает уведомление. Баланс и рефералы хранятся в таблице `users`. Пропущенные дни начисляются при следующем запуске/полуночи. diff --git a/bot.py b/bot.py index e984091..e84cb46 100644 --- a/bot.py +++ b/bot.py @@ -11,11 +11,13 @@ from pathlib import Path import httpx from telegram import ( + BotCommand, CopyTextButton, InlineKeyboardButton, InlineKeyboardMarkup, InputFile, LabeledPrice, + MenuButtonCommands, Update, ) from telegram.constants import ChatAction, ChatType @@ -47,6 +49,19 @@ RESTART_HINT = "/restart — сброс контекста" 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 result_caption(prompt: str, *, limit: int = CAPTION_LIMIT) -> str: value = (prompt or "").strip() suffix = f"\n\n{RESTART_HINT}" @@ -80,6 +95,15 @@ START_TEXT = ( "/restart — сброс контекста.\n" "/settings — качество Krea2." ) +BOT_COMMANDS = ( + BotCommand("start", "Запуск и баланс"), + BotCommand("help", "Инструкция"), + BotCommand("balance", "Баланс генераций"), + BotCommand("invite", "Пригласить друга"), + BotCommand("status", "Статус текущей задачи"), + BotCommand("settings", "Качество Krea2"), + BotCommand("restart", "Сброс контекста"), +) BLOCKED_TEXT = "Запрос нарушает правила. Звёзды не списаны." AGE_TEXT = ( "🔞 Бот доступен только пользователям 18+.\n\n" @@ -170,6 +194,7 @@ class MpakoBot: async def _post_init(self, application: Application) -> None: self._bot = application.bot + await self._setup_commands() self._settings.storage_dir.mkdir(parents=True, exist_ok=True) self._workflows = WorkflowBuilder( self._settings.workflow_t2i, @@ -228,6 +253,31 @@ class MpakoBot: await self._db.close() 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( self, update: Update, context: ContextTypes.DEFAULT_TYPE ) -> None: diff --git a/checklist.md b/checklist.md index 314fae0..5a5bc18 100644 --- a/checklist.md +++ b/checklist.md @@ -8,7 +8,7 @@ - Реализованы блоки 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 и веб-история. -- 75 тестов: 47 проходят локально без БД, 28 тестов БД выполняются в CI на PostgreSQL через `TEST_DATABASE_URL`. Покрыты workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями, retry, миграция, watchdog, контекст, админ-панель, скрытие истории, настройки Krea2. +- 78 тестов: 50 проходят локально без БД, 28 тестов БД выполняются в CI на PostgreSQL через `TEST_DATABASE_URL`. Покрыты workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями, retry, миграция, watchdog, контекст, админ-панель, скрытие истории, настройки Krea2. - Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото. PostgreSQL-миграция и тесты БД ещё не запускались вживую — это сделает CI и первый деплой. ## 0. Допущения и решения по умолчанию @@ -416,6 +416,16 @@ ON requests(user_id) WHERE status IN ('queued', 'running'); **DoD:** выбор сохраняется между запросами, стандартная использует Q4, улучшенная — Q6. +## 24. Команды и меню + +- [x] При старте бот получает `getMyCommands` и обновляет список при расхождении. +- [x] `setMyCommands` для `/start`, `/help`, `/balance`, `/invite`, `/status`, `/settings`, `/restart`. +- [x] `setChatMenuButton` включает `MenuButtonCommands` — кнопка меню слева снизу. +- [x] Ошибки настройки команд не блокируют запуск бота. +- [x] Тесты сравнения команд и идемпотентной настройки. + +**DoD:** после старта в меню бота видны команды, повторный запуск не дублирует настройку. + ## Порядок работ 1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow. diff --git a/tests/test_settings.py b/tests/test_settings.py index 8c04eb6..4b6bc7a 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,9 +1,52 @@ 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 +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: bot = MpakoBot(Settings(bot_token="")) assert callable(bot._settings_command)