From 33cc2b8a153d19bb7a4947fba39bcccfda332182 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 4 Oct 2026 21:14:05 +0200 Subject: [PATCH] feat: show typing status during processing --- README.md | 2 +- bot.py | 132 +++++++++++++++++++++++++++---------------- tests/test_typing.py | 27 +++++++++ user_store.py | 7 ++- 4 files changed, 116 insertions(+), 52 deletions(-) create mode 100644 tests/test_typing.py diff --git a/README.md b/README.md index 9f114b5..4272846 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Social Post Writer Bot — development -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 stores no conversation history. +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 - Coolify: project `Social Post Writer Bot`, environment `development`, server `spb-easy-dale` diff --git a/bot.py b/bot.py index 4a44262..1d3a026 100644 --- a/bot.py +++ b/bot.py @@ -2,6 +2,7 @@ import asyncio import io import logging import os +from contextlib import asynccontextmanager, nullcontext import aiohttp from aiogram import Bot, Dispatcher, F, Router @@ -19,6 +20,7 @@ logger = logging.getLogger(__name__) router = Router() MAX_AUDIO_BYTES = 20 * 1024 * 1024 MAX_IMAGE_BYTES = 10 * 1024 * 1024 +TYPING_REFRESH_SECONDS = 4 def split_text(text: str, limit: int = 4000) -> list[str]: @@ -48,7 +50,33 @@ async def _send_three_posts(message: Message, posts: list[str]) -> None: await message.answer(f"{index}/3\n{post}") -async def _classify_and_respond(message: Message, text: str) -> None: +@asynccontextmanager +async def typing_status(bot: Bot, chat_id: int): + try: + await bot.send_chat_action(chat_id, "typing") + except Exception as exc: + logger.debug("Typing indicator unavailable: %s", type(exc).__name__) + + async def refresh_typing() -> None: + while True: + await asyncio.sleep(TYPING_REFRESH_SECONDS) + try: + await bot.send_chat_action(chat_id, "typing") + except Exception as exc: + logger.debug("Typing indicator refresh failed: %s", type(exc).__name__) + + task = asyncio.create_task(refresh_typing()) + try: + yield + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + +async def _classify_and_respond(message: Message, text: str, show_typing: bool = True) -> None: if message.from_user is None: await message.answer("Не удалось определить пользователя.") return @@ -57,22 +85,23 @@ async def _classify_and_respond(message: Message, text: str) -> None: if reply_message and reply_message.from_user and reply_message.from_user.is_bot: original_post = (reply_message.text or reply_message.caption or "").strip() try: - intent = await classify_intent(text, original_post or None) - if intent["intent"] == "other": - await message.answer("Я помогаю только писать и редактировать посты для социальных сетей.") - return - if intent["intent"] == "revise_post": - if not original_post: - await message.answer("Чтобы исправить пост, ответь реплаем на сообщение с его вариантом.") + async with (typing_status(message.bot, message.chat.id) if show_typing else nullcontext()): + intent = await classify_intent(text, original_post or None) + if intent["intent"] == "other": + await message.answer("Я помогаю только писать и редактировать посты для социальных сетей.") return - tone = await asyncio.to_thread(get_tone_of_voice, message.from_user.id) - posts = await generate_posts( - intent, tone, original_post=original_post, edit_request=text, - ) - else: - tone = await asyncio.to_thread(get_tone_of_voice, message.from_user.id) - posts = await generate_posts(intent, tone) - await _send_three_posts(message, posts) + if intent["intent"] == "revise_post": + if not original_post: + await message.answer("Чтобы исправить пост, ответь реплаем на сообщение с его вариантом.") + return + tone = await asyncio.to_thread(get_tone_of_voice, message.from_user.id) + posts = await generate_posts( + intent, tone, original_post=original_post, edit_request=text, + ) + else: + tone = await asyncio.to_thread(get_tone_of_voice, message.from_user.id) + posts = await generate_posts(intent, tone) + await _send_three_posts(message, posts) except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError, ValueError) as exc: logger.warning("Post request failed: %s", type(exc).__name__) await message.answer("Не получилось подготовить посты. Попробуй ещё раз позже.") @@ -108,23 +137,28 @@ async def _download_message_image(message: Message, bot: Bot) -> tuple[bytes, st return image, mime_type -@router.message(F.photo | (F.document & F.document.mime_type.startswith("image/"))) -async def update_tone_from_image(message: Message, bot: Bot) -> None: +async def _process_image(message: Message, bot: Bot) -> None: if message.from_user is None: await message.answer("Не удалось определить пользователя.") return + image_payload = await _download_message_image(message, bot) + if image_payload is None: + if message.document and not (message.document.mime_type or "").lower().startswith("image/"): + await message.answer("Пришли скриншот как изображение, чтобы настроить мой стиль.") + return + tone = await extract_tone_from_image(*image_payload) + if not tone: + await message.answer("Не смогла прочитать на изображении пост для соцсетей. Пришли более чёткий скриншот.") + return + await asyncio.to_thread(save_tone_of_voice, message.from_user.id, tone) + await message.answer("Запомнила стиль твоих постов. Буду учитывать его в новых публикациях.") + + +@router.message(F.photo | (F.document & F.document.mime_type.startswith("image/"))) +async def update_tone_from_image(message: Message, bot: Bot) -> None: try: - image_payload = await _download_message_image(message, bot) - if image_payload is None: - if message.document and not (message.document.mime_type or "").lower().startswith("image/"): - await message.answer("Пришли скриншот как изображение, чтобы настроить мой стиль.") - return - tone = await extract_tone_from_image(*image_payload) - if not tone: - await message.answer("Не смогла прочитать на изображении пост для соцсетей. Пришли более чёткий скриншот.") - return - await asyncio.to_thread(save_tone_of_voice, message.from_user.id, tone) - await message.answer("Запомнила стиль твоих постов. Буду учитывать его в новых публикациях.") + async with typing_status(bot, message.chat.id): + await _process_image(message, bot) except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError, ValueError) as exc: logger.warning("Tone of voice update failed: %s", type(exc).__name__) await message.answer("Не получилось прочитать скриншот. Попробуй отправить его ещё раз.") @@ -133,36 +167,38 @@ async def update_tone_from_image(message: Message, bot: Bot) -> None: await message.answer("Произошла ошибка при чтении скриншота.") -@router.message(F.voice | F.audio) -async def transcribe_voice(message: Message, bot: Bot) -> None: +async def _process_voice(message: Message, bot: Bot) -> None: if not os.environ.get("APINEX_API_KEY", "").strip(): await message.answer("Расшифровка голосовых пока не настроена.") return - audio_file = message.voice or message.audio if audio_file is None: return if audio_file.file_size and audio_file.file_size > MAX_AUDIO_BYTES: await message.answer("Аудиофайл слишком большой. Максимальный размер — 20 МБ.") return - + audio_buffer = io.BytesIO() + await bot.download(audio_file, destination=audio_buffer) + audio = audio_buffer.getvalue() + if not audio: + await message.answer("Не удалось получить аудиофайл.") + return + if len(audio) > MAX_AUDIO_BYTES: + await message.answer("Аудиофайл слишком большой. Максимальный размер — 20 МБ.") + return content_type = "audio/ogg" if message.voice else (message.audio.mime_type or "audio/mpeg") - await bot.send_chat_action(message.chat.id, "typing") + transcript = await transcribe_audio(audio, content_type) + if not transcript: + await message.answer("Не удалось распознать речь в аудио.") + return + await _classify_and_respond(message, transcript, show_typing=False) + + +@router.message(F.voice | F.audio) +async def transcribe_voice(message: Message, bot: Bot) -> None: try: - audio_buffer = io.BytesIO() - await bot.download(audio_file, destination=audio_buffer) - audio = audio_buffer.getvalue() - if not audio: - await message.answer("Не удалось получить аудиофайл.") - return - if len(audio) > MAX_AUDIO_BYTES: - await message.answer("Аудиофайл слишком большой. Максимальный размер — 20 МБ.") - return - transcript = await transcribe_audio(audio, content_type) - if not transcript: - await message.answer("Не удалось распознать речь в аудио.") - return - await _classify_and_respond(message, transcript) + async with typing_status(bot, message.chat.id): + await _process_voice(message, bot) except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError, ValueError) as exc: logger.warning("Voice post request failed: %s", type(exc).__name__) await message.answer("Не удалось обработать голосовое. Попробуй ещё раз или напиши текстом.") diff --git a/tests/test_typing.py b/tests/test_typing.py new file mode 100644 index 0000000..520b5fe --- /dev/null +++ b/tests/test_typing.py @@ -0,0 +1,27 @@ +import unittest +from unittest.mock import AsyncMock, patch + +import bot + + +class TypingStatusTests(unittest.IsolatedAsyncioTestCase): + async def test_sends_immediately_refreshes_and_cancels(self): + fake_bot = AsyncMock() + with patch.object(bot, "TYPING_REFRESH_SECONDS", 0.01): + async with bot.typing_status(fake_bot, 123): + await bot.asyncio.sleep(0.025) + self.assertGreaterEqual(fake_bot.send_chat_action.await_count, 2) + fake_bot.send_chat_action.assert_any_await(123, "typing") + + async def test_stops_refresh_after_context(self): + fake_bot = AsyncMock() + with patch.object(bot, "TYPING_REFRESH_SECONDS", 0.01): + async with bot.typing_status(fake_bot, 123): + await bot.asyncio.sleep(0.015) + calls_after_exit = fake_bot.send_chat_action.await_count + await bot.asyncio.sleep(0.03) + self.assertEqual(fake_bot.send_chat_action.await_count, calls_after_exit) + + +if __name__ == "__main__": + unittest.main() diff --git a/user_store.py b/user_store.py index b03250b..4c44411 100644 --- a/user_store.py +++ b/user_store.py @@ -1,3 +1,4 @@ +from contextlib import closing import os import sqlite3 from pathlib import Path @@ -17,7 +18,7 @@ def connect(db_path: str | None = None) -> sqlite3.Connection: def init_db(db_path: str | None = None) -> None: - with connect(db_path) as connection: + with closing(connect(db_path)) as connection, connection: connection.execute( """ CREATE TABLE IF NOT EXISTS users ( @@ -31,7 +32,7 @@ def init_db(db_path: str | None = None) -> None: def get_tone_of_voice(user_id: int, db_path: str | None = None) -> str: - with connect(db_path) as connection: + with closing(connect(db_path)) as connection, connection: connection.execute( "INSERT OR IGNORE INTO users (telegram_user_id) VALUES (?)", (user_id,) ) @@ -43,7 +44,7 @@ def get_tone_of_voice(user_id: int, db_path: str | None = None) -> str: 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 connect(db_path) as connection: + with closing(connect(db_path)) as connection, connection: connection.execute( """ INSERT INTO users (telegram_user_id, tone_of_voice, updated_at)