773 lines
31 KiB
Python
773 lines
31 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
import sqlite3
|
|
import time
|
|
from collections.abc import Iterable, Mapping
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from src.private_state import connect_private_sqlite
|
|
from src.state_encryption import (
|
|
PrivateStateCipher,
|
|
decode_private_state_encryption_key,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PushDelivery:
|
|
session_id: str
|
|
subscription: dict
|
|
thread_revisions: tuple[tuple[int, str], ...]
|
|
digest_revisions: tuple[tuple[int, str], ...] = ()
|
|
|
|
@property
|
|
def thread_ids(self) -> tuple[int, ...]:
|
|
return tuple(thread_id for thread_id, _revision in self.thread_revisions)
|
|
|
|
@property
|
|
def digest_ids(self) -> tuple[int, ...]:
|
|
return tuple(thread_id for thread_id, _revision in self.digest_revisions)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DeadlineReminderDevice:
|
|
session_id: str
|
|
subscription: dict
|
|
timezone: str
|
|
reminder_hour: int
|
|
reminder_days: int
|
|
delivered_local_day: str | None
|
|
snoozed_until: float | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StartDayReminderDevice:
|
|
session_id: str
|
|
subscription: dict
|
|
timezone: str
|
|
reminder_hour: int
|
|
delivered_plan_date: str | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FollowingNotificationDevice:
|
|
session_id: str
|
|
subscription: dict
|
|
delivered_fingerprint: str | None
|
|
|
|
|
|
class DisabledPushSubscriptionStore:
|
|
"""No-persistence store used when Web Push is not configured."""
|
|
|
|
def is_subscribed(self, session_id: str) -> bool:
|
|
return False
|
|
|
|
def subscription_for_session(self, session_id: str) -> dict | None:
|
|
return None
|
|
|
|
def deadline_preferences(
|
|
self, session_id: str, *, now: float | None = None
|
|
) -> dict:
|
|
return {
|
|
"enabled": False,
|
|
"timezone": "UTC",
|
|
"reminder_hour": 9,
|
|
"reminder_days": 2,
|
|
"snoozed_until": None,
|
|
}
|
|
|
|
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
|
|
return []
|
|
|
|
def start_day_preferences(self, session_id: str) -> dict:
|
|
return {"enabled": False, "timezone": "UTC", "reminder_hour": 9}
|
|
|
|
def start_day_reminder_devices(self) -> list[StartDayReminderDevice]:
|
|
return []
|
|
|
|
def following_preferences(self, session_id: str) -> dict:
|
|
return {"enabled": False}
|
|
|
|
def following_notification_devices(self) -> list[FollowingNotificationDevice]:
|
|
return []
|
|
|
|
def claim_unseen(self, thread_revisions) -> list[PushDelivery]:
|
|
return []
|
|
|
|
def acquire_dispatch_lease(self, *args, **kwargs) -> bool:
|
|
return False
|
|
|
|
def release_dispatch_lease(self, *args, **kwargs) -> bool:
|
|
return False
|
|
|
|
def snooze_deadline_reminder(self, *args, **kwargs) -> bool:
|
|
return False
|
|
|
|
def upsert(self, *args, **kwargs) -> None:
|
|
raise RuntimeError("push notifications are not configured")
|
|
|
|
def delete_session(self, *args, **kwargs) -> None:
|
|
return None
|
|
|
|
def delete_all(self, *args, **kwargs) -> None:
|
|
return None
|
|
|
|
def set_deadline_preferences(self, *args, **kwargs) -> None:
|
|
return None
|
|
|
|
def clear_deadline_snooze(self, *args, **kwargs) -> None:
|
|
return None
|
|
|
|
def mark_deadline_reminder_delivered(self, *args, **kwargs) -> None:
|
|
return None
|
|
|
|
def set_start_day_preferences(self, *args, **kwargs) -> None:
|
|
return None
|
|
|
|
def mark_start_day_reminder_delivered(self, *args, **kwargs) -> None:
|
|
return None
|
|
|
|
def set_following_preferences(self, *args, **kwargs) -> None:
|
|
return None
|
|
|
|
def mark_following_delivered(self, *args, **kwargs) -> None:
|
|
return None
|
|
|
|
def reconcile_unread(self, *args, **kwargs) -> None:
|
|
return None
|
|
|
|
def mark_digest_pending(self, *args, **kwargs) -> None:
|
|
return None
|
|
|
|
def mark_delivered(self, *args, **kwargs) -> None:
|
|
return None
|
|
|
|
def delivery_health(self, *args, **kwargs) -> dict:
|
|
return {}
|
|
|
|
def mark_delivery_failed(self, *args, **kwargs) -> None:
|
|
return None
|
|
|
|
def mark_delivery_succeeded(self, *args, **kwargs) -> None:
|
|
return None
|
|
|
|
|
|
def build_push_subscription_store(
|
|
path: str | Path, *, push_enabled: bool
|
|
) -> PushSubscriptionStore | DisabledPushSubscriptionStore:
|
|
if not push_enabled:
|
|
return DisabledPushSubscriptionStore()
|
|
return PushSubscriptionStore(path)
|
|
|
|
|
|
def _revisions(
|
|
values: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
|
) -> tuple[tuple[int, str], ...]:
|
|
if isinstance(values, Mapping):
|
|
items = list(values.items())
|
|
else:
|
|
items = []
|
|
for value in values:
|
|
if isinstance(value, tuple):
|
|
items.append(value)
|
|
else:
|
|
items.append((value, ""))
|
|
normalized = {}
|
|
for thread_id, revision in items:
|
|
thread_id = int(thread_id)
|
|
if thread_id > 0:
|
|
normalized[thread_id] = str(revision or "")
|
|
return tuple(sorted(normalized.items()))
|
|
|
|
|
|
class PushSubscriptionStore:
|
|
"""Durable, device-bound Web Push subscriptions and delivery deduplication."""
|
|
|
|
def __init__(self, path: str | Path, *, encryption_key: bytes | None = None):
|
|
self.path = Path(path)
|
|
key = encryption_key
|
|
if key is None:
|
|
key = decode_private_state_encryption_key(
|
|
os.getenv("STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY", "")
|
|
)
|
|
self._encryption_key = key
|
|
self._cipher = PrivateStateCipher(key, store="push-subscriptions")
|
|
with self._connect() as connection:
|
|
connection.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|
session_id TEXT PRIMARY KEY,
|
|
endpoint TEXT NOT NULL UNIQUE,
|
|
subscription_json TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS push_deliveries (
|
|
session_id TEXT NOT NULL,
|
|
thread_id INTEGER NOT NULL,
|
|
revision TEXT NOT NULL DEFAULT '',
|
|
PRIMARY KEY (session_id, thread_id),
|
|
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
|
ON DELETE CASCADE
|
|
);
|
|
CREATE TABLE IF NOT EXISTS push_dispatch_lease (
|
|
channel TEXT PRIMARY KEY,
|
|
owner TEXT NOT NULL,
|
|
expires_at REAL NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS push_digest_pending (
|
|
session_id TEXT NOT NULL,
|
|
thread_id INTEGER NOT NULL,
|
|
revision TEXT NOT NULL DEFAULT '',
|
|
PRIMARY KEY (session_id, thread_id),
|
|
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
|
ON DELETE CASCADE
|
|
);
|
|
CREATE TABLE IF NOT EXISTS push_deadline_preferences (
|
|
session_id TEXT PRIMARY KEY,
|
|
enabled INTEGER NOT NULL DEFAULT 0,
|
|
timezone TEXT NOT NULL DEFAULT 'UTC',
|
|
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
|
reminder_days INTEGER NOT NULL DEFAULT 2,
|
|
delivered_local_day TEXT,
|
|
snoozed_until REAL,
|
|
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
|
ON DELETE CASCADE
|
|
);
|
|
CREATE TABLE IF NOT EXISTS push_delivery_health (
|
|
session_id TEXT NOT NULL,
|
|
channel TEXT NOT NULL,
|
|
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
|
last_attempted_at REAL NOT NULL,
|
|
last_succeeded_at REAL,
|
|
reason TEXT,
|
|
PRIMARY KEY (session_id, channel),
|
|
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
|
ON DELETE CASCADE
|
|
);
|
|
CREATE TABLE IF NOT EXISTS push_start_day_preferences (
|
|
session_id TEXT PRIMARY KEY,
|
|
enabled INTEGER NOT NULL DEFAULT 0,
|
|
timezone TEXT NOT NULL DEFAULT 'UTC',
|
|
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
|
delivered_plan_date TEXT,
|
|
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
|
ON DELETE CASCADE
|
|
);
|
|
CREATE TABLE IF NOT EXISTS push_following_preferences (
|
|
session_id TEXT PRIMARY KEY,
|
|
enabled INTEGER NOT NULL DEFAULT 0,
|
|
delivered_fingerprint TEXT,
|
|
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
|
ON DELETE CASCADE
|
|
);
|
|
"""
|
|
)
|
|
delivery_columns = {
|
|
row[1] for row in connection.execute("PRAGMA table_info(push_deliveries)")
|
|
}
|
|
if "revision" not in delivery_columns:
|
|
connection.execute(
|
|
"ALTER TABLE push_deliveries ADD COLUMN revision TEXT NOT NULL DEFAULT '*'"
|
|
)
|
|
digest_columns = {
|
|
row[1]
|
|
for row in connection.execute("PRAGMA table_info(push_digest_pending)")
|
|
}
|
|
if "revision" not in digest_columns:
|
|
connection.execute(
|
|
"ALTER TABLE push_digest_pending ADD COLUMN revision TEXT NOT NULL DEFAULT '*'"
|
|
)
|
|
lease_columns = {
|
|
row[1] for row in connection.execute("PRAGMA table_info(push_dispatch_lease)")
|
|
}
|
|
if "singleton" in lease_columns:
|
|
connection.executescript(
|
|
"""
|
|
ALTER TABLE push_dispatch_lease RENAME TO push_dispatch_lease_legacy;
|
|
CREATE TABLE push_dispatch_lease (
|
|
channel TEXT PRIMARY KEY,
|
|
owner TEXT NOT NULL,
|
|
expires_at REAL NOT NULL
|
|
);
|
|
INSERT INTO push_dispatch_lease(channel, owner, expires_at)
|
|
SELECT 'unread', owner, expires_at
|
|
FROM push_dispatch_lease_legacy WHERE singleton = 1;
|
|
DROP TABLE push_dispatch_lease_legacy;
|
|
"""
|
|
)
|
|
preference_columns = {
|
|
row[1]
|
|
for row in connection.execute("PRAGMA table_info(push_deadline_preferences)")
|
|
}
|
|
if "reminder_days" not in preference_columns:
|
|
connection.execute(
|
|
"ALTER TABLE push_deadline_preferences ADD COLUMN reminder_days INTEGER NOT NULL DEFAULT 2"
|
|
)
|
|
if "snoozed_until" not in preference_columns:
|
|
connection.execute(
|
|
"ALTER TABLE push_deadline_preferences ADD COLUMN snoozed_until REAL"
|
|
)
|
|
rows = connection.execute(
|
|
"SELECT session_id, endpoint, subscription_json FROM push_subscriptions"
|
|
).fetchall()
|
|
for session_id, endpoint, payload in rows:
|
|
subscription, plaintext = self._cipher.open(payload, binding=session_id)
|
|
if not isinstance(subscription, dict) or not subscription.get("endpoint"):
|
|
raise ValueError("push subscription payload is invalid")
|
|
endpoint_index = self._endpoint_index(subscription["endpoint"])
|
|
if plaintext or endpoint != endpoint_index:
|
|
connection.execute(
|
|
"UPDATE push_subscriptions SET endpoint = ?, subscription_json = ? WHERE session_id = ?",
|
|
(
|
|
endpoint_index,
|
|
self._cipher.seal(subscription, binding=session_id),
|
|
session_id,
|
|
),
|
|
)
|
|
|
|
def _endpoint_index(self, endpoint: str) -> str:
|
|
return hmac.new(
|
|
self._encryption_key,
|
|
b"stackchain:push-endpoint:v1\0" + endpoint.encode(),
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
|
|
def _connect(self):
|
|
connection = connect_private_sqlite(self.path, timeout=2)
|
|
connection.execute("PRAGMA foreign_keys = ON")
|
|
return connection
|
|
|
|
def acquire_dispatch_lease(
|
|
self, owner: str, *, channel: str = "unread", now: float, lease_seconds: float
|
|
) -> bool:
|
|
with self._connect() as connection:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
current = connection.execute(
|
|
"SELECT owner, expires_at FROM push_dispatch_lease WHERE channel = ?",
|
|
(channel,),
|
|
).fetchone()
|
|
if current is not None and current[0] != owner and current[1] > now:
|
|
return False
|
|
connection.execute(
|
|
"""INSERT INTO push_dispatch_lease(channel, owner, expires_at)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(channel) DO UPDATE SET
|
|
owner = excluded.owner, expires_at = excluded.expires_at""",
|
|
(channel, owner, now + lease_seconds),
|
|
)
|
|
return True
|
|
|
|
def release_dispatch_lease(self, owner: str, *, channel: str = "unread") -> bool:
|
|
with self._connect() as connection:
|
|
result = connection.execute(
|
|
"DELETE FROM push_dispatch_lease WHERE channel = ? AND owner = ?",
|
|
(channel, owner),
|
|
)
|
|
return result.rowcount == 1
|
|
|
|
def upsert(self, session_id: str, subscription: dict) -> None:
|
|
endpoint = subscription["endpoint"]
|
|
endpoint_index = self._endpoint_index(endpoint)
|
|
encoded = self._cipher.seal(subscription, binding=session_id)
|
|
with self._connect() as connection:
|
|
connection.execute(
|
|
"DELETE FROM push_subscriptions WHERE endpoint = ?", (endpoint_index,)
|
|
)
|
|
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
|
|
connection.execute(
|
|
"INSERT INTO push_subscriptions(session_id, endpoint, subscription_json) VALUES (?, ?, ?)",
|
|
(session_id, endpoint_index, encoded),
|
|
)
|
|
|
|
def delete_session(self, session_id: str) -> None:
|
|
with self._connect() as connection:
|
|
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
|
|
|
|
def delete_all(self) -> None:
|
|
with self._connect() as connection:
|
|
connection.execute("DELETE FROM push_subscriptions")
|
|
|
|
def is_subscribed(self, session_id: str) -> bool:
|
|
with self._connect() as connection:
|
|
return connection.execute(
|
|
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
|
|
).fetchone() is not None
|
|
|
|
def subscription_for_session(self, session_id: str) -> dict | None:
|
|
with self._connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT subscription_json FROM push_subscriptions WHERE session_id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
return self._open_subscription(session_id, row[0]) if row else None
|
|
|
|
def delivery_health(self, session_id: str) -> dict:
|
|
with self._connect() as connection:
|
|
rows = connection.execute(
|
|
"""SELECT channel, consecutive_failures, last_attempted_at,
|
|
last_succeeded_at, reason
|
|
FROM push_delivery_health WHERE session_id = ? ORDER BY channel""",
|
|
(session_id,),
|
|
).fetchall()
|
|
return {
|
|
row[0]: {
|
|
"state": "degraded" if row[1] else "healthy",
|
|
"consecutive_failures": row[1],
|
|
"last_attempted_at": row[2],
|
|
"last_succeeded_at": row[3],
|
|
"reason": row[4],
|
|
}
|
|
for row in rows
|
|
}
|
|
|
|
def mark_delivery_failed(
|
|
self, session_id: str, channel: str, reason: str, *, now: float | None = None
|
|
) -> None:
|
|
attempted_at = time.time() if now is None else now
|
|
with self._connect() as connection:
|
|
connection.execute(
|
|
"""INSERT INTO push_delivery_health(
|
|
session_id, channel, consecutive_failures, last_attempted_at, reason
|
|
) VALUES (?, ?, 1, ?, ?)
|
|
ON CONFLICT(session_id, channel) DO UPDATE SET
|
|
consecutive_failures = consecutive_failures + 1,
|
|
last_attempted_at = excluded.last_attempted_at,
|
|
reason = excluded.reason""",
|
|
(session_id, channel, attempted_at, reason),
|
|
)
|
|
|
|
def mark_delivery_succeeded(
|
|
self, session_id: str, channel: str, *, now: float | None = None
|
|
) -> None:
|
|
attempted_at = time.time() if now is None else now
|
|
with self._connect() as connection:
|
|
connection.execute(
|
|
"""INSERT INTO push_delivery_health(
|
|
session_id, channel, consecutive_failures, last_attempted_at,
|
|
last_succeeded_at, reason
|
|
) VALUES (?, ?, 0, ?, ?, NULL)
|
|
ON CONFLICT(session_id, channel) DO UPDATE SET
|
|
consecutive_failures = 0,
|
|
last_attempted_at = excluded.last_attempted_at,
|
|
last_succeeded_at = excluded.last_succeeded_at,
|
|
reason = NULL""",
|
|
(session_id, channel, attempted_at, attempted_at),
|
|
)
|
|
|
|
def set_deadline_preferences(
|
|
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int,
|
|
reminder_days: int = 2,
|
|
) -> None:
|
|
with self._connect() as connection:
|
|
connection.execute(
|
|
"""INSERT INTO push_deadline_preferences(
|
|
session_id, enabled, timezone, reminder_hour, reminder_days
|
|
) VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(session_id) DO UPDATE SET
|
|
enabled = excluded.enabled,
|
|
timezone = excluded.timezone,
|
|
reminder_hour = excluded.reminder_hour,
|
|
reminder_days = excluded.reminder_days""",
|
|
(session_id, int(enabled), timezone, reminder_hour, reminder_days),
|
|
)
|
|
|
|
def deadline_preferences(self, session_id: str, *, now: float | None = None) -> dict:
|
|
with self._connect() as connection:
|
|
row = connection.execute(
|
|
"""SELECT enabled, timezone, reminder_hour, reminder_days, snoozed_until
|
|
FROM push_deadline_preferences WHERE session_id = ?""",
|
|
(session_id,),
|
|
).fetchone()
|
|
current_time = time.time() if now is None else now
|
|
snoozed_until = row[4] if row and row[4] and row[4] > current_time else None
|
|
return {
|
|
"enabled": bool(row[0]) if row else False,
|
|
"timezone": row[1] if row else "UTC",
|
|
"reminder_hour": row[2] if row else 9,
|
|
"reminder_days": row[3] if row else 2,
|
|
"snoozed_until": snoozed_until,
|
|
}
|
|
|
|
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
|
|
with self._connect() as connection:
|
|
rows = connection.execute(
|
|
"""SELECT s.session_id, s.subscription_json, p.timezone,
|
|
p.reminder_hour, p.reminder_days, p.delivered_local_day,
|
|
p.snoozed_until
|
|
FROM push_subscriptions s
|
|
JOIN push_deadline_preferences p ON p.session_id = s.session_id
|
|
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
|
).fetchall()
|
|
return [
|
|
DeadlineReminderDevice(
|
|
row[0],
|
|
self._open_subscription(row[0], row[1]),
|
|
row[2],
|
|
row[3],
|
|
row[4],
|
|
row[5],
|
|
row[6],
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
def snooze_deadline_reminder(
|
|
self, session_id: str, *, now: float, delay_seconds: int = 3_600
|
|
) -> bool:
|
|
with self._connect() as connection:
|
|
result = connection.execute(
|
|
"""UPDATE push_deadline_preferences SET snoozed_until = ?
|
|
WHERE session_id = ? AND enabled = 1""",
|
|
(now + delay_seconds, session_id),
|
|
)
|
|
return result.rowcount == 1
|
|
|
|
def clear_deadline_snooze(self, session_id: str) -> None:
|
|
with self._connect() as connection:
|
|
connection.execute(
|
|
"""UPDATE push_deadline_preferences SET snoozed_until = NULL
|
|
WHERE session_id = ?""",
|
|
(session_id,),
|
|
)
|
|
|
|
def mark_deadline_reminder_delivered(self, session_id: str, local_day: str) -> None:
|
|
with self._connect() as connection:
|
|
connection.execute(
|
|
"""UPDATE push_deadline_preferences
|
|
SET delivered_local_day = ?, snoozed_until = NULL
|
|
WHERE session_id = ? AND enabled = 1""",
|
|
(local_day, session_id),
|
|
)
|
|
|
|
def set_start_day_preferences(
|
|
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int
|
|
) -> None:
|
|
with self._connect() as connection:
|
|
connection.execute(
|
|
"""INSERT INTO push_start_day_preferences(
|
|
session_id, enabled, timezone, reminder_hour
|
|
) VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(session_id) DO UPDATE SET
|
|
enabled = excluded.enabled,
|
|
timezone = excluded.timezone,
|
|
reminder_hour = excluded.reminder_hour""",
|
|
(session_id, int(enabled), timezone, reminder_hour),
|
|
)
|
|
|
|
def start_day_preferences(self, session_id: str) -> dict:
|
|
with self._connect() as connection:
|
|
row = connection.execute(
|
|
"""SELECT enabled, timezone, reminder_hour
|
|
FROM push_start_day_preferences WHERE session_id = ?""",
|
|
(session_id,),
|
|
).fetchone()
|
|
return {
|
|
"enabled": bool(row[0]) if row else False,
|
|
"timezone": row[1] if row else "UTC",
|
|
"reminder_hour": row[2] if row else 9,
|
|
}
|
|
|
|
def start_day_reminder_devices(self) -> list[StartDayReminderDevice]:
|
|
with self._connect() as connection:
|
|
rows = connection.execute(
|
|
"""SELECT s.session_id, s.subscription_json, p.timezone,
|
|
p.reminder_hour, p.delivered_plan_date
|
|
FROM push_subscriptions s
|
|
JOIN push_start_day_preferences p ON p.session_id = s.session_id
|
|
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
|
).fetchall()
|
|
return [
|
|
StartDayReminderDevice(
|
|
row[0], self._open_subscription(row[0], row[1]), row[2], row[3], row[4]
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
def mark_start_day_reminder_delivered(
|
|
self, session_id: str, plan_date: str
|
|
) -> None:
|
|
with self._connect() as connection:
|
|
connection.execute(
|
|
"""UPDATE push_start_day_preferences SET delivered_plan_date = ?
|
|
WHERE session_id = ? AND enabled = 1""",
|
|
(plan_date, session_id),
|
|
)
|
|
|
|
def set_following_preferences(self, session_id: str, *, enabled: bool) -> None:
|
|
with self._connect() as connection:
|
|
connection.execute(
|
|
"""INSERT INTO push_following_preferences(session_id, enabled)
|
|
VALUES (?, ?)
|
|
ON CONFLICT(session_id) DO UPDATE SET enabled = excluded.enabled""",
|
|
(session_id, int(enabled)),
|
|
)
|
|
|
|
def following_preferences(self, session_id: str) -> dict:
|
|
with self._connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT enabled FROM push_following_preferences WHERE session_id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
return {"enabled": bool(row[0]) if row else False}
|
|
|
|
def following_notification_devices(self) -> list[FollowingNotificationDevice]:
|
|
with self._connect() as connection:
|
|
rows = connection.execute(
|
|
"""SELECT s.session_id, s.subscription_json, p.delivered_fingerprint
|
|
FROM push_subscriptions s
|
|
JOIN push_following_preferences p ON p.session_id = s.session_id
|
|
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
|
).fetchall()
|
|
return [
|
|
FollowingNotificationDevice(
|
|
row[0], self._open_subscription(row[0], row[1]), row[2]
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
def mark_following_delivered(self, session_id: str, fingerprint: str) -> None:
|
|
with self._connect() as connection:
|
|
connection.execute(
|
|
"""UPDATE push_following_preferences SET delivered_fingerprint = ?
|
|
WHERE session_id = ? AND enabled = 1""",
|
|
(fingerprint, session_id),
|
|
)
|
|
|
|
def claim_unseen(
|
|
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
|
|
) -> list[PushDelivery]:
|
|
candidates = _revisions(thread_revisions)
|
|
if not candidates:
|
|
return []
|
|
with self._connect() as connection:
|
|
rows = connection.execute(
|
|
"SELECT session_id, subscription_json FROM push_subscriptions ORDER BY session_id"
|
|
).fetchall()
|
|
deliveries = []
|
|
for session_id, encoded in rows:
|
|
delivered = {
|
|
row[0]: row[1]
|
|
for row in connection.execute(
|
|
"SELECT thread_id, revision FROM push_deliveries WHERE session_id = ?",
|
|
(session_id,),
|
|
)
|
|
}
|
|
for thread_id, revision in candidates:
|
|
if delivered.get(thread_id) == "*":
|
|
connection.execute(
|
|
"""UPDATE push_deliveries SET revision = ?
|
|
WHERE session_id = ? AND thread_id = ?""",
|
|
(revision, session_id, thread_id),
|
|
)
|
|
delivered[thread_id] = revision
|
|
unseen = tuple(
|
|
candidate
|
|
for candidate in candidates
|
|
if (
|
|
candidate[0] not in delivered
|
|
or (
|
|
bool(candidate[1])
|
|
and (
|
|
not delivered[candidate[0]]
|
|
or candidate[1] > delivered[candidate[0]]
|
|
)
|
|
)
|
|
)
|
|
)
|
|
if unseen:
|
|
pending = {
|
|
row[0]: row[1]
|
|
for row in connection.execute(
|
|
"SELECT thread_id, revision FROM push_digest_pending WHERE session_id = ?",
|
|
(session_id,),
|
|
)
|
|
}
|
|
digest_revisions = []
|
|
for thread_id, revision in unseen:
|
|
if pending.get(thread_id) in {revision, "*"}:
|
|
digest_revisions.append((thread_id, revision))
|
|
if pending[thread_id] == "*":
|
|
connection.execute(
|
|
"""UPDATE push_digest_pending SET revision = ?
|
|
WHERE session_id = ? AND thread_id = ?""",
|
|
(revision, session_id, thread_id),
|
|
)
|
|
deliveries.append(
|
|
PushDelivery(
|
|
session_id,
|
|
self._open_subscription(session_id, encoded),
|
|
unseen,
|
|
tuple(digest_revisions),
|
|
)
|
|
)
|
|
return deliveries
|
|
|
|
def _open_subscription(self, session_id: str, payload: str) -> dict:
|
|
subscription, _plaintext = self._cipher.open(payload, binding=session_id)
|
|
if not isinstance(subscription, dict):
|
|
raise ValueError("push subscription payload is invalid")
|
|
return subscription
|
|
|
|
def reconcile_unread(self, thread_ids: Iterable[int]) -> None:
|
|
"""Prune per-device checkpoints that are absent from a complete snapshot."""
|
|
unread_ids = tuple(sorted({int(value) for value in thread_ids if int(value) > 0}))
|
|
with self._connect() as connection:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
connection.execute(
|
|
"CREATE TEMP TABLE current_unread(thread_id INTEGER PRIMARY KEY)"
|
|
)
|
|
connection.executemany(
|
|
"INSERT INTO current_unread(thread_id) VALUES (?)",
|
|
((thread_id,) for thread_id in unread_ids),
|
|
)
|
|
connection.execute(
|
|
"""DELETE FROM push_deliveries
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM current_unread
|
|
WHERE current_unread.thread_id = push_deliveries.thread_id
|
|
)"""
|
|
)
|
|
connection.execute(
|
|
"""DELETE FROM push_digest_pending
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM current_unread
|
|
WHERE current_unread.thread_id = push_digest_pending.thread_id
|
|
)"""
|
|
)
|
|
|
|
def mark_digest_pending(
|
|
self,
|
|
session_id: str,
|
|
thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
|
) -> None:
|
|
values = _revisions(thread_revisions)
|
|
with self._connect() as connection:
|
|
connection.executemany(
|
|
"""INSERT INTO push_digest_pending(session_id, thread_id, revision)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(session_id, thread_id) DO UPDATE SET
|
|
revision = excluded.revision""",
|
|
((session_id, thread_id, revision) for thread_id, revision in values),
|
|
)
|
|
|
|
def mark_delivered(
|
|
self,
|
|
session_id: str,
|
|
thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
|
) -> None:
|
|
values = _revisions(thread_revisions)
|
|
with self._connect() as connection:
|
|
connection.executemany(
|
|
"""INSERT INTO push_deliveries(session_id, thread_id, revision)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(session_id, thread_id) DO UPDATE SET
|
|
revision = excluded.revision""",
|
|
((session_id, thread_id, revision) for thread_id, revision in values),
|
|
)
|
|
connection.executemany(
|
|
"DELETE FROM push_digest_pending WHERE session_id = ? AND thread_id = ?",
|
|
((session_id, thread_id) for thread_id, _revision in values),
|
|
)
|