feat(backend): watches report alert counts, alerts carry watch params and time away, webhooks their last delivery

This commit is contained in:
hh committed 2026-10-19 02:57:27 +00:00
1 parent 9da0c06ac1
commit 6a4fd6ad47
4 files changed
+76 -37

No files matched your search

+4 -1
View File
@@ -61,13 +61,16 @@ async def list_webhooks(
async def create_webhook( async def create_webhook(
pool: FromDishka[asyncpg.Pool], body: WebhookCreate pool: FromDishka[asyncpg.Pool], body: WebhookCreate
) -> WebhookView: ) -> WebhookView:
return await webhooks.create_webhook( webhook = await webhooks.create_webhook(
pool, pool,
body.account_id, body.account_id,
_url(body.url), _url(body.url),
_watch_ids(body.watch_ids), _watch_ids(body.watch_ids),
enabled=body.enabled, enabled=body.enabled,
) )
if webhook is None:
raise HTTPException(status_code=500, detail="webhook was not saved")
return webhook
@router.put("/{webhook_id}") @router.put("/{webhook_id}")
+16 -11
View File
@@ -546,6 +546,8 @@ class PersonRef(BaseModel):
class WatchItem(WatchView): class WatchItem(WatchView):
chat: ChatRef | None chat: ChatRef | None
person: PersonRef | None person: PersonRef | None
alert_count: int
last_alert_at: datetime | None
class AlertView(BaseModel): class AlertView(BaseModel):
@@ -562,6 +564,7 @@ class AlertWatch(BaseModel):
id: int id: int
kind: str kind: str
name: str | None name: str | None
params: dict[str, Any]
class AlertMessage(BaseModel): class AlertMessage(BaseModel):
@@ -583,6 +586,7 @@ class AlertItem(BaseModel):
chat: ChatRef | None chat: ChatRef | None
message: AlertMessage | None message: AlertMessage | None
peer: PersonRef | None peer: PersonRef | None
away_since: datetime | None
path: str path: str
@@ -592,17 +596,6 @@ class AlertPage(BaseModel):
unseen: int unseen: int
class WebhookView(BaseModel):
id: int
account_id: int
url: str
secret: str
enabled: bool
watch_ids: list[int] | None
created_at: datetime
updated_at: datetime
class DeliveryView(BaseModel): class DeliveryView(BaseModel):
id: int id: int
webhook_id: int webhook_id: int
@@ -617,6 +610,18 @@ class DeliveryView(BaseModel):
finished_at: datetime | None finished_at: datetime | None
class WebhookView(BaseModel):
id: int
account_id: int
url: str
secret: str
enabled: bool
watch_ids: list[int] | None
created_at: datetime
updated_at: datetime
last_delivery: DeliveryView | None
class FileShareView(BaseModel): class FileShareView(BaseModel):
id: int id: int
account_id: int account_id: int
+20 -2
View File
@@ -44,9 +44,15 @@ WITH alert AS (
SELECT * FROM alert SELECT * FROM alert
""" # noqa: S608 """ # noqa: S608
_WATCH_STATS = """
SELECT watch_id, count(*) AS count, max(ts) AS last FROM alerts
WHERE watch_id = ANY($1::int[]) GROUP BY watch_id
"""
_ALERT_ROWS = """ _ALERT_ROWS = """
SELECT a.id, a.account_id, a.ts, a.seen, a.payload, SELECT a.id, a.account_id, a.ts, a.seen, a.payload,
w.id AS watch_id, w.kind AS watch_kind, w.name AS watch_name, w.id AS watch_id, w.kind AS watch_kind, w.name AS watch_name,
w.params AS watch_params,
m.text AS stored_text, m.text AS stored_text,
m.sender_id AS stored_sender, m.date AS stored_date, m.sender_id AS stored_sender, m.date AS stored_date,
m.deleted_at IS NOT NULL AS stored_deleted, m.deleted_at IS NOT NULL AS stored_deleted,
@@ -225,9 +231,17 @@ async def describe_watches(
{chat for chat, _ in targets if chat is not None}, {chat for chat, _ in targets if chat is not None},
{person for _, person in targets if person is not None}, {person for _, person in targets if person is not None},
) )
stats = {
row["watch_id"]: row
for row in await pool.fetch(_WATCH_STATS, [watch.id for watch in watches])
}
return [ return [
WatchItem( WatchItem(
**watch.model_dump(), chat=refs.chat(chat), person=refs.person(person) **watch.model_dump(),
chat=refs.chat(chat),
person=refs.person(person),
alert_count=stats[watch.id]["count"] if watch.id in stats else 0,
last_alert_at=stats[watch.id]["last"] if watch.id in stats else None,
) )
for watch, (chat, person) in zip(watches, targets, strict=True) for watch, (chat, person) in zip(watches, targets, strict=True)
] ]
@@ -267,11 +281,15 @@ def _alert_item(row: asyncpg.Record, refs: Refs) -> AlertItem:
seen=row["seen"], seen=row["seen"],
event=event, event=event,
watch=AlertWatch( watch=AlertWatch(
id=row["watch_id"], kind=row["watch_kind"], name=row["watch_name"] id=row["watch_id"],
kind=row["watch_kind"],
name=row["watch_name"],
params=json.loads(row["watch_params"]),
), ),
chat=refs.chat(chat_id), chat=refs.chat(chat_id),
message=message, message=message,
peer=refs.person(peer_id), peer=refs.person(peer_id),
away_since=payload.get("since"),
path=_path(chat_id, message_id, peer_id), path=_path(chat_id, message_id, peer_id),
) )
+36 -23
View File
@@ -1,3 +1,4 @@
import json
import secrets import secrets
from datetime import datetime from datetime import datetime
@@ -11,12 +12,21 @@ STATUS_DELIVERED = "delivered"
STATUS_FAILED = "failed" STATUS_FAILED = "failed"
DELIVERY_LOG = 20 DELIVERY_LOG = 20
_COLS = "id, account_id, url, secret, enabled, watch_ids, created_at, updated_at"
_DELIVERY_COLS = ( _DELIVERY_COLS = (
"id, webhook_id, alert_id, event, status, attempts, next_attempt_at, " "id, webhook_id, alert_id, event, status, attempts, next_attempt_at, "
"response_code, last_error, created_at, finished_at" "response_code, last_error, created_at, finished_at"
) )
_HOOKS = f"""
SELECT w.id, w.account_id, w.url, w.secret, w.enabled, w.watch_ids,
w.created_at, w.updated_at, to_jsonb(d) AS last_delivery
FROM webhooks w
LEFT JOIN LATERAL (
SELECT {_DELIVERY_COLS} FROM webhook_deliveries
WHERE webhook_id = w.id ORDER BY id DESC LIMIT 1
) d ON true
""" # noqa: S608
_CLAIM = """ _CLAIM = """
UPDATE webhook_deliveries d SET status = 'sending', attempts = d.attempts + 1 UPDATE webhook_deliveries d SET status = 'sending', attempts = d.attempts + 1
FROM webhooks w FROM webhooks w
@@ -43,20 +53,24 @@ def new_secret() -> str:
return secrets.token_hex(32) return secrets.token_hex(32)
def _to_webhook(row: asyncpg.Record) -> WebhookView:
data = dict(row)
last = data.pop("last_delivery")
return WebhookView(
**data, last_delivery=DeliveryView(**json.loads(last)) if last else None
)
async def list_webhooks(pool: asyncpg.Pool, account_id: int) -> list[WebhookView]: async def list_webhooks(pool: asyncpg.Pool, account_id: int) -> list[WebhookView]:
rows = await pool.fetch( rows = await pool.fetch(
f"SELECT {_COLS} FROM webhooks WHERE account_id = $1 ORDER BY id", # noqa: S608 f"{_HOOKS} WHERE w.account_id = $1 ORDER BY w.id", account_id
account_id,
) )
return [WebhookView(**dict(row)) for row in rows] return [_to_webhook(row) for row in rows]
async def get_webhook(pool: asyncpg.Pool, webhook_id: int) -> WebhookView | None: async def get_webhook(pool: asyncpg.Pool, webhook_id: int) -> WebhookView | None:
row = await pool.fetchrow( row = await pool.fetchrow(f"{_HOOKS} WHERE w.id = $1", webhook_id)
f"SELECT {_COLS} FROM webhooks WHERE id = $1", # noqa: S608 return _to_webhook(row) if row else None
webhook_id,
)
return WebhookView(**dict(row)) if row else None
async def create_webhook( async def create_webhook(
@@ -66,17 +80,17 @@ async def create_webhook(
watch_ids: list[int] | None, watch_ids: list[int] | None,
*, *,
enabled: bool, enabled: bool,
) -> WebhookView: ) -> WebhookView | None:
row = await pool.fetchrow( webhook_id = await pool.fetchval(
"INSERT INTO webhooks (account_id, url, secret, enabled, watch_ids) " # noqa: S608 "INSERT INTO webhooks (account_id, url, secret, enabled, watch_ids) "
f"VALUES ($1, $2, $3, $4, $5) RETURNING {_COLS}", "VALUES ($1, $2, $3, $4, $5) RETURNING id",
account_id, account_id,
url, url,
new_secret(), new_secret(),
enabled, enabled,
watch_ids, watch_ids,
) )
return WebhookView(**dict(row)) return await get_webhook(pool, webhook_id)
async def update_webhook( async def update_webhook(
@@ -88,32 +102,31 @@ async def update_webhook(
enabled: bool, enabled: bool,
) -> WebhookView | None: ) -> WebhookView | None:
async with pool.acquire() as conn, conn.transaction(): async with pool.acquire() as conn, conn.transaction():
row = await conn.fetchrow( found = await conn.fetchval(
"UPDATE webhooks SET url = $2, enabled = $3, watch_ids = $4, " # noqa: S608 "UPDATE webhooks SET url = $2, enabled = $3, watch_ids = $4, "
f"updated_at = now() WHERE id = $1 RETURNING {_COLS}", "updated_at = now() WHERE id = $1 RETURNING id",
webhook_id, webhook_id,
url, url,
enabled, enabled,
watch_ids, watch_ids,
) )
if row is not None and not enabled: if found is not None and not enabled:
await conn.execute( await conn.execute(
"UPDATE webhook_deliveries SET status = 'failed', " "UPDATE webhook_deliveries SET status = 'failed', "
"last_error = 'endpoint disabled', finished_at = now() " "last_error = 'endpoint disabled', finished_at = now() "
"WHERE webhook_id = $1 AND status = 'pending'", "WHERE webhook_id = $1 AND status = 'pending'",
webhook_id, webhook_id,
) )
return WebhookView(**dict(row)) if row else None return await get_webhook(pool, webhook_id)
async def rotate_secret(pool: asyncpg.Pool, webhook_id: int) -> WebhookView | None: async def rotate_secret(pool: asyncpg.Pool, webhook_id: int) -> WebhookView | None:
row = await pool.fetchrow( await pool.execute(
"UPDATE webhooks SET secret = $2, updated_at = now() " # noqa: S608 "UPDATE webhooks SET secret = $2, updated_at = now() WHERE id = $1",
f"WHERE id = $1 RETURNING {_COLS}",
webhook_id, webhook_id,
new_secret(), new_secret(),
) )
return WebhookView(**dict(row)) if row else None return await get_webhook(pool, webhook_id)
async def delete_webhook(pool: asyncpg.Pool, webhook_id: int) -> bool: async def delete_webhook(pool: asyncpg.Pool, webhook_id: int) -> bool: