import json from datetime import datetime from typing import Any import asyncpg from pydantic import ValidationError from utils.files import media_file_name from utils.read.models import ( ContactView, EntityView, ForwardView, InlineButton, LocationView, MediaRef, MessageView, PollOption, PollView, ReactionCount, ReplyView, ServiceView, StickerView, WebPageView, ) _MEDIA_KEYS = ( "photo", "video", "animation", "voice", "video_note", "audio", "document", "sticker", ) def load_raw(raw: str | None) -> dict[str, Any]: if not raw: return {} try: parsed = json.loads(raw) except (ValueError, TypeError): return {} return parsed if isinstance(parsed, dict) else {} def _enum(value: object) -> str | None: if not isinstance(value, str): return None return value.rsplit(".", 1)[-1].lower() def _parse_dt(value: object) -> datetime | None: if not isinstance(value, str): return None try: return datetime.fromisoformat(value) except ValueError: return None def _peer_name(user: dict[str, Any]) -> str | None: name = " ".join( part for part in (user.get("first_name"), user.get("last_name")) if part ) return name or user.get("username") def _entities(raw: dict[str, Any]) -> list[EntityView]: source = raw.get("entities") or raw.get("caption_entities") or [] if not isinstance(source, list): return [] out: list[EntityView] = [] for item in source: if not isinstance(item, dict): continue kind = _enum(item.get("type")) offset = item.get("offset") length = item.get("length") if kind is None or not isinstance(offset, int) or not isinstance(length, int): continue custom = item.get("custom_emoji_id") out.append( EntityView( type=kind, offset=offset, length=length, url=item.get("url"), custom_emoji_id=str(custom) if custom is not None else None, language=item.get("language"), ) ) return out def _media_kind(message: dict[str, Any]) -> str | None: kind = _enum(message.get("media")) if kind: return kind for key in _MEDIA_KEYS: if key in message: return key return None def _reply(raw: dict[str, Any]) -> ReplyView | None: reply = raw.get("reply_to_message") reply_id = raw.get("reply_to_message_id") if not isinstance(reply, dict): return ReplyView(message_id=reply_id) if reply_id else None sender = reply.get("from_user") sender_chat = reply.get("sender_chat") sender_id = None sender_name = None if isinstance(sender, dict): sender_id = sender.get("id") sender_name = _peer_name(sender) elif isinstance(sender_chat, dict): sender_id = sender_chat.get("id") sender_name = sender_chat.get("title") return ReplyView( message_id=reply.get("id") or reply_id, sender_id=sender_id, sender_name=sender_name, text=reply.get("text") or reply.get("caption"), media_kind=_media_kind(reply), ) def _forward(raw: dict[str, Any]) -> ForwardView | None: origin = raw.get("forward_origin") if not isinstance(origin, dict): return None tag = origin.get("_") date = _parse_dt(origin.get("date")) if tag == "MessageOriginUser": user = origin.get("sender_user") user = user if isinstance(user, dict) else {} return ForwardView( kind="user", from_id=user.get("id"), from_name=_peer_name(user), date=date ) if tag == "MessageOriginChannel": chat = origin.get("chat") chat = chat if isinstance(chat, dict) else {} return ForwardView( kind="channel", chat_id=chat.get("id"), chat_title=chat.get("title"), message_id=origin.get("message_id"), signature=origin.get("author_signature"), date=date, ) return ForwardView( kind="hidden", from_name=origin.get("sender_user_name"), date=date ) def _reactions(raw: dict[str, Any]) -> list[ReactionCount]: container = raw.get("reactions") if not isinstance(container, dict): return [] items = container.get("reactions") if not isinstance(items, list): return [] out: list[ReactionCount] = [] for item in items: if not isinstance(item, dict): continue custom = item.get("custom_emoji_id") out.append( ReactionCount( emoji=item.get("emoji"), custom_emoji_id=str(custom) if custom is not None else None, count=item.get("count") or 0, chosen="chosen_order" in item, ) ) return out def _button_kind(button: dict[str, Any]) -> str: if button.get("url"): return "url" if button.get("callback_data") is not None: return "callback" if "switch_inline_query" in button or "switch_inline_query_current_chat" in button: return "switch" return "other" def _inline_buttons(raw: dict[str, Any]) -> list[list[InlineButton]]: markup = raw.get("reply_markup") if not isinstance(markup, dict): return [] rows = markup.get("inline_keyboard") if not isinstance(rows, list): return [] out: list[list[InlineButton]] = [] for row in rows: if not isinstance(row, list): continue buttons: list[InlineButton] = [] for button in row: if not isinstance(button, dict): continue data = button.get("callback_data") buttons.append( InlineButton( text=button.get("text") or "", kind=_button_kind(button), url=button.get("url"), data=data if isinstance(data, str) else None, ) ) if buttons: out.append(buttons) return out def _web_page(raw: dict[str, Any]) -> WebPageView | None: page = raw.get("web_page") if not isinstance(page, dict) or not page.get("url"): return None return WebPageView( url=page["url"], display_url=page.get("display_url"), type=page.get("type"), site_name=page.get("site_name"), title=page.get("title"), description=page.get("description"), has_photo="photo" in page, ) def _text_of(value: dict[str, Any] | str | None) -> str | None: if isinstance(value, dict): text = value.get("text") return text if isinstance(text, str) else None return value if isinstance(value, str) else None def _poll(raw: dict[str, Any]) -> PollView | None: poll = raw.get("poll") if not isinstance(poll, dict): return None raw_options = poll.get("options") options: list[PollOption] = [] if isinstance(raw_options, list): for option in raw_options: if not isinstance(option, dict): continue options.append( PollOption( text=_text_of(option.get("text")) or "", voter_count=option.get("voter_count") or 0, vote_percentage=option.get("vote_percentage") or 0, correct=option.get("is_correct"), ) ) return PollView( question=_text_of(poll.get("question")) or "", options=options, total_voter_count=poll.get("total_voter_count") or 0, quiz=_enum(poll.get("type")) == "quiz", closed=bool(poll.get("is_closed")), multiple=bool(poll.get("allows_multiple_answers")), anonymous=bool(poll.get("is_anonymous", True)), ) def _contact(raw: dict[str, Any]) -> ContactView | None: contact = raw.get("contact") if not isinstance(contact, dict): return None return ContactView( user_id=contact.get("user_id"), first_name=contact.get("first_name"), last_name=contact.get("last_name"), phone_number=contact.get("phone_number"), ) def _location(raw: dict[str, Any]) -> LocationView | None: venue = raw.get("venue") if isinstance(venue, dict): point = venue.get("location") point = point if isinstance(point, dict) else {} return LocationView( latitude=point.get("latitude"), longitude=point.get("longitude"), title=venue.get("title"), address=venue.get("address"), ) point = raw.get("location") if not isinstance(point, dict): return None return LocationView( latitude=point.get("latitude"), longitude=point.get("longitude") ) def _service(raw: dict[str, Any]) -> ServiceView | None: kind = _enum(raw.get("service")) if kind is None: return None members = raw.get("new_chat_members") or raw.get("left_chat_member") member_ids = None if isinstance(members, list): member_ids = [m["id"] for m in members if isinstance(m, dict) and "id" in m] elif isinstance(members, dict) and "id" in members: member_ids = [members["id"]] pinned = raw.get("pinned_message") call = raw.get("phone_call_ended") return ServiceView( kind=kind, member_ids=member_ids, pinned_message_id=pinned.get("id") if isinstance(pinned, dict) else None, duration=call.get("duration") if isinstance(call, dict) else None, ) def _sticker(raw: dict[str, Any]) -> StickerView | None: sticker = raw.get("sticker") if not isinstance(sticker, dict): return None return StickerView( emoji=sticker.get("emoji"), set_name=sticker.get("set_name"), width=sticker.get("width"), height=sticker.get("height"), mime=sticker.get("mime_type"), is_animated=bool(sticker.get("is_animated")), is_video=bool(sticker.get("is_video")), ) def media_ref_from( message_id: int, raw: dict[str, Any], media_row: asyncpg.Record | None ) -> MediaRef | None: kind = (media_row["kind"] if media_row else None) or _media_kind(raw) if kind is None: return None obj = raw.get(kind) obj = obj if isinstance(obj, dict) else {} width = obj.get("width") or obj.get("length") height = obj.get("height") or obj.get("length") mime = (media_row["mime"] if media_row else None) or obj.get("mime_type") return MediaRef( message_id=message_id, id=media_row["id"] if media_row else None, kind=kind, downloaded=bool(media_row["downloaded"]) if media_row else False, width=width, height=height, duration=obj.get("duration"), mime=mime, file_size=(media_row["file_size"] if media_row else None) or obj.get("file_size"), ttl_seconds=media_row["ttl_seconds"] if media_row else None, extracted_text=media_row["extracted_text"] if media_row else None, file_name=media_file_name(kind, mime, message_id, obj.get("file_name")), ) def _base_fields(row: asyncpg.Record) -> dict[str, Any]: return { "chat_id": row["chat_id"], "message_id": row["message_id"], "date": row["date"], "sender_id": row["sender_id"], "text": row["text"], "has_media": row["has_media"], "is_self_destruct": row["is_self_destruct"], "edited_at": row["edited_at"], "deleted_at": row["deleted_at"], "media_group_id": row["media_group_id"], } def build_message_view( row: asyncpg.Record, raw: dict[str, Any], media: list[MediaRef] ) -> MessageView: base = _base_fields(row) via_bot = raw.get("via_bot") sticker = _sticker(raw) try: return MessageView( **base, entities=_entities(raw), quote=_text_of(raw.get("quote")), reply=_reply(raw), forward=_forward(raw), media=media, reactions=_reactions(raw), inline_buttons=_inline_buttons(raw), web_page=_web_page(raw), poll=_poll(raw), contact=_contact(raw), location=_location(raw), service=_service(raw), via_bot_id=via_bot.get("id") if isinstance(via_bot, dict) else None, sticker=sticker, is_sticker=sticker is not None, is_animated_emoji=False, ) except ValidationError: return MessageView(**base, media=media)