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