feat: notify pending Human Gate decisions (Closes #1437)
This commit is contained in:
1 parent
ad15f15e3c
commit
7158e00b97
13 files changed
+494
-17
No files matched your search
+52
@@ -62,6 +62,7 @@ from src.push_notifications import (
|
||||
PushConfiguration,
|
||||
dispatch_deadline_reminders,
|
||||
dispatch_following_changes,
|
||||
dispatch_human_gate_changes,
|
||||
dispatch_start_day_reminders,
|
||||
dispatch_unread_updates,
|
||||
send_web_push,
|
||||
@@ -142,6 +143,18 @@ async def _following_push_snapshot() -> dict:
|
||||
return await get_following(Response())
|
||||
|
||||
|
||||
async def _human_gate_push_snapshot() -> dict:
|
||||
user = await current_user()
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if not isinstance(login, str) or not login:
|
||||
return {"complete": False}
|
||||
result = await asyncio.to_thread(
|
||||
_human_gate_store().list, login, state="pending", limit=100
|
||||
)
|
||||
items = result.get("items", []) if isinstance(result, dict) else []
|
||||
return {"complete": True, "count": len(items)}
|
||||
|
||||
|
||||
async def _identity_bound_push_session_statuses(
|
||||
management_ids: list[str],
|
||||
) -> dict[str, str]:
|
||||
@@ -207,6 +220,17 @@ async def _push_poll_loop() -> None:
|
||||
max_concurrency=max_concurrency,
|
||||
)
|
||||
|
||||
async def dispatch_human_gates() -> None:
|
||||
await dispatch_human_gate_changes(
|
||||
_push_subscription_store,
|
||||
_push_configuration(),
|
||||
_human_gate_push_snapshot,
|
||||
session_statuses=_identity_bound_push_session_statuses,
|
||||
send_timeout_seconds=send_timeout,
|
||||
lease_seconds=lease_seconds,
|
||||
max_concurrency=max_concurrency,
|
||||
)
|
||||
|
||||
async def dispatch_start_day() -> None:
|
||||
await dispatch_start_day_reminders(
|
||||
_push_subscription_store,
|
||||
@@ -221,6 +245,7 @@ async def _push_poll_loop() -> None:
|
||||
channel_tasks = (
|
||||
asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)),
|
||||
asyncio.create_task(_push_channel_loop(dispatch_following, interval=interval)),
|
||||
asyncio.create_task(_push_channel_loop(dispatch_human_gates, interval=interval)),
|
||||
asyncio.create_task(
|
||||
_push_channel_loop(dispatch_deadlines, interval=deadline_interval)
|
||||
),
|
||||
@@ -537,6 +562,10 @@ class FollowingNotificationPayload(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class HumanGateNotificationPayload(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class QuietHoursPayload(BaseModel):
|
||||
enabled: bool
|
||||
start: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
|
||||
@@ -2712,6 +2741,9 @@ async def push_status(request: Request):
|
||||
following_preferences = await asyncio.to_thread(
|
||||
_push_subscription_store.following_preferences, device_id
|
||||
)
|
||||
human_gate_preferences = await asyncio.to_thread(
|
||||
_push_subscription_store.human_gate_preferences, device_id
|
||||
)
|
||||
quiet_hours = await asyncio.to_thread(
|
||||
_push_subscription_store.quiet_hours, device_id
|
||||
)
|
||||
@@ -2731,6 +2763,7 @@ async def push_status(request: Request):
|
||||
"start_day_timezone": start_day_preferences["timezone"],
|
||||
"start_day_reminder_hour": start_day_preferences["reminder_hour"],
|
||||
"following_enabled": following_preferences["enabled"],
|
||||
"human_gates_enabled": human_gate_preferences["enabled"],
|
||||
"quiet_hours_enabled": quiet_hours["enabled"],
|
||||
"quiet_hours_start": quiet_hours["start"],
|
||||
"quiet_hours_end": quiet_hours["end"],
|
||||
@@ -2902,6 +2935,25 @@ async def update_following_notifications(
|
||||
return {"following_enabled": payload.enabled}
|
||||
|
||||
|
||||
@app.put("/api/v1/push-subscription/human-gates")
|
||||
async def update_human_gate_notifications(
|
||||
payload: HumanGateNotificationPayload, request: Request
|
||||
):
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
request.state.dashboard_session
|
||||
)
|
||||
if payload.enabled and not await asyncio.to_thread(
|
||||
_push_subscription_store.is_subscribed, device_id
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="Enable device notifications first")
|
||||
await asyncio.to_thread(
|
||||
_push_subscription_store.set_human_gate_preferences,
|
||||
device_id,
|
||||
enabled=payload.enabled,
|
||||
)
|
||||
return {"human_gates_enabled": payload.enabled}
|
||||
|
||||
|
||||
@app.put("/api/v1/push-subscription/quiet-hours")
|
||||
async def update_quiet_hours(payload: QuietHoursPayload, request: Request):
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
|
||||
@@ -240,6 +240,104 @@ async def dispatch_following_changes(
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="following")
|
||||
|
||||
|
||||
async def dispatch_human_gate_changes(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
pending_gates: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
|
||||
lease_seconds: float = 60.0,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
max_concurrency: int = 8,
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
"""Notify opted-in active devices when the pending Human Gate count changes."""
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
owner = secrets.token_urlsafe(18)
|
||||
acquired = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="human-gates",
|
||||
now=time.time() if now is None else now,
|
||||
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||
)
|
||||
if not acquired:
|
||||
return 0
|
||||
try:
|
||||
devices = await asyncio.to_thread(store.human_gate_notification_devices, now=now)
|
||||
if not devices:
|
||||
return 0
|
||||
snapshot = await pending_gates()
|
||||
if not isinstance(snapshot, dict) or snapshot.get("complete") is False:
|
||||
return 0
|
||||
count = snapshot.get("count")
|
||||
if not isinstance(count, int) or isinstance(count, bool) or count < 0:
|
||||
return 0
|
||||
count = min(count, 50)
|
||||
pending = [device for device in devices if device.delivered_count != count]
|
||||
if not pending:
|
||||
return 0
|
||||
if session_statuses is not None:
|
||||
try:
|
||||
statuses = await session_statuses([device.session_id for device in pending])
|
||||
except Exception:
|
||||
return 0
|
||||
for device in pending:
|
||||
if statuses.get(device.session_id) != "active":
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
pending = [device for device in pending if statuses.get(device.session_id) == "active"]
|
||||
if count == 0:
|
||||
await asyncio.gather(*(
|
||||
asyncio.to_thread(store.mark_human_gate_delivered, device.session_id, 0)
|
||||
for device in pending
|
||||
))
|
||||
return 0
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def dispatch_device(device) -> int:
|
||||
async with semaphore:
|
||||
payload = json.dumps({
|
||||
"title": f"{count} release decision{' is' if count == 1 else 's are'} waiting",
|
||||
"body": f"Open Human Gates to review {'it' if count == 1 else 'them'}.",
|
||||
"route": "#/my-work/human-gates",
|
||||
"tag": f"stackchain-human-gates-{count}",
|
||||
"human_gate_count": count,
|
||||
}, separators=(",", ":"))
|
||||
try:
|
||||
operation = (
|
||||
send(device.subscription, payload)
|
||||
if send is not None
|
||||
else send_web_push(device.subscription, payload, configuration)
|
||||
)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception as error:
|
||||
status = getattr(getattr(error, "response", None), "status_code", None)
|
||||
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed, device.session_id, "human-gates",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
return 0
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_succeeded, device.session_id, "human-gates"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_human_gate_delivered, device.session_id, count
|
||||
)
|
||||
return 1
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(dispatch_device(device) for device in pending), return_exceptions=True
|
||||
)
|
||||
return sum(result for result in results if isinstance(result, int))
|
||||
finally:
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="human-gates")
|
||||
|
||||
|
||||
async def dispatch_unread_updates(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
|
||||
@@ -63,6 +63,14 @@ class FollowingNotificationDevice:
|
||||
catch_up: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HumanGateNotificationDevice:
|
||||
session_id: str
|
||||
subscription: dict
|
||||
delivered_count: int
|
||||
catch_up: bool = False
|
||||
|
||||
|
||||
class DisabledPushSubscriptionStore:
|
||||
"""No-persistence store used when Web Push is not configured."""
|
||||
|
||||
@@ -95,12 +103,18 @@ class DisabledPushSubscriptionStore:
|
||||
def following_preferences(self, session_id: str) -> dict:
|
||||
return {"enabled": False}
|
||||
|
||||
def human_gate_preferences(self, session_id: str) -> dict:
|
||||
return {"enabled": False}
|
||||
|
||||
def quiet_hours(self, session_id: str) -> dict:
|
||||
return {"enabled": False, "start": "22:00", "end": "07:00", "timezone": "UTC"}
|
||||
|
||||
def following_notification_devices(self, *, now: float | None = None) -> list[FollowingNotificationDevice]:
|
||||
return []
|
||||
|
||||
def human_gate_notification_devices(self, *, now: float | None = None) -> list[HumanGateNotificationDevice]:
|
||||
return []
|
||||
|
||||
def claim_unseen(self, thread_revisions, *, now: float | None = None) -> list[PushDelivery]:
|
||||
return []
|
||||
|
||||
@@ -140,12 +154,18 @@ class DisabledPushSubscriptionStore:
|
||||
def set_following_preferences(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def set_human_gate_preferences(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def set_quiet_hours(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def mark_following_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def mark_human_gate_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def reconcile_unread(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
@@ -282,6 +302,13 @@ class PushSubscriptionStore:
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_human_gate_preferences (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
delivered_count INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_quiet_hours (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -642,6 +669,23 @@ class PushSubscriptionStore:
|
||||
).fetchone()
|
||||
return {"enabled": bool(row[0]) if row else False}
|
||||
|
||||
def set_human_gate_preferences(self, session_id: str, *, enabled: bool) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_human_gate_preferences(session_id, enabled)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET enabled = excluded.enabled""",
|
||||
(session_id, int(enabled)),
|
||||
)
|
||||
|
||||
def human_gate_preferences(self, session_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT enabled FROM push_human_gate_preferences WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return {"enabled": bool(row[0]) if row else False}
|
||||
|
||||
def set_quiet_hours(
|
||||
self, session_id: str, *, enabled: bool, start: str, end: str, timezone: str
|
||||
) -> None:
|
||||
@@ -713,6 +757,46 @@ class PushSubscriptionStore:
|
||||
(session_id,),
|
||||
)
|
||||
|
||||
def human_gate_notification_devices(
|
||||
self, *, now: float | None = None
|
||||
) -> list[HumanGateNotificationDevice]:
|
||||
checked_at = time.time() if now is None else now
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""SELECT s.session_id, s.subscription_json, p.delivered_count,
|
||||
q.enabled, q.start_time, q.end_time, q.timezone, q.suppressed
|
||||
FROM push_subscriptions s
|
||||
JOIN push_human_gate_preferences p ON p.session_id = s.session_id
|
||||
LEFT JOIN push_quiet_hours q ON q.session_id = s.session_id
|
||||
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
||||
).fetchall()
|
||||
devices = []
|
||||
for row in rows:
|
||||
if row[3] and _inside_quiet_hours(
|
||||
now=checked_at, start=row[4], end=row[5], timezone=row[6]
|
||||
):
|
||||
connection.execute(
|
||||
"UPDATE push_quiet_hours SET suppressed = 1 WHERE session_id = ?",
|
||||
(row[0],),
|
||||
)
|
||||
continue
|
||||
devices.append(HumanGateNotificationDevice(
|
||||
row[0], self._open_subscription(row[0], row[1]), row[2], bool(row[7])
|
||||
))
|
||||
return devices
|
||||
|
||||
def mark_human_gate_delivered(self, session_id: str, count: int) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""UPDATE push_human_gate_preferences SET delivered_count = ?
|
||||
WHERE session_id = ? AND enabled = 1""",
|
||||
(count, session_id),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE push_quiet_hours SET suppressed = 0 WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
|
||||
def claim_unseen(
|
||||
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
||||
*, now: float | None = None,
|
||||
|
||||
Reference in new issue
Block a user