feat(watcher,api,frontend): watched bots become accounts and drain the update backlog

This commit is contained in:
hh committed 2026-09-18 18:41:20 +02:00
1 parent 4bd031f21e
commit b0ed041c2e
21 files changed
+414 -1155

No files matched your search

+6 -45
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, bots, chats, media, peers, presence, social, watches
from utils.read import annotations, chats, media, peers, presence, social, watches
from utils.read.accounts import self_user_id, unknown_account_hint
from utils.read.models import DEFAULT_LIMIT, MAX_LIMIT, Page
from utils.read.models import DEFAULT_LIMIT, Page
from utils.search.models import SearchFilters
from utils.search.repository import search_messages
from utils.storage import ContentAddressedStorage
@@ -58,11 +58,10 @@ 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`.
Watched bots:
- a bot whose token the user added is just another archived account: it has its
own `account_id` and its chats, messages and media read through the same
tools as a user account. Nothing bot-specific to call.
Writes: everything is read-only except `set_watch`, the only allowed write.
@@ -362,41 +361,3 @@ 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,
)
)
+2 -39
View File
@@ -1,12 +1,10 @@
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from utils.read import bots
from utils.read.models import DEFAULT_LIMIT, BotStats, BotUpdateView, BotView, Page
from utils.read.models import BotView
from watcher import tokens
router = APIRouter(prefix="/api/bots", tags=["bots"], route_class=DishkaRoute)
@@ -42,38 +40,3 @@ 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,
)