141 lines
5.2 KiB
Python
141 lines
5.2 KiB
Python
import asyncio
|
|
import io
|
|
import logging
|
|
import os
|
|
from typing import Any
|
|
|
|
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
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = Router()
|
|
APINEX_STT_URL = "https://api.apinex.bond/v1/tools/voice/stt?language=ru"
|
|
MAX_AUDIO_BYTES = 20 * 1024 * 1024
|
|
|
|
|
|
def extract_transcript(payload: dict[str, Any]) -> str:
|
|
"""Return the first APInex voice/STT transcript, or an empty string."""
|
|
channels = payload.get("results", {}).get("channels", [])
|
|
for channel in channels:
|
|
alternatives = channel.get("alternatives", [])
|
|
if alternatives:
|
|
transcript = alternatives[0].get("transcript", "")
|
|
if transcript and transcript.strip():
|
|
return transcript.strip()
|
|
return ""
|
|
|
|
|
|
def split_text(text: str, limit: int = 4000) -> list[str]:
|
|
"""Split a long transcript into Telegram-safe chunks, preferring word boundaries."""
|
|
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
|
|
|
|
|
|
async def transcribe_audio(audio: bytes, content_type: str, api_key: str) -> str:
|
|
timeout = aiohttp.ClientTimeout(total=120)
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": content_type,
|
|
"Accept": "application/json",
|
|
}
|
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
async with session.post(APINEX_STT_URL, data=audio, headers=headers) as response:
|
|
if response.status != 200:
|
|
logger.warning("APInex STT returned HTTP %s", response.status)
|
|
raise RuntimeError("APInex STT request failed")
|
|
try:
|
|
payload = await response.json(content_type=None)
|
|
except (aiohttp.ContentTypeError, ValueError) as exc:
|
|
raise RuntimeError("APInex STT returned invalid JSON") from exc
|
|
return extract_transcript(payload)
|
|
|
|
|
|
@router.message(CommandStart())
|
|
async def start(message: Message) -> None:
|
|
await message.answer("Тестовый бот запущен. Отправь текст — я повторю его, или голосовое — расшифрую.")
|
|
|
|
|
|
@router.message(F.voice | F.audio)
|
|
async def transcribe_voice(message: Message, bot: Bot) -> None:
|
|
api_key = os.environ.get("APINEX_API_KEY", "").strip()
|
|
if not api_key:
|
|
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, api_key)
|
|
except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc:
|
|
logger.warning("Voice transcription failed: %s", type(exc).__name__)
|
|
await message.answer("Не удалось расшифровать аудио. Попробуй ещё раз позже.")
|
|
return
|
|
except Exception:
|
|
logger.exception("Unexpected voice transcription failure")
|
|
await message.answer("Произошла ошибка при обработке аудио.")
|
|
return
|
|
|
|
if not transcript:
|
|
await message.answer("Не удалось распознать речь в аудио.")
|
|
return
|
|
for chunk in split_text(transcript):
|
|
await message.answer(chunk)
|
|
|
|
|
|
@router.message(F.text & ~F.text.startswith("/"))
|
|
async def echo(message: Message) -> None:
|
|
if message.text:
|
|
await message.answer(message.text)
|
|
|
|
|
|
async def main() -> None:
|
|
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())
|