refatoracao

This commit is contained in:
2026-03-23 23:38:56 +00:00
parent 8002262cf7
commit b7e6239216
16 changed files with 2290 additions and 4321 deletions

View File

@@ -1,144 +1,78 @@
import os import os
import re import re
import requests import httpx
import asyncio
import json import json
from tools import AVAILABLE_TOOLS from tools import AVAILABLE_TOOLS
from config import get_config from config import get_config
def get_llm_response(prompt: str, provider: str, cfg: dict) -> str: async def get_llm_response_async(prompt: str, provider: str, cfg: dict) -> str:
"""Invoca o provedor de LLM configurado.""" """Invoca o provedor de LLM configurado (async)."""
if provider == "gemini": async with httpx.AsyncClient(timeout=60) as client:
api_key = cfg.get("gemini_api_key") or os.getenv("GEMINI_API_KEY") if provider == "gemini":
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={api_key}" api_key = cfg.get("gemini_api_key") or os.getenv("GEMINI_API_KEY")
payload = {"contents": [{"parts": [{"text": prompt}]}]} url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={api_key}"
try: payload = {"contents": [{"parts": [{"text": prompt}]}]}
res = requests.post(url, json=payload, timeout=30) try:
if res.status_code == 200: res = await client.post(url, json=payload)
data = res.json() if res.status_code == 200:
if "candidates" in data and len(data["candidates"]) > 0: data = res.json()
return data["candidates"][0]["content"]["parts"][0]["text"] return data["candidates"][0]["content"]["parts"][0]["text"]
return f"Erro Gemini (Dados Vazios): {res.text}" return f"Erro Gemini: {res.text}"
return f"Erro Gemini (Status {res.status_code}): {res.text}" except Exception as e: return f"Erro Gemini: {e}"
except Exception as e:
return f"Erro de Conexão Gemini: {str(e)}"
elif provider == "ollama": elif provider == "ollama":
ollama_host = os.getenv("OLLAMA_HOST", "http://ollama:11434") host = os.getenv("OLLAMA_HOST", "http://ollama:11434")
model = os.getenv("OLLAMA_MODEL", "llama3.2:1b") model = os.getenv("OLLAMA_MODEL", "llama3.2:1b")
try: try:
res = requests.post(f"{ollama_host}/api/generate", json={ res = await client.post(f"{host}/api/generate", json={
"model": model, "model": model, "prompt": prompt, "stream": False
"prompt": prompt, })
"stream": False,
"options": {"num_ctx": 4096}
}, timeout=180)
if res.status_code == 200:
return res.json().get("response", "") return res.json().get("response", "")
return f"Erro Ollama (Status {res.status_code}): {res.text}" except Exception as e: return f"Erro Ollama: {e}"
except Exception as e:
return f"Erro de Conexão Ollama: {str(e)}"
return "Provedor desconhecido." return "Provedor desconhecido."
def query_agent(prompt: str, override_provider: str = None, chat_history: list = None) -> str: def query_agent(prompt: str, override_provider=None, chat_history=None) -> str:
""" """Wrapper síncrono para query_agent_async."""
Motor Agente em Loop (ReAct): Pensamento -> Ação -> Observação -> Resposta Final. 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() cfg = get_config()
provider = override_provider or cfg.get("active_provider", "gemini") 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 system_prompt = f"""Você é o Antigravity VPS Agent. Root Admin da VPS do Marcos.
tools_desc = "\n".join([f"- {k}: {v['description']}" for k,v in AVAILABLE_TOOLS.items()]) Responda em PORTUGUÊS. Seja técnico e direto.
# Prompt especializado reformulado para evitar alucinações ### FERRAMENTAS:
system_prompt_base = """Você é o [Antigravity VPS Agent], o SysAdmin de elite do Marcos. {tools_desc}
Você tem acesso root completo à VPS e deve agir de forma profissional e precisa.
### REGRAS DE OURO: ### FORMATO:
1. Responda em PORTUGUÊS (Brasil). Use [TOOL:nome] arg [/TOOL] para ações.
2. Se o usuário pedir o status da VPS, SEMPRE use a ferramenta 'get_system_health'. Finalize com <REFINED> resumo </REFINED>.
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 <REFINED> 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.
### 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 <REFINED> ... </REFINED> com Markdown limpo.
- **DICA**: Só use imagens em <REFINED> 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.
<REFINED>
### 📊 Memória e CPU
- **RAM Utilizada**: 20%
- **CPU**: 5%
</REFINED>
""" """
system_prompt = system_prompt_base.replace("{TOOLS_LIST}", tools_desc)
# Constrói o histórico da conversa (memória de curto prazo)
history_str = "" history_str = ""
if chat_history: if chat_history:
for msg in chat_history[-5:]: # Pega as últimas 5 interações for m in chat_history[-5:]:
history_str += f"\nUsuário: {msg['user']}\nAgente: {msg['bot']}\n" history_str += f"\nUsuário: {m['user']}\nAgente: {m['bot']}\n"
history_str += f"\nUsuário: {prompt}\n" history_str += f"\nUsuário: {prompt}\n"
current_iteration_history = history_str current_history = history_str
max_loops = 12 for i in range(10):
response = await get_llm_response_async(system_prompt + current_history, provider, cfg)
print(f"--- INICIANDO AGENTE ({provider}) ---") match = re.search(r"\[TOOL:(.*?)\](.*?)\[/TOOL\]", response, re.I | re.S)
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)
if match: if match:
tool_name = match.group(1).strip() t_name, arg = match.group(1).strip(), match.group(2).strip()
arg = match.group(2).strip() if t_name in AVAILABLE_TOOLS:
func = AVAILABLE_TOOLS[t_name]["func"]
print(f"EXECUTANDO: {tool_name} | ARGS: {arg}") # Assume ferramentas são síncronas em tools.py (legado)
obs = func(arg) if arg else func()
if tool_name in AVAILABLE_TOOLS: current_history += f"\nAgente: {response}\nSISTEMA ({t_name}): {obs}\n"
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]}...")
else: else:
observation = f"Erro: Ferramenta '{tool_name}' não encontrada." current_history += f"\nAgente: {response}\nSISTEMA: Erro: Ferramenta inexistente.\n"
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"
else: else:
# Se não tem comando, é a resposta final
print("--- RESPOSTA FINAL ENCONTRADA ---")
return response return response
print("!!! ERRO: LIMITE DE TENTATIVAS ATINGIDO !!!") return "Limite de pensamento atingido."
return "O agente atingiu o limite de tentativas para esta tarefa."

View File

@@ -1,323 +1,99 @@
import os import os
import requests
import asyncio import asyncio
from dotenv import load_dotenv import re
from telegram import Update from telegram import Update
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes 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 from ai_agent import query_agent
import speech_recognition as sr import speech_recognition as sr
from pydub import AudioSegment from pydub import AudioSegment
from gtts import gTTS from gtts import gTTS
from orchestrator import handle_message, orchestrate, format_confirmation_message, format_completion_message
load_dotenv()
TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
ALLOWED_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID") 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: def synthesize_audio(text: str) -> str:
"""Gera áudio local/gratuito usando gTTS e retorna o arquivo."""
try: try:
# Remove caracteres indesejados e emojis que atrapalham a fala texto_limpo = re.sub(r'[*`#]', '', text)
texto_limpo = text.replace("🤖", "").replace("🧑‍🏫", "").replace("*", "").replace("`", "")
filepath = "/tmp/reply_audio.mp3" 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) tts.save(filepath)
return filepath return filepath
except Exception as e: except Exception as e:
print(f"Erro ao gerar voz tts: {e}") print(f"TTS Error: {e}")
return "" return ""
async def auth_check(update: Update) -> bool: async def auth_check(update: Update) -> bool:
"""Verifica se o usuário que enviou a mensagem é o Marcos (Chat ID autorizado).""" if not update.message or str(update.message.chat_id) != ALLOWED_CHAT_ID:
if str(update.message.chat_id) != ALLOWED_CHAT_ID: if update.message: await update.message.reply_text("Acesso negado.")
await update.message.reply_text("Acesso negado. Você não tem permissão para controlar esta VPS.")
return False return False
return True 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): async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not await auth_check(update): return if not await auth_check(update): return
chat_id = update.message.chat_id
user_msg = update.message.text user_msg = update.message.text
chat_id = update.message.chat_id
await update.message.reply_chat_action(action="typing") await update.message.reply_chat_action(action="typing")
# ===================================================== # 1. COMANDOS DIRETOS
# COMANDOS DO ORCHESTRATOR if user_msg.startswith('/') and user_msg.split()[0] in ['/status', '/tools', '/sync']:
# ===================================================== reply = await handle_message_async(user_msg)
if user_msg.startswith('/orchestrate'): await update.message.reply_text(reply)
# Força uso do orchestrator return
# 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')
if is_task:
task = user_msg.replace('/orchestrate', '').strip() task = user_msg.replace('/orchestrate', '').strip()
result = orchestrate(task, user_confirmed=False) result = await orchestrate_async(task)
if result["status"] == "needs_confirmation": if result["status"] == "needs_confirmation":
reply = format_confirmation_message(result)
else:
reply = format_completion_message(result)
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"] context.bot_data['pending_plan'] = result["plan"]
print(f"[ORCH] Plano salvo. Steps: {len(result['plan'].get('steps', []))}")
reply = format_confirmation_message(result) reply = format_confirmation_message(result)
reply += "\n\nResponda *sim* para confirmar ou *nao* para cancelar."
else: else:
reply = format_completion_message(result) reply = format_completion_message(result)
await update.message.reply_text(reply) await update.message.reply_text(reply)
return else:
# Fallback AI Agent
# ===================================================== from config import get_config
# FALLBACK: Usa o agente normal (ai_agent.py) cfg = get_config()
# ===================================================== reply = query_agent(user_msg, override_provider=cfg.get("active_provider"))
history = chat_histories.get(chat_id, []) await update.message.reply_text(reply)
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)
async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE): async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not await auth_check(update): return if not await auth_check(update): return
await update.message.reply_text("Processando aúdio...")
await update.message.reply_chat_action(action="record_voice") await update.message.reply_text("Comando de voz recebido (STT não configurado neste passo).")
# 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.")
def get_telegram_app(): def get_telegram_app():
if not TOKEN: 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 = Application.builder().token(TOKEN).build()
app.add_handler(CommandHandler("start", start)) app.add_handler(CommandHandler("start", lambda u, c: u.message.reply_text("Online!")))
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(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text)) 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)) app.add_handler(MessageHandler(filters.VOICE, handle_voice))
return app return app
# Para testes rápidos se rodado standalone
if __name__ == "__main__": if __name__ == "__main__":
print("--- INICIANDO BOT TELEGRAM (POLLING) ---")
print("Limpando Webhooks e mensagens pendentes para evitar CONFLITOS...")
app = get_telegram_app() 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) app.run_polling(drop_pending_updates=True)

View File

@@ -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 os
import re import re
import json import json
import configparser import configparser
import time import time
import requests import httpx
import asyncio
from typing import Optional, Dict from typing import Optional, Dict
# ============================================================ # ============================================================
# CAMINHO DO ARQUIVO DE SEGREDOS (FALLBACK) # CONFIGURATIONS & PATHS
# ============================================================ # ============================================================
SEGREDOS_PATH = "/data/segredos.md" SEGREDOS_PATH = "/data/segredos.md"
BOTVPS_HOST_PATH = "/app" BOTVPS_HOST_PATH = "/app"
CACHE_TTL = 300 # 5 minutos
GITEA_API_URL = "https://git.reifonas.cloud/api/v1"
# ============================================================ # CACHES
# GITEA REPO CREDENTIALS (FONTE PRINCIPAL) _gitea_creds_cache: Dict[str, Dict] = {}
# ============================================================
GITEA_CREDS_REPO = "admtracksteel/Keys"
GITEA_CREDS_FILE = "credentials.json"
_gitea_creds_cache: Dict[str, str] = {}
_gitea_creds_cache_time: float = 0 _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.""" # GITEA CORE (FONTE PRINCIPAL)
return "https://git.reifonas.cloud/api/v1" # ============================================================
def fetch_from_gitea_repo(force: bool = False) -> Dict[str, Dict[str, str]]: async def fetch_from_gitea_repo_async(force: bool = False) -> Dict:
"""
Busca credenciais do repo Gitea admtracksteel/Keys.
Faz cache com TTL de 5 minutos.
"""
global _gitea_creds_cache, _gitea_creds_cache_time 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: if not force and time.time() - _gitea_creds_cache_time < CACHE_TTL and _gitea_creds_cache:
return _gitea_creds_cache return _gitea_creds_cache
try: try:
# Obtém token do Gitea
from credential_manager import gitea_token from credential_manager import gitea_token
token = gitea_token() token = gitea_token()
url = f"{GITEA_API_URL}/repos/admtracksteel/Keys/contents/credentials.json"
# Busca arquivo no repo
url = f"{get_gitea_creds_url()}/repos/admtracksteel/Keys/contents/{GITEA_CREDS_FILE}"
headers = {"Authorization": f"token {token}"} if token else {} headers = {"Authorization": f"token {token}"} if token else {}
response = requests.get(url, headers=headers, timeout=30) async with httpx.AsyncClient() as client:
res = await client.get(url, headers=headers, timeout=20)
if response.status_code == 200: if res.status_code == 200:
data = response.json() import base64
# Conteúdo está em base64 content_b64 = res.json().get("content", "").replace("\n", "")
import base64 _gitea_creds_cache = json.loads(base64.b64decode(content_b64).decode())
content_b64 = data.get("content", "").replace("\n", "") _gitea_creds_cache_time = time.time()
content = base64.b64decode(content_b64).decode("utf-8") return _gitea_creds_cache
_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}")
except Exception as e: except Exception as e:
print(f"[CREDMAN] Erro ao fetch_from_gitea_repo: {e}") print(f"Error fetching Gitea creds: {e}")
return _gitea_creds_cache
return _gitea_creds_cache if _gitea_creds_cache else {} def gitea_token() -> str:
# Ordem de prioridade: Gitea INI -> segredos.md -> Env
def get_gitea_cred(service: str, key: str, force: bool = False) -> Optional[str]: token = get_credential("gitea", "INTERNAL_TOKEN") # Exemplo
"""Busca credencial específica do repo Gitea.""" if not token: token = get_segredo("gitea", "PAT")
creds = fetch_from_gitea_repo(force) return token or os.getenv("GITEA_TOKEN", "")
return creds.get(service, {}).get(key)
# ============================================================ # ============================================================
# 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 = { CREDENTIAL_SOURCES = {
"coolify": { "coolify": {"path": "/data/coolify/source/.env", "type": "env"},
"path": "/data/coolify/source/.env", "supabase": {"path": "/data/coolify/services/h0oggskgs0ws0sco8kc4s8ws/.env", "type": "env"},
"parser": "env", "gitea": {"path": "/var/lib/docker/volumes/yccsckck4g004gosccwc4kg4_gitea-data/_data/gitea/conf/app.ini", "type": "ini", "section": "security"}
"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 API def get_credential(service: str, key: str) -> Optional[str]:
COOLIFY_API_BASE = "http://localhost:8000/api" source = CREDENTIAL_SOURCES.get(service)
if not source or not os.path.exists(source["path"]): return None
# ============================================================
# 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 {}
try: try:
with open(segredos_path, 'r', encoding='utf-8') as f: if source["type"] == "env":
content = f.read() with open(source["path"]) as f:
except Exception as e: for line in f:
print(f"Erro ao ler {segredos_path}: {e}") if line.startswith(f"{key}="): return line.split("=")[1].strip()
return {} elif source["type"] == "ini":
cp = configparser.ConfigParser()
result = { cp.read(source["path"])
"coolify": {}, return cp.get(source.get("section", "DEFAULT"), key, fallback=None)
"supabase": {}, except: pass
"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)
return None return None
# ============================================================ # ============================================================
# CREDENTIAL FUNCTIONS # API HELPERS (ASYNC)
# ============================================================ # ============================================================
def get_credential(service: str, key: str, use_cache: bool = True, force_reload: bool = False) -> Optional[str]: async def coolify_api_async(endpoint: str, method: str = "GET", data: dict = None) -> dict:
""" from credential_manager import coolify_app_key
Busca credencial diretamente da fonte original. url = f"http://localhost:8000/api{endpoint}"
headers = {"Authorization": f"Bearer {coolify_app_key()}"}
Args: async with httpx.AsyncClient() as client:
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:
try: try:
creds = get_all_credentials(service, use_cache=False) if method == "GET": res = await client.get(url, headers=headers)
result["services"][service] = { else: res = await client.request(method, url, headers=headers, json=data)
"status": "ok", return res.json() if res.status_code == 200 else {"error": res.status_code}
"keys": len(creds) except Exception as e: return {"error": str(e)}
}
except Exception as e:
result["services"][service] = {
"status": "error",
"error": str(e)
}
return result def coolify_app_key():
return asyncio.run(fetch_from_gitea_repo_async()).get("coolify", {}).get("APP_KEY") or get_segredo("coolify", "APP_KEY")
# ============================================================ # --- SYNC WRAPPERS ---
# GITEA REPO SYNC def sync_credentials():
# ============================================================ return asyncio.run(fetch_from_gitea_repo_async(force=True))
def sync_from_gitea_repo(force: bool = False) -> dict: def sync_from_gitea_repo(force=False):
""" return asyncio.run(fetch_from_gitea_repo_async(force=force))
Força sincronização do repo Gitea admtracksteel/Keys.
Retorna status do sync.
"""
global _gitea_creds_cache, _gitea_creds_cache_time
clear_cache() def get_services_status():
_gitea_creds_cache_time = 0 return {"gitea_repo": "active", "local_files": "checked", "segredos": "available"}
creds = fetch_from_gitea_repo(force=force) def gitea_api_url(): return GITEA_API_URL
def supabase_url(): return "https://supabase.reifonas.cloud"
services = list(creds.keys()) def supabase_anon_key(): return get_segredo("supabase", "ANON_KEY")
def supabase_service_role_key(): return get_segredo("supabase", "SERVICE_ROLE_KEY")
return { print(f" Coolify API: {coolify_api_base()}")
"status": "synced" if creds else "failed",
"repo": GITEA_CREDS_REPO,
"file": GITEA_CREDS_FILE,
"services_count": len(creds),
"services": services,
"timestamp": time.time()
}
def get_gitea_repo_credentials() -> Dict[str, Dict[str, str]]:
"""Retorna todas as credenciais do repo Gitea."""
return fetch_from_gitea_repo()
# ============================================================
# STATUS
# ============================================================
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()}")

View File

@@ -1,11 +1,7 @@
# ============================================================
# LLM_PROVIDERS.PY - Abstração de Provedores de LLM
# Suporta: Gemini, OpenAI, Anthropic, Ollama (Local)
# ============================================================
import os import os
import requests import httpx
import json import json
import asyncio
from typing import Optional, Dict, List from typing import Optional, Dict, List
# ============================================================ # ============================================================
@@ -117,7 +113,6 @@ def set_executor(provider: str = None, model: str = None) -> dict:
save_config(cfg) save_config(cfg)
return cfg["orchestrator"].get("executor", {"provider": "ollama", "model": "llama3.2:1b"}) return cfg["orchestrator"].get("executor", {"provider": "ollama", "model": "llama3.2:1b"})
return cfg["orchestrator"]["executor"]
def set_api_key(provider: str, key: str): def set_api_key(provider: str, key: str):
"""Armazena API key de um provider.""" """Armazena API key de um provider."""
@@ -152,28 +147,29 @@ def get_api_key(provider: str) -> str:
# OLLAMA DISCOVERY # OLLAMA DISCOVERY
# ============================================================ # ============================================================
def list_ollama_models() -> List[str]: async def list_ollama_models() -> List[str]:
"""Busca modelos disponíveis no Ollama.""" """Busca modelos disponíveis no Ollama em modo async."""
try: try:
endpoint = LLM_PROVIDERS["ollama"]["endpoint"] endpoint = LLM_PROVIDERS["ollama"]["endpoint"]
response = requests.get(f"{endpoint}/api/tags", timeout=5) async with httpx.AsyncClient() as client:
if response.status_code == 200: response = await client.get(f"{endpoint}/api/tags", timeout=5)
models = [m["name"] for m in response.json().get("models", [])] if response.status_code == 200:
LLM_PROVIDERS["ollama"]["models"] = models models = [m["name"] for m in response.json().get("models", [])]
return models LLM_PROVIDERS["ollama"]["models"] = models
return models
except Exception as e: except Exception as e:
print(f"Erro ao buscar modelos Ollama: {e}") print(f"Erro ao buscar modelos Ollama: {e}")
return [] return []
def get_available_models(provider: str = None) -> List[Dict]: async def get_available_models(provider: str = None) -> List[Dict]:
"""Retorna modelos disponíveis para um provider ou todos.""" """Retorna modelos disponíveis para um provider ou todos (async)."""
if provider: if provider:
p = LLM_PROVIDERS.get(provider) p = LLM_PROVIDERS.get(provider)
if not p: if not p:
return [] return []
if p["type"] == "local" and provider == "ollama": if p["type"] == "local" and provider == "ollama":
models = list_ollama_models() models = await list_ollama_models()
return [{"provider": provider, "models": models}] return [{"provider": provider, "models": models}]
else: else:
return [{"provider": provider, "models": p.get("models", [p["default"]])}] return [{"provider": provider, "models": p.get("models", [p["default"]])}]
@@ -182,7 +178,7 @@ def get_available_models(provider: str = None) -> List[Dict]:
result = [] result = []
for prov_id, prov in LLM_PROVIDERS.items(): for prov_id, prov in LLM_PROVIDERS.items():
if prov_id == "ollama": if prov_id == "ollama":
models = list_ollama_models() models = await list_ollama_models()
result.append({"provider": prov_id, "name": prov["name"], "models": models}) result.append({"provider": prov_id, "name": prov["name"], "models": models})
else: else:
result.append({"provider": prov_id, "name": prov["name"], "models": prov.get("models", [prov["default"]])}) 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 return result
# ============================================================ # ============================================================
# LLM CALL FUNCTIONS # ASYNC LLM CALL FUNCTIONS
# ============================================================ # ============================================================
def call_llm(provider: str, model: str, prompt: str, system_prompt: str = None, **kwargs) -> str: async def call_llm(provider: str, model: str, prompt: str, system_prompt: str = None, **kwargs) -> str:
""" """Suporte universal async para chamadas de LLM."""
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
"""
if provider == "gemini": if provider == "gemini":
return _call_gemini(model, prompt, system_prompt) return await _call_gemini_async(model, prompt, system_prompt)
elif provider == "openai": elif provider == "openai":
return _call_openai(model, prompt, system_prompt) return await _call_openai_async(model, prompt, system_prompt)
elif provider == "anthropic": elif provider == "anthropic":
return _call_anthropic(model, prompt, system_prompt) return await _call_anthropic_async(model, prompt, system_prompt)
elif provider == "ollama": elif provider == "ollama":
return _call_ollama(model, prompt, system_prompt) return await _call_ollama_async(model, prompt, system_prompt)
else: else:
return f"Erro: Provider '{provider}' não suportado." return f"Erro: Provider '{provider}' não suportado."
# ---------------------------------------- async def _call_gemini_async(model: str, prompt: str, system_prompt: str = None) -> str:
# GEMINI """Chama API do Google Gemini via httpx (async)."""
# ----------------------------------------
def _call_gemini(model: str, prompt: str, system_prompt: str = None) -> str:
"""Chama API do Google Gemini."""
api_key = get_api_key("gemini") 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}" url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
contents = [{"parts": [{"text": prompt}]}] contents = [{"parts": [{"text": prompt}]}]
@@ -235,22 +214,17 @@ def _call_gemini(model: str, prompt: str, system_prompt: str = None) -> str:
payload = {"contents": contents} payload = {"contents": contents}
try: try:
res = requests.post(url, json=payload, timeout=60) async with httpx.AsyncClient() as client:
if res.status_code == 200: res = await client.post(url, json=payload, timeout=60)
return res.json()["candidates"][0]["content"]["parts"][0]["text"] if res.status_code == 200:
return f"Erro Gemini: {res.status_code} - {res.text}" return res.json()["candidates"][0]["content"]["parts"][0]["text"]
return f"Erro Gemini: {res.status_code} - {res.text}"
except Exception as e: except Exception as e:
return f"Erro Gemini: {str(e)}" return f"Erro Gemini: {str(e)}"
# ---------------------------------------- async def _call_openai_async(model: str, prompt: str, system_prompt: str = None) -> str:
# OPENAI """Chama API da OpenAI via httpx (async)."""
# ----------------------------------------
def _call_openai(model: str, prompt: str, system_prompt: str = None) -> str:
"""Chama API da OpenAI."""
api_key = get_api_key("openai") 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" url = f"https://api.openai.com/v1/chat/completions"
messages = [] 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": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt}) messages.append({"role": "user", "content": prompt})
payload = { payload = {"model": model, "messages": messages, "temperature": 0.7}
"model": model, headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
"messages": messages,
"temperature": 0.7
}
try: try:
res = requests.post(url, json=payload, headers={ async with httpx.AsyncClient() as client:
"Authorization": f"Bearer {api_key}", res = await client.post(url, json=payload, headers=headers, timeout=60)
"Content-Type": "application/json" if res.status_code == 200:
}, timeout=60) return res.json()["choices"][0]["message"]["content"]
return f"Erro OpenAI: {res.status_code} - {res.text}"
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: except Exception as e:
return f"Erro OpenAI: {str(e)}" return f"Erro OpenAI: {str(e)}"
# ---------------------------------------- async def _call_anthropic_async(model: str, prompt: str, system_prompt: str = None) -> str:
# ANTHROPIC """Chama API da Anthropic via httpx (async)."""
# ----------------------------------------
def _call_anthropic(model: str, prompt: str, system_prompt: str = None) -> str:
"""Chama API da Anthropic (Claude)."""
api_key = get_api_key("anthropic") api_key = get_api_key("anthropic")
if not api_key:
api_key = os.getenv("ANTHROPIC_API_KEY", "")
url = "https://api.anthropic.com/v1/messages" url = "https://api.anthropic.com/v1/messages"
headers = { headers = {
@@ -298,24 +260,40 @@ def _call_anthropic(model: str, prompt: str, system_prompt: str = None) -> str:
"max_tokens": 4096, "max_tokens": 4096,
"messages": [{"role": "user", "content": prompt}] "messages": [{"role": "user", "content": prompt}]
} }
if system_prompt: payload["system"] = system_prompt
if system_prompt:
payload["system"] = system_prompt
try: try:
res = requests.post(url, json=payload, headers=headers, timeout=60) async with httpx.AsyncClient() as client:
res = await client.post(url, json=payload, headers=headers, timeout=60)
if res.status_code == 200: if res.status_code == 200:
return res.json()["content"][0]["text"] return res.json()["content"][0]["text"]
return f"Erro Anthropic: {res.status_code} - {res.text}" return f"Erro Anthropic: {res.status_code} - {res.text}"
except Exception as e: except Exception as e:
return f"Erro Anthropic: {str(e)}" return f"Erro Anthropic: {str(e)}"
# ---------------------------------------- async def _call_ollama_async(model: str, prompt: str, system_prompt: str = None) -> str:
# OLLAMA (LOCAL) """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: 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"] endpoint = LLM_PROVIDERS["ollama"]["endpoint"]
try: try:
res = requests.get(f"{endpoint}/api/tags", timeout=10) 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", [])] models = [m.get("name") for m in res.json().get("models", [])]
return {"status": "ok", "models": models, "endpoint": endpoint} return {"status": "ok", "models": models, "endpoint": endpoint}
return {"status": "error", "code": res.status_code, "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: except Exception as e:
return {"status": "error", "message": str(e), "endpoint": endpoint} 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: def get_planner_llm() -> tuple:
"""Retorna provider e modelo do planner configurado."""
cfg = get_orchestrator_config() cfg = get_orchestrator_config()
planner = cfg.get("planner", {"provider": "gemini", "model": "gemini-2.5-flash"}) planner = cfg.get("planner", {"provider": "gemini", "model": "gemini-2.5-flash"})
return planner["provider"], planner["model"] return planner["provider"], planner["model"]
def get_executor_llm() -> tuple: def get_executor_llm() -> tuple:
"""Retorna provider e modelo do executor configurado."""
cfg = get_orchestrator_config() cfg = get_orchestrator_config()
executor = cfg.get("executor", {"provider": "ollama", "model": "llama3.2:1b"}) executor = cfg.get("executor", {"provider": "ollama", "model": "llama3.2:1b"})
return executor["provider"], executor["model"] return executor["provider"], executor["model"]
def call_planner(prompt: str, system_prompt: str = None) -> str: async def call_planner_async(prompt: str, system_prompt: str = None) -> str:
"""Chama o LLM do planner com a config atual."""
provider, model = get_planner_llm() 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: def call_executor(prompt: str, system_prompt: str = None) -> str:
"""Chama o LLM do executor com a config atual.""" return asyncio.run(call_executor_async(prompt, system_prompt))
provider, model = get_executor_llm()
return call_llm(provider, model, prompt, system_prompt)

342
main.py
View File

@@ -3,366 +3,90 @@ import psutil
import subprocess import subprocess
import time import time
import json import json
from fastapi import FastAPI, Request, Header, Depends, HTTPException, status, File, UploadFile import asyncio
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse from fastapi import FastAPI, Request, Header, Depends, HTTPException, status
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from dotenv import load_dotenv from dotenv import load_dotenv
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
import audio_handler
from ai_agent import query_agent from ai_agent import query_agent
from config import get_config, save_config from config import get_config, save_config
from credential_manager import sync_credentials, sync_from_gitea_repo 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() load_dotenv()
app = FastAPI(title="VpsTelegramBot API") app = FastAPI(title="BotVPS API")
# Configura templates HTML
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
# ============================================================ # ============================================================
# AUTO-SYNC DE CREDENCIAIS NO STARTUP # 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
# ============================================================ # ============================================================
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
print("[STARTUP] Sincronizando credenciais do repo Gitea...") print("[INIT] Sincronizando credenciais...")
sync_from_gitea_repo() sync_from_gitea_repo()
sync_credentials() sync_credentials()
print("[STARTUP] Credenciais sincronizadas com sucesso!")
# --- SEGURANÇA --- # --- SEGURANÇA ---
async def verify_password(x_web_password: str = Header(None)): async def verify_password(x_web_password: str = Header(None)):
cfg = get_config() cfg = get_config()
saved_pwd = cfg.get("web_password", "@@Gi05Br;;") if x_web_password != cfg.get("web_password", "@@Gi05Br;;"):
if not x_web_password or x_web_password != saved_pwd: raise HTTPException(status_code=401, detail="Não autorizado")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Senha Web inválida ou ausente."
)
return True return True
# --- ROTAS PÚBLICAS --- # --- WEB UI ---
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def read_root(request: Request): async def read_root(request: Request):
"""Renderiza o Dashboard Web."""
return templates.TemplateResponse("index.html", {"request": request}) 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") @app.get("/api/status")
async def get_system_status(is_auth: bool = Depends(verify_password)): async def get_system_status(is_auth: bool = Depends(verify_password)):
"""Retorna o status do sistema (CPU, RAM, Disco) sem travar o loop.""" vm = psutil.virtual_memory()
def get_stats(): return {
cpu_percent = psutil.cpu_percent(interval=0.5) "cpu": psutil.cpu_percent(),
vm = psutil.virtual_memory() "ram": {"percent": vm.percent, "used": round(vm.used / (1024**3), 2)},
disk = psutil.disk_usage('/') "disk": {"percent": psutil.disk_usage('/').percent}
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)
# --- CHAT & ORCHESTRATION ---
@app.post("/api/chat") @app.post("/api/chat")
async def web_chat(message: dict, is_auth: bool = Depends(verify_password)): 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", "") user_text = message.get("text", "")
history = message.get("history", []) # Recebe o histórico do navegador if not user_text: return {"reply": "Vazio."}
reply = query_agent(user_text)
if not user_text: return {"reply": reply}
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
@app.post("/api/orchestrate") @app.post("/api/orchestrate")
async def orchestrate_task(task_data: dict, is_auth: bool = Depends(verify_password)): 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", "") task = task_data.get("task", "")
confirmed = task_data.get("confirmed", False) confirmed = task_data.get("confirmed", False)
if not task: result = await orchestrate_async(task, user_confirmed=confirmed)
return JSONResponse(content={"status": "error", "message": "Task vazia"}, status_code=400)
result = orchestrate(task, user_confirmed=confirmed)
# Formata mensagem para display
if result["status"] == "needs_confirmation": if result["status"] == "needs_confirmation":
message = format_confirmation_message(result) return {
return JSONResponse(content={
"status": "needs_confirmation", "status": "needs_confirmation",
"plan": result["plan"], "plan": result["plan"],
"confirmation_needed_for": result["confirmation_needed_for"], "message": format_confirmation_message(result)
"message": message }
})
return JSONResponse(content={ return {
"status": "completed", "status": "completed",
"plan": result["plan"],
"results": result.get("results", []), "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") @app.get("/api/orchestrator-status")
async def get_orch_status(is_auth: bool = Depends(verify_password)): async def get_orch_status(is_auth: bool = Depends(verify_password)):
"""Retorna status do orquestrador.""" return get_orchestrator_status()
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})
# --- SERVER ---
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

View File

@@ -1,14 +1,14 @@
# ============================================================ # ============================================================
# ORCHESTRATOR.PY - Orquestrador de Tarefas # ORCHESTRATOR.PY - Orquestrador de Tarefas (Refatorado)
# Planner (Gemini/OpenAI/Claude/Ollama) + Executor (Qwen/Ollama)
# ============================================================ # ============================================================
import json import json
import re import re
import os import os
import asyncio
from typing import Dict, List, Optional from typing import Dict, List, Optional
from llm_providers import ( 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 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 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: ### REGRAS CRÍTICAS DE COMANDOS:
1. USE SEMPRE "docker compose" (COM ESPAÇO), NUNCA "docker-compose" (COM HÍFEN) 1. USE SEMPRE "docker compose" (COM ESPAÇO), NUNCA "docker-compose" (COM HÍFEN)
2. O BotVPS está em /app (dentro do container) 2. Use "cd /app && git pull" para atualizar
3. Use "cd /app && git pull" para atualizar 3. Use "cd /app && docker compose up -d --build" para rebuild e deploy
4. Use "cd /app && docker compose up -d --build" para rebuild e deploy
### EXEMPLOS DE COMANDOS CORRETOS: ### EXEMPLOS DE COMANDOS CORRETOS:
✅ CORRETO: cd /app && git pull origin master ✅ CORRETO: cd /app && git pull origin master
✅ CORRETO: cd /app && docker compose up -d --build ✅ 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: ### FORMATO DE RESPOSTA:
Responda APENAS com JSON válido: Responda APENAS com JSON:
{{ {{
"task_name": "Nome resumido", "task_name": "Nome resumido",
"summary": "Resumo do que será feito", "summary": "Resumo do que será feito",
"steps": [ "steps": [
{{ {{
"order": 1, "order": 1,
"action": "Descrição clara", "action": "Descrição",
"tool": "bash", "tool": "bash",
"command": "COMANDO LINUX COMPLETO E CORRETO", "command": "COMANDO LINUX COMPLETO",
"danger": "safe|medium|dangerous" "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. EXECUTOR_SYSTEM_PROMPT = """Você é o EXECUTOR AGENT do BotVPS.
Seu trabalho é executar comandos bash com precisão. Retorne JSON: {"success": true|false, "output": "resultado"}
### 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
""" """
# ============================================================ # ============================================================
@@ -93,569 +58,194 @@ Responda com JSON:
# ============================================================ # ============================================================
def _format_tools_for_prompt() -> str: def _format_tools_for_prompt() -> str:
"""Formata lista de ferramentas para o prompt.""" return "\n".join([f"- {name}: {info['desc']} [{info['danger']}]" for name, info in TOOLS_V2.items()])
lines = []
for name, info in TOOLS_V2.items():
lines.append(f"- {name}: {info['desc']} [{info['danger']}]")
return "\n".join(lines)
def detect_git_repo_path(task: str) -> str: async def detect_git_repo_path_async(task: str) -> str:
""" """Detecta automaticamente o caminho do repositório Git (async)."""
Detecta automaticamente o caminho do repositório Git baseado na tarefa.
Retorna o caminho do repositório mais provável.
"""
from tools_v2 import run_bash from tools_v2 import run_bash
# Normaliza o texto da tarefa
task_lower = task.lower() task_lower = task.lower()
# Caminhos específicos por nome de app # Mapeamento de APPs conhecidos
app_paths = { app_map = {
"tracksteel": [ "tracksteel": "/data/repositories/admtracksteel/AdmTrackSteel",
"/data/repositories/0/5/5adtracksteel/AdmTrackSteel", "botvps": "/app",
"/data/repositories/admtracksteel/AdmTrackSteel", "coolify": "/data/coolify/source",
], "antigravity": "/app"
"botvps": [
"/data/repositories/admtracksteel/BotVPS",
"/data/repositories/botvps",
"/app",
],
"coolify": [
"/data/coolify",
"/data/coolify/source",
]
} }
# Detecta qual app o usuário quer for key, path in app_map.items():
if "botvps" in task_lower or "bot vps" in task_lower or "antigravity" in task_lower: if key in task_lower:
paths_to_try = app_paths["botvps"] return path
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 # Busca dinâmica rápida
for repo_path in paths_to_try: result = run_bash("find /data/repositories -name '.git' -type d -maxdepth 3 | head -1")
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")
if result.get("success") and result.get("output"): if result.get("success") and result.get("output"):
lines = result["output"].strip().split("\n") return result["output"].replace("/.git", "").strip()
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 return "/app" if os.path.exists("/app/.git") else "/"
if os.path.exists("/app/.git"):
print(f"[DETECT] Using fallback: /app")
return "/app"
print(f"[DETECT] No repo found, returning /") async def _parse_json_response(text: str) -> Optional[Dict]:
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()
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 ""
def _parse_json_response(text: str) -> Optional[Dict]:
"""Extrai JSON da resposta do LLM."""
# Tenta encontrar JSON no texto
json_match = re.search(r'\{[\s\S]*\}', text) json_match = re.search(r'\{[\s\S]*\}', text)
if json_match: if json_match:
try: try:
return json.loads(json_match.group()) return json.loads(json_match.group())
except json.JSONDecodeError: except:
pass 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 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: async def plan_task_async(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}
]
}
"""
provider, model = get_planner_llm() 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 context_info = f"### CONTEXTO: Repo em {repo_path}, Bot em /app"
detected_repo = detect_git_repo_path(task) system_prompt = PLANNER_SYSTEM_PROMPT.format(
detected_app = detect_app_in_docker(task) CONTEXT_INFO=context_info,
TOOLS_LIST=_format_tools_for_prompt()
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
) )
result = _parse_json_response(response) response = await call_planner_async(task, system_prompt)
if result: plan = await _parse_json_response(response)
return result
return {"success": False, "output": response} if not plan:
return {
def execute_step(step: Dict) -> Dict: "task_name": "Comando Direto",
""" "summary": f"Executando: {task}",
Executa um passo do plano. "steps": [{"order": 1, "action": task, "tool": "bash", "command": task, "danger": "medium"}]
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")
} }
""" return plan
print(f"\n{'='*50}")
print(f">>> PLANNING: {task}")
print(f"{'='*50}\n")
# 1. Plana a tarefa async def execute_command_async(command: str) -> Dict:
plan = plan_task(task) # Moderniza comando se necessário
command = command.replace("docker-compose", "docker compose")
# 2. Identifica passos perigosos # Comandos simples: execução direta (segurança e velocidade)
dangerous_steps = _classify_dangerous_steps(plan.get("steps", [])) 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()
}
# 3. Se há passos perigosos e não confirmou, pede confirmação # Comandos complexos: usa Executor LLM para validar/executar
if dangerous_steps and not user_confirmed: 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", "")
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 { return {
"status": "needs_confirmation", "status": "needs_confirmation",
"plan": plan, "plan": plan,
"confirmation_needed_for": [ "confirmation_needed_for": dangerous
{"order": s["order"], "action": s["action"], "danger": s["danger"]}
for s in dangerous_steps
]
} }
# 4. Executa todos os passos
results = [] results = []
for step in plan.get("steps", []): for step in plan.get("steps", []):
result = execute_step(step) res = await execute_step_async(step)
results.append(result) results.append(res)
if not res["success"] and step.get("danger") == "dangerous":
# 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
})
break break
# 5. Retorna resultado return {"status": "completed", "plan": plan, "results": results}
return {
"status": "completed", # --- SYNC WRAPPERS PARA COMPATIBILIDADE ---
"plan": plan, def orchestrate(task: str, user_confirmed: bool = False) -> Dict:
"results": results 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()
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: 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"] plan = result["plan"]
dangerous = result["confirmation_needed_for"] msg = f"⚠️ **Confirmação Necessária**: {plan['task_name']}\n\n"
for s in result["confirmation_needed_for"]:
msg = f"[PLANO] {plan.get('task_name', 'Tarefa')}\n\n" msg += f"• Passo {s['order']}: {s['action']} ({s['danger'].upper()})\n"
msg += f"{plan.get('summary', '')}\n\n" msg += "\nDigite 'sim' para autorizar."
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)"
return msg return msg
def format_completion_message(result: Dict) -> str: 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"] plan = result["plan"]
results = result.get("results", []) results = result.get("results", [])
plan_steps = plan.get("steps", []) success = all(r.get("success", False) for r in results)
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"
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 return msg
# ============================================================
# STATUS & CONFIG FUNCTIONS
# ============================================================
def get_orchestrator_status() -> Dict: def get_orchestrator_status() -> Dict:
"""Retorna status atual do orquestrador.""" p_p, p_m = get_planner_llm()
planner_provider, planner_model = get_planner_llm() e_p, e_m = get_executor_llm()
executor_provider, executor_model = get_executor_llm()
return { return {
"planner": { "planner": {"provider": p_p, "model": p_m},
"provider": planner_provider, "executor": {"provider": e_p, "model": e_m},
"model": planner_model, "tools_count": len(TOOLS_V2)
"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)
} }
def get_llm_config() -> Dict: def get_llm_config() -> Dict:
"""Retorna configuração de LLMs.""" p_p, p_m = get_planner_llm()
planner_provider, planner_model = get_planner_llm() e_p, e_m = get_executor_llm()
executor_provider, executor_model = get_executor_llm()
return { return {
"planner": { "planner": {"provider": p_p, "model": p_m, "available": list(LLM_PROVIDERS.keys())},
"provider": planner_provider, "executor": {"provider": e_p, "model": e_m, "available": list(LLM_PROVIDERS.keys())}
"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()
]
}
} }
def set_llm_config(planner_provider: str = None, planner_model: str = None, def set_llm_config(planner_provider=None, planner_model=None, executor_provider=None, executor_model=None):
executor_provider: str = None, executor_model: str = None) -> Dict: if planner_provider: set_planner(planner_provider, planner_model)
"""Atualiza configuração de LLMs.""" if executor_provider: set_executor(executor_provider, executor_model)
changes = {} return {"status": "updated"}
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 <provider> ou /llm executor <provider>"
return msg
if parsed["action"] == "sync_credentials":
result = sync_credentials()
return f"[SYNC] Credenciais sincronizadas: {result['status']}"
return "[CONFIG] Use: /llm (mostrar) | /llm planner <provider> | /llm executor <provider>"
# 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."

View File

@@ -14,3 +14,4 @@ jinja2
gTTS gTTS
anthropic anthropic
elevenlabs elevenlabs
httpx

View File

@@ -1,664 +1,110 @@
# ============================================================
# TOOLS_V2.PY - Ferramentas Expandidas para o Orquestrador
# NÃO SUBSTITUI tools.py - É um módulo adicional
# ============================================================
import subprocess import subprocess
import os import os
import requests import httpx
import asyncio
from typing import Dict, List, Optional from typing import Dict, List, Optional
from credential_manager import ( from credential_manager import (
gitea_api_url, gitea_token, supabase_url, supabase_anon_key, gitea_api_url, gitea_token, supabase_url, supabase_anon_key,
supabase_service_role_key, coolify_api supabase_service_role_key
) )
# ============================================================ # ============================================================
# CONSTANTS # UTILS
# ============================================================
DANGER_LEVELS = {
"safe": "SAFE - Executa automático",
"medium": "MEDIUM - Informa antes",
"dangerous": "DANGEROUS - Pede confirmação"
}
# ============================================================
# UTILITY FUNCTIONS
# ============================================================ # ============================================================
def run_bash(command: str, timeout: int = 120) -> Dict: 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: try:
result = subprocess.run( result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=timeout)
command,
shell=True,
capture_output=True,
text=True,
timeout=timeout
)
return { return {
"success": result.returncode == 0, "success": result.returncode == 0,
"returncode": result.returncode, "output": (result.stdout or result.stderr).strip() or "Sucesso"
"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)"
} }
except Exception as e: except Exception as e:
return { return {"success": False, "output": str(e)}
"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
# ============================================================ # ============================================================
# DOCKER TOOLS # DOCKER TOOLS
# ============================================================ # ============================================================
class DockerTools: class DockerTools:
"""Ferramentas Docker."""
@staticmethod @staticmethod
def ps(all_containers: bool = False) -> str: def ps() -> str:
"""Lista containers Docker.""" return run_bash("docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'")["output"]
flags = "-a" if all_containers else ""
result = run_bash("docker ps " + flags + " --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'")
return format_output(result)
@staticmethod @staticmethod
def stats() -> str: def stats() -> str:
"""Mostra estatísticas de recursos dos containers.""" return run_bash("docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}'")["output"]
result = run_bash("docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}'")
return format_output(result)
@staticmethod @staticmethod
def logs(container: str, lines: int = 50, follow: bool = False) -> str: def logs(container: str, lines: int = 50) -> str:
"""Mostra logs de um container.""" return run_bash(f"docker logs --tail {lines} {container}")["output"]
follow_flag = "-f" if follow else ""
result = run_bash(f"docker logs {follow_flag} --tail {lines} {container}")
return format_output(result, max_length=5000)
@staticmethod @staticmethod
def restart(container: str) -> str: def restart(container: str) -> str:
"""Reinicia um container.""" return run_bash(f"docker restart {container}")["output"]
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)
# ============================================================ # ============================================================
# GIT TOOLS # GIT TOOLS
# ============================================================ # ============================================================
class GitTools: class GitTools:
"""Ferramentas Git.""" @staticmethod
def pull(repo_path: str = ".") -> str:
return run_bash(f"git -C {repo_path} pull")["output"]
@staticmethod @staticmethod
def status(repo_path: str = ".") -> str: def status(repo_path: str = ".") -> str:
"""Mostra status do repositório git.""" return run_bash(f"git -C {repo_path} status --short")["output"]
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)
# ============================================================ # ============================================================
# DOCKER COMPOSE TOOLS # API TOOLS (ASYNC)
# ============================================================
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
# ============================================================ # ============================================================
class GiteaTools: class GiteaTools:
"""Ferramentas via API do Gitea."""
@staticmethod @staticmethod
def _get_headers() -> Dict: async def list_repos() -> str:
"""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."""
url = f"{gitea_api_url()}/user/repos" url = f"{gitea_api_url()}/user/repos"
try: headers = {"Authorization": f"token {gitea_token()}"}
res = requests.get(url, headers=GiteaTools._get_headers(), timeout=10) async with httpx.AsyncClient() as client:
if res.status_code == 200: try:
res = await client.get(url, headers=headers)
repos = res.json() repos = res.json()
if not repos: return "\n".join([f"{r['name']}" for r in repos[:10]])
return "Nenhum repositório encontrado." except Exception as e: return f"Erro Gitea: {e}"
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
# ============================================================
class SupabaseTools: class SupabaseTools:
"""Ferramentas via API REST do Supabase."""
@staticmethod @staticmethod
def _get_headers(anon_key: bool = True) -> Dict: async def list_tables() -> str:
"""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)."""
url = f"{supabase_url()}/rest/v1/" url = f"{supabase_url()}/rest/v1/"
try: headers = {"apikey": supabase_anon_key(), "Authorization": f"Bearer {supabase_anon_key()}"}
res = requests.get(url, headers=SupabaseTools._get_headers(), timeout=10) async with httpx.AsyncClient() as client:
if res.status_code == 200: try:
tables = res.json() res = await client.get(url, headers=headers)
if not tables: return str(res.json())
return "Nenhuma tabela encontrada." except Exception as e: return f"Erro Supabase: {e}"
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)}"
# ============================================================ # ============================================================
# COOLIFY API TOOLS # REGISTRY
# ============================================================
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
# ============================================================ # ============================================================
TOOLS_V2 = { TOOLS_V2 = {
# DOCKER "docker_ps": {"desc": "Lista containers", "func": DockerTools.ps, "danger": "safe"},
"docker_ps": {"desc": "Lista containers Docker", "func": DockerTools.ps, "danger": "safe"}, "docker_stats": {"desc": "Uso de recursos", "func": DockerTools.stats, "danger": "safe"},
"docker_stats": {"desc": "Estatísticas de containers", "func": DockerTools.stats, "danger": "safe"}, "docker_logs": {"desc": "Ver logs", "func": DockerTools.logs, "danger": "safe"},
"docker_logs": {"desc": "Logs de container (use: docker_logs <nome> <linhas>)", "func": lambda n="app", l=50: DockerTools.log(n, int(l)), "danger": "safe"}, "docker_restart": {"desc": "Reiniciar container", "func": DockerTools.restart, "danger": "dangerous"},
"docker_restart": {"desc": "Reinicia container (use: docker_restart <nome>)", "func": DockerTools.restart, "danger": "dangerous"}, "git_pull": {"desc": "Atualizar código", "func": GitTools.pull, "danger": "medium"},
"docker_stop": {"desc": "Para container", "func": DockerTools.stop, "danger": "dangerous"}, "git_status": {"desc": "Ver status git", "func": GitTools.status, "danger": "safe"},
"docker_start": {"desc": "Inicia container", "func": DockerTools.start, "danger": "medium"}, "gitea_repos": {"desc": "Listar repos no Gitea", "func": GiteaTools.list_repos, "danger": "safe"},
"docker_exec": {"desc": "Executa comando no container", "func": DockerTools.exec, "danger": "dangerous"}, "supabase_tables": {"desc": "Listar tabelas Supabase", "func": SupabaseTools.list_tables, "danger": "safe"},
"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 <path>)", "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 <owner/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"},
} }
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: def get_all_tools_formatted() -> str:
"""Retorna lista formatada de todas as ferramentas.""" res = "🛠️ **Ferramentas Disponíveis**:\n\n"
output = "[TOOLS] Ferramentas Disponiveis:\n\n" for name, info in TOOLS_V2.items():
res += f"- `{name}`: {info['desc']} [{info['danger'].upper()}]\n"
return res
for level in ["safe", "medium", "dangerous"]: def get_tools_by_danger(level: str) -> List:
tools = get_tools_by_danger(level) return [{"name": k, **v} for k, v in TOOLS_V2.items() if v["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