diff --git a/pxy_bots/renderer.py b/pxy_bots/renderer.py new file mode 100644 index 0000000..0ebe3a1 --- /dev/null +++ b/pxy_bots/renderer.py @@ -0,0 +1,131 @@ +# pxy_bots/renderer.py +import json +import logging +from typing import Dict, List, Optional + +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Bot, Message + +logger = logging.getLogger(__name__) + +def _build_keyboard(buttons: Optional[List[dict]]) -> Optional[InlineKeyboardMarkup]: + """ + Build an InlineKeyboardMarkup from a list of button specs. + + Supported kinds: + - open_url: {"label": "...", "kind": "open_url", "url": "https://..."} + - callback_api: {"label": "...", "kind": "callback_api", "action": "rerun", "params": {...}, "state_token": "..."} + """ + if not buttons: + return None + + rows: List[List[InlineKeyboardButton]] = [] + current_row: List[InlineKeyboardButton] = [] + + for b in buttons: + kind = (b.get("kind") or "").lower() + label = b.get("label") or "…" + + if kind == "open_url": + url = b.get("url") + if not url: + logger.warning("renderer: open_url without url; skipping button") + continue + btn = InlineKeyboardButton(text=label, url=url) + elif kind == "callback_api": + # Keep callback_data small. Prefer server-provided state_token when available. + data = {} + if b.get("state_token"): + data = {"t": b["state_token"]} # compact key to minimize size + else: + # fallback: tiny payload (risk hitting 64B limit if it grows) + data = {"a": b.get("action"), "p": b.get("params") or {}} + try: + payload = json.dumps(data, separators=(",", ":"), ensure_ascii=False) + except Exception: + payload = '{"e":"bad"}' + # Telegram limit: 1–64 bytes; try to keep it tiny + if len(payload.encode("utf-8")) > 64: + logger.warning("renderer: callback_data too long (%s bytes); trimming", len(payload.encode("utf-8"))) + payload = payload.encode("utf-8")[:64].decode("utf-8", errors="ignore") + btn = InlineKeyboardButton(text=label, callback_data=payload) + else: + logger.warning("renderer: unknown button kind=%s; skipping", kind) + continue + + current_row.append(btn) + if len(current_row) >= 3: + rows.append(current_row) + current_row = [] + + if current_row: + rows.append(current_row) + + return InlineKeyboardMarkup(rows) if rows else None + + +async def render_spec(*, bot: Bot, chat_id: int, spec: Dict) -> List[Message]: + """ + Send messages according to a render_spec: + { + "messages": [ + {"type":"text","text":"..."}, + {"type":"photo","media_url":"https://...","caption":"..."} + ], + "buttons":[ ... ], # optional top-level buttons (attached to the LAST message) + "telemetry": {"run_id":"...", "cache_ttl_s": 600}, + "schema_version": "render.v1" + } + + Returns the list of telegram.Message objects sent. + """ + msgs = spec.get("messages") or [] + top_buttons = spec.get("buttons") or None + sent: List[Message] = [] + + for i, m in enumerate(msgs): + mtype = (m.get("type") or "text").lower() + + # Per-message buttons override top-level + kb = _build_keyboard(m.get("buttons") or (top_buttons if i == len(msgs) - 1 else None)) + + if mtype == "text": + text = m.get("text") or "" + msg = await bot.send_message(chat_id=chat_id, text=text, reply_markup=kb) + sent.append(msg) + + elif mtype == "photo": + # Prefer file_id if provided, else media_url (Telegram can fetch by URL) + file_id = m.get("file_id") + media_url = m.get("media_url") + caption = m.get("caption") or None + if not (file_id or media_url): + logger.warning("renderer: photo without file_id/media_url; skipping") + continue + msg = await bot.send_photo(chat_id=chat_id, photo=file_id or media_url, caption=caption, reply_markup=kb) + sent.append(msg) + + elif mtype == "document": + file_id = m.get("file_id") + media_url = m.get("media_url") + caption = m.get("caption") or None + if not (file_id or media_url): + logger.warning("renderer: document without file_id/media_url; skipping") + continue + msg = await bot.send_document(chat_id=chat_id, document=file_id or media_url, caption=caption, reply_markup=kb) + sent.append(msg) + + elif mtype == "video": + file_id = m.get("file_id") + media_url = m.get("media_url") + caption = m.get("caption") or None + if not (file_id or media_url): + logger.warning("renderer: video without file_id/media_url; skipping") + continue + msg = await bot.send_video(chat_id=chat_id, video=file_id or media_url, caption=caption, reply_markup=kb) + sent.append(msg) + + else: + logger.warning("renderer: unsupported message type=%s; skipping", mtype) + continue + + return sent diff --git a/pxy_bots/views.py b/pxy_bots/views.py index 25ed52f..c5a7bbc 100644 --- a/pxy_bots/views.py +++ b/pxy_bots/views.py @@ -22,6 +22,10 @@ from .handlers import ( logger = logging.getLogger(__name__) openai.api_key = os.getenv("OPENAI_API_KEY") +# at top with other imports +from .renderer import render_spec + + # --------------------------- # Canonical req.v1 builder # --------------------------- @@ -264,6 +268,31 @@ async def telegram_webhook(request, bot_name: str): # Convert to telegram.Update update = Update.de_json(payload, Bot(token=bot_instance.token)) + # --- TEMP: demo renderer (safe to delete later) ---------------------- + # If user sends "/_render_demo", send a text + photo + buttons + if update.message and (update.message.text or "").strip() == "/_render_demo": + bot = Bot(token=bot_instance.token) + spec = { + "schema_version": "render.v1", + "messages": [ + {"type": "text", "text": "Demo: render_spec text ✅"}, + { + "type": "photo", + "media_url": "https://upload.wikimedia.org/wikipedia/commons/5/5f/Alameda_Central_CDMX.jpg", + "caption": "Demo: render_spec photo ✅" + } + ], + "buttons": [ + {"label": "Abrir Dashboard", "kind": "open_url", "url": "https://app.polisplexity.tech/"}, + {"label": "Re-ejecutar 10’", "kind": "callback_api", "action": "rerun", "params": {"minutes": 10}} + ], + "telemetry": {"run_id": "demo-run-001"} + } + await render_spec(bot=bot, chat_id=update.effective_chat.id, spec=spec) + return JsonResponse({"status": "ok", "render_demo": True}) + # -------------------------------------------------------------------- + + if not update.message: # No message (e.g., callback handled elsewhere in legacy); ack anyway return JsonResponse({"status": "no message"})