Push notification dedupe: one message = one notification — an offline message was notified twice (FCM push, then again when the app synced the outbox and mirrored the replayed frames). Fix: the gateway records the highest outbox cursor delivered per device via push (devices.last_pushed_cursor, advanced only on successful send) and returns it in hello.ack; sync-replayed frames carry their outbox cursor in the envelope; the app skips system notifications for replayed frames at/below the watermark (live frames never suppressed — that is the case where no push fired). Also: 5s per-chat push coalescing so a cron delivery (notification frame + message frame) pushes once, and the FCM handler no longer posts a redundant notification (skips when WS is connected or FCM already displayed the notification payload; data-only messages are the exception). Docs: frames.schema.json, 04-wire-protocol.md, 08-push.md §8.8
This commit is contained in:
1 parent
acd5fb4ad0
commit
9f3f9842c8
11 files changed
+301
-98
No files matched your search
+54
-11
@@ -17,7 +17,7 @@ import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -31,7 +31,7 @@ def generate_token() -> str:
|
||||
return secrets.token_hex(TOKEN_BYTES)
|
||||
|
||||
|
||||
def verify_token(provided: Optional[str], expected: Optional[str]) -> bool:
|
||||
def verify_token(provided: str | None, expected: str | None) -> bool:
|
||||
"""Constant-time token comparison (never time-leaks the token)."""
|
||||
if not provided or not expected:
|
||||
return False
|
||||
@@ -65,6 +65,7 @@ def pairing_url(host: str, port: int, secure: bool = False) -> str:
|
||||
# Device registry (SQLite)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DeviceRegistry:
|
||||
"""Persistent device registry under ``get_hermes_home()/"android"``.
|
||||
|
||||
@@ -88,20 +89,32 @@ class DeviceRegistry:
|
||||
caps TEXT NOT NULL DEFAULT '{}',
|
||||
fcm_token TEXT,
|
||||
ntfy_topic TEXT,
|
||||
last_pushed_cursor INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen REAL NOT NULL DEFAULT 0,
|
||||
created REAL NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
# M5: migrate pre-push-cursor databases (the column carries the
|
||||
# highest outbox cursor already delivered to the device via the
|
||||
# push backend; hello.ack returns it for notification dedupe).
|
||||
cols = {
|
||||
r["name"]
|
||||
for r in self._conn.execute("PRAGMA table_info(devices)").fetchall()
|
||||
}
|
||||
if "last_pushed_cursor" not in cols:
|
||||
self._conn.execute(
|
||||
"ALTER TABLE devices ADD COLUMN last_pushed_cursor INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
device_id: str,
|
||||
name: str,
|
||||
caps: Optional[Dict[str, Any]] = None,
|
||||
fcm_token: Optional[str] = None,
|
||||
ntfy_topic: Optional[str] = None,
|
||||
caps: dict[str, Any] | None = None,
|
||||
fcm_token: str | None = None,
|
||||
ntfy_topic: str | None = None,
|
||||
) -> None:
|
||||
now = time.time()
|
||||
caps_json = json.dumps(caps or {}, separators=(",", ":"))
|
||||
@@ -125,8 +138,8 @@ class DeviceRegistry:
|
||||
def update_push_tokens(
|
||||
self,
|
||||
device_id: str,
|
||||
fcm_token: Optional[str] = None,
|
||||
ntfy_topic: Optional[str] = None,
|
||||
fcm_token: str | None = None,
|
||||
ntfy_topic: str | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
@@ -149,14 +162,43 @@ class DeviceRegistry:
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def get(self, device_id: str) -> Optional[Dict[str, Any]]:
|
||||
def update_push_cursor(self, device_id: str, cursor: int) -> None:
|
||||
"""Advance the device's last-pushed cursor (monotonic; never
|
||||
regresses). Called after a successful push send."""
|
||||
try:
|
||||
cursor = max(0, int(cursor or 0))
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE devices SET last_pushed_cursor = MAX(last_pushed_cursor, ?)
|
||||
WHERE device_id = ?
|
||||
""",
|
||||
(cursor, device_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def last_pushed_cursor(self, device_id: str) -> int:
|
||||
"""Highest outbox cursor pushed to this device (0 = never/unknown)."""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT last_pushed_cursor FROM devices WHERE device_id = ?",
|
||||
(device_id,),
|
||||
).fetchone()
|
||||
try:
|
||||
return int(row["last_pushed_cursor"]) if row else 0
|
||||
except (TypeError, ValueError, KeyError, IndexError):
|
||||
return 0
|
||||
|
||||
def get(self, device_id: str) -> dict[str, Any] | None:
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM devices WHERE device_id = ?", (device_id,)
|
||||
).fetchone()
|
||||
return _row_to_device(row) if row else None
|
||||
|
||||
def list(self) -> List[Dict[str, Any]]:
|
||||
def list(self) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM devices ORDER BY last_seen DESC"
|
||||
@@ -171,7 +213,7 @@ class DeviceRegistry:
|
||||
pass
|
||||
|
||||
|
||||
def _row_to_device(row: sqlite3.Row) -> Dict[str, Any]:
|
||||
def _row_to_device(row: sqlite3.Row) -> dict[str, Any]:
|
||||
try:
|
||||
caps = json.loads(row["caps"] or "{}")
|
||||
if not isinstance(caps, dict):
|
||||
@@ -184,6 +226,7 @@ def _row_to_device(row: sqlite3.Row) -> Dict[str, Any]:
|
||||
"caps": caps,
|
||||
"fcm_token": row["fcm_token"],
|
||||
"ntfy_topic": row["ntfy_topic"],
|
||||
"last_pushed_cursor": row["last_pushed_cursor"] or 0,
|
||||
"last_seen": row["last_seen"],
|
||||
"created": row["created"],
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user