200 lines
9.0 KiB
Python
200 lines
9.0 KiB
Python
import asyncio
|
|
import io
|
|
import logging
|
|
import os
|
|
|
|
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 apinex_client import APINEX_STT_URL, classify_intent, extract_tone_from_image, extract_transcript, generate_posts, transcribe_audio
|
|
from user_store import get_tone_of_voice, init_db, save_tone_of_voice
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = Router()
|
|
MAX_AUDIO_BYTES = 20 * 1024 * 1024
|
|
MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
|
|
|
|
|
def split_text(text: str, limit: int = 4000) -> list[str]:
|
|
chunks: list[str] = []
|
|
remaining = text.strip()
|
|
while len(remaining) > limit:
|
|
split_at = remaining.rfind(" ", 0, limit + 1)
|
|
if split_at < limit // 2:
|
|
split_at = limit
|
|
chunks.append(remaining[:split_at].strip())
|
|
remaining = remaining[split_at:].strip()
|
|
if remaining:
|
|
chunks.append(remaining)
|
|
return chunks
|
|
|
|
|
|
@router.message(CommandStart())
|
|
async def start(message: Message) -> None:
|
|
await message.answer(
|
|
"Я помогаю писать посты для соцсетей. Пришли скриншот своих постов, чтобы настроить стиль, "
|
|
"или попроси написать пост, например: «Придумай пост про запуск нового продукта»."
|
|
)
|
|
|
|
|
|
async def _send_three_posts(message: Message, posts: list[str]) -> None:
|
|
for index, post in enumerate(posts, start=1):
|
|
await message.answer(f"{index}/3\n{post}")
|
|
|
|
|
|
async def _classify_and_respond(message: Message, text: str) -> None:
|
|
if message.from_user is None:
|
|
await message.answer("Не удалось определить пользователя.")
|
|
return
|
|
reply_message = message.reply_to_message
|
|
original_post = ""
|
|
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("Чтобы исправить пост, ответь реплаем на сообщение с его вариантом.")
|
|
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("Не получилось подготовить посты. Попробуй ещё раз позже.")
|
|
except Exception:
|
|
logger.exception("Unexpected post generation error")
|
|
await message.answer("Произошла ошибка при подготовке постов.")
|
|
|
|
|
|
async def _download_message_image(message: Message, bot: Bot) -> tuple[bytes, str] | None:
|
|
document = message.document
|
|
if document:
|
|
mime_type = (document.mime_type or "").lower()
|
|
if not mime_type.startswith("image/"):
|
|
return None
|
|
file_obj = document
|
|
elif message.photo:
|
|
file_obj = message.photo[-1]
|
|
mime_type = "image/jpeg"
|
|
else:
|
|
return None
|
|
if file_obj.file_size and file_obj.file_size > MAX_IMAGE_BYTES:
|
|
await message.answer("Скриншот слишком большой. Пришли изображение до 10 МБ.")
|
|
return None
|
|
output = io.BytesIO()
|
|
await bot.download(file_obj, destination=output)
|
|
image = output.getvalue()
|
|
if not image:
|
|
await message.answer("Не получилось скачать изображение. Попробуй ещё раз.")
|
|
return None
|
|
if len(image) > MAX_IMAGE_BYTES:
|
|
await message.answer("Скриншот слишком большой. Пришли изображение до 10 МБ.")
|
|
return None
|
|
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:
|
|
if message.from_user is None:
|
|
await message.answer("Не удалось определить пользователя.")
|
|
return
|
|
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("Запомнила стиль твоих постов. Буду учитывать его в новых публикациях.")
|
|
except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError, ValueError) as exc:
|
|
logger.warning("Tone of voice update failed: %s", type(exc).__name__)
|
|
await message.answer("Не получилось прочитать скриншот. Попробуй отправить его ещё раз.")
|
|
except Exception:
|
|
logger.exception("Unexpected image tone extraction error")
|
|
await message.answer("Произошла ошибка при чтении скриншота.")
|
|
|
|
|
|
@router.message(F.voice | F.audio)
|
|
async def transcribe_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
|
|
|
|
content_type = "audio/ogg" if message.voice else (message.audio.mime_type or "audio/mpeg")
|
|
await bot.send_chat_action(message.chat.id, "typing")
|
|
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)
|
|
except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError, ValueError) as exc:
|
|
logger.warning("Voice post request failed: %s", type(exc).__name__)
|
|
await message.answer("Не удалось обработать голосовое. Попробуй ещё раз или напиши текстом.")
|
|
except Exception:
|
|
logger.exception("Unexpected voice post processing failure")
|
|
await message.answer("Произошла ошибка при обработке голосового.")
|
|
|
|
|
|
@router.message(F.text & ~F.text.startswith("/"))
|
|
async def handle_text(message: Message) -> None:
|
|
if message.text:
|
|
await _classify_and_respond(message, message.text)
|
|
|
|
|
|
async def main() -> None:
|
|
init_db()
|
|
token = os.environ.get("BOT_TOKEN", "").strip()
|
|
if not token:
|
|
raise RuntimeError("BOT_TOKEN is required")
|
|
|
|
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)
|
|
dp = Dispatcher()
|
|
dp.include_router(router)
|
|
|
|
try:
|
|
await dp.start_polling(bot)
|
|
finally:
|
|
await bot.session.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|