# -*- 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'}
sentidos = {}      # chat_id -> [message_id...] enviados por el bot (para /limpiar)


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.3)


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):
    _registrar(chat_id, 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] — sesión nueva (olvida el contexto)\n" "/salir — cerrar la sesión actual (la próxima arranca limpia)\n" "/proyecto / /defecto — elegir repo (queda por defecto)\n" "/modelo — elegir modelo\n" "/limpiar — limpiar la pantalla (borra mensajes del bot)\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_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 los mensajes que el bot ha enviado en este chat.""" lista = sentidos.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)] = [] enviar(chat, f"🧹 Pantalla limpia ({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) 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 ("/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"): 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()