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