feat: show typing status during processing

This commit is contained in:
admin committed 2026-10-04 21:14:05 +02:00
1 parent 6abea3acc8
commit 33cc2b8a15
4 files changed
+116 -52

No files matched your search

+84 -48
View File
@@ -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("Не удалось обработать голосовое. Попробуй ещё раз или напиши текстом.")