# -*- 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)
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.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):
    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"
{_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): """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(2) 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 >= 5): terminado = True break stop.set() if not terminado: enviar(chat, "⏰ La tarea está tardando demasiado. Usa /salir " "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 {seg:.1f}s") # 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: {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" "/menu — menú interactivo (modelo, proyecto, cerrar)\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 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"🧭 Menú OpenCode\n" f"🧠 Modelo: {modelo}\n" f"📂 Proyecto: {slug}\n\n" "Configura y pulsa Cerrar.") 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 {r['slug']}…", [[{"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 {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 == "/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()