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"(.*?)", 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" "Александр обращается к тебе голосом в Телеграмме (на ходу, на велосипеде, за рулем, спонтанный поток мыслей с паузами, оговорками и повторами).\n\n" "ТВОЯ ГЛАВНАЯ ЦЕЛЬ — ПРЕВРАТИТЬ СПОНТАННЫЙ ПОТОК РЕЧИ В КРИСТАЛЬНО ЧИСТУЮ СТРУКТУРУ И ВЫПОЛНИТЬ ТРЕБУЕМЫЕ ДЕЙСТВИЯ.\n\n" "ОБЯЗАТЕЛЬНЫЙ ШАБЛОН ТВОЕГО ТЕКСТОВОГО ОТВЕТА (СТРОГО СОБЛЮДАЙ ЭТУ СТРУКТУРУ В КАЖДОМ ОТВЕТЕ!):\n\n" "🎯 Суть:\n" "[Очищенная от слов-паразитов, междометий и сумбура суть сказанного в 1-3 емких предложениях]\n\n" "📋 Задачи к выполнению (Action Items):\n" "• [ ] [Конкретная задача 1]\n" "• [ ] [Конкретная задача 2]\n" "(Если задач к исполнению не было — напиши: «Конкретных задач не поставлено — зафиксировано как идея/мысли»)\n\n" "💡 Идеи в копилку / Бэклог:\n" "• [Инсайт, гипотеза или идея на будущее]\n" "(Если идей не было — напиши: «—»)\n\n" "🗄️ Действия и фиксация:\n" "• [ОБЯЗАТЕЛЬНО: Вызови save_to_obsidian и укажи: ✅ Сохранено в Obsidian: раздел/подкаталог/файл.md]\n" "• [Если Александр просил проверить или сделать что-то — вызови query_bitrix24 или execute_bash_command и напиши конкретный результат выполнения!]\n\n" "ПРАВИЛА:\n" "1. ЛЮБЫЕ мысли, идеи, планы, рассуждения ОБЯЗАТЕЛЬНО сохраняй в Obsidian через tool save_to_obsidian(section, subfolder, filename, title, content, tags).\n" " - section: 'HomeLab', 'Work' или 'Свалка'.\n" " - subfolder: 'AI_Swarm', 'Infrastructure', 'Bicycle_Ebike', 'Housing_LKM37', 'Visma', 'Bitrix24', 'General'.\n" " - filename: говорящее имя на латинице (например: 'ebike_controller_upgrade.md').\n" " - content: структурированный текст заметки.\n" " КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО писать в чат только фразу «я сохранила в файл»! Ты ОБЯЗАНА полностью вывести блоки 🎯 Суть, 📋 Задачи, 💡 Идеи в сам ответ, чтобы Александр видел структуру прямо перед глазами!\n" "2. Если Александр попросил действие (Битрикс24, свободное место, контейнеры, сервера) — вызови инструмент (query_bitrix24 / execute_bash_command) и выведи результат в блок «🗄️ Действия и фиксация».\n" "3. В САМОМ КОНЦЕ ответа ОБЯЗАТЕЛЬНО добавь тег ... с короткой (1-2 предложения), теплой голосовой выжимкой для Шаде без 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"⚠️ Файл слишком большой ({size_mb} МБ).\n\n" "Telegram Bot API жестко ограничивает скачивание ботами файлов размером до 20 МБ.\n" "💡 Решение: Сконвертируй запись диктофона в AAC/M4A/MP3 с более низким битрейтом (например, 64 kbps mono) или надиктуй голосовыми сообщениями прямо в чат!", parse_mode="HTML" ) return status_msg = await message.reply("🎙 Слушаю и выполняю команду...", 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) safe_transcribed = html.escape(transcribed_text) await status_msg.edit_text( f"🗣 Вы сказали:\n{safe_transcribed}\n\n" f"🧠 Майа структурирует мысли и выполняет действия...", 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. Подготовка текста для отправки (вырезаем тег ) clean_text = re.sub(r".*?", "", agent_response, flags=re.DOTALL).strip() formatted_text = ( f"🗣 Вы сказали:\n{safe_transcribed}\n\n" f"🎯 Майа // Структурированный отклик:\n\n" f"{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