From 606b914a633f25b7def629bf5382ecde24164366 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 4 Oct 2026 21:55:55 +0200 Subject: [PATCH] feat: add help version notices and user errors --- Dockerfile | 2 +- IDEA.MD | 3 +- README.md | 2 + VERSION | 1 + bot.py | 90 +++++++++++++++++++++++++++++++++++++- help_message.py | 31 +++++++++++++ tests/test_release_help.py | 45 +++++++++++++++++++ user_store.py | 25 +++++++++++ 8 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 VERSION create mode 100644 help_message.py create mode 100644 tests/test_release_help.py diff --git a/Dockerfile b/Dockerfile index 614e352..b946053 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 ./ +COPY bot.py apinex_client.py user_store.py VERSION ./ CMD ["python", "bot.py"] diff --git a/IDEA.MD b/IDEA.MD index 1e32fca..6ac44bb 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -44,4 +44,5 @@ Telegram-бот, который по запросу пользователя г - [x] Последний development deploy работает без traceback - [x] Обновление ToV галереей: одна обработка группы, ToV-инструкция показывается пользователю - [x] Варианты постов показываются без нумерации и вступительного текста -- [ ] Проверить галерею, обновление ToV и генерацию в Telegram на тестовом аккаунте +- [x] Help command uses new Rich Message, with a plain-text fallback. +- [x] Development deployment/version notice and user-visible error details. diff --git a/README.md b/README.md index d922e52..abf3959 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ The bot prepares social-media posts only when the user explicitly asks. It transcribes Russian voice notes with APInex STT, classifies intent with an LLM, and uses Gemini 3.8 Flash via APInex to write/revise posts or infer Tone of Voice from a screenshot. A post request/revision returns three separate messages. The bot shows the Telegram typing indicator while classifying, transcribing, reading images, and generating. It stores no conversation history. - Runtime: Python 3.12, aiogram 3.x +- Release version is stored in `VERSION` and incremented for each code change; dev sends a version notice on boot. +- In development, processing errors are returned to the user with a concise, secret-redacted reason. - Coolify: project `Social Post Writer Bot`, environment `development`, server `spb-easy-dale` - Gitea: `admin/social-post-writer-bot`, branch `dev` - API key and model variables are stored only in Coolify: `APINEX_API_KEY`, `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..f91d2e0 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0+3 diff --git a/bot.py b/bot.py index 3a79db1..dc63eea 100644 --- a/bot.py +++ b/bot.py @@ -1,8 +1,11 @@ import asyncio +import html import io import logging import os +import re from contextlib import asynccontextmanager, nullcontext +from pathlib import Path import aiohttp from aiogram import Bot, Dispatcher, F, Router @@ -12,10 +15,20 @@ from aiogram.filters import CommandStart from aiogram.types import Message from apinex_client import APINEX_STT_URL, classify_intent, extract_tone_from_images, extract_transcript, generate_posts, transcribe_audio -from user_store import get_tone_of_voice, init_db, save_tone_of_voice +from help_message import HELP_RICH_HTML, rich_to_plain +from user_store import ( + get_deploy_notice_version, + get_tone_of_voice, + init_db, + save_tone_of_voice, + set_deploy_notice_version, +) logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +VERSION = Path(__file__).with_name("VERSION").read_text(encoding="utf-8").strip() +BOT_ENV = os.getenv("BOT_ENV", "test").strip().lower() +HELP_COMMANDS = {"help", "помощь"} router = Router() MAX_AUDIO_BYTES = 20 * 1024 * 1024 @@ -24,6 +37,8 @@ TYPING_REFRESH_SECONDS = 4 MEDIA_GROUP_COLLECT_SECONDS = 0.8 _media_groups: dict[tuple[int, str], dict[str, object]] = {} _media_groups_lock = asyncio.Lock() +_deploy_notice_sent = False +_deploy_notice_lock = asyncio.Lock() def split_text(text: str, limit: int = 4000) -> list[str]: @@ -40,14 +55,77 @@ def split_text(text: str, limit: int = 4000) -> list[str]: return chunks +async def report_error(message: Message, stage: str, exc: Exception) -> None: + detail = str(exc).strip() + detail = re.sub(r"(?i)(?:sk-[A-Za-z0-9_-]{12,}|[0-9]{8,}:[A-Za-z0-9_-]{20,})", "[секрет скрыт]", detail) + detail = re.sub(r"https?://\S+", "[внешний сервис]", detail) + detail = detail or "нет подробностей" + await message.answer(f"❌ Не получилось: {stage}.\nПричина: {html.escape(detail[:240])}", parse_mode="HTML") + logger.error("User operation failed at %s (%s): %s", stage, type(exc).__name__, detail[:240]) + + +async def notify_development_deploy(bot: Bot) -> None: + if BOT_ENV not in {"dev", "development"}: + return + chat_id = os.getenv("DEV_CHAT_ID", "").strip() + if not chat_id: + logger.warning("DEV_CHAT_ID is missing; no version notice will be sent") + return + previous = await asyncio.to_thread(get_deploy_notice_version) + if previous == VERSION: + return + try: + await asyncio.wait_for( + bot.send_message(chat_id=int(chat_id), text=f"🚀 Бот обновлён\nВерсия: {VERSION}"), + timeout=8, + ) + await asyncio.to_thread(set_deploy_notice_version, VERSION) + except Exception as exc: + logger.error("Development deploy notice failed (%s): %s", type(exc).__name__, str(exc)[:160]) + + +async def send_rich_help(message: Message) -> bool: + bot = message.bot + payload = {"chat_id": message.chat.id, "rich_message": {"html": HELP_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) + 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 help rejected with HTTP %s", response.status) + return False + + @router.message(CommandStart()) async def start(message: Message) -> None: await message.answer( "Я помогаю писать посты для соцсетей. Пришли скриншот своих постов, чтобы настроить стиль, " - "или попроси написать пост, например: «Придумай пост про запуск нового продукта»." + "или попроси написать пост, например: «Придумай пост про запуск нового продукта». " + "Подробная инструкция — /help." ) +async def help_command(message: Message) -> None: + try: + rich_sent = await send_rich_help(message) + except Exception as exc: + rich_sent = False + logger.warning("Rich help send failed (%s): %s", type(exc).__name__, str(exc)[:180]) + if BOT_ENV in {"dev", "development"}: + await report_error(message, "отправка Rich Message помощи", exc) + if not rich_sent: + if BOT_ENV in {"dev", "development"}: + await message.answer("⚠️ Новый Rich Message не отправился; отправляю упрощённую инструкцию.") + await message.answer(rich_to_plain(HELP_RICH_HTML)) + + async def _send_three_posts(message: Message, posts: list[str]) -> None: for post in posts: await message.answer(post) @@ -264,6 +342,13 @@ async def transcribe_voice(message: Message, bot: Bot) -> None: await message.answer("Произошла ошибка при обработке голосового.") +@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() + if command in {"/help", "/помощь"}: + await help_command(message) + + @router.message(F.text & ~F.text.startswith("/")) async def handle_text(message: Message) -> None: if message.text: @@ -279,6 +364,7 @@ async def main() -> None: test_env = os.environ.get("BOT_ENV", "test").strip().lower() == "test" session = AiohttpSession(api=TEST) if test_env else None bot = Bot(token=token, session=session) + await notify_development_deploy(bot) dp = Dispatcher() dp.include_router(router) diff --git a/help_message.py b/help_message.py new file mode 100644 index 0000000..68ee2bb --- /dev/null +++ b/help_message.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import html + +HELP_RICH_HTML = """

Помощь по боту

+

Я помогаю писать и редактировать посты для социальных сетей. Генерирую посты только по явной просьбе.

+
+

Написать пост

+
    +
  1. Напиши сообщение или отправь голосовое с прямой просьбой, например: «Придумай пост для Telegram про запуск нового продукта».
  2. +
  3. Можешь указать площадку, аудиторию, факты, длину, цель и другие требования. Можно надиктовать подробное ТЗ или просто тему.
  4. +
  5. Я распознаю речь, проверю, просишь ли ты именно написать пост, и верну три варианта отдельными сообщениями.
  6. +
+

В сообщениях с вариантами будет только текст поста: без нумерации и вступлений, с абзацами, переносами строк и уместными эмодзи.

+

Настроить Tone of Voice

+

Пришли скриншот или галерею скриншотов своих опубликованных постов. Я прочитаю изображения вместе, составлю инструкцию стиля, сохраню её для твоего аккаунта и покажу тебе. Новая настройка заменяет предыдущую.

+

Попросить правку

+

Ответь реплаем на сообщение с вариантом и укажи, что изменить. Например: «Сделай короче и добавь призыв». Я учту оригинальный текст и верну три новых варианта отдельными сообщениями.

+

Команды

+ +

Я не поддерживаю историю беседы и не публикую посты в соцсетях. Каждый запрос обрабатывается отдельно, кроме правки через reply.

""" + + +def rich_to_plain(rich_html: str) -> str: + import re + + text = re.sub(r"]*)?>", "• ", rich_html, flags=re.IGNORECASE) + text = re.sub(r"]*)?/?>", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"<[^>]+>", "", text) + text = html.unescape(text) + return "\n".join(line.strip() for line in text.splitlines() if line.strip()) diff --git a/tests/test_release_help.py b/tests/test_release_help.py new file mode 100644 index 0000000..55e1417 --- /dev/null +++ b/tests/test_release_help.py @@ -0,0 +1,45 @@ +import asyncio +import unittest +from unittest.mock import AsyncMock, patch + +import bot + + +class DeployNoticeTests(unittest.IsolatedAsyncioTestCase): + async def test_sends_once_per_new_version_only_in_development(self): + fake_bot = AsyncMock() + with ( + patch.object(bot, "BOT_ENV", "development"), + patch.object(bot, "VERSION", "0.1.0+3"), + patch.dict("os.environ", {"DEV_CHAT_ID": "2201809732"}, clear=False), + patch.object(bot.asyncio, "to_thread", new=AsyncMock(side_effect=["0.1.0+2", None, "0.1.0+3"])), + ): + await bot.notify_development_deploy(fake_bot) + await bot.notify_development_deploy(fake_bot) + fake_bot.send_message.assert_awaited_once_with(chat_id=2201809732, text="🚀 Бот обновлён\nВерсия: 0.1.0+3") + + async def test_does_not_send_outside_development(self): + fake_bot = AsyncMock() + with patch.object(bot, "BOT_ENV", "production"): + await bot.notify_development_deploy(fake_bot) + fake_bot.send_message.assert_not_awaited() + + +class HelpRoutingTests(unittest.TestCase): + def test_commands_are_routed_in_command_handler(self): + self.assertEqual(bot.HELP_COMMANDS, {"help", "помощь"}) + from pathlib import Path + source = Path(bot.__file__).read_text(encoding="utf-8") + self.assertIn("@router.message(F.text & F.text.startswith(\"/\"))", source) + self.assertIn("if command in {\"/help\", \"/помощь\"}", source) + + def test_help_rich_message_contains_usage_scenarios(self): + from help_message import HELP_RICH_HTML, rich_to_plain + + text = rich_to_plain(HELP_RICH_HTML) + for term in ("пост", "голосовое", "скриншот", "реплаем", "/help"): + self.assertIn(term, text) + + +if __name__ == "__main__": + unittest.main() diff --git a/user_store.py b/user_store.py index 4c44411..c38c4d6 100644 --- a/user_store.py +++ b/user_store.py @@ -42,6 +42,31 @@ def get_tone_of_voice(user_id: int, db_path: str | None = None) -> str: return str(row["tone_of_voice"] or "") if row else "" +def get_deploy_notice_version(db_path: str | None = None) -> str: + with closing(connect(db_path)) as connection, connection: + connection.execute( + "CREATE TABLE IF NOT EXISTS app_state (key TEXT PRIMARY KEY, value TEXT NOT NULL)" + ) + row = connection.execute( + "SELECT value FROM app_state WHERE key = 'deploy_notice_version'" + ).fetchone() + return str(row["value"]) if row else "" + + +def set_deploy_notice_version(version: str, db_path: str | None = None) -> None: + with closing(connect(db_path)) as connection, connection: + connection.execute( + "CREATE TABLE IF NOT EXISTS app_state (key TEXT PRIMARY KEY, value TEXT NOT NULL)" + ) + connection.execute( + """ + INSERT INTO app_state (key, value) VALUES ('deploy_notice_version', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + """, + (version,), + ) + + def save_tone_of_voice(user_id: int, tone_of_voice: str, db_path: str | None = None) -> None: tone = tone_of_voice.strip()[:1200] with closing(connect(db_path)) as connection, connection: