# pxy_bots/canonical.py from typing import Any, Dict, Optional def _pick_photo(sizes): # Telegram sends photos as array of sizes; pick the largest if not sizes: return None sizes = sorted(sizes, key=lambda s: (s.get("width", 0) * s.get("height", 0)), reverse=True) top = sizes[0] return { "type": "photo", "file_id": top.get("file_id"), "mime": "image/jpeg", # Telegram photos are JPEG "size_bytes": None, # Telegram doesn't include bytes here; leave None "width": top.get("width"), "height": top.get("height"), } def _extract_media(msg: Dict[str, Any]) -> Optional[Dict[str, Any]]: if "photo" in msg: return _pick_photo(msg.get("photo") or []) if "voice" in msg: v = msg["voice"] return {"type": "voice", "file_id": v.get("file_id"), "mime": v.get("mime_type"), "size_bytes": v.get("file_size"), "duration": v.get("duration")} if "audio" in msg: a = msg["audio"] return {"type": "audio", "file_id": a.get("file_id"), "mime": a.get("mime_type"), "size_bytes": a.get("file_size"), "duration": a.get("duration")} if "video" in msg: v = msg["video"] return {"type": "video", "file_id": v.get("file_id"), "mime": v.get("mime_type"), "size_bytes": v.get("file_size"), "duration": v.get("duration"), "width": v.get("width"), "height": v.get("height")} if "video_note" in msg: v = msg["video_note"] return {"type": "video_note", "file_id": v.get("file_id"), "mime": None, "size_bytes": v.get("file_size"), "duration": v.get("duration"), "length": v.get("length")} if "animation" in msg: a = msg["animation"] return {"type": "animation", "file_id": a.get("file_id"), "mime": a.get("mime_type"), "size_bytes": a.get("file_size")} if "document" in msg: d = msg["document"] return {"type": "document", "file_id": d.get("file_id"), "mime": d.get("mime_type"), "size_bytes": d.get("file_size"), "file_name": d.get("file_name")} return None def build_req_v1(update: Dict[str, Any], bot_name: str) -> Dict[str, Any]: """ Normalize a Telegram update into our canonical req.v1 envelope. Pure function. No network, no state. """ schema_version = "req.v1" update_id = update.get("update_id") # Determine primary container: message, edited_message, callback_query msg = update.get("message") or update.get("edited_message") cbq = update.get("callback_query") # Chat/user basics if msg: chat = msg.get("chat") or {} user = msg.get("from") or {} message_id = msg.get("message_id") ts = msg.get("date") text = msg.get("text") caption = msg.get("caption") location = msg.get("location") media = _extract_media(msg) trigger = "message" elif cbq: m = cbq.get("message") or {} chat = m.get("chat") or {} user = cbq.get("from") or {} message_id = m.get("message_id") ts = m.get("date") or None text = None caption = None location = None media = None trigger = "callback" else: # Fallback for other update types we haven't mapped yet chat = {} user = update.get("from") or {} message_id = None ts = None text = None caption = None location = None media = None trigger = "unknown" # Command name (if text/caption starts with '/') raw_cmd = None if text and isinstance(text, str) and text.startswith("/"): raw_cmd = text.split()[0][1:] elif caption and isinstance(caption, str) and caption.startswith("/"): raw_cmd = caption.split()[0][1:] elif cbq and isinstance(cbq.get("data"), str): raw_cmd = None # callbacks carry 'action' instead # Build envelope env = { "schema_version": schema_version, "bot": {"username": bot_name}, "chat": {"id": chat.get("id"), "type": chat.get("type")}, "user": {"id": user.get("id"), "language": user.get("language_code")}, "command": { "name": raw_cmd, "version": 1, "trigger": ("text_command" if raw_cmd and trigger == "message" else ("callback" if trigger == "callback" else trigger)), }, "input": { "text": text, "caption": caption, "args_raw": text or caption, "media": media, "location": ({"lat": location.get("latitude"), "lon": location.get("longitude")} if location else None), }, "callback": ( {"id": cbq.get("id"), "data": cbq.get("data"), "origin": {"message_id": message_id, "chat_id": chat.get("id")}} if cbq else None ), "context": { "message_id": message_id, "update_id": update_id, "ts": ts, "idempotency_key": f"tg:{message_id}:{user.get('id')}" if message_id and user.get("id") else None, }, } return env