Files
biblioteca_conocimiento_lab…/vps_opencode_bot/opencode_bot.py

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()