feat: web UI chat render, panels, presence + analytics

This commit is contained in:
h
2026-05-31 19:41:01 +02:00
parent 75425d1bee
commit ed469ba8dd
83 changed files with 6034 additions and 136 deletions
+15
View File
@@ -477,3 +477,18 @@ class Alert(SQLModel, table=True):
DateTime(timezone=True), nullable=False, server_default=func.now()
)
)
class Dialog(SQLModel, table=True):
__tablename__ = "dialogs"
account_id: int = Field(primary_key=True)
chat_id: int = Field(primary_key=True)
updated_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
)
+29
View File
@@ -0,0 +1,29 @@
import json
from typing import Literal
import asyncpg
BG_EVENTS_CHANNEL = "bg_events"
EventKind = Literal["message", "edit", "delete", "reaction", "presence", "receipt"]
async def notify_bg_event( # noqa: PLR0913
pool: asyncpg.Pool,
kind: EventKind,
account_id: int,
*,
chat_id: int | None = None,
message_id: int | None = None,
message_ids: list[int] | None = None,
) -> None:
payload: dict[str, object] = {"kind": kind, "account_id": account_id}
if chat_id is not None:
payload["chat_id"] = chat_id
if message_id is not None:
payload["message_id"] = message_id
if message_ids is not None:
payload["message_ids"] = message_ids
await pool.execute(
"SELECT pg_notify($1, $2)", BG_EVENTS_CHANNEL, json.dumps(payload)
)
+6
View File
@@ -3,6 +3,12 @@ import asyncpg
from utils.read.models import AccountView
async def self_user_id(pool: asyncpg.Pool, account_id: int) -> int | None:
return await pool.fetchval(
"SELECT tg_user_id FROM accounts WHERE account_id = $1", account_id
)
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 "
+69
View File
@@ -0,0 +1,69 @@
from datetime import UTC, datetime, timedelta
import asyncpg
from utils.read.accounts import self_user_id
from utils.read.models import ResponseStats, VolumeBucket
async def message_volume(
pool: asyncpg.Pool, account_id: int, chat_id: int, *, days: int = 90
) -> list[VolumeBucket]:
self_id = await self_user_id(pool, account_id)
rows = await pool.fetch(
"SELECT date_trunc('day', date) AS bucket, count(*) AS total, "
"count(*) FILTER (WHERE sender_id = $3) AS outgoing "
"FROM messages "
"WHERE account_id = $1 AND chat_id = $2 AND date >= $4 "
"GROUP BY bucket ORDER BY bucket",
account_id,
chat_id,
self_id,
datetime.now(UTC) - timedelta(days=days),
)
return [
VolumeBucket(
bucket=row["bucket"],
total=row["total"],
outgoing=row["outgoing"],
incoming=row["total"] - row["outgoing"],
)
for row in rows
]
async def response_stats(
pool: asyncpg.Pool, account_id: int, chat_id: int
) -> ResponseStats:
self_id = await self_user_id(pool, account_id)
rows = await pool.fetch(
"WITH ordered AS ("
"SELECT sender_id, date, "
"lag(sender_id) OVER w AS prev_sender, "
"lag(date) OVER w AS prev_date "
"FROM messages "
"WHERE account_id = $1 AND chat_id = $2 AND sender_id IS NOT NULL "
"WINDOW w AS (ORDER BY date, message_id)), "
"resp AS ("
"SELECT (sender_id = $3) AS is_mine, "
"EXTRACT(EPOCH FROM (date - prev_date)) AS secs "
"FROM ordered "
"WHERE prev_sender IS NOT NULL AND prev_sender <> sender_id) "
"SELECT is_mine, count(*) AS n, "
"percentile_cont(0.5) WITHIN GROUP (ORDER BY secs) AS median_secs "
"FROM resp GROUP BY is_mine",
account_id,
chat_id,
self_id,
)
stats = ResponseStats(
mine_median_seconds=None, mine_count=0, their_median_seconds=None, their_count=0
)
for row in rows:
if row["is_mine"]:
stats.mine_median_seconds = row["median_secs"]
stats.mine_count = row["n"]
else:
stats.their_median_seconds = row["median_secs"]
stats.their_count = row["n"]
return stats
+60 -16
View File
@@ -1,5 +1,6 @@
import asyncpg
from utils.read.accounts import self_user_id
from utils.read.message_view import build_message_view, load_raw, media_ref_from
from utils.read.models import (
ChatListItem,
@@ -8,6 +9,7 @@ from utils.read.models import (
MessageView,
Page,
)
from utils.read.read_receipts import read_up_to
_MESSAGE_COLS = (
"chat_id, message_id, date, sender_id, text, has_media, is_self_destruct, "
@@ -52,35 +54,43 @@ async def list_chats(
pool: asyncpg.Pool, account_id: int, page: Page
) -> list[ChatListItem]:
rows = await pool.fetch(
"SELECT m.chat_id, count(*) AS message_count, max(m.date) AS last_date, "
"WITH ids AS ("
"SELECT DISTINCT chat_id FROM messages WHERE account_id = $1 "
"UNION SELECT chat_id FROM dialogs WHERE account_id = $1), "
"agg AS (SELECT chat_id, count(*) AS message_count, max(date) AS last_date "
"FROM messages WHERE account_id = $1 GROUP BY chat_id) "
"SELECT ids.chat_id, COALESCE(agg.message_count, 0) AS message_count, "
"agg.last_date AS last_date, "
"(SELECT p.first_name FROM peers p "
"WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS first_name, "
"WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS first_name, "
"(SELECT p.last_name FROM peers p "
"WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS last_name, "
"WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS last_name, "
"(SELECT p.username FROM peers p "
"WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS username, "
"WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS username, "
"(SELECT ch.title FROM chat_history ch "
"WHERE ch.account_id = $1 AND ch.chat_id = m.chat_id "
"WHERE ch.account_id = $1 AND ch.chat_id = ids.chat_id "
"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, "
"WHERE a.account_id = $1 AND a.owner_id = ids.chat_id) AS has_avatar, "
"(SELECT COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool, "
"p.raw->>'type' = 'ChatType.BOT', false) "
"FROM peers p WHERE p.account_id = $1 AND p.peer_id = ids.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 "
"WHERE p.account_id = $1 AND p.peer_id = ids.chat_id) AS is_contact, "
"(SELECT COALESCE(ch.raw->'chat'->>'type', ch.raw->>'type') "
"= 'ChatType.CHANNEL' FROM chat_history ch "
"WHERE ch.account_id = $1 AND ch.chat_id = ids.chat_id "
"AND COALESCE(ch.raw->'chat'->>'type', ch.raw->>'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 "
"WHERE lm.account_id = $1 AND lm.chat_id = ids.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 "
"WHERE lm.account_id = $1 AND lm.chat_id = ids.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",
"FROM ids LEFT JOIN agg ON agg.chat_id = ids.chat_id "
"ORDER BY last_date DESC NULLS LAST, ids.chat_id DESC LIMIT $2 OFFSET $3",
account_id,
page.capped_limit,
page.offset,
@@ -146,9 +156,43 @@ async def get_chat_history(
else:
views.append(_build_album(members, media_by_key))
index = end
await _apply_read_status(pool, account_id, chat_id, views)
return views
async def get_message(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> MessageView | None:
row = await pool.fetchrow(
f"SELECT {_MESSAGE_COLS} FROM messages " # noqa: S608
"WHERE account_id = $1 AND chat_id = $2 AND message_id = $3",
account_id,
chat_id,
message_id,
)
if row is None:
return None
media_by_key = await _media_map(pool, account_id, [row])
raw = load_raw(row["raw"])
view = build_message_view(row, raw, _single_media(row, raw, media_by_key))
await _apply_read_status(pool, account_id, chat_id, [view])
return view
async def _apply_read_status(
pool: asyncpg.Pool, account_id: int, chat_id: int, views: list[MessageView]
) -> None:
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:
return
for view in views:
if view.sender_id == self_id and view.message_id <= marker:
view.read = True
def _build_album(
members: list[tuple[asyncpg.Record, dict]],
media_by_key: dict[tuple[int, int], asyncpg.Record],
+15
View File
@@ -0,0 +1,15 @@
import asyncpg
from utils.read.models import CustomEmojiRef
_GET = """
SELECT storage_key, downloaded, mime, kind FROM custom_emoji
WHERE custom_emoji_id = $1
"""
async def current_custom_emoji(
pool: asyncpg.Pool, custom_emoji_id: int
) -> CustomEmojiRef | None:
row = await pool.fetchrow(_GET, custom_emoji_id)
return CustomEmojiRef(**dict(row)) if row else None
+43 -1
View File
@@ -1,5 +1,6 @@
import asyncpg
from utils.read.message_view import load_raw
from utils.read.models import MediaVersionView, MediaView
_MEDIA_COLS = (
@@ -9,6 +10,45 @@ _MEDIA_COLS = (
_VERSION_COLS = "id, kind, storage_key, file_size, mime, observed_at"
_WEB_PAGE_MEDIA_KINDS = ("photo", "video", "animation", "document", "audio")
async def _web_page_media_stub(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> MediaView | None:
row = await pool.fetchrow(
"SELECT date, raw FROM messages "
"WHERE account_id = $1 AND chat_id = $2 AND message_id = $3",
account_id,
chat_id,
message_id,
)
if row is None:
return None
web_page = load_raw(row["raw"]).get("web_page")
if not isinstance(web_page, dict):
return None
kind = next(
(k for k in _WEB_PAGE_MEDIA_KINDS if isinstance(web_page.get(k), dict)), None
)
if kind is None:
return None
obj = web_page[kind]
return MediaView(
id=0,
account_id=account_id,
chat_id=chat_id,
message_id=message_id,
kind=kind,
storage_key=None,
file_size=obj.get("file_size"),
mime=obj.get("mime_type"),
ttl_seconds=None,
downloaded=False,
extracted_text=None,
created_at=row["date"],
)
async def get_media(pool: asyncpg.Pool, media_id: int) -> MediaView | None:
row = await pool.fetchrow(
@@ -28,7 +68,9 @@ async def get_message_media(
chat_id,
message_id,
)
return MediaView(**dict(row)) if row else None
if row is not None:
return MediaView(**dict(row))
return await _web_page_media_stub(pool, account_id, chat_id, message_id)
async def get_media_versions(
+29
View File
@@ -141,6 +141,13 @@ class ServiceView(BaseModel):
duration: int | None = None
class PinnedView(BaseModel):
message_id: int
text: str | None = None
media_kind: str | None = None
sender_name: str | None = None
class StickerView(BaseModel):
emoji: str | None = None
set_name: str | None = None
@@ -178,6 +185,7 @@ class MessageView(BaseModel):
sticker: StickerView | None = None
is_sticker: bool = False
is_animated_emoji: bool = False
read: bool = False
class MessageVersionView(BaseModel):
@@ -217,6 +225,13 @@ class AvatarRef(BaseModel):
mime: str | None
class CustomEmojiRef(BaseModel):
storage_key: str | None
downloaded: bool
mime: str | None
kind: str | None
class CallbackView(BaseModel):
position: int
label: str | None
@@ -256,6 +271,20 @@ class PresenceHourly(BaseModel):
last_seen: datetime | None
class VolumeBucket(BaseModel):
bucket: datetime
total: int
outgoing: int
incoming: int
class ResponseStats(BaseModel):
mine_median_seconds: float | None
mine_count: int
their_median_seconds: float | None
their_count: int
class PeerView(BaseModel):
peer_id: int
first_name: str | None
+32
View File
@@ -0,0 +1,32 @@
import asyncpg
from utils.read.message_view import _media_kind, _peer_name, load_raw
from utils.read.models import PinnedView
_PINNED_SQL = (
"SELECT raw FROM messages "
"WHERE account_id = $1 AND chat_id = $2 "
"AND raw->>'service' LIKE '%PINNED_MESSAGE%' "
"ORDER BY date DESC, message_id DESC LIMIT 1"
)
async def get_pinned(
pool: asyncpg.Pool, account_id: int, chat_id: int
) -> PinnedView | None:
row = await pool.fetchrow(_PINNED_SQL, account_id, chat_id)
if row is None:
return None
pinned = load_raw(row["raw"]).get("pinned_message")
if not isinstance(pinned, dict):
return None
message_id = pinned.get("id")
if message_id is None:
return None
sender = pinned.get("from_user")
return PinnedView(
message_id=message_id,
text=pinned.get("text") or pinned.get("caption"),
media_kind=_media_kind(pinned),
sender_name=_peer_name(sender) if isinstance(sender, dict) else None,
)
+13
View File
@@ -33,6 +33,19 @@ async def presence_history( # noqa: PLR0913
return [PresenceSample(**dict(row)) for row in rows]
async def current_presence(
pool: asyncpg.Pool, account_id: int, peer_id: int
) -> PresenceSample | None:
row = await pool.fetchrow(
"SELECT peer_id, ts, status, last_online_date, next_offline_date "
"FROM presence WHERE account_id = $1 AND peer_id = $2 "
"ORDER BY ts DESC LIMIT 1",
account_id,
peer_id,
)
return PresenceSample(**dict(row)) if row is not None else None
async def presence_hourly(
pool: asyncpg.Pool,
account_id: int,
+10
View File
@@ -0,0 +1,10 @@
import asyncpg
async def read_up_to(pool: asyncpg.Pool, account_id: int, chat_id: int) -> int | None:
return await pool.fetchval(
"SELECT max(message_id) FROM read_receipts "
"WHERE account_id = $1 AND chat_id = $2 AND kind = 'read'",
account_id,
chat_id,
)