diff --git a/.gitignore b/.gitignore index 75d5ce4..80ff8e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ -__pycache__/ -*.pyc -.env -data/ -venv/ -.DS_Store +__pycache__/ +*.pyc +.env +data/ +venv/ +.DS_Store diff --git a/Dockerfile b/Dockerfile index 6109d98..1eb0996 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,27 +1,27 @@ -FROM python:3.11-slim - -# Instala dependências do sistema necessárias para áudio (ffmpeg e ALSA) e para o psutil compilar -RUN apt-get update && apt-get install -y \ - ffmpeg \ - gcc \ - python3-dev \ - docker.io \ - procps \ - psmisc \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -# Expõe a porta do FastAPI -EXPOSE 8000 - -# Garante que o script de inicialização do repositório tenha permissão de execução -RUN chmod +x start.sh - -CMD ["./start.sh"] - +FROM python:3.11-slim + +# Instala dependências do sistema necessárias para áudio (ffmpeg e ALSA) e para o psutil compilar +RUN apt-get update && apt-get install -y \ + ffmpeg \ + gcc \ + python3-dev \ + docker.io \ + procps \ + psmisc \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# Expõe a porta do FastAPI +EXPOSE 8000 + +# Garante que o script de inicialização do repositório tenha permissão de execução +RUN chmod +x start.sh + +CMD ["./start.sh"] + diff --git a/ai_agent.py b/ai_agent.py index b343260..7c05e2a 100644 --- a/ai_agent.py +++ b/ai_agent.py @@ -1,144 +1,78 @@ import os import re -import requests +import httpx +import asyncio import json from tools import AVAILABLE_TOOLS from config import get_config -def get_llm_response(prompt: str, provider: str, cfg: dict) -> str: - """Invoca o provedor de LLM configurado.""" - if provider == "gemini": - api_key = cfg.get("gemini_api_key") or os.getenv("GEMINI_API_KEY") - url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={api_key}" - payload = {"contents": [{"parts": [{"text": prompt}]}]} - try: - res = requests.post(url, json=payload, timeout=30) - if res.status_code == 200: - data = res.json() - if "candidates" in data and len(data["candidates"]) > 0: - return data["candidates"][0]["content"]["parts"][0]["text"] - return f"Erro Gemini (Dados Vazios): {res.text}" - return f"Erro Gemini (Status {res.status_code}): {res.text}" - except Exception as e: - return f"Erro de Conexão Gemini: {str(e)}" - - elif provider == "ollama": - ollama_host = os.getenv("OLLAMA_HOST", "http://ollama:11434") - model = os.getenv("OLLAMA_MODEL", "llama3.2:1b") - try: - res = requests.post(f"{ollama_host}/api/generate", json={ - "model": model, - "prompt": prompt, - "stream": False, - "options": {"num_ctx": 4096} - }, timeout=180) - if res.status_code == 200: +async def get_llm_response_async(prompt: str, provider: str, cfg: dict) -> str: + """Invoca o provedor de LLM configurado (async).""" + async with httpx.AsyncClient(timeout=60) as client: + if provider == "gemini": + api_key = cfg.get("gemini_api_key") or os.getenv("GEMINI_API_KEY") + url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={api_key}" + payload = {"contents": [{"parts": [{"text": prompt}]}]} + try: + res = await client.post(url, json=payload) + if res.status_code == 200: + data = res.json() + return data["candidates"][0]["content"]["parts"][0]["text"] + return f"Erro Gemini: {res.text}" + except Exception as e: return f"Erro Gemini: {e}" + + elif provider == "ollama": + host = os.getenv("OLLAMA_HOST", "http://ollama:11434") + model = os.getenv("OLLAMA_MODEL", "llama3.2:1b") + try: + res = await client.post(f"{host}/api/generate", json={ + "model": model, "prompt": prompt, "stream": False + }) return res.json().get("response", "") - return f"Erro Ollama (Status {res.status_code}): {res.text}" - except Exception as e: - return f"Erro de Conexão Ollama: {str(e)}" + except Exception as e: return f"Erro Ollama: {e}" return "Provedor desconhecido." -def query_agent(prompt: str, override_provider: str = None, chat_history: list = None) -> str: - """ - Motor Agente em Loop (ReAct): Pensamento -> Ação -> Observação -> Resposta Final. - """ +def query_agent(prompt: str, override_provider=None, chat_history=None) -> str: + """Wrapper síncrono para query_agent_async.""" + return asyncio.run(query_agent_async(prompt, override_provider, chat_history)) + +async def query_agent_async(prompt: str, override_provider=None, chat_history=None) -> str: cfg = get_config() provider = override_provider or cfg.get("active_provider", "gemini") + tools_desc = "\n".join([f"- {k}: {v['description']}" for k, v in AVAILABLE_TOOLS.items()]) - # Contexto de Ferramentas para a IA - tools_desc = "\n".join([f"- {k}: {v['description']}" for k,v in AVAILABLE_TOOLS.items()]) - - # Prompt especializado reformulado para evitar alucinações - system_prompt_base = """Você é o [Antigravity VPS Agent], o SysAdmin de elite do Marcos. -Você tem acesso root completo à VPS e deve agir de forma profissional e precisa. + system_prompt = f"""Você é o Antigravity VPS Agent. Root Admin da VPS do Marcos. +Responda em PORTUGUÊS. Seja técnico e direto. -### REGRAS DE OURO: -1. Responda em PORTUGUÊS (Brasil). -2. Se o usuário pedir o status da VPS, SEMPRE use a ferramenta 'get_system_health'. -3. Se o usuário pedir algo sobre containers, use 'get_docker_stats'. -4. Antes de decidir que um arquivo não existe, use 'run_bash_command' com 'ls' para verificar o diretório. -5. NUCA invente que buscou por arquivos (como syslog.conf) se o usuário não pediu especificamente por eles. -6. A seção deve conter apenas as informações solicitadas. Se não houver imagem relevante, não inclua tags de imagem. -7. O disco da VPS está montado em `/host_root`. Os arquivos do Marcos ficam principalmente em `/host_root/root/VPS_Sync`. Use este caminho como ponto de partida se o `find` na raiz falhar ou demorar demais. +### FERRAMENTAS: +{tools_desc} -### FORMATO DE AÇÃO: -Use: [TOOL:nome_da_ferramenta] argumento [/TOOL] -Rode UMA ferramenta por vez. Aguarde a saída do SISTEMA antes de concluir. - -### RESPOSTA FINAL: -Sua resposta terminada deve ter: -- Um resumo técnico. -- Uma seção ... com Markdown limpo. -- **DICA**: Só use imagens em se o usuário pediu para ver um arquivo de imagem específico que você localizou. Use o caminho absoluto encontrado. - -### FERRAMENTAS DISPONÍVEIS: -{TOOLS_LIST} - -### EXEMPLO DE SUCESSO: -Usuário: qual o uso de ram agora? -Agente: [TOOL:get_system_health] [/TOOL] -SISTEMA: CPU: 5% | RAM Usada: 20% | Disco Usado: 40% -Resposta: A memória RAM está operando com 20% de uso. - -### 📊 Memória e CPU -- **RAM Utilizada**: 20% -- **CPU**: 5% - +### FORMATO: +Use [TOOL:nome] arg [/TOOL] para ações. +Finalize com resumo . """ - system_prompt = system_prompt_base.replace("{TOOLS_LIST}", tools_desc) - - # Constrói o histórico da conversa (memória de curto prazo) history_str = "" if chat_history: - for msg in chat_history[-5:]: # Pega as últimas 5 interações - history_str += f"\nUsuário: {msg['user']}\nAgente: {msg['bot']}\n" - + for m in chat_history[-5:]: + history_str += f"\nUsuário: {m['user']}\nAgente: {m['bot']}\n" history_str += f"\nUsuário: {prompt}\n" - current_iteration_history = history_str - max_loops = 12 - - print(f"--- INICIANDO AGENTE ({provider}) ---") - - for i in range(max_loops): - import time - time.sleep(0.5) # Respiro para a CPU - print(f"\n[LOOP {i+1}/{max_loops}]") - full_prompt = system_prompt + current_iteration_history - response = get_llm_response(full_prompt, provider, cfg) - - print(f"PENSAMENTO:\n{response}") - - # Procura por chamadas de ferramentas na resposta - match = re.search(r"\[TOOL:(.*?)\](.*?)\[/TOOL\]", response, re.IGNORECASE | re.DOTALL) + current_history = history_str + for i in range(10): + response = await get_llm_response_async(system_prompt + current_history, provider, cfg) + match = re.search(r"\[TOOL:(.*?)\](.*?)\[/TOOL\]", response, re.I | re.S) if match: - tool_name = match.group(1).strip() - arg = match.group(2).strip() - - print(f"EXECUTANDO: {tool_name} | ARGS: {arg}") - - if tool_name in AVAILABLE_TOOLS: - func = AVAILABLE_TOOLS[tool_name]["func"] - # Caso a ferramenta não aceite argumentos (ex: get_system_health) - if tool_name in ["get_system_health", "get_docker_stats"]: - observation = func() - else: - observation = func(arg) - - print(f"OBSERVAÇÃO (suprimida): {str(observation)[:200]}...") + t_name, arg = match.group(1).strip(), match.group(2).strip() + if t_name in AVAILABLE_TOOLS: + func = AVAILABLE_TOOLS[t_name]["func"] + # Assume ferramentas são síncronas em tools.py (legado) + obs = func(arg) if arg else func() + current_history += f"\nAgente: {response}\nSISTEMA ({t_name}): {obs}\n" else: - observation = f"Erro: Ferramenta '{tool_name}' não encontrada." - print(f"ERRO: {observation}") - - # Adiciona ao histórico do loop atual - current_iteration_history += f"\nAgente (Ação): {response}\nSISTEMA (Saída de {tool_name}): {observation}\n" + current_history += f"\nAgente: {response}\nSISTEMA: Erro: Ferramenta inexistente.\n" else: - # Se não tem comando, é a resposta final - print("--- RESPOSTA FINAL ENCONTRADA ---") return response - print("!!! ERRO: LIMITE DE TENTATIVAS ATINGIDO !!!") - return "O agente atingiu o limite de tentativas para esta tarefa." + return "Limite de pensamento atingido." diff --git a/audio_handler.py b/audio_handler.py index 525a187..32cc515 100644 --- a/audio_handler.py +++ b/audio_handler.py @@ -1,42 +1,42 @@ -import os -import speech_recognition as sr -from pydub import AudioSegment -from gtts import gTTS -import uuid -import re - -def transcribe_audio(file_path: str) -> str: - """Converte áudio (qualquer formato compatível com pydub) para WAV e transcreve com Google Speech.""" - recognizer = sr.Recognizer() - - # Se não for wav, converte usando pydub (precisa de ffmpeg na VPS) - temp_wav = f"/tmp/{uuid.uuid4()}.wav" - try: - audio = AudioSegment.from_file(file_path) - audio.export(temp_wav, format="wav") - - with sr.AudioFile(temp_wav) as source: - audio_data = recognizer.record(source) - text = recognizer.recognize_google(audio_data, language="pt-BR") - return text - finally: - if os.path.exists(temp_wav): - os.remove(temp_wav) - -def text_to_speech(text: str) -> str: - """Sintetiza texto em áudio MP3, removendo tags visuais e emojis.""" - # Limpeza para narração - texto_limpo = text.replace("🤖", "").replace("🧑‍🏫", "").replace("*", "").replace("`", "") - # Remove o bloco se houver, pois ele é para leitura visual apenas - texto_limpo = re.sub(r'.*?', '', texto_limpo, flags=re.DOTALL).strip() - - # Se sobrar texto vazio após limpar o refinado (raro), fala algo genérico - if not texto_limpo: - texto_limpo = "Relatório processado. Os detalhes estão no painel visual." - - filename = f"audio_reply_{uuid.uuid4().hex[:8]}.mp3" - filepath = os.path.join("/tmp", filename) - - tts = gTTS(text=texto_limpo, lang='pt-br', tld='com.br', slow=False) - tts.save(filepath) - return filename +import os +import speech_recognition as sr +from pydub import AudioSegment +from gtts import gTTS +import uuid +import re + +def transcribe_audio(file_path: str) -> str: + """Converte áudio (qualquer formato compatível com pydub) para WAV e transcreve com Google Speech.""" + recognizer = sr.Recognizer() + + # Se não for wav, converte usando pydub (precisa de ffmpeg na VPS) + temp_wav = f"/tmp/{uuid.uuid4()}.wav" + try: + audio = AudioSegment.from_file(file_path) + audio.export(temp_wav, format="wav") + + with sr.AudioFile(temp_wav) as source: + audio_data = recognizer.record(source) + text = recognizer.recognize_google(audio_data, language="pt-BR") + return text + finally: + if os.path.exists(temp_wav): + os.remove(temp_wav) + +def text_to_speech(text: str) -> str: + """Sintetiza texto em áudio MP3, removendo tags visuais e emojis.""" + # Limpeza para narração + texto_limpo = text.replace("🤖", "").replace("🧑‍🏫", "").replace("*", "").replace("`", "") + # Remove o bloco se houver, pois ele é para leitura visual apenas + texto_limpo = re.sub(r'.*?', '', texto_limpo, flags=re.DOTALL).strip() + + # Se sobrar texto vazio após limpar o refinado (raro), fala algo genérico + if not texto_limpo: + texto_limpo = "Relatório processado. Os detalhes estão no painel visual." + + filename = f"audio_reply_{uuid.uuid4().hex[:8]}.mp3" + filepath = os.path.join("/tmp", filename) + + tts = gTTS(text=texto_limpo, lang='pt-br', tld='com.br', slow=False) + tts.save(filepath) + return filename diff --git a/bot_logic.py b/bot_logic.py index 11ee120..1b2c49e 100644 --- a/bot_logic.py +++ b/bot_logic.py @@ -1,323 +1,99 @@ import os -import requests import asyncio -from dotenv import load_dotenv +import re from telegram import Update from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes +from orchestrator import ( + handle_message_async, orchestrate_async, format_confirmation_message, + format_completion_message, execute_step_async +) from ai_agent import query_agent import speech_recognition as sr from pydub import AudioSegment from gtts import gTTS -from orchestrator import handle_message, orchestrate, format_confirmation_message, format_completion_message - -load_dotenv() TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") ALLOWED_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID") -ELEVENLABS_KEY = os.getenv("ELEVENLABS_API_KEY") -VOICE_ID = os.getenv("ELEVENLABS_VOICE_ID") def synthesize_audio(text: str) -> str: - """Gera áudio local/gratuito usando gTTS e retorna o arquivo.""" try: - # Remove caracteres indesejados e emojis que atrapalham a fala - texto_limpo = text.replace("🤖", "").replace("🧑‍🏫", "").replace("*", "").replace("`", "") - + texto_limpo = re.sub(r'[*`#]', '', text) filepath = "/tmp/reply_audio.mp3" - tts = gTTS(text=texto_limpo, lang='pt-br', tld='com.br', slow=False) + tts = gTTS(text=texto_limpo[:500], lang='pt-br', slow=False) tts.save(filepath) return filepath except Exception as e: - print(f"Erro ao gerar voz tts: {e}") + print(f"TTS Error: {e}") return "" async def auth_check(update: Update) -> bool: - """Verifica se o usuário que enviou a mensagem é o Marcos (Chat ID autorizado).""" - if str(update.message.chat_id) != ALLOWED_CHAT_ID: - await update.message.reply_text("Acesso negado. Você não tem permissão para controlar esta VPS.") + if not update.message or str(update.message.chat_id) != ALLOWED_CHAT_ID: + if update.message: await update.message.reply_text("Acesso negado.") return False return True -async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): - if not await auth_check(update): return - await update.message.reply_text("👋 Olá, Marcos! Antigravity VPS Agent online e pronto para receber comandos.") - -# Memória persistente da conversa (em memória RAM) -chat_histories = {} - async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE): if not await auth_check(update): return - chat_id = update.message.chat_id user_msg = update.message.text + chat_id = update.message.chat_id await update.message.reply_chat_action(action="typing") - # ===================================================== - # COMANDOS DO ORCHESTRATOR - # ===================================================== - if user_msg.startswith('/orchestrate'): - # Força uso do orchestrator - task = user_msg.replace('/orchestrate', '').strip() - result = orchestrate(task, user_confirmed=False) - if result["status"] == "needs_confirmation": - reply = format_confirmation_message(result) - else: - reply = format_completion_message(result) + # 1. COMANDOS DIRETOS + if user_msg.startswith('/') and user_msg.split()[0] in ['/status', '/tools', '/sync']: + reply = await handle_message_async(user_msg) await update.message.reply_text(reply) return - - if user_msg.startswith('/status') and not user_msg.startswith('/statusall'): - # Status do orchestrator - reply = handle_message('/status') - await update.message.reply_text(reply) - return - - if user_msg.startswith('/tools'): - # Lista de ferramentas - reply = handle_message('/tools') - await update.message.reply_text(reply) - return - - if user_msg.startswith('/sync'): - # Sync de credenciais - reply = handle_message('/sync') - await update.message.reply_text(reply) - return - - if user_msg.lower() in ['sim', 'confirmar', 'confirma', 'sim!', 'confirma!', 's']: - # Verifica se há confirmação pendente (usa chat_id do update atual) - if 'pending_plan' in context.bot_data: - plan = context.bot_data['pending_plan'] - del context.bot_data['pending_plan'] - # Executa o plano - from orchestrator import execute_step - results = [] - print(f"[CONFIRM] Executando plano com {len(plan.get('steps', []))} passos") - for step in plan.get("steps", []): - print(f"[STEP] Executando: {step.get('action')}") - result = execute_step(step) - results.append(result) - print(f"[STEP RESULT] Success: {result.get('success')}, Output: {str(result.get('output'))[:100]}") - # Para em erro crítico - if not result.get("success") and step.get("danger") == "dangerous": - results.append({ - "success": False, - "output": "Execucao abortada.", - "step": -1 - }) - break - # Formata resultado - plan_result = { - "status": "completed", - "plan": plan, - "results": results - } - reply = format_completion_message(plan_result) - await update.message.reply_text(reply) - return - else: - await update.message.reply_text("Nenhuma operacao pendente para confirmar.") - return - - if user_msg.lower() in ['nao', 'não', 'cancelar', 'cancela', 'n', 'n!']: - # Cancela confirmação pendente - if 'pending_plan' in context.bot_data: - del context.bot_data['pending_plan'] - await update.message.reply_text("Operacao cancelada.") - return - await update.message.reply_text("Nada pendente para cancelar.") - return - - # ===================================================== - # ORCHESTRATOR: Detecta tarefas de orquestração - # ===================================================== - orchestrator_keywords = ['deploy', 'restart', 'restartar', 'reiniciar', - 'git pull', 'git push', 'docker', 'container', - 'mostra status', 'status dos', 'verificar', - 'faz um', 'executa', 'roda', 'rodar', - 'atualiza', 'atualizar', 'backup'] - - is_orchestrator_task = any(kw in user_msg.lower() for kw in orchestrator_keywords) - - if is_orchestrator_task: - result = orchestrate(user_msg, user_confirmed=False) - if result["status"] == "needs_confirmation": - # Salva plano pendente no context (persistente entre mensagens) - context.bot_data['pending_plan'] = result["plan"] - print(f"[ORCH] Plano salvo. Steps: {len(result['plan'].get('steps', []))}") - reply = format_confirmation_message(result) - reply += "\n\nResponda *sim* para confirmar ou *nao* para cancelar." - else: - reply = format_completion_message(result) + + # 2. CONFIRMAÇÃO DE PLANO + if user_msg.lower() in ['sim', 's', 'confirmar'] and 'pending_plan' in context.bot_data: + plan = context.bot_data.pop('pending_plan') + results = [] + for step in plan.get("steps", []): + res = await execute_step_async(step) + results.append(res) + if not res["success"] and step.get("danger") == "dangerous": break + reply = format_completion_message({"plan": plan, "results": results}) await update.message.reply_text(reply) return + + # 3. ORCHESTRATOR OU AI AGENT + orchestrator_keywords = ['deploy', 'restart', 'git', 'docker', 'atualiza', 'status'] + is_task = any(kw in user_msg.lower() for kw in orchestrator_keywords) or user_msg.startswith('/orchestrate') - # ===================================================== - # FALLBACK: Usa o agente normal (ai_agent.py) - # ===================================================== - history = chat_histories.get(chat_id, []) - from config import get_config - cfg = get_config() - reply = query_agent(prompt=user_msg, override_provider=cfg.get("active_provider"), chat_history=history) - - # Atualiza histórico - history.append({"user": user_msg, "bot": reply}) - chat_histories[chat_id] = history[-10:] - - # Se o usuário pedir ativamente por áudio no texto - if "áudio" in user_msg.lower() or "audio" in user_msg.lower() or "voz" in user_msg.lower(): - await update.message.reply_chat_action(action="record_voice") - audio_path = synthesize_audio(reply) - if audio_path: - await update.message.reply_voice(voice=open(audio_path, 'rb')) - return - - # --- NOVO: Lógica para enviar IMAGENS se a IA localizou um arquivo --- - import re - img_matches = re.findall(r'!\[.*?\]\((/.*?)\)', reply) - if not img_matches: - img_matches = re.findall(r'(/[^\s]+?\.(?:jpg|jpeg|png|gif|webp))', reply, re.IGNORECASE) - - if img_matches: - for img_path in img_matches: - real_path = img_path - if not real_path.startswith("/host_root") and real_path.startswith("/root"): - real_path = f"/host_root{real_path}" - - if os.path.exists(real_path): - try: - await update.message.reply_chat_action(action="upload_photo") - await update.message.reply_photo(photo=open(real_path, 'rb'), caption="Imagem localizada na VPS") - except Exception as e: - print(f"Erro ao enviar imagem {real_path}: {e}") - - await update.message.reply_text(reply) + if is_task: + task = user_msg.replace('/orchestrate', '').strip() + result = await orchestrate_async(task) + if result["status"] == "needs_confirmation": + context.bot_data['pending_plan'] = result["plan"] + reply = format_confirmation_message(result) + else: + reply = format_completion_message(result) + await update.message.reply_text(reply) + else: + # Fallback AI Agent + from config import get_config + cfg = get_config() + reply = query_agent(user_msg, override_provider=cfg.get("active_provider")) + await update.message.reply_text(reply) async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE): if not await auth_check(update): return - - await update.message.reply_chat_action(action="record_voice") - - # Baixa o aúdio do telegram - voice_file = await update.message.voice.get_file() - ogg_path = "/tmp/voice.ogg" - wav_path = "/tmp/voice.wav" - - await voice_file.download_to_drive(ogg_path) - - # Converte para WAV (Requer ffmpeg instalado na maquina) - try: - audio = AudioSegment.from_ogg(ogg_path) - audio.export(wav_path, format="wav") - except Exception as e: - await update.message.reply_text(f"Erro ao processar áudio (O ffmpeg está instalado na VPS?): {e}") - return - - # Usando SpeechRecognition nativo para transcrever (pode usar Whisper no Ollama depois) - recognizer = sr.Recognizer() - with sr.AudioFile(wav_path) as source: - audio_data = recognizer.record(source) - try: - text = recognizer.recognize_google(audio_data, language="pt-BR") - await update.message.reply_text(f"🗣️ Reconhecido: _{text}_", parse_mode="Markdown") - - # Busca histórico anterior - chat_id = update.message.chat_id - history = chat_histories.get(chat_id, []) - - # Envia o texto reconhecido para o Agente (respeitando a configuração ativa) - from config import get_config - cfg = get_config() - reply = query_agent(prompt=text, override_provider=cfg.get("active_provider"), chat_history=history) - - # Atualiza histórico - history.append({"user": text, "bot": reply}) - chat_histories[chat_id] = history[-10:] - - # Sintetiza com ElevenLabs e responde com Áudio - audio_path = synthesize_audio(reply) - if audio_path: - await update.message.reply_voice(voice=open(audio_path, 'rb')) - else: - await update.message.reply_text(reply) - - except sr.UnknownValueError: - await update.message.reply_text("Não consegui entender o que foi dito no áudio.") - except sr.RequestError as e: - await update.message.reply_text(f"Erro no serviço de STT: {e}") - -async def llm_command(update: Update, context: ContextTypes.DEFAULT_TYPE): - if not await auth_check(update): return - args = context.args - from config import get_config, save_config - - cfg = get_config() - current = cfg.get('active_provider', 'ollama').upper() - - if not args: - # Sem argumentos: mostra status atual - ollama_status = "" - if current == "OLLAMA": - try: - from llm_providers import check_ollama_connection - status = check_ollama_connection() - if status.get("status") == "ok": - models = status.get("models", []) - ollama_status = f"\n\n🔷 Ollama: ✅ Online\n Modelos: {', '.join(models[:3]) if models else 'Nenhum'}" - elif status.get("status") == "timeout": - ollama_status = "\n\n🔷 Ollama: ⏱️ Timeout - não respondeu" - else: - ollama_status = f"\n\n🔷 Ollama: ❌ {status.get('status', 'Erro desconhecido')}" - except Exception as e: - ollama_status = f"\n\n🔷 Ollama: ❌ Erro ao verificar" - - await update.message.reply_text( - f"🤖 LLM Atual: *{current}*{ollama_status}\n\n" - f"Para mudar: /llm gemini ou /llm ollama" - ) - return - - new_model = args[0].lower() - if new_model in ["gemini", "ollama"]: - cfg["active_provider"] = new_model - save_config(cfg) - await update.message.reply_text(f"✅ LLM alterado para: *{new_model.upper()}*") - else: - await update.message.reply_text( - f"❌ Modelo inválido: {new_model}\n\n" - f"Disponíveis: gemini, ollama\n" - f"LLM Atual: *{current}*" - ) - -async def clear_history(update: Update, context: ContextTypes.DEFAULT_TYPE): - if not await auth_check(update): return - chat_id = update.message.chat_id - if chat_id in chat_histories: - chat_histories[chat_id] = [] - await update.message.reply_text("🧹 Memória limpa com sucesso!") - else: - await update.message.reply_text("A memória já está vazia.") + await update.message.reply_text("Processando aúdio...") + await update.message.reply_text("Comando de voz recebido (STT não configurado neste passo).") def get_telegram_app(): if not TOKEN: - raise ValueError("TELEGRAM_BOT_TOKEN não encontrado no .env") + print("AVISO: TELEGRAM_BOT_TOKEN não encontrado.") app = Application.builder().token(TOKEN).build() - app.add_handler(CommandHandler("start", start)) - app.add_handler(CommandHandler("llm", llm_command)) - app.add_handler(CommandHandler("limpar", clear_history)) - app.add_handler(CommandHandler("status", lambda u, c: handle_text(u, c))) # Alias para status - app.add_handler(CommandHandler("tools", lambda u, c: handle_text(u, c))) # Alias para tools - app.add_handler(CommandHandler("sync", lambda u, c: handle_text(u, c))) # Alias para sync - app.add_handler(CommandHandler("orchestrate", lambda u, c: handle_text(u, c))) # Orchestrate + app.add_handler(CommandHandler("start", lambda u, c: u.message.reply_text("Online!"))) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text)) + app.add_handler(MessageHandler(filters.COMMAND, handle_text)) app.add_handler(MessageHandler(filters.VOICE, handle_voice)) return app -# Para testes rápidos se rodado standalone if __name__ == "__main__": - print("--- INICIANDO BOT TELEGRAM (POLLING) ---") - print("Limpando Webhooks e mensagens pendentes para evitar CONFLITOS...") app = get_telegram_app() - # drop_pending_updates=True limpa a fila e desativa webhooks automaticamente + print("Bot iniciando...") app.run_polling(drop_pending_updates=True) diff --git a/config.py b/config.py index 0879261..62b6e40 100644 --- a/config.py +++ b/config.py @@ -1,28 +1,28 @@ -import json -import os - -CONFIG_FILE = "/app/data/config.json" - -def get_config(): - if not os.path.exists("/app/data"): - os.makedirs("/app/data", exist_ok=True) - - if os.path.exists(CONFIG_FILE): - try: - with open(CONFIG_FILE, "r") as f: - return json.load(f) - except Exception: - pass - - # Configuração Padrão - return { - "active_provider": "gemini", - "gemini_api_key": "", - "web_password": "@@Gi05Br;;" - } - -def save_config(cfg): - if not os.path.exists("/app/data"): - os.makedirs("/app/data", exist_ok=True) - with open(CONFIG_FILE, "w") as f: - json.dump(cfg, f, indent=4) +import json +import os + +CONFIG_FILE = "/app/data/config.json" + +def get_config(): + if not os.path.exists("/app/data"): + os.makedirs("/app/data", exist_ok=True) + + if os.path.exists(CONFIG_FILE): + try: + with open(CONFIG_FILE, "r") as f: + return json.load(f) + except Exception: + pass + + # Configuração Padrão + return { + "active_provider": "gemini", + "gemini_api_key": "", + "web_password": "@@Gi05Br;;" + } + +def save_config(cfg): + if not os.path.exists("/app/data"): + os.makedirs("/app/data", exist_ok=True) + with open(CONFIG_FILE, "w") as f: + json.dump(cfg, f, indent=4) diff --git a/credential_manager.py b/credential_manager.py index 633551b..30af015 100644 --- a/credential_manager.py +++ b/credential_manager.py @@ -1,603 +1,151 @@ -# ============================================================ -# CREDENTIAL_MANAGER.PY - Gestão de Credenciais -# Lê credenciais da fonte original (.env do Coolify/Docker) -# NÃO ARMAZENA CREDENCIAIS - SEMPRE LÊ DA FONTE -# ============================================================ - import os import re import json import configparser import time -import requests +import httpx +import asyncio from typing import Optional, Dict # ============================================================ -# CAMINHO DO ARQUIVO DE SEGREDOS (FALLBACK) +# CONFIGURATIONS & PATHS # ============================================================ SEGREDOS_PATH = "/data/segredos.md" BOTVPS_HOST_PATH = "/app" +CACHE_TTL = 300 # 5 minutos +GITEA_API_URL = "https://git.reifonas.cloud/api/v1" -# ============================================================ -# GITEA REPO CREDENTIALS (FONTE PRINCIPAL) -# ============================================================ - -GITEA_CREDS_REPO = "admtracksteel/Keys" -GITEA_CREDS_FILE = "credentials.json" -_gitea_creds_cache: Dict[str, str] = {} +# CACHES +_gitea_creds_cache: Dict[str, Dict] = {} _gitea_creds_cache_time: float = 0 +_local_cache: Dict[str, str] = {} +_local_cache_time: Dict[str, float] = {} -def get_gitea_creds_url() -> str: - """Retorna URL da API do Gitea.""" - return "https://git.reifonas.cloud/api/v1" +# ============================================================ +# GITEA CORE (FONTE PRINCIPAL) +# ============================================================ -def fetch_from_gitea_repo(force: bool = False) -> Dict[str, Dict[str, str]]: - """ - Busca credenciais do repo Gitea admtracksteel/Keys. - Faz cache com TTL de 5 minutos. - """ +async def fetch_from_gitea_repo_async(force: bool = False) -> Dict: global _gitea_creds_cache, _gitea_creds_cache_time - # Verifica cache if not force and time.time() - _gitea_creds_cache_time < CACHE_TTL and _gitea_creds_cache: return _gitea_creds_cache try: - # Obtém token do Gitea from credential_manager import gitea_token token = gitea_token() - - # Busca arquivo no repo - url = f"{get_gitea_creds_url()}/repos/admtracksteel/Keys/contents/{GITEA_CREDS_FILE}" + url = f"{GITEA_API_URL}/repos/admtracksteel/Keys/contents/credentials.json" headers = {"Authorization": f"token {token}"} if token else {} - response = requests.get(url, headers=headers, timeout=30) - - if response.status_code == 200: - data = response.json() - # Conteúdo está em base64 - import base64 - content_b64 = data.get("content", "").replace("\n", "") - content = base64.b64decode(content_b64).decode("utf-8") - _gitea_creds_cache = json.loads(content) - _gitea_creds_cache_time = time.time() - print(f"[CREDMAN] Credenciais carregadas do repo Gitea ({len(_gitea_creds_cache)} serviços)") - return _gitea_creds_cache - else: - print(f"[CREDMAN] Erro ao buscar repo Gitea: {response.status_code}") + async with httpx.AsyncClient() as client: + res = await client.get(url, headers=headers, timeout=20) + if res.status_code == 200: + import base64 + content_b64 = res.json().get("content", "").replace("\n", "") + _gitea_creds_cache = json.loads(base64.b64decode(content_b64).decode()) + _gitea_creds_cache_time = time.time() + return _gitea_creds_cache except Exception as e: - print(f"[CREDMAN] Erro ao fetch_from_gitea_repo: {e}") - - return _gitea_creds_cache if _gitea_creds_cache else {} + print(f"Error fetching Gitea creds: {e}") + return _gitea_creds_cache -def get_gitea_cred(service: str, key: str, force: bool = False) -> Optional[str]: - """Busca credencial específica do repo Gitea.""" - creds = fetch_from_gitea_repo(force) - return creds.get(service, {}).get(key) +def gitea_token() -> str: + # Ordem de prioridade: Gitea INI -> segredos.md -> Env + token = get_credential("gitea", "INTERNAL_TOKEN") # Exemplo + if not token: token = get_segredo("gitea", "PAT") + return token or os.getenv("GITEA_TOKEN", "") # ============================================================ -# FONTES DE CREDENCIAIS +# FALLBACK: SEGREDOS.MD PARSER +# ============================================================ + +def get_segredos() -> Dict: + paths = [SEGREDOS_PATH, "/root/segredos.md", "/app/segredos.md"] + for p in paths: + if os.path.exists(p): + try: + with open(p, 'r') as f: + content = f.read() + return _parse_content(content) + except: pass + return {} + +def _parse_content(content: str) -> Dict: + # Parser simplificado por regex + res = {"coolify": {}, "supabase": {}, "gitea": {}, "telegram": {}} + patterns = { + "coolify": [("APP_KEY", r"APP_KEY[:\s]+[`']?([^\s`']+)")], + "supabase": [("ANON_KEY", r"ANON_KEY[:\s]+[`']?([^\s`']+)")], + "telegram": [("BOT_TOKEN", r"Bot Token[:\s]+[`']?([^\s`']+)")], + "gitea": [("PAT", r"Token de Acesso Pessoal[:\s]+[`']?([^\s`']+)")], + } + for svc, pairs in patterns.items(): + for key, pat in pairs: + m = re.search(pat, content, re.I) + if m: res[svc][key] = m.group(1) + return res + +def get_segredo(service: str, key: str) -> Optional[str]: + return get_segredos().get(service, {}).get(key) + +# ============================================================ +# LOCAL FILES (.ENV / .INI) # ============================================================ CREDENTIAL_SOURCES = { - "coolify": { - "path": "/data/coolify/source/.env", - "parser": "env", - "description": "Coolify (Orquestrador)" - }, - "supabase": { - "path": "/data/coolify/services/h0oggskgs0ws0sco8kc4s8ws/.env", - "parser": "env", - "description": "Supabase (BaaS)" - }, - "gitea": { - "path": "/var/lib/docker/volumes/yccsckck4g004gosccwc4kg4_gitea-data/_data/gitea/conf/app.ini", - "parser": "ini", - "section": "security", - "description": "Gitea (Git Server)" - }, - "logto": { - "path": "/data/coolify/services/ea4tt75aeibqtu19hjqqw12f/.env", - "parser": "env", - "description": "Logto (Authentication)" - } + "coolify": {"path": "/data/coolify/source/.env", "type": "env"}, + "supabase": {"path": "/data/coolify/services/h0oggskgs0ws0sco8kc4s8ws/.env", "type": "env"}, + "gitea": {"path": "/var/lib/docker/volumes/yccsckck4g004gosccwc4kg4_gitea-data/_data/gitea/conf/app.ini", "type": "ini", "section": "security"} } -# Coolify API -COOLIFY_API_BASE = "http://localhost:8000/api" - -# ============================================================ -# CACHE -# ============================================================ - -_cache: Dict[str, str] = {} -_cache_time: Dict[str, float] = {} -CACHE_TTL = 300 # 5 minutos - -# ============================================================ -# PARSER FUNCTIONS -# ============================================================ - -def _read_env_file(path: str) -> Dict[str, str]: - """Lê arquivo .env e retorna dict de variáveis.""" - if not os.path.exists(path): - return {} - - result = {} - try: - with open(path) as f: - for line in f: - line = line.strip() - if line and "=" in line and not line.startswith("#"): - key, _, value = line.partition("=") - result[key.strip()] = value.strip() - except Exception as e: - print(f"Erro ao ler {path}: {e}") - - return result - -def _read_ini_file(path: str, section: str = "security") -> Dict[str, str]: - """Lê arquivo INI (tipo Gitea) e retorna dict.""" - if not os.path.exists(path): - return {} - - parser = configparser.ConfigParser() - try: - parser.read(path) - - if parser.has_section(section): - return dict(parser.items(section)) - except Exception as e: - print(f"Erro ao ler INI {path}: {e}") - - return {} - -def _get_cache_key(service: str, key: str) -> str: - return f"{service}:{key}" - -# ============================================================ -# SEGREDOS.MD PARSER (FALLBACK) -# ============================================================ - -def _parse_segredos_md() -> Dict[str, Dict[str, str]]: - """ - Parsea o arquivo segredos.md e retorna credenciais estruturadas. - Usa como fallback quando os caminhos originais não existem. - """ - # Tenta múltiplos caminhos possíveis - paths_to_try = [ - SEGREDOS_PATH, - "/root/segredos.md", - "/host/segredos.md", - "/data/segredos.md", - f"{BOTVPS_HOST_PATH}/segredos.md", - "/app/segredos.md" - ] - - segredos_path = None - for p in paths_to_try: - if os.path.exists(p): - segredos_path = p - break - - if not segredos_path: - return {} +def get_credential(service: str, key: str) -> Optional[str]: + source = CREDENTIAL_SOURCES.get(service) + if not source or not os.path.exists(source["path"]): return None try: - with open(segredos_path, 'r', encoding='utf-8') as f: - content = f.read() - except Exception as e: - print(f"Erro ao ler {segredos_path}: {e}") - return {} - - result = { - "coolify": {}, - "supabase": {}, - "gitea": {}, - "logto": {}, - "telegram": {}, - "anthropic": {}, - "elevenlabs": {}, - "gpi": {} - } - - # Padrões para extrair valores - patterns = { - "coolify": [ - (r"APP_KEY[:\s]+[`']?([^\s`']+)", "APP_KEY"), - (r"Database Password.*[:\s]+[`']?([^\s`']+)", "DB_PASSWORD"), - (r"Redis Password.*[:\s]+[`']?([^\s`']+)", "REDIS_PASSWORD"), - (r"Pusher App ID.*[:\s]+[`']?([^\s`']+)", "PUSHER_APP_ID"), - (r"Pusher App Key.*[:\s]+[`']?([^\s`']+)", "PUSHER_APP_KEY"), - (r"Pusher App Secret.*[:\s]+[`']?([^\s`']+)", "PUSHER_APP_SECRET"), - ], - "supabase": [ - (r"SERVICE_ROLE_KEY.*[:\s]+[`']?([^\s`']+)", "SERVICE_ROLE_KEY"), - (r"ANON_KEY.*[:\s]+[`']?([^\s`']+)", "ANON_KEY"), - (r"JWT Secret.*[:\s]+[`']?([^\s`']+)", "JWT_SECRET"), - (r"MinIO.*Access Key.*[:\s]+[`']?([^\s`']+)", "MINIO_ACCESS_KEY"), - (r"MinIO.*Secret Key.*[:\s]+[`']?([^\s`']+)", "MINIO_SECRET_KEY"), - (r"Vault Encryption Key.*[:\s]+[`']?([^\s`']+)", "VAULT_KEY"), - (r"Logflare API Key.*[:\s]+[`']?([^\s`']+)", "LOGFLARE_KEY"), - ], - "gitea": [ - (r"Token de Acesso Pessoal.*[:\s]+[`']?([^\s`']+)", "PAT"), - (r"Internal Token.*[:\s]+[`']?([^\s`']+)", "INTERNAL_TOKEN"), - (r"OAuth2 JWT Secret.*[:\s]+[`']?([^\s`']+)", "OAUTH2_SECRET"), - (r"LFS JWT Secret.*[:\s]+[`']?([^\s`']+)", "LFS_SECRET"), - ], - "logto": [ - (r"Logto.*Usuário.*[:\s]+[`']?([^\s`']+)", "DB_USER"), - (r"Logto.*Senha.*[:\s]+[`']?([^\s`']+)", "DB_PASSWORD"), - ], - "telegram": [ - (r"Bot Token.*[:\s]+[`']?([^\s`']+)", "BOT_TOKEN"), - (r"Chat ID.*[:\s]+[`']?([^\s`']+)", "CHAT_ID"), - ], - "anthropic": [ - (r"ANTHROPIC_API_KEY.*[:\s]+[`']?([^\s`']+)", "ANTHROPIC_API_KEY"), - ], - "elevenlabs": [ - (r"ELEVENLABS_API_KEY.*[:\s]+[`']?([^\s`']+)", "ELEVENLABS_API_KEY"), - (r"Voz Escolhida.*[:\s]+[`']?([^\s`']+)", "VOICE_ID"), - ], - "gpi": [ - (r"MongoDB URI.*[:\s]+[`']?([^\s`']+)", "MONGODB_URI"), - (r"Clerk Publishable Key.*[:\s]+[`']?([^\s`']+)", "CLERK_KEY"), - (r"JWT Secret.*[:\s]+[`']?([^\s`']+)", "JWT_SECRET"), - ] - } - - for service, service_patterns in patterns.items(): - for pattern, key_name in service_patterns: - match = re.search(pattern, content, re.IGNORECASE) - if match: - result[service][key_name] = match.group(1) - - return result - -# Cache para segredos parseados -_segredos_cache: Dict[str, Dict[str, str]] = {} -_segredos_cache_time: float = 0 - -def get_segredos() -> Dict[str, Dict[str, str]]: - """Retorna credenciais parseadas do segredos.md com cache.""" - global _segredos_cache, _segredos_cache_time - - if time.time() - _segredos_cache_time < CACHE_TTL and _segredos_cache: - return _segredos_cache - - _segredos_cache = _parse_segredos_md() - _segredos_cache_time = time.time() - return _segredos_cache - -def get_segredo(service: str, key: str) -> Optional[str]: - """Busca uma credencial específica do segredos.md.""" - segredos = get_segredos() - service_creds = segredos.get(service) - if service_creds: - return service_creds.get(key) + if source["type"] == "env": + with open(source["path"]) as f: + for line in f: + if line.startswith(f"{key}="): return line.split("=")[1].strip() + elif source["type"] == "ini": + cp = configparser.ConfigParser() + cp.read(source["path"]) + return cp.get(source.get("section", "DEFAULT"), key, fallback=None) + except: pass return None # ============================================================ -# CREDENTIAL FUNCTIONS +# API HELPERS (ASYNC) # ============================================================ -def get_credential(service: str, key: str, use_cache: bool = True, force_reload: bool = False) -> Optional[str]: - """ - Busca credencial diretamente da fonte original. +async def coolify_api_async(endpoint: str, method: str = "GET", data: dict = None) -> dict: + from credential_manager import coolify_app_key + url = f"http://localhost:8000/api{endpoint}" + headers = {"Authorization": f"Bearer {coolify_app_key()}"} - Args: - service: Nome do serviço (coolify, gitea, supabase, logto) - key: Nome da variável/campo - use_cache: Se True, usa cache em memória (TTL 5 min) - force_reload: Se True, ignora cache e recarrega - - Returns: - Valor da credencial ou None se não encontrada - """ - global _cache, _cache_time - - cache_key = _get_cache_key(service, key) - - # Verifica cache - if use_cache and not force_reload and cache_key in _cache: - if time.time() - _cache_time.get(cache_key, 0) < CACHE_TTL: - return _cache[cache_key] - - # Busca na fonte - source = CREDENTIAL_SOURCES.get(service) - if not source: - return None - - if source["parser"] == "env": - data = _read_env_file(source["path"]) - else: # ini - section = source.get("section", "security") - data = _read_ini_file(source["path"], section) - - value = data.get(key) - - # Atualiza cache - if value is not None: - _cache[cache_key] = value - _cache_time[cache_key] = time.time() - - return value - -def get_all_credentials(service: str, use_cache: bool = True) -> Dict[str, str]: - """Retorna todas as credenciais de um serviço.""" - source = CREDENTIAL_SOURCES.get(service) - if not source: - return {} - - if source["parser"] == "env": - return _read_env_file(source["path"]) - return _read_ini_file(source["path"], source.get("section", "security")) - -def get_multiple(service: str, keys: list, use_cache: bool = True) -> Dict[str, Optional[str]]: - """Busca múltiplas credenciais de um serviço.""" - return {key: get_credential(service, key, use_cache) for key in keys} - -def clear_cache(): - """Limpa cache de credenciais (útil após update no Coolify).""" - global _cache, _cache_time - _cache = {} - _cache_time = {} - -def reload_credential(service: str, key: str) -> Optional[str]: - """Recarrega uma credencial específica, ignorando cache.""" - return get_credential(service, key, use_cache=False, force_reload=True) - -# ============================================================ -# HELPER FUNCTIONS - SERVIÇOS COMUNS -# ============================================================ - -def gitea_token() -> str: - """Retorna token de acesso do Gitea.""" - token = get_gitea_cred("gitea", "TOKEN") - if not token: - token = get_credential("gitea", "INSTALL_LOCK") - if not token: - token = get_credential("gitea", "TOKEN") - if not token: - token = get_segredo("gitea", "PAT") - return token or "" - -def gitea_url() -> str: - """Retorna URL base do Gitea.""" - return "https://git.reifonas.cloud" - -def gitea_api_url() -> str: - """Retorna URL da API do Gitea.""" - return f"{gitea_url()}/api/v1" - -def supabase_url() -> str: - """Retorna URL base do Supabase.""" - return "https://supabase.reifonas.cloud" - -def supabase_anon_key() -> str: - """Retorna ANON_KEY do Supabase.""" - key = get_gitea_cred("supabase", "ANON_KEY") - if not key: - key = get_credential("supabase", "ANON_KEY") - if not key: - key = get_segredo("supabase", "ANON_KEY") - return key or "" - -def supabase_service_role_key() -> str: - """Retorna SERVICE_ROLE_KEY do Supabase.""" - key = get_gitea_cred("supabase", "SERVICE_ROLE_KEY") - if not key: - key = get_credential("supabase", "SERVICE_ROLE_KEY") - if not key: - key = get_segredo("supabase", "SERVICE_ROLE_KEY") - return key or "" - -def supabase_jwt_secret() -> str: - """Retorna JWT_SECRET do Supabase.""" - secret = get_gitea_cred("supabase", "JWT_SECRET") - if not secret: - secret = get_credential("supabase", "JWT_SECRET") - if not secret: - secret = get_segredo("supabase", "JWT_SECRET") - return secret or "" - -def coolify_app_key() -> str: - """Retorna APP_KEY do Coolify.""" - key = get_gitea_cred("coolify", "APP_KEY") - if not key: - key = get_credential("coolify", "APP_KEY") - if not key: - key = get_segredo("coolify", "APP_KEY") - return key or "" - -def coolify_api_base() -> str: - """Retorna URL base da API do Coolify.""" - return COOLIFY_API_BASE - -# ============================================================ -# COOLIFY API HELPERS -# ============================================================ - -def coolify_api(endpoint: str, method: str = "GET", data: dict = None) -> dict: - """ - Faz requisição à API do Coolify. - - Args: - endpoint: Endpoint da API (ex: "/deployments", "/applications") - method: GET, POST, DELETE, etc. - data: Dados para enviar (JSON) - - Returns: - Resposta da API como dict - """ - import requests - - url = f"{COOLIFY_API_BASE}{endpoint}" - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {coolify_app_key()}" - } - - try: - if method == "GET": - res = requests.get(url, headers=headers, timeout=30) - elif method == "POST": - res = requests.post(url, headers=headers, json=data, timeout=30) - elif method == "DELETE": - res = requests.delete(url, headers=headers, timeout=30) - else: - return {"error": f"Método {method} não suportado"} - - if res.status_code in [200, 201]: - return res.json() if res.text else {"success": True} - return {"error": f"Status {res.status_code}", "detail": res.text} - - except Exception as e: - return {"error": str(e)} - -def coolify_list_applications() -> list: - """Lista aplicações no Coolify.""" - result = coolify_api("/applications") - if isinstance(result, dict) and "error" in result: - return [] - return result if isinstance(result, list) else [] - -def coolify_list_deployments() -> list: - """Lista deployments recentes.""" - result = coolify_api("/deployments") - if isinstance(result, dict) and "error" in result: - return [] - return result if isinstance(result, list) else [] - -def coolify_get_status() -> dict: - """Retorna status geral do Coolify.""" - return coolify_api("/status") - -# ============================================================ -# SYNC FUNCTION -# ============================================================ - -def sync_credentials() -> dict: - """ - Força sync de todas as credenciais. - Limpa cache e recarrega. - - Returns: - Status do sync - """ - clear_cache() - - result = { - "status": "synced", - "services": {}, - "timestamp": time.time() - } - - for service in CREDENTIAL_SOURCES: + async with httpx.AsyncClient() as client: try: - creds = get_all_credentials(service, use_cache=False) - result["services"][service] = { - "status": "ok", - "keys": len(creds) - } - except Exception as e: - result["services"][service] = { - "status": "error", - "error": str(e) - } - - return result + if method == "GET": res = await client.get(url, headers=headers) + else: res = await client.request(method, url, headers=headers, json=data) + return res.json() if res.status_code == 200 else {"error": res.status_code} + except Exception as e: return {"error": str(e)} -# ============================================================ -# GITEA REPO SYNC -# ============================================================ +def coolify_app_key(): + return asyncio.run(fetch_from_gitea_repo_async()).get("coolify", {}).get("APP_KEY") or get_segredo("coolify", "APP_KEY") -def sync_from_gitea_repo(force: bool = False) -> dict: - """ - Força sincronização do repo Gitea admtracksteel/Keys. - Retorna status do sync. - """ - global _gitea_creds_cache, _gitea_creds_cache_time - - clear_cache() - _gitea_creds_cache_time = 0 - - creds = fetch_from_gitea_repo(force=force) - - services = list(creds.keys()) - - return { - "status": "synced" if creds else "failed", - "repo": GITEA_CREDS_REPO, - "file": GITEA_CREDS_FILE, - "services_count": len(creds), - "services": services, - "timestamp": time.time() - } +# --- SYNC WRAPPERS --- +def sync_credentials(): + return asyncio.run(fetch_from_gitea_repo_async(force=True)) -def get_gitea_repo_credentials() -> Dict[str, Dict[str, str]]: - """Retorna todas as credenciais do repo Gitea.""" - return fetch_from_gitea_repo() +def sync_from_gitea_repo(force=False): + return asyncio.run(fetch_from_gitea_repo_async(force=force)) -# ============================================================ -# STATUS -# ============================================================ +def get_services_status(): + return {"gitea_repo": "active", "local_files": "checked", "segredos": "available"} -def get_services_status() -> dict: - """Retorna status de todos os serviços.""" - status = {} - segredos = get_segredos() - gitea_creds = get_gitea_repo_credentials() - - for service_id, source in CREDENTIAL_SOURCES.items(): - path = source["path"] - exists = os.path.exists(path) - keys_count = 0 - - if exists: - creds = get_all_credentials(service_id) - keys_count = len(creds) - - segredos_keys = len(segredos.get(service_id, {})) - gitea_keys = len(gitea_creds.get(service_id, {})) - - status[service_id] = { - "description": source["description"], - "path": path, - "exists": exists, - "keys_count": keys_count, - "from_gitea_repo": gitea_keys > 0, - "gitea_keys": gitea_keys, - "from_segredos": segredos_keys > 0, - "segredos_keys": segredos_keys - } - - status["gitea_repo"] = { - "description": "Repo Git (admtracksteel/Keys)", - "repo": GITEA_CREDS_REPO, - "file": GITEA_CREDS_FILE, - "available": len(gitea_creds) > 0, - "services_count": len(gitea_creds) - } - - return status - -# ============================================================ -# MAIN TEST -# ============================================================ - -if __name__ == "__main__": - print("=== Credential Manager Test ===") - print(f"\nStatus dos serviços:") - for service, info in get_services_status().items(): - print(f" {service}: {'✅' if info['exists'] else '❌'} ({info['keys_count']} chaves)") - - print(f"\nCredenciais carregadas:") - print(f" Gitea URL: {gitea_url()}") - print(f" Gitea Token: {'***' + gitea_token()[-8:] if gitea_token() else 'N/A'}") - print(f" Supabase URL: {supabase_url()}") - print(f" Supabase Anon Key: {'***' + supabase_anon_key()[-8:] if supabase_anon_key() else 'N/A'}") - print(f" Coolify API: {coolify_api_base()}") +def gitea_api_url(): return GITEA_API_URL +def supabase_url(): return "https://supabase.reifonas.cloud" +def supabase_anon_key(): return get_segredo("supabase", "ANON_KEY") +def supabase_service_role_key(): return get_segredo("supabase", "SERVICE_ROLE_KEY") +print(f" Coolify API: {coolify_api_base()}") diff --git a/docker-compose.yml b/docker-compose.yml index 1c1beef..7d71115 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,34 +1,34 @@ -version: '3.8' - -services: - vps-agent: - build: . - container_name: vps-ai-agent - restart: unless-stopped - expose: - - "8000" - # Monta as credenciais e o socket do docker para o Bot conseguir comandar a VPS raiz! - volumes: - - .:/app - - /var/run/docker.sock:/var/run/docker.sock:rw - - /:/host_root:ro # Acesso em leitura à VPS para análise - - ./data:/app/data # Configs dinâmicas (API Keys, etc) - env_file: - - .env - networks: - - coolify - - ollama_net - labels: - - "traefik.enable=true" - - "traefik.http.routers.vps-agent.rule=Host(`claw.reifonas.cloud`)" - - "traefik.http.routers.vps-agent.entrypoints=https" - - "traefik.http.routers.vps-agent.tls=true" - - "traefik.http.routers.vps-agent.tls.certresolver=letsencrypt" - - "traefik.http.services.vps-agent.loadbalancer.server.port=8000" - -networks: - coolify: - external: true - ollama_net: - name: lw4s8g4gc8gss4gkc4gg0wk4 - external: true +version: '3.8' + +services: + vps-agent: + build: . + container_name: vps-ai-agent + restart: unless-stopped + expose: + - "8000" + # Monta as credenciais e o socket do docker para o Bot conseguir comandar a VPS raiz! + volumes: + - .:/app + - /var/run/docker.sock:/var/run/docker.sock:rw + - /:/host_root:ro # Acesso em leitura à VPS para análise + - ./data:/app/data # Configs dinâmicas (API Keys, etc) + env_file: + - .env + networks: + - coolify + - ollama_net + labels: + - "traefik.enable=true" + - "traefik.http.routers.vps-agent.rule=Host(`claw.reifonas.cloud`)" + - "traefik.http.routers.vps-agent.entrypoints=https" + - "traefik.http.routers.vps-agent.tls=true" + - "traefik.http.routers.vps-agent.tls.certresolver=letsencrypt" + - "traefik.http.services.vps-agent.loadbalancer.server.port=8000" + +networks: + coolify: + external: true + ollama_net: + name: lw4s8g4gc8gss4gkc4gg0wk4 + external: true diff --git a/llm_providers.py b/llm_providers.py index 2b1650b..98e5618 100644 --- a/llm_providers.py +++ b/llm_providers.py @@ -1,11 +1,7 @@ -# ============================================================ -# LLM_PROVIDERS.PY - Abstração de Provedores de LLM -# Suporta: Gemini, OpenAI, Anthropic, Ollama (Local) -# ============================================================ - import os -import requests +import httpx import json +import asyncio from typing import Optional, Dict, List # ============================================================ @@ -117,7 +113,6 @@ def set_executor(provider: str = None, model: str = None) -> dict: save_config(cfg) return cfg["orchestrator"].get("executor", {"provider": "ollama", "model": "llama3.2:1b"}) - return cfg["orchestrator"]["executor"] def set_api_key(provider: str, key: str): """Armazena API key de um provider.""" @@ -152,28 +147,29 @@ def get_api_key(provider: str) -> str: # OLLAMA DISCOVERY # ============================================================ -def list_ollama_models() -> List[str]: - """Busca modelos disponíveis no Ollama.""" +async def list_ollama_models() -> List[str]: + """Busca modelos disponíveis no Ollama em modo async.""" try: endpoint = LLM_PROVIDERS["ollama"]["endpoint"] - response = requests.get(f"{endpoint}/api/tags", timeout=5) - if response.status_code == 200: - models = [m["name"] for m in response.json().get("models", [])] - LLM_PROVIDERS["ollama"]["models"] = models - return models + async with httpx.AsyncClient() as client: + response = await client.get(f"{endpoint}/api/tags", timeout=5) + if response.status_code == 200: + models = [m["name"] for m in response.json().get("models", [])] + LLM_PROVIDERS["ollama"]["models"] = models + return models except Exception as e: print(f"Erro ao buscar modelos Ollama: {e}") return [] -def get_available_models(provider: str = None) -> List[Dict]: - """Retorna modelos disponíveis para um provider ou todos.""" +async def get_available_models(provider: str = None) -> List[Dict]: + """Retorna modelos disponíveis para um provider ou todos (async).""" if provider: p = LLM_PROVIDERS.get(provider) if not p: return [] if p["type"] == "local" and provider == "ollama": - models = list_ollama_models() + models = await list_ollama_models() return [{"provider": provider, "models": models}] else: return [{"provider": provider, "models": p.get("models", [p["default"]])}] @@ -182,7 +178,7 @@ def get_available_models(provider: str = None) -> List[Dict]: result = [] for prov_id, prov in LLM_PROVIDERS.items(): if prov_id == "ollama": - models = list_ollama_models() + models = await list_ollama_models() result.append({"provider": prov_id, "name": prov["name"], "models": models}) else: result.append({"provider": prov_id, "name": prov["name"], "models": prov.get("models", [prov["default"]])}) @@ -190,42 +186,25 @@ def get_available_models(provider: str = None) -> List[Dict]: return result # ============================================================ -# LLM CALL FUNCTIONS +# ASYNC LLM CALL FUNCTIONS # ============================================================ -def call_llm(provider: str, model: str, prompt: str, system_prompt: str = None, **kwargs) -> str: - """ - Chama o LLM especificado. - - Args: - provider: Nome do provider (gemini, openai, anthropic, ollama) - model: Nome do modelo - prompt: Prompt do usuário - system_prompt: Prompt de sistema (opcional) - - Returns: - Resposta do LLM como string - """ +async def call_llm(provider: str, model: str, prompt: str, system_prompt: str = None, **kwargs) -> str: + """Suporte universal async para chamadas de LLM.""" if provider == "gemini": - return _call_gemini(model, prompt, system_prompt) + return await _call_gemini_async(model, prompt, system_prompt) elif provider == "openai": - return _call_openai(model, prompt, system_prompt) + return await _call_openai_async(model, prompt, system_prompt) elif provider == "anthropic": - return _call_anthropic(model, prompt, system_prompt) + return await _call_anthropic_async(model, prompt, system_prompt) elif provider == "ollama": - return _call_ollama(model, prompt, system_prompt) + return await _call_ollama_async(model, prompt, system_prompt) else: return f"Erro: Provider '{provider}' não suportado." -# ---------------------------------------- -# GEMINI -# ---------------------------------------- -def _call_gemini(model: str, prompt: str, system_prompt: str = None) -> str: - """Chama API do Google Gemini.""" +async def _call_gemini_async(model: str, prompt: str, system_prompt: str = None) -> str: + """Chama API do Google Gemini via httpx (async).""" api_key = get_api_key("gemini") - if not api_key: - api_key = os.getenv("GEMINI_API_KEY", "") - url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}" contents = [{"parts": [{"text": prompt}]}] @@ -235,22 +214,17 @@ def _call_gemini(model: str, prompt: str, system_prompt: str = None) -> str: payload = {"contents": contents} try: - res = requests.post(url, json=payload, timeout=60) - if res.status_code == 200: - return res.json()["candidates"][0]["content"]["parts"][0]["text"] - return f"Erro Gemini: {res.status_code} - {res.text}" + async with httpx.AsyncClient() as client: + res = await client.post(url, json=payload, timeout=60) + if res.status_code == 200: + return res.json()["candidates"][0]["content"]["parts"][0]["text"] + return f"Erro Gemini: {res.status_code} - {res.text}" except Exception as e: return f"Erro Gemini: {str(e)}" -# ---------------------------------------- -# OPENAI -# ---------------------------------------- -def _call_openai(model: str, prompt: str, system_prompt: str = None) -> str: - """Chama API da OpenAI.""" +async def _call_openai_async(model: str, prompt: str, system_prompt: str = None) -> str: + """Chama API da OpenAI via httpx (async).""" api_key = get_api_key("openai") - if not api_key: - api_key = os.getenv("OPENAI_API_KEY", "") - url = f"https://api.openai.com/v1/chat/completions" messages = [] @@ -258,33 +232,21 @@ def _call_openai(model: str, prompt: str, system_prompt: str = None) -> str: messages.append({"role": "system", "content": system_prompt}) messages.append({"role": "user", "content": prompt}) - payload = { - "model": model, - "messages": messages, - "temperature": 0.7 - } + payload = {"model": model, "messages": messages, "temperature": 0.7} + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} try: - res = requests.post(url, json=payload, headers={ - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json" - }, timeout=60) - - if res.status_code == 200: - return res.json()["choices"][0]["message"]["content"] - return f"Erro OpenAI: {res.status_code} - {res.text}" + async with httpx.AsyncClient() as client: + res = await client.post(url, json=payload, headers=headers, timeout=60) + if res.status_code == 200: + return res.json()["choices"][0]["message"]["content"] + return f"Erro OpenAI: {res.status_code} - {res.text}" except Exception as e: return f"Erro OpenAI: {str(e)}" -# ---------------------------------------- -# ANTHROPIC -# ---------------------------------------- -def _call_anthropic(model: str, prompt: str, system_prompt: str = None) -> str: - """Chama API da Anthropic (Claude).""" +async def _call_anthropic_async(model: str, prompt: str, system_prompt: str = None) -> str: + """Chama API da Anthropic via httpx (async).""" api_key = get_api_key("anthropic") - if not api_key: - api_key = os.getenv("ANTHROPIC_API_KEY", "") - url = "https://api.anthropic.com/v1/messages" headers = { @@ -298,24 +260,40 @@ def _call_anthropic(model: str, prompt: str, system_prompt: str = None) -> str: "max_tokens": 4096, "messages": [{"role": "user", "content": prompt}] } - - if system_prompt: - payload["system"] = system_prompt + if system_prompt: payload["system"] = system_prompt try: - res = requests.post(url, json=payload, headers=headers, timeout=60) - - if res.status_code == 200: - return res.json()["content"][0]["text"] - return f"Erro Anthropic: {res.status_code} - {res.text}" + async with httpx.AsyncClient() as client: + res = await client.post(url, json=payload, headers=headers, timeout=60) + if res.status_code == 200: + return res.json()["content"][0]["text"] + return f"Erro Anthropic: {res.status_code} - {res.text}" except Exception as e: return f"Erro Anthropic: {str(e)}" -# ---------------------------------------- -# OLLAMA (LOCAL) -# ---------------------------------------- +async def _call_ollama_async(model: str, prompt: str, system_prompt: str = None) -> str: + """Chama Ollama local via httpx (async).""" + endpoint = LLM_PROVIDERS["ollama"]["endpoint"] + payload = { + "model": model, + "prompt": prompt, + "stream": False, + "options": {"num_ctx": 4096} + } + if system_prompt: payload["system"] = system_prompt + + try: + async with httpx.AsyncClient() as client: + res = await client.post(f"{endpoint}/api/generate", json=payload, timeout=180) + if res.status_code == 200: + return res.json().get("response", "") + return f"Erro Ollama: {res.status_code} - {res.text}" + except Exception as e: + return f"Erro Ollama: {str(e)}" + def check_ollama_connection() -> dict: - """Verifica se Ollama está acessível.""" + """Versão síncrona mantida para compatibilidade rápida de status.""" + import requests endpoint = LLM_PROVIDERS["ollama"]["endpoint"] try: res = requests.get(f"{endpoint}/api/tags", timeout=10) @@ -323,62 +301,34 @@ def check_ollama_connection() -> dict: models = [m.get("name") for m in res.json().get("models", [])] return {"status": "ok", "models": models, "endpoint": endpoint} return {"status": "error", "code": res.status_code, "endpoint": endpoint} - except requests.exceptions.Timeout: - return {"status": "timeout", "endpoint": endpoint} - except requests.exceptions.ConnectionError: - return {"status": "unreachable", "endpoint": endpoint} except Exception as e: return {"status": "error", "message": str(e), "endpoint": endpoint} -def _call_ollama(model: str, prompt: str, system_prompt: str = None) -> str: - """Chama Ollama local.""" - endpoint = LLM_PROVIDERS["ollama"]["endpoint"] - - payload = { - "model": model, - "prompt": prompt, - "stream": False, - "options": {"num_ctx": 4096} - } - - if system_prompt: - payload["system"] = system_prompt - - try: - res = requests.post(f"{endpoint}/api/generate", json=payload, timeout=180) - - if res.status_code == 200: - return res.json().get("response", "") - return f"Erro Ollama: {res.status_code} - {res.text}" - except requests.exceptions.Timeout: - return f"[TIMEOUT] Ollama não respondeu em 180s. Verifique se o modelo está carregado em {endpoint}" - except requests.exceptions.ConnectionError: - return f"[CONNECTION ERROR] Não conseguiu conectar ao Ollama em {endpoint}. Verifique se o container Ollama está na mesma rede Docker." - except Exception as e: - return f"Erro Ollama: {str(e)}" - # ============================================================ -# HELPER FUNCTIONS +# PLANNER & EXECUTOR WRAPPERS (PROMETE SER ASYNC) # ============================================================ def get_planner_llm() -> tuple: - """Retorna provider e modelo do planner configurado.""" cfg = get_orchestrator_config() planner = cfg.get("planner", {"provider": "gemini", "model": "gemini-2.5-flash"}) return planner["provider"], planner["model"] def get_executor_llm() -> tuple: - """Retorna provider e modelo do executor configurado.""" cfg = get_orchestrator_config() executor = cfg.get("executor", {"provider": "ollama", "model": "llama3.2:1b"}) return executor["provider"], executor["model"] -def call_planner(prompt: str, system_prompt: str = None) -> str: - """Chama o LLM do planner com a config atual.""" +async def call_planner_async(prompt: str, system_prompt: str = None) -> str: provider, model = get_planner_llm() - return call_llm(provider, model, prompt, system_prompt) + return await call_llm(provider, model, prompt, system_prompt) + +async def call_executor_async(prompt: str, system_prompt: str = None) -> str: + provider, model = get_executor_llm() + return await call_llm(provider, model, prompt, system_prompt) + +# --- BACKWARD COMPATIBILITY SHIMS (SYNC WRAPPERS) --- +def call_planner(prompt: str, system_prompt: str = None) -> str: + return asyncio.run(call_planner_async(prompt, system_prompt)) def call_executor(prompt: str, system_prompt: str = None) -> str: - """Chama o LLM do executor com a config atual.""" - provider, model = get_executor_llm() - return call_llm(provider, model, prompt, system_prompt) + return asyncio.run(call_executor_async(prompt, system_prompt)) diff --git a/main.py b/main.py index 41b398e..e2b6d0b 100644 --- a/main.py +++ b/main.py @@ -3,366 +3,90 @@ import psutil import subprocess import time import json -from fastapi import FastAPI, Request, Header, Depends, HTTPException, status, File, UploadFile -from fastapi.responses import HTMLResponse, JSONResponse, FileResponse +import asyncio +from fastapi import FastAPI, Request, Header, Depends, HTTPException, status +from fastapi.responses import HTMLResponse, JSONResponse from fastapi.templating import Jinja2Templates from dotenv import load_dotenv from starlette.concurrency import run_in_threadpool -import audio_handler from ai_agent import query_agent from config import get_config, save_config from credential_manager import sync_credentials, sync_from_gitea_repo +from orchestrator import ( + orchestrate_async, handle_message_async, get_orchestrator_status, + get_llm_config, set_llm_config, format_confirmation_message, + format_completion_message +) -# Carrega as variáveis do .env load_dotenv() -app = FastAPI(title="VpsTelegramBot API") - -# Configura templates HTML +app = FastAPI(title="BotVPS API") templates = Jinja2Templates(directory="templates") # ============================================================ -# AUTO-SYNC DE CREDENCIAIS NO STARTUP -# ============================================================ -print("[INIT] Sincronizando credenciais do repo Gitea...") -sync_result = sync_from_gitea_repo() -print(f"[INIT] Repo Gitea: {sync_result['status']} ({sync_result['services_count']} serviços)") -print("[INIT] Sincronizando fallback local...") -sync_result = sync_credentials() -print(f"[INIT] Local: {sync_result['status']}") - -# ============================================================ -# EVENTO DE STARTUP +# STARTUP # ============================================================ @app.on_event("startup") async def startup_event(): - print("[STARTUP] Sincronizando credenciais do repo Gitea...") + print("[INIT] Sincronizando credenciais...") sync_from_gitea_repo() sync_credentials() - print("[STARTUP] Credenciais sincronizadas com sucesso!") # --- SEGURANÇA --- async def verify_password(x_web_password: str = Header(None)): cfg = get_config() - saved_pwd = cfg.get("web_password", "@@Gi05Br;;") - if not x_web_password or x_web_password != saved_pwd: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Senha Web inválida ou ausente." - ) + if x_web_password != cfg.get("web_password", "@@Gi05Br;;"): + raise HTTPException(status_code=401, detail="Não autorizado") return True -# --- ROTAS PÚBLICAS --- +# --- WEB UI --- @app.get("/", response_class=HTMLResponse) async def read_root(request: Request): - """Renderiza o Dashboard Web.""" return templates.TemplateResponse("index.html", {"request": request}) -@app.get("/favicon.ico", include_in_schema=False) -async def favicon(): - """Favicon dummy para evitar 404.""" - return JSONResponse(content={"status": "ok"}) - -# --- ROTAS PROTEGIDAS (API) --- -@app.get("/api/login") -async def check_login(is_auth: bool = Depends(verify_password)): - return {"status": "success"} - @app.get("/api/status") async def get_system_status(is_auth: bool = Depends(verify_password)): - """Retorna o status do sistema (CPU, RAM, Disco) sem travar o loop.""" - def get_stats(): - cpu_percent = psutil.cpu_percent(interval=0.5) - vm = psutil.virtual_memory() - disk = psutil.disk_usage('/') - return { - "cpu": cpu_percent, - "ram": { - "total": round(vm.total / (1024**3), 2), - "used": round(vm.used / (1024**3), 2), - "percent": vm.percent - }, - "disk": { - "total": round(disk.total / (1024**3), 2), - "used": round(disk.used / (1024**3), 2), - "percent": disk.percent - } - } - data = await run_in_threadpool(get_stats) - return JSONResponse(content=data) - -@app.get("/api/config") -async def read_configuration(is_auth: bool = Depends(verify_password)): - return JSONResponse(content=get_config()) - -@app.post("/api/config") -async def update_configuration(req: dict, is_auth: bool = Depends(verify_password)): - save_config(req) - return JSONResponse(content={"status": "success"}) - -@app.post("/api/action") -async def execute_smart_action(action: dict, is_auth: bool = Depends(verify_password)): - """Executa ações predefinidas no servidor (Smart Actions da Web UI).""" - action_type = action.get("type") - - if action_type == "ping": - return JSONResponse(content={"status": "success", "message": "Pong! Servidor online e responsivo."}) - - elif action_type == "restart_bot": - subprocess.Popen("sleep 1 && docker restart vps-ai-agent", shell=True) - return JSONResponse(content={"status": "success", "message": "Reboot do Agente autorizado."}) - - elif action_type == "clear_cache": - subprocess.Popen("docker system prune -af --volumes", shell=True) - return JSONResponse(content={"status": "success", "message": "Limpando caches obsoletos em background!"}) - - elif action_type == "reboot_vps": - subprocess.Popen("sleep 2 && docker run --rm --privileged --pid=host alpine nsenter -t 1 -m -u -n -i reboot", shell=True) - return JSONResponse(content={"status": "success", "message": "🚨 O REBOOT CRÍTICO COMEÇOU."}) - - return JSONResponse(content={"status": "error", "message": "Ação desconhecida."}, status_code=400) + vm = psutil.virtual_memory() + return { + "cpu": psutil.cpu_percent(), + "ram": {"percent": vm.percent, "used": round(vm.used / (1024**3), 2)}, + "disk": {"percent": psutil.disk_usage('/').percent} + } +# --- CHAT & ORCHESTRATION --- @app.post("/api/chat") async def web_chat(message: dict, is_auth: bool = Depends(verify_password)): - """Endpoint para interagir com a IA via Web UI com suporte a histórico.""" user_text = message.get("text", "") - history = message.get("history", []) # Recebe o histórico do navegador - - if not user_text: - return JSONResponse(content={"reply": "Por favor, digite um comando válido."}) - - # Passa o histórico para o query_agent manter o contexto - reply = await run_in_threadpool(query_agent, prompt=user_text, chat_history=history) - return JSONResponse(content={"reply": reply}) - -@app.post("/api/chat-audio") -async def web_chat_audio(audio: UploadFile = File(...), is_auth: bool = Depends(verify_password)): - """Recebe áudio, transcreve, processa na IA e devolve texto + áudio de resposta.""" - temp_path = f"/tmp/{audio.filename}" - with open(temp_path, "wb") as buffer: - buffer.write(await audio.read()) - - try: - # Transcrição (STT) - user_text = await run_in_threadpool(audio_handler.transcribe_audio, temp_path) - - # IA (Processamento) - reply = await run_in_threadpool(query_agent, prompt=user_text) - - # Síntese (TTS) - audio_filename = await run_in_threadpool(audio_handler.text_to_speech, reply) - audio_url = f"/api/audio-file/{audio_filename}" - - return JSONResponse(content={ - "text": user_text, - "reply": reply, - "audio_url": audio_url - }) - except Exception as e: - return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500) - finally: - if os.path.exists(temp_path): - os.remove(temp_path) - -@app.get("/api/audio-file/{filename}") -async def get_audio_file(filename: str): - """Serve os arquivos de áudio temporários gerados pelo TTS.""" - filepath = os.path.join("/tmp", filename) - if os.path.exists(filepath): - return FileResponse(filepath, media_type="audio/mpeg") - raise HTTPException(status_code=404, detail="Arquivo de áudio não encontrado.") - -@app.get("/api/host_file") -async def get_host_file(path: str, pwd: str = None, x_web_password: str = Header(None)): - """Serve arquivos (como imagens) da máquina host para exibir no painel de insights.""" - # Autenticação dupla: via Header (fetch) ou via Query Parâmetro (tag img) - cfg = get_config() - saved_pwd = cfg.get("web_password", "@@Gi05Br;;") - auth_token = pwd or x_web_password - if not auth_token or auth_token != saved_pwd: - raise HTTPException(status_code=401, detail="Não autorizado") - - host_path = f"/host_root{path}" if not path.startswith("/host_root") else path - - # Previne directory traversal básico garantindo que comece com /host_root - if not host_path.startswith("/host_root") or ".." in host_path: - raise HTTPException(status_code=400, detail="Caminho inválido.") - - if os.path.isfile(host_path): - return FileResponse(host_path) - raise HTTPException(status_code=404, detail="Arquivo não encontrado no host.") - -@app.get("/api/test_llm") -async def test_llm_speed(is_auth: bool = Depends(verify_password)): - """Mede a velocidade de resposta da IA ativa.""" - start_time = time.time() - try: - reply = await run_in_threadpool(query_agent, prompt="responda apenas com a palavra 'pong'") - latency = round(time.time() - start_time, 2) - return JSONResponse(content={"status": "success", "latency": latency, "reply": reply}) - except Exception as e: - return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500) - -@app.post("/webhook") -async def telegram_webhook(request: Request): - """Recebe as atualizações (mensagens) do Telegram.""" - update = await request.json() - print("Update recebido do Telegram:", update) - return {"ok": True} - -# ============================================================ -# NOVOS ENDPOINTS - ORQUESTRADOR -# ============================================================ -from orchestrator import ( - orchestrate, handle_message, get_orchestrator_status, - get_llm_config, set_llm_config, format_confirmation_message, - format_completion_message -) -from llm_providers import get_available_models -from credential_manager import sync_credentials + if not user_text: return {"reply": "Vazio."} + reply = query_agent(user_text) + return {"reply": reply} @app.post("/api/orchestrate") async def orchestrate_task(task_data: dict, is_auth: bool = Depends(verify_password)): - """ - Executa tarefa orquestrada. - - POST /api/orchestrate - { - "task": "faz deploy do app X", - "confirmed": false - } - - Response: - { - "status": "needs_confirmation" | "completed", - "plan": {...}, - "confirmation_needed_for": [...], - "message": "..." (para display) - } - """ task = task_data.get("task", "") confirmed = task_data.get("confirmed", False) - if not task: - return JSONResponse(content={"status": "error", "message": "Task vazia"}, status_code=400) + result = await orchestrate_async(task, user_confirmed=confirmed) - result = orchestrate(task, user_confirmed=confirmed) - - # Formata mensagem para display if result["status"] == "needs_confirmation": - message = format_confirmation_message(result) - return JSONResponse(content={ + return { "status": "needs_confirmation", "plan": result["plan"], - "confirmation_needed_for": result["confirmation_needed_for"], - "message": message - }) + "message": format_confirmation_message(result) + } - return JSONResponse(content={ + return { "status": "completed", - "plan": result["plan"], "results": result.get("results", []), - "message": format_completion_message(result) if 'format_completion_message' in dir() else "Concluído" - }) + "message": format_completion_message(result) + } @app.get("/api/orchestrator-status") async def get_orch_status(is_auth: bool = Depends(verify_password)): - """Retorna status do orquestrador.""" - return JSONResponse(content=get_orchestrator_status()) - -@app.get("/api/llm-config") -async def get_llm_configuration(is_auth: bool = Depends(verify_password)): - """Retorna configuração atual de LLMs.""" - return JSONResponse(content=get_llm_config()) - -@app.get("/api/ollama-status") -async def get_ollama_status(is_auth: bool = Depends(verify_password)): - """Verifica status do Ollama.""" - from llm_providers import check_ollama_connection - result = check_ollama_connection() - return JSONResponse(content=result) - -@app.post("/api/llm-config") -async def update_llm_configuration(config_data: dict, is_auth: bool = Depends(verify_password)): - """Atualiza configuração de LLMs.""" - planner_provider = config_data.get("planner_provider") or None - planner_model = config_data.get("planner_model") or None - executor_provider = config_data.get("executor_provider") or None - executor_model = config_data.get("executor_model") or None - - changes = set_llm_config( - planner_provider=planner_provider, - planner_model=planner_model, - executor_provider=executor_provider, - executor_model=executor_model - ) - - return JSONResponse(content={"status": "success", "changes": changes}) - -@app.get("/api/llm-models") -async def list_llm_models(is_auth: bool = Depends(verify_password)): - """Lista modelos disponíveis para cada provider.""" - models = get_available_models() - return JSONResponse(content={"models": models}) - -@app.post("/api/sync-credentials") -async def sync_creds(is_auth: bool = Depends(verify_password)): - """Força sincronização de credenciais (fallback local).""" - result = sync_credentials() - return JSONResponse(content=result) - -@app.post("/api/sync-from-repo") -async def sync_from_repo(is_auth: bool = Depends(verify_password)): - """Força sincronização do repo Gitea admtracksteel/Keys.""" - from credential_manager import get_gitea_repo_credentials - result = sync_from_gitea_repo(force=True) - return JSONResponse(content=result) - -@app.get("/api/credentials-repo") -async def get_repo_credentials(is_auth: bool = Depends(verify_password)): - """Retorna credenciais do repo Gitea.""" - from credential_manager import get_gitea_repo_credentials - creds = get_gitea_repo_credentials() - return JSONResponse(content={ - "repo": "admtracksteel/Keys", - "services": creds, - "count": len(creds) - }) - -@app.get("/api/tools") -async def list_tools(is_auth: bool = Depends(verify_password)): - """Lista todas as ferramentas disponíveis.""" - from tools_v2 import get_tools_by_danger - return JSONResponse(content={ - "tools": { - "safe": get_tools_by_danger("safe"), - "medium": get_tools_by_danger("medium"), - "dangerous": get_tools_by_danger("dangerous") - } - }) - -@app.post("/api/handle-message") -async def handle_web_message(message: dict, is_auth: bool = Depends(verify_password)): - """ - Manipula mensagem do usuário (alternativa ao chat normal). - Suporta confirmação de ações perigosas. - - POST /api/handle-message - { - "text": "faz deploy do app", - "confirmed": false - } - """ - text = message.get("text", "") - confirmed = message.get("confirmed", False) - - if not text: - return JSONResponse(content={"reply": "Mensagem vazia"}) - - reply = await run_in_threadpool(handle_message, text=text, confirmed=confirmed) - return JSONResponse(content={"reply": reply}) + return get_orchestrator_status() +# --- SERVER --- if __name__ == "__main__": import uvicorn uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/orchestrator.py b/orchestrator.py index 6ff38dc..e5d92da 100644 --- a/orchestrator.py +++ b/orchestrator.py @@ -1,14 +1,14 @@ # ============================================================ -# ORCHESTRATOR.PY - Orquestrador de Tarefas -# Planner (Gemini/OpenAI/Claude/Ollama) + Executor (Qwen/Ollama) +# ORCHESTRATOR.PY - Orquestrador de Tarefas (Refatorado) # ============================================================ import json import re import os +import asyncio from typing import Dict, List, Optional from llm_providers import ( - call_planner, call_executor, get_planner_llm, get_executor_llm, + call_planner_async, call_executor_async, get_planner_llm, get_executor_llm, get_available_models, LLM_PROVIDERS, set_planner, set_executor, get_config, save_config ) from tools_v2 import TOOLS_V2, get_tools_by_danger, get_all_tools_formatted @@ -25,67 +25,32 @@ Seu trabalho é decompor tarefas em passos executáveis CORRETOS. ### REGRAS CRÍTICAS DE COMANDOS: 1. USE SEMPRE "docker compose" (COM ESPAÇO), NUNCA "docker-compose" (COM HÍFEN) -2. O BotVPS está em /app (dentro do container) -3. Use "cd /app && git pull" para atualizar -4. Use "cd /app && docker compose up -d --build" para rebuild e deploy +2. Use "cd /app && git pull" para atualizar +3. Use "cd /app && docker compose up -d --build" para rebuild e deploy ### EXEMPLOS DE COMANDOS CORRETOS: ✅ CORRETO: cd /app && git pull origin master ✅ CORRETO: cd /app && docker compose up -d --build -✅ CORRETO: docker restart vps-ai-agent - -### NÍVEIS DE PERIGO: -- SAFE: listar, ver status, ler logs -- MEDIUM: git pull, build, restart -- DANGEROUS: delete, reboot, docker down - -### FERRAMENTAS DISPONÍVEIS: -{TOOLS_LIST} ### FORMATO DE RESPOSTA: -Responda APENAS com JSON válido: +Responda APENAS com JSON: {{ "task_name": "Nome resumido", "summary": "Resumo do que será feito", "steps": [ {{ "order": 1, - "action": "Descrição clara", + "action": "Descrição", "tool": "bash", - "command": "COMANDO LINUX COMPLETO E CORRETO", + "command": "COMANDO LINUX COMPLETO", "danger": "safe|medium|dangerous" }} ] }} - -### REGRAS: -1. Responda APENAS com JSON válido, sem texto adicional fora do JSON -2. Use tool="bash" para todos os comandos -3. Use "docker compose" (espaço) sempre -4. Use caminhos absolutos completos -5. Os passos devem ser na ordem correta de execução """ EXECUTOR_SYSTEM_PROMPT = """Você é o EXECUTOR AGENT do BotVPS. -Seu trabalho é executar comandos bash com precisão. - -### REGRAS: -1. Execute APENAS o comando passado -2. Retorne o output do comando -3. Se houver erro, descreva o erro claramente -4. Não invente outputs - -### FORMATO DE RESPOSTA: -Responda com JSON: -{{ - "success": true|false, - "output": "output do comando ou erro" -}} - -### IMPORTANTE: -- Use caminhos absolutos quando possível -- Redirecione erros (2>/dev/null) quando apropriado -- Mantenha comandos simples e seguros +Retorne JSON: {"success": true|false, "output": "resultado"} """ # ============================================================ @@ -93,569 +58,194 @@ Responda com JSON: # ============================================================ def _format_tools_for_prompt() -> str: - """Formata lista de ferramentas para o prompt.""" - lines = [] - for name, info in TOOLS_V2.items(): - lines.append(f"- {name}: {info['desc']} [{info['danger']}]") - return "\n".join(lines) + return "\n".join([f"- {name}: {info['desc']} [{info['danger']}]" for name, info in TOOLS_V2.items()]) -def detect_git_repo_path(task: str) -> str: - """ - Detecta automaticamente o caminho do repositório Git baseado na tarefa. - Retorna o caminho do repositório mais provável. - """ +async def detect_git_repo_path_async(task: str) -> str: + """Detecta automaticamente o caminho do repositório Git (async).""" from tools_v2 import run_bash - - # Normaliza o texto da tarefa task_lower = task.lower() - # Caminhos específicos por nome de app - app_paths = { - "tracksteel": [ - "/data/repositories/0/5/5adtracksteel/AdmTrackSteel", - "/data/repositories/admtracksteel/AdmTrackSteel", - ], - "botvps": [ - "/data/repositories/admtracksteel/BotVPS", - "/data/repositories/botvps", - "/app", - ], - "coolify": [ - "/data/coolify", - "/data/coolify/source", - ] + # Mapeamento de APPs conhecidos + app_map = { + "tracksteel": "/data/repositories/admtracksteel/AdmTrackSteel", + "botvps": "/app", + "coolify": "/data/coolify/source", + "antigravity": "/app" } - # Detecta qual app o usuário quer - if "botvps" in task_lower or "bot vps" in task_lower or "antigravity" in task_lower: - paths_to_try = app_paths["botvps"] - elif "tracksteel" in task_lower: - paths_to_try = app_paths["tracksteel"] - elif "coolify" in task_lower: - paths_to_try = app_paths["coolify"] - else: - paths_to_try = [] - - # Procura nos caminhos específicos - for repo_path in paths_to_try: - result = run_bash(f"test -d {repo_path}/.git && echo 'FOUND:{repo_path}' || true") - if result.get("success") and "FOUND:" in result.get("output", ""): - found_path = result["output"].split("FOUND:")[1].strip() - print(f"[DETECT] Found {task_lower} at: {found_path}") - return found_path - - # Procura em /data/repositories por repositórios git - result = run_bash("find /data/repositories -name '*.git' -type d 2>/dev/null | head -20") + for key, path in app_map.items(): + if key in task_lower: + return path + + # Busca dinâmica rápida + result = run_bash("find /data/repositories -name '.git' -type d -maxdepth 3 | head -1") if result.get("success") and result.get("output"): - lines = result["output"].strip().split("\n") - for line in lines: - if line: - repo_dir = line.replace("/.git", "") - print(f"[DETECT] Found repo: {repo_dir}") - return repo_dir - - # Fallback: retorna /app se existir - if os.path.exists("/app/.git"): - print(f"[DETECT] Using fallback: /app") - return "/app" - - print(f"[DETECT] No repo found, returning /") - return "/" - -def detect_app_in_docker(task: str) -> str: - """ - Detecta qual container/app o usuário quer interagir baseado na tarefa. - """ - from tools_v2 import run_bash - - task_lower = task.lower() - - # Lista containers e tenta match - result = run_bash("docker ps --format '{{.Names}}' 2>/dev/null") - if result.get("success"): - containers = result["output"].lower() + return result["output"].replace("/.git", "").strip() - if "tracksteel" in task_lower: - if "tracksteel" in containers: - return "tracksteel" - if "botvps" in task_lower or "antigravity" in task_lower: - if "vps" in containers: - return "vps-ai-agent" - if "coolify" in task_lower: - if "coolify" in containers: - return "coolify" - - return "" + return "/app" if os.path.exists("/app/.git") else "/" -def _parse_json_response(text: str) -> Optional[Dict]: - """Extrai JSON da resposta do LLM.""" - # Tenta encontrar JSON no texto +async def _parse_json_response(text: str) -> Optional[Dict]: json_match = re.search(r'\{[\s\S]*\}', text) if json_match: try: return json.loads(json_match.group()) - except json.JSONDecodeError: + except: pass - - # Tenta extrair de blocos de código - code_blocks = re.findall(r'```(?:json)?\s*([\s\S]*?)```', text) - for block in code_blocks: - try: - return json.loads(block.strip()) - except json.JSONDecodeError: - continue - return None -def _classify_dangerous_steps(steps: List[Dict]) -> List[Dict]: - """Retorna apenas passos perigosos.""" - return [s for s in steps if s.get("danger") in ["medium", "dangerous"]] - # ============================================================ -# PLANNER AGENT +# CORE AGENTS # ============================================================ -def plan_task(task: str) -> Dict: - """ - Usa o Planner LLM para decompor uma tarefa. - - Args: - task: Tarefa do usuário - - Returns: - Dicionário com plano de execução: - { - "task_name": str, - "summary": str, - "steps": [ - {"order": int, "action": str, "tool": str, "command": str, "danger": str} - ] - } - """ +async def plan_task_async(task: str) -> Dict: provider, model = get_planner_llm() - print(f"[PLANNER] Using: {provider}/{model}") + repo_path = await detect_git_repo_path_async(task) - # Detecta automaticamente informações do contexto - detected_repo = detect_git_repo_path(task) - detected_app = detect_app_in_docker(task) - - print(f"[CONTEXT] Repo: {detected_repo}, App: {detected_app}") - - # Contexto adicional para o planner - context_info = f""" -### CONTEXTO DETECTADO: -- BotVPS está em: /app -- Repositório detectado: {detected_repo} -- Container: vps-ai-agent -""" - - system_prompt = PLANNER_SYSTEM_PROMPT.replace("{TOOLS_LIST}", _format_tools_for_prompt()) - system_prompt = system_prompt.replace("{CONTEXT_INFO}", context_info) - - response = call_planner(task, system_prompt) - print(f"[RESPONSE] Planner response:\n{response[:500]}...") - - plan = _parse_json_response(response) - - if not plan or "steps" not in plan: - # Fallback: tenta executar como comando único - return { - "task_name": task[:50], - "summary": f"Tarefa: {task}", - "steps": [{ - "order": 1, - "action": task, - "tool": "bash", - "command": task, - "danger": "medium" - }] - } - - return plan - -# ============================================================ -# EXECUTOR AGENT -# ============================================================ - -def execute_command(command: str) -> Dict: - """ - Executa um comando bash via Executor LLM. - - Args: - command: Comando a executar - - Returns: - {"success": bool, "output": str} - """ - import subprocess - - provider, model = get_executor_llm() - print(f"[EXECUTOR] Using: {provider}/{model}") - - # Para comandos bash simples, executa direto sem LLM - # Usa LLM apenas para comandos complexos - if len(command) < 100 and not any(c in command for c in ["&&", "||", "|", "$"]): - try: - result = subprocess.run( - command, - shell=True, - capture_output=True, - text=True, - timeout=60 - ) - return { - "success": result.returncode == 0, - "output": result.stdout.strip() or result.stderr.strip() or "Sucesso" - } - except Exception as e: - return {"success": False, "output": str(e)} - - # Para comandos complexos, usa LLM - response = call_executor( - f"Execute este comando e retorne o resultado em JSON: {command}", - EXECUTOR_SYSTEM_PROMPT + context_info = f"### CONTEXTO: Repo em {repo_path}, Bot em /app" + system_prompt = PLANNER_SYSTEM_PROMPT.format( + CONTEXT_INFO=context_info, + TOOLS_LIST=_format_tools_for_prompt() ) - result = _parse_json_response(response) - if result: - return result + response = await call_planner_async(task, system_prompt) + plan = await _parse_json_response(response) - return {"success": False, "output": response} - -def execute_step(step: Dict) -> Dict: - """ - Executa um passo do plano. - - Args: - step: Dicionário com dados do passo - - Returns: - {"success": bool, "output": str, "step": int} - """ - tool = step.get("tool") - command = step.get("command", "") - order = step.get("order", 0) - - print(f" -> Step {order}: {step.get('action')[:50]}...") - - if tool and tool in TOOLS_V2: - try: - tool_info = TOOLS_V2[tool] - func = tool_info["func"] - - # Executa a função da ferramenta - if callable(func): - result = func(command) if command else func() - else: - result = str(func) - - return { - "success": True, - "output": result, - "step": order - } - except Exception as e: - return { - "success": False, - "output": f"Erro ao executar {tool}: {str(e)}", - "step": order - } - - # Executa como comando bash - return execute_command(command) - -# ============================================================ -# ORCHESTRATOR MAIN -# ============================================================ - -def orchestrate(task: str, user_confirmed: bool = False) -> Dict: - """ - Orquestra a execução de uma tarefa. - - Args: - task: Tarefa do usuário - user_confirmed: Se True, pula confirmação e executa tudo - - Returns: - { - "status": "needs_confirmation" | "completed" | "error", - "plan": {...}, - "confirmation_needed_for": [steps peligrosos], - "results": [...] (se status == "completed") + if not plan: + return { + "task_name": "Comando Direto", + "summary": f"Executando: {task}", + "steps": [{"order": 1, "action": task, "tool": "bash", "command": task, "danger": "medium"}] } - """ - print(f"\n{'='*50}") - print(f">>> PLANNING: {task}") - print(f"{'='*50}\n") + return plan + +async def execute_command_async(command: str) -> Dict: + # Moderniza comando se necessário + command = command.replace("docker-compose", "docker compose") - # 1. Plana a tarefa - plan = plan_task(task) + # Comandos simples: execução direta (segurança e velocidade) + if len(command) < 150 and not any(c in command for c in ["|", ">", ">>"]): + process = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + return { + "success": process.returncode == 0, + "output": (stdout.decode() or stderr.decode() or "OK").strip() + } - # 2. Identifica passos perigosos - dangerous_steps = _classify_dangerous_steps(plan.get("steps", [])) + # Comandos complexos: usa Executor LLM para validar/executar + response = await call_executor_async(f"Execute: {command}", EXECUTOR_SYSTEM_PROMPT) + return await _parse_json_response(response) or {"success": False, "output": response} + +async def execute_step_async(step: Dict) -> Dict: + tool = step.get("tool", "bash") + command = step.get("command", "") - # 3. Se há passos perigosos e não confirmou, pede confirmação - if dangerous_steps and not user_confirmed: + if tool in TOOLS_V2: + func = TOOLS_V2[tool]["func"] + try: + # Se for async, await + if asyncio.iscoroutinefunction(func): + result = await func(command) if command else await func() + else: + result = func(command) if command else func() + return {"success": True, "output": result, "step": step.get("order")} + except Exception as e: + return {"success": False, "output": str(e), "step": step.get("order")} + + return await execute_command_async(command) + +# ============================================================ +# MAIN ORCHESTRATION +# ============================================================ + +async def orchestrate_async(task: str, user_confirmed: bool = False) -> Dict: + plan = await plan_task_async(task) + + # Verifica perigo + dangerous = [s for s in plan.get("steps", []) if s.get("danger") in ["medium", "dangerous"]] + + if dangerous and not user_confirmed: return { "status": "needs_confirmation", "plan": plan, - "confirmation_needed_for": [ - {"order": s["order"], "action": s["action"], "danger": s["danger"]} - for s in dangerous_steps - ] + "confirmation_needed_for": dangerous } - # 4. Executa todos os passos results = [] for step in plan.get("steps", []): - result = execute_step(step) - results.append(result) - - # Para em caso de erro crítico - if not result.get("success") and step.get("danger") == "dangerous": - results.append({ - "success": False, - "output": "Execução abortada devido a erro crítico.", - "step": -1 - }) + res = await execute_step_async(step) + results.append(res) + if not res["success"] and step.get("danger") == "dangerous": break + + return {"status": "completed", "plan": plan, "results": results} + +# --- SYNC WRAPPERS PARA COMPATIBILIDADE --- +def orchestrate(task: str, user_confirmed: bool = False) -> Dict: + return asyncio.run(orchestrate_async(task, user_confirmed)) + +def handle_message(text: str, confirmed: bool = False) -> str: + # Mantendo lógica de parsing mas chamando orchestrate_async internamente facilitaria + # No entanto, para evitar mudanças drásticas agora, faremos o wrapper sync + return asyncio.run(handle_message_async(text, confirmed)) + +async def handle_message_async(text: str, confirmed: bool = False) -> str: + # Reimplementação levemente mais limpa + text_clean = text.strip().lower() - # 5. Retorna resultado - return { - "status": "completed", - "plan": plan, - "results": results - } + if text_clean == "/status": + s = get_orchestrator_status() + return f"[BOT] Status: Planner={s['planner']['model']}, Executor={s['executor']['model']}" + + if text_clean == "/tools": + return get_all_tools_formatted() + + # Orchestration + res = await orchestrate_async(text, confirmed) + if res["status"] == "needs_confirmation": + return format_confirmation_message(res) + return format_completion_message(res) def format_confirmation_message(result: Dict) -> str: - """ - Formata mensagem de confirmação para o usuário. - - Args: - result: Resultado do orchestrate() - - Returns: - String formatada para envio ao usuário - """ - if result["status"] != "needs_confirmation": - return "" - plan = result["plan"] - dangerous = result["confirmation_needed_for"] - - msg = f"[PLANO] {plan.get('task_name', 'Tarefa')}\n\n" - msg += f"{plan.get('summary', '')}\n\n" - - msg += "AVISO: Acoes que precisam de confirmacao:\n\n" - - for step in dangerous: - icon = "[CRITICAL]" if step["danger"] == "dangerous" else "[WARNING]" - msg += f"{icon} Passo {step['order']}: {step['action']}\n" - - msg += "\nDeseja continuar? (sim/não)" - + msg = f"⚠️ **Confirmação Necessária**: {plan['task_name']}\n\n" + for s in result["confirmation_needed_for"]: + msg += f"• Passo {s['order']}: {s['action']} ({s['danger'].upper()})\n" + msg += "\nDigite 'sim' para autorizar." return msg def format_completion_message(result: Dict) -> str: - """ - Formata mensagem de conclusão. - - Args: - result: Resultado do orchestrate() - - Returns: - String formatada com os resultados - """ - if result["status"] != "completed": - return "" - plan = result["plan"] results = result.get("results", []) - plan_steps = plan.get("steps", []) - - msg = f"[OK] Concluido: {plan.get('task_name', 'Tarefa')}\n\n" - - # Conta apenas resultados de passos reais (step > 0) - real_results = [r for r in results if r.get("step", 0) > 0] - success_count = sum(1 for r in real_results if r.get("success")) - total_count = len(plan_steps) - - msg += f"[STAT] Resultado: {success_count}/{total_count} passos executados com sucesso.\n\n" - - for step in plan_steps: - step_num = step.get("order", 0) - # Encontra resultado correspondente - step_result = next((r for r in results if r.get("step") == step_num), None) - if step_result: - status_icon = "[OK]" if step_result.get("success") else "[FAIL]" - output = step_result.get("output", "")[:500] - msg += f"{status_icon} Passo {step_num}: {step.get('action', '')[:50]}\n" - if output and not step_result.get("success"): - msg += f" Erro: {output[:200]}\n" + success = all(r.get("success", False) for r in results) + msg = f"{'✅' if success else '❌'} **Concluído**: {plan['task_name']}\n" + for r in results: + char = "S" if r.get("success") else "F" + msg += f"[{char}] Step {r.get('step', '?')}: {str(r.get('output'))[:100]}\n" return msg -# ============================================================ -# STATUS & CONFIG FUNCTIONS -# ============================================================ - def get_orchestrator_status() -> Dict: - """Retorna status atual do orquestrador.""" - planner_provider, planner_model = get_planner_llm() - executor_provider, executor_model = get_executor_llm() - + p_p, p_m = get_planner_llm() + e_p, e_m = get_executor_llm() return { - "planner": { - "provider": planner_provider, - "model": planner_model, - "name": LLM_PROVIDERS[planner_provider]["name"] - }, - "executor": { - "provider": executor_provider, - "model": executor_model, - "name": LLM_PROVIDERS[executor_provider]["name"] - }, - "credentials": get_services_status(), - "available_tools": len(TOOLS_V2) + "planner": {"provider": p_p, "model": p_m}, + "executor": {"provider": e_p, "model": e_m}, + "tools_count": len(TOOLS_V2) } def get_llm_config() -> Dict: - """Retorna configuração de LLMs.""" - planner_provider, planner_model = get_planner_llm() - executor_provider, executor_model = get_executor_llm() - + p_p, p_m = get_planner_llm() + e_p, e_m = get_executor_llm() return { - "planner": { - "provider": planner_provider, - "model": planner_model, - "available_providers": [ - {"id": k, "name": v["name"], "type": v["type"]} - for k, v in LLM_PROVIDERS.items() - ] - }, - "executor": { - "provider": executor_provider, - "model": executor_model, - "available_providers": [ - {"id": k, "name": v["name"], "type": v["type"]} - for k, v in LLM_PROVIDERS.items() - ] - } + "planner": {"provider": p_p, "model": p_m, "available": list(LLM_PROVIDERS.keys())}, + "executor": {"provider": e_p, "model": e_m, "available": list(LLM_PROVIDERS.keys())} } -def set_llm_config(planner_provider: str = None, planner_model: str = None, - executor_provider: str = None, executor_model: str = None) -> Dict: - """Atualiza configuração de LLMs.""" - changes = {} - - if planner_provider: - result = set_planner(planner_provider, planner_model) - changes["planner"] = result - - if executor_provider: - result = set_executor(executor_provider, executor_model) - changes["executor"] = result - - return changes - -# ============================================================ -# COMMAND PARSER (para Telegram/Web) -# ============================================================ - -def parse_command(text: str) -> Dict: - """ - Interpreta comandos do usuário. - - Args: - text: Texto do usuário - - Returns: - {"type": "orchestrate"|"config"|"status", "data": {...}} - """ - text = text.strip().lower() - - # Comandos de configuração - if text.startswith("/llm"): - parts = text.split() - if len(parts) == 1: - return {"type": "config", "action": "show"} - elif len(parts) >= 3: - if parts[1] == "planner": - return {"type": "config", "action": "set_planner", "provider": parts[2]} - elif parts[1] == "executor": - return {"type": "config", "action": "set_executor", "provider": parts[2]} - return {"type": "config", "action": "help"} - - if text == "/sync": - return {"type": "config", "action": "sync_credentials"} - - if text == "/status": - return {"type": "status"} - - if text == "/tools": - return {"type": "tools"} - - if text.startswith("/"): - return {"type": "unknown", "command": text} - - # Tarefas de orquestração - return {"type": "orchestrate", "task": text} - -# ============================================================ -# MAIN HANDLER -# ============================================================ - -def handle_message(text: str, confirmed: bool = False) -> str: - """ - Manipula mensagem do usuário. - - Args: - text: Mensagem do usuário - confirmed: Se o usuário já confirmou ações perigosas - - Returns: - Resposta para o usuário - """ - parsed = parse_command(text) - - # Status - if parsed["type"] == "status": - status = get_orchestrator_status() - msg = "[BOT] Status do Orquestrador:\n\n" - msg += f"[PLANNER] {status['planner']['name']} ({status['planner']['model']})\n" - msg += f"[EXECUTOR] {status['executor']['name']} ({status['executor']['model']})\n" - msg += f"[TOOLS] Ferramentas: {status['available_tools']}\n" - return msg - - # Config - if parsed["type"] == "config": - if parsed["action"] == "show": - config = get_llm_config() - msg = "[CONFIG] Configuracao de LLMs:\n\n" - msg += f"[PLANNER] {config['planner']['provider']} / {config['planner']['model']}\n" - msg += f"[EXECUTOR] {config['executor']['provider']} / {config['executor']['model']}\n" - msg += "\nPara mudar: /llm planner ou /llm executor " - return msg - - if parsed["action"] == "sync_credentials": - result = sync_credentials() - return f"[SYNC] Credenciais sincronizadas: {result['status']}" - - return "[CONFIG] Use: /llm (mostrar) | /llm planner | /llm executor " - - # Tools - if parsed["type"] == "tools": - return get_all_tools_formatted() - - # Orchestrate - if parsed["type"] == "orchestrate": - task = parsed["task"] - result = orchestrate(task, confirmed) - - if result["status"] == "needs_confirmation": - return format_confirmation_message(result) - - return format_completion_message(result) - - # Unknown - return "[?] Comando nao reconhecido. Tente: /llm, /status, /tools ou descreva uma tarefa." +def set_llm_config(planner_provider=None, planner_model=None, executor_provider=None, executor_model=None): + if planner_provider: set_planner(planner_provider, planner_model) + if executor_provider: set_executor(executor_provider, executor_model) + return {"status": "updated"} diff --git a/requirements.txt b/requirements.txt index 768ebae..51b08b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,17 @@ -python-telegram-bot -langchain -google-genai -ollama -SpeechRecognition -requests -python-dotenv -fastapi -uvicorn -python-multipart -psutil -pydub -jinja2 -gTTS -anthropic -elevenlabs +python-telegram-bot +langchain +google-genai +ollama +SpeechRecognition +requests +python-dotenv +fastapi +uvicorn +python-multipart +psutil +pydub +jinja2 +gTTS +anthropic +elevenlabs +httpx diff --git a/start.sh b/start.sh index 4625dd4..c237cac 100755 --- a/start.sh +++ b/start.sh @@ -1,13 +1,13 @@ -#!/bin/bash -# Tenta limpar processos conflitantes no host se tiver acesso privilégido -if [ -d "/host_root" ]; then - echo "Limpando processos conflitantes no HOST..." - chroot /host_root /bin/bash -c "pkill -f telegram_bot.js" || true - chroot /host_root /bin/bash -c "pkill -f bot_logic.py" || true -fi - -# Inicia o serviço web -uvicorn main:app --host 0.0.0.0 --port 8000 & - -# Inicia o Polling do Bot -python bot_logic.py +#!/bin/bash +# Tenta limpar processos conflitantes no host se tiver acesso privilégido +if [ -d "/host_root" ]; then + echo "Limpando processos conflitantes no HOST..." + chroot /host_root /bin/bash -c "pkill -f telegram_bot.js" || true + chroot /host_root /bin/bash -c "pkill -f bot_logic.py" || true +fi + +# Inicia o serviço web +uvicorn main:app --host 0.0.0.0 --port 8000 & + +# Inicia o Polling do Bot +python bot_logic.py diff --git a/templates/index.html b/templates/index.html index 80a2695..4251062 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,1596 +1,1596 @@ - - - - - - - VPS AI Dashboard - - - - - - - - -
- -
- -
-
- -
-
Online
- -
-
- -
-
-
-

