retro-cpu / app.py
d1d4c's picture
feat: v0.8.0 β€” run_bash tool (subprocess, 15s timeout)
5286aae verified
Raw History Blame Contribute Delete
31 kB
"""
retro-cpu β€” Online Retro instance (Telegram webhook bridge).
ADR-562 pilot. A CPU-only HF Docker Space that lets DΓ­dac operate a lightweight
Retro from his phone (Telegram) when the home `satelite` node is offline.
Flow:
Telegram update -> /webhook -> (text | voice)
text : retro-space /v1/chat/completions (Qwen2.5-7B)
voice : retro-tts /v1/audio/transcriptions (Whisper) -> same chat pipeline
reply -> Telegram sendMessage
No GPU here. Inference is delegated to the two ZeroGPU Spaces. Conversation
memory is an ontology-governed graph (bruno/v1) on the /data bucket volume β€”
see bruno_graph.py (sovereign adapter, ADR-562/563 Fase 1).
Version: 0.8.0
"""
import os
import re
import json
import ipaddress
import logging
import asyncio
import tempfile
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Optional
from bruno_graph import BrunoGraph
import httpx
from fastapi import FastAPI, Request, Header, HTTPException
from fastapi.responses import JSONResponse, PlainTextResponse
# ──────────────────────────────────────────────────────────────────────────
# Config
# ──────────────────────────────────────────────────────────────────────────
VERSION = "0.8.0"
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
HF_TOKEN = os.environ.get("HF_TOKEN", "")
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "")
ALLOWED_CHAT_IDS = {
s.strip() for s in os.environ.get("ALLOWED_CHAT_IDS", "").split(",") if s.strip()
}
# Inference backends (private Spaces β€” reachable via https + Bearer HF_TOKEN)
RETRO_SPACE_URL = os.environ.get(
"RETRO_SPACE_URL", "https://d1d4c-retro-space.hf.space"
)
RETRO_TTS_URL = os.environ.get(
"RETRO_TTS_URL", "https://d1d4c-retro-tts.hf.space"
)
MODEL = os.environ.get("MODEL", "7b")
MAX_HISTORY = int(os.environ.get("MAX_HISTORY", "20")) # messages kept per chat
MAX_TOKENS = int(os.environ.get("MAX_TOKENS", "1024"))
TEMPERATURE = float(os.environ.get("TEMPERATURE", "0.3"))
INFER_TIMEOUT = float(os.environ.get("INFER_TIMEOUT", "120"))
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
TELEGRAM_API = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}"
TELEGRAM_FILE_API = f"https://api.telegram.org/file/bot{TELEGRAM_BOT_TOKEN}"
SYSTEM_PROMPT = os.environ.get(
"SYSTEM_PROMPT",
"Eres Retro Lite, una instancia online y ligera de Retro que corre en "
"HuggingFace para que DΓ­dac pueda hablar contigo desde el mΓ³vil (Telegram) "
"cuando el nodo de casa (satΓ©lite) estΓ‘ offline.\n"
"\n"
"QUIÉN ERES:\n"
"- Eres el modelo Qwen2.5-7B sirviendo como interlocutor conversacional.\n"
"- NO eres el Retro completo de casa: no tienes acceso a sus agentes, a su "
"memoria/HyperGraph, ni a sus herramientas. Eres una versiΓ³n de respaldo "
"para conversar y pensar en voz alta.\n"
"\n"
"QUÉ PUEDES HACER:\n"
"- Conversar, responder preguntas, razonar, redactar y resumir texto.\n"
"- Mantener el hilo de esta conversaciΓ³n (recuerdas los mensajes recientes).\n"
"- Ejecutar comandos bash en el contenedor de HuggingFace donde corres "
"(usa la tool run_bash). Puedes hacer ls, cat, python3, curl, etc. "
"El filesystem es el del Space, no el de casa.\n"
"- Consultar tu memoria de conversaciones pasadas (tools get_memory y search_messages).\n"
"\n"
"QUÉ NO PUEDES HACER (sé honesto, NUNCA lo finjas):\n"
"- No controlas dispositivos, domΓ³tica, ni sistemas externos.\n"
"- No tienes acceso a internet real (salvo endpoints internos de HF), "
"calendario, email ni a la red de casa.\n"
"- No puedes ejecutar acciones en el mundo real ni en otros agentes de Retro.\n"
"- El bash que ejecutas corre en el Space de HF, no en satΓ©lite ni en casa.\n"
"Si te piden algo de lo anterior, dilo con claridad y, si procede, sugiere que "
"lo haga el Retro de casa cuando vuelva a estar online.\n"
"\n"
"ESTILO:\n"
"- Directo, honesto y conciso. Es un chat de mΓ³vil: respuestas breves por "
"defecto (2-5 frases), sin markdown pesado ni listas largas salvo que se pidan.\n"
"- Responde en el idioma de DΓ­dac (normalmente espaΓ±ol).\n"
"- No te inventes capacidades ni datos. Ante la duda, reconoce lo que no sabes.",
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
log = logging.getLogger("retro-cpu")
app = FastAPI(title="retro-cpu", version=VERSION)
# ──────────────────────────────────────────────────────────────────────────
# "Public-but-closed" hardening
#
# HF Space visibility is all-or-nothing, so when the Space is public we close it
# at the application layer: only the authenticated Telegram webhook does anything.
# - /webhook : Telegram IP allowlist AND secret token AND chat_id allowlist
# - /health : detailed only with the secret; bare {"ok":true} otherwise
# - / : bare 200 (required by HF init-process readiness probe)
# - /admin/* : removed (webhook is registered out-of-band via the Telegram API)
#
# Telegram's official webhook source ranges (Bot API docs):
# 149.154.160.0/20 and 91.108.4.0/22
# NOTE: behind HF's reverse proxy the real client IP arrives in X-Forwarded-For;
# the secret token remains the strong control (XFF can be spoofed), the IP filter
# is defense-in-depth. Toggle with ENABLE_IP_FILTER=false if it ever blocks real
# Telegram delivery (we calibrate against the first real message).
# ──────────────────────────────────────────────────────────────────────────
ENABLE_IP_FILTER = os.environ.get("ENABLE_IP_FILTER", "true").lower() == "true"
TELEGRAM_IP_NETS = [
ipaddress.ip_network("149.154.160.0/20"),
ipaddress.ip_network("91.108.4.0/22"),
]
def _client_ip(request: Request) -> Optional[str]:
"""Original client IP. Behind HF's proxy it's the first X-Forwarded-For entry."""
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else None
def _ip_is_telegram(ip: Optional[str]) -> bool:
if not ip:
return False
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False
return any(addr in net for net in TELEGRAM_IP_NETS)
# ──────────────────────────────────────────────────────────────────────────
# Conversation memory β€” ontology-governed graph (bruno/v1, sovereign)
#
# Replaces the old flat-JSON transcript. Messages persist as bruno/v1 `Message`
# nodes (append-only, A1-unique) in a sovereign sqlite graph on the /data bucket;
# the context carousel is a query over the active session. ContextMemory (singleton
# per chat) is scaffolded across sessions. Type-E gate: accept-bruno-v1-gate-20260621.
# ──────────────────────────────────────────────────────────────────────────
_bruno: Optional[BrunoGraph] = None
def get_bruno() -> BrunoGraph:
"""Lazily open the sovereign bruno/v1 graph. Falls back to /tmp if /data is RO."""
global _bruno
if _bruno is None:
if DATA_DIR.exists() and os.access(DATA_DIR, os.W_OK):
db_dir = DATA_DIR
persistent = True
else:
db_dir = Path(tempfile.gettempdir())
persistent = False
log.warning("/data not writable β€” bruno_graph on ephemeral %s", db_dir)
_bruno = BrunoGraph(
db_path=str(db_dir / "bruno_graph.db"),
ontology_path=str(Path(__file__).with_name("bruno_v1.json")),
)
log.info("BrunoGraph ready (bruno/v1, schema_enforced, persistent=%s) at %s",
persistent, db_dir / "bruno_graph.db")
return _bruno
def _touch_context_memory(bruno: BrunoGraph, chat_id: int, new_session: bool) -> None:
"""Ensure a ContextMemory node exists for this chat; bump session_count on /reset.
Creates a placeholder on first contact (synthesis_strategy="pending_fase1").
Real content is written asynchronously by synthesize_context_memory() after each turn.
"""
try:
cm = bruno.get_context_memory(chat_id)
if cm is None:
note = f"Chat {chat_id}. Memoria inicializada. SΓ­ntesis en curso."
bruno.upsert_context_memory(chat_id, note, synthesis_strategy="pending_fase1")
elif new_session:
bruno.upsert_context_memory(
chat_id, cm.get("content", ""),
bump_session=True,
synthesis_strategy=cm.get("synthesis_strategy", "pending_fase1"),
)
except Exception as exc: # noqa: BLE001 β€” memory upkeep must never break a reply
log.warning("ContextMemory upkeep failed for %s: %s", chat_id, exc)
def build_system_prompt(bruno: BrunoGraph, chat_id: int) -> str:
"""Return the base SYSTEM_PROMPT with cross-session context injected when available.
Injects ContextMemory content only when it carries a real LLM synthesis
(synthesis_strategy == "llm_qwen2.5-7b"). Placeholders are silently skipped.
"""
cm = bruno.get_context_memory(chat_id)
if cm and cm.get("synthesis_strategy") == "llm_qwen2.5-7b":
content = cm.get("content", "").strip()
if content:
return (
SYSTEM_PROMPT
+ "\n\nCONTEXTO DE SESIONES ANTERIORES (resumen de memoria):\n"
+ content
+ "\n\nUsa este contexto si es relevante para la conversaciΓ³n actual."
)
return SYSTEM_PROMPT
async def synthesize_context_memory(
bruno: BrunoGraph, chat_id: int, history: List[Dict[str, str]]
) -> None:
"""Background task: synthesize recent conversation β†’ upsert ContextMemory.
Calls retro-space with a low-token meta-prompt (≀200 output tokens). Runs
AFTER the reply is already sent (asyncio.create_task) so latency = 0 for user.
A failed synthesis is logged but never raises (never breaks the next turn).
"""
try:
# Use last 10 turns to keep the synthesis prompt short
recent = history[-10:]
conv_text = "\n".join(
f"{m['role'].upper()}: {m['content'][:300]}" for m in recent
)
messages = [
{
"role": "system",
"content": (
"Eres un asistente de memoria. Tu ΓΊnica tarea es extraer de la "
"conversaciΓ³n un resumen CONCISO (mΓ‘x 150 palabras) con los datos "
"relevantes sobre el usuario: intereses, proyectos mencionados, "
"preferencias, contexto personal. Solo hechos, sin florituras."
),
},
{
"role": "user",
"content": f"ConversaciΓ³n reciente:\n\n{conv_text}\n\nΒΏQuΓ© debo recordar?",
},
]
payload = {
"model": MODEL,
"messages": messages,
"temperature": 0.1,
"max_tokens": 200,
}
headers = {"Authorization": f"Bearer {HF_TOKEN}", "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=INFER_TIMEOUT) as client:
r = await client.post(
f"{RETRO_SPACE_URL}/v1/chat/completions", json=payload, headers=headers
)
r.raise_for_status()
synthesis = r.json()["choices"][0]["message"]["content"].strip()
bruno.upsert_context_memory(chat_id, synthesis, synthesis_strategy="llm_qwen2.5-7b")
log.info("ContextMemory synthesized for chat %s (%d chars)", chat_id, len(synthesis))
except Exception as exc: # noqa: BLE001
log.warning("ContextMemory synthesis failed for chat %s: %s", chat_id, exc)
# ──────────────────────────────────────────────────────────────────────────
# Tool definitions (OpenAI format) β€” Bruno's graph query capabilities
# ──────────────────────────────────────────────────────────────────────────
BRUNO_TOOLS = [
{
"type": "function",
"function": {
"name": "get_memory",
"description": (
"Recupera el resumen de memoria de conversaciones anteriores con este usuario. "
"Úsala cuando el usuario pregunte sobre conversaciones pasadas, qué recuerdas "
"de Γ©l, o cuando el contexto histΓ³rico sea relevante para responder bien."
),
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "search_messages",
"description": (
"Busca en el historial completo de conversaciones (todas las sesiones) los "
"mensajes que contengan una palabra clave. Úsala cuando el usuario pregunte "
"sobre algo especΓ­fico que puede haber mencionado en sesiones anteriores."
),
"parameters": {
"type": "object",
"properties": {
"keyword": {
"type": "string",
"description": "Palabra o frase a buscar en el historial",
}
},
"required": ["keyword"],
},
},
},
{
"type": "function",
"function": {
"name": "run_bash",
"description": (
"Ejecuta un comando bash en el contenedor de HuggingFace donde corre Bruno. "
"Úsalo cuando el usuario pida hacer ls, cat, python3, ver archivos, comprobar "
"el entorno, etc. El filesystem es el del Space (no el de casa/satΓ©lite). "
"Devuelve stdout+stderr combinados. Timeout 15s."
),
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Comando bash a ejecutar (e.g. 'ls /home/user/app', 'python3 --version')",
}
},
"required": ["command"],
},
},
},
]
TOOL_MAX_ROUNDS = 3 # safety cap: at most N tool-call rounds per user turn
def _execute_tool(name: str, args: dict, bruno: BrunoGraph, chat_id: int) -> str:
"""Execute a Bruno tool call locally (SQLite β€” microseconds). Never raises."""
try:
if name == "get_memory":
cm = bruno.get_context_memory(chat_id)
if cm and cm.get("synthesis_strategy") == "llm_qwen2.5-7b":
content = cm.get("content", "").strip()
if content:
return content
return "No hay memoria sintetizada todavΓ­a de conversaciones anteriores."
if name == "search_messages":
keyword = str(args.get("keyword", "")).strip().lower()
if not keyword:
return "Error: se requiere una palabra clave para buscar."
rows = bruno.conn.execute(
"SELECT properties FROM nodes "
"WHERE graph_id = ? AND domain_type = 'Message' "
"AND lower(json_extract(properties, '$.content')) LIKE ? "
"ORDER BY created_at DESC LIMIT 5",
(bruno.graph_id, f"%{keyword}%"),
).fetchall()
if not rows:
return f"No encontrΓ© mensajes que contengan '{keyword}'."
parts = []
for r in rows:
p = json.loads(r[0])
parts.append(
f"[{p['role']}] sesiΓ³n {p.get('session_ref', '?')}: "
f"{p['content'][:300]}"
)
return "\n---\n".join(parts)
if name == "run_bash":
command = args.get("command", "").strip()
if not command:
return "Error: se requiere un comando bash."
try:
proc = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=15,
cwd="/home/user/app",
)
output = (proc.stdout + proc.stderr).strip()
if not output:
output = f"(sin salida, exit code {proc.returncode})"
elif len(output) > 2000:
output = output[:2000] + "\n…(truncado)"
return output
except subprocess.TimeoutExpired:
return "Error: el comando superΓ³ el lΓ­mite de 15 segundos."
return f"Herramienta '{name}' no reconocida."
except Exception as exc: # noqa: BLE001
log.warning("_execute_tool %s error: %s", name, exc)
return f"Error ejecutando '{name}': {exc}"
# ──────────────────────────────────────────────────────────────────────────
# Backend calls
# ──────────────────────────────────────────────────────────────────────────
async def _call_retro_space(
messages: List[Dict[str, Any]],
tools: Optional[List] = None,
) -> dict:
"""Raw HTTP call to retro-space /v1/chat/completions. Returns full response dict."""
payload: Dict[str, Any] = {
"model": MODEL,
"messages": messages,
"temperature": 0 if tools else TEMPERATURE,
"max_tokens": MAX_TOKENS,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = "auto"
headers = {"Authorization": f"Bearer {HF_TOKEN}", "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=INFER_TIMEOUT) as client:
r = await client.post(
f"{RETRO_SPACE_URL}/v1/chat/completions", json=payload, headers=headers
)
r.raise_for_status()
return r.json()
async def infer_chat(history: List[Dict[str, str]], system_prompt: str = SYSTEM_PROMPT) -> str:
"""Single-shot inference (no tools). Returns text content."""
messages = [{"role": "system", "content": system_prompt}] + history
data = await _call_retro_space(messages)
return (data["choices"][0]["message"]["content"] or "").strip()
async def run_with_tools(
history: List[Dict[str, str]],
sys_prompt: str,
bruno: BrunoGraph,
chat_id: int,
) -> str:
"""Inference loop with tool calling. Returns final text reply.
At most TOOL_MAX_ROUNDS rounds:
1. Call retro-space with BRUNO_TOOLS.
2. If finish_reason == "tool_calls" β†’ execute locally β†’ append results β†’ repeat.
3. If finish_reason == "stop" (or max rounds hit) β†’ return text.
"""
messages = [{"role": "system", "content": sys_prompt}] + list(history)
msg: Dict[str, Any] = {}
for round_num in range(TOOL_MAX_ROUNDS):
data = await _call_retro_space(messages, tools=BRUNO_TOOLS)
choice = data["choices"][0]
finish_reason = choice.get("finish_reason", "stop")
msg = choice["message"]
if finish_reason != "tool_calls":
return (msg.get("content") or "").strip()
tool_calls = msg.get("tool_calls") or []
if not tool_calls:
return (msg.get("content") or "").strip()
# Append assistant's tool_call message to the working context
messages.append({
"role": "assistant",
"content": msg.get("content") or "",
"tool_calls": tool_calls,
})
# Execute each tool and append results
for tc in tool_calls:
fn_name = tc["function"]["name"]
try:
fn_args = json.loads(tc["function"].get("arguments") or "{}")
except (json.JSONDecodeError, KeyError):
fn_args = {}
result = _execute_tool(fn_name, fn_args, bruno, chat_id)
log.info(
"tool_call round=%d: %s(%r) β†’ %d chars",
round_num + 1, fn_name, fn_args, len(result),
)
messages.append({
"role": "tool",
"tool_call_id": tc["id"],
"content": result,
})
# Safety: max rounds exhausted
log.warning("run_with_tools: TOOL_MAX_ROUNDS=%d exhausted for chat %s", TOOL_MAX_ROUNDS, chat_id)
return (msg.get("content") or "(Sin respuesta tras ejecutar herramientas)").strip()
async def transcribe_voice(file_bytes: bytes, filename: str = "voice.ogg") -> str:
"""Call retro-tts Whisper endpoint with the downloaded voice file."""
headers = {"Authorization": f"Bearer {HF_TOKEN}"}
files = {"file": (filename, file_bytes, "audio/ogg")}
data = {"language": "es"}
async with httpx.AsyncClient(timeout=INFER_TIMEOUT) as client:
r = await client.post(
f"{RETRO_TTS_URL}/v1/audio/transcriptions",
files=files,
data=data,
headers=headers,
)
r.raise_for_status()
return r.json().get("text", "").strip()
# ──────────────────────────────────────────────────────────────────────────
# Telegram helpers
# ──────────────────────────────────────────────────────────────────────────
async def tg_send_message(chat_id: int, text: str) -> None:
# Telegram hard limit 4096 chars/message β€” chunk if needed.
chunks = [text[i : i + 4000] for i in range(0, len(text), 4000)] or ["(sin respuesta)"]
async with httpx.AsyncClient(timeout=30) as client:
for chunk in chunks:
await client.post(
f"{TELEGRAM_API}/sendMessage",
json={"chat_id": chat_id, "text": chunk},
)
async def tg_send_typing(chat_id: int) -> None:
try:
async with httpx.AsyncClient(timeout=10) as client:
await client.post(
f"{TELEGRAM_API}/sendChatAction",
json={"chat_id": chat_id, "action": "typing"},
)
except Exception: # noqa: BLE001
pass
async def tg_download_file(file_id: str) -> Optional[bytes]:
async with httpx.AsyncClient(timeout=60) as client:
r = await client.get(f"{TELEGRAM_API}/getFile", params={"file_id": file_id})
r.raise_for_status()
file_path = r.json()["result"]["file_path"]
fr = await client.get(f"{TELEGRAM_FILE_API}/{file_path}")
fr.raise_for_status()
return fr.content
# ──────────────────────────────────────────────────────────────────────────
# Core handler
#
# IMPORTANT (HF Spaces network constraint, discovered 2026-06-20):
# HF Spaces CANNOT open outbound connections to api.telegram.org (ConnectTimeout),
# although they CAN reach HF-internal hosts (*.hf.space) β€” so inference works but
# sending a reply via the Bot API does not. The fix is to reply *inline* in the
# webhook HTTP response: Telegram executes a single method returned as the JSON
# body. No outbound to Telegram required.
# -> compute_reply() returns the inline method dict (or None).
# -> Voice notes need getFile + file download (both api.telegram.org) which are
# unreachable, so voice is not supported in this mode.
# ──────────────────────────────────────────────────────────────────────────
def _say(chat_id: int, text: str) -> Dict[str, Any]:
"""Build an inline sendMessage method for the webhook response."""
return {"method": "sendMessage", "chat_id": chat_id, "text": text[:4096]}
async def compute_reply(update: Dict[str, Any]) -> Optional[Dict[str, Any]]:
message = update.get("message") or update.get("edited_message")
if not message:
return None
chat = message.get("chat", {})
chat_id = chat.get("id")
if chat_id is None:
return None
# Security: allowlist
if str(chat_id) not in ALLOWED_CHAT_IDS:
log.warning("Rejected message from unauthorized chat_id=%s", chat_id)
return None
user_text: Optional[str] = message.get("text")
# Voice notes require outbound to api.telegram.org (blocked on HF Spaces).
if user_text is None and "voice" in message:
return _say(
chat_id,
"🎀 Las notas de voz no funcionan en esta instancia online: el Space "
"de HuggingFace no puede descargar el audio de Telegram (outbound "
"bloqueado). EscrΓ­beme texto y te respondo.",
)
if not user_text:
return None
bruno = get_bruno()
# Slash commands
if user_text.strip() == "/start":
return _say(chat_id, "πŸ€– Retro online (HF). EscrΓ­beme y te respondo con Qwen2.5-7B.")
if user_text.strip() == "/reset":
bruno.reset_session(chat_id) # rotate epoch β€” old turns persist (append-only)
_touch_context_memory(bruno, chat_id, new_session=True)
return _say(chat_id, "🧹 Nueva conversación iniciada (el historial anterior se conserva en memoria).")
# Conversation pipeline β€” persist the user turn, build the carousel from the graph
bruno.append_message(chat_id, "user", user_text, source="telegram")
_touch_context_memory(bruno, chat_id, new_session=False)
history = bruno.carousel(chat_id, n=MAX_HISTORY)
# Fase 1: inject cross-session context when a real synthesis is available
sys_prompt = build_system_prompt(bruno, chat_id)
try:
# Fase 2: tool-calling loop (get_memory, search_messages against bruno_graph)
reply = await run_with_tools(history, sys_prompt, bruno, chat_id)
except httpx.HTTPStatusError as exc:
log.error("Inference HTTP error: %s β€” %s", exc, exc.response.text[:200])
return _say(
chat_id,
"⚠️ El servidor de inferencia falló (puede estar arrancando). "
"IntΓ©ntalo de nuevo en ~1 min.",
)
except Exception as exc: # noqa: BLE001
log.error("Inference failed: %s", exc)
return _say(chat_id, "⚠️ Error procesando el mensaje.")
bruno.append_message(chat_id, "assistant", reply, source="qwen2.5-7b")
# Fase 1: background synthesis β€” runs AFTER reply is sent, zero user latency
full_history = bruno.carousel(chat_id, n=MAX_HISTORY)
asyncio.create_task(synthesize_context_memory(bruno, chat_id, full_history))
return _say(chat_id, reply)
# ──────────────────────────────────────────────────────────────────────────
# Routes
# ──────────────────────────────────────────────────────────────────────────
@app.get("/")
async def root() -> JSONResponse:
# Bare 200 for HF's init-process readiness probe β€” no info disclosed.
return JSONResponse({"ok": True})
@app.get("/health")
async def health(
request: Request,
x_telegram_bot_api_secret_token: Optional[str] = Header(None),
) -> JSONResponse:
# Detailed health only with the shared secret; otherwise bare ok.
if not WEBHOOK_SECRET or x_telegram_bot_api_secret_token != WEBHOOK_SECRET:
return JSONResponse({"ok": True})
return JSONResponse(
{
"status": "ok",
"version": VERSION,
"ready": True,
"model": MODEL,
"allowlist_size": len(ALLOWED_CHAT_IDS),
"data_writable": os.access(DATA_DIR, os.W_OK) if DATA_DIR.exists() else False,
"ip_filter": ENABLE_IP_FILTER,
"memory": "bruno/v1 (schema_enforced)",
}
)
@app.post("/webhook")
async def webhook(
request: Request,
x_telegram_bot_api_secret_token: Optional[str] = Header(None),
) -> JSONResponse:
# Layer 1 β€” Telegram IP allowlist (defense-in-depth). Log the observed IP so
# the filter can be calibrated against the first real Telegram delivery.
ip = _client_ip(request)
if ENABLE_IP_FILTER and not _ip_is_telegram(ip):
log.warning("Rejected /webhook from non-Telegram IP: %s (xff=%s)",
ip, request.headers.get("x-forwarded-for"))
raise HTTPException(status_code=403, detail="forbidden")
# Layer 2 β€” Telegram secret token (strong control)
if WEBHOOK_SECRET and x_telegram_bot_api_secret_token != WEBHOOK_SECRET:
raise HTTPException(status_code=403, detail="bad secret token")
update = await request.json()
# Reply INLINE in the webhook response (HF Spaces can't reach api.telegram.org
# for outbound sendMessage). Telegram executes the returned method. The whole
# pipeline (incl. inference) runs within Telegram's webhook timeout (~60s);
# warm inference is sub-second, cold start ~30-45s.
# (Layer 3 β€” chat_id allowlist β€” is enforced inside compute_reply.)
try:
method = await compute_reply(update)
except Exception as exc: # noqa: BLE001
log.error("compute_reply crashed: %s", exc)
method = None
if method is None:
return JSONResponse({"ok": True})
return JSONResponse(method)