diff --git a/README.md b/README.md index cf02618..83cacb1 100644 --- a/README.md +++ b/README.md @@ -12,18 +12,19 @@ Useful on its own: a Telegram where nothing disappears. In the [beaver](https:// - **Presence** as a time series (TimescaleDB), read receipts, stories. - **Capture policy** from your Telegram folders with per-chat overrides: channels nothing, groups messages only, private chats everything, by default. - **Annotations and watches**: your notes on chats and rules that fire alerts, both yours to write in the web client. +- **Watched bots**: bot tokens you add in the web client. Each one logs in over MTProto (`auth.importBotAuthorization`, the same door a bot-login client uses), backfills the history of the bot's dialogs once and then keeps the incoming stream: who writes to the bot, what, and when. A separate authorization, so it does not take `getUpdates` away from whoever runs that bot over the Bot API. Nothing is ever deleted from the archive. ## Parts -- `backend/` - `userbot` (kurigram, the capture loop and jobs) and `api` (FastAPI: the web API, the MCP server, and the built frontend as static files). +- `backend/` - `userbot` (kurigram, the capture loop and jobs), `watcher` (kurigram again, one bot-token session per watched bot) and `api` (FastAPI: the web API, the MCP server, and the built frontend as static files). - `frontend/` - SvelteKit, `adapter-static`; a chat client over the archive: folders, chat list, thread with media, versions and the deleted messages inline, search, stories, presence graphs. - `docker-compose.yml` - includes `backend/` (Postgres with TimescaleDB, `userbot`, `api`) and `frontend/` (the dev server); profiles `external` and `services`. ## MCP -Read-only: `search_messages_tool`, `list_chats`, `get_chat_history` (with deleted messages included), `get_deleted_messages`, `get_message_versions`, `get_media`, `get_callbacks`, `presence_history`, `get_peer_history`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. The one write is `set_watch`. There are no send, edit or delete tools and there will not be. +Read-only: `list_watched_bots`, `get_bot_stats`, `get_bot_updates`, `search_messages_tool`, `list_chats`, `get_chat_history` (with deleted messages included), `get_deleted_messages`, `get_message_versions`, `get_media`, `get_callbacks`, `presence_history`, `get_peer_history`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. The one write is `set_watch`. There are no send, edit or delete tools and there will not be. ## Run diff --git a/backend/.env.example b/backend/.env.example index 29b6fb7..eafd9f2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -18,6 +18,10 @@ DB__SCRIPTS_CONNECTION_URL=postgresql://beavergram:beavergram@localhost:5432/bea TG__SESSION_NAME=beavergram TG__SESSIONS_DIR=sessions +BOTS__SESSIONS_DIR=sessions/bots +BOTS__BACKFILL_CHATS=0 +BOTS__BACKFILL_PER_CHAT=0 + API__HOST=0.0.0.0 API__PORT=8080 diff --git a/backend/Makefile b/backend/Makefile index 3cb1fdb..cbcd6e6 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -2,7 +2,7 @@ COMPOSE := cd .. && docker compose IMAGE := beavergram/backend IMAGE_INPUTS := pyproject.toml uv.lock Dockerfile CODE := src scripts ../frontend -SERVICES := api userbot +SERVICES := api userbot watcher changed = ! git diff --quiet $(1) -- $(2) 2>/dev/null stamp = { git update-ref $(1) HEAD 2>/dev/null || true; } diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 2b824d3..fcbf545 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -55,6 +55,30 @@ services: networks: database: + watcher: + build: backend + image: beavergram/backend + profiles: [watcher, services] + restart: unless-stopped + env_file: + - path: .env + required: false + - path: backend/.env + required: false + environment: + RUN_ENVIRONMENT: prod + TZ: ${TZ:-UTC} + volumes: + - ./backend/src:/app/src + - ./backend/sessions:/app/sessions + command: [watcher] + depends_on: + postgres: + condition: service_healthy + required: false + networks: + database: + postgres: image: timescale/timescaledb:2.27.1-pg17 profiles: [postgres, external] diff --git a/backend/migrations/versions/c8f3b6a2d571_bot_watcher.py b/backend/migrations/versions/c8f3b6a2d571_bot_watcher.py new file mode 100644 index 0000000..8a06c52 --- /dev/null +++ b/backend/migrations/versions/c8f3b6a2d571_bot_watcher.py @@ -0,0 +1,85 @@ +"""bot watcher + +Revision ID: c8f3b6a2d571 +Revises: a6d2f8b41c9e +Create Date: 2026-09-18 12:00:00.000000 + +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "c8f3b6a2d571" +down_revision: str | None = "a6d2f8b41c9e" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_BOTS = """ +CREATE TABLE watched_bots ( + bot_id bigint PRIMARY KEY, + username text, + title text, + enabled boolean NOT NULL DEFAULT true, + status text NOT NULL DEFAULT 'pending', + status_detail text, + backfilled_at timestamptz, + checked_at timestamptz, + added_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +) +""" + +_UPDATES = """ +CREATE TABLE bot_updates ( + bot_id bigint NOT NULL, + kind text NOT NULL, + chat_id bigint NOT NULL, + item_id bigint NOT NULL, + ts timestamptz NOT NULL, + source text NOT NULL, + outgoing boolean NOT NULL DEFAULT false, + sender_id bigint, + sender_username text, + sender_name text, + sender_is_bot boolean, + chat_type text, + chat_title text, + text text, + media_kind text, + edited_at timestamptz, + received_at timestamptz NOT NULL DEFAULT now(), + raw jsonb NOT NULL, + PRIMARY KEY (bot_id, kind, chat_id, item_id, ts) +) +""" + + +def upgrade() -> None: + op.execute(_BOTS) + op.execute(_UPDATES) + op.execute( + "SELECT create_hypertable('bot_updates', by_range('ts', INTERVAL '1 month'))" + ) + op.execute( + "ALTER TABLE bot_updates SET (" + "timescaledb.enable_columnstore = true, " + "timescaledb.segmentby = 'bot_id', " + "timescaledb.orderby = 'ts DESC')" + ) + op.execute( + "CALL add_columnstore_policy('bot_updates', after => INTERVAL '30 days')" + ) + op.execute("CREATE INDEX ix_bot_updates_recent ON bot_updates (bot_id, ts DESC)") + op.execute( + "CREATE INDEX ix_bot_updates_sender ON bot_updates " + "(bot_id, sender_id, ts DESC) WHERE NOT outgoing" + ) + op.execute( + "CREATE INDEX ix_bot_updates_chat ON bot_updates (bot_id, chat_id, ts DESC)" + ) + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS bot_updates") + op.execute("DROP TABLE IF EXISTS watched_bots") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 84da4b8..079ed12 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -27,14 +27,14 @@ requires = ["uv_build>=0.12.13,<0.13.0"] build-backend = "uv_build" [tool.uv.build-backend] -module-name = ["userbot", "api", "utils", "dependencies"] +module-name = ["userbot", "api", "watcher", "utils", "dependencies"] [tool.ruff] target-version = "py313" [tool.ruff.lint] select = ["ALL"] -ignore = ["CPY", "D1", "D203", "D212", "COM812", "BLE", "S101", "PT", "RUF001", "RUF002", "RUF003", "TRY301"] +ignore = ["CPY", "D1", "D203", "D212", "COM812", "BLE", "S101", "PT", "PLR0917", "RUF001", "RUF002", "RUF003", "TRY301"] unfixable = ["F401"] [tool.ruff.lint.per-file-ignores] diff --git a/backend/src/api/app.py b/backend/src/api/app.py index ae6eb0d..80869e7 100644 --- a/backend/src/api/app.py +++ b/backend/src/api/app.py @@ -18,6 +18,7 @@ from api.routers import ( annotations, avatars, backfill, + bots, chats, custom_emoji, discover, @@ -73,6 +74,7 @@ async def health(pool: FromDishka[asyncpg.Pool]) -> dict[str, bool]: app.include_router(accounts.router) app.include_router(analytics.router) +app.include_router(bots.router) app.include_router(policy.router) app.include_router(folders.router) app.include_router(backfill.router) diff --git a/backend/src/api/mcp/server.py b/backend/src/api/mcp/server.py index 361458c..50ceda2 100644 --- a/backend/src/api/mcp/server.py +++ b/backend/src/api/mcp/server.py @@ -13,9 +13,9 @@ from pydantic import BaseModel from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names from dependencies.container import container from utils.jobs import enqueue -from utils.read import annotations, chats, media, peers, presence, social, watches +from utils.read import annotations, bots, chats, media, peers, presence, social, watches from utils.read.accounts import self_user_id, unknown_account_hint -from utils.read.models import DEFAULT_LIMIT, Page +from utils.read.models import DEFAULT_LIMIT, MAX_LIMIT, Page from utils.search.models import SearchFilters from utils.search.repository import search_messages from utils.storage import ContentAddressedStorage @@ -58,6 +58,12 @@ Typical flows: - Monitoring: `set_watch` creates a local rule, `list_watches` lists rules, and `list_alerts` reads what those rules fired. +Watched bots (a separate axis, `account_id` does not apply): +- beavergram can also hold bot tokens of arbitrary bots and archive who writes + to them over MTProto. `list_watched_bots` names them, `get_bot_stats` gives + the aggregate picture and `get_bot_updates` the raw incoming stream. These + take `bot_id` (the bot's Telegram id), never `account_id`. + Writes: everything is read-only except `set_watch`, the only allowed write. Paging: list/search tools accept `limit` and `offset`. @@ -356,3 +362,41 @@ async def list_alerts( await _pool(), account_id, Page(limit=limit, offset=offset), seen=seen ) ) + + +@mcp.tool +async def list_watched_bots() -> list[dict[str, Any]]: + """List bots this beavergram watches, with their status and counters.""" + return _dump(await bots.list_bots(await _pool())) + + +@mcp.tool +async def get_bot_stats( + bot_id: int, days: int = 90, senders: int = 20 +) -> dict[str, Any] | None: + """Who writes to a watched bot: totals, daily volume, hours, top senders.""" + result = await bots.stats(await _pool(), bot_id, days=days, senders=senders) + return result.model_dump(mode="json") if result else None + + +@mcp.tool +async def get_bot_updates( + bot_id: int, + sender_id: int | None = None, + chat_id: int | None = None, + incoming_only: bool = True, + limit: int = DEFAULT_LIMIT, + offset: int = 0, +) -> list[dict[str, Any]]: + """Read what people sent to a watched bot, newest first.""" + return _dump( + await bots.list_updates( + await _pool(), + bot_id, + sender_id=sender_id, + chat_id=chat_id, + incoming_only=incoming_only, + limit=min(limit, MAX_LIMIT), + offset=offset, + ) + ) diff --git a/backend/src/api/routers/bots.py b/backend/src/api/routers/bots.py new file mode 100644 index 0000000..33260a2 --- /dev/null +++ b/backend/src/api/routers/bots.py @@ -0,0 +1,79 @@ +from typing import Annotated + +import asyncpg +from dishka.integrations.fastapi import DishkaRoute, FromDishka +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +from utils.read import bots +from utils.read.models import DEFAULT_LIMIT, BotStats, BotUpdateView, BotView, Page +from watcher import tokens + +router = APIRouter(prefix="/api/bots", tags=["bots"], route_class=DishkaRoute) + +INVALID_TOKEN = "token must look like 123456:ABC-DEF..." # noqa: S105 + + +class BotAdd(BaseModel): + token: str + + +@router.get("") +async def list_bots(pool: FromDishka[asyncpg.Pool]) -> list[BotView]: + return await bots.list_bots(pool) + + +@router.post("", status_code=201) +async def add_bot(pool: FromDishka[asyncpg.Pool], body: BotAdd) -> BotView: + bot_id = tokens.bot_id_of(body.token) + if bot_id is None: + raise HTTPException(status_code=422, detail=INVALID_TOKEN) + tokens.save(bot_id, body.token) + await bots.register_bot(pool, bot_id) + await bots.notify_bots_changed(pool) + bot = await bots.get_bot(pool, bot_id) + if bot is None: + raise HTTPException(status_code=500, detail="bot was not registered") + return bot + + +@router.delete("/{bot_id}", status_code=204) +async def remove_bot(pool: FromDishka[asyncpg.Pool], bot_id: int) -> None: + if not await bots.remove_bot(pool, bot_id): + raise HTTPException(status_code=404, detail="unknown bot") + await bots.notify_bots_changed(pool) + + +@router.get("/{bot_id}/stats") +async def stats( + pool: FromDishka[asyncpg.Pool], + bot_id: int, + days: Annotated[int, Query()] = 90, + senders: Annotated[int, Query()] = 20, +) -> BotStats: + result = await bots.stats(pool, bot_id, days=days, senders=senders) + if result is None: + raise HTTPException(status_code=404, detail="unknown bot") + return result + + +@router.get("/{bot_id}/updates") +async def list_updates( + pool: FromDishka[asyncpg.Pool], + bot_id: int, + sender_id: Annotated[int | None, Query()] = None, + chat_id: Annotated[int | None, Query()] = None, + incoming_only: Annotated[bool, Query()] = True, + limit: Annotated[int, Query()] = DEFAULT_LIMIT, + offset: Annotated[int, Query()] = 0, +) -> list[BotUpdateView]: + page = Page(limit=limit, offset=offset) + return await bots.list_updates( + pool, + bot_id, + sender_id=sender_id, + chat_id=chat_id, + incoming_only=incoming_only, + limit=page.capped_limit, + offset=page.offset, + ) diff --git a/backend/src/userbot/modules/client.py b/backend/src/userbot/modules/client.py index 779c3fc..9b549b0 100644 --- a/backend/src/userbot/modules/client.py +++ b/backend/src/userbot/modules/client.py @@ -2,6 +2,8 @@ from typing import TYPE_CHECKING from pyrogram import Client, enums +from utils.telegram import API_HASH, API_ID + if TYPE_CHECKING: from userbot.modules.capture import CaptureContext @@ -22,8 +24,8 @@ class PyroClient(Client): super().__init__( name, workdir=workdir, - api_id=2040, - api_hash="b18441a1ff607e10a989891a5462e627", + api_id=API_ID, + api_hash=API_HASH, device_model=device_model or DEVICE_MODEL, system_version="Windows 11 x64", app_version="7.0.8 x64", diff --git a/backend/src/utils/env.py b/backend/src/utils/env.py index 8b2ec26..bc7cb3f 100644 --- a/backend/src/utils/env.py +++ b/backend/src/utils/env.py @@ -31,6 +31,12 @@ class TelegramSettings(BaseSettings): sessions_dir: str = "sessions" +class BotsSettings(BaseSettings): + sessions_dir: str = "sessions/bots" + backfill_per_chat: int = 0 + backfill_chats: int = 0 + + class ApiSettings(BaseSettings): host: str = "0.0.0.0" # noqa: S104 port: int = 8080 @@ -56,6 +62,7 @@ class LogSettings(BaseSettings): class Settings(BaseSettings): db: DatabaseSettings = Field(default_factory=DatabaseSettings) tg: TelegramSettings = Field(default_factory=TelegramSettings) + bots: BotsSettings = Field(default_factory=BotsSettings) api: ApiSettings = Field(default_factory=ApiSettings) auth: AuthSettings = Field(default_factory=AuthSettings) storage: StorageSettings = Field(default_factory=StorageSettings) diff --git a/backend/src/utils/read/bots.py b/backend/src/utils/read/bots.py new file mode 100644 index 0000000..ba104f8 --- /dev/null +++ b/backend/src/utils/read/bots.py @@ -0,0 +1,261 @@ +from datetime import UTC, datetime, timedelta + +import asyncpg + +from utils.read.models import ( + BotBucket, + BotCount, + BotHour, + BotSender, + BotStats, + BotSummary, + BotUpdateView, + BotView, +) + +BOTS_CHANGED_CHANNEL = "watched_bots_changed" + +STATUS_PENDING = "pending" +STATUS_OK = "ok" +STATUS_UNAUTHORIZED = "unauthorized" +STATUS_ERROR = "error" +STATUS_REMOVED = "removed" + +_BOT_COLS = """ +SELECT b.bot_id, b.username, b.title, b.enabled, b.status, b.status_detail, + b.backfilled_at, b.checked_at, b.added_at, + coalesce(u.incoming, 0) AS incoming, + coalesce(u.senders, 0) AS senders, + u.last_ts +FROM watched_bots b +LEFT JOIN LATERAL ( + SELECT count(*) AS incoming, count(DISTINCT sender_id) AS senders, + max(ts) AS last_ts + FROM bot_updates WHERE bot_id = b.bot_id AND NOT outgoing +) u ON true +""" + +_REGISTER = """ +INSERT INTO watched_bots (bot_id, status) VALUES ($1, 'pending') +ON CONFLICT (bot_id) DO UPDATE SET + enabled = true, status = 'pending', status_detail = NULL, updated_at = now() +""" + +_REMOVE = """ +UPDATE watched_bots +SET enabled = false, status = 'removed', status_detail = NULL, updated_at = now() +WHERE bot_id = $1 AND enabled +""" + +_SET_STATUS = """ +UPDATE watched_bots SET + status = $2, + status_detail = $3, + username = coalesce($4, username), + title = coalesce($5, title), + checked_at = now(), + updated_at = now() +WHERE bot_id = $1 +""" + +_STORE = """ +INSERT INTO bot_updates + (bot_id, kind, chat_id, item_id, ts, source, outgoing, sender_id, + sender_username, sender_name, sender_is_bot, chat_type, chat_title, + text, media_kind, edited_at, raw) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, + $16, $17::jsonb) +ON CONFLICT (bot_id, kind, chat_id, item_id, ts) DO UPDATE SET + text = EXCLUDED.text, + media_kind = EXCLUDED.media_kind, + chat_title = coalesce(EXCLUDED.chat_title, bot_updates.chat_title), + sender_username = coalesce(EXCLUDED.sender_username, + bot_updates.sender_username), + sender_name = coalesce(EXCLUDED.sender_name, bot_updates.sender_name), + edited_at = coalesce(EXCLUDED.edited_at, bot_updates.edited_at), + raw = EXCLUDED.raw +""" + +_INCOMING = "bot_id = $1 AND NOT outgoing AND ts >= $2" + +_SUMMARY = """ +SELECT count(*) FILTER (WHERE NOT outgoing) AS incoming, + count(*) FILTER (WHERE outgoing) AS outgoing, + count(DISTINCT sender_id) FILTER (WHERE NOT outgoing) AS senders, + count(DISTINCT chat_id) AS chats, + min(ts) AS first_ts, max(ts) AS last_ts +FROM bot_updates WHERE bot_id = $1 AND ts >= $2 +""" + +_TIMELINE = f""" +SELECT time_bucket('1 day', ts) AS bucket, count(*) AS incoming, + count(DISTINCT sender_id) AS senders +FROM bot_updates WHERE {_INCOMING} +GROUP BY bucket ORDER BY bucket +""" # noqa: S608 + +_KINDS = f""" +SELECT kind AS key, count(*) AS incoming +FROM bot_updates WHERE {_INCOMING} +GROUP BY kind ORDER BY incoming DESC +""" # noqa: S608 + +_MEDIA = f""" +SELECT media_kind AS key, count(*) AS incoming +FROM bot_updates WHERE {_INCOMING} AND media_kind IS NOT NULL +GROUP BY media_kind ORDER BY incoming DESC +""" # noqa: S608 + +_HOURS = f""" +SELECT EXTRACT(HOUR FROM ts)::int AS hour, count(*) AS incoming +FROM bot_updates WHERE {_INCOMING} +GROUP BY hour ORDER BY hour +""" # noqa: S608 + +_SENDERS = f""" +SELECT sender_id, + (array_agg(sender_username ORDER BY ts DESC) FILTER ( + WHERE sender_username IS NOT NULL))[1] AS username, + (array_agg(sender_name ORDER BY ts DESC) FILTER ( + WHERE sender_name IS NOT NULL))[1] AS name, + bool_or(sender_is_bot) AS is_bot, + count(*) AS incoming, + (array_agg(chat_id ORDER BY ts DESC))[1] AS chat_id, + min(ts) AS first_ts, max(ts) AS last_ts +FROM bot_updates WHERE {_INCOMING} AND sender_id IS NOT NULL +GROUP BY sender_id ORDER BY incoming DESC LIMIT $3 +""" # noqa: S608 + +_VIEW_COLS = ( + "kind, chat_id, item_id, ts, source, outgoing, sender_id, sender_username, " + "sender_name, sender_is_bot, chat_type, chat_title, text, media_kind, edited_at" +) + + +async def notify_bots_changed(pool: asyncpg.Pool) -> None: + await pool.execute("SELECT pg_notify($1, '')", BOTS_CHANGED_CHANNEL) + + +async def list_bots(pool: asyncpg.Pool) -> list[BotView]: + rows = await pool.fetch(f"{_BOT_COLS} ORDER BY u.last_ts DESC NULLS LAST") + return [BotView(**dict(row)) for row in rows] + + +async def get_bot(pool: asyncpg.Pool, bot_id: int) -> BotView | None: + row = await pool.fetchrow(f"{_BOT_COLS} WHERE b.bot_id = $1", bot_id) + return BotView(**dict(row)) if row else None + + +async def enabled_bot_ids(pool: asyncpg.Pool) -> set[int]: + rows = await pool.fetch( + "SELECT bot_id FROM watched_bots WHERE enabled AND status <> $1", + STATUS_UNAUTHORIZED, + ) + return {row["bot_id"] for row in rows} + + +async def register_bot(pool: asyncpg.Pool, bot_id: int) -> None: + await pool.execute(_REGISTER, bot_id) + + +async def remove_bot(pool: asyncpg.Pool, bot_id: int) -> bool: + return await pool.execute(_REMOVE, bot_id) != "UPDATE 0" + + +async def set_status( # noqa: PLR0913 + pool: asyncpg.Pool, + bot_id: int, + status: str, + *, + detail: str | None = None, + username: str | None = None, + title: str | None = None, +) -> None: + await pool.execute(_SET_STATUS, bot_id, status, detail, username, title) + + +async def needs_backfill(pool: asyncpg.Pool, bot_id: int) -> bool: + return not await pool.fetchval( + "SELECT backfilled_at IS NOT NULL FROM watched_bots WHERE bot_id = $1", bot_id + ) + + +async def mark_backfilled(pool: asyncpg.Pool, bot_id: int) -> None: + await pool.execute( + "UPDATE watched_bots SET backfilled_at = now(), updated_at = now() " + "WHERE bot_id = $1", + bot_id, + ) + + +async def store_update(pool: asyncpg.Pool, values: tuple) -> None: + await pool.execute(_STORE, *values) + + +async def store_updates(pool: asyncpg.Pool, values: list[tuple]) -> None: + if values: + await pool.executemany(_STORE, values) + + +async def stats( + pool: asyncpg.Pool, bot_id: int, *, days: int = 90, senders: int = 20 +) -> BotStats | None: + bot = await get_bot(pool, bot_id) + if bot is None: + return None + since = datetime.now(UTC) - timedelta(days=days) + summary = await pool.fetchrow(_SUMMARY, bot_id, since) + timeline = await pool.fetch(_TIMELINE, bot_id, since) + kinds = await pool.fetch(_KINDS, bot_id, since) + media = await pool.fetch(_MEDIA, bot_id, since) + hours = await pool.fetch(_HOURS, bot_id, since) + sender_rows = await pool.fetch(_SENDERS, bot_id, since, senders) + return BotStats( + bot=bot, + summary=BotSummary(**dict(summary)), + timeline=[BotBucket(**dict(row)) for row in timeline], + kinds=[BotCount(**dict(row)) for row in kinds], + media=[BotCount(**dict(row)) for row in media], + hours=[BotHour(**dict(row)) for row in hours], + senders=[BotSender(**dict(row)) for row in sender_rows], + ) + + +async def list_updates( # noqa: PLR0913 + pool: asyncpg.Pool, + bot_id: int, + *, + sender_id: int | None = None, + chat_id: int | None = None, + incoming_only: bool = True, + limit: int = 50, + offset: int = 0, +) -> list[BotUpdateView]: + rows = await pool.fetch( + f"SELECT {_VIEW_COLS} FROM bot_updates " # noqa: S608 + "WHERE bot_id = $1 " + "AND ($2::bigint IS NULL OR sender_id = $2) " + "AND ($3::bigint IS NULL OR chat_id = $3) " + "AND (NOT $4 OR NOT outgoing) " + "ORDER BY ts DESC LIMIT $5 OFFSET $6", + bot_id, + sender_id, + chat_id, + incoming_only, + limit, + offset, + ) + return [BotUpdateView(**dict(row)) for row in rows] + + +async def removed_bot_ids(pool: asyncpg.Pool) -> set[int]: + rows = await pool.fetch( + "SELECT bot_id FROM watched_bots WHERE NOT enabled AND status = $1", + STATUS_REMOVED, + ) + return {row["bot_id"] for row in rows} + + +async def known_bot_ids(pool: asyncpg.Pool) -> set[int]: + rows = await pool.fetch("SELECT bot_id FROM watched_bots") + return {row["bot_id"] for row in rows} diff --git a/backend/src/utils/read/models.py b/backend/src/utils/read/models.py index 163b579..2db937a 100644 --- a/backend/src/utils/read/models.py +++ b/backend/src/utils/read/models.py @@ -423,3 +423,82 @@ class FileShareHitView(BaseModel): ip: str | None user_agent: str | None counted: bool + + +class BotView(BaseModel): + bot_id: int + username: str | None + title: str | None + enabled: bool + status: str + status_detail: str | None + backfilled_at: datetime | None + checked_at: datetime | None + added_at: datetime + incoming: int + senders: int + last_ts: datetime | None + + +class BotSummary(BaseModel): + incoming: int + outgoing: int + senders: int + chats: int + first_ts: datetime | None + last_ts: datetime | None + + +class BotSender(BaseModel): + sender_id: int + username: str | None + name: str | None + is_bot: bool | None + incoming: int + chat_id: int | None + first_ts: datetime + last_ts: datetime + + +class BotBucket(BaseModel): + bucket: datetime + incoming: int + senders: int + + +class BotCount(BaseModel): + key: str + incoming: int + + +class BotHour(BaseModel): + hour: int + incoming: int + + +class BotStats(BaseModel): + bot: BotView + summary: BotSummary + timeline: list[BotBucket] + kinds: list[BotCount] + media: list[BotCount] + hours: list[BotHour] + senders: list[BotSender] + + +class BotUpdateView(BaseModel): + kind: str + chat_id: int + item_id: int + ts: datetime + source: str + outgoing: bool + sender_id: int | None + sender_username: str | None + sender_name: str | None + sender_is_bot: bool | None + chat_type: str | None + chat_title: str | None + text: str | None + media_kind: str | None + edited_at: datetime | None diff --git a/backend/src/utils/telegram.py b/backend/src/utils/telegram.py new file mode 100644 index 0000000..1e18c6f --- /dev/null +++ b/backend/src/utils/telegram.py @@ -0,0 +1,2 @@ +API_ID = 2040 +API_HASH = "b18441a1ff607e10a989891a5462e627" diff --git a/backend/src/watcher/__init__.py b/backend/src/watcher/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/watcher/__main__.py b/backend/src/watcher/__main__.py new file mode 100644 index 0000000..acaf989 --- /dev/null +++ b/backend/src/watcher/__main__.py @@ -0,0 +1,4 @@ +from .runner import main + +if __name__ == "__main__": + main() diff --git a/backend/src/watcher/backfill.py b/backend/src/watcher/backfill.py new file mode 100644 index 0000000..2a12957 --- /dev/null +++ b/backend/src/watcher/backfill.py @@ -0,0 +1,71 @@ +import asyncio + +import asyncpg +from pyrogram import Client +from pyrogram.errors import FloodWait, RPCError + +from utils.env import env +from utils.logging import logger +from utils.read import bots +from watcher.capture import SOURCE_HISTORY, Row, message_row, store_many + +BATCH = 200 +MAX_FLOOD_WAIT = 300 +HISTORY_UNAVAILABLE = "История недоступна: {}" + + +async def _flush(pool: asyncpg.Pool, rows: list[Row]) -> int: + await store_many(pool, rows) + stored = len(rows) + rows.clear() + return stored + + +async def _chat_history( + client: Client, pool: asyncpg.Pool, bot_id: int, chat_id: int +) -> int: + rows: list[Row] = [] + stored = 0 + async for message in client.get_chat_history( + chat_id, limit=env.bots.backfill_per_chat + ): + if message.empty or message.chat is None or message.date is None: + continue + rows.append(message_row(bot_id, message, source=SOURCE_HISTORY)) + if len(rows) >= BATCH: + stored += await _flush(pool, rows) + return stored + await _flush(pool, rows) + + +async def backfill(client: Client, pool: asyncpg.Pool, bot_id: int) -> None: + try: + dialogs = [ + dialog.chat.id + async for dialog in client.get_dialogs(limit=env.bots.backfill_chats) + if dialog.chat is not None and dialog.chat.id is not None + ] + except RPCError as exc: + logger.warning(f"[yellow]Bot {bot_id}: dialogs unavailable:[/] {exc}") + await bots.set_status( + pool, bot_id, bots.STATUS_OK, detail=HISTORY_UNAVAILABLE.format(exc) + ) + return + + stored = 0 + for chat_id in dialogs: + try: + stored += await _chat_history(client, pool, bot_id, chat_id) + except FloodWait as exc: + wait = exc.value if isinstance(exc.value, int) else MAX_FLOOD_WAIT + 1 + if wait > MAX_FLOOD_WAIT: + logger.warning(f"[yellow]Bot {bot_id}: flood wait {wait}s, stop[/]") + break + await asyncio.sleep(wait) + except RPCError as exc: + logger.warning(f"[yellow]Bot {bot_id}: chat {chat_id} skipped:[/] {exc}") + + await bots.mark_backfilled(pool, bot_id) + logger.info( + f"[green]Bot {bot_id}: backfilled[/] {stored} messages " + f"from {len(dialogs)} chats" + ) diff --git a/backend/src/watcher/capture.py b/backend/src/watcher/capture.py new file mode 100644 index 0000000..7de33b9 --- /dev/null +++ b/backend/src/watcher/capture.py @@ -0,0 +1,146 @@ +from datetime import UTC, datetime + +import asyncpg +from pyrogram.types import CallbackQuery, ChatMemberUpdated, Message, User + +from userbot.modules.raw_json import raw_json +from utils.read import bots + +SOURCE_HISTORY = "history" +SOURCE_LIVE = "live" + +KIND_MESSAGE = "message" +KIND_CALLBACK = "callback_query" +KIND_MEMBER = "member" + +MEDIA_KINDS = ( + "photo", + "video", + "animation", + "audio", + "voice", + "video_note", + "document", + "sticker", + "contact", + "location", + "venue", + "poll", + "dice", + "story", + "game", +) + +Row = tuple + + +def _media_kind(message: Message) -> str | None: + return next( + (kind for kind in MEDIA_KINDS if getattr(message, kind, None) is not None), None + ) + + +def _full_name(user: User | None) -> str | None: + if user is None: + return None + parts = [user.first_name, user.last_name] + return " ".join(part for part in parts if part) or None + + +def _chat_title(message: Message) -> str | None: + chat = message.chat + if chat is None: + return None + parts = [chat.first_name, chat.last_name] + return chat.title or " ".join(part for part in parts if part) or None + + +def _chat_type(message: Message) -> str | None: + kind = getattr(message.chat, "type", None) + return kind.value if kind is not None else None + + +def message_row(bot_id: int, message: Message, *, source: str) -> Row: + user = message.from_user + chat = message.chat + sender = user.id if user is not None else None + if sender is None and message.sender_chat is not None: + sender = message.sender_chat.id + return ( + bot_id, + KIND_MESSAGE, + chat.id if chat is not None else 0, + message.id, + message.date, + source, + bool(message.outgoing), + sender, + user.username if user is not None else None, + _full_name(user), + user.is_bot if user is not None else None, + _chat_type(message), + _chat_title(message), + message.text or message.caption, + _media_kind(message), + message.edit_date, + raw_json(message), + ) + + +def callback_row(bot_id: int, query: CallbackQuery) -> Row: + user = query.from_user + message = query.message + chat = message.chat if message is not None else None + return ( + bot_id, + KIND_CALLBACK, + chat.id if chat is not None else 0, + message.id if message is not None else 0, + datetime.now(UTC), + SOURCE_LIVE, + False, + user.id if user is not None else None, + user.username if user is not None else None, + _full_name(user), + user.is_bot if user is not None else None, + chat.type.value if chat is not None and chat.type is not None else None, + chat.title if chat is not None else None, + query.data, + None, + None, + raw_json(query), + ) + + +def member_row(bot_id: int, update: ChatMemberUpdated) -> Row: + member = update.new_chat_member + user = member.user if member is not None else None + chat = update.chat + status = member.status.value if member is not None else None + return ( + bot_id, + KIND_MEMBER, + chat.id if chat is not None else 0, + user.id if user is not None else 0, + update.date or datetime.now(UTC), + SOURCE_LIVE, + False, + update.from_user.id if update.from_user is not None else None, + update.from_user.username if update.from_user is not None else None, + _full_name(update.from_user), + update.from_user.is_bot if update.from_user is not None else None, + chat.type.value if chat is not None and chat.type is not None else None, + chat.title if chat is not None else None, + status, + None, + None, + raw_json(update), + ) + + +async def store(pool: asyncpg.Pool, row: Row) -> None: + await bots.store_update(pool, row) + + +async def store_many(pool: asyncpg.Pool, rows: list[Row]) -> None: + await bots.store_updates(pool, rows) diff --git a/backend/src/watcher/registry.py b/backend/src/watcher/registry.py new file mode 100644 index 0000000..28021eb --- /dev/null +++ b/backend/src/watcher/registry.py @@ -0,0 +1,156 @@ +import asyncio +import contextlib +from dataclasses import dataclass + +import asyncpg +from pyrogram import Client +from pyrogram.errors import RPCError +from pyrogram.handlers import ( + CallbackQueryHandler, + ChatMemberUpdatedHandler, + EditedMessageHandler, + MessageHandler, +) + +from utils.logging import logger +from utils.read import bots +from utils.telegram import API_HASH, API_ID +from watcher import tokens +from watcher.backfill import backfill +from watcher.capture import SOURCE_LIVE, callback_row, member_row, message_row, store + +DEVICE_MODEL = "Beavergram Watcher" +TOKEN_MISSING = "Токен не найден на диске" # noqa: S105 +INVALID_TOKEN_ERRORS = ("ACCESS_TOKEN_INVALID", "ACCESS_TOKEN_EXPIRED") + + +@dataclass +class RunningBot: + client: Client + backfill_task: asyncio.Task | None + + +async def _cancel(task: asyncio.Task) -> None: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + +def _handlers(pool: asyncpg.Pool, bot_id: int) -> tuple: + async def on_message(_client: Client, message) -> None: # noqa: ANN001 + if message.empty or message.chat is None or message.date is None: + return + await store(pool, message_row(bot_id, message, source=SOURCE_LIVE)) + + async def on_callback(_client: Client, query) -> None: # noqa: ANN001 + await store(pool, callback_row(bot_id, query)) + + async def on_member(_client: Client, update) -> None: # noqa: ANN001 + await store(pool, member_row(bot_id, update)) + + return ( + MessageHandler(on_message), + EditedMessageHandler(on_message), + CallbackQueryHandler(on_callback), + 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) -> None: + self._pool = pool + 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}") + + 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 = Client( + str(bot_id), + api_id=API_ID, + api_hash=API_HASH, + bot_token=token, + workdir=str(tokens.sessions_dir()), + device_model=DEVICE_MODEL, + ) + for handler in _handlers(self._pool, bot_id): + 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 + + await bots.set_status( + self._pool, bot_id, bots.STATUS_OK, username=me.username, title=me.full_name + ) + task = ( + asyncio.create_task(backfill(client, self._pool, bot_id)) + if await bots.needs_backfill(self._pool, bot_id) + else None + ) + self._running[bot_id] = RunningBot(client, task) + logger.info(f"[green]Bot watched:[/] @{me.username} ({bot_id})") + + async def _stop(self, bot_id: int) -> None: + running = self._running.pop(bot_id, None) + if running is None: + return + if running.backfill_task is not None: + await _cancel(running.backfill_task) + with contextlib.suppress(Exception): + await running.client.stop() + logger.info(f"[yellow]Bot unwatched:[/] {bot_id}") diff --git a/backend/src/watcher/runner.py b/backend/src/watcher/runner.py new file mode 100644 index 0000000..f8e3de5 --- /dev/null +++ b/backend/src/watcher/runner.py @@ -0,0 +1,72 @@ +import asyncio +import contextlib +import signal + +import asyncpg +import uvloop + +from dependencies.container import container +from utils.env import env +from utils.logging import logger, setup_logging +from utils.read.bots import BOTS_CHANGED_CHANNEL +from watcher.registry import BotRegistry + +setup_logging() + +RETRY_INTERVAL = 300 + + +async def _retry_loop(registry: BotRegistry) -> None: + while True: + await asyncio.sleep(RETRY_INTERVAL) + with contextlib.suppress(Exception): + await registry.sync() + + +async def _listen_changes( + registry: BotRegistry, tasks: set[asyncio.Task] +) -> asyncpg.Connection: + def on_change( + _conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str + ) -> None: + task = asyncio.create_task(registry.sync()) + tasks.add(task) + task.add_done_callback(tasks.discard) + + conn = await asyncpg.connect(dsn=env.db.connection_url) + await conn.add_listener(BOTS_CHANGED_CHANNEL, on_change) + return conn + + +async def runner() -> None: + pool = await container.get(asyncpg.Pool) + registry = BotRegistry(pool) + tasks: set[asyncio.Task] = set() + listen_conn: asyncpg.Connection | None = None + stop = asyncio.Event() + asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set) + retry = asyncio.create_task(_retry_loop(registry)) + try: + await registry.sync() + if not registry.running: + logger.warning("[yellow]No bots yet. Add a bot token in the web UI.[/]") + listen_conn = await _listen_changes(registry, tasks) + logger.info("[green]Bot watcher running.[/]") + await stop.wait() + finally: + retry.cancel() + with contextlib.suppress(asyncio.CancelledError): + await retry + if listen_conn is not None: + with contextlib.suppress(Exception): + await listen_conn.close() + await registry.close() + await container.close() + + +def main() -> None: + uvloop.install() + logger.info("Starting bot watcher...") + with contextlib.suppress(KeyboardInterrupt): + asyncio.run(runner()) + logger.info("[red]Bot watcher stopped.[/]") diff --git a/backend/src/watcher/tokens.py b/backend/src/watcher/tokens.py new file mode 100644 index 0000000..e9ac630 --- /dev/null +++ b/backend/src/watcher/tokens.py @@ -0,0 +1,42 @@ +from pathlib import Path + +from utils.env import env + +TOKEN_SUFFIX = ".token" # noqa: S105 +SESSION_SUFFIX = ".session" +TOKEN_MODE = 0o600 + + +def sessions_dir() -> Path: + path = Path(env.bots.sessions_dir) + path.mkdir(parents=True, exist_ok=True) + return path + + +def bot_id_of(token: str) -> int | None: + head, _, secret = token.strip().partition(":") + return int(head) if head.isdigit() and secret else None + + +def save(bot_id: int, token: str) -> None: + path = sessions_dir() / f"{bot_id}{TOKEN_SUFFIX}" + path.write_text(token.strip(), encoding="utf-8") + path.chmod(TOKEN_MODE) + + +def load(bot_id: int) -> str | None: + path = sessions_dir() / f"{bot_id}{TOKEN_SUFFIX}" + return path.read_text(encoding="utf-8").strip() if path.is_file() else None + + +def drop(bot_id: int) -> None: + for suffix in (TOKEN_SUFFIX, SESSION_SUFFIX): + (sessions_dir() / f"{bot_id}{suffix}").unlink(missing_ok=True) + + +def stored_bot_ids() -> set[int]: + return { + int(path.stem) + for path in sessions_dir().glob(f"*{TOKEN_SUFFIX}") + if path.stem.isdigit() + } diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts index 18b91d5..e20f36c 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -4,6 +4,8 @@ import type { Alert, Annotation, AvatarHistoryView, + BotStats, + BotUpdate, CallbackView, CaptureToggles, Chat, @@ -34,6 +36,7 @@ import type { StoryView, VolumeBucket, Watch, + WatchedBot, } from "$lib/api/types"; import { accounts } from "$lib/stores/accounts.svelte"; @@ -568,3 +571,32 @@ export function updateAnnotation( export function deleteAnnotation(id: number): Promise { return request(`/annotations/${id}`, { method: "DELETE" }); } + +export function listWatchedBots(): Promise { + return request("/bots"); +} + +export function addWatchedBot(token: string): Promise { + return request("/bots", { method: "POST", body: { token } }); +} + +export function removeWatchedBot(botId: number): Promise { + return request(`/bots/${botId}`, { method: "DELETE" }); +} + +export function botStats(botId: number, days = 90): Promise { + return request(`/bots/${botId}/stats`, { query: { days } }); +} + +export function botUpdates( + botId: number, + options: { limit?: number; offset?: number; senderId?: number } = {} +): Promise { + return request(`/bots/${botId}/updates`, { + query: { + limit: options.limit, + offset: options.offset, + sender_id: options.senderId, + }, + }); +} diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index a8b67af..3b313bf 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -546,3 +546,82 @@ export interface ShareSubject { storyId?: number; versionId?: number; } + +export interface WatchedBot { + added_at: string; + backfilled_at: string | null; + bot_id: number; + checked_at: string | null; + enabled: boolean; + incoming: number; + last_ts: string | null; + senders: number; + status: string; + status_detail: string | null; + title: string | null; + username: string | null; +} + +export interface BotSummary { + chats: number; + first_ts: string | null; + incoming: number; + last_ts: string | null; + outgoing: number; + senders: number; +} + +export interface BotSender { + chat_id: number | null; + first_ts: string; + incoming: number; + is_bot: boolean | null; + last_ts: string; + name: string | null; + sender_id: number; + username: string | null; +} + +export interface BotBucket { + bucket: string; + incoming: number; + senders: number; +} + +export interface BotCount { + incoming: number; + key: string; +} + +export interface BotHour { + hour: number; + incoming: number; +} + +export interface BotStats { + bot: WatchedBot; + hours: BotHour[]; + kinds: BotCount[]; + media: BotCount[]; + senders: BotSender[]; + summary: BotSummary; + timeline: BotBucket[]; +} + +export interface BotUpdate { + chat_id: number; + chat_title: string | null; + chat_type: string | null; + edited_at: string | null; + item_id: number; + kind: string; + media_kind: string | null; + outgoing: boolean; + sender_id: number | null; + sender_is_bot: boolean | null; + sender_name: string | null; + sender_username: string | null; + source: string; + text: string | null; + ts: string; +} diff --git a/frontend/src/lib/components/RightColumn.svelte b/frontend/src/lib/components/RightColumn.svelte index 97293d3..499609e 100644 --- a/frontend/src/lib/components/RightColumn.svelte +++ b/frontend/src/lib/components/RightColumn.svelte @@ -1,6 +1,7 @@ @@ -78,6 +80,8 @@ {:else if ui.rightPanel === "shares"} + {:else if ui.rightPanel === "bots"} + {/if} diff --git a/frontend/src/lib/components/bots/BotStatsView.svelte b/frontend/src/lib/components/bots/BotStatsView.svelte new file mode 100644 index 0000000..eef942b --- /dev/null +++ b/frontend/src/lib/components/bots/BotStatsView.svelte @@ -0,0 +1,234 @@ + + +{#if loading && stats === null} + +{:else if stats} +
+
+
+ {stats.summary.incoming} + входящих +
+
+ {stats.summary.senders} + отправителей +
+
+ {stats.summary.chats} + чатов +
+
+ {stats.summary.outgoing} + ответов бота +
+
+ + {#if stats.timeline.length > 0} +
+

По дням

+ +
+ {/if} + + {#if stats.media.length > 0} +
+

Вложения

+
    + {#each stats.media as item (item.key)} +
  • {item.key}{item.incoming}
  • + {/each} +
+
+ {/if} + +
+

Кто пишет

+ {#if stats.senders.length === 0} +

Пока никто.

+ {:else} +
    + {#each stats.senders as item (item.sender_id)} +
  • + +
  • + {/each} +
+ {/if} +
+ +
+
+

Лента

+ {#if sender !== null} + + {/if} +
+ {#if updates.length === 0} +

Ничего не пришло.

+ {:else} + + {/if} +
+
+{/if} + + diff --git a/frontend/src/lib/components/bots/BotTimeline.svelte b/frontend/src/lib/components/bots/BotTimeline.svelte new file mode 100644 index 0000000..9a6fa2a --- /dev/null +++ b/frontend/src/lib/components/bots/BotTimeline.svelte @@ -0,0 +1,43 @@ + + +
+ {#each timeline as bucket (bucket.bucket)} +
+ {/each} +
+ + diff --git a/frontend/src/lib/components/bots/BotUpdateList.svelte b/frontend/src/lib/components/bots/BotUpdateList.svelte new file mode 100644 index 0000000..d0fc881 --- /dev/null +++ b/frontend/src/lib/components/bots/BotUpdateList.svelte @@ -0,0 +1,106 @@ + + +
    + {#each updates as update (`${update.kind}:${update.chat_id}:${update.item_id}:${update.ts}`)} +
  • +
    + {senderName(update)} + {#if update.sender_username && update.sender_name} + @{update.sender_username} + {/if} + {kindLabels[update.kind] ?? update.kind} + {#if update.source === "history"} + история + {/if} + +
    + {#if body(update)} +

    {body(update)}

    + {/if} + {#if update.chat_title && update.chat_type !== "private"} +
    {update.chat_title}
    + {/if} +
  • + {/each} +
+ + diff --git a/frontend/src/lib/components/bots/BotsPanel.svelte b/frontend/src/lib/components/bots/BotsPanel.svelte new file mode 100644 index 0000000..5ff2fe1 --- /dev/null +++ b/frontend/src/lib/components/bots/BotsPanel.svelte @@ -0,0 +1,268 @@ + + +
+ {#if current} +
+ +
+
{botName(current)}
+
+ {statusLabels[current.status] ?? current.status} + {#if current.backfilled_at === null && current.status === "ok"} + · история загружается + {/if} +
+
+
+ {#if current.status_detail} +

{current.status_detail}

+ {/if} + + {:else} + {#if loading} + + {:else if bots.length === 0} +

Ботов пока нет.

+ {:else} +
    + {#each bots as bot (bot.bot_id)} +
  • + + {#if bot.enabled} + + {/if} +
  • + {/each} +
+ {/if} + +
+ + +
+ +

+ Бот логинится по MTProto, отдельной авторизацией. Это не забирает + getUpdates у того, кто держит того же бота через Bot API, и не требует + вебхука. Сколько прошлой переписки удастся вытащить, решает сервер + Telegram: watcher просит историю диалогов бота и берёт, что дают. +

+

+ В личке бот видит каждого, кто ему написал. В группах ему доставляют всё + только при выключенном privacy mode, иначе — команды и ответы на его + сообщения. Отключение бота убирает токен и сессию, но архив остаётся. +

+ {/if} +
+ + diff --git a/frontend/src/lib/components/settings/Settings.svelte b/frontend/src/lib/components/settings/Settings.svelte index 0d5b95d..c527824 100644 --- a/frontend/src/lib/components/settings/Settings.svelte +++ b/frontend/src/lib/components/settings/Settings.svelte @@ -57,6 +57,11 @@ label="Файлы по ссылке" onclick={() => ui.openPanel("shares")} /> + ui.openPanel("bots")} + />