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 re
import requests
import httpx
import asyncio
import json
from tools import AVAILABLE_TOOLS
from config import get_config
def get_llm_response(prompt: str, provider: str, cfg: dict) -> str:
"""Invoca o provedor de LLM configurado."""
async def get_llm_response_async(prompt: str, provider: str, cfg: dict) -> str:
"""Invoca o provedor de LLM configurado (async)."""
async with httpx.AsyncClient(timeout=60) as client:
if provider == "gemini":
api_key = cfg.get("gemini_api_key") or os.getenv("GEMINI_API_KEY")
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={api_key}"
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={api_key}"
payload = {"contents": [{"parts": [{"text": prompt}]}]}
try:
res = requests.post(url, json=payload, timeout=30)
res = await client.post(url, json=payload)
if res.status_code == 200:
data = res.json()
if "candidates" in data and len(data["candidates"]) > 0:
return data["candidates"][0]["content"]["parts"][0]["text"]
return f"Erro Gemini (Dados Vazios): {res.text}"
return f"Erro Gemini (Status {res.status_code}): {res.text}"
except Exception as e:
return f"Erro de Conexão Gemini: {str(e)}"
return f"Erro Gemini: {res.text}"
except Exception as e: return f"Erro Gemini: {e}"
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")
try:
res = requests.post(f"{ollama_host}/api/generate", json={
"model": model,
"prompt": prompt,
"stream": False,
"options": {"num_ctx": 4096}
}, timeout=180)
if res.status_code == 200:
res = await client.post(f"{host}/api/generate", json={
"model": model, "prompt": prompt, "stream": False
})
return res.json().get("response", "")
return f"Erro Ollama (Status {res.status_code}): {res.text}"
except Exception as e:
return f"Erro de Conexão Ollama: {str(e)}"
except Exception as e: return f"Erro Ollama: {e}"
return "Provedor desconhecido."
def query_agent(prompt: str, override_provider: str = None, chat_history: list = None) -> str:
"""
Motor Agente em Loop (ReAct): Pensamento -> Ação -> Observação -> Resposta Final.
"""
def query_agent(prompt: str, override_provider=None, chat_history=None) -> str:
"""Wrapper síncrono para query_agent_async."""
return asyncio.run(query_agent_async(prompt, override_provider, chat_history))
async def query_agent_async(prompt: str, override_provider=None, chat_history=None) -> str:
cfg = get_config()
provider = override_provider or cfg.get("active_provider", "gemini")
tools_desc = "\n".join([f"- {k}: {v['description']}" for k, v in AVAILABLE_TOOLS.items()])
# Contexto de Ferramentas para a IA
tools_desc = "\n".join([f"- {k}: {v['description']}" for k,v in AVAILABLE_TOOLS.items()])
system_prompt = f"""Você é o Antigravity VPS Agent. Root Admin da VPS do Marcos.
Responda em PORTUGUÊS. Seja técnico e direto.
# Prompt especializado reformulado para evitar alucinações
system_prompt_base = """Você é o [Antigravity VPS Agent], o SysAdmin de elite do Marcos.
Você tem acesso root completo à VPS e deve agir de forma profissional e precisa.
### FERRAMENTAS:
{tools_desc}
### REGRAS DE OURO:
1. Responda em PORTUGUÊS (Brasil).
2. Se o usuário pedir o status da VPS, SEMPRE use a ferramenta 'get_system_health'.
3. Se o usuário pedir algo sobre containers, use 'get_docker_stats'.
4. Antes de decidir que um arquivo não existe, use 'run_bash_command' com 'ls' para verificar o diretório.
5. NUCA invente que buscou por arquivos (como syslog.conf) se o usuário não pediu especificamente por eles.
6. A seção <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>
### FORMATO:
Use [TOOL:nome] arg [/TOOL] para ações.
Finalize com <REFINED> resumo </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 = ""
if chat_history:
for msg in chat_history[-5:]: # Pega as últimas 5 interações
history_str += f"\nUsuário: {msg['user']}\nAgente: {msg['bot']}\n"
for m in chat_history[-5:]:
history_str += f"\nUsuário: {m['user']}\nAgente: {m['bot']}\n"
history_str += f"\nUsuário: {prompt}\n"
current_iteration_history = history_str
max_loops = 12
print(f"--- INICIANDO AGENTE ({provider}) ---")
for i in range(max_loops):
import time
time.sleep(0.5) # Respiro para a CPU
print(f"\n[LOOP {i+1}/{max_loops}]")
full_prompt = system_prompt + current_iteration_history
response = get_llm_response(full_prompt, provider, cfg)
print(f"PENSAMENTO:\n{response}")
# Procura por chamadas de ferramentas na resposta
match = re.search(r"\[TOOL:(.*?)\](.*?)\[/TOOL\]", response, re.IGNORECASE | re.DOTALL)
current_history = history_str
for i in range(10):
response = await get_llm_response_async(system_prompt + current_history, provider, cfg)
match = re.search(r"\[TOOL:(.*?)\](.*?)\[/TOOL\]", response, re.I | re.S)
if match:
tool_name = match.group(1).strip()
arg = match.group(2).strip()
print(f"EXECUTANDO: {tool_name} | ARGS: {arg}")
if tool_name in AVAILABLE_TOOLS:
func = AVAILABLE_TOOLS[tool_name]["func"]
# Caso a ferramenta não aceite argumentos (ex: get_system_health)
if tool_name in ["get_system_health", "get_docker_stats"]:
observation = func()
t_name, arg = match.group(1).strip(), match.group(2).strip()
if t_name in AVAILABLE_TOOLS:
func = AVAILABLE_TOOLS[t_name]["func"]
# Assume ferramentas são síncronas em tools.py (legado)
obs = func(arg) if arg else func()
current_history += f"\nAgente: {response}\nSISTEMA ({t_name}): {obs}\n"
else:
observation = func(arg)
print(f"OBSERVAÇÃO (suprimida): {str(observation)[:200]}...")
current_history += f"\nAgente: {response}\nSISTEMA: Erro: Ferramenta inexistente.\n"
else:
observation = f"Erro: Ferramenta '{tool_name}' não encontrada."
print(f"ERRO: {observation}")
# Adiciona ao histórico do loop atual
current_iteration_history += f"\nAgente (Ação): {response}\nSISTEMA (Saída de {tool_name}): {observation}\n"
else:
# Se não tem comando, é a resposta final
print("--- RESPOSTA FINAL ENCONTRADA ---")
return response
print("!!! ERRO: LIMITE DE TENTATIVAS ATINGIDO !!!")
return "O agente atingiu o limite de tentativas para esta tarefa."
return "Limite de pensamento atingido."

View File

