diff --git a/vps_opencode_bot/README.md b/vps_opencode_bot/README.md new file mode 100644 index 0000000..a612a06 --- /dev/null +++ b/vps_opencode_bot/README.md @@ -0,0 +1,50 @@ +# opencode_tg — Bot de Telegram para OpenCode (VPS) + +Bot que permite usar OpenCode desde el móvil vía Telegram. Corre en el VPS +Contabo junto a `opencode serve` (headless). Sin librerías extra (solo +`requests`), long-polling, whitelist por chat_id. + +## Despliegue + +- Código: `/opt/opencode-tg/opencode_bot.py` +- `.env`: `/opt/opencode-tg/.env` +- Repo activo del serve: `/etc/opencode-tg/repo.env` (`REPO_DIR=...`) +- Servicios systemd: + - `opencode-serve.service` → `opencode serve --hostname 127.0.0.1 --port 4097` + dentro de `$REPO_DIR` (credenciales OPENCODE_SERVER_USERNAME/PASSWORD). + - `opencode-tg.service` → el bot. + +Para cambiar de repo el bot reinicia `opencode-serve` (escribe `repo.env`). +Cada chat conserva su sesión y modelo en `estado.json`. + +## Comandos del bot + +``` +/start /ayuda ayuda +/nueva [texto] sesión nueva (olvida el contexto) +/proyecto elegir repo de /home/Github (inline) +/modelo elegir modelo (inline) +/estado repo, sesión y modelo actuales +cualquier texto → lo ejecuta OpenCode en la sesión del chat +``` + +Entrega: texto como HTML (bloques ``` en
), ficheros creados/editados
+como documentos adjuntos (se leen de `state.metadata.files` del historial).
+
+## PENDIENTE (único paso manual)
+
+1. Crear el bot con @BotFather y copiar el token.
+2. Rellenar en `/opt/opencode-tg/.env`:
+   ```
+   TELEGRAM_BOT_TOKEN=
+   TELEGRAM_CHAT_ID=
+   ```
+   (`TELEGRAM_CHAT_ID` puede ser una lista separada por comas.)
+3. Arrancar:
+   ```
+   systemctl enable --now opencode-tg
+   ```
+
+API comprobada contra opencode 1.17.15: POST /session, POST /session/:id/message
+(body `parts` + `model` con `providerID`/`modelID`), GET /session/:id/message,
+GET /config/providers, GET /global/health, GET /project.
diff --git a/vps_opencode_bot/opencode_bot.py b/vps_opencode_bot/opencode_bot.py
new file mode 100644
index 0000000..f1844f7
--- /dev/null
+++ b/vps_opencode_bot/opencode_bot.py
@@ -0,0 +1,607 @@
+# -*- 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 
 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'}
+
+
+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 enviar(chat_id, texto, parse="HTML"):
+    for i in range(0, len(texto), 3900):
+        tg("sendMessage", {"chat_id": chat_id, "text": texto[i:i + 3900],
+                           "parse_mode": parse})
+        time.sleep(0.3)
+
+
+def enviar_doc(chat_id, ruta, caption=None):
+    if not os.path.isfile(ruta):
+        return
+    try:
+        with open(ruta, "rb") as f:
+            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):
+    tg("sendMessage", {"chat_id": chat_id, "text": texto, "parse_mode": "HTML",
+                       "reply_markup": json.dumps(teclado(filas))})
+
+
+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 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"
{_escape(bloque.strip())}
") + continue + bloque = _escape(bloque) + bloque = re.sub(r"\*\*(.+?)\*\*", r"\1", bloque) + bloque = re.sub(r"`([^`]+)`", r"\1", 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): + 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) + try: + data, err = enviar_mensaje(sid, texto, modelo=modelo) + finally: + stop.set() + if data is None: + enviar(chat, f"❌ Error del servidor OpenCode: {err}") + return + rtexto, _ = extraer_respuesta(data) + # ficheros tocados en este 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 rtexto: + enviar(chat, formatear_html(rtexto)) + if adjuntos: + enviar(chat, f"📎 {len(adjuntos)} fichero(s) del turno:") + for ruta in adjuntos[:6]: + enviar_doc(chat, ruta) + if not rtexto and not adjuntos: + enviar(chat, "🤔 (sin respuesta de texto. Revisa la sesión en el TUI)") + + +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: {slug}") + if not sid: + enviar(chat, f"⚠️ El serve no respondió aún: {err}") + + +# ------------------------------------------------------------ comandos ---- + +def cmd_ayuda(chat): + enviar(chat, ( + "🤖 OpenCode por Telegram\n" + "Escribe cualquier cosa y lo hará el agente en el repo activo.\n\n" + "Comandos:\n" + "/nueva [texto] — nueva sesión (borra contexto)\n" + "/proyecto — elegir repo de trabajo\n" + "/modelo — elegir modelo\n" + "/estado — repo y modelo actuales\n" + "/ayuda — 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, ( + "📂 Estado\n" + f"Repo: {slug} ({ruta})\n" + f"Sesión: {sid[:20]}…\n" + f"Modelo: {modelo}\n\n" + "Usa /proyecto 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_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:", 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) + + +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 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 {r['slug']}…") + 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: por defecto (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: {elegido['name']}") + + +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 in ("/nueva", "/nuevo"): + cmd_nueva(chat, resto) + elif comando in ("/proyecto", "/repo"): + 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"): + responder_callback(upd["callback_query"]) + 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 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()