Download app.py from d1d4c/retro-cpu: direct link, hf CLI and curl.
- Browser
- Download file 31 kB
-
https://huggingface.co/spaces/d1d4c/retro-cpu/resolve/main/app.py
- Command line
-
hf download hf://spaces/d1d4c/retro-cpu/app.py
-
curl -L -o app.py https://huggingface.co/spaces/d1d4c/retro-cpu/resolve/main/app.py
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 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def root() -> JSONResponse: | |
| # Bare 200 for HF's init-process readiness probe β no info disclosed. | |
| return JSONResponse({"ok": True}) | |
| 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)", | |
| } | |
| ) | |
| 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) | |