201 lines
7.0 KiB
Python
201 lines
7.0 KiB
Python
import asyncio
|
|
import contextlib
|
|
from dataclasses import dataclass
|
|
|
|
import asyncpg
|
|
from pyrogram import Client, enums
|
|
from pyrogram.errors import RPCError
|
|
from pyrogram.handlers import (
|
|
ChatMemberUpdatedHandler,
|
|
EditedMessageHandler,
|
|
MessageHandler,
|
|
)
|
|
|
|
from userbot import PyroClient
|
|
from userbot.modules.capture import capture_message
|
|
from userbot.modules.capture.chat_meta import meta_from_chat
|
|
from userbot.modules.jobs import JobConsumer
|
|
from utils.events import notify_bg_event
|
|
from utils.jobs import enqueue
|
|
from utils.logging import logger
|
|
from utils.read import accounts, bots
|
|
from utils.storage import ContentAddressedStorage
|
|
from watcher import tokens
|
|
from watcher.context import build_bot_capture_context
|
|
from watcher.drain import drain_backlog
|
|
|
|
DEVICE_MODEL = "Beavergram Bot"
|
|
TOKEN_MISSING = "Токен не найден на диске" # noqa: S105
|
|
INVALID_TOKEN_ERRORS = ("ACCESS_TOKEN_INVALID", "ACCESS_TOKEN_EXPIRED")
|
|
|
|
|
|
@dataclass
|
|
class RunningBot:
|
|
client: PyroClient
|
|
account_id: int
|
|
tasks: list[asyncio.Task]
|
|
|
|
|
|
async def _cancel(task: asyncio.Task) -> None:
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|
|
|
|
|
|
def _handlers(pool: asyncpg.Pool) -> tuple:
|
|
async def on_message(client: Client, message) -> None: # noqa: ANN001
|
|
ctx = getattr(client, "capture", None)
|
|
if ctx is None or message.empty or message.chat is None or message.date is None:
|
|
return
|
|
meta = meta_from_chat(message.chat, ctx.contacts.ids)
|
|
await ctx.watches.on_text(
|
|
meta.chat_id, message.id, message.text or message.caption
|
|
)
|
|
toggles = ctx.resolve(meta)
|
|
if not toggles.messages:
|
|
return
|
|
await capture_message(client, message, ctx, toggles)
|
|
await notify_bg_event(
|
|
ctx.pool,
|
|
"message",
|
|
ctx.account_id,
|
|
chat_id=meta.chat_id,
|
|
message_id=message.id,
|
|
)
|
|
if message.chat.type is not enums.ChatType.PRIVATE:
|
|
await enqueue(
|
|
pool, ctx.account_id, "enrich_chat", {"chat_id": meta.chat_id}
|
|
)
|
|
|
|
async def on_member(client: Client, update) -> None: # noqa: ANN001
|
|
ctx = getattr(client, "capture", None)
|
|
if ctx is None or update.chat is None:
|
|
return
|
|
await enqueue(pool, ctx.account_id, "enrich_chat", {"chat_id": update.chat.id})
|
|
|
|
return (
|
|
MessageHandler(on_message),
|
|
EditedMessageHandler(on_message),
|
|
ChatMemberUpdatedHandler(on_member),
|
|
)
|
|
|
|
|
|
def _is_invalid_token(exc: RPCError) -> bool:
|
|
return any(marker in str(exc) for marker in INVALID_TOKEN_ERRORS)
|
|
|
|
|
|
class BotRegistry:
|
|
def __init__(self, pool: asyncpg.Pool, storage: ContentAddressedStorage) -> None:
|
|
self._pool = pool
|
|
self._storage = storage
|
|
self._running: dict[int, RunningBot] = {}
|
|
self._lock = asyncio.Lock()
|
|
|
|
@property
|
|
def running(self) -> list[int]:
|
|
return list(self._running)
|
|
|
|
async def sync(self) -> None:
|
|
async with self._lock:
|
|
await self._adopt_dropped_in()
|
|
enabled = await bots.enabled_bot_ids(self._pool)
|
|
for bot_id in set(self._running) - enabled:
|
|
await self._stop(bot_id)
|
|
for bot_id in enabled - set(self._running):
|
|
await self._start(bot_id)
|
|
for bot_id in await bots.removed_bot_ids(self._pool):
|
|
tokens.drop(bot_id)
|
|
|
|
async def close(self) -> None:
|
|
for bot_id in list(self._running):
|
|
await self._stop(bot_id)
|
|
|
|
async def _adopt_dropped_in(self) -> None:
|
|
unknown = tokens.stored_bot_ids() - await bots.known_bot_ids(self._pool)
|
|
for bot_id in unknown:
|
|
await bots.register_bot(self._pool, bot_id)
|
|
logger.info(f"[green]Bot token adopted from disk:[/] {bot_id}")
|
|
|
|
def _client(self, bot_id: int, token: str) -> PyroClient:
|
|
return PyroClient(
|
|
str(bot_id),
|
|
workdir=str(tokens.sessions_dir()),
|
|
device_model=DEVICE_MODEL,
|
|
load_handlers=False,
|
|
bot_token=token,
|
|
skip_updates=False,
|
|
)
|
|
|
|
async def _start(self, bot_id: int) -> None:
|
|
token = tokens.load(bot_id)
|
|
if token is None:
|
|
await bots.set_status(
|
|
self._pool, bot_id, bots.STATUS_UNAUTHORIZED, detail=TOKEN_MISSING
|
|
)
|
|
return
|
|
client = self._client(bot_id, token)
|
|
for handler in _handlers(self._pool):
|
|
client.add_handler(handler)
|
|
try:
|
|
await client.start()
|
|
me = client.me
|
|
if me is None:
|
|
msg = "bot session is not authorized"
|
|
raise RuntimeError(msg)
|
|
except RPCError as exc:
|
|
status = (
|
|
bots.STATUS_UNAUTHORIZED
|
|
if _is_invalid_token(exc)
|
|
else bots.STATUS_ERROR
|
|
)
|
|
await bots.set_status(self._pool, bot_id, status, detail=str(exc))
|
|
logger.warning(f"[yellow]Bot {bot_id} failed to start:[/] {exc}")
|
|
with contextlib.suppress(Exception):
|
|
await client.stop()
|
|
return
|
|
except Exception as exc:
|
|
await bots.set_status(
|
|
self._pool, bot_id, bots.STATUS_ERROR, detail=str(exc)
|
|
)
|
|
logger.exception(f"[red]Bot {bot_id} failed to start[/]")
|
|
with contextlib.suppress(Exception):
|
|
await client.stop()
|
|
return
|
|
|
|
account_id = await accounts.sync_account(
|
|
self._pool, me, str(bot_id), is_bot=True
|
|
)
|
|
client.capture = await build_bot_capture_context(
|
|
client, self._pool, self._storage, account_id
|
|
)
|
|
await bots.link_account(self._pool, bot_id, account_id)
|
|
await bots.set_status(
|
|
self._pool, bot_id, bots.STATUS_OK, username=me.username, title=me.full_name
|
|
)
|
|
await accounts.notify_accounts_changed(self._pool)
|
|
|
|
consumer = JobConsumer(client, self._pool, account_id)
|
|
tasks = [asyncio.create_task(consumer.run())]
|
|
if await bots.needs_backfill(self._pool, bot_id):
|
|
tasks.append(asyncio.create_task(self._backlog(client, bot_id, account_id)))
|
|
self._running[bot_id] = RunningBot(client, account_id, tasks)
|
|
logger.info(
|
|
f"[green]Bot account {account_id} watched:[/] @{me.username} ({bot_id})"
|
|
)
|
|
|
|
async def _backlog(self, client: PyroClient, bot_id: int, account_id: int) -> None:
|
|
stored = await drain_backlog(client, account_id)
|
|
await bots.mark_backfilled(self._pool, bot_id)
|
|
if stored:
|
|
await accounts.notify_accounts_changed(self._pool)
|
|
|
|
async def _stop(self, bot_id: int) -> None:
|
|
running = self._running.pop(bot_id, None)
|
|
if running is None:
|
|
return
|
|
for task in running.tasks:
|
|
await _cancel(task)
|
|
with contextlib.suppress(Exception):
|
|
await running.client.stop()
|
|
logger.info(f"[yellow]Bot unwatched:[/] {bot_id}")
|