926 lines
31 KiB
Python
926 lines
31 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
opencode_bot.py — Bot de Telegram que usa OpenCode (opencode serve) como
|
|
interfaz de codificacion desde el movil.
|
|
|
|
Corre en el VPS junto a `opencode serve`. Long-polling a Telegram, whitelist
|
|
por chat_id, una sesion persistente por chat y proyecto, entrega de codigo
|
|
como <pre> y ficheros como documento adjunto.
|
|
|
|
Configuracion: variables en el .env del mismo directorio (ver .env.ejemplo).
|
|
"""
|
|
|
|
import html
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.parse
|
|
|
|
import requests
|
|
|
|
BASE = os.path.dirname(os.path.abspath(__file__))
|
|
ENV = {}
|
|
try:
|
|
for linea in open(os.path.join(BASE, ".env"), encoding="utf-8"):
|
|
linea = linea.strip()
|
|
if linea and not linea.startswith("#") and "=" in linea:
|
|
k, v = linea.split("=", 1)
|
|
ENV[k.strip()] = v.strip()
|
|
except Exception:
|
|
pass
|
|
|
|
TOKEN = ENV.get("TELEGRAM_BOT_TOKEN", "")
|
|
ALLOWED = ENV.get("TELEGRAM_CHAT_ID", "")
|
|
OC_HOST = ENV.get("OC_HOST", "127.0.0.1")
|
|
OC_PORT = ENV.get("OC_PORT", "4097")
|
|
OC_USER = ENV.get("OC_USER", "opencode")
|
|
OC_PASS = ENV.get("OC_PASS", "")
|
|
GH = ENV.get("GH_ROOT", "/home/Github")
|
|
REPO_ENV = ENV.get("REPO_ENV_FILE", "/etc/opencode-tg/repo.env")
|
|
ESTADO_FILE = os.path.join(BASE, "estado.json")
|
|
OFFSET_FILE = os.path.join(BASE, "offset.txt")
|
|
LOG_FILE = os.path.join(BASE, "opencode_bot.log")
|
|
|
|
API = f"https://api.telegram.org/bot{TOKEN}"
|
|
OC = f"http://{OC_HOST}:{OC_PORT}"
|
|
AUTH = (OC_USER, OC_PASS)
|
|
|
|
bloqueos = {} # chat_id -> True (procesando mensaje)
|
|
sesiones = {} # chat_id -> {'repo': slug, 'session': id, 'model': obj|None}
|
|
repos_cache = [] # lista de {'slug','dir'}
|
|
sentidos = {} # chat_id -> [message_id...] enviados por el bot (para /limpiar)
|
|
entrantes = {} # chat_id -> [message_id...] enviados por el usuario (para /limpiar)
|
|
menu_msg = {} # chat_id -> message_id del menu abierto
|
|
|
|
|
|
def log(linea):
|
|
try:
|
|
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
|
f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {linea}\n")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def cargar_estado():
|
|
global sesiones
|
|
try:
|
|
sesiones = json.load(open(ESTADO_FILE, encoding="utf-8"))
|
|
except Exception:
|
|
sesiones = {}
|
|
|
|
|
|
def guardar_estado():
|
|
try:
|
|
json.dump(sesiones, open(ESTADO_FILE, "w", encoding="utf-8"),
|
|
ensure_ascii=False, indent=2)
|
|
except Exception as e:
|
|
log(f"estado error: {e}")
|
|
|
|
|
|
def cargar_offset():
|
|
try:
|
|
return int(open(OFFSET_FILE).read().strip())
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def guardar_offset(offset):
|
|
try:
|
|
open(OFFSET_FILE, "w").write(str(offset))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def tg(method, data=None, files=None):
|
|
try:
|
|
return requests.post(f"{API}/{method}", data=data, files=files,
|
|
timeout=40).json()
|
|
except Exception as e:
|
|
log(f"tg {method} error: {e}")
|
|
return {"ok": False}
|
|
|
|
|
|
def _registrar(chat_id, respuesta):
|
|
try:
|
|
if respuesta and respuesta.get("ok"):
|
|
mid = (respuesta.get("result") or {}).get("message_id")
|
|
if mid:
|
|
lista = sentidos.setdefault(str(chat_id), [])
|
|
lista.append(mid)
|
|
if len(lista) > 300:
|
|
sentidos[str(chat_id)] = lista[-300:]
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def enviar(chat_id, texto, parse="HTML"):
|
|
for i in range(0, len(texto), 3900):
|
|
_registrar(chat_id, tg("sendMessage",
|
|
{"chat_id": chat_id, "text": texto[i:i + 3900],
|
|
"parse_mode": parse}))
|
|
time.sleep(0.1)
|
|
|
|
|
|
def enviar_doc(chat_id, ruta, caption=None):
|
|
if not os.path.isfile(ruta):
|
|
return
|
|
try:
|
|
with open(ruta, "rb") as f:
|
|
_registrar(chat_id, tg("sendDocument",
|
|
{"chat_id": chat_id, "caption": caption},
|
|
{"document": (os.path.basename(ruta), f)}))
|
|
except Exception as e:
|
|
log(f"doc error {ruta}: {e}")
|
|
|
|
|
|
def teclado(filas):
|
|
return {"inline_keyboard": filas}
|
|
|
|
|
|
def tg_con_teclado(chat_id, texto, filas):
|
|
resp = tg("sendMessage",
|
|
{"chat_id": chat_id, "text": texto, "parse_mode": "HTML",
|
|
"reply_markup": json.dumps(teclado(filas))})
|
|
_registrar(chat_id, resp)
|
|
try:
|
|
return (resp.get("result") or {}).get("message_id")
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def editar(chat_id, mid, texto, filas=None):
|
|
data = {"chat_id": chat_id, "message_id": mid, "text": texto,
|
|
"parse_mode": "HTML"}
|
|
if filas is not None:
|
|
data["reply_markup"] = json.dumps(teclado(filas))
|
|
tg("editMessageText", data)
|
|
|
|
|
|
def _aviso_voz_fin(chat):
|
|
"""Aviso sonoro de fin de turno (nota de voz) si esta habilitado."""
|
|
if ENV.get("AVISO_VOZ", "0") != "1":
|
|
return
|
|
try:
|
|
subprocess.run(
|
|
[sys.executable, os.path.join(BASE, "aviso_voz.py"), str(chat)],
|
|
timeout=90, capture_output=True)
|
|
except Exception as e:
|
|
log(f"aviso_voz error: {e}")
|
|
|
|
|
|
def aviso_voz_fin(chat):
|
|
threading.Thread(target=_aviso_voz_fin, args=(chat,), daemon=True).start()
|
|
|
|
|
|
def typing(chat_id):
|
|
"""Manda sendChatAction 'typing' cada 4 s hasta que se detenga."""
|
|
stop = threading.Event()
|
|
|
|
def loop():
|
|
while not stop.wait(4):
|
|
tg("sendChatAction", {"chat_id": chat_id, "action": "typing"})
|
|
|
|
h = threading.Thread(target=loop, daemon=True)
|
|
h.start()
|
|
return stop
|
|
|
|
|
|
# -------------------------------------------------------------------- API --
|
|
|
|
def oc_get(ruta, timeout=15):
|
|
try:
|
|
r = requests.get(f"{OC}{ruta}", auth=AUTH, timeout=timeout)
|
|
return r.status_code, r.json() if r.status_code == 200 else None
|
|
except Exception as e:
|
|
return 0, str(e)
|
|
|
|
|
|
def oc_post(ruta, payload, timeout=600):
|
|
try:
|
|
r = requests.post(f"{OC}{ruta}", auth=AUTH, json=payload,
|
|
timeout=timeout)
|
|
try:
|
|
return r.status_code, r.json()
|
|
except Exception:
|
|
return r.status_code, r.text
|
|
except Exception as e:
|
|
return 0, str(e)
|
|
|
|
|
|
def health_ok():
|
|
code, _ = oc_get("/global/health", timeout=5)
|
|
return code == 200
|
|
|
|
|
|
def listar_repos():
|
|
"""Repos disponibles: directorios de GH con .git."""
|
|
global repos_cache
|
|
lista = []
|
|
try:
|
|
for nombre in sorted(os.listdir(GH)):
|
|
ruta = os.path.join(GH, nombre)
|
|
if os.path.isdir(os.path.join(ruta, ".git")):
|
|
lista.append({"slug": nombre, "dir": ruta})
|
|
except Exception as e:
|
|
log(f"repos error: {e}")
|
|
repos_cache = lista
|
|
return lista
|
|
|
|
|
|
def repo_activo():
|
|
"""Lee el repo en el que corre el serve (fichero del servicio)."""
|
|
try:
|
|
for linea in open(REPO_ENV):
|
|
if linea.startswith("REPO_DIR="):
|
|
ruta = linea.strip().split("=", 1)[1]
|
|
return os.path.basename(ruta.rstrip("/")), ruta
|
|
except Exception:
|
|
pass
|
|
return None, None
|
|
|
|
|
|
def crear_sesion(titulo=None):
|
|
payload = {}
|
|
if titulo:
|
|
payload["title"] = titulo[:80]
|
|
code, data = oc_post("/session", payload, timeout=40)
|
|
if code != 200 or not data or not data.get("id"):
|
|
return None, data
|
|
return data["id"], data
|
|
|
|
|
|
def enviar_mensaje(session_id, texto, modelo=None, timeout=900):
|
|
parts = [{"type": "text", "text": texto}]
|
|
payload = {"parts": parts}
|
|
if modelo:
|
|
# el body.model exige modelID + providerID
|
|
payload["model"] = {
|
|
"providerID": modelo.get("providerID"),
|
|
"modelID": modelo.get("id"),
|
|
}
|
|
code, data = oc_post(f"/session/{session_id}/message", payload,
|
|
timeout=timeout)
|
|
if code != 200:
|
|
return None, data
|
|
return data, None
|
|
|
|
|
|
def listar_modelos():
|
|
"""Devuelve lista de {id, providerID, name} de providers conectados."""
|
|
code, data = oc_get("/config/providers")
|
|
modelos = []
|
|
if code != 200 or not data:
|
|
return modelos
|
|
for p in data.get("providers", []):
|
|
if not isinstance(p, dict):
|
|
continue
|
|
for mid, mobj in (p.get("models") or {}).items():
|
|
if mobj.get("status") == "active":
|
|
modelos.append({
|
|
"id": mid,
|
|
"providerID": p.get("id"),
|
|
"name": mobj.get("name") or mid,
|
|
})
|
|
return modelos
|
|
|
|
|
|
# ------------------------------------------------------------ formateo ----
|
|
|
|
def _escape(t):
|
|
return html.escape(t, quote=False)
|
|
|
|
|
|
def _trocear_codigo(texto):
|
|
"""Trocea en [('txt', str) | ('code', str)] por bloques ```."""
|
|
partes = []
|
|
resto = texto
|
|
while "```" in resto:
|
|
idx = resto.index("```")
|
|
if idx > 0:
|
|
partes.append(("txt", resto[:idx]))
|
|
resto = resto[idx + 3:]
|
|
fin = resto.find("```")
|
|
if fin == -1:
|
|
partes.append(("txt", resto))
|
|
return partes
|
|
partes.append(("code", resto[:fin]))
|
|
resto = resto[fin + 3:]
|
|
if resto:
|
|
partes.append(("txt", resto))
|
|
return partes
|
|
|
|
|
|
def formatear_html(texto):
|
|
"""Convierte la respuesta del modelo a HTML simple seguro."""
|
|
salida = []
|
|
for tipo, bloque in _trocear_codigo(texto):
|
|
if tipo == "code":
|
|
salida.append(f"<pre>{_escape(bloque.strip())}</pre>")
|
|
continue
|
|
bloque = _escape(bloque)
|
|
bloque = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", bloque)
|
|
bloque = re.sub(r"`([^`]+)`", r"<code>\1</code>", bloque)
|
|
salida.append(bloque)
|
|
return "\n".join(salida)
|
|
|
|
|
|
def extraer_respuesta(data):
|
|
"""Devuelve (texto, adjuntos[path...]) desde la respuesta del modelo."""
|
|
texto = ""
|
|
adjuntos = []
|
|
for p in data.get("parts") or []:
|
|
t = p.get("type")
|
|
if t == "text" and p.get("text"):
|
|
texto += ("" if not texto else "\n") + p["text"]
|
|
return texto, adjuntos
|
|
|
|
|
|
def _rutas_de_partes(hist, t0):
|
|
"""Recoge rutas de ficheros tocados tras t0 desde el historial."""
|
|
rutas = []
|
|
for item in hist or []:
|
|
info = item.get("info") or {}
|
|
if info.get("role") != "assistant":
|
|
continue
|
|
for p in item.get("parts") or []:
|
|
t = p.get("type")
|
|
inicio = (p.get("time") or {}).get("start") or 0
|
|
if t0 and inicio and inicio < t0:
|
|
continue
|
|
if t in ("file", "patch"):
|
|
for clave in ("path", "filepath"):
|
|
if p.get(clave):
|
|
rutas.append(p[clave])
|
|
elif t == "tool":
|
|
estado = p.get("state")
|
|
if isinstance(estado, dict):
|
|
archivos = ((estado.get("metadata") or {}).get("files")
|
|
or [])
|
|
for a in archivos:
|
|
if a.get("filePath"):
|
|
rutas.append(a["filePath"])
|
|
# dedupe manteniendo orden
|
|
vistos = set()
|
|
unicas = []
|
|
for r in rutas:
|
|
if r not in vistos:
|
|
vistos.add(r)
|
|
unicas.append(r)
|
|
return unicas
|
|
|
|
|
|
# ------------------------------------------------------------- flujo -----
|
|
|
|
def ok_sesion(chat):
|
|
"""Devuelve (session_id, repo_actual) garantizando sesion creada."""
|
|
st = sesiones.get(str(chat)) or {}
|
|
slug, ruta = repo_activo()
|
|
st_repo = st.get("repo")
|
|
if not slug:
|
|
return None, None
|
|
if st_repo != slug or not st.get("session"):
|
|
sid, err = crear_sesion()
|
|
if not sid:
|
|
return None, err
|
|
st = {"repo": slug, "session": sid, "model": st.get("model")}
|
|
sesiones[str(chat)] = st
|
|
guardar_estado()
|
|
return st["session"], None
|
|
|
|
|
|
def procesar_texto(chat, texto):
|
|
"""Envia la peticion de forma asincrona y entrega el resultado por partes
|
|
(streaming), para que el usuario vea progreso como en el terminal."""
|
|
sid, err = ok_sesion(chat)
|
|
if not sid:
|
|
enviar(chat, f"❌ No hay sesión OpenCode disponible: {err}")
|
|
return
|
|
st = sesiones.get(str(chat)) or {}
|
|
modelo = st.get("model")
|
|
stop = typing(chat)
|
|
t0 = int(time.time() * 1000)
|
|
|
|
payload = {"parts": [{"type": "text", "text": texto}]}
|
|
if modelo:
|
|
payload["model"] = {
|
|
"providerID": modelo.get("providerID"),
|
|
"modelID": modelo.get("id"),
|
|
}
|
|
code, resp = oc_post(f"/session/{sid}/prompt_async", payload, timeout=40)
|
|
if code not in (200, 204):
|
|
stop.set()
|
|
enviar(chat, f"❌ Error al lanzar OpenCode: {resp}")
|
|
return
|
|
|
|
# Respuesta SIN verbose: no se envian los comandos/tools intermedios ni
|
|
# fragmentos de streaming. Solo se recoge el texto del assistant generado
|
|
# en este turno (filtrado por t0 para no repetir mensajes de turnos
|
|
# anteriores) y se entrega al terminar junto con el tiempo empleado.
|
|
trozos = {} # pid -> (start, texto)
|
|
comienzo = time.time()
|
|
terminado = False
|
|
while time.time() - comienzo < 900:
|
|
time.sleep(1)
|
|
code, hist = oc_get(f"/session/{sid}/message?limit=10")
|
|
if code == 200 and isinstance(hist, list):
|
|
for item in reversed(hist[-10:]):
|
|
info = item.get("info") or {}
|
|
if info.get("role") != "assistant":
|
|
continue
|
|
for p in item.get("parts") or []:
|
|
if p.get("type") != "text" or not p.get("text"):
|
|
continue
|
|
inicio = (p.get("time") or {}).get("start") or 0
|
|
if inicio and inicio < t0:
|
|
continue # parte de un turno anterior: ignorar
|
|
pid = p.get("id") or f"{inicio}-{len(p['text'])}"
|
|
if pid not in trozos:
|
|
trozos[pid] = (inicio, p["text"])
|
|
code2, sts = oc_get("/session/status", timeout=10)
|
|
transcurrido = time.time() - comienzo
|
|
if (code2 == 200 and isinstance(sts, dict)
|
|
and sts.get(sid, {}).get("type") != "busy"
|
|
and transcurrido >= 2):
|
|
terminado = True
|
|
break
|
|
|
|
stop.set()
|
|
if not terminado:
|
|
enviar(chat, "⏰ La tarea está tardando demasiado. Usa <code>/salir</code> "
|
|
"para cortarla y empezar de nuevo.")
|
|
return
|
|
|
|
# Texto consolidado del turno, ordenado por tiempo de inicio.
|
|
texto_final = "\n".join(t for _, t in
|
|
sorted(trozos.values(), key=lambda x: x[0]))
|
|
if texto_final.strip():
|
|
seg = time.time() - comienzo
|
|
enviar(chat, formatear_html(texto_final.strip()))
|
|
enviar(chat, f"⏱️ Hecho en <b>{seg:.1f}s</b>")
|
|
|
|
# ficheros tocados en el turno
|
|
adjuntos = []
|
|
try:
|
|
code, hist = oc_get(f"/session/{sid}/message?limit=8")
|
|
if code == 200 and isinstance(hist, list):
|
|
adjuntos = [r for r in _rutas_de_partes(hist, t0)
|
|
if os.path.isfile(r)]
|
|
except Exception as e:
|
|
log(f"adjuntos error: {e}")
|
|
if adjuntos:
|
|
enviar(chat, f"📎 {len(adjuntos)} fichero(s) del turno:")
|
|
for ruta in adjuntos[:6]:
|
|
enviar_doc(chat, ruta)
|
|
|
|
# Aviso sonoro de fin (nota de voz en el movil), en segundo plano.
|
|
aviso_voz_fin(chat)
|
|
|
|
|
|
def cambiar_repo(chat, slug):
|
|
ruta = os.path.join(GH, slug)
|
|
if not os.path.isdir(os.path.join(ruta, ".git")):
|
|
enviar(chat, f"❌ Repo no válido: {slug}")
|
|
return
|
|
try:
|
|
with open(REPO_ENV, "w") as f:
|
|
f.write(f"REPO_DIR={ruta}\n")
|
|
subprocess.run(["systemctl", "restart", "opencode-serve"],
|
|
timeout=60, capture_output=True)
|
|
except Exception as e:
|
|
log(f"cambiar_repo error: {e}")
|
|
enviar(chat, f"❌ No pude reiniciar el serve: {e}")
|
|
return
|
|
for t in range(20):
|
|
if health_ok():
|
|
break
|
|
time.sleep(1)
|
|
# sesion nueva en el repo nuevo
|
|
st = sesiones.get(str(chat)) or {}
|
|
sid, err = crear_sesion()
|
|
if sid:
|
|
sesiones[str(chat)] = {"repo": slug, "session": sid,
|
|
"model": st.get("model")}
|
|
guardar_estado()
|
|
enviar(chat, f"📂 Repo activo: <b>{slug}</b>")
|
|
if not sid:
|
|
enviar(chat, f"⚠️ El serve no respondió aún: {err}")
|
|
|
|
|
|
# ------------------------------------------------------------ comandos ----
|
|
|
|
def cmd_ayuda(chat):
|
|
enviar(chat, (
|
|
"<b>🤖 OpenCode por Telegram</b>\n"
|
|
"Escribe cualquier cosa y lo hará el agente en el repo activo.\n\n"
|
|
"<b>Comandos:</b>\n"
|
|
"<code>/menu</code> — menú interactivo (modelo, proyecto, cerrar)\n"
|
|
"<code>/nueva [texto]</code> — sesión nueva (olvida el contexto)\n"
|
|
"<code>/salir</code> — cerrar la sesión actual (la próxima arranca limpia)\n"
|
|
"<code>/proyecto</code> / <code>/defecto</code> — elegir repo (queda por defecto)\n"
|
|
"<code>/modelo</code> — elegir modelo\n"
|
|
"<code>/limpiar</code> — limpiar la pantalla (borra mensajes del bot)\n"
|
|
"<code>/estado</code> — repo y modelo actuales\n"
|
|
"<code>/ayuda</code> — esta ayuda\n\n"
|
|
"Los ficheros que cree te llegarán como documentos."
|
|
))
|
|
|
|
|
|
def cmd_estado(chat):
|
|
slug, ruta = repo_activo()
|
|
st = sesiones.get(str(chat)) or {}
|
|
modelo = (st.get("model") or {}).get("name") or "por defecto"
|
|
sid = st.get("session") or "—"
|
|
enviar(chat, (
|
|
"📂 <b>Estado</b>\n"
|
|
f"Repo: <b>{slug}</b> ({ruta})\n"
|
|
f"Sesión: <code>{sid[:20]}…</code>\n"
|
|
f"Modelo: <b>{modelo}</b>\n\n"
|
|
"Usa <code>/proyecto</code> para cambiar de repo."
|
|
))
|
|
|
|
|
|
def cmd_nueva(chat, resto):
|
|
st = sesiones.get(str(chat)) or {}
|
|
slug, _ = repo_activo()
|
|
sid, err = crear_sesion()
|
|
if not sid:
|
|
enviar(chat, f"❌ No pude crear sesión: {err}")
|
|
return
|
|
sesiones[str(chat)] = {"repo": slug, "session": sid, "model": st.get("model")}
|
|
guardar_estado()
|
|
if resto:
|
|
procesar_texto(chat, resto)
|
|
else:
|
|
enviar(chat, "🧠 Sesión nueva creada. Dime qué hago.")
|
|
|
|
|
|
def cmd_salir(chat):
|
|
"""Sale de la sesion actual: la cierra y avisa que la proxima sera nueva."""
|
|
st = sesiones.get(str(chat))
|
|
if st and st.get("session"):
|
|
try:
|
|
oc_post(f"/session/{st['session']}/abort", {})
|
|
except Exception:
|
|
pass
|
|
sid, _ = crear_sesion()
|
|
slug, _ = repo_activo()
|
|
st["session"] = sid
|
|
sesiones[str(chat)] = st
|
|
guardar_estado()
|
|
enviar(chat, "🚪 Sesión anterior cerrada. La próxima petición arranca "
|
|
"en una sesión nueva sin contexto.")
|
|
else:
|
|
enviar(chat, "No había ninguna sesión abierta.")
|
|
|
|
|
|
def cmd_limpiar(chat):
|
|
"""Borra TODO el chat visible: mensajes del bot y del usuario.
|
|
Limite de Telegram: solo mensajes de menos de 48h se pueden borrar."""
|
|
lista = sentidos.get(str(chat)) or []
|
|
lista += entrantes.get(str(chat)) or []
|
|
borrados = 0
|
|
for mid in reversed(lista):
|
|
try:
|
|
r = requests.post(f"{API}/deleteMessage",
|
|
data={"chat_id": chat, "message_id": mid},
|
|
timeout=15)
|
|
if r.json().get("ok"):
|
|
borrados += 1
|
|
except Exception:
|
|
pass
|
|
sentidos[str(chat)] = []
|
|
entrantes[str(chat)] = []
|
|
enviar(chat, f"🧹 Chat limpio ({borrados} mensajes borrados).")
|
|
# borra tambien este aviso al cabo de unos segundos
|
|
threading.Timer(6.0, _autolimpia, args=(chat,)).start()
|
|
|
|
|
|
def _autolimpia(chat):
|
|
lista = sentidos.get(str(chat)) or []
|
|
for mid in reversed(lista[:1]):
|
|
try:
|
|
requests.post(f"{API}/deleteMessage",
|
|
data={"chat_id": chat, "message_id": mid},
|
|
timeout=10)
|
|
except Exception:
|
|
pass
|
|
sentidos[str(chat)] = [m for m in lista if m != mid]
|
|
|
|
|
|
def cmd_proyecto(chat):
|
|
lista = listar_repos()
|
|
if not lista:
|
|
enviar(chat, "No hay repos en GH_ROOT.")
|
|
return
|
|
slug_act, _ = repo_activo()
|
|
filas = []
|
|
for i, r in enumerate(lista[:14]):
|
|
marca = " ✅" if r["slug"] == slug_act else ""
|
|
filas.append([{"text": r["slug"] + marca,
|
|
"callback_data": f"cb:repo:{i}"}])
|
|
tg_con_teclado(chat, "📂 Elige repo de trabajo. Quedará fijado como "
|
|
"repo por defecto del bot (reinicia el serve):", filas)
|
|
|
|
|
|
def cmd_modelo(chat):
|
|
modelos = listar_modelos()
|
|
if not modelos:
|
|
enviar(chat, "No hay modelos disponibles (¿serve arrancado?).")
|
|
return
|
|
st = sesiones.get(str(chat)) or {}
|
|
actual = (st.get("model") or {}).get("id")
|
|
filas = []
|
|
for m in modelos[:30]:
|
|
marca = " ✅" if m["id"] == actual else ""
|
|
texto = f"{m['name']}{marca}"
|
|
filas.append([{"text": texto[:55],
|
|
"callback_data": f"cb:modelo:{m['providerID']}:{m['id']}"}])
|
|
filas.append([{"text": "↩️ Por defecto", "callback_data": "cb:modelo:def"}])
|
|
tg_con_teclado(chat, "🧠 Elige modelo (se aplica a partir del próximo mensaje):", filas)
|
|
|
|
|
|
# ------------------------------------------------------------ menu wizard --
|
|
|
|
def _wiz_resumen(chat):
|
|
slug, _ = repo_activo()
|
|
st = sesiones.get(str(chat)) or {}
|
|
modelo = (st.get("model") or {}).get("name") or "por defecto"
|
|
return (f"🧭 <b>Menú OpenCode</b>\n"
|
|
f"🧠 Modelo: <b>{modelo}</b>\n"
|
|
f"📂 Proyecto: <b>{slug}</b>\n\n"
|
|
"Configura y pulsa <b>Cerrar</b>.")
|
|
|
|
|
|
def _wiz_raiz_filas():
|
|
return [
|
|
[{"text": "🧠 Elegir modelo", "callback_data": "w:modelo"},
|
|
{"text": "📂 Elegir proyecto", "callback_data": "w:proyecto"}],
|
|
[{"text": "✅ Cerrar menú", "callback_data": "w:cerrar"},
|
|
{"text": "🚪 Salir", "callback_data": "w:salir"}],
|
|
]
|
|
|
|
|
|
def _quitar_teclado(chat, mid):
|
|
try:
|
|
requests.post(f"{API}/editMessageReplyMarkup",
|
|
data={"chat_id": chat, "message_id": mid}, timeout=10)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def cmd_menu(chat):
|
|
mid = tg_con_teclado(chat, _wiz_resumen(chat), _wiz_raiz_filas())
|
|
if mid:
|
|
menu_msg[str(chat)] = mid
|
|
|
|
|
|
def _wiz_mostrar(chat, mid, texto, filas):
|
|
try:
|
|
requests.post(f"{API}/editMessageText",
|
|
data={"chat_id": chat, "message_id": mid, "text": texto,
|
|
"parse_mode": "HTML",
|
|
"reply_markup": json.dumps(teclado(filas))},
|
|
timeout=15)
|
|
except Exception as e:
|
|
log(f"wiz edit error: {e}")
|
|
|
|
|
|
def _cambiar_repo_silencioso(chat, slug):
|
|
ruta = os.path.join(GH, slug)
|
|
if not os.path.isdir(os.path.join(ruta, ".git")):
|
|
return "Repo no válido"
|
|
try:
|
|
with open(REPO_ENV, "w") as f:
|
|
f.write(f"REPO_DIR={ruta}\n")
|
|
subprocess.run(["systemctl", "restart", "opencode-serve"],
|
|
timeout=60, capture_output=True)
|
|
except Exception as e:
|
|
log(f"cambiar_repo_silencioso error: {e}")
|
|
return str(e)
|
|
for _ in range(25):
|
|
if health_ok():
|
|
break
|
|
time.sleep(1)
|
|
st = sesiones.get(str(chat)) or {}
|
|
sid, err = crear_sesion()
|
|
if sid:
|
|
sesiones[str(chat)] = {"repo": slug, "session": sid,
|
|
"model": st.get("model")}
|
|
guardar_estado()
|
|
return None
|
|
return str(err)
|
|
|
|
|
|
def manejar_wizard(data, chat, mid):
|
|
if data == "w:cerrar":
|
|
_quitar_teclado(chat, mid)
|
|
return True
|
|
if data == "w:salir":
|
|
_quitar_teclado(chat, mid)
|
|
return True
|
|
if data == "w:modelo":
|
|
modelos = listar_modelos()
|
|
if not modelos:
|
|
_wiz_mostrar(chat, mid, "No hay modelos disponibles.", _wiz_raiz_filas())
|
|
return True
|
|
st = sesiones.get(str(chat)) or {}
|
|
actual = (st.get("model") or {}).get("id")
|
|
filas = []
|
|
for m in modelos[:24]:
|
|
marca = " ✅" if m["id"] == actual else ""
|
|
filas.append([{"text": (m["name"] + marca)[:50],
|
|
"callback_data": f"wm:{m['providerID']}:{m['id']}"}])
|
|
filas.append([{"text": "↩️ Por defecto", "callback_data": "wm:def"},
|
|
{"text": "⬅️ Atrás", "callback_data": "w:root"}])
|
|
_wiz_mostrar(chat, mid, "🧠 Elige modelo:", filas)
|
|
return True
|
|
if data == "w:proyecto":
|
|
lista = listar_repos()
|
|
if not lista:
|
|
_wiz_mostrar(chat, mid, "No hay repos.", _wiz_raiz_filas())
|
|
return True
|
|
slug_act, _ = repo_activo()
|
|
filas = []
|
|
for i, r in enumerate(lista[:20]):
|
|
marca = " ✅" if r["slug"] == slug_act else ""
|
|
filas.append([{"text": r["slug"] + marca,
|
|
"callback_data": f"wr:{i}"}])
|
|
filas.append([{"text": "⬅️ Atrás", "callback_data": "w:root"}])
|
|
_wiz_mostrar(chat, mid, "📂 Elige proyecto (reinicia el serve):", filas)
|
|
return True
|
|
if data == "w:root":
|
|
_wiz_mostrar(chat, mid, _wiz_resumen(chat), _wiz_raiz_filas())
|
|
return True
|
|
if data.startswith("wm:"):
|
|
if data == "wm:def":
|
|
st = sesiones.get(str(chat)) or {}
|
|
st["model"] = None
|
|
sesiones[str(chat)] = st
|
|
guardar_estado()
|
|
else:
|
|
resto = data[3:]
|
|
provider, mid_modelo = resto.split(":", 1)
|
|
elegido = next((m for m in listar_modelos()
|
|
if m["providerID"] == provider
|
|
and m["id"] == mid_modelo), None)
|
|
if elegido:
|
|
st = sesiones.get(str(chat)) or {}
|
|
st["model"] = elegido
|
|
sesiones[str(chat)] = st
|
|
guardar_estado()
|
|
_wiz_mostrar(chat, mid, _wiz_resumen(chat), _wiz_raiz_filas())
|
|
return True
|
|
if data.startswith("wr:"):
|
|
try:
|
|
idx = int(data[3:])
|
|
r = repos_cache[idx]
|
|
except Exception:
|
|
return True
|
|
_wiz_mostrar(chat, mid, f"Cambiando a <b>{r['slug']}</b>…",
|
|
[[{"text": "⏳", "callback_data": "w:none"}]])
|
|
err = _cambiar_repo_silencioso(chat, r["slug"])
|
|
if err:
|
|
_wiz_mostrar(chat, mid, f"⚠️ {err}", _wiz_raiz_filas())
|
|
else:
|
|
_wiz_mostrar(chat, mid, _wiz_resumen(chat), _wiz_raiz_filas())
|
|
return True
|
|
return False
|
|
|
|
|
|
def responder_callback(callback):
|
|
cid = callback.get("id")
|
|
data = callback.get("data") or ""
|
|
msg = callback.get("message") or {}
|
|
chat = (msg.get("chat") or {}).get("id")
|
|
mid = msg.get("message_id")
|
|
tg("answerCallbackQuery", {"callback_query_id": cid})
|
|
if ALLOWED and str(chat) not in ALLOWED.split(","):
|
|
return
|
|
if data.startswith(("w:", "wm:", "wr:")):
|
|
try:
|
|
manejar_wizard(data, chat, mid)
|
|
except Exception as e:
|
|
log(f"wizard error: {e}")
|
|
return
|
|
if not data.startswith("cb:"):
|
|
return
|
|
partes = data.split(":")
|
|
if partes[1] == "repo":
|
|
try:
|
|
idx = int(partes[2])
|
|
r = repos_cache[idx]
|
|
except Exception:
|
|
return
|
|
editar(chat, mid, f"Cambiando a <b>{r['slug']}</b>…")
|
|
cambiar_repo(chat, r["slug"])
|
|
elif partes[1] == "modelo":
|
|
if len(partes) == 3 and partes[2] == "def":
|
|
st = sesiones.get(str(chat)) or {}
|
|
st["model"] = None
|
|
sesiones[str(chat)] = st
|
|
guardar_estado()
|
|
editar(chat, mid, "🧠 Modelo: <b>por defecto</b> (config del repo)")
|
|
return
|
|
provider, mid_modelo = partes[2], ":".join(partes[3:])
|
|
modelos = listar_modelos()
|
|
elegido = next((m for m in modelos
|
|
if m["providerID"] == provider and m["id"] == mid_modelo),
|
|
None)
|
|
if not elegido:
|
|
return
|
|
st = sesiones.get(str(chat)) or {}
|
|
st["model"] = elegido
|
|
sesiones[str(chat)] = st
|
|
guardar_estado()
|
|
editar(chat, mid, f"🧠 Modelo: <b>{elegido['name']}</b>")
|
|
|
|
|
|
def procesar(texto, chat):
|
|
texto = texto.strip()
|
|
bajo = texto.lower()
|
|
if not bajo.startswith("/"):
|
|
# pendiente? No; todo texto normal es una peticion para OpenCode.
|
|
if bloqueos.get(chat):
|
|
enviar(chat, "⏳ Aún estoy procesando tu último mensaje…")
|
|
return
|
|
bloqueos[chat] = True
|
|
try:
|
|
procesar_texto(chat, texto)
|
|
finally:
|
|
bloqueos[chat] = False
|
|
return
|
|
|
|
partes = texto.split(None, 1)
|
|
comando = partes[0].split("@", 1)[0].lower()
|
|
resto = partes[1].strip() if len(partes) > 1 else ""
|
|
if comando in ("/start", "/ayuda", "/help"):
|
|
cmd_ayuda(chat)
|
|
elif comando == "/menu":
|
|
cmd_menu(chat)
|
|
elif comando in ("/nueva", "/nuevo"):
|
|
cmd_nueva(chat, resto)
|
|
elif comando in ("/salir", "/fin", "/exit"):
|
|
cmd_salir(chat)
|
|
elif comando in ("/limpiar", "/clear"):
|
|
cmd_limpiar(chat)
|
|
elif comando in ("/proyecto", "/repo", "/defecto"):
|
|
cmd_proyecto(chat)
|
|
elif comando == "/modelo":
|
|
cmd_modelo(chat)
|
|
elif comando == "/estado":
|
|
cmd_estado(chat)
|
|
else:
|
|
enviar(chat, "No reconozco ese comando. Usa /ayuda.")
|
|
|
|
|
|
def main():
|
|
if not TOKEN:
|
|
print("Falta TELEGRAM_BOT_TOKEN en .env")
|
|
return
|
|
cargar_estado()
|
|
listar_repos()
|
|
log("Bot iniciado")
|
|
offset = cargar_offset()
|
|
while True:
|
|
try:
|
|
params = {"timeout": 30}
|
|
if offset:
|
|
params["offset"] = offset
|
|
r = requests.get(f"{API}/getUpdates", params=params,
|
|
timeout=45).json()
|
|
for upd in r.get("result", []):
|
|
offset = upd["update_id"] + 1
|
|
guardar_offset(offset)
|
|
if upd.get("callback_query"):
|
|
threading.Thread(target=responder_callback,
|
|
args=(upd["callback_query"],),
|
|
daemon=True).start()
|
|
continue
|
|
msg = upd.get("message")
|
|
if not msg:
|
|
continue
|
|
chat = str(msg["chat"]["id"])
|
|
if ALLOWED and chat not in ALLOWED.split(","):
|
|
log(f"Bloqueado chat {chat}")
|
|
continue
|
|
texto = msg.get("text")
|
|
if msg.get("message_id"):
|
|
lista = entrantes.setdefault(chat, [])
|
|
lista.append(msg["message_id"])
|
|
if len(lista) > 300:
|
|
entrantes[chat] = lista[-300:]
|
|
if texto:
|
|
threading.Thread(target=procesar, args=(texto, chat),
|
|
daemon=True).start()
|
|
except Exception as e:
|
|
log(f"error loop: {e}")
|
|
time.sleep(3)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|