feat(watcher,api,frontend): archive who writes to watched bots over mtproto

This commit is contained in:
hh committed 2026-09-18 15:05:01 +02:00
1 parent 089a31db8d
commit 4bd031f21e
30 files changed
+1863 -10

No files matched your search

+3 -2
View File
@@ -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
+4
View File
@@ -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
+1 -1
View File
@@ -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; }
+24
View File
@@ -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]
@@ -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")
+2 -2
View File
@@ -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]
+2
View File
@@ -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)
+46 -2
View File
@@ -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,
)
)
+79
View File
@@ -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,
)
+4 -2
View File
@@ -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",
+7
View File
@@ -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)
+261
View File
@@ -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}
+79
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
API_ID = 2040
API_HASH = "b18441a1ff607e10a989891a5462e627"
View File
Whitespace-only changes.
+4
View File
@@ -0,0 +1,4 @@
from .runner import main
if __name__ == "__main__":
main()
+71
View File
@@ -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"
)
+146
View File
@@ -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)
+156
View File
@@ -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}")
+72
View File
@@ -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.[/]")
+42
View File
@@ -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()
}
+32
View File
@@ -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<void> {
return request<void>(`/annotations/${id}`, { method: "DELETE" });
}
export function listWatchedBots(): Promise<WatchedBot[]> {
return request<WatchedBot[]>("/bots");
}
export function addWatchedBot(token: string): Promise<WatchedBot> {
return request<WatchedBot>("/bots", { method: "POST", body: { token } });
}
export function removeWatchedBot(botId: number): Promise<void> {
return request<void>(`/bots/${botId}`, { method: "DELETE" });
}
export function botStats(botId: number, days = 90): Promise<BotStats> {
return request<BotStats>(`/bots/${botId}/stats`, { query: { days } });
}
export function botUpdates(
botId: number,
options: { limit?: number; offset?: number; senderId?: number } = {}
): Promise<BotUpdate[]> {
return request<BotUpdate[]>(`/bots/${botId}/updates`, {
query: {
limit: options.limit,
offset: options.offset,
sender_id: options.senderId,
},
});
}
+79
View File
@@ -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;
}
@@ -1,6 +1,7 @@
<script lang="ts">
import AlertsPanel from "$lib/components/alerts/AlertsPanel.svelte";
import AnnotationsPanel from "$lib/components/annotations/AnnotationsPanel.svelte";
import BotsPanel from "$lib/components/bots/BotsPanel.svelte";
import JobsPanel from "$lib/components/jobs/JobsPanel.svelte";
import PolicyEditor from "$lib/components/policy/PolicyEditor.svelte";
import AnalyticsPanel from "$lib/components/presence/AnalyticsPanel.svelte";
@@ -33,6 +34,7 @@
watches: "Отслеживания",
alerts: "Алерты",
shares: "Файлы по ссылке",
bots: "Боты под наблюдением",
};
</script>
@@ -78,6 +80,8 @@
<AnnotationsPanel />
{:else if ui.rightPanel === "shares"}
<SharesPanel />
{:else if ui.rightPanel === "bots"}
<BotsPanel />
{/if}
</div>
@@ -0,0 +1,234 @@
<script lang="ts">
import { botStats, botUpdates } from "$lib/api/endpoints";
import type { BotSender, BotStats, BotUpdate } from "$lib/api/types";
import BotTimeline from "$lib/components/bots/BotTimeline.svelte";
import BotUpdateList from "$lib/components/bots/BotUpdateList.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatListDate } from "$lib/format/datetime";
interface Props {
botId: number;
}
const { botId }: Props = $props();
let stats = $state<BotStats | null>(null);
let updates = $state<BotUpdate[]>([]);
let sender = $state<number | null>(null);
let loading = $state(true);
$effect(() => {
loading = true;
botStats(botId)
.then((result) => {
stats = result;
})
.finally(() => {
loading = false;
});
});
$effect(() => {
botUpdates(botId, { limit: 50, senderId: sender ?? undefined }).then(
(result) => {
updates = result;
}
);
});
function senderLabel(item: BotSender): string {
return (
item.name ??
(item.username ? `@${item.username}` : String(item.sender_id))
);
}
function toggleSender(id: number): void {
sender = sender === id ? null : id;
}
</script>
{#if loading && stats === null}
<Spinner />
{:else if stats}
<div class="stats">
<div class="tiles">
<div class="tile">
<span class="value">{stats.summary.incoming}</span>
<span class="label">входящих</span>
</div>
<div class="tile">
<span class="value">{stats.summary.senders}</span>
<span class="label">отправителей</span>
</div>
<div class="tile">
<span class="value">{stats.summary.chats}</span>
<span class="label">чатов</span>
</div>
<div class="tile">
<span class="value">{stats.summary.outgoing}</span>
<span class="label">ответов бота</span>
</div>
</div>
{#if stats.timeline.length > 0}
<section>
<h3>По дням</h3>
<BotTimeline timeline={stats.timeline} />
</section>
{/if}
{#if stats.media.length > 0}
<section>
<h3>Вложения</h3>
<ul class="counts">
{#each stats.media as item (item.key)}
<li><span>{item.key}</span><b>{item.incoming}</b></li>
{/each}
</ul>
</section>
{/if}
<section>
<h3>Кто пишет</h3>
{#if stats.senders.length === 0}
<p class="empty">Пока никто.</p>
{:else}
<ul class="senders">
{#each stats.senders as item (item.sender_id)}
<li>
<button
type="button"
class="sender"
class:active={sender === item.sender_id}
onclick={() => toggleSender(item.sender_id)}
>
<span class="name">{senderLabel(item)}</span>
<span class="meta">
{item.incoming}
· {formatListDate(item.last_ts)}
</span>
</button>
</li>
{/each}
</ul>
{/if}
</section>
<section>
<div class="section-head">
<h3>Лента</h3>
{#if sender !== null}
<Button variant="translucent" smaller onclick={() => (sender = null)}>
Все
</Button>
{/if}
</div>
{#if updates.length === 0}
<p class="empty">Ничего не пришло.</p>
{:else}
<BotUpdateList {updates} />
{/if}
</section>
</div>
{/if}
<style lang="scss">
.stats {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.tiles {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
.tile {
display: flex;
flex-direction: column;
gap: 0.125rem;
align-items: center;
padding: 0.5rem 0.25rem;
border-radius: var(--border-radius-default);
background-color: var(--color-background-secondary);
}
.value {
font-size: 1.125rem;
font-weight: var(--font-weight-medium);
}
.label {
font-size: 0.6875rem;
color: var(--color-text-secondary);
text-align: center;
}
h3 {
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.counts,
.senders {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
}
.counts li {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
}
.sender {
display: flex;
align-items: baseline;
justify-content: space-between;
width: 100%;
padding: 0.375rem 0.5rem;
border: none;
border-radius: var(--border-radius-default);
font-size: 0.875rem;
color: inherit;
text-align: left;
background: none;
cursor: pointer;
&:hover,
&.active {
background-color: var(--color-background-secondary);
}
}
.meta {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.empty {
margin: 0;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,43 @@
<script lang="ts">
import type { BotBucket } from "$lib/api/types";
interface Props {
timeline: BotBucket[];
}
const { timeline }: Props = $props();
const max = $derived(
Math.max(1, ...timeline.map((bucket) => bucket.incoming))
);
function barTitle(bucket: BotBucket): string {
return `${bucket.bucket.slice(0, 10)} — ${bucket.incoming} входящих от ${bucket.senders}`;
}
</script>
<div class="bars">
{#each timeline as bucket (bucket.bucket)}
<div
class="bar"
style:height={`${(bucket.incoming / max) * 100}%`}
title={barTitle(bucket)}
></div>
{/each}
</div>
<style lang="scss">
.bars {
display: flex;
align-items: flex-end;
gap: 1px;
height: 7.5rem;
}
.bar {
flex: 1;
min-height: 2px;
border-radius: 2px 2px 0 0;
background-color: var(--color-primary);
}
</style>
@@ -0,0 +1,106 @@
<script lang="ts">
import type { BotUpdate } from "$lib/api/types";
import { formatFull } from "$lib/format/datetime";
import { mediaKindLabel } from "$lib/format/media";
interface Props {
updates: BotUpdate[];
}
const { updates }: Props = $props();
const kindLabels: Record<string, string> = {
message: "сообщение",
callback_query: "кнопка",
member: "статус",
};
function senderName(update: BotUpdate): string {
if (update.sender_name) {
return update.sender_name;
}
if (update.sender_username) {
return `@${update.sender_username}`;
}
return update.sender_id === null ? "неизвестно" : String(update.sender_id);
}
function body(update: BotUpdate): string {
const media = mediaKindLabel(update.media_kind);
const text = update.text ?? "";
if (media && text) {
return `[${media}] ${text}`;
}
return media ? `[${media}]` : text;
}
</script>
<ul class="updates">
{#each updates as update (`${update.kind}:${update.chat_id}:${update.item_id}:${update.ts}`)}
<li class="update">
<div class="head">
<span class="sender">{senderName(update)}</span>
{#if update.sender_username && update.sender_name}
<span class="username">@{update.sender_username}</span>
{/if}
<span class="kind">{kindLabels[update.kind] ?? update.kind}</span>
{#if update.source === "history"}
<span class="kind">история</span>
{/if}
<time>{formatFull(update.ts)}</time>
</div>
{#if body(update)}
<p class="body">{body(update)}</p>
{/if}
{#if update.chat_title && update.chat_type !== "private"}
<div class="chat">{update.chat_title}</div>
{/if}
</li>
{/each}
</ul>
<style lang="scss">
.updates {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
.update {
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--color-borders);
}
.head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem;
font-size: 0.8125rem;
}
.sender {
font-weight: var(--font-weight-medium);
}
.username,
.kind,
time {
color: var(--color-text-secondary);
}
.body {
overflow-wrap: anywhere;
margin: 0.25rem 0 0;
}
.chat {
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,268 @@
<script lang="ts">
import {
addWatchedBot,
listWatchedBots,
removeWatchedBot,
} from "$lib/api/endpoints";
import type { WatchedBot } from "$lib/api/types";
import BotStatsView from "$lib/components/bots/BotStatsView.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatListDate } from "$lib/format/datetime";
import { toasts } from "$lib/stores/toasts.svelte";
let bots = $state<WatchedBot[]>([]);
let loading = $state(true);
let token = $state("");
let adding = $state(false);
let selected = $state<number | null>(null);
const statusLabels: Record<string, string> = {
pending: "подключается",
ok: "слушает",
unauthorized: "токен отклонён",
error: "ошибка",
removed: "отключён",
};
async function load() {
loading = true;
try {
bots = await listWatchedBots();
} finally {
loading = false;
}
}
$effect(() => {
load();
});
async function add() {
if (!token.trim()) {
return;
}
adding = true;
try {
await addWatchedBot(token.trim());
token = "";
await load();
} catch {
toasts.error("Не похоже на токен бота");
} finally {
adding = false;
}
}
async function remove(bot: WatchedBot) {
await removeWatchedBot(bot.bot_id);
if (selected === bot.bot_id) {
selected = null;
}
await load();
}
function botName(bot: WatchedBot): string {
return bot.username
? `@${bot.username}`
: (bot.title ?? String(bot.bot_id));
}
const current = $derived(bots.find((bot) => bot.bot_id === selected) ?? null);
</script>
<div class="panel-content">
{#if current}
<header class="bot-header">
<Button
variant="translucent"
round
smaller
onclick={() => (selected = null)}
aria-label="Назад"
>
<Icon name="arrow-left" />
</Button>
<div>
<div class="name">{botName(current)}</div>
<div class="meta">
{statusLabels[current.status] ?? current.status}
{#if current.backfilled_at === null && current.status === "ok"}
· история загружается
{/if}
</div>
</div>
</header>
{#if current.status_detail}
<p class="detail">{current.status_detail}</p>
{/if}
<BotStatsView botId={current.bot_id} />
{:else}
{#if loading}
<Spinner />
{:else if bots.length === 0}
<p class="empty">Ботов пока нет.</p>
{:else}
<ul class="bots">
{#each bots as bot (bot.bot_id)}
<li class="bot">
<button
type="button"
class="open"
onclick={() => (selected = bot.bot_id)}
>
<span class="name">{botName(bot)}</span>
<span class="meta">
{statusLabels[bot.status] ?? bot.status}
· {bot.incoming} от {bot.senders}
{#if bot.last_ts}
· {formatListDate(bot.last_ts)}
{/if}
</span>
</button>
{#if bot.enabled}
<Button
variant="translucent"
round
smaller
onclick={() => remove(bot)}
aria-label="Отключить"
>
<Icon name="delete" />
</Button>
{/if}
</li>
{/each}
</ul>
{/if}
<div class="add">
<label class="field">
<span>Токен бота от @BotFather</span>
<input type="password" bind:value={token} placeholder="123456:AA…">
</label>
<Button fluid loading={adding} onclick={add}>Добавить</Button>
</div>
<p class="note">
Бот логинится по MTProto, отдельной авторизацией. Это не забирает
getUpdates у того, кто держит того же бота через Bot API, и не требует
вебхука. Сколько прошлой переписки удастся вытащить, решает сервер
Telegram: watcher просит историю диалогов бота и берёт, что дают.
</p>
<p class="note">
В личке бот видит каждого, кто ему написал. В группах ему доставляют всё
только при выключенном privacy mode, иначе — команды и ответы на его
сообщения. Отключение бота убирает токен и сессию, но архив остаётся.
</p>
{/if}
</div>
<style lang="scss">
.panel-content {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.bot-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.bots {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
}
.bot {
display: flex;
align-items: center;
gap: 0.25rem;
}
.open {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.125rem;
padding: 0.5rem;
border: none;
border-radius: var(--border-radius-default);
color: inherit;
text-align: left;
background: none;
cursor: pointer;
&:hover {
background-color: var(--color-background-secondary);
}
}
.name {
font-weight: var(--font-weight-medium);
}
.meta {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.detail {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.add {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--color-borders);
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--color-text-secondary);
input {
padding: 0.5rem;
border: 1px solid var(--color-borders);
border-radius: var(--border-radius-default);
font-size: 0.875rem;
color: var(--color-text);
background-color: var(--color-background);
}
}
.note {
margin: 0;
font-size: 0.75rem;
line-height: 1.4;
color: var(--color-text-secondary);
}
.empty {
margin: 0;
color: var(--color-text-secondary);
text-align: center;
}
</style>
@@ -57,6 +57,11 @@
label="Файлы по ссылке"
onclick={() => ui.openPanel("shares")}
/>
<SettingsItem
icon="bots"
label="Боты под наблюдением"
onclick={() => ui.openPanel("bots")}
/>
<SettingsItem
icon="eye"
label="Отслеживания"
+2 -1
View File
@@ -13,7 +13,8 @@ export type RightPanel =
| "policy"
| "watches"
| "alerts"
| "shares";
| "shares"
| "bots";
export type LeftView = "main" | "settings";