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

+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,
)