445 lines
23 KiB
Python
445 lines
23 KiB
Python
import os
|
||
import re
|
||
import logging
|
||
import asyncio
|
||
import sqlite3
|
||
import subprocess
|
||
import urllib.request
|
||
import json
|
||
from datetime import datetime
|
||
from aiogram import Router, F, Bot
|
||
from aiogram.types import Message, BufferedInputFile
|
||
from config import ADMIN_USER_ID, DATABASE_PATH
|
||
import google.generativeai as genai
|
||
import edge_tts
|
||
import aiohttp
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = Router()
|
||
|
||
# Валидный проверенный ключ Gemini API
|
||
WORKING_GEMINI_KEY = "AIzaSyAJMfQ_mRZ1picRXVFNaeFJ7fpAkZxUKyk"
|
||
genai.configure(api_key=WORKING_GEMINI_KEY)
|
||
|
||
# Вебхук Битрикс24 холдинга (ecomsystems.bitrix24.ru)
|
||
B24_WEBHOOK = "https://ecomsystems.bitrix24.ru/rest/1424/yqt0a18d9qwf0l4r/"
|
||
|
||
def query_bitrix24(action: str, task_id: int = 0, search_text: str = "") -> str:
|
||
"""Инструмент для работы с Битрикс24 холдинга (ecomsystems.bitrix24.ru).
|
||
Александр Тырин: ID 1424, Илья Гавришин (Генеральный директор): ID 1.
|
||
Доступные действия (action):
|
||
- 'my_tasks': список активных задач Александра Тырина.
|
||
- 'search_tasks': поиск задач по ключевому слову search_text в названии.
|
||
- 'task_details': детальная информация по задаче с ID task_id.
|
||
- 'task_comments': все комментарии к задаче с ID task_id (включая комментарии гендира и юристов).
|
||
"""
|
||
logger.info(f"🏢 [Bitrix24 Query] action={action}, task_id={task_id}, search_text='{search_text}'")
|
||
try:
|
||
if action == "my_tasks":
|
||
url = f"{B24_WEBHOOK}tasks.task.list.json"
|
||
payload = json.dumps({
|
||
"filter": {"RESPONSIBLE_ID": 1424, "!STATUS": 5},
|
||
"select": ["ID", "TITLE", "STATUS", "CREATED_DATE", "DEADLINE"]
|
||
}).encode('utf-8')
|
||
elif action == "search_tasks":
|
||
url = f"{B24_WEBHOOK}tasks.task.list.json"
|
||
payload = json.dumps({
|
||
"filter": {"%TITLE": search_text},
|
||
"select": ["ID", "TITLE", "STATUS", "RESPONSIBLE_ID", "CREATED_BY"]
|
||
}).encode('utf-8')
|
||
elif action == "task_details":
|
||
url = f"{B24_WEBHOOK}tasks.task.get.json"
|
||
payload = json.dumps({
|
||
"taskId": task_id,
|
||
"select": ["ID", "TITLE", "DESCRIPTION", "STATUS", "CREATED_BY", "RESPONSIBLE_ID"]
|
||
}).encode('utf-8')
|
||
elif action == "task_comments":
|
||
url = f"{B24_WEBHOOK}task.commentitem.getlist.json"
|
||
payload = json.dumps([task_id]).encode('utf-8')
|
||
else:
|
||
return f"Неизвестное действие: {action}"
|
||
|
||
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
|
||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||
data = json.loads(resp.read().decode('utf-8'))
|
||
res = data.get("result", {})
|
||
return json.dumps(res, ensure_ascii=False)[:3500]
|
||
except Exception as e:
|
||
logger.error(f"❌ Bitrix24 Error: {e}")
|
||
return f"Ошибка обращения к Битрикс24: {e}"
|
||
|
||
def execute_bash_command(command: str) -> str:
|
||
"""Выполняет bash-команду на хосте или по SSH и возвращает результат вывода."""
|
||
logger.info(f"⚡️ [Voice Agent Command] Executing bash: {command}")
|
||
try:
|
||
res = subprocess.run(
|
||
command,
|
||
shell=True,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=45
|
||
)
|
||
out = (res.stdout or "") + ("\n" + res.stderr if res.stderr else "")
|
||
output_str = out.strip()
|
||
if len(output_str) > 3000:
|
||
output_str = output_str[:3000] + "\n... [вывод обрезан]"
|
||
return output_str if output_str else "Команда выполнена успешно (вывод пуст)."
|
||
except Exception as e:
|
||
logger.error(f"❌ Bash Execution Error: {e}")
|
||
return f"Ошибка выполнения bash-команды: {e}"
|
||
|
||
def save_to_obsidian(section: str, subfolder: str, filename: str, title: str, content: str, tags: str = "") -> str:
|
||
"""Сохранение надиктованной мысли, заметки или плана в Obsidian Vault на Synology NAS.
|
||
section: один из 3 разделов ('HomeLab', 'Work', 'Свалка').
|
||
subfolder: подкаталог внутри раздела (например: 'Bicycle_Ebike', 'Housing_LKM37', 'AI_Swarm', 'Infrastructure', 'Visma', 'Bitrix24', 'General').
|
||
filename: имя файла на латинице (например: 'ebike_battery_upgrade.md').
|
||
title: заголовок заметки.
|
||
content: структурированное содержимое заметки (Суть, Action Items [ ], Идеи в копилку).
|
||
tags: ключевые теги через пробел или запятую.
|
||
"""
|
||
logger.info(f"🗄️ [Obsidian Tool] section={section}, subfolder={subfolder}, file={filename}")
|
||
try:
|
||
base_vault = "/vault"
|
||
if not os.path.exists(base_vault):
|
||
base_vault = "/mnt/nas/obsidian/data/vault"
|
||
|
||
valid_sections = ["HomeLab", "Work", "Свалка"]
|
||
matched_section = "HomeLab"
|
||
for s in valid_sections:
|
||
if s.lower() == (section or "").lower():
|
||
matched_section = s
|
||
break
|
||
|
||
target_dir = os.path.join(base_vault, matched_section)
|
||
if subfolder:
|
||
target_dir = os.path.join(target_dir, subfolder)
|
||
os.makedirs(target_dir, exist_ok=True)
|
||
|
||
clean_fn = re.sub(r'[^a-zA-Z0-9_\-\.]', '_', filename) if filename else f"note_{int(datetime.now().timestamp())}.md"
|
||
if not clean_fn.endswith('.md'):
|
||
clean_fn += '.md'
|
||
|
||
filepath = os.path.join(target_dir, clean_fn)
|
||
today_str = datetime.now().strftime("%Y-%m-%d")
|
||
|
||
md_content = f"""---
|
||
date_created: {today_str}
|
||
tags:
|
||
- {matched_section.lower()}
|
||
- {subfolder.lower() if subfolder else 'notes'}
|
||
- мысли_на_ходу
|
||
---
|
||
|
||
📅 **Дата записи:** {today_str}
|
||
🔗 **Связи в Графе:** [[00_Master_Graph_Index]]
|
||
|
||
# {title}
|
||
|
||
{content}
|
||
"""
|
||
with open(filepath, "w", encoding="utf-8") as f:
|
||
f.write(md_content)
|
||
|
||
logger.info(f"✅ Успешно записано в Obsidian: {filepath}")
|
||
return f"Заметка успешно сохранена в Obsidian по пути: {matched_section}/{subfolder}/{clean_fn}"
|
||
except Exception as e:
|
||
logger.error(f"❌ Ошибка записи в Obsidian: {e}")
|
||
return f"Ошибка записи в Obsidian: {e}"
|
||
|
||
def log_voice_to_db(user_id, username, msg_type, duration, text):
|
||
"""Фиксация статистики в основную базу Домового"""
|
||
try:
|
||
conn = sqlite3.connect(DATABASE_PATH)
|
||
cursor = conn.cursor()
|
||
cursor.execute('''
|
||
CREATE TABLE IF NOT EXISTS voice_recognition_logs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER,
|
||
username TEXT,
|
||
message_type TEXT,
|
||
duration INTEGER,
|
||
text TEXT,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
''')
|
||
cursor.execute('''
|
||
INSERT INTO voice_recognition_logs (user_id, username, message_type, duration, text)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
''', (user_id, username, msg_type, duration, text))
|
||
conn.commit()
|
||
conn.close()
|
||
except Exception as e:
|
||
logger.error(f"❌ DB Log error: {e}")
|
||
|
||
async def text_to_speech_bytes(text: str) -> bytes:
|
||
"""Синтез голоса Дженис Джоплин через локальный XTTS-v2 на MacMini M4 (с фоллбэком на Edge TTS)"""
|
||
match = re.search(r"<voice>(.*?)</voice>", text, re.DOTALL | re.IGNORECASE)
|
||
if match:
|
||
speech_text = match.group(1).strip()
|
||
else:
|
||
speech_text = text[:350] if len(text) > 350 else text
|
||
|
||
# Очищаем Markdown для чистого звука
|
||
for ch in ["*", "#", "_", "`", "~", ">", "•", "🎯", "⚙️", "📋", "💡", "🗄️", "🚨", "⚠️", "✅", "❌"]:
|
||
speech_text = speech_text.replace(ch, "")
|
||
speech_text = re.sub(r"\[.*?\]\(.*?\)", "", speech_text).strip()
|
||
|
||
if not speech_text:
|
||
speech_text = "Приняла, Александр. Всё проанализировано и готово."
|
||
|
||
# 1. Приоритет: локальный синтез голосом Шаде (Sade) на MacMini M4 (XTTS-v2 Apple Metal)
|
||
try:
|
||
timeout = aiohttp.ClientTimeout(total=20)
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
payload = {
|
||
"text": speech_text,
|
||
"language": "ru",
|
||
"speaker_wav": "/Users/matrixhasyou/sade_ref_15s.wav"
|
||
}
|
||
async with session.post("http://192.168.10.167:8050/tts", json=payload) as resp:
|
||
if resp.status == 200:
|
||
data = await resp.read()
|
||
if len(data) > 1000:
|
||
logger.info(f"🎤 Голос Шаде (XTTS-v2 MacMini) успешно синтезирован ({len(data)} байт)")
|
||
return data
|
||
logger.warning(f"XTTS вернул HTTP {resp.status}, переход на Edge TTS")
|
||
except Exception as e:
|
||
logger.warning(f"XTTS на MacMini недоступен ({e}), переход на Edge TTS")
|
||
|
||
# 2. Фоллбэк: Edge TTS (ru-RU-SvetlanaNeural)
|
||
try:
|
||
communicate = edge_tts.Communicate(speech_text, "ru-RU-SvetlanaNeural", pitch="-8Hz", rate="-3%")
|
||
out_path = f"/tmp/tts_{os.getpid()}_{int(datetime.now().timestamp())}.mp3"
|
||
await communicate.save(out_path)
|
||
audio_bytes = b""
|
||
if os.path.exists(out_path):
|
||
with open(out_path, "rb") as f:
|
||
audio_bytes = f.read()
|
||
os.remove(out_path)
|
||
return audio_bytes
|
||
except Exception as e:
|
||
logger.error(f"Ошибка Edge TTS: {e}")
|
||
return b""
|
||
|
||
async def transcribe_audio(audio_path: str) -> str:
|
||
"""Распознавание речи через Gemini Multimodal Audio API:
|
||
- Идеальное распознавание Google (длинные записи, шумы, речь на ходу, естественные мысли).
|
||
- Автоматическая нормализация через ffmpeg для видеосообщений (.mp4) и голосовых (.ogg).
|
||
- Умный перебор моделей при секундных задержках/лимитах сети.
|
||
"""
|
||
clean_path = audio_path
|
||
tmp_wav = None
|
||
|
||
# Нормализация для видеосообщений и нестандартных контейнеров
|
||
if audio_path.endswith(('.mp4', '.ogg', '.m4a', '.3gp')):
|
||
tmp_wav = f"/tmp/norm_{os.getpid()}_{int(datetime.now().timestamp())}.wav"
|
||
res = subprocess.run([
|
||
'ffmpeg', '-y', '-i', audio_path, '-vn', '-ar', '16000', '-ac', '1', tmp_wav
|
||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
if res.returncode == 0 and os.path.exists(tmp_wav):
|
||
clean_path = tmp_wav
|
||
|
||
models_to_try = [
|
||
'gemini-2.5-flash',
|
||
'gemini-flash-latest',
|
||
'gemini-3.5-transcribe',
|
||
'gemini-3.5-flash'
|
||
]
|
||
|
||
def _sync_transcribe(clean_file_path: str, m_name: str) -> str:
|
||
audio_file = genai.upload_file(path=clean_file_path)
|
||
try:
|
||
prompt = (
|
||
"Точно транскрибируй эту аудиозапись на русском языке в текст. "
|
||
"Не добавляй никаких вводных слов, пояснений и комментариев, отдай исключительно чистый текст произнесенных слов."
|
||
)
|
||
model = genai.GenerativeModel(m_name)
|
||
resp = model.generate_content([audio_file, prompt])
|
||
return resp.text.strip() if resp and resp.text else ""
|
||
finally:
|
||
try:
|
||
genai.delete_file(name=audio_file.name)
|
||
except Exception:
|
||
pass
|
||
|
||
for model_name in models_to_try:
|
||
try:
|
||
logger.info(f"🎙 [Gemini STT] Распознавание {clean_path} через {model_name}...")
|
||
text = await asyncio.to_thread(_sync_transcribe, clean_path, model_name)
|
||
|
||
if text:
|
||
logger.info(f"🎯 [Gemini STT] Успешно распознано ({model_name}): {text[:70]}...")
|
||
if tmp_wav and os.path.exists(tmp_wav):
|
||
os.remove(tmp_wav)
|
||
return text
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ [Gemini STT] Ошибка на модели {model_name}: {e}")
|
||
await asyncio.sleep(0.5)
|
||
|
||
if tmp_wav and os.path.exists(tmp_wav):
|
||
os.remove(tmp_wav)
|
||
return ""
|
||
|
||
async def execute_antigravity_agent(user_text: str) -> str:
|
||
"""Обработка команды и мыслей агентом Майа с систематизацией, Битрикс24 и правом исполнения bash-команд"""
|
||
system_instruction = (
|
||
"Ты — Майа (Maya / Antigravity Lead Agent), суперинтеллектуальный помощник Александра с полными правами инженера.\n"
|
||
"Александр обращается к тебе голосом в Телеграмме или надиктованной аудиозаписью на ходу / с велосипеда / с диктофона телефона Vivo X200 Ultra.\n\n"
|
||
"ТЫ САМОСТОЯТЕЛЬНО ПО СМЫСЛУ ОПРЕДЕЛЯЕШЬ ТИП ЗАПРОСА:\n\n"
|
||
"1. КОМАНДА К ДЕЙСТВИЮ ИЛИ ЗАПРОС ИНФОРМАЦИИ:\n"
|
||
" Примеры: «Посмотри задачи на мне в Битриксе», «Проверь свободное место на синолоджи», «Перезапусти контейнер», «Сколько памяти свободно на макмини».\n"
|
||
" - Если задача по Битрикс24 — вызови query_bitrix24 (my_tasks, search_tasks, task_details, task_comments).\n"
|
||
" - Если системная задача — вызови execute_bash_command (хост, docker, ssh на NanoPi 192.168.10.1, Synology 192.168.10.105, MacMini 192.168.10.167).\n"
|
||
" - Выполни команду, получи результат и дай ясный инженерный отчет.\n\n"
|
||
"2. ПОТОК МЫСЛЕЙ, ЗАМЕТКА, ИДЕИ НА ХОДУ ИЛИ С ВЕЛОСИПЕДА:\n"
|
||
" Примеры: «Слушай, еду сейчас на велике, пришла мысль по поводу... надо бы сделать вот так, и не забыть...», «Заметка по фабрике Висма...».\n"
|
||
" - Даже если мысли надиктованы сумбурно и скачут, приведи их в четкую логическую структуру:\n"
|
||
" 🎯 Суть: главное зерно мысли в 1-2 емких предложениях.\n"
|
||
" 📋 Задачи (Action Items): конкретные шаги [ ] для реализации.\n"
|
||
" 💡 Идеи в копилку: гипотезы, расширения, инсайты.\n"
|
||
" - ОБЯЗАТЕЛЬНО вызови save_to_obsidian(section, subfolder, filename, title, content, tags)!\n"
|
||
" Выбери подходящий раздел ('HomeLab' или 'Work') и подкаталог (например: 'Bicycle_Ebike', 'Housing_LKM37', 'AI_Swarm', 'Infrastructure', 'Visma', 'Bitrix24', 'General').\n"
|
||
" Имя файла сделай говорящим на латинице (например: 'ebike_controller_upgrade.md').\n"
|
||
" - В отчете для Александра покажи структурированный вид и подтверди, что файл сохранен в Obsidian.\n\n"
|
||
"3. КОМБИНИРОВАННЫЙ ЗАПРОС:\n"
|
||
" Если Александр и надиктовал мысль в Obsidian, и попросил что-то выполнить — выполни оба действия!\n\n"
|
||
"ГОЛОСОВОЙ ОТКЛИК ДЛЯ ОЗВУЧКИ:\n"
|
||
"В САМОМ КОНЦЕ ответа ОБЯЗАТЕЛЬНО добавь тег <voice>...</voice>.\n"
|
||
"Внутри него напиши короткую (1-3 предложения), емкую, теплую голосовую выжимку от Майи без Markdown и эмодзи, которая будет озвучена голосом Шаде."
|
||
)
|
||
|
||
models_to_try = [
|
||
'gemini-2.5-flash',
|
||
'gemini-flash-latest',
|
||
'gemini-2.5-pro',
|
||
'gemini-3.5-flash'
|
||
]
|
||
last_error = None
|
||
|
||
def _sync_run_agent(m_name: str, u_text: str) -> str:
|
||
model = genai.GenerativeModel(
|
||
model_name=m_name,
|
||
system_instruction=system_instruction,
|
||
tools=[query_bitrix24, execute_bash_command, save_to_obsidian]
|
||
)
|
||
chat = model.start_chat(enable_automatic_function_calling=True)
|
||
resp = chat.send_message(u_text)
|
||
return resp.text.strip() if resp and resp.text else ""
|
||
|
||
for model_name in models_to_try:
|
||
try:
|
||
logger.info(f"🚀 [Maya Agent] Запуск с моделью: {model_name}")
|
||
response_text = await asyncio.to_thread(_sync_run_agent, model_name, user_text)
|
||
if response_text:
|
||
return response_text
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ [Maya Agent] Сбой на модели {model_name}: {e}")
|
||
last_error = e
|
||
await asyncio.sleep(0.5)
|
||
|
||
return f"❌ Ошибка исполнения Maya Agent: {last_error}"
|
||
|
||
@router.message(F.voice | F.video_note | F.audio | F.document)
|
||
async def handle_voice_message(message: Message, bot: Bot):
|
||
# ПЕРЕВОДИМ И ИСПОЛНЯЕМ СТРОГО ДЛЯ АЛЕКСАНДРА (ЧУЖИХ НЕ ПУСКАЕМ!)
|
||
if int(message.from_user.id) != int(ADMIN_USER_ID):
|
||
return
|
||
|
||
msg_type = "VOICE"
|
||
duration = 0
|
||
filename = "audio.ogg"
|
||
file_id = None
|
||
|
||
if message.voice:
|
||
file_id = message.voice.file_id
|
||
filename = f"voice_{file_id}.ogg"
|
||
duration = message.voice.duration
|
||
msg_type = "VOICE"
|
||
elif message.video_note:
|
||
file_id = message.video_note.file_id
|
||
filename = f"note_{file_id}.mp4"
|
||
duration = message.video_note.duration
|
||
msg_type = "VIDEO_NOTE"
|
||
elif message.audio:
|
||
file_id = message.audio.file_id
|
||
ext = os.path.splitext(message.audio.file_name or "audio.mp3")[1] or ".mp3"
|
||
filename = f"audio_{file_id}{ext}"
|
||
duration = message.audio.duration or 0
|
||
msg_type = "AUDIO_RECORDING"
|
||
elif message.document:
|
||
doc = message.document
|
||
fn = (doc.file_name or "").lower()
|
||
mime = (doc.mime_type or "").lower()
|
||
allowed_exts = ('.mp3', '.m4a', '.ogg', '.wav', '.aac', '.flac', '.3gp', '.oga', '.wma')
|
||
if not (mime.startswith("audio/") or fn.endswith(allowed_exts)):
|
||
return
|
||
file_id = doc.file_id
|
||
ext = os.path.splitext(doc.file_name or "rec.m4a")[1] or ".m4a"
|
||
filename = f"doc_{file_id}{ext}"
|
||
duration = 0
|
||
msg_type = "RECORDER_FILE"
|
||
else:
|
||
return
|
||
|
||
target_obj = message.voice or message.video_note or message.audio or message.document
|
||
file_size = getattr(target_obj, "file_size", 0) or 0
|
||
if file_size > 20 * 1024 * 1024:
|
||
size_mb = round(file_size / (1024 * 1024), 1)
|
||
await message.reply(
|
||
f"⚠️ <b>Файл слишком большой ({size_mb} МБ).</b>\n\n"
|
||
"Telegram Bot API жестко ограничивает скачивание ботами файлов размером до 20 МБ.\n"
|
||
"💡 <b>Решение:</b> Сконвертируй запись диктофона в AAC/M4A/MP3 с более низким битрейтом (например, 64 kbps mono) или надиктуй голосовыми сообщениями прямо в чат!",
|
||
parse_mode="HTML"
|
||
)
|
||
return
|
||
|
||
status_msg = await message.reply("🎙 <i>Слушаю и выполняю команду...</i>", parse_mode="HTML")
|
||
await bot.send_chat_action(message.chat.id, "typing")
|
||
|
||
try:
|
||
local_audio_path = f"/tmp/{filename}"
|
||
file_info = await bot.get_file(file_id)
|
||
await bot.download_file(file_info.file_path, local_audio_path)
|
||
|
||
# 1. Прямая расшифровка через локальный Whisper M4 (с фоллбэком на Gemini)
|
||
transcribed_text = await transcribe_audio(local_audio_path)
|
||
|
||
if os.path.exists(local_audio_path):
|
||
os.remove(local_audio_path)
|
||
|
||
if transcribed_text:
|
||
log_voice_to_db(message.from_user.id, message.from_user.username, msg_type, int(duration), transcribed_text)
|
||
|
||
await status_msg.edit_text(f"🗣 <b>Команда принята:</b> «<i>{transcribed_text}</i>»\n\n⚙️ <b>Майа работает над задачей...</b>", parse_mode="HTML")
|
||
await bot.send_chat_action(message.chat.id, "record_voice")
|
||
|
||
# 2. Исполнение Maya Lead Agent (Битрикс24, bash, анализ)
|
||
agent_response = await execute_antigravity_agent(transcribed_text)
|
||
|
||
# 3. Подготовка текста для отправки (вырезаем тег <voice>)
|
||
clean_text = re.sub(r"<voice>.*?</voice>", "", agent_response, flags=re.DOTALL).strip()
|
||
formatted_text = f"🎯 <b>Майа // Отчет исполнения:</b>\n\n{clean_text}"
|
||
|
||
if len(formatted_text) > 4000:
|
||
await status_msg.edit_text(formatted_text[:4000], parse_mode="HTML")
|
||
await message.reply(formatted_text[4000:], parse_mode="HTML")
|
||
else:
|
||
await status_msg.edit_text(formatted_text, parse_mode="HTML")
|
||
|
||
# 4. Синтез и отправка голосового ответа
|
||
try:
|
||
voice_bytes = await text_to_speech_bytes(agent_response)
|
||
if voice_bytes:
|
||
voice_file = BufferedInputFile(voice_bytes, filename="reply_voice.mp3")
|
||
await message.reply_voice(voice=voice_file, caption="🎙 Голосовой отклик Майи")
|
||
except Exception as tts_err:
|
||
logger.error(f"TTS Send error: {tts_err}")
|
||
|
||
else:
|
||
await status_msg.edit_text("🔇 Аудиозапись принята, но слова не распознаны. Попробуй наговорить еще раз.")
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Maya Voice Integration Error: {e}")
|
||
await status_msg.edit_text(f"❌ Ошибка обработки аудио: {e}", parse_mode="HTML")
|
||
|
||
voice_router = router
|