220 lines
7.2 KiB
Python
220 lines
7.2 KiB
Python
import asyncio
|
|
import contextlib
|
|
import signal
|
|
from collections.abc import Callable, Coroutine
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import asyncpg
|
|
import uvloop
|
|
|
|
from dependencies.container import container
|
|
from userbot.modules.capture import CaptureContext, build_capture_context
|
|
from userbot.modules.client import PyroClient
|
|
from userbot.modules.jobs import JobConsumer
|
|
from utils.env import env
|
|
from utils.jobs import enqueue
|
|
from utils.listen import Listener
|
|
from utils.logging import logger, setup_logging
|
|
from utils.read.accounts import (
|
|
ACCOUNTS_CHANGED_CHANNEL,
|
|
inactive_session_names,
|
|
session_device_models,
|
|
sync_account,
|
|
)
|
|
from utils.read.watches import WATCHES_CHANGED_CHANNEL
|
|
from utils.storage import ContentAddressedStorage
|
|
|
|
setup_logging()
|
|
|
|
SESSION_FLUSH_INTERVAL = 60
|
|
|
|
|
|
@dataclass
|
|
class RunningAccount:
|
|
client: PyroClient
|
|
consumer_task: asyncio.Task
|
|
device_model: str | None
|
|
|
|
|
|
def _sessions_dir() -> Path:
|
|
sessions_dir = Path(env.tg.sessions_dir)
|
|
sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
return sessions_dir
|
|
|
|
|
|
async def _cancel(task: asyncio.Task) -> None:
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|
|
|
|
|
|
class AccountRegistry:
|
|
def __init__(self, pool: asyncpg.Pool, storage: ContentAddressedStorage) -> None:
|
|
self._pool = pool
|
|
self._storage = storage
|
|
self._running: dict[str, RunningAccount] = {}
|
|
self._lock = asyncio.Lock()
|
|
|
|
@property
|
|
def clients(self) -> list[PyroClient]:
|
|
return [account.client for account in self._running.values()]
|
|
|
|
async def sync(self) -> None:
|
|
async with self._lock:
|
|
inactive = await inactive_session_names(self._pool)
|
|
models = await session_device_models(self._pool)
|
|
present: set[str] = set()
|
|
for path in sorted(_sessions_dir().glob("*.session")):
|
|
if path.stem in inactive:
|
|
await self._log_out(path)
|
|
continue
|
|
present.add(path.stem)
|
|
device_model = models.get(path.stem)
|
|
running = self._running.get(path.stem)
|
|
if running is not None and running.device_model != device_model:
|
|
await self._stop(path.stem)
|
|
running = None
|
|
if running is None:
|
|
await self._start(path, device_model)
|
|
for session_name in set(self._running) - present:
|
|
await self._stop(session_name)
|
|
|
|
async def close(self) -> None:
|
|
for session_name in list(self._running):
|
|
await self._stop(session_name)
|
|
|
|
async def _start(self, path: Path, device_model: str | None) -> None:
|
|
session_name = path.stem
|
|
client = PyroClient(
|
|
session_name, workdir=str(path.parent), device_model=device_model
|
|
)
|
|
try:
|
|
await client.start()
|
|
me = client.me
|
|
if me is None:
|
|
msg = f"session {session_name} is not authorized"
|
|
raise RuntimeError(msg)
|
|
account_id = await sync_account(self._pool, me, session_name)
|
|
client.capture = await build_capture_context(
|
|
client, self._pool, self._storage, account_id
|
|
)
|
|
except Exception:
|
|
logger.exception(f"[red]Failed to start session:[/] {session_name}")
|
|
with contextlib.suppress(Exception):
|
|
await client.stop()
|
|
return
|
|
consumer = JobConsumer(client, self._pool, account_id)
|
|
self._running[session_name] = RunningAccount(
|
|
client, asyncio.create_task(consumer.run()), device_model
|
|
)
|
|
logger.info(f"[green]Client started:[/] {me.full_name} ({me.id})")
|
|
await enqueue(self._pool, account_id, "sync_dialogs", {})
|
|
await enqueue(self._pool, account_id, "sync_contacts", {})
|
|
|
|
async def _stop(self, session_name: str) -> None:
|
|
account = self._running.pop(session_name, None)
|
|
if account is None:
|
|
return
|
|
await _cancel(account.consumer_task)
|
|
with contextlib.suppress(Exception):
|
|
await account.client.stop()
|
|
logger.info(f"[yellow]Client stopped:[/] {session_name}")
|
|
|
|
async def _log_out(self, path: Path) -> None:
|
|
account = self._running.pop(path.stem, None)
|
|
if account is not None:
|
|
await _cancel(account.consumer_task)
|
|
client = account.client
|
|
else:
|
|
client = PyroClient(
|
|
path.stem, workdir=str(path.parent), load_handlers=False
|
|
)
|
|
with contextlib.suppress(Exception):
|
|
if account is None:
|
|
await client.start()
|
|
await client.log_out()
|
|
with contextlib.suppress(Exception):
|
|
await client.stop()
|
|
path.unlink(missing_ok=True) # noqa: ASYNC240
|
|
logger.info(f"[yellow]Account logged out:[/] {path.stem}")
|
|
|
|
|
|
async def _flush_sessions(registry: AccountRegistry) -> None:
|
|
while True:
|
|
await asyncio.sleep(SESSION_FLUSH_INTERVAL)
|
|
for client in registry.clients:
|
|
with contextlib.suppress(Exception):
|
|
await client.storage.save()
|
|
|
|
|
|
def _change_listener(registry: AccountRegistry, tasks: set[asyncio.Task]) -> Listener:
|
|
def spawn(coro: Coroutine[Any, Any, None]) -> None:
|
|
task = asyncio.create_task(coro)
|
|
tasks.add(task)
|
|
task.add_done_callback(tasks.discard)
|
|
|
|
def reload(
|
|
make_coro: Callable[[CaptureContext], Coroutine[Any, Any, None]],
|
|
) -> None:
|
|
for client in registry.clients:
|
|
if client.capture is not None:
|
|
spawn(make_coro(client.capture))
|
|
|
|
def on_policy(_payload: str) -> None:
|
|
reload(lambda capture: capture.reload_policies())
|
|
|
|
def on_watch(_payload: str) -> None:
|
|
reload(lambda capture: capture.watches.refresh())
|
|
|
|
def on_accounts(_payload: str) -> None:
|
|
spawn(registry.sync())
|
|
|
|
def on_reconnect() -> None:
|
|
on_policy("")
|
|
on_watch("")
|
|
on_accounts("")
|
|
|
|
return Listener(
|
|
"userbot",
|
|
{
|
|
"policy_changed": on_policy,
|
|
WATCHES_CHANGED_CHANNEL: on_watch,
|
|
ACCOUNTS_CHANGED_CHANNEL: on_accounts,
|
|
},
|
|
on_reconnect,
|
|
)
|
|
|
|
|
|
async def runner() -> None:
|
|
pool = await container.get(asyncpg.Pool)
|
|
storage = await container.get(ContentAddressedStorage)
|
|
|
|
registry = AccountRegistry(pool, storage)
|
|
tasks: set[asyncio.Task] = set()
|
|
listener = _change_listener(registry, tasks)
|
|
stop = asyncio.Event()
|
|
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
|
|
flush = asyncio.create_task(_flush_sessions(registry))
|
|
try:
|
|
await registry.sync()
|
|
if not registry.clients:
|
|
logger.warning("[yellow]No sessions yet. Add an account in the web UI.[/]")
|
|
await listener.start()
|
|
logger.info("[green]Userbot running.[/]")
|
|
await stop.wait()
|
|
finally:
|
|
await _cancel(flush)
|
|
await listener.stop()
|
|
await registry.close()
|
|
await container.close()
|
|
|
|
|
|
def main() -> None:
|
|
uvloop.install()
|
|
logger.info("Starting userbot...")
|
|
with contextlib.suppress(KeyboardInterrupt):
|
|
asyncio.run(runner())
|
|
logger.info("[red]Userbot stopped.[/]")
|