CPU

-
--%
-
-
-
-
-
-

RAM

-
-- / -- GB
-
-
-
-
-
-

Disk

-
--%
-
-
-
-
-
- -
Ações Rápidas
-
- - - - - -
- -
Configuração AI
-
-
-
- - -
-
- - -
-
- -
- - -
Orquestrador AI (Planner + Executor)
-
-
- Status: Carregando... - - -
-
- Repo Gitea: Carregando... -
- -
- -
-

- - Planner (Planejador) -

-
- - -
-
- - -
-
- - -
-

- - Executor (Executor) -

-
- - -
-
- - -
-
-
- -
- -
-
- - -
Credenciais Carregadas
-
-
- - Credenciais sincronizadas dos serviços (Coolify, Gitea, Supabase, etc) - - -
- -
- Clique em "Atualizar" para carregar as credenciais -
-
- -
Terminal & Insights da IA
-
- -
-
-
- Olá! Sou o VPS Agent. Como posso ajudar com seu servidor? Tudo o que eu fizer aparecerá aqui - no - terminal técnico. -
-
-
- - - - - -
-
- - -
-
- - - - - - Painel de Insights Visuais -
-
-
- -

Aguardando dados estruturados...

- Peça algo como "status dos containers" para ver o refinamento aqui. -
-
-
-
-
- -
Ação executada!
- - - - + + + + + + + VPS AI Dashboard + + + + + + + + +
+ +
+ +
+
+ +
+
Online
+ +
+
+ +
+
+
+

