feat: 1-to-1 message render + web data-lake backend
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
from pydantic import ValidationError
|
||||
|
||||
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")
|
||||
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=(media_row["mime"] if media_row else None) or obj.get("mime_type"),
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user