feat(watcher,api,frontend): archive who writes to watched bots over mtproto
This commit is contained in:
1 parent
089a31db8d
commit
4bd031f21e
30 files changed
+1863
-10
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
Reference in new issue
Block a user