This commit is contained in:
parent
4dde3f74ee
commit
19b34d9468
@ -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: 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]:
|
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
|
||||||
|
@ -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:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user