From 19b34d946887311be4a5cffe4c785fb63157229d Mon Sep 17 00:00:00 2001 From: Ekaropolus Date: Tue, 16 Sep 2025 19:46:33 -0600 Subject: [PATCH] Render Error --- pxy_bots/renderer.py | 149 +++++++++++++------------------------------ pxy_bots/views.py | 22 +++++++ 2 files changed, 65 insertions(+), 106 deletions(-) diff --git a/pxy_bots/renderer.py b/pxy_bots/renderer.py index 0ebe3a1..d6622ef 100644 --- a/pxy_bots/renderer.py +++ b/pxy_bots/renderer.py @@ -4,128 +4,65 @@ import logging from typing import Dict, List, Optional from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Bot, Message +from telegram.error import TelegramError 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 - +# ... _build_keyboard stays the same ... 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) + try: + 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 == "photo": + 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 == "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) + 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 + else: + logger.warning("renderer: unsupported message type=%s; skipping", mtype) + + except TelegramError as te: + logger.exception("renderer.telegram_error type=%s err=%s", mtype, te) + # continue to next message + except Exception as e: + logger.exception("renderer.unexpected type=%s err=%s", mtype, e) + # continue to next message return sent diff --git a/pxy_bots/views.py b/pxy_bots/views.py index c5a7bbc..823334c 100644 --- a/pxy_bots/views.py +++ b/pxy_bots/views.py @@ -24,6 +24,8 @@ openai.api_key = os.getenv("OPENAI_API_KEY") # at top with other imports from .renderer import render_spec +# top imports +from django.core.cache import cache # --------------------------- @@ -257,6 +259,26 @@ async def telegram_webhook(request, bot_name: str): payload = json.loads(request.body.decode("utf-8") or "{}") except json.JSONDecodeError: return JsonResponse({"ok": False, "error": "invalid_json"}, status=400) + + # ----- Idempotency / retry guard (drops duplicates for ~90s) ----- + upd_id = payload.get("update_id") + # Fallback if no update_id: use message_id + user_id + fallback_msg = (payload.get("message") or {}).get("message_id") + fallback_user = ((payload.get("message") or {}).get("from") or {}).get("id") + + dedupe_key = None + if upd_id is not None: + dedupe_key = f"tg:update:{upd_id}" + elif fallback_msg and fallback_user: + dedupe_key = f"tg:msg:{fallback_msg}:{fallback_user}" + + if dedupe_key: + # cache.add returns True if the key did not exist (first time), False otherwise + if not cache.add(dedupe_key, "1", timeout=90): + logger.info("tg.idempotent.skip key=%s", dedupe_key) + return JsonResponse({"status": "duplicate_skipped"}) + # ----------------------------------------------------------------- + # Build canonical req.v1 (LOG ONLY for now) try: