feat(backend): alerts come enriched and paged with an unseen count and a live alert frame
This commit is contained in:
1 parent
4469938f47
commit
2bb8ca45fb
7 files changed
+682
-65
No files matched your search
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user