Render Error
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Ekaropolus 2025-09-16 19:46:33 -06:00
parent 4dde3f74ee
commit 19b34d9468
2 changed files with 65 additions and 106 deletions

View File

@ -4,128 +4,65 @@ import logging
from typing import Dict, List, Optional from typing import Dict, List, Optional
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Bot, Message from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Bot, Message
from telegram.error import TelegramError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _build_keyboard(buttons: Optional[List[dict]]) -> Optional[InlineKeyboardMarkup]: # ... _build_keyboard stays the same ...
"""
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: 164 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]: 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 [] msgs = spec.get("messages") or []
top_buttons = spec.get("buttons") or None top_buttons = spec.get("buttons") or None
sent: List[Message] = [] sent: List[Message] = []
for i, m in enumerate(msgs): for i, m in enumerate(msgs):
mtype = (m.get("type") or "text").lower() 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)) kb = _build_keyboard(m.get("buttons") or (top_buttons if i == len(msgs) - 1 else None))
if mtype == "text": try:
text = m.get("text") or "" if mtype == "text":
msg = await bot.send_message(chat_id=chat_id, text=text, reply_markup=kb) text = m.get("text") or ""
sent.append(msg) msg = await bot.send_message(chat_id=chat_id, text=text, reply_markup=kb)
sent.append(msg)
elif mtype == "photo": elif mtype == "photo":
# Prefer file_id if provided, else media_url (Telegram can fetch by URL) file_id = m.get("file_id")
file_id = m.get("file_id") media_url = m.get("media_url")
media_url = m.get("media_url") caption = m.get("caption") or None
caption = m.get("caption") or None if not (file_id or media_url):
if not (file_id or media_url): logger.warning("renderer: photo without file_id/media_url; skipping")
logger.warning("renderer: photo without file_id/media_url; skipping") continue
continue msg = await bot.send_photo(chat_id=chat_id, photo=file_id or media_url, caption=caption, reply_markup=kb)
msg = await bot.send_photo(chat_id=chat_id, photo=file_id or media_url, caption=caption, reply_markup=kb) sent.append(msg)
sent.append(msg)
elif mtype == "document": elif mtype == "document":
file_id = m.get("file_id") file_id = m.get("file_id")
media_url = m.get("media_url") media_url = m.get("media_url")
caption = m.get("caption") or None caption = m.get("caption") or None
if not (file_id or media_url): if not (file_id or media_url):
logger.warning("renderer: document without file_id/media_url; skipping") logger.warning("renderer: document without file_id/media_url; skipping")
continue continue
msg = await bot.send_document(chat_id=chat_id, document=file_id or media_url, caption=caption, reply_markup=kb) msg = await bot.send_document(chat_id=chat_id, document=file_id or media_url, caption=caption, reply_markup=kb)
sent.append(msg) sent.append(msg)
elif mtype == "video": elif mtype == "video":
file_id = m.get("file_id") file_id = m.get("file_id")
media_url = m.get("media_url") media_url = m.get("media_url")
caption = m.get("caption") or None caption = m.get("caption") or None
if not (file_id or media_url): if not (file_id or media_url):
logger.warning("renderer: video without file_id/media_url; skipping") logger.warning("renderer: video without file_id/media_url; skipping")
continue continue
msg = await bot.send_video(chat_id=chat_id, video=file_id or media_url, caption=caption, reply_markup=kb) msg = await bot.send_video(chat_id=chat_id, video=file_id or media_url, caption=caption, reply_markup=kb)
sent.append(msg) sent.append(msg)
else: else:
logger.warning("renderer: unsupported message type=%s; skipping", mtype) logger.warning("renderer: unsupported message type=%s; skipping", mtype)
continue
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 return sent

View File

@ -24,6 +24,8 @@ openai.api_key = os.getenv("OPENAI_API_KEY")
# at top with other imports # at top with other imports
from .renderer import render_spec 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 "{}") payload = json.loads(request.body.decode("utf-8") or "{}")
except json.JSONDecodeError: except json.JSONDecodeError:
return JsonResponse({"ok": False, "error": "invalid_json"}, status=400) 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) # Build canonical req.v1 (LOG ONLY for now)
try: try: