Compare commits
4 commits
5643d8fb4a
...
a47d1eee77
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a47d1eee77 | ||
|
|
39e7646766 | ||
|
|
080371bc1c | ||
|
|
177070ab5a |
4 changed files with 210 additions and 53 deletions
|
|
@ -13,6 +13,10 @@ BOT_TOKEN = os.getenv('BOT_TOKEN', '')
|
||||||
ADMIN_CHAT_ID = int(os.getenv('ADMIN_CHAT_ID', '0'))
|
ADMIN_CHAT_ID = int(os.getenv('ADMIN_CHAT_ID', '0'))
|
||||||
ADMIN_USER_ID = int(os.getenv('ADMIN_USER_ID', '0'))
|
ADMIN_USER_ID = int(os.getenv('ADMIN_USER_ID', '0'))
|
||||||
|
|
||||||
|
# Gemini API
|
||||||
|
GOOGLE_API_KEY = os.getenv('GOOGLE_API_KEY', os.getenv('GEMINI_API_KEY', 'AIzaSyAJMfQ_mRZ1picRXVFNaeFJ7fpAkZxUKyk'))
|
||||||
|
GEMINI_API_KEY = GOOGLE_API_KEY
|
||||||
|
|
||||||
# База данных
|
# База данных
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
DATABASE_PATH = os.getenv('DATABASE_PATH', str(BASE_DIR / 'database' / 'domovoy.db'))
|
DATABASE_PATH = os.getenv('DATABASE_PATH', str(BASE_DIR / 'database' / 'domovoy.db'))
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,13 @@ services:
|
||||||
- PYTHONPATH=/app:/app/services
|
- PYTHONPATH=/app:/app/services
|
||||||
- MUTT_DIR=/home/matrixhasyou/mutt
|
- MUTT_DIR=/home/matrixhasyou/mutt
|
||||||
- GOOGLE_CREDS_FILE=/home/matrixhasyou/domovoy_google_creds.json
|
- GOOGLE_CREDS_FILE=/home/matrixhasyou/domovoy_google_creds.json
|
||||||
|
- TZ=Europe/Ulyanovsk
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
- ./exports:/app/exports
|
- ./exports:/app/exports
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
- ./handlers:/app/handlers
|
||||||
- ../libs/swarmlib:/app/swarmlib:ro
|
- ../libs/swarmlib:/app/swarmlib:ro
|
||||||
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro
|
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro
|
||||||
- /home/matrixhasyou/mutt:/home/matrixhasyou/mutt:rw
|
- /home/matrixhasyou/mutt:/home/matrixhasyou/mutt:rw
|
||||||
|
|
@ -36,11 +38,13 @@ services:
|
||||||
- PYTHONPATH=/app:/app/services
|
- PYTHONPATH=/app:/app/services
|
||||||
- MUTT_DIR=/home/matrixhasyou/mutt
|
- MUTT_DIR=/home/matrixhasyou/mutt
|
||||||
- GOOGLE_CREDS_FILE=/home/matrixhasyou/domovoy_google_creds.json
|
- GOOGLE_CREDS_FILE=/home/matrixhasyou/domovoy_google_creds.json
|
||||||
|
- TZ=Europe/Ulyanovsk
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
- ./exports:/app/exports
|
- ./exports:/app/exports
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
- ./handlers:/app/handlers
|
||||||
- ../libs/swarmlib:/app/swarmlib:ro
|
- ../libs/swarmlib:/app/swarmlib:ro
|
||||||
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro
|
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro
|
||||||
- /home/matrixhasyou/mutt:/home/matrixhasyou/mutt:rw
|
- /home/matrixhasyou/mutt:/home/matrixhasyou/mutt:rw
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,42 @@
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import logging
|
import logging
|
||||||
import aiohttp
|
import asyncio
|
||||||
import json
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from aiogram import Router, F, Bot
|
from aiogram import Router, F, Bot
|
||||||
from aiogram.types import Message
|
from aiogram.types import Message, BufferedInputFile
|
||||||
from config import ADMIN_USER_ID, DATABASE_PATH
|
from config import ADMIN_USER_ID, DATABASE_PATH
|
||||||
from pathlib import Path
|
import google.generativeai as genai
|
||||||
|
import edge_tts
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
WHISPER_API_URL = "http://192.168.10.168:9001/transcribe"
|
# Валидный проверенный ключ Gemini API
|
||||||
|
WORKING_GEMINI_KEY = "AIzaSyAJMfQ_mRZ1picRXVFNaeFJ7fpAkZxUKyk"
|
||||||
|
genai.configure(api_key=WORKING_GEMINI_KEY)
|
||||||
|
|
||||||
|
def execute_bash_command(command: str) -> str:
|
||||||
|
"""Выполняет bash-команду на локальном Linux хосте (DockerServer) или по SSH на узлах сети (MacMini 192.168.10.167, Synology 192.168.10.105, MikroTik 192.168.10.215, NanoPi 192.168.10.1) и возвращает результат вывода."""
|
||||||
|
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):
|
def log_voice_to_db(user_id, username, msg_type, duration, text):
|
||||||
"""Фиксация статистики в основную базу Домового"""
|
"""Фиксация статистики в основную базу Домового"""
|
||||||
|
|
@ -39,68 +63,192 @@ def log_voice_to_db(user_id, username, msg_type, duration, text):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ DB Log error: {e}")
|
logger.error(f"❌ DB Log error: {e}")
|
||||||
|
|
||||||
@router.message(F.voice | F.video_note)
|
async def text_to_speech_bytes(text: str) -> bytes:
|
||||||
|
"""Синтез качественного женского голосового ответа на русском через Edge TTS с глубоким тембром"""
|
||||||
|
# Если в тексте есть тег <voice>...</voice>, озвучиваем именно его
|
||||||
|
match = re.search(r"<voice>(.*?)</voice>", text, re.DOTALL | re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
speech_text = match.group(1).strip()
|
||||||
|
else:
|
||||||
|
# Иначе берем выжимку до 350 символов
|
||||||
|
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 = "Приняла, Александр. Всё структурировано и зафиксировано."
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def process_audio_with_gemini(audio_path: str) -> str:
|
||||||
|
"""Прямое транскрибирование аудио через Gemini Multimodal 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:
|
||||||
|
"""Обработка команды и мыслей агентом Майа с систематизацией и правом исполнения bash-команд"""
|
||||||
|
system_instruction = (
|
||||||
|
"Ты — Майа (Maya / Antigravity Lead Agent), суперинтеллектуальный помощник Александра. "
|
||||||
|
"Александр обращается к тебе голосом в Телеграмме или надиктованной аудиозаписью с диктофона. "
|
||||||
|
"Твоя задача — быть идеальным личным инженером и аналитиком:\n\n"
|
||||||
|
"1. СТРУКТУРИРОВАНИЕ МЫСЛЕЙ (если Александр надиктовал идеи, мысли, список дел или сумбурный поток):\n"
|
||||||
|
" Разложи всё по четким блокам:\n"
|
||||||
|
" 🎯 Суть: краткая главная мысль в 1-2 предложениях.\n"
|
||||||
|
" 📋 Задачи к выполнению (Action Items): конкретные пункты с галочками [ ].\n"
|
||||||
|
" 💡 Идеи в копилку / Бэклог: новые задумки, архитектурные гипотезы.\n"
|
||||||
|
" 🗄️ Куда зафиксировать: в какую заметку Obsidian (HomeLab или Work) это стоит сохранить.\n\n"
|
||||||
|
"2. СИСТЕМНЫЕ КОМАНДЫ (если Александр просит проверить ресурсы, диски, сеть, контейнеры, запустить что-то):\n"
|
||||||
|
" ОБЯЗАТЕЛЬНО используй execute_bash_command! Выполни команду, проанализируй вывод и дай четкий отчет.\n"
|
||||||
|
" Узлы сети: NanoPi R3S (root@192.168.10.1), Synology (matrixhasyou@192.168.10.105), MacMini (matrixhasyou@192.168.10.167), MikroTik (matrixhasyou@192.168.10.215).\n\n"
|
||||||
|
"3. ГОЛОСОВОЙ ОТКЛИК ДЛЯ ОЗВУЧКИ:\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=[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):
|
async def handle_voice_message(message: Message, bot: Bot):
|
||||||
# ПЕРЕВОДИМ ТОЛЬКО ДЛЯ ТЕБЯ (СУПЕРАДМИНА)
|
# ПЕРЕВОДИМ И ИСПОЛНЯЕМ СТРОГО ДЛЯ АЛЕКСАНДРА (ЧУЖИХ НЕ ПУСКАЕМ!)
|
||||||
if int(message.from_user.id) != int(ADMIN_USER_ID):
|
if int(message.from_user.id) != int(ADMIN_USER_ID):
|
||||||
return
|
return
|
||||||
|
|
||||||
status_msg = await message.reply("⏳ <i>Распознаю голос (Whisper AI)...</i>", parse_mode="HTML")
|
# Определяем тип медиафайла
|
||||||
|
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")
|
await bot.send_chat_action(message.chat.id, "typing")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if message.voice:
|
local_audio_path = f"/tmp/{filename}"
|
||||||
file_id = message.voice.file_id
|
file_info = await bot.get_file(file_id)
|
||||||
filename = f"{file_id}.oga"
|
await bot.download_file(file_info.file_path, local_audio_path)
|
||||||
msg_type = "VOICE"
|
|
||||||
|
# 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 (систематизация + команды хоста)
|
||||||
|
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}"
|
||||||
|
|
||||||
|
# Разделяем длинный текст Telegram если нужно (TG limit 4096)
|
||||||
|
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:
|
else:
|
||||||
file_id = message.video_note.file_id
|
await status_msg.edit_text(formatted_text, parse_mode="HTML")
|
||||||
filename = f"{file_id}.mp4"
|
|
||||||
msg_type = "VIDEO_NOTE"
|
|
||||||
|
|
||||||
file = await bot.get_file(file_id)
|
# 4. Синтез и отправка голосовой заметки (Voice Note)
|
||||||
|
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}")
|
||||||
|
|
||||||
# Используем aiohttp для отправки файла в микросервис
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
# Скачиваем файл во временный буфер
|
|
||||||
file_url = f"https://api.telegram.org/file/bot{bot.token}/{file.file_path}"
|
|
||||||
|
|
||||||
# Прокси если нужно (подтянется из конфига если настроено, но тут идем напрямую)
|
|
||||||
async with session.get(file_url) as file_resp:
|
|
||||||
if file_resp.status != 200:
|
|
||||||
raise Exception(f"Не удалось скачать файл из TG: {file_resp.status}")
|
|
||||||
|
|
||||||
file_content = await file_resp.read()
|
|
||||||
|
|
||||||
# Отправляем в наш Whisper-Service
|
|
||||||
data = aiohttp.FormData()
|
|
||||||
data.add_field('file', file_content, filename=filename)
|
|
||||||
|
|
||||||
async with session.post(WHISPER_API_URL, data=data) as whisper_resp:
|
|
||||||
if whisper_resp.status != 200:
|
|
||||||
err_text = await whisper_resp.text()
|
|
||||||
raise Exception(f"Ошибка Whisper API: {err_text}")
|
|
||||||
|
|
||||||
result = await whisper_resp.json()
|
|
||||||
text = result.get("text", "")
|
|
||||||
duration = result.get("duration", 0)
|
|
||||||
|
|
||||||
if text:
|
|
||||||
# Логируем в базу
|
|
||||||
log_voice_to_db(message.from_user.id, message.from_user.username, msg_type, int(duration), text)
|
|
||||||
|
|
||||||
# Формируем красивый ответ
|
|
||||||
response = f"📝 <b>РАСШИФРОВКА</b> ({msg_type})\n"
|
|
||||||
response += f"⏱ Длительность: {int(duration)}с\n"
|
|
||||||
response += f"━━━━━━━━━━━━━━\n"
|
|
||||||
response += f"<i>{text}</i>"
|
|
||||||
|
|
||||||
await status_msg.edit_text(response, parse_mode="HTML")
|
|
||||||
else:
|
else:
|
||||||
await status_msg.edit_text("🔇 Голос обнаружен, но слов не разобрал.")
|
await status_msg.edit_text("🔇 Аудиозапись принята, но слова не распознаны. Попробуй наговорить еще раз.")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Voice Integration Error: {e}")
|
logger.error(f"❌ Maya Voice Integration Error: {e}")
|
||||||
await status_msg.edit_text(f"❌ Ошибка модуля Whisper: {e}")
|
await status_msg.edit_text(f"❌ Ошибка обработки аудио: {e}", parse_mode="HTML")
|
||||||
|
|
||||||
voice_router = router
|
voice_router = router
|
||||||
|
|
|
||||||
|
|
@ -25,3 +25,4 @@ google-auth-httplib2
|
||||||
google-auth-oauthlib
|
google-auth-oauthlib
|
||||||
|
|
||||||
|
|
||||||
|
edge-tts>=7.0.0
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue