feat: 1-to-1 message render + web data-lake backend

This commit is contained in:
hh
2026-05-31 01:45:05 +02:00
parent f0afb7ec5b
commit 75425d1bee
110 changed files with 10199 additions and 54 deletions
+404
View File
@@ -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)