409 lines
12 KiB
Python
409 lines
12 KiB
Python
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)
|