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