Files
beavergram/backend/src/utils/read/feed.py
T

292 lines
8.9 KiB
Python

import json
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from enum import StrEnum
import asyncpg
from utils.read import chats, peers
from utils.read.accounts import self_user_id
from utils.read.models import (
DEFAULT_LIMIT,
MAX_LIMIT,
ChatListItem,
FeedChat,
FeedItem,
FeedMessagesPage,
FeedPage,
FeedUnseen,
MessageView,
PeerView,
)
CURSOR_PATTERN = r"^\d+\.\d+$"
_EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
_MICROSECOND = timedelta(microseconds=1)
_ITEM_COLS = (
"id, kind, chat_id, chat_kind, message_id, message_date, at, since, "
"message_count, detail"
)
_VERSIONS = """
SELECT v.chat_id, v.message_id, v.observed_at, v.edit_date, v.text,
p.text AS before, p.observed_at IS NOT NULL AS has_before
FROM unnest($2::bigint[], $3::bigint[], $4::timestamptz[])
AS k(chat_id, message_id, observed_at)
JOIN message_versions v
ON v.account_id = $1 AND v.chat_id = k.chat_id
AND v.message_id = k.message_id AND v.observed_at = k.observed_at
LEFT JOIN LATERAL (
SELECT text, observed_at FROM message_versions p
WHERE p.account_id = $1 AND p.chat_id = v.chat_id
AND p.message_id = v.message_id AND p.observed_at < v.observed_at
ORDER BY p.observed_at DESC LIMIT 1
) p ON true
"""
_UNSEEN = """
SELECT count(*) FILTER (WHERE kind = 'deleted') AS deleted,
count(*) FILTER (WHERE kind = 'edited') AS edited
FROM feed_items
WHERE account_id = $1 AND at > coalesce($2, '-infinity'::timestamptz)
"""
_MARK_SEEN = """
INSERT INTO feed_seen (account_id, seen_at) VALUES ($1, coalesce($2, now()))
ON CONFLICT (account_id) DO UPDATE
SET seen_at = greatest(feed_seen.seen_at, EXCLUDED.seen_at)
"""
class FeedKind(StrEnum):
ALL = "all"
MESSAGES = "messages"
DELETED = "deleted"
EDITED = "edited"
PROFILE = "profile"
class FeedChatKind(StrEnum):
DM = "dm"
GROUP = "group"
CHANNEL = "channel"
@dataclass(slots=True)
class FeedFilter:
kind: FeedKind = FeedKind.ALL
chat_id: int | None = None
chat_kind: FeedChatKind | None = None
since: datetime | None = None
def encode_cursor(at: datetime, key: int) -> str:
return f"{(at - _EPOCH) // _MICROSECOND}.{key}"
def decode_cursor(cursor: str) -> tuple[datetime, int]:
micros, _, key = cursor.partition(".")
return _EPOCH + int(micros) * _MICROSECOND, int(key)
def _where(
account_id: int, feed: FeedFilter, cursor: str | None
) -> tuple[str, list[object]]:
params: list[object] = [account_id]
clauses = ["account_id = $1"]
if feed.kind is FeedKind.MESSAGES:
clauses.append("kind <> 'profile'")
exact = feed.kind not in {FeedKind.ALL, FeedKind.MESSAGES}
conditions = (
("kind = {}", feed.kind.value if exact else None),
("chat_id = {}", feed.chat_id),
("chat_kind = {}", feed.chat_kind.value if feed.chat_kind else None),
("at >= {}", feed.since),
)
for template, value in conditions:
if value is not None:
params.append(value)
clauses.append(template.format(f"${len(params)}"))
if cursor is not None:
params.extend(decode_cursor(cursor))
clauses.append(f"(at, id) < (${len(params) - 1}, ${len(params)})")
return " AND ".join(clauses), params
def _peer_name(peer: PeerView) -> str | None:
name = " ".join(part for part in (peer.first_name, peer.last_name) if part)
return name or peer.username
async def _versions(
pool: asyncpg.Pool, account_id: int, rows: list[asyncpg.Record]
) -> dict[tuple[int, int, datetime], asyncpg.Record]:
edited = [row for row in rows if row["kind"] == FeedKind.EDITED]
if not edited:
return {}
found = await pool.fetch(
_VERSIONS,
account_id,
[row["chat_id"] for row in edited],
[row["message_id"] for row in edited],
[row["at"] for row in edited],
)
return {
(row["chat_id"], row["message_id"], row["observed_at"]): row for row in found
}
async def _sender_names(
pool: asyncpg.Pool,
account_id: int,
messages: dict[tuple[int, int], MessageView],
chat_views: dict[int, ChatListItem],
) -> dict[int, str | None]:
ids = {m.sender_id for m in messages.values() if m.sender_id is not None}
found = await peers.get_peers(pool, account_id, list(ids))
names: dict[int, str | None] = {
chat_id: chat.title for chat_id, chat in chat_views.items()
}
names.update({peer.peer_id: _peer_name(peer) for peer in found})
return names
async def _build_items(
pool: asyncpg.Pool, account_id: int, rows: list[asyncpg.Record]
) -> list[FeedItem]:
if not rows:
return []
messages = await chats.get_messages_at(
pool,
account_id,
[
(row["chat_id"], row["message_id"], row["message_date"])
for row in rows
if row["kind"] != FeedKind.PROFILE
],
)
versions = await _versions(pool, account_id, rows)
chat_views = {
chat.chat_id: chat
for chat in await chats.get_chats(
pool, account_id, list({row["chat_id"] for row in rows})
)
}
names = await _sender_names(pool, account_id, messages, chat_views)
self_id = await self_user_id(pool, account_id)
items: list[FeedItem] = []
for row in rows:
message = messages.get((row["chat_id"], row["message_id"]))
chat = chat_views.get(row["chat_id"])
version = versions.get((row["chat_id"], row["message_id"], row["at"]))
sender_id = message.sender_id if message else None
items.append(
FeedItem(
id=row["id"],
kind=row["kind"],
at=row["at"],
since=row["since"],
message_count=row["message_count"],
chat=FeedChat(
chat_id=row["chat_id"],
title=chat.title if chat else None,
kind=row["chat_kind"],
has_avatar=chat.has_avatar if chat else False,
),
message_id=row["message_id"],
message=message,
sender_id=sender_id,
sender_name=names.get(sender_id) if sender_id is not None else None,
outgoing=self_id is not None and sender_id == self_id,
before=version["before"] if version else None,
after=version["text"] if version else None,
has_before=bool(version and version["has_before"]),
edit_date=version["edit_date"] if version else None,
detail=json.loads(row["detail"]) if row["detail"] else None,
)
)
return items
async def list_feed(
pool: asyncpg.Pool,
account_id: int,
feed: FeedFilter,
*,
limit: int = DEFAULT_LIMIT,
cursor: str | None = None,
) -> FeedPage:
size = max(1, min(limit, MAX_LIMIT))
where, params = _where(account_id, feed, cursor)
params.append(size + 1)
rows = await pool.fetch(
f"SELECT {_ITEM_COLS} FROM feed_items WHERE {where} " # noqa: S608
f"ORDER BY at DESC, id DESC LIMIT ${len(params)}",
*params,
)
page = rows[:size]
more = len(rows) > size
return FeedPage(
items=await _build_items(pool, account_id, page),
next_cursor=encode_cursor(page[-1]["at"], page[-1]["id"]) if more else None,
)
async def unseen(pool: asyncpg.Pool, account_id: int) -> FeedUnseen:
seen_at = await pool.fetchval(
"SELECT seen_at FROM feed_seen WHERE account_id = $1", account_id
)
counts = await pool.fetchrow(_UNSEEN, account_id, seen_at)
latest = await list_feed(
pool, account_id, FeedFilter(kind=FeedKind.MESSAGES), limit=1
)
return FeedUnseen(
count=counts["deleted"] + counts["edited"],
deleted=counts["deleted"],
edited=counts["edited"],
seen_at=seen_at,
latest=latest.items[0] if latest.items else None,
)
async def mark_seen(
pool: asyncpg.Pool, account_id: int, at: datetime | None
) -> FeedUnseen:
await pool.execute(_MARK_SEEN, account_id, at)
return await unseen(pool, account_id)
async def item_messages(
pool: asyncpg.Pool,
account_id: int,
item_id: int,
*,
limit: int = DEFAULT_LIMIT,
cursor: str | None = None,
) -> FeedMessagesPage | None:
row = await pool.fetchrow(
"SELECT chat_id, since, at FROM feed_items "
"WHERE id = $1 AND account_id = $2 AND kind = 'deleted'",
item_id,
account_id,
)
if row is None:
return None
size = max(1, min(limit, MAX_LIMIT))
views = await chats.get_deleted_between(
pool,
account_id,
row["chat_id"],
row["since"],
row["at"],
size + 1,
decode_cursor(cursor) if cursor else None,
)
page = views[:size]
more = len(views) > size
return FeedMessagesPage(
messages=page,
next_cursor=encode_cursor(page[-1].date, page[-1].message_id) if more else None,
)