domovoy_bot/handlers/voice.py

331 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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. Приоритет: локальный синтез голосом Дженис Джоплин на 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/janis_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""
def process_audio_with_gemini(audio_path: str) -> str:
"""Прямое транскрибирование аудио через Gemini Multimodal Audio API"""
try:
logger.info(f"Выгрузка {audio_path} в Gemini Multimodal Audio API...")
audio_file = genai.upload_file(path=audio_path)
prompt = (
"Точно транскрибируй эту аудиозапись на русском языке в текст. "
"Не добавляй никаких вводных слов, пояснений и комментариев, отдай исключительно чистый текст произнесенных слов."
)
model = genai.GenerativeModel('gemini-2.5-flash')
response = model.generate_content([audio_file, prompt])
try:
genai.delete_file(name=audio_file.name)
except Exception:
pass
return response.text.strip() if response.text else ""
except Exception as e:
logger.error(f"Ошибка Gemini Audio API: {e}")
return ""
async def execute_antigravity_agent(user_text: str) -> str:
"""Обработка команды и мыслей агентом Майа с систематизацией, Битрикс24 и правом исполнения bash-команд"""
system_instruction = (
"Ты — Майа (Maya / Antigravity Lead Agent), суперинтеллектуальный помощник Александра с полными правами инженера. "
"Александр обращается к тебе голосом в Телеграмме или надиктованной аудиозаписью с диктофона. "
"Твои возможности:\n\n"
"1. РАБОЧИЕ ЗАДАЧИ В БИТРИКС24:\n"
" Если Александр просит проверить задачи, комментарии руководства, поручения в Битрикс24 — "
" ОБЯЗАТЕЛЬНО используй функцию query_bitrix24!\n"
" - Поиск задачи по ключевым словам: query_bitrix24(action='search_tasks', search_text='...')\n"
" - Список открытых задач Александра (ID 1424): query_bitrix24(action='my_tasks')\n"
" - Подробности задачи: query_bitrix24(action='task_details', task_id=...)\n"
" - Комментарии к задаче: query_bitrix24(action='task_comments', task_id=...)\n"
" Проанализируй суть, выдели позицию Ильи Гавришина (ID 1, гендир) и юристов, дай свой ясный вывод и предложи шаги.\n\n"
"2. СИСТЕМНЫЕ КОМАНДЫ (Docker, сеть, диски, серверы):\n"
" Используй execute_bash_command для выполнения консольных команд. Узлы: NanoPi (192.168.10.1), Synology (192.168.10.105), MacMini (192.168.10.167).\n\n"
"3. СТРУКТУРИРОВАНИЕ МЫСЛЕЙ (если Александр надиктовал идеи или планы):\n"
" Разложи всё по блокам:\n"
" 🎯 Суть: главная мысль в 1-2 предложениях.\n"
" 📋 Задачи (Action Items): конкретные пункты [ ].\n"
" 💡 Идеи в копилку: новые мысли и гипотезы.\n"
" 🗄️ Фиксация в Obsidian: куда сохранить (HomeLab или Work).\n\n"
"4. ГОЛОСОВОЙ ОТКЛИК ДЛЯ ОЗВУЧКИ:\n"
" В САМОМ КОНЦЕ ответа ОБЯЗАТЕЛЬНО добавь тег <voice>...</voice>.\n"
" Внутри него напиши короткое, емкое, живое и теплое резюме от Майи (1-3 предложения), которое будет озвучено вслух! "
" Внутри <voice> пиши только чистый русский текст без звездочек, эмодзи и Markdown."
)
models_to_try = ['gemini-2.5-flash', 'gemini-3.5-flash', 'gemini-3.5-flash-lite']
last_error = None
for model_name in models_to_try:
try:
logger.info(f"🚀 [Maya Agent] Запуск с моделью: {model_name}")
model = genai.GenerativeModel(
model_name=model_name,
system_instruction=system_instruction,
tools=[query_bitrix24, execute_bash_command]
)
chat = model.start_chat(enable_automatic_function_calling=True)
response = chat.send_message(user_text)
if response and response.text:
return response.text.strip()
except Exception as e:
logger.warning(f"⚠️ [Maya Agent] Сбой на модели {model_name}: {e}")
last_error = e
await asyncio.sleep(1)
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
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. Прямая расшифровка через Gemini Audio
transcribed_text = process_audio_with_gemini(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