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
@@ -4,6 +4,8 @@ from userbot.modules.jobs.handlers import (
fetch_avatar,
fetch_custom_emoji,
fetch_media,
search_peers,
sync_contacts,
sync_dialogs,
transcribe,
)
@@ -14,6 +16,8 @@ __all__ = [
"fetch_avatar",
"fetch_custom_emoji",
"fetch_media",
"search_peers",
"sync_contacts",
"sync_dialogs",
"transcribe",
]
@@ -0,0 +1,102 @@
import re
from pyrogram import Client, raw
from pyrogram.errors import BadRequest, Forbidden
from pyrogram.types import Chat
from userbot.modules.capture.context import CaptureContext
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.profiles.snapshots import save_chat
DEFAULT_LIMIT = 30
_USERNAME = re.compile(r"^[a-z][a-z0-9_]{3,31}$", re.IGNORECASE)
_PREFIXES = ("https://t.me/", "http://t.me/", "t.me/", "@")
def _normalize(query: str) -> str:
text = query.strip()
for prefix in _PREFIXES:
if text.lower().startswith(prefix):
text = text[len(prefix) :]
break
return text.strip("/")
_SOURCE_TYPES = (raw.types.User, raw.types.Chat, raw.types.Channel)
def _source(
peer: raw.base.Peer, users: dict, chats: dict
) -> raw.types.User | raw.types.Chat | raw.types.Channel | None:
if isinstance(peer, raw.types.PeerUser):
source = users.get(peer.user_id)
elif isinstance(peer, raw.types.PeerChannel):
source = chats.get(peer.channel_id)
elif isinstance(peer, raw.types.PeerChat):
source = chats.get(peer.chat_id)
else:
return None
return source if isinstance(source, _SOURCE_TYPES) else None
async def _save_found(
client: Client, ctx: CaptureContext, peer: raw.base.Peer, users: dict, chats: dict
) -> int | None:
source = _source(peer, users, chats)
if source is None:
return None
chat = Chat._parse_chat(client, source) # noqa: SLF001
if chat is None or chat.id is None:
return None
await save_chat(ctx, chat)
return chat.id
async def _resolve(client: Client, ctx: CaptureContext, query: str) -> int | None:
try:
chat = await client.get_chat(query)
except (BadRequest, Forbidden):
return None
if not isinstance(chat, Chat) or chat.id is None:
return None
await save_chat(ctx, chat)
return chat.id
async def _search(
client: Client, query: str, limit: int
) -> raw.base.contacts.Found | None:
try:
return await client.invoke(raw.functions.contacts.Search(q=query, limit=limit))
except (BadRequest, Forbidden):
return None
@register("search_peers")
async def search_peers(ctx: JobContext) -> None:
client = ctx.client
if client is None:
return
capture = getattr(client, "capture", None)
if capture is None:
return
query = _normalize(ctx.job.params.get("query", ""))
if not query:
await ctx.report_progress({"ids": [], "done": True})
return
limit = int(ctx.job.params.get("limit", DEFAULT_LIMIT))
found = await _search(client, query, limit)
ids: list[int] = []
if found is not None:
users = {user.id: user for user in found.users}
chats = {chat.id: chat for chat in found.chats}
for peer in (*found.my_results, *found.results):
peer_id = await _save_found(client, capture, peer, users, chats)
if peer_id is not None and peer_id not in ids:
ids.append(peer_id)
if _USERNAME.match(query):
resolved = await _resolve(client, capture, query)
if resolved is not None and resolved not in ids:
ids.insert(0, resolved)
await ctx.report_progress({"ids": ids, "done": True})
@@ -0,0 +1,36 @@
from pyrogram.types import User
from userbot.modules.avatars import note_avatar
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.profiles.parse import snapshot_from_high_level
from userbot.modules.profiles.repository import write_profile
@register("sync_contacts")
async def sync_contacts(ctx: JobContext) -> None:
client = ctx.client
if client is None:
return
capture = getattr(client, "capture", None)
if capture is None:
return
contacts = await client.get_contacts()
processed = 0
for user in contacts:
if not isinstance(user, User):
continue
fields, photo_file_id, photo_unique_id = snapshot_from_high_level(user)
await write_profile(ctx.pool, ctx.account_id, user.id, fields, str(user))
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool,
ctx.account_id,
user.id,
"peer",
photo_unique_id,
photo_file_id,
)
processed += 1
await capture.contacts.refresh()
await ctx.report_progress({"processed": processed, "done": True})
@@ -1,16 +1,14 @@
from datetime import UTC, datetime
from pyrogram import Client
from pyrogram.errors import BadRequest, Forbidden
from pyrogram.types import Chat, User
from pyrogram.types import User
from userbot.modules.avatars import note_avatar
from userbot.modules.capture.context import CaptureContext
from userbot.modules.groups.repository import insert_chat_history
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.profiles.parse import snapshot_from_chat, snapshot_from_high_level
from userbot.modules.profiles.parse import snapshot_from_high_level
from userbot.modules.profiles.repository import write_profile
from userbot.modules.profiles.snapshots import save_group, save_private
SAVE_EVERY = 100
USERS_BATCH = 200
@@ -21,16 +19,6 @@ ON CONFLICT (account_id, chat_id) DO UPDATE SET updated_at = now()
"""
async def _save_private(ctx: CaptureContext, chat: Chat, chat_id: int) -> bool:
fields, photo_file_id, photo_unique_id = snapshot_from_chat(chat)
await write_profile(ctx.pool, ctx.account_id, chat_id, fields, str(chat))
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool, ctx.account_id, chat_id, "peer", photo_unique_id, photo_file_id
)
return bool(fields.first_name or fields.last_name or fields.username)
async def _enrich_users(client: Client, ctx: CaptureContext, ids: list[int]) -> None:
for start in range(0, len(ids), USERS_BATCH):
batch = ids[start : start + USERS_BATCH]
@@ -55,28 +43,6 @@ async def _enrich_users(client: Client, ctx: CaptureContext, ids: list[int]) ->
)
async def _save_group(ctx: CaptureContext, chat: Chat, chat_id: int) -> None:
photo = chat.photo
photo_unique_id = photo.big_photo_unique_id if photo else None
photo_file_id = photo.big_file_id if photo else None
await insert_chat_history(
ctx.pool,
ctx.account_id,
chat_id,
0,
"meta",
chat.title,
photo_unique_id,
None,
datetime.now(UTC),
str(chat),
)
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool, ctx.account_id, chat_id, "chat", photo_unique_id, photo_file_id
)
@register("sync_dialogs")
async def sync_dialogs(ctx: JobContext) -> None:
client = ctx.client
@@ -94,10 +60,10 @@ async def sync_dialogs(ctx: JobContext) -> None:
chat_id = chat.id
try:
if chat_id > 0:
if not await _save_private(capture, chat, chat_id):
if not await save_private(capture, chat):
nameless.append(chat_id)
else:
await _save_group(capture, chat, chat_id)
await save_group(capture, chat)
except (BadRequest, Forbidden):
pass
await ctx.pool.execute(_UPSERT_DIALOG, ctx.account_id, chat_id)
@@ -0,0 +1,50 @@
from datetime import UTC, datetime
from pyrogram.types import Chat
from userbot.modules.avatars import note_avatar
from userbot.modules.capture.context import CaptureContext
from userbot.modules.groups.repository import insert_chat_history
from userbot.modules.profiles.parse import snapshot_from_chat
from userbot.modules.profiles.repository import write_profile
async def save_private(ctx: CaptureContext, chat: Chat) -> bool:
chat_id = chat.id or 0
fields, photo_file_id, photo_unique_id = snapshot_from_chat(chat)
await write_profile(ctx.pool, ctx.account_id, chat_id, fields, str(chat))
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool, ctx.account_id, chat_id, "peer", photo_unique_id, photo_file_id
)
return bool(fields.first_name or fields.last_name or fields.username)
async def save_group(ctx: CaptureContext, chat: Chat) -> None:
chat_id = chat.id or 0
photo = chat.photo
photo_unique_id = photo.big_photo_unique_id if photo else None
photo_file_id = photo.big_file_id if photo else None
await insert_chat_history(
ctx.pool,
ctx.account_id,
chat_id,
0,
"meta",
chat.title,
photo_unique_id,
None,
datetime.now(UTC),
str(chat),
)
if photo_file_id and photo_unique_id:
await note_avatar(
ctx.pool, ctx.account_id, chat_id, "chat", photo_unique_id, photo_file_id
)
async def save_chat(ctx: CaptureContext, chat: Chat) -> None:
if (chat.id or 0) > 0:
await save_private(ctx, chat)
else:
await save_group(ctx, chat)