@@ -1,323 +1,99 @@
import os
import requests
import asyncio
from dotenv import load_dotenv
import re
from telegram import Update
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
from orchestrator import (
handle_message_async, orchestrate_async, format_confirmation_message,
format_completion_message, execute_step_async
)
from ai_agent import query_agent
import speech_recognition as sr
from pydub import AudioSegment
from gtts import gTTS
from orchestrator import handle_message, orchestrate, format_confirmation_message, format_completion_message
load_dotenv()
TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
ALLOWED_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID")
ELEVENLABS_KEY = os.getenv("ELEVENLABS_API_KEY")
VOICE_ID = os.getenv("ELEVENLABS_VOICE_ID")
def synthesize_audio(text: str) -> str:
"""Gera áudio local/gratuito usando gTTS e retorna o arquivo."""
try:
# Remove caracteres indesejados e emojis que atrapalham a fala
texto_limpo = text.replace("🤖", "").replace("🧑‍🏫", "").replace("*", "").replace("`", "")
texto_limpo = re.sub(r'[*`#]', '', text)
filepath = "/tmp/reply_audio.mp3"
tts = gTTS(text=texto_limpo, lang='pt-br', tld='com.br', slow=False)
tts = gTTS(text=texto_limpo[:500], lang='pt-br', slow=False)
tts.save(filepath)
return filepath
except Exception as e:
print(f"Erro ao gerar voz tts: {e}")
print(f"TTS Error: {e}")
return ""
async def auth_check(update: Update) -> bool:
"""Verifica se o usuário que enviou a mensagem é o Marcos (Chat ID autorizado)."""
if str(update.message.chat_id) != ALLOWED_CHAT_ID:
await update.message.reply_text("Acesso negado. Você não tem permissão para controlar esta VPS.")
if not update.message or str(update.message.chat_id) != ALLOWED_CHAT_ID:
if update.message: await update.message.reply_text("Acesso negado.")
return False
return True
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not await auth_check(update): return
await update.message.reply_text("👋 Olá, Marcos! Antigravity VPS Agent online e pronto para receber comandos.")
# Memória persistente da conversa (em memória RAM)
chat_histories = {}
async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not await auth_check(update): return
chat_id = update.message.chat_id
user_msg = update.message.text
chat_id = update.message.chat_id
await update.message.reply_chat_action(action="typing")
# =====================================================
# COMANDOS DO ORCHESTRATOR
# =====================================================
if user_msg.startswith('/orchestrate'):
# Força uso do orchestrator
task = user_msg.replace('/orchestrate', '').strip()
result = orchestrate(task, user_confirmed=False)
if result["status"] == "needs_confirmation":
reply = format_confirmation_message(result)
else:
reply = format_completion_message(result)
# 1. COMANDOS DIRETOS
if user_msg.startswith('/') and user_msg.split()[0] in ['/status', '/tools', '/sync']:
reply = await handle_message_async(user_msg)
await update.message.reply_text(reply)
return
if user_msg.startswith('/status') and not user_msg.startswith('/statusall'):
# Status do orchestrator
reply = handle_message('/status')
await update.message.reply_text(reply)
return
if user_msg.startswith('/tools'):
# Lista de ferramentas
reply = handle_message('/tools')
await update.message.reply_text(reply)
return
if user_msg.startswith('/sync'):
# Sync de credenciais
reply = handle_message('/sync')
await update.message.reply_text(reply)
return
if user_msg.lower() in ['sim', 'confirmar', 'confirma', 'sim!', 'confirma!', 's']:
# Verifica se há confirmação pendente (usa chat_id do update atual)
if 'pending_plan' in context.bot_data:
plan = context.bot_data['pending_plan']
del context.bot_data['pending_plan']
# Executa o plano
from orchestrator import execute_step
# 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 = []
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)
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
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
# 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')
# =====================================================
# 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 is_task:
task = user_msg.replace('/orchestrate', '').strip()
result = await orchestrate_async(task)
if result["status"] == "needs_confirmation":
# Salva plano pendente no context (persistente entre mensagens)
context.bot_data['pending_plan'] = result["plan"]
print(f"[ORCH] Plano salvo. Steps: {len(result['plan'].get('steps', []))}")
reply = format_confirmation_message(result)
reply += "\n\nResponda *sim* para confirmar ou *nao* para cancelar."
else:
reply = format_completion_message(result)
await update.message.reply_text(reply)
return
# =====================================================
# FALLBACK: Usa o agente normal (ai_agent.py)
# =====================================================
history = chat_histories.get(chat_id, [])
else:
# Fallback AI Agent
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}")
reply = query_agent(user_msg, override_provider=cfg.get("active_provider"))
await update.message.reply_text(reply)
async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not await auth_check(update): return
await update.message.reply_chat_action(action="record_voice")
# Baixa o aúdio do telegram
voice_file = await update.message.voice.get_file()
ogg_path = "/tmp/voice.ogg"
wav_path = "/tmp/voice.wav"
await voice_file.download_to_drive(ogg_path)
# Converte para WAV (Requer ffmpeg instalado na maquina)
try:
audio = AudioSegment.from_ogg(ogg_path)
audio.export(wav_path, format="wav")
except Exception as e:
await update.message.reply_text(f"Erro ao processar áudio (O ffmpeg está instalado na VPS?): {e}")
return
# Usando SpeechRecognition nativo para transcrever (pode usar Whisper no Ollama depois)
recognizer = sr.Recognizer()
with sr.AudioFile(wav_path) as source:
audio_data = recognizer.record(source)
try:
text = recognizer.recognize_google(audio_data, language="pt-BR")
await update.message.reply_text(f"🗣️ Reconhecido: _{text}_", parse_mode="Markdown")
# Busca histórico anterior
chat_id = update.message.chat_id
history = chat_histories.get(chat_id, [])
# Envia o texto reconhecido para o Agente (respeitando a configuração ativa)
from config import get_config
cfg = get_config()
reply = query_agent(prompt=text, override_provider=cfg.get("active_provider"), chat_history=history)
# Atualiza histórico
history.append({"user": text, "bot": reply})
chat_histories[chat_id] = history[-10:]
# Sintetiza com ElevenLabs e responde com Áudio
audio_path = synthesize_audio(reply)
if audio_path:
await update.message.reply_voice(voice=open(audio_path, 'rb'))
else:
await update.message.reply_text(reply)
except sr.UnknownValueError:
await update.message.reply_text("Não consegui entender o que foi dito no áudio.")
except sr.RequestError as e:
await update.message.reply_text(f"Erro no serviço de STT: {e}")
async def llm_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not await auth_check(update): return
args = context.args
from config import get_config, save_config
cfg = get_config()
current = cfg.get('active_provider', 'ollama').upper()
if not args:
# Sem argumentos: mostra status atual
ollama_status = ""
if current == "OLLAMA":
try:
from llm_providers import check_ollama_connection
status = check_ollama_connection()
if status.get("status") == "ok":
models = status.get("models", [])
ollama_status = f"\n\n🔷 Ollama: ✅ Online\n Modelos: {', '.join(models[:3]) if models else 'Nenhum'}"
elif status.get("status") == "timeout":
ollama_status = "\n\n🔷 Ollama: ⏱️ Timeout - não respondeu"
else:
ollama_status = f"\n\n🔷 Ollama: ❌ {status.get('status', 'Erro desconhecido')}"
except Exception as e:
ollama_status = f"\n\n🔷 Ollama: ❌ Erro ao verificar"
await update.message.reply_text(
f"🤖 LLM Atual: *{current}*{ollama_status}\n\n"
f"Para mudar: /llm gemini ou /llm ollama"
)
return
new_model = args[0].lower()
if new_model in ["gemini", "ollama"]:
cfg["active_provider"] = new_model
save_config(cfg)
await update.message.reply_text(f"✅ LLM alterado para: *{new_model.upper()}*")
else:
await update.message.reply_text(
f"❌ Modelo inválido: {new_model}\n\n"
f"Disponíveis: gemini, ollama\n"
f"LLM Atual: *{current}*"
)
async def clear_history(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not await auth_check(update): return
chat_id = update.message.chat_id
if chat_id in chat_histories:
chat_histories[chat_id] = []
await update.message.reply_text("🧹 Memória limpa com sucesso!")
else:
await update.message.reply_text("A memória já está vazia.")
await update.message.reply_text("Processando aúdio...")
await update.message.reply_text("Comando de voz recebido (STT não configurado neste passo).")
def get_telegram_app():
if not TOKEN:
raise ValueError("TELEGRAM_BOT_TOKEN não encontrado no .env")
print("AVISO: TELEGRAM_BOT_TOKEN não encontrado.")
app = Application.builder().token(TOKEN).build()
app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("llm", llm_command))
app.add_handler(CommandHandler("limpar", clear_history))
app.add_handler(CommandHandler("status", lambda u, c: handle_text(u, c))) # Alias para status
app.add_handler(CommandHandler("tools", lambda u, c: handle_text(u, c))) # Alias para tools
app.add_handler(CommandHandler("sync", lambda u, c: handle_text(u, c))) # Alias para sync
app.add_handler(CommandHandler("orchestrate", lambda u, c: handle_text(u, c))) # Orchestrate
app.add_handler(CommandHandler("start", lambda u, c: u.message.reply_text("Online!")))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text))
app.add_handler(MessageHandler(filters.COMMAND, handle_text))
app.add_handler(MessageHandler(filters.VOICE, handle_voice))
return app
# Para testes rápidos se rodado standalone
if __name__ == "__main__":
print("--- INICIANDO BOT TELEGRAM (POLLING) ---")
print("Limpando Webhooks e mensagens pendentes para evitar CONFLITOS...")
app = get_telegram_app()
# drop_pending_updates=True limpa a fila e desativa webhooks automaticamente
print("Bot iniciando...")
app.run_polling(drop_pending_updates=True)

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 re
import json
import configparser
import time
import requests
import httpx
import asyncio
from typing import Optional, Dict
# ============================================================
# CAMINHO DO ARQUIVO DE SEGREDOS (FALLBACK)
# CONFIGURATIONS & PATHS
# ============================================================
SEGREDOS_PATH = "/data/segredos.md"
BOTVPS_HOST_PATH = "/app"
CACHE_TTL = 300 # 5 minutos
GITEA_API_URL = "https://git.reifonas.cloud/api/v1"
# ============================================================
# GITEA REPO CREDENTIALS (FONTE PRINCIPAL)
# ============================================================
GITEA_CREDS_REPO = "admtracksteel/Keys"
GITEA_CREDS_FILE = "credentials.json"
_gitea_creds_cache: Dict[str, str] = {}
# CACHES
_gitea_creds_cache: Dict[str, Dict] = {}
_gitea_creds_cache_time: float = 0
_local_cache: Dict[str, str] = {}
_local_cache_time: Dict[str, float] = {}
def get_gitea_creds_url() -> str:
"""Retorna URL da API do Gitea."""
return "https://git.reifonas.cloud/api/v1"
# ============================================================
# GITEA CORE (FONTE PRINCIPAL)
# ============================================================
def fetch_from_gitea_repo(force: bool = False) -> Dict[str, Dict[str, str]]:
"""
Busca credenciais do repo Gitea admtracksteel/Keys.
Faz cache com TTL de 5 minutos.
"""
async def fetch_from_gitea_repo_async(force: bool = False) -> Dict:
global _gitea_creds_cache, _gitea_creds_cache_time
# Verifica cache
if not force and time.time() - _gitea_creds_cache_time < CACHE_TTL and _gitea_creds_cache:
return _gitea_creds_cache
try:
# Obtém token do Gitea
from credential_manager import gitea_token
token = gitea_token()
# Busca arquivo no repo
url = f"{get_gitea_creds_url()}/repos/admtracksteel/Keys/contents/{GITEA_CREDS_FILE}"
url = f"{GITEA_API_URL}/repos/admtracksteel/Keys/contents/credentials.json"
headers = {"Authorization": f"token {token}"} if token else {}
response = requests.get(url, headers=headers, timeout=30)
if response.status_code == 200:
data = response.json()
# Conteúdo está em base64
async with httpx.AsyncClient() as client:
res = await client.get(url, headers=headers, timeout=20)
if res.status_code == 200:
import base64
content_b64 = data.get("content", "").replace("\n", "")
content = base64.b64decode(content_b64).decode("utf-8")
_gitea_creds_cache = json.loads(content)
content_b64 = res.json().get("content", "").replace("\n", "")
_gitea_creds_cache = json.loads(base64.b64decode(content_b64).decode())
_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:
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 get_gitea_cred(service: str, key: str, force: bool = False) -> Optional[str]:
"""Busca credencial específica do repo Gitea."""
creds = fetch_from_gitea_repo(force)
return creds.get(service, {}).get(key)
def gitea_token() -> str:
# Ordem de prioridade: Gitea INI -> segredos.md -> Env
token = get_credential("gitea", "INTERNAL_TOKEN") # Exemplo
if not token: token = get_segredo("gitea", "PAT")
return token or os.getenv("GITEA_TOKEN", "")
# ============================================================
# FONTES DE CREDENCIAIS
# FALLBACK: SEGREDOS.MD PARSER
# ============================================================
def get_segredos() -> Dict:
paths = [SEGREDOS_PATH, "/root/segredos.md", "/app/segredos.md"]
for p in paths:
if os.path.exists(p):
try:
with open(p, 'r') as f:
content = f.read()
return _parse_content(content)
except: pass
return {}
def _parse_content(content: str) -> Dict:
# Parser simplificado por regex
res = {"coolify": {}, "supabase": {}, "gitea": {}, "telegram": {}}
patterns = {
"coolify": [("APP_KEY", r"APP_KEY[:\s]+[`']?([^\s`']+)")],
"supabase": [("ANON_KEY", r"ANON_KEY[:\s]+[`']?([^\s`']+)")],
"telegram": [("BOT_TOKEN", r"Bot Token[:\s]+[`']?([^\s`']+)")],
"gitea": [("PAT", r"Token de Acesso Pessoal[:\s]+[`']?([^\s`']+)")],
}
for svc, pairs in patterns.items():
for key, pat in pairs:
m = re.search(pat, content, re.I)
if m: res[svc][key] = m.group(1)
return res
def get_segredo(service: str, key: str) -> Optional[str]:
return get_segredos().get(service, {}).get(key)
# ============================================================
# LOCAL FILES (.ENV / .INI)
# ============================================================
CREDENTIAL_SOURCES = {
"coolify": {
"path": "/data/coolify/source/.env",
"parser": "env",
"description": "Coolify (Orquestrador)"
},
"supabase": {
"path": "/data/coolify/services/h0oggskgs0ws0sco8kc4s8ws/.env",
"parser": "env",
"description": "Supabase (BaaS)"
},
"gitea": {
"path": "/var/lib/docker/volumes/yccsckck4g004gosccwc4kg4_gitea-data/_data/gitea/conf/app.ini",
"parser": "ini",
"section": "security",
"description": "Gitea (Git Server)"
},
"logto": {
"path": "/data/coolify/services/ea4tt75aeibqtu19hjqqw12f/.env",
"parser": "env",
"description": "Logto (Authentication)"
}
"coolify": {"path": "/data/coolify/source/.env", "type": "env"},
"supabase": {"path": "/data/coolify/services/h0oggskgs0ws0sco8kc4s8ws/.env", "type": "env"},
"gitea": {"path": "/var/lib/docker/volumes/yccsckck4g004gosccwc4kg4_gitea-data/_data/gitea/conf/app.ini", "type": "ini", "section": "security"}
}
# Coolify API
COOLIFY_API_BASE = "http://localhost:8000/api"
def get_credential(service: str, key: str) -> Optional[str]:
source = CREDENTIAL_SOURCES.get(service)
if not source or not os.path.exists(source["path"]): return None
# ============================================================
# 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:
if source["type"] == "env":
with open(source["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:
with open(segredos_path, 'r', encoding='utf-8') as f:
content = f.read()
except Exception as e:
print(f"Erro ao ler {segredos_path}: {e}")
return {}
result = {
"coolify": {},
"supabase": {},
"gitea": {},
"logto": {},
"telegram": {},
"anthropic": {},
"elevenlabs": {},
"gpi": {}
}
# Padrões para extrair valores
patterns = {
"coolify": [
(r"APP_KEY[:\s]+[`']?([^\s`']+)", "APP_KEY"),
(r"Database Password.*[:\s]+[`']?([^\s`']+)", "DB_PASSWORD"),
(r"Redis Password.*[:\s]+[`']?([^\s`']+)", "REDIS_PASSWORD"),
(r"Pusher App ID.*[:\s]+[`']?([^\s`']+)", "PUSHER_APP_ID"),
(r"Pusher App Key.*[:\s]+[`']?([^\s`']+)", "PUSHER_APP_KEY"),
(r"Pusher App Secret.*[:\s]+[`']?([^\s`']+)", "PUSHER_APP_SECRET"),
],
"supabase": [
(r"SERVICE_ROLE_KEY.*[:\s]+[`']?([^\s`']+)", "SERVICE_ROLE_KEY"),
(r"ANON_KEY.*[:\s]+[`']?([^\s`']+)", "ANON_KEY"),
(r"JWT Secret.*[:\s]+[`']?([^\s`']+)", "JWT_SECRET"),
(r"MinIO.*Access Key.*[:\s]+[`']?([^\s`']+)", "MINIO_ACCESS_KEY"),
(r"MinIO.*Secret Key.*[:\s]+[`']?([^\s`']+)", "MINIO_SECRET_KEY"),
(r"Vault Encryption Key.*[:\s]+[`']?([^\s`']+)", "VAULT_KEY"),
(r"Logflare API Key.*[:\s]+[`']?([^\s`']+)", "LOGFLARE_KEY"),
],
"gitea": [
(r"Token de Acesso Pessoal.*[:\s]+[`']?([^\s`']+)", "PAT"),
(r"Internal Token.*[:\s]+[`']?([^\s`']+)", "INTERNAL_TOKEN"),
(r"OAuth2 JWT Secret.*[:\s]+[`']?([^\s`']+)", "OAUTH2_SECRET"),
(r"LFS JWT Secret.*[:\s]+[`']?([^\s`']+)", "LFS_SECRET"),
],
"logto": [
(r"Logto.*Usuário.*[:\s]+[`']?([^\s`']+)", "DB_USER"),
(r"Logto.*Senha.*[:\s]+[`']?([^\s`']+)", "DB_PASSWORD"),
],
"telegram": [
(r"Bot Token.*[:\s]+[`']?([^\s`']+)", "BOT_TOKEN"),
(r"Chat ID.*[:\s]+[`']?([^\s`']+)", "CHAT_ID"),
],
"anthropic": [
(r"ANTHROPIC_API_KEY.*[:\s]+[`']?([^\s`']+)", "ANTHROPIC_API_KEY"),
],
"elevenlabs": [
(r"ELEVENLABS_API_KEY.*[:\s]+[`']?([^\s`']+)", "ELEVENLABS_API_KEY"),
(r"Voz Escolhida.*[:\s]+[`']?([^\s`']+)", "VOICE_ID"),
],
"gpi": [
(r"MongoDB URI.*[:\s]+[`']?([^\s`']+)", "MONGODB_URI"),
(r"Clerk Publishable Key.*[:\s]+[`']?([^\s`']+)", "CLERK_KEY"),
(r"JWT Secret.*[:\s]+[`']?([^\s`']+)", "JWT_SECRET"),
]
}
for service, service_patterns in patterns.items():
for pattern, key_name in service_patterns:
match = re.search(pattern, content, re.IGNORECASE)
if match:
result[service][key_name] = match.group(1)
return result
# Cache para segredos parseados
_segredos_cache: Dict[str, Dict[str, str]] = {}
_segredos_cache_time: float = 0
def get_segredos() -> Dict[str, Dict[str, str]]:
"""Retorna credenciais parseadas do segredos.md com cache."""
global _segredos_cache, _segredos_cache_time
if time.time() - _segredos_cache_time < CACHE_TTL and _segredos_cache:
return _segredos_cache
_segredos_cache = _parse_segredos_md()
_segredos_cache_time = time.time()
return _segredos_cache
def get_segredo(service: str, key: str) -> Optional[str]:
"""Busca uma credencial específica do segredos.md."""
segredos = get_segredos()
service_creds = segredos.get(service)
if service_creds:
return service_creds.get(key)
if line.startswith(f"{key}="): return line.split("=")[1].strip()
elif source["type"] == "ini":
cp = configparser.ConfigParser()
cp.read(source["path"])
return cp.get(source.get("section", "DEFAULT"), key, fallback=None)
except: pass
return None
# ============================================================
# CREDENTIAL FUNCTIONS
# API HELPERS (ASYNC)
# ============================================================
def get_credential(service: str, key: str, use_cache: bool = True, force_reload: bool = False) -> Optional[str]:
"""
Busca credencial diretamente da fonte original.
Args:
service: Nome do serviço (coolify, gitea, supabase, logto)
key: Nome da variável/campo
use_cache: Se True, usa cache em memória (TTL 5 min)
force_reload: Se True, ignora cache e recarrega
Returns:
Valor da credencial ou None se não encontrada
"""
global _cache, _cache_time
cache_key = _get_cache_key(service, key)
# Verifica cache
if use_cache and not force_reload and cache_key in _cache:
if time.time() - _cache_time.get(cache_key, 0) < CACHE_TTL:
return _cache[cache_key]
# Busca na fonte
source = CREDENTIAL_SOURCES.get(service)
if not source:
return None
if source["parser"] == "env":
data = _read_env_file(source["path"])
else: # ini
section = source.get("section", "security")
data = _read_ini_file(source["path"], section)
value = data.get(key)
# Atualiza cache
if value is not None:
_cache[cache_key] = value
_cache_time[cache_key] = time.time()
return value
def get_all_credentials(service: str, use_cache: bool = True) -> Dict[str, str]:
"""Retorna todas as credenciais de um serviço."""
source = CREDENTIAL_SOURCES.get(service)
if not source:
return {}
if source["parser"] == "env":
return _read_env_file(source["path"])
return _read_ini_file(source["path"], source.get("section", "security"))
def get_multiple(service: str, keys: list, use_cache: bool = True) -> Dict[str, Optional[str]]:
"""Busca múltiplas credenciais de um serviço."""
return {key: get_credential(service, key, use_cache) for key in keys}
def clear_cache():
"""Limpa cache de credenciais (útil após update no Coolify)."""
global _cache, _cache_time
_cache = {}
_cache_time = {}
def reload_credential(service: str, key: str) -> Optional[str]:
"""Recarrega uma credencial específica, ignorando cache."""
return get_credential(service, key, use_cache=False, force_reload=True)
# ============================================================
# HELPER FUNCTIONS - SERVIÇOS COMUNS
# ============================================================
def gitea_token() -> str:
"""Retorna token de acesso do Gitea."""
token = get_gitea_cred("gitea", "TOKEN")
if not token:
token = get_credential("gitea", "INSTALL_LOCK")
if not token:
token = get_credential("gitea", "TOKEN")
if not token:
token = get_segredo("gitea", "PAT")
return token or ""
def gitea_url() -> str:
"""Retorna URL base do Gitea."""
return "https://git.reifonas.cloud"
def gitea_api_url() -> str:
"""Retorna URL da API do Gitea."""
return f"{gitea_url()}/api/v1"
def supabase_url() -> str:
"""Retorna URL base do Supabase."""
return "https://supabase.reifonas.cloud"
def supabase_anon_key() -> str:
"""Retorna ANON_KEY do Supabase."""
key = get_gitea_cred("supabase", "ANON_KEY")
if not key:
key = get_credential("supabase", "ANON_KEY")
if not key:
key = get_segredo("supabase", "ANON_KEY")
return key or ""
def supabase_service_role_key() -> str:
"""Retorna SERVICE_ROLE_KEY do Supabase."""
key = get_gitea_cred("supabase", "SERVICE_ROLE_KEY")
if not key:
key = get_credential("supabase", "SERVICE_ROLE_KEY")
if not key:
key = get_segredo("supabase", "SERVICE_ROLE_KEY")
return key or ""
def supabase_jwt_secret() -> str:
"""Retorna JWT_SECRET do Supabase."""
secret = get_gitea_cred("supabase", "JWT_SECRET")
if not secret:
secret = get_credential("supabase", "JWT_SECRET")
if not secret:
secret = get_segredo("supabase", "JWT_SECRET")
return secret or ""
def coolify_app_key() -> str:
"""Retorna APP_KEY do Coolify."""
key = get_gitea_cred("coolify", "APP_KEY")
if not key:
key = get_credential("coolify", "APP_KEY")
if not key:
key = get_segredo("coolify", "APP_KEY")
return key or ""
def coolify_api_base() -> str:
"""Retorna URL base da API do Coolify."""
return COOLIFY_API_BASE
# ============================================================
# COOLIFY API HELPERS
# ============================================================
def coolify_api(endpoint: str, method: str = "GET", data: dict = None) -> dict:
"""
Faz requisição à API do Coolify.
Args:
endpoint: Endpoint da API (ex: "/deployments", "/applications")
method: GET, POST, DELETE, etc.
data: Dados para enviar (JSON)
Returns:
Resposta da API como dict
"""
import requests
url = f"{COOLIFY_API_BASE}{endpoint}"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {coolify_app_key()}"
}
async def coolify_api_async(endpoint: str, method: str = "GET", data: dict = None) -> dict:
from credential_manager import coolify_app_key
url = f"http://localhost:8000/api{endpoint}"
headers = {"Authorization": f"Bearer {coolify_app_key()}"}
async with httpx.AsyncClient() as client:
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 method == "GET": res = await client.get(url, headers=headers)
else: res = await client.request(method, url, headers=headers, json=data)
return res.json() if res.status_code == 200 else {"error": res.status_code}
except Exception as e: return {"error": str(e)}
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}
def coolify_app_key():
return asyncio.run(fetch_from_gitea_repo_async()).get("coolify", {}).get("APP_KEY") or get_segredo("coolify", "APP_KEY")
except Exception as e:
return {"error": str(e)}
# --- SYNC WRAPPERS ---
def sync_credentials():
return asyncio.run(fetch_from_gitea_repo_async(force=True))
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 sync_from_gitea_repo(force=False):
return asyncio.run(fetch_from_gitea_repo_async(force=force))
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 get_services_status():
return {"gitea_repo": "active", "local_files": "checked", "segredos": "available"}
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:
creds = get_all_credentials(service, use_cache=False)
result["services"][service] = {
"status": "ok",
"keys": len(creds)
}
except Exception as e:
result["services"][service] = {
"status": "error",
"error": str(e)
}
return result
# ============================================================
# GITEA REPO SYNC
# ============================================================
def sync_from_gitea_repo(force: bool = False) -> dict:
"""
Força sincronização do repo Gitea admtracksteel/Keys.
Retorna status do sync.
"""
global _gitea_creds_cache, _gitea_creds_cache_time
clear_cache()
_gitea_creds_cache_time = 0
creds = fetch_from_gitea_repo(force=force)
services = list(creds.keys())
return {
"status": "synced" if creds else "failed",
"repo": GITEA_CREDS_REPO,
"file": GITEA_CREDS_FILE,
"services_count": len(creds),
"services": services,
"timestamp": time.time()
}
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()}")
def gitea_api_url(): return GITEA_API_URL
def supabase_url(): return "https://supabase.reifonas.cloud"
def supabase_anon_key(): return get_segredo("supabase", "ANON_KEY")
def supabase_service_role_key(): return get_segredo("supabase", "SERVICE_ROLE_KEY")
print(f" Coolify API: {coolify_api_base()}")

