Slice 6: notifications per §15
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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