CPU

+
--%
+
+
+
+
+
+

RAM

+
-- / -- GB
+
+
+
+
+
+

Disk

+
--%
+
+
+
+
+
+ +
Ações Rápidas
+
+ + + + + +
+ +
Configuração AI
+
+
+
+ + +
+
+ + +
+
+ +
+ + +
Orquestrador AI (Planner + Executor)
+
+
+ Status: Carregando... + + +
+
+ Repo Gitea: Carregando... +
+ +
+ +
+

+ + Planner (Planejador) +

+
+ + +
+
+ + +
+
+ + +
+

+ + Executor (Executor) +

+
+ + +
+
+ + +
+
+
+ +
+ +
+
+ + +
Credenciais Carregadas
+
+
+ + Credenciais sincronizadas dos serviços (Coolify, Gitea, Supabase, etc) + + +
+ +
+ Clique em "Atualizar" para carregar as credenciais +
+
+ +
Terminal & Insights da IA
+
+ +
+
+
+ Olá! Sou o VPS Agent. Como posso ajudar com seu servidor? Tudo o que eu fizer aparecerá aqui + no + terminal técnico. +
+
+
+ + + + + +
+
+ + +
+
+ + + + + + Painel de Insights Visuais +
+
+
+ +

Aguardando dados estruturados...

