diff --git a/memory/ontology/graph.jsonl b/memory/ontology/graph.jsonl index af3330b..aa89eec 100644 --- a/memory/ontology/graph.jsonl +++ b/memory/ontology/graph.jsonl @@ -88,3 +88,4 @@ {"op":"update","id":"proj_proxy_vm","properties":{"description":"Proxy VM (vpn-srv) — VLESS Reality прокси. Задача #1 (Wi-Fi Homenet_vpn transparent proxy) — ГОТОВО (TPROXY, 12.04.2026). Задача #2 (HA Telegram) — DONE.","status":"active"},"timestamp":"2026-04-12T08:26:00Z"} {"op": "update", "id": "proj_proxy_vm", "properties": {"description": "Proxy VM (vpn-srv) \u2014 VLESS Reality \u043f\u0440\u043e\u043a\u0441\u0438. \u0417\u0430\u0434\u0430\u0447\u0430 #1 (Wi-Fi Homenet_vpn transparent proxy) \u2014 DONE (nat REDIRECT + MSS clamp 1280, 12.04.2026). \u0417\u0430\u0434\u0430\u0447\u0430 #2 (HA Telegram \u0447\u0435\u0440\u0435\u0437 SOCKS5) \u2014 DONE (10.04.2026)."}, "timestamp": "2026-04-12T12:10:52.824669+00:00"} {"op":"create","entity":{"id":"proj_kids_helper","type":"Project","properties":{"title":"Детский помощник — агент для родителей","description":"Персональный помощник по вопросам ребёнка: одежда, обувь, игрушки, обучение, события. Отдельный Telegram-бот, изолированная память. Workspace: workspace-kids. ТЗ: tasks/kids-helper/TZ.md.","status":"todo","priority":"medium","created":"2026-04-12T16:54:00Z","labels":["agent","kids","telegram"],"path":"tasks/kids-helper/"},"created":"2026-04-12T16:54:00Z"} +{"op":"create","entity":{"id":"proj_bytik","type":"Project","properties":{"title":"Байтик — детский ИИ-помощник","description":"Telegram-бот Байтик: ИИ-помощник для Егора (8 лет, ДР 17.04.2018). Отвечает на вопросы, утренняя рассылка 7:30 MSK с энциклопедическими фактами, поздравления с праздниками. Строгая детская цензура. LLM: Qwen 3.6-Plus. Инфраструктура: mva154, изолированный workspace.","status":"planned","created":"2026-04-14T21:55:00Z","path":"tasks/bytik/","labels":["agent","telegram","kids","ai"]},"created":"2026-04-14T21:55:00Z"}} diff --git a/tasks/bytik/.env.example b/tasks/bytik/.env.example new file mode 100644 index 0000000..2517aee --- /dev/null +++ b/tasks/bytik/.env.example @@ -0,0 +1,5 @@ +BOT_TOKEN=your_telegram_bot_token +ALLOWED_CHAT_ID=your_group_chat_id +OPENROUTER_API_KEY=your_openrouter_api_key +OPENROUTER_MODEL=openrouter/qwen/qwen3.6-plus +ELEVENLABS_API_KEY=your_elevenlabs_api_key diff --git a/tasks/bytik/TZ.md b/tasks/bytik/TZ.md new file mode 100644 index 0000000..78a8256 --- /dev/null +++ b/tasks/bytik/TZ.md @@ -0,0 +1,172 @@ +# ТЗ: Telegram-бот «Байтик» 🤖 + +> Детский ИИ-помощник для Егора (8 лет, ДР 17.04.2018) +> Проект: `proj_bytik` | Дата создания: 2026-04-14 + +--- + +## 1. Цель + +Telegram-бот, который: +- Отвечает на вопросы ребёнка простым языком +- Присылает 1-2 раза в день энциклопедические факты (утро 7:30 MSK) +- Поздравляет с праздниками и ДР +- Работает **только в одной группе** (whitelist chat_id) +- Строгая детская цензура 0+ + +--- + +## 2. Требования к боту + +### 2.1 Whitelist (критично) +- **Разрешённый chat_id:** будет прописан в `.env` (`ALLOWED_CHAT_ID`) +- Бот принимает сообщения **только** из этой группы +- Сообщения из ЛС и других групп — **полный игнор** (без ответа) +- При добавлении в новую группу — автоматически `leave_chat` +- Проверка: `message.chat.id` для text, `callback_query.message.chat.id` для inline + +### 2.2 Обработка сообщений +- Получает текст от пользователя в разрешённом чате +- Отправляет промпт в LLM (OpenRouter API, Qwen 3.6-Plus) +- Получает ответ → отправляет в Telegram +- Поддерживает диалог (хранит последние 5-10 сообщений как контекст) + +### 2.3 Системный промпт (обязателен) +``` +Ты — Байтик, дружелюбный робот-помощник для мальчика Егора (8 лет). + +Правила: +- Объясняй сложные вещи просто, с примерами, которые понятны 8-летнему +- Используй эмодзи, чтобы было весело +- Если спрашивают на русском — отвечай на русском +- Иногда (когда уместно) отвечай на простом английском, по типу "Did you know..." — это помогает учить язык +- Если вопрос про что-то тревожное (смерть, война, болезни): мягко уйди от темы, а если настаивает — скажи "Это серьёзный вопрос, лучше спроси у папы!" +- НИКОГДА не используй ненормативную лексику, страшные подробности, или взрослые темы +- Если не знаешь ответа — честно скажи, но предложи "давай поищем вместе!" +- Иногда используй смешные факты и шутки +- Будь терпелив — ребёнок может задавать один и тот же вопрос много раз +- Используй короткие ответы для детей до 12 лет, не более 200 слов +``` + +### 2.4 Утренняя рассылка +- **Время:** 07:30 MSK (04:30 UTC) +- Присылать 1 интересный энциклопедический факт +- Темы по интересам Егора: машины, самолёты, корабли (Титаник!), лего, животные, география, математика, IT, Minecraft +- **Формат:** текст + эмодзи, иногда картинка +- Можно иногда отправлять голосовое сообщение (через ElevenLabs TTS) + +### 2.5 Праздники и ДР +- **17 апреля:** поздравление с ДР (особое, весёлое) +- **1 января:** Новый Год +- **23 февраля:** День защитника Отечества +- **8 марта:** Международный женский день +- **12 апреля:** День космонавтики (Егор любит, будет в тему!) +- **Другие:** можно добавить в конфиг + +### 2.6 Голосовые сообщения (опционально) +- Иногда факты/ответы — голосом +- ElevenLabs TTS (или Yandex SpeechKit fallback) +- Конвертация в OGG Opus (требование Telegram) + +--- + +## 3. Архитектура + +### 3.1 Технологии +- **Язык:** Python 3.11+ +- **Telegram:** `aiogram` 3.x +- **LLM:** OpenRouter API (`openrouter/openrouter/auto` или явно `openrouter/qwen/qwen3.6-plus`) +- **TTS (опционально):** ElevenLabs API / Yandex SpeechKit +- **Конфиг:** `.env` (python-dotenv) +- **Хранение диалога:** JSON-файлы по user_id в `data/chat_history/` + +### 3.2 Структура проекта +``` +bytik/ +├── bot.py # Точка входа +├── config.py # Загрузка конфига из .env +├── prompts.py # Системный промпт + факты +├── llm.py # Обёртка над OpenRouter API +├── tts.py # Голосовые сообщения +├── scheduler.py # Планировщик (рассылка + праздники) +├── chat_history.py # Хранение контекста диалогов +├── facts.json # Энциклопедические факты +├── holidays.json # Праздники +├── data/ +│ └── chat_history/ # JSON файлы с историей +├── .env # Секреты +├── .env.example # Пример +└── requirements.txt # Зависимости +``` + +### 3.3 .env.example +``` +BOT_TOKEN= +ALLOWED_CHAT_ID= +OPENROUTER_API_KEY= +OPENROUTER_MODEL=openrouter/qwen/qwen3.6-plus +ELEVENLABS_API_KEY= # optional +``` + +--- + +## 4. Инфраструктура + +- **Хост:** mva154 (контейнер OpenClaw) или vpn-srv +- **Запуск:** как Docker container (предпочтительно) или systemd service +- **Бэкапы:** chat_history JSON-файлы +- **Логи:** `logs/bytik.log` + +--- + +## 5. Безопасность + +### 5.1 Whitelist — ОБЯЗАТЕЛЬНО +- Все входящие сообщения проходят фильтр `message.chat.id == ALLOWED_CHAT_ID` +- Невалидные сообщения — молчаливый игнор +- При `my_chat_member: left` или добавлении в новую группу → `leave_chat()` + +### 5.2 Контент-фильтр +- LLM промпт уже содержит ограничения +- Валидация ответа: если содержит мат / adult контент → не отправлять, логировать +- Можно добавить дополнительный фильтр (список запрещённых слов) + +--- + +## 6. Этапы реализации + +### Фаза 1 (MVP) +- Бот принимает сообщения из whitelist группы +- Отправляет в LLM, получает ответ, шлёт в Telegram +- Системный промпт с детской цензурой + +### Фаза 2 +- Утренняя рассылка (7:30 MSK) +- Праздничные поздравления +- Контекст диалога (последние N сообщений) + +### Фаза 3 +- Голосовые сообщения (TTS) +- Картинки (генерация или подбор) + +--- + +## 7. Критерии приёмки + +- [ ] Бот отвечает только в разрешённой группе +- [ ] Игнорирует ЛС и другие группы +- [ ] Системный промпт блокирует взрослый контент +- [ ] Утренняя рассылка в 07:30 MSK +- [ ] Поздравление с ДР Егора (17.04) +- [ ] Ответы простые, понятные 8-летнему +- [ ] Контекст диалога сохраняется +- [ ] Логи пишутся в файл + +--- + +## 8. Примечания + +- День рождения Егора — 17.04.2018 (скоро 8 лет!) +- Интересы: машины, самолёты, корабли, лего, животные, география, математика, IT, Minecraft +- LLM: **Qwen 3.6-Plus** (OpenRouter) +- Дедлайн: фаза 1 готова к 16.04.2026 (чтобы ДР поздравить!) diff --git a/tasks/bytik/bot.py b/tasks/bytik/bot.py new file mode 100644 index 0000000..a572d7a --- /dev/null +++ b/tasks/bytik/bot.py @@ -0,0 +1,121 @@ +import asyncio +import logging +import os +from logging.handlers import RotatingFileHandler +from aiogram import Bot, Dispatcher, types +from aiogram.filters import Command +from aiogram.types import BotCommand + +from config import BOT_TOKEN, ALLOWED_CHAT_ID +from prompts import SYSTEM_PROMPT +from llm import ask_llm +from chat_history import load_history, add_message +from scheduler import BytikScheduler + +LOG_DIR = "logs" +os.makedirs(LOG_DIR, exist_ok=True) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[ + RotatingFileHandler( + os.path.join(LOG_DIR, "bytik.log"), + maxBytes=5*1024*1024, + backupCount=3, + encoding="utf-8", + ), + logging.StreamHandler(), + ], +) +logger = logging.getLogger(__name__) + +bot = Bot(token=BOT_TOKEN) +dp = Dispatcher() +scheduler = None + +def is_allowed_chat(chat_id: int) -> bool: + return chat_id == ALLOWED_CHAT_ID + +@dp.message(Command("start")) +async def cmd_start(message: types.Message): + if not is_allowed_chat(message.chat.id): + logger.warning(f"Попытка /start из неразрешённого чата: {message.chat.id}") + return + await message.answer("Привет, Егор! 👋 Я Байтик, твой робот-помощник! Спрашивай меня о чём угодно! 🤖") + +@dp.message(Command("clear")) +async def cmd_clear(message: types.Message): + if not is_allowed_chat(message.chat.id): + return + from chat_history import clear_history + clear_history(message.from_user.id) + await message.answer("История наших разговоров очищена! 🧹") + +@dp.message() +async def handle_message(message: types.Message): + if not message.text: + return + + if not is_allowed_chat(message.chat.id): + logger.debug(f"Игнорирую сообщение из чата {message.chat.id}") + return + + user_message = message.text.strip() + logger.info(f"Сообщение от user {message.from_user.id}: {user_message[:100]}") + + typing_msg = await message.answer("Думаю... 🤔") + + history = load_history(message.from_user.id) + messages_for_llm = [{"role": "system", "content": SYSTEM_PROMPT}] + history + messages_for_llm.append({"role": "user", "content": user_message}) + + add_message(message.from_user.id, "user", user_message) + + answer = await ask_llm(messages_for_llm) + + add_message(message.from_user.id, "assistant", answer) + + try: + await typing_msg.edit_text(answer) + except Exception: + await message.answer(answer) + +@dp.my_chat_member() +async def handle_bot_status_change(update: types.ChatMemberUpdated): + chat_id = update.chat.id + new_status = update.new_chat_member.status + + if new_status == "member" and chat_id != ALLOWED_CHAT_ID: + logger.warning(f"Бот добавлен в неразрешённый чат {chat_id}. Покидаю.") + await bot.leave_chat(chat_id) + elif new_status == "left": + logger.info(f"Бот удалён из чата {chat_id}") + +async def set_commands(): + commands = [ + BotCommand(command="start", description="Начать общение"), + BotCommand(command="clear", description="Очистить историю диалога"), + ] + await bot.set_my_commands(commands) + +async def main(): + global scheduler + await set_commands() + + scheduler = BytikScheduler(bot) + scheduler.start() + + logger.info(f"Бот запущен. Whitelist chat_id: {ALLOWED_CHAT_ID}") + await dp.start_polling(bot) + +async def on_shutdown(): + if scheduler: + scheduler.shutdown() + await bot.session.close() + +if __name__ == "__main__": + try: + asyncio.run(main()) + except (KeyboardInterrupt, SystemExit): + logger.info("Бот остановлен") diff --git a/tasks/bytik/chat_history.py b/tasks/bytik/chat_history.py new file mode 100644 index 0000000..8ed8ae2 --- /dev/null +++ b/tasks/bytik/chat_history.py @@ -0,0 +1,45 @@ +import json +import os +import logging +from typing import List, Dict + +logger = logging.getLogger(__name__) + +HISTORY_DIR = "data/chat_history" +MAX_MESSAGES = 10 + +os.makedirs(HISTORY_DIR, exist_ok=True) + +def _get_file_path(user_id: int) -> str: + return os.path.join(HISTORY_DIR, f"{user_id}.json") + +def load_history(user_id: int) -> List[Dict[str, str]]: + path = _get_file_path(user_id) + if not os.path.exists(path): + return [] + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return [] + +def save_history(user_id: int, history: List[Dict[str, str]]): + path = _get_file_path(user_id) + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(history, f, ensure_ascii=False, indent=2) + except IOError as e: + logger.error(f"Не удалось сохранить историю для user {user_id}: {e}") + +def add_message(user_id: int, role: str, content: str): + history = load_history(user_id) + history.append({"role": role, "content": content}) + if len(history) > MAX_MESSAGES: + history = history[-MAX_MESSAGES:] + save_history(user_id, history) + +def clear_history(user_id: int): + path = _get_file_path(user_id) + if os.path.exists(path): + os.remove(path) + logger.info(f"История очищена для user {user_id}") diff --git a/tasks/bytik/config.py b/tasks/bytik/config.py new file mode 100644 index 0000000..c190d43 --- /dev/null +++ b/tasks/bytik/config.py @@ -0,0 +1,15 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +BOT_TOKEN = os.getenv("BOT_TOKEN") +ALLOWED_CHAT_ID = int(os.getenv("ALLOWED_CHAT_ID", "0")) +OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") +OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "openrouter/qwen/qwen3.6-plus") +ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY") + +if not BOT_TOKEN: + raise ValueError("BOT_TOKEN не установлен в .env") +if not OPENROUTER_API_KEY: + raise ValueError("OPENROUTER_API_KEY не установлен в .env") diff --git a/tasks/bytik/llm.py b/tasks/bytik/llm.py new file mode 100644 index 0000000..fb20518 --- /dev/null +++ b/tasks/bytik/llm.py @@ -0,0 +1,54 @@ +import aiohttp +import logging +from config import OPENROUTER_API_KEY, OPENROUTER_MODEL + +logger = logging.getLogger(__name__) + +FORBIDDEN_WORDS = [ + "мат", "блять", "сука", "хуй", "пизда", "ебать", + "fuck", "shit", "bitch", "asshole", +] + +def is_safe_text(text: str) -> bool: + text_lower = text.lower() + for word in FORBIDDEN_WORDS: + if word in text_lower: + logger.warning(f"Обнаружено запрещённое слово: {word}") + return False + return True + +async def ask_llm(messages: list) -> str: + url = "https://openrouter.ai/api/v1/chat/completions" + headers = { + "Authorization": f"Bearer {OPENROUTER_API_KEY}", + "Content-Type": "application/json", + "HTTP-Referer": "https://github.com/bytik-bot", + "X-Title": "Bytik Bot", + } + payload = { + "model": OPENROUTER_MODEL, + "messages": messages, + "max_tokens": 500, + "temperature": 0.7, + } + + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, json=payload, headers=headers) as resp: + if resp.status != 200: + error_text = await resp.text() + logger.error(f"OpenRouter error: {resp.status} - {error_text}") + return "Ой, я что-то запутался. Попробуй спросить ещё раз! 🤔" + + data = await resp.json() + answer = data["choices"][0]["message"]["content"].strip() + + if not is_safe_text(answer): + logger.warning("Ответ от LLM содержит запрещённые слова. Блокируем.") + return "Извини, я не могу это обсудить. Давай поговорим о чём-то другом! 😊" + + return answer + + except Exception as e: + logger.error(f"Ошибка при запросе к LLM: {e}") + return "Извини, я пока не могу ответить. Попробуй позже! 🤖" diff --git a/tasks/bytik/prompts.py b/tasks/bytik/prompts.py new file mode 100644 index 0000000..de42fe8 --- /dev/null +++ b/tasks/bytik/prompts.py @@ -0,0 +1,41 @@ +SYSTEM_PROMPT = """Ты — Байтик, дружелюбный робот-помощник для мальчика Егора (8 лет). + +Правила: +- Объясняй сложные вещи просто, с примерами, которые понятны 8-летнему +- Используй эмодзи, чтобы было весело 😊 +- Если спрашивают на русском — отвечай на русском +- Иногда (когда уместно) отвечай на простом английском, по типу "Did you know..." — это помогает учить язык +- Если вопрос про что-то тревожное (смерть, война, болезни): мягко уйди от темы, а если настаивает — скажи "Это серьёзный вопрос, лучше спроси у папы!" +- НИКОГДА не используй ненормативную лексику, страшные подробности, или взрослые темы +- Если не знаешь ответа — честно скажи, но предложи "давай поищем вместе!" +- Иногда используй смешные факты и шутки +- Будь терпелив — ребёнок может задавать один и тот же вопрос много раз +- Используй короткие ответы для детей до 12 лет, не более 200 слов +""" + +FACTS = [ + "🚗 Знаешь ли ты, что самый быстрый автомобиль в мире развивает скорость более 490 км/ч? Это быстрее, чем летит самолёт при посадке!", + "✈️ Самая большая птица в мире — альбатрос. Размах его крыльев может быть больше 3 метров!", + "🚢 Титаник был самым большим кораблём своего времени. Его длина была как 4 футбольных поля!", + "🧱 Из обычного набора LEGO можно собрать более 915 миллионов разных комбинаций!", + "🦁 Лев рычит так громко, что его слышно за 8 километров. Это как услышать крик друга с другого конца города!", + "🌍 Самая большая страна в мире — Россия. Чтобы пересечь её на поезде, нужно ехать целую неделю!", + "🔢 Математика: если сложить все числа от 1 до 100, получится 5050. Попробуй проверить!", + "💻 Первый компьютер был размером с большую комнату. А теперь помещается в карман!", + "⛏️ В Minecraft один блок = 1 кубический метр. Значит, если ты строишь дом 10x10, он размером с настоящую комнату!", + "🚀 12 апреля 1961 года Юрий Гагарин стал первым человеком в космосе. Его полёт длился всего 108 минут, но это изменило весь мир!", + "🐘 Слон — единственное животное, которое не умеет прыгать. Но он может бегать со скоростью 40 км/ч!", + "🌊 В океане больше историй, чем во всех книгах мира. Мы исследовали только 5% океана!", + "🦕 Динозавры жили на Земле 165 миллионов лет. А люди — всего 300 тысяч лет!", + "🎮 Самая первая видеоигра называлась Pong. В ней был только мячик и две ракетки!", + "🌙 На Луне нет ветра, поэтому следы астронавтов останутся там на миллионы лет!", +] + +HOLIDAYS = { + "01-01": "🎄 С Новым Годом, Егор! Пусть этот год будет полон приключений, новых открытий и крутых подарков! 🎁", + "02-23": "🎖️ С Днём защитника Отечества! Ты будущий защитник, как папа! 💪", + "03-08": "🌸 С 8 Марта! Поздравь маму и всех девочек вокруг! Они самые лучшие! 💐", + "04-12": "🚀 С Днём космонавтики! Сегодня день, когда человек впервые полетел в космос! Ты тоже когда-нибудь полетишь? ⭐", + "04-17": "🎉🎂 С ДНЁМ РОЖДЕНИЯ, ЕГОР! Тебе сегодня 8 лет! Расти умным, смелым и счастливым! Пусть сбудутся все мечты! 🎈🎁🚀", + "05-09": "🎖️ С Днём Победы! Это день памяти и благодарности тем, кто защитил нашу страну.", +} diff --git a/tasks/bytik/requirements.txt b/tasks/bytik/requirements.txt new file mode 100644 index 0000000..15dc0f0 --- /dev/null +++ b/tasks/bytik/requirements.txt @@ -0,0 +1,4 @@ +aiogram>=3.0.0 +python-dotenv>=1.0.0 +aiohttp>=3.8.0 +APScheduler>=3.10.0 diff --git a/tasks/bytik/scheduler.py b/tasks/bytik/scheduler.py new file mode 100644 index 0000000..e0431ac --- /dev/null +++ b/tasks/bytik/scheduler.py @@ -0,0 +1,53 @@ +import logging +from datetime import datetime +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from prompts import FACTS, HOLIDAYS +from config import ALLOWED_CHAT_ID + +logger = logging.getLogger(__name__) + +class BytikScheduler: + def __init__(self, bot): + self.bot = bot + self.scheduler = AsyncIOScheduler(timezone="Europe/Moscow") + + def start(self): + self.scheduler.add_job( + self.send_morning_fact, + "cron", + hour=7, + minute=30, + timezone="Europe/Moscow", + ) + self.scheduler.add_job( + self.check_holidays, + "cron", + hour=8, + minute=0, + timezone="Europe/Moscow", + ) + self.scheduler.start() + logger.info("Планировщик запущен") + + def shutdown(self): + self.scheduler.shutdown() + logger.info("Планировщик остановлен") + + async def send_morning_fact(self): + import random + fact = random.choice(FACTS) + try: + await self.bot.send_message(chat_id=ALLOWED_CHAT_ID, text=fact) + logger.info(f"Утренняя рассылка отправлена: {fact[:50]}...") + except Exception as e: + logger.error(f"Ошибка утренней рассылки: {e}") + + async def check_holidays(self): + today = datetime.now().strftime("%m-%d") + if today in HOLIDAYS: + message = HOLIDAYS[today] + try: + await self.bot.send_message(chat_id=ALLOWED_CHAT_ID, text=message) + logger.info(f"Праздничное поздравление отправлено: {today}") + except Exception as e: + logger.error(f"Ошибка отправки праздничного сообщения: {e}") diff --git a/tasks/bytik/tts.py b/tasks/bytik/tts.py new file mode 100644 index 0000000..c850bd9 --- /dev/null +++ b/tasks/bytik/tts.py @@ -0,0 +1,80 @@ +import os +import subprocess +import tempfile +import logging +from config import ELEVENLABS_API_KEY + +logger = logging.getLogger(__name__) + +async def text_to_speech(text: str) -> bytes | None: + if not ELEVENLABS_API_KEY: + logger.debug("ElevenLabs API key не установлен, TTS пропущен") + return None + + logger.info(f"Генерация TTS: {text[:50]}...") + + try: + import aiohttp + headers = { + "xi-api-key": ELEVENLABS_API_KEY, + "Content-Type": "application/json", + } + payload = { + "text": text, + "model_id": "eleven_monolingual_v1", + "voice_settings": { + "stability": 0.5, + "similarity_boost": 0.75, + } + } + + async with aiohttp.ClientSession() as session: + async with session.post( + "https://api.elevenlabs.io/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM/stream", + json=payload, + headers=headers, + ) as resp: + if resp.status != 200: + logger.error(f"ElevenLabs error: {resp.status}") + return None + mp3_data = await resp.read() + + mp3_path = None + ogg_path = None + try: + with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as mp3_tmp: + mp3_tmp.write(mp3_data) + mp3_path = mp3_tmp.name + + ogg_fd, ogg_path = tempfile.mkstemp(suffix=".ogg") + os.close(ogg_fd) + + ffmpeg_cmd = [ + "ffmpeg", "-y", + "-i", mp3_path, + "-acodec", "libopus", + "-b:a", "48k", + "-vbr", "on", + ogg_path, + ] + subprocess.run(ffmpeg_cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + with open(ogg_path, "rb") as f: + ogg_data = f.read() + + return ogg_data + + except Exception as e: + logger.error(f"Ошибка при конвертации TTS: {e}") + return None + finally: + for path in [mp3_path, ogg_path]: + if path and os.path.exists(path): + os.remove(path) + + except ImportError: + logger.warning("aiohttp не установлен, TTS недоступен") + return None + except Exception as e: + logger.error(f"Ошибка TTS: {e}") + return None