View File

@@ -1,11 +1,7 @@
# ============================================================
# LLM_PROVIDERS.PY - Abstração de Provedores de LLM
# Suporta: Gemini, OpenAI, Anthropic, Ollama (Local)
# ============================================================
import os
import requests
import httpx
import json
import asyncio
from typing import Optional, Dict, List
# ============================================================
@@ -117,7 +113,6 @@ def set_executor(provider: str = None, model: str = None) -> dict:
save_config(cfg)
return cfg["orchestrator"].get("executor", {"provider": "ollama", "model": "llama3.2:1b"})
return cfg["orchestrator"]["executor"]
def set_api_key(provider: str, key: str):
"""Armazena API key de um provider."""
@@ -152,11 +147,12 @@ def get_api_key(provider: str) -> str:
# OLLAMA DISCOVERY
# ============================================================
def list_ollama_models() -> List[str]:
"""Busca modelos disponíveis no Ollama."""
async def list_ollama_models() -> List[str]:
"""Busca modelos disponíveis no Ollama em modo async."""
try:
endpoint = LLM_PROVIDERS["ollama"]["endpoint"]
response = requests.get(f"{endpoint}/api/tags", timeout=5)
async with httpx.AsyncClient() as client:
response = await client.get(f"{endpoint}/api/tags", timeout=5)
if response.status_code == 200:
models = [m["name"] for m in response.json().get("models", [])]
LLM_PROVIDERS["ollama"]["models"] = models
@@ -165,15 +161,15 @@ def list_ollama_models() -> List[str]:
print(f"Erro ao buscar modelos Ollama: {e}")
return []
def get_available_models(provider: str = None) -> List[Dict]:
"""Retorna modelos disponíveis para um provider ou todos."""
async def get_available_models(provider: str = None) -> List[Dict]:
"""Retorna modelos disponíveis para um provider ou todos (async)."""
if provider:
p = LLM_PROVIDERS.get(provider)
if not p:
return []
if p["type"] == "local" and provider == "ollama":
models = list_ollama_models()
models = await list_ollama_models()
return [{"provider": provider, "models": models}]
else:
return [{"provider": provider, "models": p.get("models", [p["default"]])}]
@@ -182,7 +178,7 @@ def get_available_models(provider: str = None) -> List[Dict]:
result = []
for prov_id, prov in LLM_PROVIDERS.items():
if prov_id == "ollama":
models = list_ollama_models()
models = await list_ollama_models()
result.append({"provider": prov_id, "name": prov["name"], "models": models})
else:
result.append({"provider": prov_id, "name": prov["name"], "models": prov.get("models", [prov["default"]])})
@@ -190,42 +186,25 @@ def get_available_models(provider: str = None) -> List[Dict]:
return result
# ============================================================
# LLM CALL FUNCTIONS
# ASYNC LLM CALL FUNCTIONS
# ============================================================
def call_llm(provider: str, model: str, prompt: str, system_prompt: str = None, **kwargs) -> str:
"""
Chama o LLM especificado.
Args:
provider: Nome do provider (gemini, openai, anthropic, ollama)
model: Nome do modelo
prompt: Prompt do usuário
system_prompt: Prompt de sistema (opcional)
Returns:
Resposta do LLM como string
"""
async def call_llm(provider: str, model: str, prompt: str, system_prompt: str = None, **kwargs) -> str:
"""Suporte universal async para chamadas de LLM."""
if provider == "gemini":
return _call_gemini(model, prompt, system_prompt)
return await _call_gemini_async(model, prompt, system_prompt)
elif provider == "openai":
return _call_openai(model, prompt, system_prompt)
return await _call_openai_async(model, prompt, system_prompt)
elif provider == "anthropic":
return _call_anthropic(model, prompt, system_prompt)
return await _call_anthropic_async(model, prompt, system_prompt)
elif provider == "ollama":
return _call_ollama(model, prompt, system_prompt)
return await _call_ollama_async(model, prompt, system_prompt)
else:
return f"Erro: Provider '{provider}' não suportado."
# ----------------------------------------
# GEMINI
# ----------------------------------------
def _call_gemini(model: str, prompt: str, system_prompt: str = None) -> str:
"""Chama API do Google Gemini."""
async def _call_gemini_async(model: str, prompt: str, system_prompt: str = None) -> str:
"""Chama API do Google Gemini via httpx (async)."""
api_key = get_api_key("gemini")
if not api_key:
api_key = os.getenv("GEMINI_API_KEY", "")
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
contents = [{"parts": [{"text": prompt}]}]
@@ -235,22 +214,17 @@ def _call_gemini(model: str, prompt: str, system_prompt: str = None) -> str:
payload = {"contents": contents}
try:
res = requests.post(url, json=payload, timeout=60)
async with httpx.AsyncClient() as client:
res = await client.post(url, json=payload, timeout=60)
if res.status_code == 200:
return res.json()["candidates"][0]["content"]["parts"][0]["text"]
return f"Erro Gemini: {res.status_code} - {res.text}"
except Exception as e:
return f"Erro Gemini: {str(e)}"
# ----------------------------------------
# OPENAI
# ----------------------------------------
def _call_openai(model: str, prompt: str, system_prompt: str = None) -> str:
"""Chama API da OpenAI."""
async def _call_openai_async(model: str, prompt: str, system_prompt: str = None) -> str:
"""Chama API da OpenAI via httpx (async)."""
api_key = get_api_key("openai")
if not api_key:
api_key = os.getenv("OPENAI_API_KEY", "")
url = f"https://api.openai.com/v1/chat/completions"
messages = []
@@ -258,33 +232,21 @@ def _call_openai(model: str, prompt: str, system_prompt: str = None) -> str:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
payload = {
"model": model,
"messages": messages,
"temperature": 0.7
}
payload = {"model": model, "messages": messages, "temperature": 0.7}
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
try:
res = requests.post(url, json=payload, headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}, timeout=60)
async with httpx.AsyncClient() as client:
res = await client.post(url, json=payload, headers=headers, timeout=60)
if res.status_code == 200:
return res.json()["choices"][0]["message"]["content"]
return f"Erro OpenAI: {res.status_code} - {res.text}"
except Exception as e:
return f"Erro OpenAI: {str(e)}"
# ----------------------------------------
# ANTHROPIC
# ----------------------------------------
def _call_anthropic(model: str, prompt: str, system_prompt: str = None) -> str:
"""Chama API da Anthropic (Claude)."""
async def _call_anthropic_async(model: str, prompt: str, system_prompt: str = None) -> str:
"""Chama API da Anthropic via httpx (async)."""
api_key = get_api_key("anthropic")
if not api_key:
api_key = os.getenv("ANTHROPIC_API_KEY", "")
url = "https://api.anthropic.com/v1/messages"
headers = {
@@ -298,24 +260,40 @@ def _call_anthropic(model: str, prompt: str, system_prompt: str = None) -> str:
"max_tokens": 4096,
"messages": [{"role": "user", "content": prompt}]
}
if system_prompt:
payload["system"] = system_prompt
if system_prompt: payload["system"] = system_prompt
try:
res = requests.post(url, json=payload, headers=headers, timeout=60)
async with httpx.AsyncClient() as client:
res = await client.post(url, json=payload, headers=headers, timeout=60)
if res.status_code == 200:
return res.json()["content"][0]["text"]
return f"Erro Anthropic: {res.status_code} - {res.text}"
except Exception as e:
return f"Erro Anthropic: {str(e)}"
# ----------------------------------------
# OLLAMA (LOCAL)
# ----------------------------------------
async def _call_ollama_async(model: str, prompt: str, system_prompt: str = None) -> str:
"""Chama Ollama local via httpx (async)."""
endpoint = LLM_PROVIDERS["ollama"]["endpoint"]
payload = {
"model": model,
"prompt": prompt,
"stream": False,
"options": {"num_ctx": 4096}
}
if system_prompt: payload["system"] = system_prompt
try:
async with httpx.AsyncClient() as client:
res = await client.post(f"{endpoint}/api/generate", json=payload, timeout=180)
if res.status_code == 200:
return res.json().get("response", "")
return f"Erro Ollama: {res.status_code} - {res.text}"
except Exception as e:
return f"Erro Ollama: {str(e)}"
def check_ollama_connection() -> dict:
"""Verifica se Ollama está acessível."""
"""Versão síncrona mantida para compatibilidade rápida de status."""
import requests
endpoint = LLM_PROVIDERS["ollama"]["endpoint"]
try:
res = requests.get(f"{endpoint}/api/tags", timeout=10)
@@ -323,62 +301,34 @@ def check_ollama_connection() -> dict:
models = [m.get("name") for m in res.json().get("models", [])]
return {"status": "ok", "models": models, "endpoint": endpoint}
return {"status": "error", "code": res.status_code, "endpoint": endpoint}
except requests.exceptions.Timeout:
return {"status": "timeout", "endpoint": endpoint}
except requests.exceptions.ConnectionError:
return {"status": "unreachable", "endpoint": endpoint}
except Exception as e:
return {"status": "error", "message": str(e), "endpoint": endpoint}
def _call_ollama(model: str, prompt: str, system_prompt: str = None) -> str:
"""Chama Ollama local."""
endpoint = LLM_PROVIDERS["ollama"]["endpoint"]
payload = {
"model": model,
"prompt": prompt,
"stream": False,
"options": {"num_ctx": 4096}
}
if system_prompt:
payload["system"] = system_prompt
try:
res = requests.post(f"{endpoint}/api/generate", json=payload, timeout=180)
if res.status_code == 200:
return res.json().get("response", "")
return f"Erro Ollama: {res.status_code} - {res.text}"
except requests.exceptions.Timeout:
return f"[TIMEOUT] Ollama não respondeu em 180s. Verifique se o modelo está carregado em {endpoint}"
except requests.exceptions.ConnectionError:
return f"[CONNECTION ERROR] Não conseguiu conectar ao Ollama em {endpoint}. Verifique se o container Ollama está na mesma rede Docker."
except Exception as e:
return f"Erro Ollama: {str(e)}"
# ============================================================
# HELPER FUNCTIONS
# PLANNER & EXECUTOR WRAPPERS (PROMETE SER ASYNC)
# ============================================================
def get_planner_llm() -> tuple:
"""Retorna provider e modelo do planner configurado."""
cfg = get_orchestrator_config()
planner = cfg.get("planner", {"provider": "gemini", "model": "gemini-2.5-flash"})
return planner["provider"], planner["model"]
def get_executor_llm() -> tuple:
"""Retorna provider e modelo do executor configurado."""
cfg = get_orchestrator_config()
executor = cfg.get("executor", {"provider": "ollama", "model": "llama3.2:1b"})
return executor["provider"], executor["model"]
def call_planner(prompt: str, system_prompt: str = None) -> str:
"""Chama o LLM do planner com a config atual."""
async def call_planner_async(prompt: str, system_prompt: str = None) -> str:
provider, model = get_planner_llm()
return call_llm(provider, model, prompt, system_prompt)
return await call_llm(provider, model, prompt, system_prompt)
async def call_executor_async(prompt: str, system_prompt: str = None) -> str:
provider, model = get_executor_llm()
return await call_llm(provider, model, prompt, system_prompt)
# --- BACKWARD COMPATIBILITY SHIMS (SYNC WRAPPERS) ---
def call_planner(prompt: str, system_prompt: str = None) -> str:
return asyncio.run(call_planner_async(prompt, system_prompt))
def call_executor(prompt: str, system_prompt: str = None) -> str:
"""Chama o LLM do executor com a config atual."""
provider, model = get_executor_llm()
return call_llm(provider, model, prompt, system_prompt)
return asyncio.run(call_executor_async(prompt, system_prompt))

