feat: web UI chat render, panels, presence + analytics
This commit is contained in:
@@ -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(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user