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

This commit is contained in:
h
2026-05-31 01:27:40 +02:00
parent f0afb7ec5b
commit 75425d1bee
110 changed files with 10199 additions and 54 deletions
+1
View File
@@ -34,6 +34,7 @@ class TelegramSettings(BaseSettings):
class ApiSettings(BaseSettings):
host: str = "0.0.0.0" # noqa: S104
port: int = 8080
static_dir: str = "static"
class AuthSettings(BaseSettings):
+11
View File
@@ -0,0 +1,11 @@
import asyncpg
from utils.read.models import AccountView
async def list_accounts(pool: asyncpg.Pool) -> list[AccountView]:
rows = await pool.fetch(
"SELECT account_id, label, phone, tg_user_id, is_active FROM accounts "
"ORDER BY account_id"
)
return [AccountView(**dict(row)) for row in rows]
+30
View File
@@ -0,0 +1,30 @@
import asyncpg
from utils.read.models import AvatarRef
_PEER_UNIQUE_ID = """
SELECT photo_unique_id FROM peers
WHERE account_id = $1 AND peer_id = $2
"""
_CHAT_UNIQUE_ID = """
SELECT photo_unique_id FROM chat_history
WHERE account_id = $1 AND chat_id = $2 AND photo_unique_id IS NOT NULL
ORDER BY ts DESC LIMIT 1
"""
_AVATAR = """
SELECT unique_id, storage_key, downloaded, mime FROM avatars
WHERE account_id = $1 AND owner_id = $2 AND unique_id = $3
"""
async def current_avatar(
pool: asyncpg.Pool, account_id: int, owner_kind: str, owner_id: int
) -> AvatarRef | None:
query = _PEER_UNIQUE_ID if owner_kind == "peer" else _CHAT_UNIQUE_ID
unique_id = await pool.fetchval(query, account_id, owner_id)
if unique_id is None:
return None
row = await pool.fetchrow(_AVATAR, account_id, owner_id, unique_id)
return AvatarRef(**dict(row)) if row else None
+106 -6
View File
@@ -1,13 +1,46 @@
import asyncpg
from utils.read.models import ChatListItem, MessageVersionView, MessageView, Page
from utils.read.message_view import build_message_view, load_raw, media_ref_from
from utils.read.models import (
ChatListItem,
MediaRef,
MessageVersionView,
MessageView,
Page,
)
_MESSAGE_COLS = (
"chat_id, message_id, date, sender_id, text, "
"has_media, is_self_destruct, edited_at, deleted_at"
"chat_id, message_id, date, sender_id, text, has_media, is_self_destruct, "
"edited_at, deleted_at, raw, raw->>'media_group_id' AS media_group_id"
)
async def _media_map(
pool: asyncpg.Pool, account_id: int, rows: list[asyncpg.Record]
) -> dict[tuple[int, int], asyncpg.Record]:
message_ids = list({row["message_id"] for row in rows})
if not message_ids:
return {}
media_rows = await pool.fetch(
"SELECT id, chat_id, message_id, kind, downloaded, mime, file_size, "
"ttl_seconds FROM media "
"WHERE account_id = $1 AND message_id = ANY($2::bigint[])",
account_id,
message_ids,
)
return {(row["chat_id"], row["message_id"]): row for row in media_rows}
def _single_media(
row: asyncpg.Record, raw: dict, media_by_key: dict[tuple[int, int], asyncpg.Record]
) -> list[MediaRef]:
media_row = media_by_key.get((row["chat_id"], row["message_id"]))
if not (row["has_media"] or media_row):
return []
ref = media_ref_from(row["message_id"], raw, media_row)
return [ref] if ref else []
def _peer_title(
first: str | None, last: str | None, username: str | None
) -> str | None:
@@ -28,7 +61,24 @@ async def list_chats(
"WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS username, "
"(SELECT ch.title FROM chat_history ch "
"WHERE ch.account_id = $1 AND ch.chat_id = m.chat_id "
"AND ch.title IS NOT NULL ORDER BY ch.ts DESC LIMIT 1) AS group_title "
"AND ch.title IS NOT NULL ORDER BY ch.ts DESC LIMIT 1) AS group_title, "
"EXISTS (SELECT 1 FROM avatars a "
"WHERE a.account_id = $1 AND a.owner_id = m.chat_id) AS has_avatar, "
"(SELECT COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool, false) "
"FROM peers p WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS is_bot, "
"(SELECT COALESCE((p.raw->>'is_contact')::bool, (p.raw->>'contact')::bool, "
"false) FROM peers p "
"WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS is_contact, "
"(SELECT ch.raw->'chat'->>'type' = 'ChatType.CHANNEL' FROM chat_history ch "
"WHERE ch.account_id = $1 AND ch.chat_id = m.chat_id "
"AND ch.raw->'chat'->>'type' IS NOT NULL "
"ORDER BY ch.ts DESC LIMIT 1) AS is_broadcast, "
"(SELECT lm.text FROM messages lm "
"WHERE lm.account_id = $1 AND lm.chat_id = m.chat_id "
"ORDER BY lm.date DESC, lm.message_id DESC LIMIT 1) AS last_text, "
"(SELECT lm.sender_id FROM messages lm "
"WHERE lm.account_id = $1 AND lm.chat_id = m.chat_id "
"ORDER BY lm.date DESC, lm.message_id DESC LIMIT 1) AS last_sender_id "
"FROM messages m WHERE m.account_id = $1 "
"GROUP BY m.chat_id ORDER BY last_date DESC LIMIT $2 OFFSET $3",
account_id,
@@ -44,8 +94,15 @@ async def list_chats(
ChatListItem(
chat_id=row["chat_id"],
title=title,
kind="private" if row["chat_id"] > 0 else "group",
has_avatar=row["has_avatar"],
is_bot=bool(row["is_bot"]),
is_contact=bool(row["is_contact"]),
is_broadcast=bool(row["is_broadcast"]),
message_count=row["message_count"],
last_date=row["last_date"],
last_text=row["last_text"],
last_sender_id=row["last_sender_id"],
)
)
return items
@@ -70,7 +127,43 @@ async def get_chat_history(
page.capped_limit,
page.offset,
)
return [MessageView(**dict(row)) for row in rows]
media_by_key = await _media_map(pool, account_id, rows)
parsed = [(row, load_raw(row["raw"])) for row in rows]
views: list[MessageView] = []
index = 0
while index < len(parsed):
group_id = parsed[index][0]["media_group_id"]
end = index + 1
if group_id is not None:
while end < len(parsed) and parsed[end][0]["media_group_id"] == group_id:
end += 1
members = parsed[index:end]
if len(members) == 1:
row, raw = members[0]
views.append(
build_message_view(row, raw, _single_media(row, raw, media_by_key))
)
else:
views.append(_build_album(members, media_by_key))
index = end
return views
def _build_album(
members: list[tuple[asyncpg.Record, dict]],
media_by_key: dict[tuple[int, int], asyncpg.Record],
) -> MessageView:
ordered = sorted(members, key=lambda m: m[0]["message_id"])
media: list[MediaRef] = []
for row, raw in ordered:
media_row = media_by_key.get((row["chat_id"], row["message_id"]))
ref = media_ref_from(row["message_id"], raw, media_row)
if ref:
media.append(ref)
primary_row, primary_raw = next(
((row, raw) for row, raw in ordered if row["text"]), ordered[0]
)
return build_message_view(primary_row, primary_raw, media)
async def get_deleted_messages(
@@ -88,7 +181,14 @@ async def get_deleted_messages(
f"ORDER BY deleted_at DESC LIMIT ${len(params) - 1} OFFSET ${len(params)}",
*params,
)
return [MessageView(**dict(row)) for row in rows]
media_by_key = await _media_map(pool, account_id, rows)
views: list[MessageView] = []
for row in rows:
raw = load_raw(row["raw"])
views.append(
build_message_view(row, raw, _single_media(row, raw, media_by_key))
)
return views
async def get_message_versions(
+27 -1
View File
@@ -1,12 +1,14 @@
import asyncpg
from utils.read.models import MediaView
from utils.read.models import MediaVersionView, MediaView
_MEDIA_COLS = (
"id, account_id, chat_id, message_id, kind, storage_key, file_size, "
"mime, ttl_seconds, downloaded, extracted_text, created_at"
)
_VERSION_COLS = "id, kind, storage_key, file_size, mime, observed_at"
async def get_media(pool: asyncpg.Pool, media_id: int) -> MediaView | None:
row = await pool.fetchrow(
@@ -27,3 +29,27 @@ async def get_message_media(
message_id,
)
return MediaView(**dict(row)) if row else None
async def get_media_versions(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> list[MediaVersionView]:
rows = await pool.fetch(
f"SELECT {_VERSION_COLS} FROM media_versions " # noqa: S608
"WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 "
"ORDER BY observed_at",
account_id,
chat_id,
message_id,
)
return [MediaVersionView(**dict(row)) for row in rows]
async def get_media_version(
pool: asyncpg.Pool, version_id: int
) -> MediaVersionView | None:
row = await pool.fetchrow(
f"SELECT {_VERSION_COLS} FROM media_versions WHERE id = $1", # noqa: S608
version_id,
)
return MediaVersionView(**dict(row)) if row else None
+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)
+162
View File
@@ -16,11 +16,139 @@ class Page(BaseModel):
return min(self.limit, MAX_LIMIT)
class AccountView(BaseModel):
account_id: int
label: str | None
phone: str | None
tg_user_id: int | None
is_active: bool
class ChatListItem(BaseModel):
chat_id: int
title: str | None
kind: str
has_avatar: bool
is_bot: bool
is_contact: bool
is_broadcast: bool
message_count: int
last_date: datetime | None
last_text: str | None
last_sender_id: int | None
class EntityView(BaseModel):
type: str
offset: int
length: int
url: str | None = None
custom_emoji_id: str | None = None
language: str | None = None
class ReplyView(BaseModel):
message_id: int | None = None
sender_id: int | None = None
sender_name: str | None = None
text: str | None = None
media_kind: str | None = None
class ForwardView(BaseModel):
kind: str
from_id: int | None = None
from_name: str | None = None
chat_id: int | None = None
chat_title: str | None = None
message_id: int | None = None
date: datetime | None = None
signature: str | None = None
class MediaRef(BaseModel):
message_id: int
id: int | None = None
kind: str
downloaded: bool = False
width: int | None = None
height: int | None = None
duration: float | None = None
mime: str | None = None
file_size: int | None = None
ttl_seconds: int | None = None
class ReactionCount(BaseModel):
emoji: str | None = None
custom_emoji_id: str | None = None
count: int
chosen: bool = False
class InlineButton(BaseModel):
text: str
kind: str
url: str | None = None
data: str | None = None
class WebPageView(BaseModel):
url: str
display_url: str | None = None
type: str | None = None
site_name: str | None = None
title: str | None = None
description: str | None = None
has_photo: bool = False
class PollOption(BaseModel):
text: str
voter_count: int = 0
vote_percentage: int = 0
correct: bool | None = None
class PollView(BaseModel):
question: str
options: list[PollOption] = []
total_voter_count: int = 0
quiz: bool = False
closed: bool = False
multiple: bool = False
anonymous: bool = True
class ContactView(BaseModel):
user_id: int | None = None
first_name: str | None = None
last_name: str | None = None
phone_number: str | None = None
class LocationView(BaseModel):
latitude: float | None = None
longitude: float | None = None
title: str | None = None
address: str | None = None
class ServiceView(BaseModel):
kind: str
member_ids: list[int] | None = None
pinned_message_id: int | None = None
duration: int | None = None
class StickerView(BaseModel):
emoji: str | None = None
set_name: str | None = None
width: int | None = None
height: int | None = None
mime: str | None = None
is_animated: bool = False
is_video: bool = False
class MessageView(BaseModel):
@@ -33,6 +161,23 @@ class MessageView(BaseModel):
is_self_destruct: bool
edited_at: datetime | None
deleted_at: datetime | None
entities: list[EntityView] = []
quote: str | None = None
reply: ReplyView | None = None
forward: ForwardView | None = None
media_group_id: str | None = None
media: list[MediaRef] = []
reactions: list[ReactionCount] = []
inline_buttons: list[list[InlineButton]] = []
web_page: WebPageView | None = None
poll: PollView | None = None
contact: ContactView | None = None
location: LocationView | None = None
service: ServiceView | None = None
via_bot_id: int | None = None
sticker: StickerView | None = None
is_sticker: bool = False
is_animated_emoji: bool = False
class MessageVersionView(BaseModel):
@@ -56,6 +201,22 @@ class MediaView(BaseModel):
created_at: datetime
class MediaVersionView(BaseModel):
id: int
kind: str
storage_key: str
file_size: int | None
mime: str | None
observed_at: datetime
class AvatarRef(BaseModel):
unique_id: str
storage_key: str | None
downloaded: bool
mime: str | None
class CallbackView(BaseModel):
position: int
label: str | None
@@ -103,6 +264,7 @@ class PeerView(BaseModel):
phone: str | None
photo_unique_id: str | None
is_deleted_account: bool
has_avatar: bool
updated_at: datetime
+22 -2
View File
@@ -2,13 +2,19 @@ import asyncpg
from utils.read.models import Page, PeerHistoryView, PeerView, StoryView
_PEER_COLS = (
"peer_id, first_name, last_name, username, phone, photo_unique_id, "
"is_deleted_account, updated_at, "
"EXISTS (SELECT 1 FROM avatars a WHERE a.account_id = peers.account_id "
"AND a.owner_id = peers.peer_id) AS has_avatar"
)
async def get_peer(
pool: asyncpg.Pool, account_id: int, peer_id: int
) -> PeerView | None:
row = await pool.fetchrow(
"SELECT peer_id, first_name, last_name, username, phone, "
"photo_unique_id, is_deleted_account, updated_at FROM peers "
f"SELECT {_PEER_COLS} FROM peers " # noqa: S608
"WHERE account_id = $1 AND peer_id = $2",
account_id,
peer_id,
@@ -16,6 +22,20 @@ async def get_peer(
return PeerView(**dict(row)) if row else None
async def get_peers(
pool: asyncpg.Pool, account_id: int, ids: list[int]
) -> list[PeerView]:
if not ids:
return []
rows = await pool.fetch(
f"SELECT {_PEER_COLS} FROM peers " # noqa: S608
"WHERE account_id = $1 AND peer_id = ANY($2)",
account_id,
ids,
)
return [PeerView(**dict(row)) for row in rows]
async def get_peer_history(
pool: asyncpg.Pool, account_id: int, peer_id: int
) -> list[PeerHistoryView]: