feat(backend): alerts come enriched and paged with an unseen count and a live alert frame

This commit is contained in:
hh committed 2026-10-19 02:57:27 +00:00
1 parent 4469938f47
commit 2bb8ca45fb
7 files changed
+682 -65

No files matched your search

+46 -12
View File
@@ -37,6 +37,7 @@ from utils.read.read_receipts import read_info
from utils.search.models import SearchFilters
from utils.search.repository import search_messages
from utils.storage import ContentAddressedStorage
from utils.watch_rules import WatchKind, WatchParamsError, clean_name, normalize_params
INSTRUCTIONS = """\
beavergram archives Telegram data (chats, messages, media, presence, stories,
@@ -76,8 +77,10 @@ Typical flows:
returns a public link valid 24h that needs no auth (`curl -O <link>`), plus
the content itself for small images and text files. `get_media` returns
metadata only.
- Monitoring: `set_watch` creates a local rule, `list_watches` lists rules, and
`list_alerts` reads what those rules fired.
- Monitoring: `set_watch` creates a local rule (kinds `keyword`,
`new_message`, `message_deleted`, `message_edited`, `peer_online`; params in
its description), `list_watches` lists rules, and `list_alerts` reads what
those rules fired, with chat, message preview and a web client `path`.
Watched bots:
- a bot whose token the user added is just another archived account: it has its
@@ -420,13 +423,37 @@ async def get_annotations(
@mcp.tool
async def set_watch(
account_id: int,
kind: str,
kind: WatchKind,
params: dict[str, Any] | None = None,
name: str | None = None,
enabled: bool = True,
) -> dict[str, Any]:
"""Create a local watch rule (the only local write MCP is allowed)."""
"""Create a local watch rule (the only local write MCP is allowed).
A watch fires an alert into the web client (live counter and list) and to
the user's webhooks. The user's own messages never fire. Kinds and params:
- `keyword`: `{"pattern": str, "regex": bool = false, "chat_id"?: int,
"sender_id"?: int}`. Case-insensitive substring, or a Python regex when
`regex` is true (at most 200 characters, no nested repeats like
`(a+)+`). Checked on new messages, on edits (the new text) and on voice
transcripts. Without `chat_id` it watches every chat.
- `new_message`: `{"chat_id"?: int, "sender_id"?: int}`, at least one: any
new message in that chat, or from that person anywhere, or both.
- `message_deleted`: `{"chat_id"?: int}`. A stored message deleted in that
chat; without `chat_id`, in any private chat.
- `message_edited`: `{"chat_id"?: int}`. Same scopes as `message_deleted`.
- `peer_online`: `{"peer_id": int}`. The person comes online after at
least 15 minutes away.
`name` is an optional human label shown in the list and in alerts.
"""
try:
clean = normalize_params(kind, params or {})
except WatchParamsError as exc:
raise ToolError(str(exc)) from exc
watch = await watches.create_watch(
await _pool(), account_id, kind, params or {}, enabled=enabled
await _pool(), account_id, kind, clean, name=clean_name(name), enabled=enabled
)
return watch.model_dump(mode="json")
@@ -441,12 +468,19 @@ async def list_watches(account_id: int) -> list[dict[str, Any]]:
async def list_alerts(
account_id: int,
seen: bool | None = None,
before: int | None = None,
limit: int = DEFAULT_LIMIT,
offset: int = 0,
) -> list[dict[str, Any]]:
"""List fired alerts from watch rules."""
return _dump(
await watches.list_alerts(
await _pool(), account_id, Page(limit=limit, offset=offset), seen=seen
)
) -> dict[str, Any]:
"""List fired alerts from watch rules, newest first.
Each alert names its watch (id, kind, name), the event (`message`, `edit`,
`transcript`, `delete`, `online`), the chat, the message preview with its
sender and deleted/edited flags, the peer for `peer_online`, and `path`,
the web client link. `seen=false` keeps unseen ones. Page older with
`before` set to `next_before` from the previous call; `unseen` is the
account's unseen count.
"""
page = await watches.list_alerts(
await _pool(), account_id, seen=seen, before=before, limit=limit
)
return page.model_dump(mode="json")
+46 -2
View File
@@ -2,7 +2,7 @@ import asyncio
import json
import logging
from datetime import UTC, datetime
from typing import Any
from typing import TYPE_CHECKING, Any
import asyncpg
@@ -10,6 +10,10 @@ from utils.events import BG_EVENTS_CHANNEL
from utils.listen import Listener
from utils.read import chats as chats_read
from utils.read import presence as presence_read
from utils.read import watches as watches_read
if TYPE_CHECKING:
from collections.abc import Callable
logger = logging.getLogger(__name__)
@@ -27,8 +31,15 @@ class EventHub:
def __init__(self) -> None:
self._subscribers: set[Subscriber] = set()
self._pool: asyncpg.Pool | None = None
self._listener = Listener("realtime", {BG_EVENTS_CHANNEL: self._on_notify})
self._listener = Listener(
"realtime",
{
BG_EVENTS_CHANNEL: self._on_notify,
watches_read.ALERTS_CHANGED_CHANNEL: self._on_alert,
},
)
self._tasks: set[asyncio.Task] = set()
self.alert_listeners: list[Callable[[], None]] = []
def subscribe(self, account_id: int, chat_id: int | None) -> Subscriber:
sub = Subscriber(account_id, chat_id)
@@ -52,6 +63,39 @@ class EventHub:
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
def _on_alert(self, payload: str) -> None:
for listener in self.alert_listeners:
listener()
task = asyncio.create_task(self._dispatch_alert(payload))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _dispatch_alert(self, payload: str) -> None:
try:
event = json.loads(payload)
except json.JSONDecodeError:
return
account_id = event.get("account_id")
targets = [
sub
for sub in self._subscribers
if sub.account_id == account_id and sub.chat_id is None
]
if not targets or self._pool is None:
return
alert_id = event.get("alert_id")
item = await watches_read.get_alert(self._pool, alert_id) if alert_id else None
frame = {
"type": "alert",
"alert": item.model_dump(mode="json") if item else None,
"unseen": await watches_read.unseen_count(self._pool, account_id),
}
for sub in targets:
try:
sub.queue.put_nowait(frame)
except asyncio.QueueFull:
logger.warning("Dropping alert for slow subscriber")
async def _dispatch(self, payload: str) -> None:
try:
event = json.loads(payload)
+69 -21
View File
@@ -6,55 +6,88 @@ from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from utils.read import watches
from utils.read.models import DEFAULT_LIMIT, AlertView, Page, WatchView
from utils.read.models import AlertPage, WatchItem, WatchView
from utils.watch_rules import WatchParamsError, clean_name, normalize_params
router = APIRouter(prefix="/api", tags=["watches"], route_class=DishkaRoute)
class WatchCreate(BaseModel):
class WatchUpdate(BaseModel):
kind: str | None = None
params: dict[str, Any] = {}
name: str | None = None
enabled: bool = True
class WatchCreate(WatchUpdate):
account_id: int
kind: str
params: dict[str, Any] = {}
enabled: bool = True
class WatchUpdate(BaseModel):
params: dict[str, Any] = {}
enabled: bool = True
class SeenAll(BaseModel):
account_id: int
up_to: int | None = None
def _params(kind: str, params: dict[str, Any]) -> dict[str, Any]:
try:
return normalize_params(kind, params)
except WatchParamsError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
async def _item(pool: asyncpg.Pool, watch: WatchView) -> WatchItem:
return (await watches.describe_watches(pool, watch.account_id, [watch]))[0]
@router.get("/watches")
async def list_watches(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> list[WatchView]:
return await watches.list_watches(pool, account_id)
) -> list[WatchItem]:
rows = await watches.list_watches(pool, account_id)
return await watches.describe_watches(pool, account_id, rows)
@router.post("/watches", status_code=201)
async def create_watch(pool: FromDishka[asyncpg.Pool], body: WatchCreate) -> WatchView:
return await watches.create_watch(
pool, body.account_id, body.kind, body.params, enabled=body.enabled
async def create_watch(pool: FromDishka[asyncpg.Pool], body: WatchCreate) -> WatchItem:
watch = await watches.create_watch(
pool,
body.account_id,
body.kind,
_params(body.kind, body.params),
name=clean_name(body.name),
enabled=body.enabled,
)
return await _item(pool, watch)
@router.get("/watches/{watch_id}")
async def get_watch(pool: FromDishka[asyncpg.Pool], watch_id: int) -> WatchView:
async def get_watch(pool: FromDishka[asyncpg.Pool], watch_id: int) -> WatchItem:
watch = await watches.get_watch(pool, watch_id)
if watch is None:
raise HTTPException(status_code=404, detail="watch not found")
return watch
return await _item(pool, watch)
@router.put("/watches/{watch_id}")
async def update_watch(
pool: FromDishka[asyncpg.Pool], watch_id: int, body: WatchUpdate
) -> WatchView:
) -> WatchItem:
current = await watches.get_watch(pool, watch_id)
if current is None:
raise HTTPException(status_code=404, detail="watch not found")
kind = body.kind or current.kind
watch = await watches.update_watch(
pool, watch_id, body.params, enabled=body.enabled
pool,
watch_id,
kind,
_params(kind, body.params),
name=clean_name(body.name),
enabled=body.enabled,
)
if watch is None:
raise HTTPException(status_code=404, detail="watch not found")
return watch
return await _item(pool, watch)
@router.delete("/watches/{watch_id}", status_code=204)
@@ -68,14 +101,29 @@ async def list_alerts(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
seen: Annotated[bool | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[AlertView]:
before: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query()] = watches.ALERT_PAGE,
) -> AlertPage:
return await watches.list_alerts(
pool, account_id, Page(limit=limit, offset=offset), seen=seen
pool, account_id, seen=seen, before=before, limit=limit
)
@router.get("/alerts/unseen")
async def unseen_alerts(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> dict[str, int]:
return {"unseen": await watches.unseen_count(pool, account_id)}
@router.post("/alerts/seen")
async def mark_all_seen(
pool: FromDishka[asyncpg.Pool], body: SeenAll
) -> dict[str, int]:
marked = await watches.mark_all_seen(pool, body.account_id, body.up_to)
return {"marked": marked}
@router.post("/alerts/{alert_id}/seen", status_code=204)
async def mark_alert_seen(pool: FromDishka[asyncpg.Pool], alert_id: int) -> None:
if not await watches.mark_alert_seen(pool, alert_id):