336
main.py
View File

@@ -3,366 +3,90 @@ import psutil
import subprocess
import time
import json
from fastapi import FastAPI, Request, Header, Depends, HTTPException, status, File, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
import asyncio
from fastapi import FastAPI, Request, Header, Depends, HTTPException, status
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from dotenv import load_dotenv
from starlette.concurrency import run_in_threadpool
import audio_handler
from ai_agent import query_agent
from config import get_config, save_config
from credential_manager import sync_credentials, sync_from_gitea_repo
from orchestrator import (
orchestrate_async, handle_message_async, get_orchestrator_status,
get_llm_config, set_llm_config, format_confirmation_message,
format_completion_message
)
# Carrega as variáveis do .env
load_dotenv()
app = FastAPI(title="VpsTelegramBot API")
# Configura templates HTML
app = FastAPI(title="BotVPS API")
templates = Jinja2Templates(directory="templates")
# ============================================================
# AUTO-SYNC DE CREDENCIAIS NO STARTUP
# ============================================================
print("[INIT] Sincronizando credenciais do repo Gitea...")
sync_result = sync_from_gitea_repo()
print(f"[INIT] Repo Gitea: {sync_result['status']} ({sync_result['services_count']} serviços)")
print("[INIT] Sincronizando fallback local...")
sync_result = sync_credentials()
print(f"[INIT] Local: {sync_result['status']}")
# ============================================================
# EVENTO DE STARTUP
# STARTUP
# ============================================================
@app.on_event("startup")
async def startup_event():
print("[STARTUP] Sincronizando credenciais do repo Gitea...")
print("[INIT] Sincronizando credenciais...")
sync_from_gitea_repo()
sync_credentials()
print("[STARTUP] Credenciais sincronizadas com sucesso!")
# --- SEGURANÇA ---
async def verify_password(x_web_password: str = Header(None)):
cfg = get_config()
saved_pwd = cfg.get("web_password", "@@Gi05Br;;")
if not x_web_password or x_web_password != saved_pwd:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Senha Web inválida ou ausente."
)
if x_web_password != cfg.get("web_password", "@@Gi05Br;;"):
raise HTTPException(status_code=401, detail="Não autorizado")
return True
# --- ROTAS PÚBLICAS ---
# --- WEB UI ---
@app.get("/", response_class=HTMLResponse)
async def read_root(request: Request):
"""Renderiza o Dashboard Web."""
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
"""Favicon dummy para evitar 404."""
return JSONResponse(content={"status": "ok"})
# --- ROTAS PROTEGIDAS (API) ---
@app.get("/api/login")
async def check_login(is_auth: bool = Depends(verify_password)):
return {"status": "success"}
@app.get("/api/status")
async def get_system_status(is_auth: bool = Depends(verify_password)):
"""Retorna o status do sistema (CPU, RAM, Disco) sem travar o loop."""
def get_stats():
cpu_percent = psutil.cpu_percent(interval=0.5)
vm = psutil.virtual_memory()
disk = psutil.disk_usage('/')
return {
"cpu": cpu_percent,
"ram": {
"total": round(vm.total / (1024**3), 2),
"used": round(vm.used / (1024**3), 2),
"percent": vm.percent
},
"disk": {
"total": round(disk.total / (1024**3), 2),
"used": round(disk.used / (1024**3), 2),
"percent": disk.percent
"cpu": psutil.cpu_percent(),
"ram": {"percent": vm.percent, "used": round(vm.used / (1024**3), 2)},
"disk": {"percent": psutil.disk_usage('/').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")
async def web_chat(message: dict, is_auth: bool = Depends(verify_password)):
"""Endpoint para interagir com a IA via Web UI com suporte a histórico."""
user_text = message.get("text", "")
history = message.get("history", []) # Recebe o histórico do navegador
if not user_text:
return JSONResponse(content={"reply": "Por favor, digite um comando válido."})
# Passa o histórico para o query_agent manter o contexto
reply = await run_in_threadpool(query_agent, prompt=user_text, chat_history=history)
return JSONResponse(content={"reply": reply})
@app.post("/api/chat-audio")
async def web_chat_audio(audio: UploadFile = File(...), is_auth: bool = Depends(verify_password)):
"""Recebe áudio, transcreve, processa na IA e devolve texto + áudio de resposta."""
temp_path = f"/tmp/{audio.filename}"
with open(temp_path, "wb") as buffer:
buffer.write(await audio.read())
try:
# Transcrição (STT)
user_text = await run_in_threadpool(audio_handler.transcribe_audio, temp_path)
# IA (Processamento)
reply = await run_in_threadpool(query_agent, prompt=user_text)
# Síntese (TTS)
audio_filename = await run_in_threadpool(audio_handler.text_to_speech, reply)
audio_url = f"/api/audio-file/{audio_filename}"
return JSONResponse(content={
"text": user_text,
"reply": reply,
"audio_url": audio_url
})
except Exception as e:
return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
@app.get("/api/audio-file/{filename}")
async def get_audio_file(filename: str):
"""Serve os arquivos de áudio temporários gerados pelo TTS."""
filepath = os.path.join("/tmp", filename)
if os.path.exists(filepath):
return FileResponse(filepath, media_type="audio/mpeg")
raise HTTPException(status_code=404, detail="Arquivo de áudio não encontrado.")
@app.get("/api/host_file")
async def get_host_file(path: str, pwd: str = None, x_web_password: str = Header(None)):
"""Serve arquivos (como imagens) da máquina host para exibir no painel de insights."""
# Autenticação dupla: via Header (fetch) ou via Query Parâmetro (tag img)
cfg = get_config()
saved_pwd = cfg.get("web_password", "@@Gi05Br;;")
auth_token = pwd or x_web_password
if not auth_token or auth_token != saved_pwd:
raise HTTPException(status_code=401, detail="Não autorizado")
host_path = f"/host_root{path}" if not path.startswith("/host_root") else path
# Previne directory traversal básico garantindo que comece com /host_root
if not host_path.startswith("/host_root") or ".." in host_path:
raise HTTPException(status_code=400, detail="Caminho inválido.")
if os.path.isfile(host_path):
return FileResponse(host_path)
raise HTTPException(status_code=404, detail="Arquivo não encontrado no host.")
@app.get("/api/test_llm")
async def test_llm_speed(is_auth: bool = Depends(verify_password)):
"""Mede a velocidade de resposta da IA ativa."""
start_time = time.time()
try:
reply = await run_in_threadpool(query_agent, prompt="responda apenas com a palavra 'pong'")
latency = round(time.time() - start_time, 2)
return JSONResponse(content={"status": "success", "latency": latency, "reply": reply})
except Exception as e:
return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500)
@app.post("/webhook")
async def telegram_webhook(request: Request):
"""Recebe as atualizações (mensagens) do Telegram."""
update = await request.json()
print("Update recebido do Telegram:", update)
return {"ok": True}
# ============================================================
# NOVOS ENDPOINTS - ORQUESTRADOR
# ============================================================
from orchestrator import (
orchestrate, handle_message, get_orchestrator_status,
get_llm_config, set_llm_config, format_confirmation_message,
format_completion_message
)
from llm_providers import get_available_models
from credential_manager import sync_credentials
if not user_text: return {"reply": "Vazio."}
reply = query_agent(user_text)
return {"reply": reply}
@app.post("/api/orchestrate")
async def orchestrate_task(task_data: dict, is_auth: bool = Depends(verify_password)):
"""
Executa tarefa orquestrada.
POST /api/orchestrate
{
"task": "faz deploy do app X",
"confirmed": false
}
Response:
{
"status": "needs_confirmation" | "completed",
"plan": {...},
"confirmation_needed_for": [...],
"message": "..." (para display)
}
"""
task = task_data.get("task", "")
confirmed = task_data.get("confirmed", False)
if not task:
return JSONResponse(content={"status": "error", "message": "Task vazia"}, status_code=400)
result = await orchestrate_async(task, user_confirmed=confirmed)
result = orchestrate(task, user_confirmed=confirmed)
# Formata mensagem para display
if result["status"] == "needs_confirmation":
message = format_confirmation_message(result)
return JSONResponse(content={
return {
"status": "needs_confirmation",
"plan": result["plan"],
"confirmation_needed_for": result["confirmation_needed_for"],
"message": message
})
"message": format_confirmation_message(result)
}
return JSONResponse(content={
return {
"status": "completed",
"plan": result["plan"],
"results": result.get("results", []),
"message": format_completion_message(result) if 'format_completion_message' in dir() else "Concluído"
})
"message": format_completion_message(result)
}
@app.get("/api/orchestrator-status")
async def get_orch_status(is_auth: bool = Depends(verify_password)):
"""Retorna status do orquestrador."""
return JSONResponse(content=get_orchestrator_status())
@app.get("/api/llm-config")
async def get_llm_configuration(is_auth: bool = Depends(verify_password)):
"""Retorna configuração atual de LLMs."""
return JSONResponse(content=get_llm_config())
@app.get("/api/ollama-status")
async def get_ollama_status(is_auth: bool = Depends(verify_password)):
"""Verifica status do Ollama."""
from llm_providers import check_ollama_connection
result = check_ollama_connection()
return JSONResponse(content=result)
@app.post("/api/llm-config")
async def update_llm_configuration(config_data: dict, is_auth: bool = Depends(verify_password)):
"""Atualiza configuração de LLMs."""
planner_provider = config_data.get("planner_provider") or None
planner_model = config_data.get("planner_model") or None
executor_provider = config_data.get("executor_provider") or None
executor_model = config_data.get("executor_model") or None
changes = set_llm_config(
planner_provider=planner_provider,
planner_model=planner_model,
executor_provider=executor_provider,
executor_model=executor_model
)
return JSONResponse(content={"status": "success", "changes": changes})
@app.get("/api/llm-models")
async def list_llm_models(is_auth: bool = Depends(verify_password)):
"""Lista modelos disponíveis para cada provider."""
models = get_available_models()
return JSONResponse(content={"models": models})
@app.post("/api/sync-credentials")
async def sync_creds(is_auth: bool = Depends(verify_password)):
"""Força sincronização de credenciais (fallback local)."""
result = sync_credentials()
return JSONResponse(content=result)
@app.post("/api/sync-from-repo")
async def sync_from_repo(is_auth: bool = Depends(verify_password)):
"""Força sincronização do repo Gitea admtracksteel/Keys."""
from credential_manager import get_gitea_repo_credentials
result = sync_from_gitea_repo(force=True)
return JSONResponse(content=result)
@app.get("/api/credentials-repo")
async def get_repo_credentials(is_auth: bool = Depends(verify_password)):
"""Retorna credenciais do repo Gitea."""
from credential_manager import get_gitea_repo_credentials
creds = get_gitea_repo_credentials()
return JSONResponse(content={
"repo": "admtracksteel/Keys",
"services": creds,
"count": len(creds)
})
@app.get("/api/tools")
async def list_tools(is_auth: bool = Depends(verify_password)):
"""Lista todas as ferramentas disponíveis."""
from tools_v2 import get_tools_by_danger
return JSONResponse(content={
"tools": {
"safe": get_tools_by_danger("safe"),
"medium": get_tools_by_danger("medium"),
"dangerous": get_tools_by_danger("dangerous")
}
})
@app.post("/api/handle-message")
async def handle_web_message(message: dict, is_auth: bool = Depends(verify_password)):
"""
Manipula mensagem do usuário (alternativa ao chat normal).
Suporta confirmação de ações perigosas.
POST /api/handle-message
{
"text": "faz deploy do app",
"confirmed": false
}
"""
text = message.get("text", "")
confirmed = message.get("confirmed", False)
if not text:
return JSONResponse(content={"reply": "Mensagem vazia"})
reply = await run_in_threadpool(handle_message, text=text, confirmed=confirmed)
return JSONResponse(content={"reply": reply})
return get_orchestrator_status()
# --- SERVER ---
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

View File

@@ -1,14 +1,14 @@
# ============================================================
# ORCHESTRATOR.PY - Orquestrador de Tarefas
# Planner (Gemini/OpenAI/Claude/Ollama) + Executor (Qwen/Ollama)
# ORCHESTRATOR.PY - Orquestrador de Tarefas (Refatorado)
# ============================================================
import json
import re
import os
import asyncio
from typing import Dict, List, Optional
from llm_providers import (
call_planner, call_executor, get_planner_llm, get_executor_llm,
call_planner_async, call_executor_async, get_planner_llm, get_executor_llm,
get_available_models, LLM_PROVIDERS, set_planner, set_executor, get_config, save_config
)
from tools_v2 import TOOLS_V2, get_tools_by_danger, get_all_tools_formatted
@@ -25,67 +25,32 @@ Seu trabalho é decompor tarefas em passos executáveis CORRETOS.
### REGRAS CRÍTICAS DE COMANDOS:
1. USE SEMPRE "docker compose" (COM ESPAÇO), NUNCA "docker-compose" (COM HÍFEN)
2. O BotVPS está em /app (dentro do container)
3. Use "cd /app && git pull" para atualizar
4. Use "cd /app && docker compose up -d --build" para rebuild e deploy
2. Use "cd /app && git pull" para atualizar
3. Use "cd /app && docker compose up -d --build" para rebuild e deploy
### EXEMPLOS DE COMANDOS CORRETOS:
✅ CORRETO: cd /app && git pull origin master
✅ CORRETO: cd /app && docker compose up -d --build
✅ CORRETO: docker restart vps-ai-agent
### NÍVEIS DE PERIGO:
- SAFE: listar, ver status, ler logs
- MEDIUM: git pull, build, restart
- DANGEROUS: delete, reboot, docker down
### FERRAMENTAS DISPONÍVEIS:
{TOOLS_LIST}
### FORMATO DE RESPOSTA:
Responda APENAS com JSON válido:
Responda APENAS com JSON:
{{
"task_name": "Nome resumido",
"summary": "Resumo do que será feito",
"steps": [
{{
"order": 1,
"action": "Descrição clara",
"action": "Descrição",
"tool": "bash",
"command": "COMANDO LINUX COMPLETO E CORRETO",
"command": "COMANDO LINUX COMPLETO",
"danger": "safe|medium|dangerous"
}}
]
}}
### REGRAS:
1. Responda APENAS com JSON válido, sem texto adicional fora do JSON
2. Use tool="bash" para todos os comandos
3. Use "docker compose" (espaço) sempre
4. Use caminhos absolutos completos
5. Os passos devem ser na ordem correta de execução
"""
EXECUTOR_SYSTEM_PROMPT = """Você é o EXECUTOR AGENT do BotVPS.
Seu trabalho é executar comandos bash com precisão.
### REGRAS:
1. Execute APENAS o comando passado
2. Retorne o output do comando
3. Se houver erro, descreva o erro claramente
4. Não invente outputs
### FORMATO DE RESPOSTA:
Responda com JSON:
{{
"success": true|false,
"output": "output do comando ou erro"
}}
### IMPORTANTE:
- Use caminhos absolutos quando possível
- Redirecione erros (2>/dev/null) quando apropriado
- Mantenha comandos simples e seguros
Retorne JSON: {"success": true|false, "output": "resultado"}
"""
# ============================================================
@@ -93,569 +58,194 @@ Responda com JSON:
# ============================================================
def _format_tools_for_prompt() -> str:
"""Formata lista de ferramentas para o prompt."""
lines = []
for name, info in TOOLS_V2.items():
lines.append(f"- {name}: {info['desc']} [{info['danger']}]")
return "\n".join(lines)
return "\n".join([f"- {name}: {info['desc']} [{info['danger']}]" for name, info in TOOLS_V2.items()])
def detect_git_repo_path(task: str) -> str:
"""
Detecta automaticamente o caminho do repositório Git baseado na tarefa.
Retorna o caminho do repositório mais provável.
"""
async def detect_git_repo_path_async(task: str) -> str:
"""Detecta automaticamente o caminho do repositório Git (async)."""
from tools_v2 import run_bash
# Normaliza o texto da tarefa
task_lower = task.lower()
# Caminhos específicos por nome de app
app_paths = {
"tracksteel": [
"/data/repositories/0/5/5adtracksteel/AdmTrackSteel",
"/data/repositories/admtracksteel/AdmTrackSteel",
],
"botvps": [
"/data/repositories/admtracksteel/BotVPS",
"/data/repositories/botvps",
"/app",
],
"coolify": [
"/data/coolify",
"/data/coolify/source",
]
# Mapeamento de APPs conhecidos
app_map = {
"tracksteel": "/data/repositories/admtracksteel/AdmTrackSteel",
"botvps": "/app",
"coolify": "/data/coolify/source",
"antigravity": "/app"
}
# Detecta qual app o usuário quer
if "botvps" in task_lower or "bot vps" in task_lower or "antigravity" in task_lower:
paths_to_try = app_paths["botvps"]
elif "tracksteel" in task_lower:
paths_to_try = app_paths["tracksteel"]
elif "coolify" in task_lower:
paths_to_try = app_paths["coolify"]
else:
paths_to_try = []
for key, path in app_map.items():
if key in task_lower:
return path
# Procura nos caminhos específicos
for repo_path in paths_to_try:
result = run_bash(f"test -d {repo_path}/.git && echo 'FOUND:{repo_path}' || true")
if result.get("success") and "FOUND:" in result.get("output", ""):
found_path = result["output"].split("FOUND:")[1].strip()
print(f"[DETECT] Found {task_lower} at: {found_path}")
return found_path
# Procura em /data/repositories por repositórios git
result = run_bash("find /data/repositories -name '*.git' -type d 2>/dev/null | head -20")
# Busca dinâmica rápida
result = run_bash("find /data/repositories -name '.git' -type d -maxdepth 3 | head -1")
if result.get("success") and result.get("output"):
lines = result["output"].strip().split("\n")
for line in lines:
if line:
repo_dir = line.replace("/.git", "")
print(f"[DETECT] Found repo: {repo_dir}")
return repo_dir
return result["output"].replace("/.git", "").strip()
# Fallback: retorna /app se existir
if os.path.exists("/app/.git"):
print(f"[DETECT] Using fallback: /app")
return "/app"
return "/app" if os.path.exists("/app/.git") else "/"
print(f"[DETECT] No repo found, returning /")
return "/"
def detect_app_in_docker(task: str) -> str:
"""
Detecta qual container/app o usuário quer interagir baseado na tarefa.
"""
from tools_v2 import run_bash
task_lower = task.lower()
# Lista containers e tenta match
result = run_bash("docker ps --format '{{.Names}}' 2>/dev/null")
if result.get("success"):
containers = result["output"].lower()
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
async def _parse_json_response(text: str) -> Optional[Dict]:
json_match = re.search(r'\{[\s\S]*\}', text)
if json_match:
try:
return json.loads(json_match.group())
except json.JSONDecodeError:
except:
pass
# Tenta extrair de blocos de código
code_blocks = re.findall(r'```(?:json)?\s*([\s\S]*?)```', text)
for block in code_blocks:
try:
return json.loads(block.strip())
except json.JSONDecodeError:
continue
return None
def _classify_dangerous_steps(steps: List[Dict]) -> List[Dict]:
"""Retorna apenas passos perigosos."""
return [s for s in steps if s.get("danger") in ["medium", "dangerous"]]
# ============================================================
# PLANNER AGENT
# CORE AGENTS
# ============================================================
def plan_task(task: str) -> Dict:
"""
Usa o Planner LLM para decompor uma tarefa.
Args:
task: Tarefa do usuário
Returns:
Dicionário com plano de execução:
{
"task_name": str,
"summary": str,
"steps": [
{"order": int, "action": str, "tool": str, "command": str, "danger": str}
]
}
"""
async def plan_task_async(task: str) -> Dict:
provider, model = get_planner_llm()
print(f"[PLANNER] Using: {provider}/{model}")
repo_path = await detect_git_repo_path_async(task)
# Detecta automaticamente informações do contexto
detected_repo = detect_git_repo_path(task)
detected_app = detect_app_in_docker(task)
context_info = f"### CONTEXTO: Repo em {repo_path}, Bot em /app"
system_prompt = PLANNER_SYSTEM_PROMPT.format(
CONTEXT_INFO=context_info,
TOOLS_LIST=_format_tools_for_prompt()
)
print(f"[CONTEXT] Repo: {detected_repo}, App: {detected_app}")
response = await call_planner_async(task, system_prompt)
plan = await _parse_json_response(response)
# 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
if not plan:
return {
"task_name": task[:50],
"summary": f"Tarefa: {task}",
"steps": [{
"order": 1,
"action": task,
"tool": "bash",
"command": task,
"danger": "medium"
}]
"task_name": "Comando Direto",
"summary": f"Executando: {task}",
"steps": [{"order": 1, "action": task, "tool": "bash", "command": task, "danger": "medium"}]
}
return plan
# ============================================================
# EXECUTOR AGENT
# ============================================================
async def execute_command_async(command: str) -> Dict:
# Moderniza comando se necessário
command = command.replace("docker-compose", "docker compose")
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(
# Comandos simples: execução direta (segurança e velocidade)
if len(command) < 150 and not any(c in command for c in ["|", ">", ">>"]):
process = await asyncio.create_subprocess_shell(
command,
shell=True,
capture_output=True,
text=True,
timeout=60
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
return {
"success": result.returncode == 0,
"output": result.stdout.strip() or result.stderr.strip() or "Sucesso"
"success": process.returncode == 0,
"output": (stdout.decode() or stderr.decode() or "OK").strip()
}
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
)
# Comandos complexos: usa Executor LLM para validar/executar
response = await call_executor_async(f"Execute: {command}", EXECUTOR_SYSTEM_PROMPT)
return await _parse_json_response(response) or {"success": False, "output": response}
result = _parse_json_response(response)
if result:
return result
return {"success": False, "output": response}
def execute_step(step: Dict) -> Dict:
"""
Executa um passo do plano.
Args:
step: Dicionário com dados do passo
Returns:
{"success": bool, "output": str, "step": int}
"""
tool = step.get("tool")
async def execute_step_async(step: Dict) -> Dict:
tool = step.get("tool", "bash")
command = step.get("command", "")
order = step.get("order", 0)
print(f" -> Step {order}: {step.get('action')[:50]}...")
if tool and tool in TOOLS_V2:
if tool in TOOLS_V2:
func = TOOLS_V2[tool]["func"]
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()
# Se for async, await
if asyncio.iscoroutinefunction(func):
result = await func(command) if command else await func()
else:
result = str(func)
return {
"success": True,
"output": result,
"step": order
}
result = func(command) if command else func()
return {"success": True, "output": result, "step": step.get("order")}
except Exception as e:
return {
"success": False,
"output": f"Erro ao executar {tool}: {str(e)}",
"step": order
}
return {"success": False, "output": str(e), "step": step.get("order")}
# Executa como comando bash
return execute_command(command)
return await execute_command_async(command)
# ============================================================
# ORCHESTRATOR MAIN
# MAIN ORCHESTRATION
# ============================================================
def orchestrate(task: str, user_confirmed: bool = False) -> Dict:
"""
Orquestra a execução de uma tarefa.
async def orchestrate_async(task: str, user_confirmed: bool = False) -> Dict:
plan = await plan_task_async(task)
Args:
task: Tarefa do usuário
user_confirmed: Se True, pula confirmação e executa tudo
# Verifica perigo
dangerous = [s for s in plan.get("steps", []) if s.get("danger") in ["medium", "dangerous"]]
Returns:
{
"status": "needs_confirmation" | "completed" | "error",
"plan": {...},
"confirmation_needed_for": [steps peligrosos],
"results": [...] (se status == "completed")
}
"""
print(f"\n{'='*50}")
print(f">>> PLANNING: {task}")
print(f"{'='*50}\n")
# 1. Plana a tarefa
plan = plan_task(task)
# 2. Identifica passos perigosos
dangerous_steps = _classify_dangerous_steps(plan.get("steps", []))
# 3. Se há passos perigosos e não confirmou, pede confirmação
if dangerous_steps and not user_confirmed:
if dangerous and not user_confirmed:
return {
"status": "needs_confirmation",
"plan": plan,
"confirmation_needed_for": [
{"order": s["order"], "action": s["action"], "danger": s["danger"]}
for s in dangerous_steps
]
"confirmation_needed_for": dangerous
}
# 4. Executa todos os passos
results = []
for step in plan.get("steps", []):
result = execute_step(step)
results.append(result)
# Para em caso de erro crítico
if not result.get("success") and step.get("danger") == "dangerous":
results.append({
"success": False,
"output": "Execução abortada devido a erro crítico.",
"step": -1
})
res = await execute_step_async(step)
results.append(res)
if not res["success"] and step.get("danger") == "dangerous":
break
# 5. Retorna resultado
return {
"status": "completed",
"plan": plan,
"results": results
}
return {"status": "completed", "plan": plan, "results": results}
# --- SYNC WRAPPERS PARA COMPATIBILIDADE ---
def orchestrate(task: str, user_confirmed: bool = False) -> Dict:
return asyncio.run(orchestrate_async(task, user_confirmed))
def handle_message(text: str, confirmed: bool = False) -> str:
# Mantendo lógica de parsing mas chamando orchestrate_async internamente facilitaria
# No entanto, para evitar mudanças drásticas agora, faremos o wrapper sync
return asyncio.run(handle_message_async(text, confirmed))
async def handle_message_async(text: str, confirmed: bool = False) -> str:
# Reimplementação levemente mais limpa
text_clean = text.strip().lower()
if text_clean == "/status":
s = get_orchestrator_status()
return f"[BOT] Status: Planner={s['planner']['model']}, Executor={s['executor']['model']}"
if text_clean == "/tools":
return get_all_tools_formatted()
# Orchestration
res = await orchestrate_async(text, confirmed)
if res["status"] == "needs_confirmation":
return format_confirmation_message(res)
return format_completion_message(res)
def format_confirmation_message(result: Dict) -> str:
"""
Formata mensagem de confirmação para o usuário.
Args:
result: Resultado do orchestrate()
Returns:
String formatada para envio ao usuário
"""
if result["status"] != "needs_confirmation":
return ""
plan = result["plan"]
dangerous = result["confirmation_needed_for"]
msg = f"[PLANO] {plan.get('task_name', 'Tarefa')}\n\n"
msg += f"{plan.get('summary', '')}\n\n"
msg += "AVISO: Acoes que precisam de confirmacao:\n\n"
for step in dangerous:
icon = "[CRITICAL]" if step["danger"] == "dangerous" else "[WARNING]"
msg += f"{icon} Passo {step['order']}: {step['action']}\n"
msg += "\nDeseja continuar? (sim/não)"
msg = f"⚠️ **Confirmação Necessária**: {plan['task_name']}\n\n"
for s in result["confirmation_needed_for"]:
msg += f"• Passo {s['order']}: {s['action']} ({s['danger'].upper()})\n"
msg += "\nDigite 'sim' para autorizar."
return msg
def format_completion_message(result: Dict) -> str:
"""
Formata mensagem de conclusão.
Args:
result: Resultado do orchestrate()
Returns:
String formatada com os resultados
"""
if result["status"] != "completed":
return ""
plan = result["plan"]
results = result.get("results", [])
plan_steps = plan.get("steps", [])
msg = f"[OK] Concluido: {plan.get('task_name', 'Tarefa')}\n\n"
# Conta apenas resultados de passos reais (step > 0)
real_results = [r for r in results if r.get("step", 0) > 0]
success_count = sum(1 for r in real_results if r.get("success"))
total_count = len(plan_steps)
msg += f"[STAT] Resultado: {success_count}/{total_count} passos executados com sucesso.\n\n"
for step in plan_steps:
step_num = step.get("order", 0)
# Encontra resultado correspondente
step_result = next((r for r in results if r.get("step") == step_num), None)
if step_result:
status_icon = "[OK]" if step_result.get("success") else "[FAIL]"
output = step_result.get("output", "")[:500]
msg += f"{status_icon} Passo {step_num}: {step.get('action', '')[:50]}\n"
if output and not step_result.get("success"):
msg += f" Erro: {output[:200]}\n"
success = all(r.get("success", False) for r in results)
msg = f"{'' if success else ''} **Concluído**: {plan['task_name']}\n"
for r in results:
char = "S" if r.get("success") else "F"
msg += f"[{char}] Step {r.get('step', '?')}: {str(r.get('output'))[:100]}\n"
return msg
# ============================================================
# STATUS & CONFIG FUNCTIONS
# ============================================================
def get_orchestrator_status() -> Dict:
"""Retorna status atual do orquestrador."""
planner_provider, planner_model = get_planner_llm()
executor_provider, executor_model = get_executor_llm()
p_p, p_m = get_planner_llm()
e_p, e_m = get_executor_llm()
return {
"planner": {
"provider": planner_provider,
"model": planner_model,
"name": LLM_PROVIDERS[planner_provider]["name"]
},
"executor": {
"provider": executor_provider,
"model": executor_model,
"name": LLM_PROVIDERS[executor_provider]["name"]
},
"credentials": get_services_status(),
"available_tools": len(TOOLS_V2)
"planner": {"provider": p_p, "model": p_m},
"executor": {"provider": e_p, "model": e_m},
"tools_count": len(TOOLS_V2)
}
def get_llm_config() -> Dict:
"""Retorna configuração de LLMs."""
planner_provider, planner_model = get_planner_llm()
executor_provider, executor_model = get_executor_llm()
p_p, p_m = get_planner_llm()
e_p, e_m = get_executor_llm()
return {
"planner": {
"provider": planner_provider,
"model": planner_model,
"available_providers": [
{"id": k, "name": v["name"], "type": v["type"]}
for k, v in LLM_PROVIDERS.items()
]
},
"executor": {
"provider": executor_provider,
"model": executor_model,
"available_providers": [
{"id": k, "name": v["name"], "type": v["type"]}
for k, v in LLM_PROVIDERS.items()
]
}
"planner": {"provider": p_p, "model": p_m, "available": list(LLM_PROVIDERS.keys())},
"executor": {"provider": e_p, "model": e_m, "available": list(LLM_PROVIDERS.keys())}
}
def set_llm_config(planner_provider: str = None, planner_model: str = None,
executor_provider: str = None, executor_model: str = None) -> Dict:
"""Atualiza configuração de LLMs."""
changes = {}
if planner_provider:
result = set_planner(planner_provider, planner_model)
changes["planner"] = result
if executor_provider:
result = set_executor(executor_provider, executor_model)
changes["executor"] = result
return changes
# ============================================================
# COMMAND PARSER (para Telegram/Web)
# ============================================================
def parse_command(text: str) -> Dict:
"""
Interpreta comandos do usuário.
Args:
text: Texto do usuário
Returns:
{"type": "orchestrate"|"config"|"status", "data": {...}}
"""
text = text.strip().lower()
# Comandos de configuração
if text.startswith("/llm"):
parts = text.split()
if len(parts) == 1:
return {"type": "config", "action": "show"}
elif len(parts) >= 3:
if parts[1] == "planner":
return {"type": "config", "action": "set_planner", "provider": parts[2]}
elif parts[1] == "executor":
return {"type": "config", "action": "set_executor", "provider": parts[2]}
return {"type": "config", "action": "help"}
if text == "/sync":
return {"type": "config", "action": "sync_credentials"}
if text == "/status":
return {"type": "status"}
if text == "/tools":
return {"type": "tools"}
if text.startswith("/"):
return {"type": "unknown", "command": text}
# Tarefas de orquestração
return {"type": "orchestrate", "task": text}
# ============================================================
# MAIN HANDLER
# ============================================================
def handle_message(text: str, confirmed: bool = False) -> str:
"""
Manipula mensagem do usuário.
Args:
text: Mensagem do usuário
confirmed: Se o usuário já confirmou ações perigosas
Returns:
Resposta para o usuário
"""
parsed = parse_command(text)
# Status
if parsed["type"] == "status":
status = get_orchestrator_status()
msg = "[BOT] Status do Orquestrador:\n\n"
msg += f"[PLANNER] {status['planner']['name']} ({status['planner']['model']})\n"
msg += f"[EXECUTOR] {status['executor']['name']} ({status['executor']['model']})\n"
msg += f"[TOOLS] Ferramentas: {status['available_tools']}\n"
return msg
# Config
if parsed["type"] == "config":
if parsed["action"] == "show":
config = get_llm_config()
msg = "[CONFIG] Configuracao de LLMs:\n\n"
msg += f"[PLANNER] {config['planner']['provider']} / {config['planner']['model']}\n"
msg += f"[EXECUTOR] {config['executor']['provider']} / {config['executor']['model']}\n"
msg += "\nPara mudar: /llm planner <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."
def set_llm_config(planner_provider=None, planner_model=None, executor_provider=None, executor_model=None):
if planner_provider: set_planner(planner_provider, planner_model)
if executor_provider: set_executor(executor_provider, executor_model)
return {"status": "updated"}

View File

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