feat: /help разбита на разделы с навигацией инлайн-кнопками (aiogram 3.31.0)
- requirements.txt: aiogram 3.26.0 -> 3.31.0 (нативный send_rich_message + edit_message_text(rich_message=...)) - help_message.py: HELP_RICH_HTML -> HELP_SECTIONS (6 разделов) + help_menu_html/help_section_html/help_view/help_plain_view - навигация на HTML-тегах <tg-button-row>/<tg-button type="callback_data"> (в меню 2 кнопки в ряд, в разделе «В меню» + «Предыдущий»/«Следующий») - bot.py: самописный raw-запрос sendRichMessage заменён на bot.send_rich_message; edit_rich_message + callback help_navigation - онбординг по-прежнему вызывает help_command — кнопки появляются и там - тест поймал реальный баг: в help_navigation пропущен await у edit_rich_message - 58 тестов, VERSION 0.1.0+23
This commit is contained in:
1 parent
0ff0e1abe7
commit
c85f0a34fe
7 files changed
+236
-69
No files matched your search
@@ -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+22`).
|
||||
- `DEPLOY_ENV=development`, `DEV_CHAT_ID` — для уведомления о новой версии; версия в файле `VERSION` (сейчас `0.1.0+23`).
|
||||
- Вики Gitea: страницы `Home`, `User-Guide`, `Developer-Guide`, `Subscription`, `Deployment`.
|
||||
|
||||
## Статус
|
||||
@@ -67,6 +67,7 @@ Telegram-бот, который по запросу пользователя г
|
||||
- [x] `/idea` возвращает одну идею одним сообщением и сохраняет её для исключения повторов
|
||||
- [x] Все отправленные варианты постов сохраняются по пользователю, включая исправленные; история используется для новых идей
|
||||
- [x] Служебные ответы по-русски, язык текстов постов определяется запросом пользователя
|
||||
- [x] Справка `/help` разбита на разделы с навигацией инлайн-кнопками (aiogram 3.31.0: `send_rich_message` + `edit_message_text(rich_message=…)` + HTML-теги `<tg-button-row>`/`<tg-button type="callback_data">`)
|
||||
- [x] Help command uses new Rich Message, with a plain-text fallback.
|
||||
- [x] Development deployment/version notice and user-visible error details.
|
||||
- [x] Онбординг после `/start`: welcome Rich Message (`WELCOME_RICH_HTML`) → пауза → вызов `help_command`; правки справки автоматически меняют онбординг
|
||||
|
||||
@@ -21,7 +21,7 @@ Telegram-бот для генерации постов для соцсетей.
|
||||
Первое сообщение пользователя запускает онбординг — **два сообщения с паузой 0.8 сек** между ними:
|
||||
|
||||
1. **Welcome (Rich Message)** — `WELCOME_RICH_HTML`: приветствие, примеры запросов, упоминание скриншотов для Tone of Voice.
|
||||
2. **Вызов `help_command`** — пауза 0.8 сек, затем тот же Rich Message справки, что и по команде `/help`.
|
||||
2. **Вызов `help_command`** — пауза 0.8 сек, затем меню справки с инлайн-кнопками (тот же хендлер, что и по команде `/help`).
|
||||
|
||||
Одновременно `/start` создаёт запись триала на 3 дня.
|
||||
|
||||
@@ -58,8 +58,8 @@ Telegram-бот для генерации постов для соцсетей.
|
||||
bot.py — роутинг, команды, обработка сообщений (aiogram 3.x)
|
||||
apinex_client.py — клиент APInex (LLM, STT, vision), промпты, логирование вызовов
|
||||
user_store.py — SQLite: пользователи, ToV, посты, идеи, API-логи, подписки
|
||||
help_message.py — Rich Message: `WELCOME_RICH_HTML` (онбординг) + `HELP_RICH_HTML` (справка), plain fallback
|
||||
VERSION — версия релиза (сейчас 0.1.0+22)
|
||||
help_message.py — Rich Message: `WELCOME_RICH_HTML` (онбординг) + `HELP_SECTIONS`/`help_view()` (справка с кнопками), plain fallback
|
||||
VERSION — версия релиза (сейчас 0.1.0+23)
|
||||
```
|
||||
|
||||
### Основные потоки
|
||||
@@ -102,7 +102,9 @@ update_tone_from_image → _handle_image_message
|
||||
| `balance_command` | `/balance` — статус подписки |
|
||||
| `subscribe_command` | `/subscribe` — активация подписки (оплата 100 звёзд) |
|
||||
| `clear_command` | `/clear` — сброс данных + обнуление подписки |
|
||||
| `help_command` | `/help` — Rich Message с fallback на plain text |
|
||||
| `help_command` | `/help` — меню справки (Rich Message с кнопками) + fallback на plain text |
|
||||
| `help_navigation` | Callback `help:<section>` → `edit_message_text(rich_message=...)` — переключение разделов |
|
||||
| `edit_rich_message` | Редактирование текущего rich-сообщения на новый раздел |
|
||||
| `typing_status` | Индикатор «печатает» во время обработки |
|
||||
| `report_error` | Понятная ошибка пользователю, секреты вырезаны |
|
||||
|
||||
@@ -166,7 +168,7 @@ SUBSCRIPTION_PRICE_STARS = 100
|
||||
| Команда | Что делает | Доступна без подписки |
|
||||
|---|---|---|
|
||||
| `/start` | Онбординг (welcome Rich Message + справка) + создание триала | ✅ |
|
||||
| `/help` | Подробная справка (Rich Message + plain fallback) | ✅ |
|
||||
| `/help` | Меню справки: разделы с инлайн-кнопками + plain fallback | ✅ |
|
||||
| `/balance` | Статус подписки / триала | ✅ |
|
||||
| `/subscribe` | Активация подписки (100 звёзд, 30 дней) | ✅ |
|
||||
| `/idea` | Одна идея для поста | ❌ (нужна подписка) |
|
||||
@@ -193,4 +195,4 @@ SUBSCRIPTION_PRICE_STARS = 100
|
||||
python -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, Rich help, **онбординг** (welcome → вызов `help_command`), **подписки** (6 тестов: таблица, триал после `/start`, оплата, баланс, блокровка, обнуление через `/clear`).
|
||||
Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **справка с кнопками** (меню, разделы, callback-навигация, plain fallback), **онбординг** (welcome → вызов `help_command`), **подписки** (6 тестов: таблица, триал после `/start`, оплата, баланс, блокировка, обнуление через `/clear`).
|
||||
@@ -8,17 +8,16 @@ import sqlite3
|
||||
from contextlib import asynccontextmanager, nullcontext
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
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 Message
|
||||
from aiogram.types import CallbackQuery, InputRichMessage, Message
|
||||
|
||||
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 HELP_RICH_HTML, WELCOME_RICH_HTML, rich_to_plain
|
||||
from help_message import WELCOME_RICH_HTML, help_plain_view, help_view, rich_to_plain
|
||||
from user_store import (
|
||||
add_post_samples,
|
||||
clear_user_data,
|
||||
@@ -114,41 +113,49 @@ async def notify_development_deploy(bot: Bot) -> None:
|
||||
logger.info("Development deploy notice sent: version=%s", VERSION)
|
||||
|
||||
|
||||
async def send_rich_html(message: Message, rich_html: str, stage: str = "помощи") -> bool:
|
||||
async def send_rich_message(message: Message, rich_html: str, stage: str) -> Message | None:
|
||||
bot = message.bot
|
||||
payload = {"chat_id": message.chat.id, "rich_message": {"html": rich_html}}
|
||||
if message.message_thread_id is not None:
|
||||
payload["message_thread_id"] = message.message_thread_id
|
||||
url = bot.session.api.api_url(token=bot.token, method="sendRichMessage")
|
||||
timeout = aiohttp.ClientTimeout(total=15)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(
|
||||
url, json=payload,
|
||||
headers={"User-Agent": "Mozilla/5.0 (compatible; SocialPostWriterBot/1.0)"},
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
if response.status == 200 and data.get("ok"):
|
||||
return True
|
||||
logger.warning("Rich %s rejected with HTTP %s", stage, response.status)
|
||||
return False
|
||||
sent = await bot.send_rich_message(
|
||||
chat_id=message.chat.id,
|
||||
rich_message=InputRichMessage(html=rich_html),
|
||||
message_thread_id=message.message_thread_id,
|
||||
)
|
||||
return sent
|
||||
except Exception as exc:
|
||||
logger.warning("Rich %s delivery error (%s): %s", stage, type(exc).__name__, str(exc)[:160])
|
||||
return False
|
||||
|
||||
|
||||
async def send_rich_message(message: Message, rich_html: str, stage: str) -> None:
|
||||
try:
|
||||
sent = await send_rich_html(message, rich_html, stage)
|
||||
except Exception as exc:
|
||||
sent = False
|
||||
logger.warning("Rich %s send failed (%s): %s", stage, type(exc).__name__, str(exc)[:180])
|
||||
if DEPLOY_ENV == "development":
|
||||
await report_error(message, f"отправка Rich Message: {stage}", exc)
|
||||
if not sent:
|
||||
if DEPLOY_ENV == "development":
|
||||
await report_error(message, f"отправка Rich Message: {stage}", RuntimeError("sendRichMessage returned false"))
|
||||
await message.answer(rich_to_plain(rich_html))
|
||||
await message.answer(help_plain_view(None) if stage == "помощи" else rich_to_plain(rich_html))
|
||||
return None
|
||||
|
||||
|
||||
async def edit_rich_message(message: Message, rich_html: str) -> bool:
|
||||
bot = message.bot
|
||||
if message.message_id is None:
|
||||
return False
|
||||
try:
|
||||
await bot.edit_message_text(
|
||||
chat_id=message.chat.id,
|
||||
message_id=message.message_id,
|
||||
rich_message=InputRichMessage(html=rich_html),
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("Rich help edit failed (%s): %s", type(exc).__name__, str(exc)[:180])
|
||||
return False
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("help:"))
|
||||
async def help_navigation(callback: CallbackQuery) -> None:
|
||||
message = callback.message
|
||||
if not isinstance(message, Message):
|
||||
return
|
||||
section = (callback.data or "help:menu").split(":", 1)[1]
|
||||
if not await edit_rich_message(message, help_view(section)):
|
||||
await callback.answer("Не удалось обновить справку, открой /help заново", show_alert=True)
|
||||
return
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
@@ -234,7 +241,7 @@ async def clear_command(message: Message) -> None:
|
||||
|
||||
|
||||
async def help_command(message: Message) -> None:
|
||||
await send_rich_message(message, HELP_RICH_HTML, "помощи")
|
||||
await send_rich_message(message, help_view(None), "помощи")
|
||||
|
||||
|
||||
|
||||
|
||||
+94
-16
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
|
||||
WELCOME_RICH_HTML = """<h1>Привет! Я — бот для написания постов</h1>
|
||||
<p>Помогаю писать и редактировать посты для соцсетей. Расскажи, о чём нужен пост, а я верну три варианта: короткий, развёрнутый и с призывом к действию.</p>
|
||||
<h2>Примеры запросов</h2>
|
||||
@@ -12,30 +13,107 @@ WELCOME_RICH_HTML = """<h1>Привет! Я — бот для написания
|
||||
<p>Также я умею запоминать твой стиль: пришли скриншоты своих постов. Хочешь идею — напиши «предложи тему».</p>
|
||||
<p>Ниже — подробная инструкция по работе с ботом.</p>
|
||||
"""
|
||||
HELP_RICH_HTML = """<h1>Помощь по боту</h1>
|
||||
<p>Я помогаю писать и редактировать посты для социальных сетей. Генерирую посты только по явной просьбе.</p>
|
||||
<hr/>
|
||||
<h2>Написать пост</h2>
|
||||
<ol>
|
||||
|
||||
HELP_MENU_TITLE = "Помощь по боту"
|
||||
HELP_MENU_INTRO = "Выбери раздел — откроется только он."
|
||||
HELP_SECTIONS: dict[str, tuple[str, str]] = {
|
||||
"posts": (
|
||||
"✍️ Написать пост",
|
||||
"""<ol>
|
||||
<li>Напиши сообщение или отправь голосовое с прямой просьбой, например: «Придумай пост для Telegram про запуск нового продукта».</li>
|
||||
<li>Можешь указать площадку, аудиторию, факты, длину, цель и другие требования. Можно надиктовать подробное ТЗ или просто тему.</li>
|
||||
<li>Я распознаю речь, проверю, просишь ли ты именно написать пост, и верну три варианта отдельными сообщениями.</li>
|
||||
</ol>
|
||||
<p>В сообщениях с вариантами будет только текст поста: без нумерации и вступлений, с абзацами, переносами строк и уместными эмодзи.</p>
|
||||
<h2>Настроить Tone of Voice</h2>
|
||||
<p>Пришли скриншот или галерею скриншотов своих опубликованных постов. Я распознаю текст постов и сохраню его как образцы, дополню предыдущие примеры и обновлю только правила Tone of Voice. Сами публикации не переписываю.</p>
|
||||
<h2>Попросить правку</h2>
|
||||
<p>Ответь реплаем на сообщение с вариантом и укажи, что изменить. Например: «Сделай короче и добавь призыв». Я учту оригинальный текст и верну три новых варианта отдельными сообщениями.</p>
|
||||
<h2>Подписка</h2>
|
||||
<p>У тебя есть пробный период — 3 дня бесплатно. Дальше — подписка на 30 дней за 100 звёзд.</p>
|
||||
<p>В сообщениях с вариантами будет только текст поста: без нумерации и вступлений, с абзацами, переносами строк и уместными эмодзи.</p>""",
|
||||
),
|
||||
"tone": (
|
||||
"🎨 Tone of Voice",
|
||||
"""<p>Пришли скриншот или галерею скриншотов своих опубликованных постов. Я распознаю текст постов и сохраню его как образцы, дополню предыдущие примеры и обновлю только правила Tone of Voice. Сами публикации не переписываю.</p>""",
|
||||
),
|
||||
"edit": (
|
||||
"✏️ Правка поста",
|
||||
"""<p>Ответь реплаем на сообщение с вариантом и укажи, что изменить. Например: «Сделай короче и добавь призыв». Я учту оригинальный текст и верну три новых варианта отдельными сообщениями.</p>""",
|
||||
),
|
||||
"idea": (
|
||||
"💡 Идея для поста",
|
||||
"""<p>Напиши «предложи идею для поста», «о чём написать?» или «накидай тему» — верну одну идею. Команда <code>/idea</code> работает так же.</p>
|
||||
<p>Идеи сохраняются, чтобы не повторяться: каждая новая идея отличается от предыдущих.</p>""",
|
||||
),
|
||||
"subscription": (
|
||||
"💳 Подписка",
|
||||
"""<p>У тебя есть пробный период — 3 дня бесплатно. Дальше — подписка на 30 дней за 100 звёзд.</p>
|
||||
<ul>
|
||||
<li><code>/balance</code> — узнать статус: сколько осталось дней триала или подписки.</li>
|
||||
<li><code>/subscribe</code> — оплатить подписку на 30 дней.</li>
|
||||
</ul>
|
||||
<p>Когда период закончится, генерация постов и идей станет недоступна — бот предложит оформить подписку. Команды <code>/help</code>, <code>/balance</code>, <code>/subscribe</code>, <code>/start</code> и <code>/clear</code> работают всегда.</p>
|
||||
<h2>Команды</h2>
|
||||
<p>Быстрые slash-команды только на английском: <code>/help</code> — эта инструкция; <code>/start</code> — краткое описание бота; <code>/clear</code> — полностью обнулить твои данные бота, кроме технических API-логов; <code>/idea</code> — предложить одну идею для нового поста; <code>/balance</code> — статус подписки; <code>/subscribe</code> — оплатить подписку на 30 дней. Можно попросить идею обычными словами: «предложи идею для поста», «о чём написать?», «накидай тему».</p>
|
||||
"""
|
||||
<p>Когда период закончится, генерация постов и идей станет недоступна — бот предложит оформить подписку. Команды <code>/help</code>, <code>/balance</code>, <code>/subscribe</code>, <code>/start</code> и <code>/clear</code> работают всегда.</p>""",
|
||||
),
|
||||
"commands": (
|
||||
"⌨️ Команды",
|
||||
"""<p>Быстрые slash-команды только на английском: <code>/help</code> — эта справка; <code>/start</code> — краткое описание бота; <code>/clear</code> — полностью обнулить твои данные бота, кроме технических API-логов; <code>/idea</code> — предложить одну идею для нового поста; <code>/balance</code> — статус подписки; <code>/subscribe</code> — оплатить подписку на 30 дней.</p>
|
||||
<p>Можно попросить идею обычными словами: «предложи идею для поста», «о чём написать?», «накидай тему».</p>""",
|
||||
),
|
||||
}
|
||||
|
||||
HELP_SECTION_ORDER: tuple[str, ...] = tuple(HELP_SECTIONS)
|
||||
HELP_BACK = "⬅️ В меню"
|
||||
HELP_PREV = "⬅️ Предыдущий"
|
||||
HELP_NEXT = "➡️ Следующий"
|
||||
|
||||
|
||||
def _section_nav(section: str) -> str:
|
||||
buttons = [f'<tg-button type="callback_data" data="help:menu" style="primary">{HELP_BACK}</tg-button>']
|
||||
index = HELP_SECTION_ORDER.index(section)
|
||||
if index > 0:
|
||||
previous = HELP_SECTION_ORDER[index - 1]
|
||||
buttons.append(
|
||||
f'<tg-button type="callback_data" data="help:{previous}">{HELP_PREV}</tg-button>'
|
||||
)
|
||||
if index + 1 < len(HELP_SECTION_ORDER):
|
||||
following = HELP_SECTION_ORDER[index + 1]
|
||||
buttons.append(
|
||||
f'<tg-button type="callback_data" data="help:{following}">{HELP_NEXT}</tg-button>'
|
||||
)
|
||||
return '<tg-button-row align="center">' + "".join(buttons) + "</tg-button-row>"
|
||||
|
||||
|
||||
def help_menu_html() -> str:
|
||||
buttons = [
|
||||
f'<tg-button type="callback_data" data="help:{section}" style="primary">'
|
||||
f"{HELP_SECTIONS[section][0]}</tg-button>"
|
||||
for section in HELP_SECTION_ORDER
|
||||
]
|
||||
pairs = "".join(
|
||||
'<tg-button-row align="center">' + "".join(buttons[i : i + 2]) + "</tg-button-row>"
|
||||
for i in range(0, len(buttons), 2)
|
||||
)
|
||||
return (
|
||||
f"<h1>{HELP_MENU_TITLE}</h1>"
|
||||
f"<p>{HELP_MENU_INTRO}</p>"
|
||||
f"<p>Я помогаю писать и редактировать посты для соцсетей. "
|
||||
f"Генерирую посты только по явной просьбе.</p>"
|
||||
f"{pairs}"
|
||||
)
|
||||
|
||||
|
||||
def help_section_html(section: str) -> str:
|
||||
title, body = HELP_SECTIONS[section]
|
||||
return f"<h1>{title}</h1>{body}{_section_nav(section)}"
|
||||
|
||||
|
||||
def help_view(section: str | None = None) -> str:
|
||||
if section and section in HELP_SECTIONS:
|
||||
return help_section_html(section)
|
||||
return help_menu_html()
|
||||
|
||||
|
||||
def help_plain_view(section: str | None = None) -> str:
|
||||
if section and section in HELP_SECTIONS:
|
||||
title, body = HELP_SECTIONS[section]
|
||||
return f"{HELP_MENU_TITLE} — {title}\n\n{rich_to_plain(body)}"
|
||||
lines = [HELP_MENU_TITLE, HELP_MENU_INTRO]
|
||||
lines += [f"• {HELP_SECTIONS[name][0]}" for name in HELP_SECTION_ORDER]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def rich_to_plain(rich_html: str) -> str:
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
aiogram==3.26.0
|
||||
aiogram==3.31.0
|
||||
aiohttp>=3.9,<4
|
||||
+90
-11
@@ -1,7 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aiogram.types import Chat, Message
|
||||
|
||||
import bot
|
||||
|
||||
|
||||
@@ -51,19 +56,56 @@ class HelpRoutingTests(unittest.TestCase):
|
||||
self.assertIn("elif command == \"/idea\"", source)
|
||||
self.assertNotIn("idea_voice", source)
|
||||
|
||||
def test_help_rich_message_contains_usage_scenarios(self):
|
||||
from help_message import HELP_RICH_HTML, rich_to_plain
|
||||
def test_help_sections_cover_usage_scenarios(self):
|
||||
from help_message import HELP_SECTIONS, help_plain_view, rich_to_plain
|
||||
|
||||
text = rich_to_plain(HELP_RICH_HTML)
|
||||
text = "\n".join(rich_to_plain(body) for _title, body in HELP_SECTIONS.values())
|
||||
self.assertIn("/clear", text)
|
||||
self.assertIn("/idea", text)
|
||||
self.assertNotIn("/idea_voice", text)
|
||||
for term in ("пост", "голосовое", "скриншот", "реплаем", "/help"):
|
||||
self.assertIn(term, text)
|
||||
menu = help_plain_view(None)
|
||||
for title, _body in HELP_SECTIONS.values():
|
||||
self.assertIn(title, menu)
|
||||
|
||||
|
||||
class HelpNavigationTests(unittest.TestCase):
|
||||
def test_menu_lists_every_section_as_callback_button(self):
|
||||
from help_message import HELP_SECTIONS, help_menu_html
|
||||
|
||||
menu = help_menu_html()
|
||||
self.assertIn("<tg-button-row", menu)
|
||||
for section in HELP_SECTIONS:
|
||||
self.assertIn(f'data="help:{section}"', menu)
|
||||
self.assertNotIn('data="help:menu"', menu)
|
||||
|
||||
def test_section_has_menu_back_and_step_navigation(self):
|
||||
from help_message import help_section_html
|
||||
|
||||
posts = help_section_html("posts")
|
||||
self.assertIn('data="help:menu"', posts)
|
||||
self.assertIn('data="help:tone"', posts)
|
||||
self.assertNotIn('data="help:commands"', posts)
|
||||
|
||||
last = help_section_html("commands")
|
||||
self.assertIn('data="help:menu"', last)
|
||||
self.assertNotIn("Следующий", last)
|
||||
|
||||
def test_unknown_section_falls_back_to_menu(self):
|
||||
from help_message import help_view
|
||||
|
||||
self.assertEqual(help_view("nope"), help_view(None))
|
||||
|
||||
def test_plain_fallback_has_menu_and_section_text(self):
|
||||
from help_message import help_plain_view
|
||||
|
||||
self.assertIn("Помощь по боту", help_plain_view(None))
|
||||
self.assertIn("Написать пост", help_plain_view("posts"))
|
||||
|
||||
|
||||
class OnboardingTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_start_sends_welcome_rich_then_help_command(self):
|
||||
async def test_start_sends_welcome_rich_then_help_menu(self):
|
||||
message = AsyncMock()
|
||||
message.from_user.id = 42
|
||||
message.message_thread_id = None
|
||||
@@ -79,14 +121,51 @@ class OnboardingTests(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(bot, "send_rich_message", new=fake_rich),
|
||||
):
|
||||
await bot.start(message)
|
||||
self.assertEqual(calls, [bot.WELCOME_RICH_HTML, bot.HELP_RICH_HTML])
|
||||
self.assertEqual(calls, [bot.WELCOME_RICH_HTML, bot.help_view(None)])
|
||||
|
||||
async def test_start_help_step_reuses_help_command(self):
|
||||
from pathlib import Path
|
||||
def _callback(self, data: str, message: Message | None):
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
source = Path(bot.__file__).read_text(encoding="utf-8")
|
||||
start_body = source.split("async def start(message: Message) -> None:")[1].split("\nasync def ")[0]
|
||||
self.assertIn("await help_command(message)", start_body)
|
||||
callback = AsyncMock(spec=CallbackQuery)
|
||||
callback.data = data
|
||||
callback.answer = AsyncMock()
|
||||
callback.message = message
|
||||
return callback
|
||||
|
||||
async def test_help_callback_edits_rich_message(self):
|
||||
message = Message(
|
||||
message_id=77,
|
||||
date=datetime.now(),
|
||||
chat=Chat(id=5, type="private"),
|
||||
)
|
||||
callback = self._callback("help:tone", message)
|
||||
|
||||
with patch.object(bot, "edit_rich_message", new=AsyncMock(return_value=True)) as edit:
|
||||
await bot.help_navigation(callback)
|
||||
|
||||
edit.assert_awaited_once_with(message, bot.help_view("tone"))
|
||||
callback.answer.assert_awaited_once_with()
|
||||
|
||||
async def test_help_callback_reports_error_when_edit_fails(self):
|
||||
message = Message(
|
||||
message_id=78,
|
||||
date=datetime.now(),
|
||||
chat=Chat(id=5, type="private"),
|
||||
)
|
||||
callback = self._callback("help:menu", message)
|
||||
|
||||
with patch.object(bot, "edit_rich_message", new=AsyncMock(return_value=False)):
|
||||
await bot.help_navigation(callback)
|
||||
|
||||
callback.answer.assert_awaited_once_with("Не удалось обновить справку, открой /help заново", show_alert=True)
|
||||
|
||||
async def test_help_callback_ignores_inaccessible_message(self):
|
||||
callback = self._callback("help:posts", None)
|
||||
|
||||
with patch.object(bot, "edit_rich_message", new=AsyncMock()) as edit:
|
||||
await bot.help_navigation(callback)
|
||||
|
||||
edit.assert_not_awaited()
|
||||
|
||||
def test_welcome_message_has_examples_and_help_handoff(self):
|
||||
from help_message import WELCOME_RICH_HTML, rich_to_plain
|
||||
@@ -97,4 +176,4 @@ class OnboardingTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user