feat: reply-клавиатура + фикс пропавший _send_idea
- keyboards.py: MAIN_MENU (✍️ Написать пост / 💡 Идея / 🎨 Мой стиль / 📖 Помощь / 💳 Подписка / 👤 Баланс) и LOCKED_MENU — при блокировке первой кнопкой идёт «💳 Оплатить подписку» - кнопки не обходят роутинг: resolve_button_text превращает их в slash-команду (/help, /balance, /subscribe, /style) или готовый запрос («Напиши пост», «предложи идею…») - новая команда /style показывает сохранённый Tone of Voice и подсказывает, как его настроить - set_my_commands публикует slash-команды при старте, ошибка не роняет бот - fix: _send_idea вызывалась на bot.py, но была потеряна в коммите про подписку — /idea падал с NameError; функция восстановлена - 17 новых тестов клавиатуры; 92 теста OK; VERSION 0.1.0+26
This commit is contained in:
1 parent
3c8917c20a
commit
971bf115da
7 files changed
+312
-11
No files matched your search
+1
-1
@@ -6,5 +6,5 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY bot.py apinex_client.py user_store.py help_message.py payments.py VERSION ./
|
||||
COPY bot.py apinex_client.py user_store.py help_message.py payments.py keyboards.py VERSION ./
|
||||
CMD ["python", "bot.py"]
|
||||
@@ -44,7 +44,7 @@ Telegram-бот, который по запросу пользователя г
|
||||
- Coolify приложение: `social-post-writer-bot-development` (`d32u5zcq2g4odmdpq9hw91yn`) на `spb-easy-dale`.
|
||||
- SQLite: `/app/data/social_writer.sqlite3`; persistent volume `d32u5zcq2g4odmdpq9hw91yn-social-post-writer-dev-data` монтируется в `/app/data`.
|
||||
- Секреты хранятся в переменных окружения приложения Coolify; не сохраняются в репозитории.
|
||||
- `DEPLOY_ENV=development`, `DEV_CHAT_ID` — для уведомления о новой версии; версия в файле `VERSION` (сейчас `0.1.0+25`).
|
||||
- `DEPLOY_ENV=development`, `DEV_CHAT_ID` — для уведомления о новой версии; версия в файле `VERSION` (сейчас `0.1.0+26`).
|
||||
- Вики Gitea: страницы `Home`, `User-Guide`, `Developer-Guide`, `Subscription`, `Deployment`.
|
||||
|
||||
## Статус
|
||||
@@ -72,6 +72,8 @@ Telegram-бот, который по запросу пользователя г
|
||||
- [x] Development deployment/version notice and user-visible error details.
|
||||
- [x] Онбординг после `/start`: welcome Rich Message (`WELCOME_RICH_HTML`) → пауза → вызов `help_command`; правки справки автоматически меняют онбординг
|
||||
- [x] Подписка: триал 3 дня, `/subscribe` за 100 звёзд (30 дней), `/balance`, блокировка генерации без активной подписки
|
||||
- [x] Reply-клавиатура (`keyboards.py`): основное меню при `/start`, отдельное меню с кнопкой оплаты при блокировке, кнопки мапятся на команды и запросы, slash-команды публикуются через `set_my_commands`
|
||||
- [x] Исправлен баг: `_send_idea` была потеряна в коммите про подписку — `/idea` падал с `NameError`
|
||||
- [x] Реальная оплата Stars: `sendInvoice` (XTR) → `pre_checkout_query` → `successful_payment`; подписка активируется только после оплаты, `telegram_payment_charge_id` защищает от двойного начисления
|
||||
- [x] Справка бота (`/help`) описывает `/balance` и `/subscribe`
|
||||
- [x] Проект Gitea «Social Post Writer Bot Roadmap» с задачами (выполненные — в «Готово», будущие — в Backlog)
|
||||
|
||||
@@ -17,6 +17,23 @@ Telegram-бот для генерации постов для соцсетей.
|
||||
|
||||
## Как работает бот (UX)
|
||||
|
||||
### Reply-клавиатура
|
||||
|
||||
Основное меню показывается в `/start`:
|
||||
|
||||
```
|
||||
✍️ Написать пост 💡 Идея
|
||||
🎨 Мой стиль 📖 Помощь
|
||||
💳 Подписка 👤 Баланс
|
||||
```
|
||||
|
||||
Если подписки нет, `_show_subscription_info` ставит `LOCKED_MENU` — с кнопкой «💳 Оплатить подписку»
|
||||
на первом месте. Нажатие кнопки отправляет текст, который `resolve_button_text` превращает
|
||||
либо в slash-команду (`/help`, `/balance`, `/subscribe`, `/style`), либо в готовый запрос
|
||||
(«Напиши пост», «предложи идею для нового поста») — то есть кнопки не обходят роутинг, а идут через него.
|
||||
|
||||
Slash-команды публикуются в Telegram через `set_my_commands` при старте (`set_main_menu`).
|
||||
|
||||
### Онбординг (`/start`)
|
||||
Первое сообщение пользователя запускает онбординг — **два сообщения с паузой 0.8 сек** между ними:
|
||||
|
||||
@@ -59,8 +76,9 @@ bot.py — роутинг, команды, обработка со
|
||||
payments.py — оплата Stars: sendInvoice, pre_checkout_query, successful_payment, защита от двойного списания
|
||||
apinex_client.py — клиент APInex (LLM, STT, vision), промпты, логирование вызовов
|
||||
user_store.py — SQLite: пользователи, ToV, посты, идеи, API-логи, подписки
|
||||
keyboards.py — reply-клавиатура: `MAIN_MENU` (основная) и `LOCKED_MENU` (без подписки), маппинг кнопок на команды
|
||||
help_message.py — Rich Message: `WELCOME_RICH_HTML` (онбординг) + `HELP_SECTIONS`/`help_view()` (справка с кнопками), plain fallback
|
||||
VERSION — версия релиза (сейчас 0.1.0+25)
|
||||
VERSION — версия релиза (сейчас 0.1.0+26)
|
||||
```
|
||||
|
||||
### Основные потоки
|
||||
@@ -198,4 +216,4 @@ SUBSCRIPTION_PRICE_STARS = 100
|
||||
python -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **платежи** (инвойс, pre_checkout, successful_payment, защита от двойного списания), **справка с кнопками** (меню, разделы, callback-навигация, plain fallback), **онбординг** (welcome → вызов `help_command`), **подписки** (6 тестов: таблица, триал после `/start`, оплата, баланс, блокировка, обнуление через `/clear`).
|
||||
Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **reply-клавиатура** (маппинг кнопок, роутинг, `/style`), **платежи** (инвойс, pre_checkout, successful_payment, защита от двойного списания), **справка с кнопками** (меню, разделы, callback-навигация, plain fallback), **онбординг** (welcome → вызов `help_command`), **подписки** (6 тестов: таблица, триал после `/start`, оплата, баланс, блокировка, обнуление через `/clear`).
|
||||
@@ -12,13 +12,15 @@ from aiogram import Bot, Dispatcher, F, Router
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
from aiogram.client.telegram import TEST
|
||||
from aiogram.filters import CommandStart
|
||||
from aiogram.types import CallbackQuery, InputRichMessage, Message
|
||||
from aiogram.types import BotCommand, CallbackQuery, InputRichMessage, Message
|
||||
|
||||
import keyboards
|
||||
import payments
|
||||
import user_store
|
||||
|
||||
from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, create_tone_profile, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, transcribe_audio
|
||||
from help_message import WELCOME_RICH_HTML, help_plain_view, help_view, rich_to_plain
|
||||
from keyboards import LOCKED_MENU, MAIN_MENU, resolve_button_text
|
||||
from payments import send_subscription_invoice
|
||||
from user_store import (
|
||||
add_post_samples,
|
||||
@@ -27,6 +29,7 @@ from user_store import (
|
||||
get_deploy_notice_version,
|
||||
get_idea_history,
|
||||
get_post_history,
|
||||
get_post_samples,
|
||||
get_recent_post_history_text,
|
||||
get_tone_of_voice,
|
||||
init_db,
|
||||
@@ -166,14 +169,19 @@ async def start(message: Message) -> None:
|
||||
return
|
||||
await notify_development_deploy(message.bot)
|
||||
await asyncio.to_thread(ensure_subscription, message.from_user.id)
|
||||
await message.answer("Меню — внизу экрана. По нему можно написать пост, взять идею или открыть справку.")
|
||||
await send_rich_message(message, WELCOME_RICH_HTML, "приветствия")
|
||||
await asyncio.sleep(0.8)
|
||||
await help_command(message)
|
||||
await message.answer("Меню обновлено.", reply_markup=MAIN_MENU)
|
||||
|
||||
|
||||
async def _show_subscription_info(message: Message, sub: sqlite3.Row | None) -> None:
|
||||
if not sub:
|
||||
await message.answer("У тебя нет активной подписки или триала. Начни с команды /subscribe.")
|
||||
await message.answer(
|
||||
"У тебя нет активной подписки или триала. Начни с команды /subscribe.",
|
||||
reply_markup=LOCKED_MENU,
|
||||
)
|
||||
return
|
||||
days_left = user_store._days_until(sub)
|
||||
if days_left >= 0:
|
||||
@@ -190,7 +198,8 @@ async def _show_subscription_info(message: Message, sub: sqlite3.Row | None) ->
|
||||
else:
|
||||
await message.answer(
|
||||
"Твой период (триал или подписка) истёк. "
|
||||
"Оплати /subscribe, чтобы продолжить. Стоимость — 100 звёзд в месяц."
|
||||
"Оплати /subscribe, чтобы продолжить. Стоимость — 100 звёзд в месяц.",
|
||||
reply_markup=LOCKED_MENU,
|
||||
)
|
||||
if sub["last_paid_at"] is None:
|
||||
await message.answer(
|
||||
@@ -230,6 +239,28 @@ async def subscribe_command(message: Message) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def style_command(message: Message) -> None:
|
||||
if message.from_user is None:
|
||||
await message.answer("Не удалось определить пользователя.")
|
||||
return
|
||||
tone = await asyncio.to_thread(get_tone_of_voice, message.from_user.id)
|
||||
samples = await asyncio.to_thread(get_post_samples, message.from_user.id)
|
||||
if not tone and not samples:
|
||||
await message.answer(
|
||||
"Пока не настроен. Пришли скриншоты своих опубликованных постов — "
|
||||
"я распознаю текст, сохраню образцы и построю правила твоего тона.",
|
||||
reply_markup=MAIN_MENU,
|
||||
)
|
||||
return
|
||||
await message.answer(
|
||||
f"<b>Твой стиль</b>\n\n{tone or 'Правила пока не сформированы.'}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=MAIN_MENU,
|
||||
)
|
||||
if samples:
|
||||
await message.answer(f"Образцов сохранено: {len(samples)}.")
|
||||
|
||||
|
||||
async def clear_command(message: Message) -> None:
|
||||
if message.from_user is None:
|
||||
await message.answer("Не удалось определить пользователя.")
|
||||
@@ -262,6 +293,11 @@ async def _check_subscription(message: Message) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def _send_idea(message: Message, idea: dict[str, str], user_id: int) -> None:
|
||||
await message.answer(f"{idea['title']}\n\n{idea['angle']}\n\nЗачем: {idea['why']}")
|
||||
await asyncio.to_thread(save_idea, user_id, idea)
|
||||
|
||||
|
||||
async def _send_three_posts(message: Message, posts: list[str], user_id: int | None = None, origin: str = "generated") -> None:
|
||||
for post in posts:
|
||||
await message.answer(post)
|
||||
@@ -498,19 +534,27 @@ async def transcribe_voice(message: Message, bot: Bot) -> None:
|
||||
|
||||
@router.message(F.text & F.text.startswith("/"))
|
||||
async def handle_command_text(message: Message) -> None:
|
||||
command = (message.text or "").strip().split(maxsplit=1)[0].split("@", 1)[0].casefold()
|
||||
raw = (message.text or "").strip()
|
||||
# Кнопки reply-клавиатуры отправляют текст, который мапится на команду/запрос.
|
||||
resolved = resolve_button_text(raw) or raw
|
||||
command = resolved.split(maxsplit=1)[0].split("@", 1)[0].casefold()
|
||||
if command == "/help":
|
||||
await help_command(message)
|
||||
elif command == "/start":
|
||||
await start(message)
|
||||
elif command == "/clear":
|
||||
await clear_command(message)
|
||||
elif command == "/style":
|
||||
await style_command(message)
|
||||
elif command == "/idea":
|
||||
await _classify_and_respond(message, "предложи идею для нового поста")
|
||||
elif command == "/balance":
|
||||
await balance_command(message)
|
||||
elif command == "/subscribe":
|
||||
await subscribe_command(message)
|
||||
elif resolved != raw:
|
||||
# Это была кнопка меню, а не команда — отправляем её как обычный запрос.
|
||||
await _classify_and_respond(message, resolved)
|
||||
else:
|
||||
await message.answer("Неизвестная команда. Используй /help.")
|
||||
|
||||
@@ -518,8 +562,33 @@ async def handle_command_text(message: Message) -> None:
|
||||
@router.message(F.text & ~F.text.startswith("/"))
|
||||
async def handle_text(message: Message) -> None:
|
||||
await notify_development_deploy(message.bot)
|
||||
if message.text:
|
||||
await _classify_and_respond(message, message.text)
|
||||
text = (message.text or "").strip()
|
||||
if not text:
|
||||
return
|
||||
button = resolve_button_text(text)
|
||||
if button:
|
||||
if button.startswith("/"):
|
||||
await handle_command_text(message)
|
||||
return
|
||||
text = button
|
||||
await _classify_and_respond(message, text)
|
||||
|
||||
|
||||
async def set_main_menu(bot: Bot) -> None:
|
||||
"""Публикует reply-клавиатуру для всех пользователей бота."""
|
||||
try:
|
||||
await bot.set_my_commands(
|
||||
commands=[
|
||||
BotCommand(command="help", description="Справка по боту"),
|
||||
BotCommand(command="style", description="Мой Tone of Voice"),
|
||||
BotCommand(command="idea", description="Идея для поста"),
|
||||
BotCommand(command="balance", description="Статус подписки"),
|
||||
BotCommand(command="subscribe", description="Оплатить подписку"),
|
||||
BotCommand(command="clear", description="Сбросить мои данные"),
|
||||
]
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Bot commands publish failed (%s): %s", type(exc).__name__, str(exc)[:120])
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -536,6 +605,8 @@ async def main() -> None:
|
||||
dp = Dispatcher()
|
||||
dp.include_router(payments.router)
|
||||
dp.include_router(router)
|
||||
# Ставим меню один раз при старте, чтобы оно было у всех, кто писал боту.
|
||||
await set_main_menu(bot)
|
||||
|
||||
try:
|
||||
await dp.start_polling(bot)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Reply-клавиатура бота: быстрый доступ к главным действиям.
|
||||
|
||||
Кнопки отправляют те же тексты, что и slash-команды, поэтому роутинг в
|
||||
`handle_command_text` обрабатывает их без изменений.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from aiogram.types import (
|
||||
KeyboardButton,
|
||||
ReplyKeyboardMarkup,
|
||||
ReplyKeyboardRemove,
|
||||
)
|
||||
|
||||
from user_store import SUBSCRIPTION_PRICE_STARS
|
||||
|
||||
# Основное меню. Текст кнопки = текст команды, который обрабатывает handle_command_text.
|
||||
MAIN_MENU = ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text="✍️ Написать пост"),
|
||||
KeyboardButton(text="💡 Идея"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="🎨 Мой стиль"),
|
||||
KeyboardButton(text="📖 Помощь"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="💳 Подписка"),
|
||||
KeyboardButton(text="👤 Баланс"),
|
||||
],
|
||||
],
|
||||
resize_keyboard=True,
|
||||
input_field_placeholder="Напиши тему поста или нажми кнопку",
|
||||
)
|
||||
|
||||
# Меню без подписки: главное — оплатить, а не генерация.
|
||||
LOCKED_MENU = ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text="💳 Оплатить подписку"),
|
||||
KeyboardButton(text="👤 Баланс"),
|
||||
],
|
||||
[KeyboardButton(text="📖 Помощь")],
|
||||
],
|
||||
resize_keyboard=True,
|
||||
input_field_placeholder="Нужна подписка для генерации",
|
||||
)
|
||||
|
||||
# Соответствие подписей кнопок текстам, которые понимает бот.
|
||||
BUTTON_TEXT_MAP: dict[str, str] = {
|
||||
"✍️ Написать пост": "Напиши пост",
|
||||
"💡 Идея": "предложи идею для нового поста",
|
||||
"🎨 Мой стиль": "/style",
|
||||
"📖 Помощь": "/help",
|
||||
"💳 Подписка": "/subscribe",
|
||||
"👤 Баланс": "/balance",
|
||||
"💳 Оплатить подписку": "/subscribe",
|
||||
"🗑 Сбросить всё": "/clear",
|
||||
}
|
||||
|
||||
|
||||
def resolve_button_text(text: str | None) -> str | None:
|
||||
"""Возвращает команду или текст запроса для нажатой кнопки, иначе None."""
|
||||
if not text:
|
||||
return None
|
||||
return BUTTON_TEXT_MAP.get(text.strip())
|
||||
|
||||
|
||||
def price_hint() -> str:
|
||||
return f"Подписка {SUBSCRIPTION_PRICE_STARS} звёзд — кнопка «{LOCKED_MENU.keyboard[0][0].text}»."
|
||||
@@ -0,0 +1,140 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import bot
|
||||
import keyboards
|
||||
|
||||
|
||||
class ButtonTextTests(unittest.TestCase):
|
||||
def test_buttons_map_to_commands_and_requests(self):
|
||||
self.assertEqual(keyboards.resolve_button_text("📖 Помощь"), "/help")
|
||||
self.assertEqual(keyboards.resolve_button_text("👤 Баланс"), "/balance")
|
||||
self.assertEqual(keyboards.resolve_button_text("💳 Подписка"), "/subscribe")
|
||||
self.assertEqual(keyboards.resolve_button_text("🎨 Мой стиль"), "/style")
|
||||
|
||||
def test_idea_button_sends_request_not_command(self):
|
||||
self.assertEqual(
|
||||
keyboards.resolve_button_text("💡 Идея"),
|
||||
"предложи идею для нового поста",
|
||||
)
|
||||
|
||||
def test_regular_text_is_not_treated_as_button(self):
|
||||
self.assertIsNone(keyboards.resolve_button_text("Напиши пост про запуск"))
|
||||
self.assertIsNone(keyboards.resolve_button_text("привет"))
|
||||
self.assertIsNone(keyboards.resolve_button_text(None))
|
||||
|
||||
def test_button_lookup_tolerates_whitespace(self):
|
||||
self.assertEqual(keyboards.resolve_button_text(" 📖 Помощь "), "/help")
|
||||
|
||||
|
||||
class KeyboardLayoutTests(unittest.TestCase):
|
||||
def test_main_menu_has_core_actions(self):
|
||||
labels = [b.text for row in keyboards.MAIN_MENU.keyboard for b in row]
|
||||
for expected in ("✍️ Написать пост", "💡 Идея", "🎨 Мой стиль", "📖 Помощь"):
|
||||
self.assertIn(expected, labels)
|
||||
|
||||
def test_locked_menu_offers_payment_first(self):
|
||||
self.assertEqual(keyboards.LOCKED_MENU.keyboard[0][0].text, "💳 Оплатить подписку")
|
||||
|
||||
def test_every_button_is_resolvable(self):
|
||||
for markup in (keyboards.MAIN_MENU, keyboards.LOCKED_MENU):
|
||||
for row in markup.keyboard:
|
||||
for button in row:
|
||||
self.assertIsNotNone(
|
||||
keyboards.resolve_button_text(button.text), button.text
|
||||
)
|
||||
|
||||
def test_keyboards_are_resize_keyboard(self):
|
||||
self.assertTrue(keyboards.MAIN_MENU.resize_keyboard)
|
||||
self.assertTrue(keyboards.LOCKED_MENU.resize_keyboard)
|
||||
|
||||
|
||||
class _StubMessage:
|
||||
"""Минимальный Message: нужны from_user, text и собираемые ответы."""
|
||||
|
||||
def __init__(self, text=None, user_id=42, chat_id=555):
|
||||
self.text = text
|
||||
self.from_user = SimpleNamespace(id=user_id)
|
||||
self.chat = SimpleNamespace(id=chat_id)
|
||||
self.message_thread_id = None
|
||||
self.bot = AsyncMock()
|
||||
self.answers: list[tuple[str, dict]] = []
|
||||
|
||||
async def answer(self, text, **kwargs):
|
||||
self.answers.append((text, kwargs))
|
||||
|
||||
|
||||
class StyleCommandTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_style_without_tone_explains_how_to_set_it(self):
|
||||
message = _StubMessage()
|
||||
with patch.object(bot.asyncio, "to_thread", new=AsyncMock(side_effect=["", []])):
|
||||
await bot.style_command(message)
|
||||
self.assertIn("скриншот", message.answers[0][0])
|
||||
|
||||
async def test_style_shows_saved_tone(self):
|
||||
message = _StubMessage()
|
||||
with patch.object(bot.asyncio, "to_thread", new=AsyncMock(side_effect=["Короткие фразы", ["a", "b"]])):
|
||||
await bot.style_command(message)
|
||||
self.assertIn("Короткие фразы", message.answers[0][0])
|
||||
self.assertIn("Образцов сохранено: 2", message.answers[1][0])
|
||||
|
||||
|
||||
class ButtonRoutingTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_button_with_command_routes_to_command_handler(self):
|
||||
message = _StubMessage("📖 Помощь")
|
||||
with patch.object(bot, "help_command", new=AsyncMock()) as help_cmd:
|
||||
await bot.handle_text(message)
|
||||
help_cmd.assert_awaited_once_with(message)
|
||||
|
||||
async def test_button_with_request_goes_to_classifier(self):
|
||||
message = _StubMessage("💡 Идея")
|
||||
with (
|
||||
patch.object(bot, "notify_development_deploy", new=AsyncMock()),
|
||||
patch.object(bot, "_classify_and_respond", new=AsyncMock()) as classify,
|
||||
):
|
||||
await bot.handle_text(message)
|
||||
classify.assert_awaited_once_with(message, "предложи идею для нового поста")
|
||||
|
||||
async def test_write_post_button_sends_topic_request(self):
|
||||
message = _StubMessage("✍️ Написать пост")
|
||||
with (
|
||||
patch.object(bot, "notify_development_deploy", new=AsyncMock()),
|
||||
patch.object(bot, "_classify_and_respond", new=AsyncMock()) as classify,
|
||||
):
|
||||
await bot.handle_text(message)
|
||||
classify.assert_awaited_once_with(message, "Напиши пост")
|
||||
|
||||
async def test_plain_text_is_not_rewritten(self):
|
||||
message = _StubMessage("Напиши пост про запуск продукта")
|
||||
with (
|
||||
patch.object(bot, "notify_development_deploy", new=AsyncMock()),
|
||||
patch.object(bot, "_classify_and_respond", new=AsyncMock()) as classify,
|
||||
):
|
||||
await bot.handle_text(message)
|
||||
classify.assert_awaited_once_with(message, "Напиши пост про запуск продукта")
|
||||
|
||||
async def test_style_command_is_routed(self):
|
||||
message = _StubMessage("/style")
|
||||
with patch.object(bot, "style_command", new=AsyncMock()) as style:
|
||||
await bot.handle_command_text(message)
|
||||
style.assert_awaited_once_with(message)
|
||||
|
||||
|
||||
class MenuPublishTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_commands_are_published_to_telegram(self):
|
||||
fake_bot = AsyncMock()
|
||||
await bot.set_main_menu(fake_bot)
|
||||
names = [c.command for c in fake_bot.set_my_commands.await_args.kwargs["commands"]]
|
||||
self.assertIn("help", names)
|
||||
self.assertIn("subscribe", names)
|
||||
self.assertIn("style", names)
|
||||
|
||||
async def test_publish_failure_does_not_crash_startup(self):
|
||||
fake_bot = AsyncMock()
|
||||
fake_bot.set_my_commands.side_effect = RuntimeError("boom")
|
||||
await bot.set_main_menu(fake_bot)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user