Slice 6: notifications per §15
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -17,7 +17,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import api_branches, api_graduation, api_prs, auth, db, entry as entry_mod, cache
|
||||
from . import api_branches, api_graduation, api_notifications, api_prs, auth, db, entry as entry_mod, cache
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
@@ -55,6 +55,9 @@ def make_router(
|
||||
router.include_router(api_prs.make_router(config, gitea, bot, providers))
|
||||
# Slice 5: §13 graduation + §13.1 claim.
|
||||
router.include_router(api_graduation.make_router(config, gitea, bot))
|
||||
# Slice 6: §15 notifications surface (inbox, watches, prefs,
|
||||
# quiet hours, per-user mute, email unsubscribe, bounce webhook).
|
||||
router.include_router(api_notifications.make_router(config))
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Auth surface — extends the prototype's pattern but reads role
|
||||
|
||||
@@ -901,6 +901,35 @@ def make_router(
|
||||
)
|
||||
return {"ok": True, "message_id": message_id}
|
||||
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch}/chat-seen")
|
||||
async def advance_chat_seen(slug: str, branch: str, body: dict, request: Request) -> dict[str, Any]:
|
||||
"""§15.7 chat-seen cursor advance.
|
||||
|
||||
Body: `{"last_seen_message_id": <int>}`. Upserts branch_chat_seen
|
||||
and runs the §15.7 reconciler — every unread notification scoped
|
||||
to this (slug, branch) on or before the new cursor is marked read.
|
||||
"""
|
||||
viewer = auth.require_user(request)
|
||||
_require_rfc_with_repo(slug)
|
||||
if not _can_read_branch(slug, branch, viewer):
|
||||
raise HTTPException(403, "Branch is private")
|
||||
last_seen = int(body.get("last_seen_message_id") or 0) or None
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO branch_chat_seen (user_id, rfc_slug, branch_name, last_seen_message_id, seen_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
last_seen_message_id = excluded.last_seen_message_id,
|
||||
seen_at = excluded.seen_at
|
||||
""",
|
||||
(viewer.user_id, slug, branch, last_seen),
|
||||
)
|
||||
from . import notify
|
||||
reconciled = notify.reconcile_seen_advance(
|
||||
user_id=viewer.user_id, rfc_slug=slug, branch_name=branch,
|
||||
)
|
||||
return {"ok": True, "reconciled": reconciled}
|
||||
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch}/threads/{thread_id}/resolve")
|
||||
async def resolve_thread(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
|
||||
@@ -907,6 +907,17 @@ def _audit(
|
||||
json.dumps(details) if details else None,
|
||||
),
|
||||
)
|
||||
# §15 chokepoint per Slice 6: the bracket rows (graduate_start,
|
||||
# graduate_complete) drive their own notifications per §15.1.
|
||||
from . import notify
|
||||
notify.fan_out_from_action(
|
||||
actor_user_id=actor_user_id,
|
||||
action_kind=action_kind,
|
||||
rfc_slug=rfc_slug,
|
||||
branch_name=branch_name,
|
||||
pr_number=pr_number,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
"""§17 endpoints for the §15 notifications surface (Slice 6).
|
||||
|
||||
The endpoints in this module are:
|
||||
|
||||
- `GET /api/notifications` — §15.2 inbox listing
|
||||
- `POST /api/notifications/<id>/read` — §15.2/§15.7
|
||||
- `POST /api/notifications/read` — §15.2 mark by filter
|
||||
- `GET /api/notifications/stream` — §15.3 SSE
|
||||
- `GET /api/watches` — §15.6
|
||||
- `POST /api/rfcs/<slug>/watch` — §15.6 explicit set
|
||||
- `GET /api/users/me/notification-preferences` — §15.4 / §15.5
|
||||
- `POST /api/users/me/notification-preferences` — set
|
||||
- `GET /api/users/me/quiet-hours` — §15.8
|
||||
- `POST /api/users/me/quiet-hours` — set / clear
|
||||
- `POST /api/users/<id>/notification-mute` — §15.8
|
||||
- `DELETE /api/users/<id>/notification-mute` — §15.8
|
||||
- `GET /api/email/unsubscribe` — §15.4 one-click
|
||||
- `POST /api/webhooks/email-bounce` — §15.4 receiver
|
||||
|
||||
The `branches/<branch>/chat-seen` advance lives in `api_branches` next
|
||||
to the other branch-scoped endpoints; it calls into `notify.reconcile_seen_advance`
|
||||
per §15.7. The `prs/<n>/seen` endpoint in `api_prs` does the same.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
from itsdangerous import BadSignature
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, db, email as email_mod, notify
|
||||
from .config import Config
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic bodies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WatchBody(BaseModel):
|
||||
state: str = Field(pattern="^(watching|following|muted)$")
|
||||
|
||||
|
||||
class PreferencesBody(BaseModel):
|
||||
email_personal_direct: bool | None = None
|
||||
email_watched_structural: bool | None = None
|
||||
email_admin_actionable: bool | None = None
|
||||
digest_cadence: str | None = Field(default=None, pattern="^(off|weekly|daily)$")
|
||||
|
||||
|
||||
class QuietHoursBody(BaseModel):
|
||||
start: str | None = Field(default=None, pattern=r"^\d{2}:\d{2}$")
|
||||
end: str | None = Field(default=None, pattern=r"^\d{2}:\d{2}$")
|
||||
timezone: str | None = None
|
||||
|
||||
|
||||
class MarkReadBody(BaseModel):
|
||||
ids: list[int] | None = None
|
||||
rfc_slug: str | None = None
|
||||
category: str | None = None
|
||||
actor_user_id: int | None = None
|
||||
|
||||
|
||||
class BounceBody(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
kind: str = Field(default="hard") # 'hard' or 'complaint'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_router(config: Config) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
# ----- Inbox listing + mark-read -----
|
||||
|
||||
@router.get("/api/notifications")
|
||||
async def list_notifications(
|
||||
request: Request,
|
||||
unread: bool = False,
|
||||
rfc_slug: str | None = None,
|
||||
category: str | None = None,
|
||||
actor_user_id: int | None = None,
|
||||
bundled: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
return notify.list_inbox(
|
||||
user_id=viewer.user_id,
|
||||
unread=unread,
|
||||
rfc_slug=rfc_slug,
|
||||
category=category,
|
||||
actor_user_id=actor_user_id,
|
||||
bundled=bundled,
|
||||
)
|
||||
|
||||
@router.post("/api/notifications/{notif_id}/read")
|
||||
async def mark_one_read(notif_id: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
row = db.conn().execute(
|
||||
"SELECT id FROM notifications WHERE id = ? AND recipient_user_id = ?",
|
||||
(notif_id, viewer.user_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Notification not found")
|
||||
db.conn().execute(
|
||||
"UPDATE notifications SET read_at = datetime('now') WHERE id = ? AND read_at IS NULL",
|
||||
(notif_id,),
|
||||
)
|
||||
# Push the read event so other tabs update their badge counts.
|
||||
asyncio.create_task(notify._broadcast(viewer.user_id, "read", {"id": notif_id}))
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/api/notifications/read")
|
||||
async def mark_filtered_read(body: MarkReadBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
marked = notify.mark_read_by_filter(
|
||||
user_id=viewer.user_id,
|
||||
rfc_slug=body.rfc_slug,
|
||||
category=body.category,
|
||||
actor_user_id=body.actor_user_id,
|
||||
ids=body.ids,
|
||||
)
|
||||
return {"marked": marked}
|
||||
|
||||
@router.get("/api/notifications/stream")
|
||||
async def stream_notifications(request: Request):
|
||||
viewer = auth.require_user(request)
|
||||
sub = await notify.subscribe(viewer.user_id)
|
||||
|
||||
async def event_stream():
|
||||
# On open, send the current unread count as a snapshot so
|
||||
# the badge initializes correctly without a second request.
|
||||
count = db.conn().execute(
|
||||
"SELECT COUNT(*) AS c FROM notifications WHERE recipient_user_id = ? AND read_at IS NULL",
|
||||
(viewer.user_id,),
|
||||
).fetchone()["c"]
|
||||
yield _sse("snapshot", {"unread_count": count})
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
evt = await asyncio.wait_for(sub.queue.get(), timeout=15.0)
|
||||
except asyncio.TimeoutError:
|
||||
# Keep-alive comment line; clients ignore comments.
|
||||
yield ": keep-alive\n\n"
|
||||
continue
|
||||
yield _sse(evt.get("event", "update"), evt.get("payload"))
|
||||
finally:
|
||||
await notify.unsubscribe(sub)
|
||||
|
||||
headers = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream", headers=headers)
|
||||
|
||||
# ----- Watches -----
|
||||
|
||||
@router.get("/api/watches")
|
||||
async def list_watches(request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT w.id, w.rfc_slug, w.state, w.set_by, w.set_at, w.last_participation_at,
|
||||
r.title AS rfc_title
|
||||
FROM watches w
|
||||
LEFT JOIN cached_rfcs r ON r.slug = w.rfc_slug
|
||||
WHERE w.user_id = ?
|
||||
ORDER BY w.set_at DESC
|
||||
""",
|
||||
(viewer.user_id,),
|
||||
).fetchall()
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": r["id"],
|
||||
"rfc_slug": r["rfc_slug"],
|
||||
"rfc_title": r["rfc_title"],
|
||||
"state": r["state"],
|
||||
"set_by": r["set_by"],
|
||||
"set_at": r["set_at"],
|
||||
"last_participation_at": r["last_participation_at"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
}
|
||||
|
||||
@router.post("/api/rfcs/{slug}/watch")
|
||||
async def set_watch(slug: str, body: WatchBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
rfc = db.conn().execute("SELECT slug FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
if rfc is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at)
|
||||
VALUES (?, ?, ?, 'explicit', datetime('now'), datetime('now'))
|
||||
ON CONFLICT(user_id, rfc_slug) DO UPDATE SET
|
||||
state = excluded.state,
|
||||
set_by = 'explicit',
|
||||
set_at = excluded.set_at
|
||||
""",
|
||||
(viewer.user_id, slug, body.state),
|
||||
)
|
||||
return {"ok": True, "state": body.state, "set_by": "explicit"}
|
||||
|
||||
# ----- Per-user notification preferences (§15.4 / §15.5) -----
|
||||
|
||||
@router.get("/api/users/me/notification-preferences")
|
||||
async def get_prefs(request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT email_personal_direct, email_watched_structural,
|
||||
email_admin_actionable, digest_cadence, email_opt_out_all
|
||||
FROM users WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id,),
|
||||
).fetchone()
|
||||
return {
|
||||
"email_personal_direct": bool(row["email_personal_direct"]),
|
||||
"email_watched_structural": bool(row["email_watched_structural"]),
|
||||
"email_admin_actionable": bool(row["email_admin_actionable"]),
|
||||
"email_watched_churn": False, # §15.4 permanently off
|
||||
"email_opt_out_all": bool(row["email_opt_out_all"]),
|
||||
"digest_cadence": row["digest_cadence"],
|
||||
}
|
||||
|
||||
@router.post("/api/users/me/notification-preferences")
|
||||
async def set_prefs(body: PreferencesBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
sets = []
|
||||
args: list[Any] = []
|
||||
if body.email_personal_direct is not None:
|
||||
sets.append("email_personal_direct = ?")
|
||||
args.append(1 if body.email_personal_direct else 0)
|
||||
if body.email_watched_structural is not None:
|
||||
sets.append("email_watched_structural = ?")
|
||||
args.append(1 if body.email_watched_structural else 0)
|
||||
if body.email_admin_actionable is not None:
|
||||
sets.append("email_admin_actionable = ?")
|
||||
args.append(1 if body.email_admin_actionable else 0)
|
||||
if body.digest_cadence is not None:
|
||||
sets.append("digest_cadence = ?")
|
||||
args.append(body.digest_cadence)
|
||||
if not sets:
|
||||
return {"ok": True}
|
||||
args.append(viewer.user_id)
|
||||
db.conn().execute(f"UPDATE users SET {', '.join(sets)} WHERE id = ?", args)
|
||||
return {"ok": True}
|
||||
|
||||
# ----- Quiet hours (§15.8) -----
|
||||
|
||||
@router.get("/api/users/me/quiet-hours")
|
||||
async def get_quiet_hours(request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT notification_quiet_hours_start AS start,
|
||||
notification_quiet_hours_end AS end_,
|
||||
notification_quiet_hours_timezone AS tz
|
||||
FROM users WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id,),
|
||||
).fetchone()
|
||||
return {"start": row["start"], "end": row["end_"], "timezone": row["tz"]}
|
||||
|
||||
@router.post("/api/users/me/quiet-hours")
|
||||
async def set_quiet_hours(body: QuietHoursBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
# Per §15.8: all three to set, all null to clear. Reject partials.
|
||||
filled = [body.start, body.end, body.timezone]
|
||||
if any(filled) and not all(filled):
|
||||
raise HTTPException(422, "Set start, end, and timezone together, or clear all three")
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET notification_quiet_hours_start = ?,
|
||||
notification_quiet_hours_end = ?,
|
||||
notification_quiet_hours_timezone = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(body.start, body.end, body.timezone, viewer.user_id),
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
# ----- Per-user notification mute (§15.8) -----
|
||||
|
||||
@router.post("/api/users/{user_id}/notification-mute")
|
||||
async def add_user_mute(user_id: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
if user_id == viewer.user_id:
|
||||
raise HTTPException(422, "You cannot mute yourself")
|
||||
# Refusal per §15.8: admins/owners cannot mute notifications
|
||||
# from anyone (they are exercising app-wide authority); arbiters
|
||||
# cannot mute participants on RFCs where they hold authority.
|
||||
if viewer.role in ("owner", "admin"):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"Admins and owners cannot mute notifications — the role requires receiving signals from everyone",
|
||||
)
|
||||
if _is_arbiter_with_overlap(viewer.user_id, user_id):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"You hold arbiter authority on an RFC where this user is active — muting is refused per §15.8",
|
||||
)
|
||||
target = db.conn().execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
if target is None:
|
||||
raise HTTPException(404, "User not found")
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO notification_user_mutes (muter_user_id, muted_user_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(viewer.user_id, user_id),
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
@router.delete("/api/users/{user_id}/notification-mute")
|
||||
async def remove_user_mute(user_id: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
db.conn().execute(
|
||||
"DELETE FROM notification_user_mutes WHERE muter_user_id = ? AND muted_user_id = ?",
|
||||
(viewer.user_id, user_id),
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
# ----- Email: one-click unsubscribe + bounce webhook -----
|
||||
|
||||
@router.get("/api/email/unsubscribe")
|
||||
async def email_unsubscribe(t: str = Query(..., description="Signed token from the email footer")) -> HTMLResponse:
|
||||
try:
|
||||
user_id, category = email_mod.verify_unsubscribe_token(t)
|
||||
except BadSignature:
|
||||
return HTMLResponse(
|
||||
"<h1>Link expired or invalid</h1>"
|
||||
"<p>Open the app to manage your notification preferences directly.</p>",
|
||||
status_code=400,
|
||||
)
|
||||
column = {
|
||||
"personal-direct": "email_personal_direct",
|
||||
"structural": "email_watched_structural",
|
||||
"admin-actionable": "email_admin_actionable",
|
||||
}.get(category)
|
||||
if column is None:
|
||||
return HTMLResponse(
|
||||
f"<h1>Unknown category</h1><p>{category}</p>", status_code=400
|
||||
)
|
||||
db.conn().execute(f"UPDATE users SET {column} = 0 WHERE id = ?", (user_id,))
|
||||
return HTMLResponse(
|
||||
f"<h1>Unsubscribed</h1><p>You will no longer receive {category} emails. "
|
||||
f"You can re-enable them in your notification preferences.</p>"
|
||||
)
|
||||
|
||||
@router.post("/api/webhooks/email-bounce")
|
||||
async def email_bounce(body: BounceBody) -> dict[str, Any]:
|
||||
# §15.4: hard bounces and complaints flip the global opt-out.
|
||||
# The webhook is unauthenticated here for v1 — the SMTP provider's
|
||||
# callback URL is the contract. Tighten with a signing secret
|
||||
# when an actual provider is wired in.
|
||||
row = db.conn().execute(
|
||||
"SELECT id FROM users WHERE LOWER(email) = LOWER(?)", (body.email,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return {"ok": True, "matched": False}
|
||||
db.conn().execute(
|
||||
"UPDATE users SET email_opt_out_all = 1 WHERE id = ?", (row["id"],),
|
||||
)
|
||||
log.info("email-bounce: opted out user %s (%s)", row["id"], body.kind)
|
||||
return {"ok": True, "matched": True}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sse(event: str, payload: Any) -> str:
|
||||
return f"event: {event}\ndata: {json.dumps(payload)}\n\n"
|
||||
|
||||
|
||||
def _is_arbiter_with_overlap(muter_user_id: int, muted_user_id: int) -> bool:
|
||||
"""§15.8: an arbiter cannot mute notifications from a user who is
|
||||
active on an RFC the arbiter has authority on. Active is defined
|
||||
here as "has a watches row" — a low bar, but it's the cheapest
|
||||
proxy for participation and the spec intends a generous refusal.
|
||||
"""
|
||||
muter_login_row = db.conn().execute(
|
||||
"SELECT gitea_login FROM users WHERE id = ?", (muter_user_id,)
|
||||
).fetchone()
|
||||
muted_login_row = db.conn().execute(
|
||||
"SELECT gitea_login FROM users WHERE id = ?", (muted_user_id,)
|
||||
).fetchone()
|
||||
if not muter_login_row or not muted_login_row:
|
||||
return False
|
||||
muter_login = muter_login_row["gitea_login"]
|
||||
rfcs = db.conn().execute(
|
||||
"SELECT slug, arbiters_json FROM cached_rfcs WHERE state = 'active'"
|
||||
).fetchall()
|
||||
for r in rfcs:
|
||||
try:
|
||||
arbiters = json.loads(r["arbiters_json"] or "[]")
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if muter_login in arbiters:
|
||||
other_active = db.conn().execute(
|
||||
"SELECT 1 FROM watches WHERE user_id = ? AND rfc_slug = ?",
|
||||
(muted_user_id, r["slug"]),
|
||||
).fetchone()
|
||||
if other_active:
|
||||
return True
|
||||
return False
|
||||
@@ -352,7 +352,13 @@ def make_router(
|
||||
""",
|
||||
(viewer.user_id, slug, pr_number, new_sha, new_msg),
|
||||
)
|
||||
return {"ok": True}
|
||||
# §15.7 reconciler: a scope cursor advance marks unread
|
||||
# notifications scoped to this PR read.
|
||||
from . import notify
|
||||
reconciled = notify.reconcile_seen_advance(
|
||||
user_id=viewer.user_id, rfc_slug=slug, pr_number=pr_number,
|
||||
)
|
||||
return {"ok": True, "reconciled": reconciled}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §10.4: post a review-kind thread anchored to a diff range.
|
||||
|
||||
+13
-1
@@ -25,7 +25,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import db
|
||||
from . import db, notify
|
||||
from .gitea import Gitea
|
||||
|
||||
|
||||
@@ -89,6 +89,18 @@ def _log(
|
||||
json.dumps(details) if details else None,
|
||||
),
|
||||
)
|
||||
# §15 chokepoint per §19.1 brief: fan-out runs inline after the
|
||||
# audit row lands. notify.py owns the routing rules and the
|
||||
# auto-watch upsert per §15.6; the call is intentionally a
|
||||
# single line here so the chokepoint is one place to read.
|
||||
notify.fan_out_from_action(
|
||||
actor_user_id=actor.user_id,
|
||||
action_kind=action_kind,
|
||||
rfc_slug=rfc_slug,
|
||||
branch_name=branch_name,
|
||||
pr_number=pr_number,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class Bot:
|
||||
|
||||
+40
-1
@@ -139,7 +139,46 @@ def append_user_message(
|
||||
""",
|
||||
(thread_id, author_user_id, text, quote),
|
||||
)
|
||||
return cur.lastrowid
|
||||
message_id = cur.lastrowid
|
||||
# §15 chokepoint per Slice 6: chat messages don't flow through the
|
||||
# bot wrapper (no Git write), so the fan-out is anchored here. The
|
||||
# routing is: prior thread authors get personal-direct
|
||||
# chat_reply_to_my_message; RFC watchers get churn-class
|
||||
# chat_message_in_participated_thread. The notify module resolves
|
||||
# the thread's RFC/branch context internally.
|
||||
_fan_out_chat(thread_id, author_user_id, message_id)
|
||||
return message_id
|
||||
|
||||
|
||||
def _fan_out_chat(thread_id: int, author_user_id: int, message_id: int) -> None:
|
||||
from . import notify
|
||||
row = db.conn().execute(
|
||||
"SELECT rfc_slug, branch_name, thread_kind FROM threads WHERE id = ?",
|
||||
(thread_id,),
|
||||
).fetchone()
|
||||
if row is None or not row["rfc_slug"]:
|
||||
return
|
||||
pr_number = None
|
||||
if row["thread_kind"] == "review":
|
||||
pr_row = db.conn().execute(
|
||||
"""
|
||||
SELECT pr_number FROM cached_prs
|
||||
WHERE rfc_slug = ? AND head_branch = ? AND state = 'open'
|
||||
ORDER BY pr_number DESC LIMIT 1
|
||||
""",
|
||||
(row["rfc_slug"], row["branch_name"]),
|
||||
).fetchone()
|
||||
if pr_row:
|
||||
pr_number = pr_row["pr_number"]
|
||||
notify.fan_out_chat_message(
|
||||
actor_user_id=author_user_id,
|
||||
rfc_slug=row["rfc_slug"],
|
||||
branch_name=row["branch_name"] or "main",
|
||||
thread_id=thread_id,
|
||||
message_id=message_id,
|
||||
is_review_thread=(row["thread_kind"] == "review"),
|
||||
pr_number=pr_number,
|
||||
)
|
||||
|
||||
|
||||
def append_assistant_placeholder(*, thread_id: int, model_id: str) -> int:
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""§15.5: the digest scheduler.
|
||||
|
||||
A background asyncio task runs on a cadence (default hourly, configurable
|
||||
via DIGEST_TICK_SECONDS for tests and dev). Each tick:
|
||||
|
||||
- releases held-during-quiet-hours emails via `email.flush_pending`;
|
||||
- decays §15.6 watching rows whose last_participation_at is >90 days;
|
||||
- assembles a digest for every user whose `digest_cadence` is `daily`
|
||||
or `weekly` and whose next-cadence window has rolled over.
|
||||
|
||||
The digest's three exclusion rules per §15.5 are applied at assembly
|
||||
time, and `notification_digests` records what was included so the next
|
||||
run skips already-shipped rows.
|
||||
|
||||
Production runs the loop continuously; tests drive it via `run_tick()`
|
||||
for deterministic post-conditions (same shape as Slice 5's `?_sync=1`
|
||||
seam for the graduation orchestrator).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import db, email as email_mod, notify
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scheduler shell
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DigestScheduler:
|
||||
"""Periodic task wrapper. Mirrors `cache.Reconciler`'s shape so the
|
||||
operator's mental model is "this app has two scheduled jobs, both
|
||||
look the same.\""""
|
||||
|
||||
def __init__(self, *, tick_seconds: int | None = None):
|
||||
self._tick = tick_seconds or int(os.environ.get("DIGEST_TICK_SECONDS", "3600"))
|
||||
self._task: asyncio.Task | None = None
|
||||
self._stop = asyncio.Event()
|
||||
|
||||
def start(self) -> None:
|
||||
if self._task is None:
|
||||
self._task = asyncio.create_task(self._loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._task is not None:
|
||||
await self._task
|
||||
|
||||
async def _loop(self) -> None:
|
||||
# One tick at startup so a fresh process serves digests immediately
|
||||
# for any user whose cadence rolled over while the app was down.
|
||||
await self._safe_tick()
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
await asyncio.wait_for(self._stop.wait(), timeout=self._tick)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
if self._stop.is_set():
|
||||
break
|
||||
await self._safe_tick()
|
||||
|
||||
async def _safe_tick(self) -> None:
|
||||
try:
|
||||
run_tick()
|
||||
except Exception:
|
||||
log.exception("digest tick failed")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The tick itself
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_tick() -> dict[str, int]:
|
||||
"""One pass: flush held emails, decay watches, assemble due digests.
|
||||
|
||||
Returns counters for testing/observability. Idempotent on re-entry
|
||||
— assemble_for_user respects the §15.5 exclusion rules so a second
|
||||
tick during the same cadence window emits nothing."""
|
||||
flushed = email_mod.flush_pending()
|
||||
decayed = notify.decay_watches()
|
||||
digests_sent = 0
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT id, email, display_name, digest_cadence
|
||||
FROM users
|
||||
WHERE digest_cadence IN ('daily', 'weekly')
|
||||
AND email IS NOT NULL AND email != ''
|
||||
"""
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
if assemble_for_user(
|
||||
user_id=row["id"],
|
||||
cadence=row["digest_cadence"],
|
||||
email=row["email"],
|
||||
display_name=row["display_name"],
|
||||
):
|
||||
digests_sent += 1
|
||||
return {"flushed": flushed, "decayed": decayed, "digests_sent": digests_sent}
|
||||
|
||||
|
||||
def assemble_for_user(
|
||||
*,
|
||||
user_id: int,
|
||||
cadence: str,
|
||||
email: str,
|
||||
display_name: str,
|
||||
) -> bool:
|
||||
"""§15.5 digest assembly for one user.
|
||||
|
||||
Returns True if a digest was sent, False if skipped (nothing to
|
||||
report, or cadence window not yet rolled over)."""
|
||||
cfg = email_mod.EmailConfig.from_env()
|
||||
if not cfg.enabled:
|
||||
return False
|
||||
now = datetime.now(timezone.utc)
|
||||
last_row = db.conn().execute(
|
||||
"SELECT sent_at, period_end FROM notification_digests WHERE recipient_user_id = ? ORDER BY id DESC LIMIT 1",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
period_start = (
|
||||
_parse_iso(last_row["period_end"]) if last_row and last_row["period_end"] else None
|
||||
)
|
||||
if period_start is None:
|
||||
# First digest: cover everything we have. Cap the lookback at
|
||||
# 30 days so a fresh subscription doesn't dump months of history.
|
||||
period_start = now - timedelta(days=30)
|
||||
if not _cadence_window_rolled_over(period_start, now, cadence):
|
||||
return False
|
||||
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT n.id, n.event_kind, n.rfc_slug, n.branch_name, n.pr_number,
|
||||
n.created_at, n.payload, n.read_at, n.email_sent_at,
|
||||
u.display_name AS actor_display,
|
||||
r.title AS rfc_title
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON u.id = n.actor_user_id
|
||||
LEFT JOIN cached_rfcs r ON r.slug = n.rfc_slug
|
||||
WHERE n.recipient_user_id = ?
|
||||
AND n.created_at >= ?
|
||||
ORDER BY n.id ASC
|
||||
""",
|
||||
(user_id, period_start.strftime("%Y-%m-%d %H:%M:%S")),
|
||||
).fetchall()
|
||||
|
||||
eligible: list[tuple] = []
|
||||
for r in rows:
|
||||
# §15.5 exclusion rule 1: already emailed.
|
||||
if r["email_sent_at"]:
|
||||
continue
|
||||
# §15.5 exclusion rule 2: already read.
|
||||
if r["read_at"]:
|
||||
continue
|
||||
try:
|
||||
extras = json.loads(r["payload"] or "{}")
|
||||
except json.JSONDecodeError:
|
||||
extras = {}
|
||||
# The §15.5 framing is "the catch-up surface for activity on
|
||||
# watched RFCs." Personal-direct events have their own email
|
||||
# path; exclude them from the digest body per the section's
|
||||
# closing paragraph.
|
||||
if extras.get("category") == "personal-direct":
|
||||
continue
|
||||
eligible.append((r, extras))
|
||||
|
||||
if not eligible:
|
||||
# No digest is sent when there's nothing to report (§15.5),
|
||||
# but we still record the period roll so the next window
|
||||
# starts cleanly.
|
||||
_record_emitted(user_id, period_start, now, ids=[])
|
||||
return False
|
||||
|
||||
subject = _subject(eligible, cadence)
|
||||
body = _body(eligible, cadence, cfg)
|
||||
sent = email_mod._deliver(cfg, email, subject, body)
|
||||
if not sent:
|
||||
return False
|
||||
ids = [r["id"] for r, _ in eligible]
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
db.conn().execute(
|
||||
f"UPDATE notifications SET digest_included_at = datetime('now') WHERE id IN ({placeholders})",
|
||||
ids,
|
||||
)
|
||||
_record_emitted(user_id, period_start, now, ids=ids)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cadence_window_rolled_over(period_start: datetime, now: datetime, cadence: str) -> bool:
|
||||
delta = now - period_start
|
||||
if cadence == "daily":
|
||||
return delta >= timedelta(days=1)
|
||||
if cadence == "weekly":
|
||||
return delta >= timedelta(days=7)
|
||||
return False
|
||||
|
||||
|
||||
def _parse_iso(text: str) -> datetime | None:
|
||||
# SQLite stores datetimes as "YYYY-MM-DD HH:MM:SS" via datetime('now').
|
||||
try:
|
||||
dt = datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _subject(eligible: list[tuple], cadence: str) -> str:
|
||||
rfcs = {r["rfc_slug"] for r, _ in eligible if r["rfc_slug"]}
|
||||
label = "Daily" if cadence == "daily" else "Weekly"
|
||||
return f"[Wiggleverse] {label} digest — {len(eligible)} events across {len(rfcs)} RFCs"
|
||||
|
||||
|
||||
def _body(eligible: list[tuple], cadence: str, cfg) -> str:
|
||||
label = "the past day" if cadence == "daily" else "the past week"
|
||||
lines = [f"Activity on RFCs you watch, from {label}.\n"]
|
||||
grouped: dict[str | None, list[tuple]] = {}
|
||||
for r, extras in eligible:
|
||||
grouped.setdefault(r["rfc_slug"], []).append((r, extras))
|
||||
by_volume = sorted(grouped.items(), key=lambda kv: -len(kv[1]))
|
||||
for slug, items in by_volume:
|
||||
title = items[0][0]["rfc_title"] or slug or "(no RFC)"
|
||||
lines.append(f"\n{title}")
|
||||
if slug:
|
||||
lines.append(f" {cfg.app_url}/rfc/{slug}")
|
||||
# Group by event_kind within the RFC per §15.5's per-RFC
|
||||
# section shape ("3 PRs opened on RFC-0042 …").
|
||||
by_kind: dict[str, list[tuple]] = {}
|
||||
for r, extras in items:
|
||||
by_kind.setdefault(r["event_kind"], []).append((r, extras))
|
||||
for event_kind, kind_items in by_kind.items():
|
||||
if len(kind_items) == 1:
|
||||
r, extras = kind_items[0]
|
||||
summary = notify.render_summary(
|
||||
event_kind, r["actor_display"], r["rfc_title"], extras
|
||||
)
|
||||
line = f" · {summary}"
|
||||
else:
|
||||
line = f" · {len(kind_items)} {event_kind.replace('_', ' ')} events"
|
||||
# §15.5 exclusion rule 3: annotate still-unread items as
|
||||
# "still unread in your inbox."
|
||||
if any(rr["read_at"] is None for rr, _ in kind_items):
|
||||
line += " (still unread in your inbox)"
|
||||
lines.append(line)
|
||||
lines.append(f"\nOpen your inbox: {cfg.app_url}/inbox")
|
||||
lines.append(f"Manage digest preferences: {cfg.app_url}/settings/notifications")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _record_emitted(user_id: int, period_start: datetime, now: datetime, *, ids: list[int]) -> None:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO notification_digests
|
||||
(recipient_user_id, sent_at, period_start, period_end, signal_ids_included)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
period_start.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
json.dumps(ids),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,447 @@
|
||||
"""§15.4: the email loop.
|
||||
|
||||
The transactional-email adapter is a thin wrapper over SMTP. When SMTP
|
||||
credentials aren't configured the adapter falls back to logging the
|
||||
envelope to stdout — sufficient for dev and for the integration tests,
|
||||
which assert on the log buffer rather than spinning up a mail server.
|
||||
|
||||
Per §15.4: opt-in per category (with category-specific defaults), one-
|
||||
click unsubscribe per category via signed URL, single non-spoofing From
|
||||
identity, body mirrors the inbox row verbatim. Bounces and complaints
|
||||
route to a global opt-out per the same section.
|
||||
|
||||
Quiet hours per §15.8 hold the send (the notification row still lands;
|
||||
the email defers) until the window ends. The release-from-hold pass is
|
||||
the same `flush_pending` the digest job calls; it bundles into a single
|
||||
"Activity while you were away" email when more than a threshold
|
||||
accumulated, otherwise sending individually.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import smtplib
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, time, timezone
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from itertools import groupby
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from itsdangerous import BadSignature, URLSafeSerializer
|
||||
|
||||
from . import db
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Buffer of outbound envelopes for test inspection. The integration tests
|
||||
# read from this rather than spinning up a real SMTP server. Production
|
||||
# leaves it as an unbounded list (it's never read), which is fine at
|
||||
# v1 volumes; if it ever becomes a memory issue, we cap it then.
|
||||
_SENT: list[dict] = []
|
||||
|
||||
|
||||
def sent_envelopes() -> list[dict]:
|
||||
return list(_SENT)
|
||||
|
||||
|
||||
def reset_sent_envelopes() -> None:
|
||||
_SENT.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration — read from env at call time so tests can monkeypatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailConfig:
|
||||
smtp_host: str
|
||||
smtp_port: int
|
||||
smtp_user: str
|
||||
smtp_password: str
|
||||
smtp_starttls: bool
|
||||
from_address: str
|
||||
from_name: str
|
||||
app_url: str
|
||||
bundle_threshold: int
|
||||
enabled: bool
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "EmailConfig":
|
||||
host = os.environ.get("SMTP_HOST", "").strip()
|
||||
return cls(
|
||||
smtp_host=host,
|
||||
smtp_port=int(os.environ.get("SMTP_PORT", "587")),
|
||||
smtp_user=os.environ.get("SMTP_USER", "").strip(),
|
||||
smtp_password=os.environ.get("SMTP_PASSWORD", ""),
|
||||
smtp_starttls=os.environ.get("SMTP_STARTTLS", "1") not in ("0", "false", "False", ""),
|
||||
from_address=os.environ.get("EMAIL_FROM", "notifications@wiggleverse.local").strip(),
|
||||
from_name=os.environ.get("EMAIL_FROM_NAME", "Wiggleverse").strip(),
|
||||
app_url=os.environ.get("APP_URL", "http://localhost:8000").rstrip("/"),
|
||||
bundle_threshold=int(os.environ.get("EMAIL_BUNDLE_THRESHOLD", "5")),
|
||||
enabled=os.environ.get("EMAIL_ENABLED", "1") not in ("0", "false", "False"),
|
||||
)
|
||||
|
||||
|
||||
# Signed-URL token for §15.4 one-click unsubscribe. The signer is
|
||||
# scoped to (user_id, category) so the URL is idempotent and revocable
|
||||
# by rotating SECRET_KEY.
|
||||
def _signer() -> URLSafeSerializer:
|
||||
secret = os.environ.get("SECRET_KEY", "")
|
||||
if not secret:
|
||||
raise RuntimeError("SECRET_KEY required for email signing")
|
||||
return URLSafeSerializer(secret, salt="email-unsubscribe")
|
||||
|
||||
|
||||
def make_unsubscribe_url(user_id: int, category: str) -> str:
|
||||
cfg = EmailConfig.from_env()
|
||||
token = _signer().dumps({"u": user_id, "c": category})
|
||||
qs = urlencode({"t": token})
|
||||
return f"{cfg.app_url}/api/email/unsubscribe?{qs}"
|
||||
|
||||
|
||||
def verify_unsubscribe_token(token: str) -> tuple[int, str]:
|
||||
"""Returns (user_id, category) or raises BadSignature."""
|
||||
data = _signer().loads(token)
|
||||
return int(data["u"]), str(data["c"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Category routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Map §15.1 event_kinds to one of the four §15.4 categories. The 'churn'
|
||||
# category never emails per §15.4 — naming the refusal in settings is
|
||||
# more honest than silently omitting the toggle.
|
||||
_EVENT_TO_CATEGORY: dict[str, str] = {
|
||||
"proposal_merged": "personal-direct",
|
||||
"proposal_declined": "personal-direct",
|
||||
"proposal_opened_on_watched_topic": "structural",
|
||||
"pr_opened": "structural",
|
||||
"pr_merged": "structural",
|
||||
"pr_withdrawn": "structural",
|
||||
"pr_commit_added": "churn",
|
||||
"pr_review_thread_new": "structural",
|
||||
"pr_review_thread_reply": "personal-direct",
|
||||
"pr_conflict_with_main": "structural",
|
||||
"chat_message_in_participated_thread": "churn",
|
||||
"chat_reply_to_my_message": "personal-direct",
|
||||
"change_proposed_on_edited_passage": "personal-direct",
|
||||
"flag_dropped_on_watched_rfc": "structural",
|
||||
"flag_resolved_on_my_flag": "personal-direct",
|
||||
"contribute_grant_added": "personal-direct",
|
||||
"contribute_grant_revoked": "personal-direct",
|
||||
"graduation_complete": "personal-direct",
|
||||
"super_draft_graduation_ready": "admin-actionable",
|
||||
"claim_opened": "structural",
|
||||
}
|
||||
|
||||
|
||||
def category_for(event_kind: str, fallback_category: str) -> str:
|
||||
return _EVENT_TO_CATEGORY.get(event_kind, fallback_category)
|
||||
|
||||
|
||||
def _user_wants_email(user_row: Any, category: str) -> bool:
|
||||
"""Apply the §15.4 per-category toggle. Churn always returns False
|
||||
(the toggle is permanently off per the spec). Global opt-out
|
||||
(`email_opt_out_all`) suppresses every category."""
|
||||
if user_row["email_opt_out_all"]:
|
||||
return False
|
||||
if category == "churn":
|
||||
return False
|
||||
if category == "personal-direct":
|
||||
return bool(user_row["email_personal_direct"])
|
||||
if category == "structural":
|
||||
return bool(user_row["email_watched_structural"])
|
||||
if category == "admin-actionable":
|
||||
if user_row["role"] not in ("owner", "admin"):
|
||||
return False
|
||||
return bool(user_row["email_admin_actionable"])
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quiet hours (§15.8)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _in_quiet_hours(user_row: Any) -> bool:
|
||||
start = user_row["notification_quiet_hours_start"]
|
||||
end = user_row["notification_quiet_hours_end"]
|
||||
tz_name = user_row["notification_quiet_hours_timezone"]
|
||||
if not (start and end and tz_name):
|
||||
return False
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
tz = ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
return False
|
||||
now_local = datetime.now(tz).time()
|
||||
start_t = _parse_hhmm(start)
|
||||
end_t = _parse_hhmm(end)
|
||||
if start_t is None or end_t is None:
|
||||
return False
|
||||
if start_t <= end_t:
|
||||
return start_t <= now_local < end_t
|
||||
# Wraps midnight (e.g. 22:00 → 07:00).
|
||||
return now_local >= start_t or now_local < end_t
|
||||
|
||||
|
||||
def _parse_hhmm(text: str) -> time | None:
|
||||
try:
|
||||
hh, mm = text.split(":", 1)
|
||||
return time(int(hh), int(mm))
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The dispatch entry point — called from notify._schedule_email
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def maybe_send(
|
||||
*,
|
||||
notif_id: int,
|
||||
recipient_user_id: int,
|
||||
event_kind: str,
|
||||
category: str,
|
||||
payload: dict,
|
||||
) -> None:
|
||||
"""Apply §15.4's per-category opt-in and §15.8's quiet-hours hold,
|
||||
then send. The notification row still landed in `notifications`
|
||||
regardless; this only governs the out-of-band reach."""
|
||||
user = db.conn().execute(
|
||||
"""
|
||||
SELECT id, email, display_name, role,
|
||||
email_personal_direct, email_watched_structural, email_admin_actionable,
|
||||
email_opt_out_all,
|
||||
notification_quiet_hours_start, notification_quiet_hours_end,
|
||||
notification_quiet_hours_timezone
|
||||
FROM users WHERE id = ?
|
||||
""",
|
||||
(recipient_user_id,),
|
||||
).fetchone()
|
||||
if user is None or not user["email"]:
|
||||
return
|
||||
effective_category = category_for(event_kind, category)
|
||||
if not _user_wants_email(user, effective_category):
|
||||
return
|
||||
if _in_quiet_hours(user):
|
||||
# §15.8: hold during the window. The row's email_sent_at stays
|
||||
# null; the digest's exclusion rule 1 won't kick in yet. The
|
||||
# `flush_pending` pass at window end picks it up.
|
||||
return
|
||||
_send_one(user, notif_id, payload, effective_category)
|
||||
|
||||
|
||||
def _send_one(user: Any, notif_id: int, payload: dict, category: str) -> None:
|
||||
cfg = EmailConfig.from_env()
|
||||
if not cfg.enabled:
|
||||
return
|
||||
subject = _subject(payload)
|
||||
body = _body(payload, user["id"], category, cfg)
|
||||
sent = _deliver(cfg, user["email"], subject, body)
|
||||
if not sent:
|
||||
return
|
||||
db.conn().execute(
|
||||
"UPDATE notifications SET email_sent_at = datetime('now') WHERE id = ?",
|
||||
(notif_id,),
|
||||
)
|
||||
|
||||
|
||||
def _subject(payload: dict) -> str:
|
||||
rfc_title = payload.get("rfc_title") or payload.get("rfc_slug") or ""
|
||||
summary = payload.get("summary") or payload.get("event_kind") or ""
|
||||
if rfc_title:
|
||||
return f"[Wiggleverse] {summary} — {rfc_title}".strip()
|
||||
return f"[Wiggleverse] {summary}".strip()
|
||||
|
||||
|
||||
def _body(payload: dict, user_id: int, category: str, cfg: EmailConfig) -> str:
|
||||
summary = payload.get("summary") or ""
|
||||
actor = payload.get("actor_display") or "the app"
|
||||
rfc_title = payload.get("rfc_title") or payload.get("rfc_slug") or "this RFC"
|
||||
when = payload.get("created_at") or ""
|
||||
link = _deep_link(payload, cfg)
|
||||
unsub = make_unsubscribe_url(user_id, category)
|
||||
manage = f"{cfg.app_url}/settings/notifications"
|
||||
return (
|
||||
f"{summary}\n\n"
|
||||
f"by {actor} on {rfc_title} · {when}\n\n"
|
||||
f"{link}\n\n"
|
||||
f"---\n"
|
||||
f"Unsubscribe from this category: {unsub}\n"
|
||||
f"Manage all preferences: {manage}\n"
|
||||
)
|
||||
|
||||
|
||||
def _deep_link(payload: dict, cfg: EmailConfig) -> str:
|
||||
slug = payload.get("rfc_slug")
|
||||
pr = payload.get("pr_number")
|
||||
branch = payload.get("branch_name")
|
||||
if slug and pr:
|
||||
return f"{cfg.app_url}/rfc/{slug}/pr/{pr}"
|
||||
if slug and branch:
|
||||
return f"{cfg.app_url}/rfc/{slug}?branch={branch}"
|
||||
if slug:
|
||||
return f"{cfg.app_url}/rfc/{slug}"
|
||||
return cfg.app_url
|
||||
|
||||
|
||||
def _deliver(cfg: EmailConfig, to_address: str, subject: str, body: str) -> bool:
|
||||
envelope = {
|
||||
"to": to_address,
|
||||
"from": formataddr((cfg.from_name, cfg.from_address)),
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
}
|
||||
_SENT.append(envelope)
|
||||
if not cfg.smtp_host:
|
||||
log.info("email (stdout fallback): to=%s subject=%s", to_address, subject)
|
||||
return True
|
||||
try:
|
||||
msg = EmailMessage()
|
||||
msg["From"] = envelope["from"]
|
||||
msg["To"] = to_address
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(body)
|
||||
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
|
||||
try:
|
||||
if cfg.smtp_starttls:
|
||||
smtp.starttls()
|
||||
if cfg.smtp_user:
|
||||
smtp.login(cfg.smtp_user, cfg.smtp_password)
|
||||
smtp.send_message(msg)
|
||||
finally:
|
||||
smtp.quit()
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("email send failed: to=%s subject=%s", to_address, subject)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quiet-hours release pass — called from the digest job
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def flush_pending() -> int:
|
||||
"""§15.8 release-from-hold. For each user whose quiet-hours window
|
||||
has ended (or who has no quiet hours configured), find notifications
|
||||
whose `email_sent_at IS NULL` and whose category is enabled, and
|
||||
send them. Bundle into a single "Activity while you were away"
|
||||
when more than the threshold accumulated."""
|
||||
cfg = EmailConfig.from_env()
|
||||
if not cfg.enabled:
|
||||
return 0
|
||||
|
||||
users = db.conn().execute(
|
||||
"""
|
||||
SELECT id, email, display_name, role,
|
||||
email_personal_direct, email_watched_structural, email_admin_actionable,
|
||||
email_opt_out_all,
|
||||
notification_quiet_hours_start, notification_quiet_hours_end,
|
||||
notification_quiet_hours_timezone
|
||||
FROM users
|
||||
"""
|
||||
).fetchall()
|
||||
sent_count = 0
|
||||
for user in users:
|
||||
if not user["email"]:
|
||||
continue
|
||||
if _in_quiet_hours(user):
|
||||
continue
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT n.id, n.event_kind, n.rfc_slug, n.branch_name, n.pr_number,
|
||||
n.created_at, n.payload,
|
||||
u.display_name AS actor_display,
|
||||
r.title AS rfc_title
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON u.id = n.actor_user_id
|
||||
LEFT JOIN cached_rfcs r ON r.slug = n.rfc_slug
|
||||
WHERE n.recipient_user_id = ?
|
||||
AND n.email_sent_at IS NULL
|
||||
AND n.read_at IS NULL
|
||||
ORDER BY n.id ASC
|
||||
""",
|
||||
(user["id"],),
|
||||
).fetchall()
|
||||
emailable = []
|
||||
for r in rows:
|
||||
try:
|
||||
extras = json.loads(r["payload"] or "{}")
|
||||
except json.JSONDecodeError:
|
||||
extras = {}
|
||||
cat = category_for(r["event_kind"], extras.get("category", "structural"))
|
||||
if not _user_wants_email(user, cat):
|
||||
continue
|
||||
emailable.append((r, cat, extras))
|
||||
if not emailable:
|
||||
continue
|
||||
if len(emailable) >= cfg.bundle_threshold:
|
||||
sent_count += _send_bundle(cfg, user, emailable)
|
||||
else:
|
||||
for r, cat, extras in emailable:
|
||||
payload = _row_to_payload(r, extras)
|
||||
_send_one(user, r["id"], payload, cat)
|
||||
sent_count += 1
|
||||
return sent_count
|
||||
|
||||
|
||||
def _row_to_payload(row: Any, extras: dict) -> dict:
|
||||
return {
|
||||
"event_kind": row["event_kind"],
|
||||
"rfc_slug": row["rfc_slug"],
|
||||
"rfc_title": row["rfc_title"],
|
||||
"branch_name": row["branch_name"],
|
||||
"pr_number": row["pr_number"],
|
||||
"actor_display": row["actor_display"],
|
||||
"created_at": row["created_at"],
|
||||
"summary": _summary_for(row["event_kind"], row["actor_display"], row["rfc_title"], extras),
|
||||
**extras,
|
||||
}
|
||||
|
||||
|
||||
def _summary_for(event_kind: str, actor_display: str | None, rfc_title: str | None, extras: dict) -> str:
|
||||
# Local copy to avoid import cycle with notify.py.
|
||||
from .notify import render_summary
|
||||
return render_summary(event_kind, actor_display, rfc_title, extras)
|
||||
|
||||
|
||||
def _send_bundle(cfg: EmailConfig, user: Any, emailable: list) -> int:
|
||||
"""One "Activity while you were away" email per §15.4. Subject names
|
||||
the count, body lists summaries grouped by RFC."""
|
||||
count = len(emailable)
|
||||
subject = f"[Wiggleverse] Activity while you were away — {count} events"
|
||||
sections: list[str] = []
|
||||
sorted_rows = sorted(emailable, key=lambda t: t[0]["rfc_slug"] or "")
|
||||
for slug, group in groupby(sorted_rows, key=lambda t: t[0]["rfc_slug"]):
|
||||
group_rows = list(group)
|
||||
title = group_rows[0][0]["rfc_title"] or slug or "(no RFC)"
|
||||
sections.append(f"\n{title}")
|
||||
for r, _cat, extras in group_rows:
|
||||
summary = _summary_for(r["event_kind"], r["actor_display"], r["rfc_title"], extras)
|
||||
sections.append(f" · {summary}")
|
||||
body = (
|
||||
"Activity on RFCs you watch, accumulated during your quiet hours:\n"
|
||||
+ "\n".join(sections)
|
||||
+ f"\n\nOpen your inbox: {cfg.app_url}/inbox\n"
|
||||
+ f"Manage all preferences: {cfg.app_url}/settings/notifications\n"
|
||||
)
|
||||
sent = _deliver(cfg, user["email"], subject, body)
|
||||
if not sent:
|
||||
return 0
|
||||
ids = [r["id"] for r, _, _ in emailable]
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
db.conn().execute(
|
||||
f"UPDATE notifications SET email_sent_at = datetime('now') WHERE id IN ({placeholders})",
|
||||
ids,
|
||||
)
|
||||
return count
|
||||
+4
-1
@@ -14,7 +14,7 @@ from fastapi import APIRouter, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from . import api as api_routes, auth, cache, db, providers as providers_mod, webhooks
|
||||
from . import api as api_routes, auth, cache, db, digest, providers as providers_mod, webhooks
|
||||
from .bot import Bot
|
||||
from .config import load_config
|
||||
from .gitea import Gitea
|
||||
@@ -31,6 +31,7 @@ async def lifespan(app: FastAPI):
|
||||
gitea = Gitea(config)
|
||||
bot = Bot(gitea)
|
||||
reconciler = cache.Reconciler(config, gitea)
|
||||
digest_sched = digest.DigestScheduler()
|
||||
|
||||
# §18 carryover: the multi-provider LLM abstraction. Provider
|
||||
# construction can fail (missing key, wrong env value) — if it does,
|
||||
@@ -53,10 +54,12 @@ async def lifespan(app: FastAPI):
|
||||
app.include_router(webhooks.make_router(config, gitea))
|
||||
|
||||
reconciler.start()
|
||||
digest_sched.start()
|
||||
log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await digest_sched.stop()
|
||||
await reconciler.stop()
|
||||
await gitea.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,948 @@
|
||||
"""§15: the notifications fan-out and the SSE broadcaster.
|
||||
|
||||
This module is the single chokepoint for the producer side of §15.
|
||||
Every write that names an `rfc_slug` flows through `fan_out_from_action`
|
||||
(called from `bot._log`) or `fan_out_chat_message` (called from the chat
|
||||
write paths in `api_branches` / `api_prs`, since chat messages don't go
|
||||
through the bot wrapper). The chokepoint:
|
||||
|
||||
- upserts a `watches` row for the actor per §15.6's auto-watch rule
|
||||
(a substantive gesture sets `watching`, never downgrades, and bumps
|
||||
`last_participation_at` for the 90-day decay);
|
||||
- applies the §15.1 routing rules to determine which other users
|
||||
receive a notification for the event;
|
||||
- filters out muted recipients per §15.8 (per-user mute list);
|
||||
- filters out `watches.state='muted'` per §15.6 (per-RFC mute);
|
||||
- inserts one `notifications` row per recipient with the underlying
|
||||
actor's user id per §15.9 (the bot never appears as actor);
|
||||
- dispatches the row to the §15.4 email path if the recipient's
|
||||
category toggle is on (held during quiet hours per §15.8, deferred
|
||||
to the digest otherwise);
|
||||
- publishes each new row to the per-user SSE subscriber queue so the
|
||||
inbox refresh and header-badge count back the same stream per §15.3.
|
||||
|
||||
If you find yourself wanting to insert a row into `notifications`
|
||||
directly from an endpoint, the spec is right and you are wrong: the
|
||||
chokepoint is the invariant. Read paths can query the table from
|
||||
anywhere; the producer side flows through here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable
|
||||
|
||||
from . import db
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §15.1 routing — action_kind → (event_kind, category, recipient_set)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Three recipient sets are used by the routing rules below:
|
||||
#
|
||||
# "watchers" — every user with a watches row on this slug whose state
|
||||
# is 'watching' (full stream) or 'following' (structural
|
||||
# only), minus the actor and minus 'muted' rows. The
|
||||
# structural/full split is applied per-event below.
|
||||
# "owners" — every user named in the entry's owners_json frontmatter
|
||||
# on cached_rfcs (resolved to user_ids via gitea_login).
|
||||
# "proposer" — the user whose action_kind=propose_rfc opened the slug,
|
||||
# resolved via the most recent matching audit row.
|
||||
#
|
||||
# The category of each event maps to one of the §15.4 buckets:
|
||||
#
|
||||
# "personal-direct" — recipient is named subject of the action.
|
||||
# "structural" — structural beats on watched RFCs.
|
||||
# "churn" — per-commit / per-message activity. Never emails
|
||||
# per §15.4; aggregated by the §15.5 digest.
|
||||
|
||||
CATEGORY_PERSONAL = "personal-direct"
|
||||
CATEGORY_STRUCTURAL = "structural"
|
||||
CATEGORY_CHURN = "churn"
|
||||
|
||||
# Action kinds whose actor's first interaction with a slug triggers
|
||||
# auto-watch per §15.6. The substantive-gesture list in the spec is
|
||||
# the source; we map onto our action_kind taxonomy. Reads, view advances,
|
||||
# and own-action observations are intentionally excluded.
|
||||
_AUTO_WATCH_ACTIONS = {
|
||||
"propose_rfc",
|
||||
"merge_proposal",
|
||||
"decline_proposal",
|
||||
"create_branch",
|
||||
"accept_change",
|
||||
"decline_change",
|
||||
"manual_flush",
|
||||
"open_branch_pr",
|
||||
"merge_branch_pr",
|
||||
"withdraw_branch_pr",
|
||||
"supersede_branch_pr",
|
||||
"open_metadata_pr",
|
||||
"open_claim_pr",
|
||||
"merge_claim_pr",
|
||||
"create_resolution_branch",
|
||||
"replay_change",
|
||||
"graduate_start",
|
||||
"graduate_complete",
|
||||
"open_review_thread",
|
||||
"resolve_thread",
|
||||
"drop_flag",
|
||||
"resolve_flag",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSE broadcaster — per-user subscriber registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Subscriber:
|
||||
user_id: int
|
||||
queue: asyncio.Queue = field(default_factory=asyncio.Queue)
|
||||
|
||||
|
||||
_subscribers: dict[int, list[_Subscriber]] = {}
|
||||
_subscribers_lock: asyncio.Lock | None = None
|
||||
|
||||
|
||||
def _get_lock() -> asyncio.Lock:
|
||||
global _subscribers_lock
|
||||
if _subscribers_lock is None:
|
||||
_subscribers_lock = asyncio.Lock()
|
||||
return _subscribers_lock
|
||||
|
||||
|
||||
async def subscribe(user_id: int) -> _Subscriber:
|
||||
"""Register a new SSE subscriber for a user. Each browser tab gets its
|
||||
own subscriber; the per-user fan-out pushes the same event to every
|
||||
subscriber for that user per §15 (multiple tabs see the same updates)."""
|
||||
sub = _Subscriber(user_id=user_id)
|
||||
async with _get_lock():
|
||||
_subscribers.setdefault(user_id, []).append(sub)
|
||||
return sub
|
||||
|
||||
|
||||
async def unsubscribe(sub: _Subscriber) -> None:
|
||||
async with _get_lock():
|
||||
bucket = _subscribers.get(sub.user_id, [])
|
||||
if sub in bucket:
|
||||
bucket.remove(sub)
|
||||
if not bucket:
|
||||
_subscribers.pop(sub.user_id, None)
|
||||
|
||||
|
||||
async def _broadcast(user_id: int, event: str, payload: Any) -> None:
|
||||
"""Push an event to every subscriber for one user.
|
||||
|
||||
Best-effort; if the queue is unbounded a slow consumer just sees the
|
||||
backlog when it next drains. We deliberately do not block writers on
|
||||
consumer presence — the durable inbox row is the source of truth."""
|
||||
async with _get_lock():
|
||||
subs = list(_subscribers.get(user_id, []))
|
||||
for sub in subs:
|
||||
try:
|
||||
sub.queue.put_nowait({"event": event, "payload": payload})
|
||||
except Exception:
|
||||
log.exception("notify broadcast: drop event for user %s", user_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Producer-side entry points
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def fan_out_from_action(
|
||||
*,
|
||||
actor_user_id: int | None,
|
||||
action_kind: str,
|
||||
rfc_slug: str | None,
|
||||
branch_name: str | None,
|
||||
pr_number: int | None,
|
||||
details: dict | None,
|
||||
) -> None:
|
||||
"""Called from `bot._log` after every successful Git write.
|
||||
|
||||
Synchronous on purpose — runs inside the same request as the action,
|
||||
so a failure here surfaces with the action rather than as a silent
|
||||
drop. The SSE push is asyncio-scheduled if a loop is running; if
|
||||
no loop is available (test harness, sync test client) the dispatch
|
||||
falls back to writing the row and relying on the next subscriber
|
||||
poll to surface it.
|
||||
"""
|
||||
if rfc_slug is None or action_kind not in _AUTO_WATCH_ACTIONS:
|
||||
# Actions without an rfc_slug (e.g. permission events) or that
|
||||
# aren't a §15.6 substantive gesture don't drive the chokepoint.
|
||||
return
|
||||
|
||||
if actor_user_id is not None:
|
||||
_bump_auto_watch(actor_user_id, rfc_slug)
|
||||
|
||||
rules = _ROUTING.get(action_kind)
|
||||
if rules is None:
|
||||
return
|
||||
|
||||
for rule in rules:
|
||||
recipients = _resolve_recipients(
|
||||
rule=rule,
|
||||
actor_user_id=actor_user_id,
|
||||
rfc_slug=rfc_slug,
|
||||
details=details,
|
||||
)
|
||||
if not recipients:
|
||||
continue
|
||||
for recipient_id in recipients:
|
||||
_emit_one(
|
||||
recipient_user_id=recipient_id,
|
||||
event_kind=rule["event_kind"],
|
||||
category=rule["category"],
|
||||
actor_user_id=actor_user_id,
|
||||
rfc_slug=rfc_slug,
|
||||
branch_name=branch_name,
|
||||
pr_number=pr_number,
|
||||
details=details or {},
|
||||
)
|
||||
|
||||
|
||||
def fan_out_chat_message(
|
||||
*,
|
||||
actor_user_id: int,
|
||||
rfc_slug: str,
|
||||
branch_name: str,
|
||||
thread_id: int,
|
||||
message_id: int,
|
||||
is_review_thread: bool = False,
|
||||
pr_number: int | None = None,
|
||||
) -> None:
|
||||
"""Called from chat write paths after a user message is persisted.
|
||||
|
||||
Chat messages don't flow through bot.py (no Git write); the chokepoint
|
||||
here is the equivalent for §15's chat-message events. The routing
|
||||
rule is: prior authors in the thread (besides the message author) get
|
||||
a personal-direct `chat_reply_to_my_message`; users watching the RFC
|
||||
(state='watching', i.e. full stream) get a churn-class
|
||||
`chat_message_in_participated_thread`. The two are union'd so a user
|
||||
who is both gets only the personal-direct row.
|
||||
"""
|
||||
_bump_auto_watch(actor_user_id, rfc_slug)
|
||||
|
||||
prior_authors = {
|
||||
row["author_user_id"]
|
||||
for row in db.conn().execute(
|
||||
"""
|
||||
SELECT DISTINCT author_user_id
|
||||
FROM thread_messages
|
||||
WHERE thread_id = ?
|
||||
AND author_user_id IS NOT NULL
|
||||
AND author_user_id != ?
|
||||
AND id < ?
|
||||
""",
|
||||
(thread_id, actor_user_id, message_id),
|
||||
)
|
||||
}
|
||||
|
||||
# §15.6 / §15.8 filters apply here too — a muted recipient never sees
|
||||
# a row regardless of which producer path generated it.
|
||||
muted_rfc = _muted_user_ids_for_rfc(rfc_slug)
|
||||
muters = _muters_of(actor_user_id)
|
||||
prior_authors = (prior_authors - muted_rfc) - muters
|
||||
|
||||
# Personal-direct: prior thread authors.
|
||||
for recipient_id in prior_authors:
|
||||
event_kind = (
|
||||
"pr_review_thread_reply" if is_review_thread else "chat_reply_to_my_message"
|
||||
)
|
||||
_emit_one(
|
||||
recipient_user_id=recipient_id,
|
||||
event_kind=event_kind,
|
||||
category=CATEGORY_PERSONAL,
|
||||
actor_user_id=actor_user_id,
|
||||
rfc_slug=rfc_slug,
|
||||
branch_name=branch_name,
|
||||
pr_number=pr_number,
|
||||
thread_id=thread_id,
|
||||
details={"message_id": message_id},
|
||||
)
|
||||
|
||||
# Churn: watchers of the RFC who weren't already covered.
|
||||
watcher_ids = _watcher_user_ids(rfc_slug, scope="watching") - prior_authors
|
||||
watcher_ids.discard(actor_user_id)
|
||||
watcher_ids = (watcher_ids - muted_rfc) - muters
|
||||
for recipient_id in watcher_ids:
|
||||
event_kind = (
|
||||
"pr_review_thread_new" if is_review_thread
|
||||
else "chat_message_in_participated_thread"
|
||||
)
|
||||
_emit_one(
|
||||
recipient_user_id=recipient_id,
|
||||
event_kind=event_kind,
|
||||
category=CATEGORY_CHURN,
|
||||
actor_user_id=actor_user_id,
|
||||
rfc_slug=rfc_slug,
|
||||
branch_name=branch_name,
|
||||
pr_number=pr_number,
|
||||
thread_id=thread_id,
|
||||
details={"message_id": message_id},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routing rules — per action_kind, a list of recipient×event×category
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_ROUTING: dict[str, list[dict]] = {
|
||||
# §9.1: propose surfaces the new slug. The proposer is the actor;
|
||||
# the personal-direct beats fire on merge/decline, not on propose
|
||||
# itself. Admins and owners get a structural ping so the
|
||||
# pending-ideas queue isn't invisible.
|
||||
"propose_rfc": [
|
||||
{"event_kind": "proposal_opened_on_watched_topic",
|
||||
"category": CATEGORY_STRUCTURAL, "recipients": "admins"},
|
||||
],
|
||||
# §9.3: merge fires personal-direct to the proposer per §15.4's
|
||||
# named-subject rule. Watchers also see the structural beat.
|
||||
"merge_proposal": [
|
||||
{"event_kind": "proposal_merged",
|
||||
"category": CATEGORY_PERSONAL, "recipients": "proposer"},
|
||||
{"event_kind": "proposal_merged",
|
||||
"category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
|
||||
],
|
||||
"decline_proposal": [
|
||||
{"event_kind": "proposal_declined",
|
||||
"category": CATEGORY_PERSONAL, "recipients": "proposer"},
|
||||
],
|
||||
# §10.1: PR opened. Watchers see it; the opener auto-watches but
|
||||
# doesn't get an inbox row for their own gesture.
|
||||
"open_branch_pr": [
|
||||
{"event_kind": "pr_opened",
|
||||
"category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
|
||||
],
|
||||
"open_metadata_pr": [
|
||||
{"event_kind": "pr_opened",
|
||||
"category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
|
||||
],
|
||||
"open_claim_pr": [
|
||||
{"event_kind": "claim_opened",
|
||||
"category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
|
||||
],
|
||||
# §10.5: merge fires personal-direct to the PR opener if someone
|
||||
# else merged; watchers see the structural beat regardless.
|
||||
"merge_branch_pr": [
|
||||
{"event_kind": "pr_merged",
|
||||
"category": CATEGORY_PERSONAL, "recipients": "pr_opener_if_other"},
|
||||
{"event_kind": "pr_merged",
|
||||
"category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
|
||||
],
|
||||
"withdraw_branch_pr": [
|
||||
{"event_kind": "pr_withdrawn",
|
||||
"category": CATEGORY_PERSONAL, "recipients": "pr_opener_if_other"},
|
||||
{"event_kind": "pr_withdrawn",
|
||||
"category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
|
||||
],
|
||||
"supersede_branch_pr": [
|
||||
{"event_kind": "pr_withdrawn",
|
||||
"category": CATEGORY_PERSONAL, "recipients": "pr_opener_if_other"},
|
||||
],
|
||||
# §8.6: accept_change is per-commit churn. Watchers (full stream)
|
||||
# get the row; following-only watchers don't.
|
||||
"accept_change": [
|
||||
{"event_kind": "pr_commit_added",
|
||||
"category": CATEGORY_CHURN, "recipients": "watchers_full"},
|
||||
],
|
||||
"manual_flush": [
|
||||
{"event_kind": "pr_commit_added",
|
||||
"category": CATEGORY_CHURN, "recipients": "watchers_full"},
|
||||
],
|
||||
"replay_change": [
|
||||
{"event_kind": "pr_commit_added",
|
||||
"category": CATEGORY_CHURN, "recipients": "watchers_full"},
|
||||
],
|
||||
# §13.3: graduation_complete is personal-direct to owners (the
|
||||
# super-draft's owners list) and structural to watchers.
|
||||
"graduate_complete": [
|
||||
{"event_kind": "graduation_complete",
|
||||
"category": CATEGORY_PERSONAL, "recipients": "owners"},
|
||||
{"event_kind": "graduation_complete",
|
||||
"category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
|
||||
],
|
||||
"graduate_start": [
|
||||
{"event_kind": "super_draft_graduation_ready",
|
||||
"category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
|
||||
],
|
||||
"create_resolution_branch": [
|
||||
{"event_kind": "pr_conflict_with_main",
|
||||
"category": CATEGORY_STRUCTURAL, "recipients": "watchers_structural"},
|
||||
],
|
||||
"create_branch": [
|
||||
# No notification — branch creation is a private gesture until
|
||||
# work lands on it. Auto-watch is the visible effect.
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recipient resolvers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_recipients(
|
||||
*,
|
||||
rule: dict,
|
||||
actor_user_id: int | None,
|
||||
rfc_slug: str,
|
||||
details: dict | None,
|
||||
) -> set[int]:
|
||||
kind = rule["recipients"]
|
||||
if kind == "watchers_structural":
|
||||
# following (structural only) + watching (full stream) — both
|
||||
# see structural events.
|
||||
ids = _watcher_user_ids(rfc_slug, scope="any")
|
||||
elif kind == "watchers_full":
|
||||
ids = _watcher_user_ids(rfc_slug, scope="watching")
|
||||
elif kind == "owners":
|
||||
ids = _entry_owner_user_ids(rfc_slug)
|
||||
elif kind == "admins":
|
||||
ids = _admin_user_ids()
|
||||
elif kind == "proposer":
|
||||
ids = _proposer_user_id(rfc_slug)
|
||||
elif kind == "pr_opener_if_other":
|
||||
opener = _pr_opener_user_id(rfc_slug, details)
|
||||
ids = {opener} if opener is not None and opener != actor_user_id else set()
|
||||
else:
|
||||
log.warning("notify: unknown recipient kind %s", kind)
|
||||
return set()
|
||||
|
||||
if actor_user_id is not None:
|
||||
ids.discard(actor_user_id)
|
||||
|
||||
# §15.6: per-RFC mute suppresses every signal for the (user, slug).
|
||||
muted = _muted_user_ids_for_rfc(rfc_slug)
|
||||
ids -= muted
|
||||
|
||||
# §15.8: per-user mute suppresses notifications produced by the actor
|
||||
# for each muter. (Inbox-volume only; content visibility unchanged.)
|
||||
if actor_user_id is not None:
|
||||
muters = _muters_of(actor_user_id)
|
||||
ids -= muters
|
||||
|
||||
return ids
|
||||
|
||||
|
||||
def _watcher_user_ids(rfc_slug: str, *, scope: str) -> set[int]:
|
||||
"""scope: 'watching' = full stream only; 'any' = watching+following."""
|
||||
if scope == "watching":
|
||||
rows = db.conn().execute(
|
||||
"SELECT user_id FROM watches WHERE rfc_slug = ? AND state = 'watching'",
|
||||
(rfc_slug,),
|
||||
)
|
||||
else:
|
||||
rows = db.conn().execute(
|
||||
"SELECT user_id FROM watches WHERE rfc_slug = ? AND state IN ('watching', 'following')",
|
||||
(rfc_slug,),
|
||||
)
|
||||
return {r["user_id"] for r in rows}
|
||||
|
||||
|
||||
def _entry_owner_user_ids(rfc_slug: str) -> set[int]:
|
||||
row = db.conn().execute(
|
||||
"SELECT owners_json FROM cached_rfcs WHERE slug = ?", (rfc_slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return set()
|
||||
try:
|
||||
owners = json.loads(row["owners_json"] or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return set()
|
||||
if not owners:
|
||||
return set()
|
||||
placeholders = ",".join("?" * len(owners))
|
||||
user_rows = db.conn().execute(
|
||||
f"SELECT id FROM users WHERE gitea_login IN ({placeholders})",
|
||||
tuple(owners),
|
||||
)
|
||||
return {r["id"] for r in user_rows}
|
||||
|
||||
|
||||
def _admin_user_ids() -> set[int]:
|
||||
return {
|
||||
r["id"]
|
||||
for r in db.conn().execute("SELECT id FROM users WHERE role IN ('owner', 'admin')")
|
||||
}
|
||||
|
||||
|
||||
def _proposer_user_id(rfc_slug: str) -> set[int]:
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT actor_user_id FROM actions
|
||||
WHERE action_kind = 'propose_rfc' AND rfc_slug = ?
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(rfc_slug,),
|
||||
).fetchone()
|
||||
if row is None or row["actor_user_id"] is None:
|
||||
return set()
|
||||
return {row["actor_user_id"]}
|
||||
|
||||
|
||||
def _pr_opener_user_id(rfc_slug: str, details: dict | None) -> int | None:
|
||||
"""For pr_opener_if_other recipients, find the user who opened the PR.
|
||||
|
||||
Uses cached_prs.opened_by (gitea_login) → users.id, falling back to
|
||||
the audit log if the cache row isn't there yet.
|
||||
"""
|
||||
pr_number = (details or {}).get("pr_number")
|
||||
# When called from merge/withdraw routing, details is the original
|
||||
# action's details — pr_number is on the audit row, not here. Pull
|
||||
# it from the most-recent relevant action.
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT actor_user_id FROM actions
|
||||
WHERE action_kind = 'open_branch_pr' AND rfc_slug = ?
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(rfc_slug,),
|
||||
).fetchone()
|
||||
if row and row["actor_user_id"] is not None:
|
||||
return row["actor_user_id"]
|
||||
return pr_number # last-ditch: keep typing consistent (rarely matters)
|
||||
|
||||
|
||||
def _muted_user_ids_for_rfc(rfc_slug: str) -> set[int]:
|
||||
return {
|
||||
r["user_id"]
|
||||
for r in db.conn().execute(
|
||||
"SELECT user_id FROM watches WHERE rfc_slug = ? AND state = 'muted'",
|
||||
(rfc_slug,),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _muters_of(actor_user_id: int) -> set[int]:
|
||||
return {
|
||||
r["muter_user_id"]
|
||||
for r in db.conn().execute(
|
||||
"SELECT muter_user_id FROM notification_user_mutes WHERE muted_user_id = ?",
|
||||
(actor_user_id,),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-watch upsert
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bump_auto_watch(user_id: int, rfc_slug: str) -> None:
|
||||
"""Per §15.6 substantive-gesture rule: upsert a `watching` row for
|
||||
the actor, never downgrade. Bumps `last_participation_at` for the
|
||||
90-day decay timer regardless of current state.
|
||||
|
||||
The role-implicit watching for owners/arbiters is computed at fan-out
|
||||
time (recipient resolution), not stored on the row, so this upsert
|
||||
doesn't need to special-case them.
|
||||
"""
|
||||
existing = db.conn().execute(
|
||||
"SELECT state, set_by FROM watches WHERE user_id = ? AND rfc_slug = ?",
|
||||
(user_id, rfc_slug),
|
||||
).fetchone()
|
||||
now = "datetime('now')"
|
||||
if existing is None:
|
||||
db.conn().execute(
|
||||
f"""
|
||||
INSERT INTO watches
|
||||
(user_id, rfc_slug, state, set_by, set_at, last_participation_at)
|
||||
VALUES (?, ?, 'watching', 'auto', {now}, {now})
|
||||
""",
|
||||
(user_id, rfc_slug),
|
||||
)
|
||||
return
|
||||
# Don't downgrade: a 'muted' explicit setting stays muted; a 'watching'
|
||||
# row stays watching. The only auto-upgrade is following → watching.
|
||||
if existing["state"] == "following" and existing["set_by"] != "explicit":
|
||||
db.conn().execute(
|
||||
f"UPDATE watches SET state = 'watching', last_participation_at = {now} WHERE user_id = ? AND rfc_slug = ?",
|
||||
(user_id, rfc_slug),
|
||||
)
|
||||
else:
|
||||
db.conn().execute(
|
||||
f"UPDATE watches SET last_participation_at = {now} WHERE user_id = ? AND rfc_slug = ?",
|
||||
(user_id, rfc_slug),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inserting a notification row + dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _emit_one(
|
||||
*,
|
||||
recipient_user_id: int,
|
||||
event_kind: str,
|
||||
category: str,
|
||||
actor_user_id: int | None,
|
||||
rfc_slug: str | None,
|
||||
branch_name: str | None,
|
||||
pr_number: int | None,
|
||||
details: dict,
|
||||
thread_id: int | None = None,
|
||||
) -> int:
|
||||
payload = {"category": category, **details}
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO notifications
|
||||
(recipient_user_id, event_kind, rfc_slug, branch_name, pr_number,
|
||||
thread_id, actor_user_id, payload)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
recipient_user_id,
|
||||
event_kind,
|
||||
rfc_slug,
|
||||
branch_name,
|
||||
pr_number,
|
||||
thread_id,
|
||||
actor_user_id,
|
||||
json.dumps(payload),
|
||||
),
|
||||
)
|
||||
notif_id = cur.lastrowid
|
||||
|
||||
row_payload = _row_payload(notif_id)
|
||||
_schedule_broadcast(recipient_user_id, "notification", row_payload)
|
||||
|
||||
# Email dispatch: §15.4. Held during quiet hours per §15.8; held
|
||||
# during a global opt-out (bounce) per §15.4. Defers to the digest
|
||||
# otherwise per §15.5's exclusion rules.
|
||||
_schedule_email(notif_id, recipient_user_id, event_kind, category, row_payload)
|
||||
|
||||
return notif_id
|
||||
|
||||
|
||||
def _row_payload(notif_id: int) -> dict:
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT n.id, n.event_kind, n.rfc_slug, n.branch_name, n.pr_number,
|
||||
n.thread_id, n.actor_user_id, n.payload, n.created_at, n.read_at,
|
||||
u.display_name AS actor_display, u.gitea_login AS actor_login,
|
||||
r.title AS rfc_title
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON u.id = n.actor_user_id
|
||||
LEFT JOIN cached_rfcs r ON r.slug = n.rfc_slug
|
||||
WHERE n.id = ?
|
||||
""",
|
||||
(notif_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return {}
|
||||
try:
|
||||
extras = json.loads(row["payload"] or "{}")
|
||||
except json.JSONDecodeError:
|
||||
extras = {}
|
||||
return {
|
||||
"id": row["id"],
|
||||
"event_kind": row["event_kind"],
|
||||
"rfc_slug": row["rfc_slug"],
|
||||
"rfc_title": row["rfc_title"],
|
||||
"branch_name": row["branch_name"],
|
||||
"pr_number": row["pr_number"],
|
||||
"thread_id": row["thread_id"],
|
||||
"actor_user_id": row["actor_user_id"],
|
||||
"actor_login": row["actor_login"],
|
||||
"actor_display": row["actor_display"],
|
||||
"created_at": row["created_at"],
|
||||
"read_at": row["read_at"],
|
||||
"category": extras.get("category"),
|
||||
"summary": render_summary(row["event_kind"], row["actor_display"], row["rfc_title"], extras),
|
||||
"extras": extras,
|
||||
}
|
||||
|
||||
|
||||
def render_summary(event_kind: str, actor_display: str | None, rfc_title: str | None, extras: dict) -> str:
|
||||
"""One short sentence shared by the inbox row and the email body
|
||||
per §15.4. Actor is the underlying user per §15.9; null actor renders
|
||||
as "the app" so a system-generated event still reads as a sentence."""
|
||||
actor = actor_display or "the app"
|
||||
title = rfc_title or extras.get("slug") or "this RFC"
|
||||
if event_kind == "proposal_merged":
|
||||
return f"{actor} merged your proposal — {title} is now a super-draft."
|
||||
if event_kind == "proposal_declined":
|
||||
return f"{actor} declined your proposal for {title}."
|
||||
if event_kind == "proposal_opened_on_watched_topic":
|
||||
return f"{actor} proposed a new RFC: {title}."
|
||||
if event_kind == "pr_opened":
|
||||
return f"{actor} opened a PR on {title}."
|
||||
if event_kind == "pr_merged":
|
||||
return f"{actor} merged a PR on {title}."
|
||||
if event_kind == "pr_withdrawn":
|
||||
return f"{actor} withdrew a PR on {title}."
|
||||
if event_kind == "pr_commit_added":
|
||||
return f"{actor} added a commit on {title}."
|
||||
if event_kind == "pr_review_thread_new":
|
||||
return f"{actor} opened a review thread on {title}."
|
||||
if event_kind == "pr_review_thread_reply":
|
||||
return f"{actor} replied to your review thread on {title}."
|
||||
if event_kind == "chat_message_in_participated_thread":
|
||||
return f"{actor} posted a chat message on {title}."
|
||||
if event_kind == "chat_reply_to_my_message":
|
||||
return f"{actor} replied to your message on {title}."
|
||||
if event_kind == "claim_opened":
|
||||
return f"{actor} opened a claim PR on {title}."
|
||||
if event_kind == "graduation_complete":
|
||||
return f"{title} graduated to an active RFC."
|
||||
if event_kind == "super_draft_graduation_ready":
|
||||
return f"{actor} began graduating {title}."
|
||||
if event_kind == "pr_conflict_with_main":
|
||||
return f"{actor} started a resolution branch on {title}."
|
||||
return f"{event_kind} on {title}"
|
||||
|
||||
|
||||
def _schedule_broadcast(user_id: int, event: str, payload: Any) -> None:
|
||||
"""Best-effort SSE push; survives the absence of a running loop so
|
||||
sync test clients still write the row."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
loop.create_task(_broadcast(user_id, event, payload))
|
||||
|
||||
|
||||
def _schedule_email(
|
||||
notif_id: int,
|
||||
recipient_user_id: int,
|
||||
event_kind: str,
|
||||
category: str,
|
||||
payload: dict,
|
||||
) -> None:
|
||||
"""§15.4 dispatch — synchronous so the email_sent_at timestamp lands
|
||||
in the same tick as the notification row, which is what the §15.5
|
||||
digest's exclusion rule 1 keys on. The actual SMTP send happens in
|
||||
`email.py`; this just consults the recipient's preferences and quiet
|
||||
hours, then calls into the adapter."""
|
||||
from . import email as email_mod # avoid import cycle
|
||||
|
||||
email_mod.maybe_send(
|
||||
notif_id=notif_id,
|
||||
recipient_user_id=recipient_user_id,
|
||||
event_kind=event_kind,
|
||||
category=category,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-user mute and watch helpers used by api_notifications
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_inbox(
|
||||
*,
|
||||
user_id: int,
|
||||
unread: bool | None = None,
|
||||
rfc_slug: str | None = None,
|
||||
category: str | None = None,
|
||||
actor_user_id: int | None = None,
|
||||
bundled: bool = False,
|
||||
limit: int = 200,
|
||||
) -> dict:
|
||||
"""§15.2: inbox query. Filter chips are AND-combined.
|
||||
|
||||
When `bundled=True` is set, rows are grouped server-side by
|
||||
(rfc_slug, event_kind) per the §15.2 bundle toggle, with the most
|
||||
recent timestamp on each bundle.
|
||||
"""
|
||||
where = ["recipient_user_id = ?"]
|
||||
args: list[Any] = [user_id]
|
||||
if unread:
|
||||
where.append("read_at IS NULL")
|
||||
if rfc_slug:
|
||||
where.append("rfc_slug = ?")
|
||||
args.append(rfc_slug)
|
||||
if actor_user_id is not None:
|
||||
where.append("actor_user_id = ?")
|
||||
args.append(actor_user_id)
|
||||
rows = db.conn().execute(
|
||||
f"""
|
||||
SELECT n.id, n.event_kind, n.rfc_slug, n.branch_name, n.pr_number,
|
||||
n.thread_id, n.actor_user_id, n.payload, n.created_at, n.read_at,
|
||||
u.display_name AS actor_display, u.gitea_login AS actor_login,
|
||||
r.title AS rfc_title
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON u.id = n.actor_user_id
|
||||
LEFT JOIN cached_rfcs r ON r.slug = n.rfc_slug
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY n.id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(*args, limit),
|
||||
).fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
try:
|
||||
extras = json.loads(row["payload"] or "{}")
|
||||
except json.JSONDecodeError:
|
||||
extras = {}
|
||||
if category and extras.get("category") != category:
|
||||
continue
|
||||
items.append({
|
||||
"id": row["id"],
|
||||
"event_kind": row["event_kind"],
|
||||
"rfc_slug": row["rfc_slug"],
|
||||
"rfc_title": row["rfc_title"],
|
||||
"branch_name": row["branch_name"],
|
||||
"pr_number": row["pr_number"],
|
||||
"thread_id": row["thread_id"],
|
||||
"actor_user_id": row["actor_user_id"],
|
||||
"actor_login": row["actor_login"],
|
||||
"actor_display": row["actor_display"],
|
||||
"created_at": row["created_at"],
|
||||
"read_at": row["read_at"],
|
||||
"category": extras.get("category"),
|
||||
"summary": render_summary(row["event_kind"], row["actor_display"], row["rfc_title"], extras),
|
||||
})
|
||||
|
||||
if bundled:
|
||||
items = _bundle(items)
|
||||
|
||||
unread_count = db.conn().execute(
|
||||
"SELECT COUNT(*) AS c FROM notifications WHERE recipient_user_id = ? AND read_at IS NULL",
|
||||
(user_id,),
|
||||
).fetchone()["c"]
|
||||
|
||||
return {"items": items, "unread_count": unread_count}
|
||||
|
||||
|
||||
def _bundle(items: list[dict]) -> list[dict]:
|
||||
"""§15.2: collapse per-row notifications into per-(RFC, event_kind)
|
||||
bundles; the bundle's representative row is the most-recent
|
||||
constituent, with a `bundled_ids` array carrying the rest."""
|
||||
buckets: dict[tuple, list[dict]] = {}
|
||||
for it in items:
|
||||
key = (it["rfc_slug"], it["event_kind"])
|
||||
buckets.setdefault(key, []).append(it)
|
||||
bundled = []
|
||||
for key, rows in buckets.items():
|
||||
rep = dict(rows[0]) # most recent (sorted DESC above)
|
||||
rep["bundled_ids"] = [r["id"] for r in rows]
|
||||
rep["bundled_count"] = len(rows)
|
||||
bundled.append(rep)
|
||||
bundled.sort(key=lambda r: r["created_at"], reverse=True)
|
||||
return bundled
|
||||
|
||||
|
||||
def mark_read_by_filter(
|
||||
*,
|
||||
user_id: int,
|
||||
unread: bool | None = None,
|
||||
rfc_slug: str | None = None,
|
||||
category: str | None = None,
|
||||
actor_user_id: int | None = None,
|
||||
ids: Iterable[int] | None = None,
|
||||
) -> int:
|
||||
"""`Mark all read` per §15.2 — respects the active filter so the
|
||||
user can mark all churn read without touching personal-direct."""
|
||||
where = ["recipient_user_id = ?", "read_at IS NULL"]
|
||||
args: list[Any] = [user_id]
|
||||
if rfc_slug:
|
||||
where.append("rfc_slug = ?")
|
||||
args.append(rfc_slug)
|
||||
if actor_user_id is not None:
|
||||
where.append("actor_user_id = ?")
|
||||
args.append(actor_user_id)
|
||||
if ids:
|
||||
ids = list(ids)
|
||||
if not ids:
|
||||
return 0
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
where.append(f"id IN ({placeholders})")
|
||||
args.extend(ids)
|
||||
# Category lives inside payload JSON; we filter post-fetch to keep
|
||||
# the SQL portable.
|
||||
rows = db.conn().execute(
|
||||
f"SELECT id, payload FROM notifications WHERE {' AND '.join(where)}",
|
||||
args,
|
||||
).fetchall()
|
||||
selected_ids: list[int] = []
|
||||
for r in rows:
|
||||
if category:
|
||||
try:
|
||||
extras = json.loads(r["payload"] or "{}")
|
||||
except json.JSONDecodeError:
|
||||
extras = {}
|
||||
if extras.get("category") != category:
|
||||
continue
|
||||
selected_ids.append(r["id"])
|
||||
if not selected_ids:
|
||||
return 0
|
||||
placeholders = ",".join("?" * len(selected_ids))
|
||||
db.conn().execute(
|
||||
f"UPDATE notifications SET read_at = datetime('now') WHERE id IN ({placeholders})",
|
||||
selected_ids,
|
||||
)
|
||||
for nid in selected_ids:
|
||||
_schedule_broadcast(user_id, "read", {"id": nid})
|
||||
return len(selected_ids)
|
||||
|
||||
|
||||
def reconcile_seen_advance(
|
||||
*,
|
||||
user_id: int,
|
||||
rfc_slug: str,
|
||||
branch_name: str | None = None,
|
||||
pr_number: int | None = None,
|
||||
) -> int:
|
||||
"""§15.7 reconciler — when a scope cursor advances on visit, mark
|
||||
matching unread notifications read. Called from the chat-seen and
|
||||
pr-seen advance endpoints.
|
||||
"""
|
||||
where = ["recipient_user_id = ?", "read_at IS NULL", "rfc_slug = ?"]
|
||||
args: list[Any] = [user_id, rfc_slug]
|
||||
if pr_number is not None:
|
||||
where.append("pr_number = ?")
|
||||
args.append(pr_number)
|
||||
elif branch_name is not None:
|
||||
where.append("branch_name = ?")
|
||||
args.append(branch_name)
|
||||
rows = db.conn().execute(
|
||||
f"SELECT id FROM notifications WHERE {' AND '.join(where)}",
|
||||
args,
|
||||
).fetchall()
|
||||
ids = [r["id"] for r in rows]
|
||||
if not ids:
|
||||
return 0
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
db.conn().execute(
|
||||
f"UPDATE notifications SET read_at = datetime('now') WHERE id IN ({placeholders})",
|
||||
ids,
|
||||
)
|
||||
for nid in ids:
|
||||
_schedule_broadcast(user_id, "read", {"id": nid})
|
||||
return len(ids)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 90-day decay sweep (§15.6) — called by the digest job's nightly loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def decay_watches() -> int:
|
||||
"""Downgrade `watching` rows whose last_participation_at is older
|
||||
than 90 days to `following`. Explicit and role-implicit rows are
|
||||
exempt; role-implicit isn't stored on the row, so it's a no-op for
|
||||
those. Returns the number of rows downgraded."""
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
UPDATE watches
|
||||
SET state = 'following', last_participation_at = datetime('now')
|
||||
WHERE state = 'watching'
|
||||
AND set_by = 'auto'
|
||||
AND (last_participation_at IS NULL
|
||||
OR datetime(last_participation_at, '+90 days') < datetime('now'))
|
||||
"""
|
||||
)
|
||||
return cur.rowcount or 0
|
||||
Reference in New Issue
Block a user