feat(backend): read time of my messages from the archive, exact dates and group readers asked from telegram on demand
This commit is contained in:
1 parent
f27943aae6
commit
d8dd08d7de
10 files changed
+497
-9
No files matched your search
@@ -10,7 +10,7 @@ from utils.read.models import (
|
||||
MessageView,
|
||||
Page,
|
||||
)
|
||||
from utils.read.read_receipts import read_up_to
|
||||
from utils.read.read_receipts import read_times, read_up_to
|
||||
|
||||
_MESSAGE_COLS = (
|
||||
"chat_id, message_id, date, sender_id, text, has_media, is_self_destruct, "
|
||||
@@ -285,12 +285,18 @@ async def _apply_read_status(
|
||||
self_id = await self_user_id(pool, account_id)
|
||||
if self_id is None:
|
||||
return
|
||||
marker = await read_up_to(pool, account_id, chat_id)
|
||||
if marker is None:
|
||||
own = [view for view in views if view.sender_id == self_id]
|
||||
if not own:
|
||||
return
|
||||
for view in views:
|
||||
if view.sender_id == self_id and view.message_id <= marker:
|
||||
view.read = True
|
||||
marker = await read_up_to(pool, account_id, chat_id)
|
||||
times = await read_times(
|
||||
pool, account_id, chat_id, [(view.message_id, view.date) for view in own]
|
||||
)
|
||||
for view in own:
|
||||
view.read_at = times.get(view.message_id)
|
||||
view.read = view.read_at is not None or (
|
||||
marker is not None and view.message_id <= marker
|
||||
)
|
||||
|
||||
|
||||
def _build_album(
|
||||
|
||||
@@ -203,6 +203,7 @@ class MessageView(BaseModel):
|
||||
is_sticker: bool = False
|
||||
is_animated_emoji: bool = False
|
||||
read: bool = False
|
||||
read_at: datetime | None = None
|
||||
|
||||
|
||||
class MessageVersionView(BaseModel):
|
||||
@@ -387,6 +388,29 @@ class PeerView(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ReaderView(BaseModel):
|
||||
peer_id: int
|
||||
read_at: datetime
|
||||
peer: PeerView | None
|
||||
|
||||
|
||||
class ReadInfoView(BaseModel):
|
||||
chat_id: int
|
||||
message_id: int
|
||||
chat_kind: str
|
||||
own: bool
|
||||
voice: bool
|
||||
observed_at: datetime | None
|
||||
listened_at: datetime | None
|
||||
exact_at: datetime | None
|
||||
readers: list[ReaderView]
|
||||
read: bool
|
||||
unread: bool
|
||||
outcome: str | None
|
||||
fetched_at: datetime | None
|
||||
can_fetch: bool
|
||||
|
||||
|
||||
class PeerHistoryView(BaseModel):
|
||||
observed_at: datetime
|
||||
first_name: str | None
|
||||
|
||||
@@ -1,5 +1,89 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import asyncpg
|
||||
|
||||
from utils.policy.models import ChatKind
|
||||
from utils.read.discover import chat_kind
|
||||
from utils.read.models import ReaderView, ReadInfoView
|
||||
from utils.read.peers import get_peers
|
||||
|
||||
FETCH_WINDOW = timedelta(days=7)
|
||||
CLOCK_SKEW = timedelta(minutes=1)
|
||||
VOICE_KINDS = ["voice", "video_note"]
|
||||
|
||||
_READ_TIMES = """
|
||||
SELECT m.id,
|
||||
COALESCE(
|
||||
(SELECT min(mr.read_at) FROM message_reads mr
|
||||
WHERE mr.account_id = $1 AND mr.chat_id = $2 AND mr.message_id = m.id),
|
||||
CASE WHEN m.date >= $5 THEN (
|
||||
SELECT r.ts FROM read_receipts r
|
||||
WHERE r.account_id = $1 AND r.chat_id = $2 AND r.kind = 'read'
|
||||
AND r.message_id >= m.id AND r.ts >= m.date - $6::interval
|
||||
ORDER BY r.ts LIMIT 1
|
||||
) END
|
||||
) AS read_at
|
||||
FROM unnest($3::bigint[], $4::timestamptz[]) AS m(id, date)
|
||||
"""
|
||||
|
||||
_MESSAGE = """
|
||||
SELECT m.sender_id, m.date, a.tg_user_id, a.is_bot,
|
||||
EXISTS (
|
||||
SELECT 1 FROM media md
|
||||
WHERE md.account_id = m.account_id AND md.chat_id = m.chat_id
|
||||
AND md.message_id = m.message_id AND md.kind = ANY($4::text[])
|
||||
) AS voice
|
||||
FROM messages m
|
||||
JOIN accounts a ON a.account_id = m.account_id
|
||||
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
_RECEIPTS = """
|
||||
SELECT
|
||||
(SELECT ts FROM read_receipts
|
||||
WHERE account_id = $1 AND chat_id = $2 AND kind = 'read'
|
||||
AND message_id >= $3 AND ts >= $4
|
||||
ORDER BY ts LIMIT 1) AS observed_at,
|
||||
(SELECT min(ts) FROM read_receipts
|
||||
WHERE account_id = $1 AND chat_id = $2 AND kind = 'listened'
|
||||
AND message_id = $3) AS listened_at,
|
||||
(SELECT max(message_id) FROM read_receipts
|
||||
WHERE account_id = $1 AND chat_id = $2 AND kind = 'read') AS watermark
|
||||
"""
|
||||
|
||||
_READS = """
|
||||
SELECT reader_id, read_at, source FROM message_reads
|
||||
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3
|
||||
ORDER BY read_at
|
||||
"""
|
||||
|
||||
_FETCH = """
|
||||
SELECT outcome, fetched_at, retry_at FROM read_fetches
|
||||
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3
|
||||
"""
|
||||
|
||||
_INHERITED = """
|
||||
SELECT outcome FROM read_fetches
|
||||
WHERE account_id = $1 AND retry_at > now()
|
||||
AND ((chat_id = $2 AND outcome = 'chat_too_big') OR outcome = 'your_privacy')
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
_capture_starts: dict[int, datetime] = {}
|
||||
|
||||
|
||||
async def capture_start(pool: asyncpg.Pool, account_id: int) -> datetime | None:
|
||||
cached = _capture_starts.get(account_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
start = await pool.fetchval(
|
||||
"SELECT min(ts) FROM read_receipts WHERE account_id = $1", account_id
|
||||
)
|
||||
if start is not None:
|
||||
_capture_starts[account_id] = start
|
||||
return start
|
||||
|
||||
|
||||
async def read_up_to(pool: asyncpg.Pool, account_id: int, chat_id: int) -> int | None:
|
||||
return await pool.fetchval(
|
||||
@@ -8,3 +92,97 @@ async def read_up_to(pool: asyncpg.Pool, account_id: int, chat_id: int) -> int |
|
||||
account_id,
|
||||
chat_id,
|
||||
)
|
||||
|
||||
|
||||
async def read_times(
|
||||
pool: asyncpg.Pool,
|
||||
account_id: int,
|
||||
chat_id: int,
|
||||
messages: list[tuple[int, datetime]],
|
||||
) -> dict[int, datetime]:
|
||||
if not messages:
|
||||
return {}
|
||||
start = await capture_start(pool, account_id) or datetime.max.replace(tzinfo=UTC)
|
||||
rows = await pool.fetch(
|
||||
_READ_TIMES,
|
||||
account_id,
|
||||
chat_id,
|
||||
[message_id for message_id, _ in messages],
|
||||
[date for _, date in messages],
|
||||
start,
|
||||
CLOCK_SKEW,
|
||||
)
|
||||
return {row["id"]: row["read_at"] for row in rows if row["read_at"] is not None}
|
||||
|
||||
|
||||
async def read_info(
|
||||
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
|
||||
) -> ReadInfoView | None:
|
||||
message = await pool.fetchrow(
|
||||
_MESSAGE, account_id, chat_id, message_id, VOICE_KINDS
|
||||
)
|
||||
if message is None:
|
||||
return None
|
||||
kind = await chat_kind(pool, account_id, chat_id)
|
||||
date: datetime = message["date"]
|
||||
receipts = await pool.fetchrow(
|
||||
_RECEIPTS, account_id, chat_id, message_id, date - CLOCK_SKEW
|
||||
)
|
||||
reads = await pool.fetch(_READS, account_id, chat_id, message_id)
|
||||
fetch = await pool.fetchrow(_FETCH, account_id, chat_id, message_id)
|
||||
inherited = await pool.fetchval(_INHERITED, account_id, chat_id)
|
||||
start = await capture_start(pool, account_id)
|
||||
|
||||
observed_at = receipts["observed_at"] if start and date >= start else None
|
||||
exact_at = next(
|
||||
(row["read_at"] for row in reads if row["source"] == "outbox_date"), None
|
||||
)
|
||||
reader_rows = [row for row in reads if row["source"] == "participants"]
|
||||
known = {
|
||||
peer.peer_id: peer
|
||||
for peer in await get_peers(
|
||||
pool, account_id, [row["reader_id"] for row in reader_rows]
|
||||
)
|
||||
}
|
||||
readers = [
|
||||
ReaderView(
|
||||
peer_id=row["reader_id"],
|
||||
read_at=row["read_at"],
|
||||
peer=known.get(row["reader_id"]),
|
||||
)
|
||||
for row in reader_rows
|
||||
]
|
||||
outcome = fetch["outcome"] if fetch else inherited
|
||||
|
||||
now = datetime.now(UTC)
|
||||
own = (
|
||||
message["sender_id"] is not None
|
||||
and message["sender_id"] == (message["tg_user_id"])
|
||||
)
|
||||
applicable = own and kind != ChatKind.CHANNEL and chat_id != message["tg_user_id"]
|
||||
watermark = receipts["watermark"]
|
||||
covered = watermark is not None and message_id <= watermark
|
||||
seen = covered or any((exact_at, readers, receipts["listened_at"]))
|
||||
evidence = watermark is not None or outcome in {"ok", "not_read_yet"}
|
||||
due = fetch is None or fetch["retry_at"] <= now
|
||||
return ReadInfoView(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
chat_kind=kind.value,
|
||||
own=own,
|
||||
voice=message["voice"],
|
||||
observed_at=observed_at,
|
||||
listened_at=receipts["listened_at"],
|
||||
exact_at=exact_at,
|
||||
readers=readers,
|
||||
read=applicable and seen,
|
||||
unread=applicable and not seen and evidence,
|
||||
outcome=outcome,
|
||||
fetched_at=fetch["fetched_at"] if fetch else None,
|
||||
can_fetch=applicable
|
||||
and not message["is_bot"]
|
||||
and now - date < FETCH_WINDOW
|
||||
and exact_at is None
|
||||
and inherited is None
|
||||
and due,
|
||||
)
|
||||
Reference in new issue
Block a user