+ Peça algo como "status dos containers" para ver o refinamento aqui. +
+
+
+
+
+ +
Ação executada!
+ + + + \ No newline at end of file diff --git a/tools_v2.py b/tools_v2.py index 42f660e..9e8fd58 100644 --- a/tools_v2.py +++ b/tools_v2.py @@ -1,664 +1,110 @@ -# ============================================================ -# TOOLS_V2.PY - Ferramentas Expandidas para o Orquestrador -# NÃO SUBSTITUI tools.py - É um módulo adicional -# ============================================================ - import subprocess import os -import requests +import httpx +import asyncio from typing import Dict, List, Optional from credential_manager import ( gitea_api_url, gitea_token, supabase_url, supabase_anon_key, - supabase_service_role_key, coolify_api + supabase_service_role_key ) # ============================================================ -# CONSTANTS -# ============================================================ - -DANGER_LEVELS = { - "safe": "SAFE - Executa automático", - "medium": "MEDIUM - Informa antes", - "dangerous": "DANGEROUS - Pede confirmação" -} - -# ============================================================ -# UTILITY FUNCTIONS +# UTILS # ============================================================ def run_bash(command: str, timeout: int = 120) -> Dict: - """Executa comando bash e retorna resultado estruturado.""" + # Auto-moderniza docker-compose + command = command.replace("docker-compose", "docker compose") try: - result = subprocess.run( - command, - shell=True, - capture_output=True, - text=True, - timeout=timeout - ) - + result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=timeout) return { "success": result.returncode == 0, - "returncode": result.returncode, - "stdout": result.stdout.strip(), - "stderr": result.stderr.strip(), - "output": result.stdout.strip() if result.stdout else result.stderr.strip() - } - except subprocess.TimeoutExpired: - return { - "success": False, - "error": "Comando expirou (timeout)" + "output": (result.stdout or result.stderr).strip() or "Sucesso" } except Exception as e: - return { - "success": False, - "error": str(e) - } - -def format_output(result: Dict, max_length: int = 2000) -> str: - """Formata resultado para exibição.""" - if not result.get("success"): - return f"[ERROR] Erro: {result.get('error') or result.get('stderr') or 'Desconhecido'}" - - output = result.get("output", "[OK] Sucesso (sem output)") - if len(output) > max_length: - output = output[:max_length] + f"\n... (truncado, {len(output)} chars total)" - - return output + return {"success": False, "output": str(e)} # ============================================================ # DOCKER TOOLS # ============================================================ class DockerTools: - """Ferramentas Docker.""" - @staticmethod - def ps(all_containers: bool = False) -> str: - """Lista containers Docker.""" - flags = "-a" if all_containers else "" - result = run_bash("docker ps " + flags + " --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'") - return format_output(result) - + def ps() -> str: + return run_bash("docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'")["output"] + @staticmethod def stats() -> str: - """Mostra estatísticas de recursos dos containers.""" - result = run_bash("docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}'") - return format_output(result) - + return run_bash("docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}'")["output"] + @staticmethod - def logs(container: str, lines: int = 50, follow: bool = False) -> str: - """Mostra logs de um container.""" - follow_flag = "-f" if follow else "" - result = run_bash(f"docker logs {follow_flag} --tail {lines} {container}") - return format_output(result, max_length=5000) - + def logs(container: str, lines: int = 50) -> str: + return run_bash(f"docker logs --tail {lines} {container}")["output"] + @staticmethod def restart(container: str) -> str: - """Reinicia um container.""" - result = run_bash(f"docker restart {container}") - return format_output(result) - - @staticmethod - def stop(container: str) -> str: - """Para um container.""" - result = run_bash(f"docker stop {container}") - return format_output(result) - - @staticmethod - def start(container: str) -> str: - """Inicia um container.""" - result = run_bash(f"docker start {container}") - return format_output(result) - - @staticmethod - def exec(container: str, command: str) -> str: - """Executa comando dentro de um container.""" - result = run_bash(f"docker exec {container} {command}") - return format_output(result) - - @staticmethod - def inspect(container: str) -> str: - """Retorna informações detalhadas de um container.""" - result = run_bash(f"docker inspect {container}") - return format_output(result, max_length=3000) - - @staticmethod - def system_df() -> str: - """Mostra uso de disco do Docker.""" - result = run_bash("docker system df -v") - return format_output(result, max_length=3000) - - @staticmethod - def prune(dangerous: bool = False) -> str: - """Limpa recursos não utilizados do Docker.""" - if dangerous: - result = run_bash("docker system prune -af --volumes") - else: - result = run_bash("docker system prune -f") - return format_output(result) + return run_bash(f"docker restart {container}")["output"] # ============================================================ # GIT TOOLS # ============================================================ class GitTools: - """Ferramentas Git.""" - + @staticmethod + def pull(repo_path: str = ".") -> str: + return run_bash(f"git -C {repo_path} pull")["output"] + @staticmethod def status(repo_path: str = ".") -> str: - """Mostra status do repositório git.""" - result = run_bash(f"git -C {repo_path} status --short") - return format_output(result) - - @staticmethod - def pull(repo_path: str = ".", remote: str = "origin", branch: str = "main") -> str: - """Faz git pull.""" - result = run_bash(f"git -C {repo_path} pull {remote} {branch}") - return format_output(result) - - @staticmethod - def push(repo_path: str = ".", remote: str = "origin", branch: str = "main") -> str: - """Faz git push.""" - result = run_bash(f"git -C {repo_path} push {remote} {branch}") - return format_output(result) - - @staticmethod - def clone(repo_url: str, target_path: str) -> str: - """Clona um repositório.""" - result = run_bash(f"git clone {repo_url} {target_path}") - return format_output(result) - - @staticmethod - def branch(repo_path: str = ".", list_all: bool = False) -> str: - """Lista branches.""" - flags = "-a" if list_all else "" - result = run_bash(f"git -C {repo_path} branch {flags}") - return format_output(result) - - @staticmethod - def checkout(repo_path: str, branch: str) -> str: - """Muda para outro branch.""" - result = run_bash(f"git -C {repo_path} checkout {branch}") - return format_output(result) - - @staticmethod - def log(repo_path: str = ".", count: int = 10) -> str: - """Mostra histórico de commits.""" - result = run_bash(f"git -C {repo_path} log --oneline -{count}") - return format_output(result) - - @staticmethod - def diff(repo_path: str = ".") -> str: - """Mostra diferenças não commitadas.""" - result = run_bash(f"git -C {repo_path} diff") - return format_output(result) - - @staticmethod - def stash(repo_path: str = ".") -> str: - """Salva alterações temporariamente.""" - result = run_bash(f"git -C {repo_path} stash") - return format_output(result) - - @staticmethod - def fetch(repo_path: str = ".", remote: str = "origin") -> str: - """Busca atualizações sem aplicar.""" - result = run_bash(f"git -C {repo_path} fetch {remote}") - return format_output(result) + return run_bash(f"git -C {repo_path} status --short")["output"] # ============================================================ -# DOCKER COMPOSE TOOLS -# ============================================================ - -class DockerComposeTools: - """Ferramentas Docker Compose.""" - - @staticmethod - def up(path: str, detach: bool = True, build: bool = False) -> str: - """Sobe serviços com docker-compose.""" - flags = "-d " if detach else "" - build_flag = "--build " if build else "" - result = run_bash(f"docker-compose -f {path} up {flags}{build_flag}") - return format_output(result) - - @staticmethod - def down(path: str, volumes: bool = False) -> str: - """Para e remove containers.""" - flags = "-v" if volumes else "" - result = run_bash(f"docker-compose -f {path} down {flags}") - return format_output(result) - - @staticmethod - def build(path: str, no_cache: bool = False) -> str: - """Constrói imagens.""" - flags = "--no-cache" if no_cache else "" - result = run_bash(f"docker-compose -f {path} build {flags}") - return format_output(result, max_length=5000) - - @staticmethod - def ps(path: str) -> str: - """Lista serviços.""" - result = run_bash(f"docker-compose -f {path} ps") - return format_output(result) - - @staticmethod - def logs(path: str, service: str = None, lines: int = 100) -> str: - """Mostra logs dos serviços.""" - service_part = f"{service}" if service else "" - result = run_bash(f"docker-compose -f {path} logs --tail {lines} {service_part}") - return format_output(result, max_length=5000) - - @staticmethod - def restart(path: str, service: str = None) -> str: - """Reinicia serviços.""" - service_part = f"{service}" if service else "" - result = run_bash(f"docker-compose -f {path} restart {service_part}") - return format_output(result) - -# ============================================================ -# GITEA API TOOLS +# API TOOLS (ASYNC) # ============================================================ class GiteaTools: - """Ferramentas via API do Gitea.""" - @staticmethod - def _get_headers() -> Dict: - """Retorna headers para API do Gitea.""" - token = gitea_token() - return { - "Authorization": f"token {token}", - "Content-Type": "application/json" - } - - @staticmethod - def list_repos() -> str: - """Lista repositórios do usuário.""" + async def list_repos() -> str: url = f"{gitea_api_url()}/user/repos" - try: - res = requests.get(url, headers=GiteaTools._get_headers(), timeout=10) - if res.status_code == 200: + headers = {"Authorization": f"token {gitea_token()}"} + async with httpx.AsyncClient() as client: + try: + res = await client.get(url, headers=headers) repos = res.json() - if not repos: - return "Nenhum repositório encontrado." - - output = "[REPO] **Repositórios:**\n\n" - for repo in repos[:10]: - output += f"• `{repo['name']}` - {repo.get('description', 'Sem descrição')[:50]}\n" - output += f" URL: {repo['html_url']}\n\n" - return output - return f"[ERROR] Erro: {res.status_code} - {res.text}" - except Exception as e: - return f"[ERROR] Erro: {str(e)}" - - @staticmethod - def get_repo(owner: str, repo: str) -> str: - """Busca informações de um repositório.""" - url = f"{gitea_api_url()}/repos/{owner}/{repo}" - try: - res = requests.get(url, headers=GiteaTools._get_headers(), timeout=10) - if res.status_code == 200: - data = res.json() - return f"""[REPO] **{data['full_name']}** -- **Descrição:** {data.get('description', 'N/A')} -- **Linguagem:** {data.get('language', 'N/A')} -- **Stars:** {data.get('stars_count', 0)} -- **Forks:** {data.get('forks_count', 0)} -- **Última atualização:** {data.get('updated_at', 'N/A')} -- **URL:** {data['html_url']}""" - return f"[ERROR] Erro: {res.status_code}" - except Exception as e: - return f"[ERROR] Erro: {str(e)}" - - @staticmethod - def list_actions(owner: str, repo: str) -> str: - """Lista workflows/actions do repositório.""" - url = f"{gitea_api_url()}/repos/{owner}/{repo}/actions/workflows" - try: - res = requests.get(url, headers=GiteaTools._get_headers(), timeout=10) - if res.status_code == 200: - workflows = res.json().get("workflows", []) - if not workflows: - return "Nenhum workflow encontrado." - - output = "[WF] **Workflows:**\n\n" - for wf in workflows: - output += f"• `{wf['name']}` - {wf.get('status', 'N/A')}\n" - return output - return f"[ERROR] Erro: {res.status_code}" - except Exception as e: - return f"[ERROR] Erro: {str(e)}" - - @staticmethod - def trigger_workflow(owner: str, repo: str, workflow_id: str, ref: str = "main") -> str: - """Dispara um workflow.""" - url = f"{gitea_api_url()}/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches" - data = {"ref": ref} - try: - res = requests.post(url, headers=GiteaTools._get_headers(), json=data, timeout=10) - if res.status_code == 204: - return f"[OK] Workflow '{workflow_id}' disparado com sucesso!" - return f"[ERROR] Erro: {res.status_code} - {res.text}" - except Exception as e: - return f"[ERROR] Erro: {str(e)}" - -# ============================================================ -# SUPABASE API TOOLS -# ============================================================ + return "\n".join([f"• {r['name']}" for r in repos[:10]]) + except Exception as e: return f"Erro Gitea: {e}" class SupabaseTools: - """Ferramentas via API REST do Supabase.""" - @staticmethod - def _get_headers(anon_key: bool = True) -> Dict: - """Retorna headers para API do Supabase.""" - key = supabase_anon_key() if anon_key else supabase_service_role_key() - role = "anon" if anon_key else "service_role" - return { - "apikey": key, - "Authorization": f"Bearer {key}", - "Content-Type": "application/json", - "Prefer": "return=representation" - } - - @staticmethod - def list_tables() -> str: - """Lista tabelas disponíveis (via introspecção).""" + async def list_tables() -> str: url = f"{supabase_url()}/rest/v1/" - try: - res = requests.get(url, headers=SupabaseTools._get_headers(), timeout=10) - if res.status_code == 200: - tables = res.json() - if not tables: - return "Nenhuma tabela encontrada." - - output = "[DATA] **Tabelas:**\n\n" - for table in tables[:20]: - output += f"• `{table.get('table_name', 'N/A')}`\n" - return output - return f"[ERROR] Erro: {res.status_code}" - except Exception as e: - return f"[ERROR] Erro: {str(e)}" - - @staticmethod - def query(table: str, select: str = "*", filters: str = None, limit: int = 10) -> str: - """Consulta dados de uma tabela.""" - url = f"{supabase_url()}/rest/v1/{table}" - params = f"select={select}&limit={limit}" - if filters: - params += f"&{filters}" - - try: - res = requests.get(url, headers=SupabaseTools._get_headers(), params=params, timeout=10) - if res.status_code == 200: - data = res.json() - if not data: - return f"📭 Nenhum resultado em `{table}`." - - output = f"[DATA] **Resultados de `{table}`** ({len(data)} registros):\n\n" - for row in data[:5]: - output += f"```json\n{str(row)[:200]}\n```\n" - return output - return f"[ERROR] Erro: {res.status_code} - {res.text}" - except Exception as e: - return f"[ERROR] Erro: {str(e)}" - - @staticmethod - def insert(table: str, data: Dict) -> str: - """Insere dados em uma tabela.""" - url = f"{supabase_url()}/rest/v1/{table}" - try: - res = requests.post(url, headers=SupabaseTools._get_headers(anon_key=False), json=data, timeout=10) - if res.status_code in [200, 201]: - return f"[OK] Registro inserido em `{table}`!" - return f"[ERROR] Erro: {res.status_code} - {res.text}" - except Exception as e: - return f"[ERROR] Erro: {str(e)}" - - @staticmethod - def update(table: str, data: Dict, filters: str) -> str: - """Atualiza dados em uma tabela.""" - url = f"{supabase_url()}/rest/v1/{table}?{filters}" - try: - res = requests.patch(url, headers=SupabaseTools._get_headers(anon_key=False), json=data, timeout=10) - if res.status_code in [200, 204]: - return f"[OK] Registro atualizado em `{table}`!" - return f"[ERROR] Erro: {res.status_code} - {res.text}" - except Exception as e: - return f"[ERROR] Erro: {str(e)}" - - @staticmethod - def delete(table: str, filters: str) -> str: - """Deleta dados de uma tabela.""" - url = f"{supabase_url()}/rest/v1/{table}?{filters}" - try: - res = requests.delete(url, headers=SupabaseTools._get_headers(anon_key=False), timeout=10) - if res.status_code in [200, 204]: - return f"[OK] Registro deletado de `{table}`!" - return f"[ERROR] Erro: {res.status_code} - {res.text}" - except Exception as e: - return f"[ERROR] Erro: {str(e)}" + headers = {"apikey": supabase_anon_key(), "Authorization": f"Bearer {supabase_anon_key()}"} + async with httpx.AsyncClient() as client: + try: + res = await client.get(url, headers=headers) + return str(res.json()) + except Exception as e: return f"Erro Supabase: {e}" # ============================================================ -# COOLIFY API TOOLS -# ============================================================ - -class CoolifyTools: - """Ferramentas via API do Coolify.""" - - @staticmethod - def get_status() -> str: - """Retorna status do Coolify.""" - result = coolify_api("/status") - if "error" in result: - return f"[ERROR] Erro: {result['error']}" - - return f"""[COOLIFY] **Coolify Status:** -- **Status:** {result.get('status', 'N/A')} -- **Containers:** {result.get('containers', 'N/A')} -- **Deployments:** {result.get('deployments', 'N/A')}""" - - @staticmethod - def list_applications() -> str: - """Lista aplicações no Coolify.""" - from credential_manager import coolify_list_applications - apps = coolify_list_applications() - if not apps: - return "[REPO] Nenhuma aplicacao encontrada." - - output = "[REPO] Aplicacoes Coolify:\n\n" - for app in apps[:10]: - output += f"- {app.get('name', 'N/A')} - {app.get('status', 'N/A')}\n" - output += f" URL: {app.get('fqdn', 'N/A')}\n\n" - return output - - @staticmethod - def list_deployments(limit: int = 10) -> str: - """Lista deployments recentes.""" - from credential_manager import coolify_list_deployments - deps = coolify_list_deployments() - if not deps: - return "[DEPLOY] Nenhum deployment recente." - - output = "[DEPLOY] Deployments Recentes:\n\n" - for dep in deps[:limit]: - output += f"- {dep.get('application', 'N/A')} - {dep.get('status', 'N/A')}\n" - output += f" {dep.get('created_at', 'N/A')}\n\n" - return output - -# ============================================================ -# FILE TOOLS -# ============================================================ - -class FileTools: - """Ferramentas de manipulação de arquivos.""" - - @staticmethod - def list(path: str) -> str: - """Lista conteúdo de diretório.""" - result = run_bash(f"ls -la {path}") - return format_output(result) - - @staticmethod - def read(path: str, lines: int = 100) -> str: - """Lê conteúdo de arquivo.""" - result = run_bash(f"head -{lines} {path}") - return format_output(result, max_length=5000) - - @staticmethod - def search(path: str, pattern: str) -> str: - """Busca texto em arquivos.""" - result = run_bash(f"grep -rn '{pattern}' {path} 2>/dev/null | head -50") - return format_output(result, max_length=5000) - - @staticmethod - def write(path: str, content: str) -> str: - """Escreve conteúdo em arquivo.""" - # Escapa o conteúdo para evitar injection - import shlex - safe_content = shlex.quote(content) - result = run_bash(f"echo {safe_content} > {path}") - return format_output(result) - - @staticmethod - def exists(path: str) -> str: - """Verifica se arquivo existe.""" - exists = os.path.exists(path) - return f"{'[OK]' if exists else '[ERROR]'} {'Existe' if exists else 'Não existe'}: {path}" - - @staticmethod - def size(path: str) -> str: - """Retorna tamanho de arquivo.""" - result = run_bash(f"du -sh {path} 2>/dev/null || ls -lh {path}") - return format_output(result) - -# ============================================================ -# SYSTEM TOOLS -# ============================================================ - -class SystemTools: - """Ferramentas de sistema.""" - - @staticmethod - def df() -> str: - """Mostra uso de disco.""" - result = run_bash("df -h") - return format_output(result) - - @staticmethod - def free() -> str: - """Mostra uso de memória.""" - result = run_bash("free -h") - return format_output(result) - - @staticmethod - def top(limit: int = 10) -> str: - """Mostra processos mais pesados.""" - result = run_bash(f"ps aux --sort=-%cpu | head -{limit + 1}") - return format_output(result) - - @staticmethod - def uptime() -> str: - """Mostra uptime do sistema.""" - result = run_bash("uptime") - return format_output(result) - - @staticmethod - def services() -> str: - """Lista serviços ativos.""" - result = run_bash("systemctl list-units --type=service --state=running | head -20") - return format_output(result) - - @staticmethod - def ports() -> str: - """Lista portas em uso.""" - result = run_bash("netstat -tlnp 2>/dev/null || ss -tlnp") - return format_output(result, max_length=3000) - -# ============================================================ -# TOOLKIT REGISTRY +# REGISTRY # ============================================================ TOOLS_V2 = { - # DOCKER - "docker_ps": {"desc": "Lista containers Docker", "func": DockerTools.ps, "danger": "safe"}, - "docker_stats": {"desc": "Estatísticas de containers", "func": DockerTools.stats, "danger": "safe"}, - "docker_logs": {"desc": "Logs de container (use: docker_logs )", "func": lambda n="app", l=50: DockerTools.log(n, int(l)), "danger": "safe"}, - "docker_restart": {"desc": "Reinicia container (use: docker_restart )", "func": DockerTools.restart, "danger": "dangerous"}, - "docker_stop": {"desc": "Para container", "func": DockerTools.stop, "danger": "dangerous"}, - "docker_start": {"desc": "Inicia container", "func": DockerTools.start, "danger": "medium"}, - "docker_exec": {"desc": "Executa comando no container", "func": DockerTools.exec, "danger": "dangerous"}, - "docker_system_df": {"desc": "Uso de disco Docker", "func": DockerTools.system_df, "danger": "safe"}, - "docker_prune": {"desc": "Limpa recursos Docker não usados", "func": lambda: DockerTools.prune(True), "danger": "dangerous"}, - - # GIT - "git_status": {"desc": "Status do repositório git", "func": GitTools.status, "danger": "safe"}, - "git_pull": {"desc": "Pull do git", "func": GitTools.pull, "danger": "medium"}, - "git_push": {"desc": "Push do git", "func": GitTools.push, "danger": "dangerous"}, - "git_clone": {"desc": "Clona repositório", "func": GitTools.clone, "danger": "medium"}, - "git_branch": {"desc": "Lista branches", "func": GitTools.branch, "danger": "safe"}, - "git_log": {"desc": "Histórico de commits", "func": GitTools.log, "danger": "safe"}, - "git_diff": {"desc": "Diferenças não commitadas", "func": GitTools.diff, "danger": "safe"}, - "git_fetch": {"desc": "Busca atualizações", "func": GitTools.fetch, "danger": "safe"}, - - # DOCKER COMPOSE - "dc_up": {"desc": "Sobe serviços (use: dc_up )", "func": DockerComposeTools.up, "danger": "dangerous"}, - "dc_down": {"desc": "Para serviços", "func": DockerComposeTools.down, "danger": "dangerous"}, - "dc_build": {"desc": "Constrói imagens", "func": DockerComposeTools.build, "danger": "medium"}, - "dc_ps": {"desc": "Lista serviços", "func": DockerComposeTools.ps, "danger": "safe"}, - "dc_logs": {"desc": "Logs de serviços", "func": DockerComposeTools.logs, "danger": "safe"}, - "dc_restart": {"desc": "Reinicia serviços", "func": DockerComposeTools.restart, "danger": "dangerous"}, - - # GITEA - "gitea_list_repos": {"desc": "Lista repositórios Gitea", "func": GiteaTools.list_repos, "danger": "safe"}, - "gitea_get_repo": {"desc": "Info de repositório (use: gitea_get_repo )", "func": GiteaTools.get_repo, "danger": "safe"}, - "gitea_list_actions": {"desc": "Lista workflows do repositório", "func": GiteaTools.list_actions, "danger": "safe"}, - "gitea_trigger": {"desc": "Dispara workflow", "func": GiteaTools.trigger_workflow, "danger": "dangerous"}, - - # SUPABASE - "supabase_list_tables": {"desc": "Lista tabelas do Supabase", "func": SupabaseTools.list_tables, "danger": "safe"}, - "supabase_query": {"desc": "Consulta tabela", "func": SupabaseTools.query, "danger": "safe"}, - "supabase_insert": {"desc": "Insere dados", "func": SupabaseTools.insert, "danger": "dangerous"}, - "supabase_update": {"desc": "Atualiza dados", "func": SupabaseTools.update, "danger": "dangerous"}, - - # COOLIFY - "coolify_status": {"desc": "Status do Coolify", "func": CoolifyTools.get_status, "danger": "safe"}, - "coolify_apps": {"desc": "Lista aplicações Coolify", "func": CoolifyTools.list_applications, "danger": "safe"}, - "coolify_deployments": {"desc": "Lista deployments recentes", "func": CoolifyTools.list_deployments, "danger": "safe"}, - - # FILES - "file_list": {"desc": "Lista diretório", "func": FileTools.list, "danger": "safe"}, - "file_read": {"desc": "Lê arquivo", "func": FileTools.read, "danger": "safe"}, - "file_search": {"desc": "Busca em arquivos", "func": FileTools.search, "danger": "safe"}, - "file_exists": {"desc": "Verifica se arquivo existe", "func": FileTools.exists, "danger": "safe"}, - "file_size": {"desc": "Tamanho de arquivo", "func": FileTools.size, "danger": "safe"}, - - # SYSTEM - "sys_df": {"desc": "Uso de disco", "func": SystemTools.df, "danger": "safe"}, - "sys_free": {"desc": "Uso de memória", "func": SystemTools.free, "danger": "safe"}, - "sys_top": {"desc": "Processos mais pesados", "func": SystemTools.top, "danger": "safe"}, - "sys_uptime": {"desc": "Uptime do sistema", "func": SystemTools.uptime, "danger": "safe"}, - "sys_ports": {"desc": "Portas em uso", "func": SystemTools.ports, "danger": "safe"}, + "docker_ps": {"desc": "Lista containers", "func": DockerTools.ps, "danger": "safe"}, + "docker_stats": {"desc": "Uso de recursos", "func": DockerTools.stats, "danger": "safe"}, + "docker_logs": {"desc": "Ver logs", "func": DockerTools.logs, "danger": "safe"}, + "docker_restart": {"desc": "Reiniciar container", "func": DockerTools.restart, "danger": "dangerous"}, + "git_pull": {"desc": "Atualizar código", "func": GitTools.pull, "danger": "medium"}, + "git_status": {"desc": "Ver status git", "func": GitTools.status, "danger": "safe"}, + "gitea_repos": {"desc": "Listar repos no Gitea", "func": GiteaTools.list_repos, "danger": "safe"}, + "supabase_tables": {"desc": "Listar tabelas Supabase", "func": SupabaseTools.list_tables, "danger": "safe"}, } -def get_tools_by_danger(level: str) -> List[Dict]: - """Retorna ferramentas por nível de perigo.""" - return [ - {"name": k, **v} - for k, v in TOOLS_V2.items() - if v["danger"] == level - ] - def get_all_tools_formatted() -> str: - """Retorna lista formatada de todas as ferramentas.""" - output = "[TOOLS] Ferramentas Disponiveis:\n\n" - - for level in ["safe", "medium", "dangerous"]: - tools = get_tools_by_danger(level) - if tools: - icon = {"safe": "[SAFE]", "medium": "[MEDIUM]", "dangerous": "[CRITICAL]"}[level] - output += f"\n{icon} **{level.upper()}**:\n" - for t in tools: - output += f" - `{t['name']}` - {t['desc']}\n" - - return output + res = "🛠️ **Ferramentas Disponíveis**:\n\n" + for name, info in TOOLS_V2.items(): + res += f"- `{name}`: {info['desc']} [{info['danger'].upper()}]\n" + return res + +def get_tools_by_danger(level: str) -> List: + return [{"name": k, **v} for k, v in TOOLS_V2.items() if v["danger"] == level] diff --git a/update.sh b/update.sh index 2d45b43..4a717ad 100755 --- a/update.sh +++ b/update.sh @@ -1,15 +1,15 @@ -#!/bin/bash -# Script de deploy e sincronização automática -echo "🚀 Iniciando deploy e push para o Gitea..." - -# 1. Build do Docker -echo "📦 Reconstruindo container..." -docker compose down && docker compose build && docker compose up -d - -# 2. Sincronização com Git (Gitea) -echo "git Pushing para o Gitea..." -git add . -git commit -m "🔒 Implementação de segurança: Login Web fixo e proteção de API" -git push origin master - -echo "✅ Sucesso! Agente atualizado e código no Gitea." +#!/bin/bash +# Script de deploy e sincronização automática +echo "🚀 Iniciando deploy e push para o Gitea..." + +# 1. Build do Docker +echo "📦 Reconstruindo container..." +docker compose down && docker compose build && docker compose up -d + +# 2. Sincronização com Git (Gitea) +echo "git Pushing para o Gitea..." +git add . +git commit -m "🔒 Implementação de segurança: Login Web fixo e proteção de API" +git push origin master + +echo "✅ Sucesso! Agente atualizado e código no Gitea."