feat(api,userbot,frontend): search peers without chats and start tracking them

This commit is contained in:
hh
2026-08-05 23:46:54 +02:00
parent dcf95bd9d4
commit 92fd20137e
21 changed files with 936 additions and 65 deletions
+19
View File
@@ -16,3 +16,22 @@ DEFAULTS: dict[ChatKind, CaptureToggles] = {
backfill=True,
),
}
TRACKING: dict[ChatKind, CaptureToggles] = {
ChatKind.CHANNEL: CaptureToggles(
messages=True,
media=True,
reactions=True,
track_edits_deletes=True,
backfill=True,
),
ChatKind.GROUP: CaptureToggles(
messages=True,
media=True,
reactions=True,
track_edits_deletes=True,
profile_history=True,
backfill=True,
),
ChatKind.DM: DEFAULTS[ChatKind.DM],
}
+3 -1
View File
@@ -56,7 +56,9 @@ async def list_chats(
rows = await pool.fetch(
"WITH ids AS ("
"SELECT DISTINCT chat_id FROM messages WHERE account_id = $1 "
"UNION SELECT chat_id FROM dialogs WHERE account_id = $1), "
"UNION SELECT chat_id FROM dialogs WHERE account_id = $1 "
"UNION SELECT scope_id FROM capture_policy WHERE account_id = $1 "
"AND scope_type = 'chat' AND scope_id IS NOT NULL), "
"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, "
+141
View File
@@ -0,0 +1,141 @@
import asyncpg
from utils.policy.models import ChatKind
from utils.read.models import DiscoverItem
_ESCAPE = str.maketrans({"\\": "\\\\", "%": r"\%", "_": r"\_"})
_IS_BROADCAST = """
SELECT COALESCE(raw->'chat'->>'type', raw->>'type') = 'ChatType.CHANNEL'
FROM chat_history
WHERE account_id = $1 AND chat_id = $2
AND COALESCE(raw->'chat'->>'type', raw->>'type') IS NOT NULL
ORDER BY ts DESC LIMIT 1
"""
_IS_TRACKED = """
SELECT EXISTS (
SELECT 1 FROM capture_policy
WHERE account_id = $1 AND scope_type = 'chat' AND scope_id = $2
)
"""
_ITEMS = """
WITH chat_meta AS (
SELECT DISTINCT ON (chat_id) chat_id, title,
COALESCE(raw->'chat'->>'username', raw->>'username') AS username,
COALESCE(raw->'chat'->>'type', raw->>'type') = 'ChatType.CHANNEL'
AS is_broadcast
FROM chat_history
WHERE account_id = $1 AND title IS NOT NULL
ORDER BY chat_id, ts DESC
), hits AS (
SELECT p.peer_id AS chat_id,
COALESCE(NULLIF(concat_ws(' ', p.first_name, p.last_name), ''), p.username)
AS title,
p.username,
COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool, false) AS is_bot,
COALESCE((p.raw->>'is_contact')::bool, (p.raw->>'contact')::bool, false)
AS is_contact,
false AS is_broadcast
FROM peers p
WHERE p.account_id = $1 AND (p.peer_id = ANY($3::bigint[]) OR ($2 <> '' AND (
concat_ws(' ', p.first_name, p.last_name) ILIKE $2
OR p.username ILIKE $2 OR p.phone ILIKE $2)))
UNION ALL
SELECT c.chat_id, c.title, c.username, false, false,
COALESCE(c.is_broadcast, false)
FROM chat_meta c
WHERE c.chat_id = ANY($3::bigint[])
OR ($2 <> '' AND (c.title ILIKE $2 OR c.username ILIKE $2))
), merged AS (
SELECT chat_id, max(title) AS title, max(username) AS username,
bool_or(is_bot) AS is_bot, bool_or(is_contact) AS is_contact,
bool_or(is_broadcast) AS is_broadcast
FROM hits GROUP BY chat_id
), counts AS (
SELECT chat_id, count(*) AS message_count FROM messages
WHERE account_id = $1 AND chat_id IN (SELECT chat_id FROM merged)
GROUP BY chat_id
)
SELECT m.chat_id, m.title, m.username, m.is_bot, m.is_contact, m.is_broadcast,
COALESCE(c.message_count, 0) AS message_count,
EXISTS (SELECT 1 FROM avatars a
WHERE a.account_id = $1 AND a.owner_id = m.chat_id) AS has_avatar,
EXISTS (SELECT 1 FROM dialogs d
WHERE d.account_id = $1 AND d.chat_id = m.chat_id) AS in_dialogs,
EXISTS (SELECT 1 FROM capture_policy cp WHERE cp.account_id = $1
AND cp.scope_type = 'chat' AND cp.scope_id = m.chat_id) AS tracked
FROM merged m LEFT JOIN counts c ON c.chat_id = m.chat_id
ORDER BY in_dialogs DESC, message_count DESC, is_contact DESC, m.title
LIMIT $4
"""
def _kind(chat_id: int, *, is_broadcast: bool) -> str:
if chat_id > 0:
return "private"
return "channel" if is_broadcast else "group"
def _to_item(row: asyncpg.Record) -> DiscoverItem:
return DiscoverItem(
chat_id=row["chat_id"],
title=row["title"],
username=row["username"],
kind=_kind(row["chat_id"], is_broadcast=row["is_broadcast"]),
is_bot=row["is_bot"],
is_contact=row["is_contact"],
has_avatar=row["has_avatar"],
message_count=row["message_count"],
in_dialogs=row["in_dialogs"],
tracked=row["tracked"],
)
async def search(
pool: asyncpg.Pool, account_id: int, query: str, limit: int
) -> list[DiscoverItem]:
text = query.strip()
if not text:
return []
rows = await pool.fetch(
_ITEMS, account_id, f"%{text.translate(_ESCAPE)}%", [], limit
)
return [_to_item(row) for row in rows]
async def by_ids(
pool: asyncpg.Pool, account_id: int, ids: list[int]
) -> list[DiscoverItem]:
if not ids:
return []
rows = await pool.fetch(_ITEMS, account_id, "", ids, len(ids))
by_id = {row["chat_id"]: _to_item(row) for row in rows}
return [by_id[chat_id] for chat_id in ids if chat_id in by_id]
async def get_item(pool: asyncpg.Pool, account_id: int, chat_id: int) -> DiscoverItem:
known = await by_ids(pool, account_id, [chat_id])
if known:
return known[0]
kind = await chat_kind(pool, account_id, chat_id)
return DiscoverItem(
chat_id=chat_id,
title=None,
username=None,
kind="private" if kind is ChatKind.DM else kind.value,
is_bot=False,
is_contact=False,
has_avatar=False,
message_count=0,
in_dialogs=False,
tracked=bool(await pool.fetchval(_IS_TRACKED, account_id, chat_id)),
)
async def chat_kind(pool: asyncpg.Pool, account_id: int, chat_id: int) -> ChatKind:
if chat_id > 0:
return ChatKind.DM
is_broadcast = await pool.fetchval(_IS_BROADCAST, account_id, chat_id)
return ChatKind.CHANNEL if is_broadcast else ChatKind.GROUP
+13
View File
@@ -38,6 +38,19 @@ class ChatListItem(BaseModel):
last_sender_id: int | None
class DiscoverItem(BaseModel):
chat_id: int
title: str | None
username: str | None
kind: str
is_bot: bool
is_contact: bool
has_avatar: bool
message_count: int
in_dialogs: bool
tracked: bool
class EntityView(BaseModel):
type: str
offset: int