domovoy_bot/handlers/voice.py

518 lines
26 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
import html
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 structure_thoughts(user_text: str) -> str:
"""Этап 2: Превращение сумбурного потока мыслей в кристальную структуру с метаданными для Obsidian"""
prompt = f"""
Ты — Майа, личный аналитик и инженер Александра.
Александр наговорил голосовое сообщение на ходу. В речи могут быть паузы, оговорки, повторы, слова-паразиты, сумбур.
Твоя задача — кристально структурировать надиктованное по строгой схеме:
🎯 <b>Суть:</b>
[Главное зерно мысли в 1-2 емких, четких предложениях, полностью очищенных от мусорных слов]
📋 <b>Задачи к выполнению:</b>
* [ ] [Конкретная задача 1]
* [ ] [Конкретная задача 2]
(Если задач нет — напиши: «Конкретных задач не поставлено — зафиксировано как идея/мысли»)
💡 <b>Идеи в копилку / Бэклог:</b>
* [Инсайт, гипотеза или идея на перспективу]
(Если идей нет — напиши: «—»)
ОБЯЗАТЕЛЬНО добавь в конце метаданные для сохранения в Obsidian:
<obsidian section="HomeLab|Work|Свалка" subfolder="AI_Swarm|Infrastructure|Bicycle_Ebike|Housing_LKM37|Visma|Bitrix24|General" filename="имя_файла_на_латинице.md" title="Понятный заголовок заметки" />
И добавь тег с короткой теплой выжимкой для синтеза речи (1-2 предложения без markdown):
<voice>Текст выжимки от первого лица для озвучки</voice>
Сырой текст речи Александра:
«{user_text}»
"""
models_to_try = [
'gemini-2.5-flash',
'gemini-flash-latest',
'gemini-2.5-pro',
'gemini-3.5-flash'
]
for model_name in models_to_try:
try:
logger.info(f"🧠 [Maya Structuring] Запуск с моделью: {model_name}")
def _sync_gen(m_name, p):
model = genai.GenerativeModel(m_name)
resp = model.generate_content(p)
return resp.text.strip() if resp and resp.text else ""
res = await asyncio.to_thread(_sync_gen, model_name, prompt)
if res:
return res
except Exception as e:
logger.warning(f"⚠️ [Maya Structuring] Сбой на модели {model_name}: {e}")
await asyncio.sleep(0.5)
return (
f"🎯 <b>Суть:</b>\n{user_text}\n\n"
f"📋 <b>Задачи к выполнению:</b>\n* [ ] Разобрать надиктованное голосовое сообщение\n\n"
f"💡 <b>Идеи в копилку / Бэклог:</b>\n—\n\n"
f"<obsidian section=\"HomeLab\" subfolder=\"General\" filename=\"note_{int(datetime.now().timestamp())}.md\" title=\"Голосовая мысль\" />\n"
f"<voice>Я зафиксировала твою мысль и сохранила её в Obsidian.</voice>"
)
async def execute_voice_commands_if_any(user_text: str) -> str:
"""Этап 3: Проверка и выполнение реальных инженерных команд (Битрикс24, bash, серверы)"""
system_instruction = (
"Ты — Майа, системный инженер и исполнитель команд Александра.\n"
"Проанализируй текст Александра. Если он содержит ЯВНЫЕ команды к исполнению прямо сейчас "
"(например: проверить задачи в Битрикс24, посмотреть статус сервера, проверить диск/память/докер, выполнить команду):\n"
"1. Немедленно вызови соответствующий инструмент (query_bitrix24 или execute_bash_command).\n"
"2. Дай четкий, емкий инженерный результат выполнения.\n"
"Если же текст Александра — это ПРОСТО мысли, рассуждения, идеи или планы на будущее (без прямого приказа выполнить что-то прямо сейчас) — "
"верни СТРОГО ОДНО СЛОВО: НЕТ_КОМАНД"
)
models_to_try = [
'gemini-2.5-flash',
'gemini-flash-latest',
'gemini-2.5-pro'
]
def _sync_cmd(m_name, u_txt):
model = genai.GenerativeModel(
model_name=m_name,
system_instruction=system_instruction,
tools=[query_bitrix24, execute_bash_command]
)
chat = model.start_chat(enable_automatic_function_calling=True)
resp = chat.send_message(u_txt)
return resp.text.strip() if resp and resp.text else ""
for model_name in models_to_try:
try:
logger.info(f"⚡️ [Maya Commands] Проверка наличия команд ({model_name})...")
res = await asyncio.to_thread(_sync_cmd, model_name, user_text)
if res:
return res
except Exception as e:
logger.warning(f"⚠️ [Maya Commands] Ошибка {model_name}: {e}")
await asyncio.sleep(0.5)
return "НЕТ_КОМАНД"
@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. Расшифровка через Gemini Audio API (с нормализацией ffmpeg)
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)
# ===== ЭТАП 1: Показываем сырой текст как есть (без эффектов) =====
safe_raw = html.escape(transcribed_text)
await status_msg.edit_text(
f"🗣 <b>Услышано:</b>\n«<i>{safe_raw}</i>»\n\n"
f"🧠 <i>Майа структурирует мысли и вникает в суть...</i>",
parse_mode="HTML"
)
await bot.send_chat_action(message.chat.id, "typing")
# ===== ЭТАП 2: Структурирование мыслей через модель =====
structured_raw = await structure_thoughts(transcribed_text)
# Извлекаем теги метаданных и озвучки
m_obs = re.search(
r"<obsidian\s+section=[\"\'](.*?)[\"\']\s+subfolder=[\"\'](.*?)[\"\']\s+filename=[\"\'](.*?)[\"\']\s+title=[\"\'](.*?)[\"\']\s*/>",
structured_raw
)
obs_section = m_obs.group(1) if m_obs else "HomeLab"
obs_subfolder = m_obs.group(2) if m_obs else "General"
obs_filename = m_obs.group(3) if m_obs else f"note_{int(datetime.now().timestamp())}.md"
obs_title = m_obs.group(4) if m_obs else "Голосовая мысль"
# Очищаем структурированный текст для отображения в Telegram
clean_structure = re.sub(r"<obsidian.*?>", "", structured_raw, flags=re.DOTALL)
clean_structure = re.sub(r"<voice>.*?</voice>", "", clean_structure, flags=re.DOTALL).strip()
# Редактируем статусное сообщение: сырой текст заменяется на красивую структуру!
formatted_structure = f"🎯 <b>Майа // Структурированный отклик:</b>\n\n{clean_structure}"
if len(formatted_structure) > 4000:
await status_msg.edit_text(formatted_structure[:4000], parse_mode="HTML")
else:
await status_msg.edit_text(formatted_structure, parse_mode="HTML")
# ===== ЭТАП 3: Физические действия (Obsidian + Команды) отдельным сообщением! =====
# 1. Запись в Obsidian
obsidian_res = save_to_obsidian(obs_section, obs_subfolder, obs_filename, obs_title, clean_structure)
# 2. Выполнение команд (если были)
commands_result = await execute_voice_commands_if_any(transcribed_text)
# 3. Отправка отчета о действиях ОТДЕЛЬНЫМ сообщением
action_report = f"🗄️ <b>Действия и фиксация:</b>\n• {obsidian_res}"
if commands_result and commands_result.strip() != "НЕТ_КОМАНД":
action_report += f"\n\n⚙️ <b>Результат выполнения:</b>\n{commands_result.strip()}"
await message.reply(action_report, parse_mode="HTML")
# ===== 4. Голосовой отклик голосом Шаде =====
try:
voice_bytes = await text_to_speech_bytes(structured_raw)
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