@@ -5,6 +5,20 @@ import { sendApnsNotification, type ApnsConfig } from "../utils/apns.js";
55import { generateId as generateIdUtil } from "../utils/id.js" ;
66import { randomUUID } from "../utils/uuid.js" ;
77
8+ /** Presence info stored in browser WebSocket attachments (survives DO hibernation). */
9+ interface BrowserAttachment {
10+ authenticated : boolean ;
11+ tag : string ;
12+ foreground : boolean ;
13+ sessionKey : string | null ;
14+ /** Timestamp (ms) when the session last went to background. */
15+ backgroundAt : number | null ;
16+ }
17+
18+ /** Grace period constants for push notification suppression. */
19+ const BG_GRACE_MS = 15_000 ; // 15 s after going background
20+ const DC_GRACE_MS = 30_000 ; // 30 s after WebSocket disconnect
21+
822/**
923 * ConnectionDO — one Durable Object instance per BotsChat user.
1024 *
@@ -29,8 +43,12 @@ export class ConnectionDO implements DurableObject {
2943 /** Pending resolve for a real-time task.scan.request → task.scan.result round-trip. */
3044 private pendingScanResolve : ( ( tasks : Array < Record < string , unknown > > ) => void ) | null = null ;
3145
32- /** Browser sessions that report themselves in foreground (push notifications are suppressed). */
33- private foregroundSessions = new Set < string > ( ) ;
46+ /**
47+ * Recently disconnected browser sessions — provides a grace period so that
48+ * brief network blips don't immediately trigger push notifications.
49+ * In-memory only; if the DO hibernates, the grace period has expired anyway.
50+ */
51+ private recentDisconnects = new Map < string , number > ( ) ;
3452
3553 /** Timestamp of last accepted OpenClaw WebSocket (in-memory, no storage write). */
3654 private lastOpenClawAcceptedAt = 0 ;
@@ -150,9 +168,13 @@ export class ConnectionDO implements DurableObject {
150168 JSON . stringify ( { type : "openclaw.disconnected" } ) ,
151169 ) ;
152170 }
153- // Clean up foreground tracking for browser sessions
171+ // Disconnect grace: remember recently-disconnected browser sessions so
172+ // push notifications are still suppressed during brief network blips.
154173 if ( tag ?. startsWith ( "browser:" ) ) {
155- this . foregroundSessions . delete ( tag ) ;
174+ const att = ws . deserializeAttachment ( ) as BrowserAttachment | null ;
175+ if ( att ?. foreground ) {
176+ this . recentDisconnects . set ( tag , Date . now ( ) ) ;
177+ }
156178 }
157179 }
158180
@@ -217,7 +239,17 @@ export class ConnectionDO implements DurableObject {
217239
218240 const tag = `browser:${ sessionId } ` ;
219241 this . state . acceptWebSocket ( server , [ tag ] ) ;
220- server . serializeAttachment ( { authenticated : false , tag } ) ;
242+ const att : BrowserAttachment = {
243+ authenticated : false ,
244+ tag,
245+ foreground : false ,
246+ sessionKey : null ,
247+ backgroundAt : null ,
248+ } ;
249+ server . serializeAttachment ( att ) ;
250+
251+ // Clear disconnect grace entry — this session is back.
252+ this . recentDisconnects . delete ( tag ) ;
221253
222254 return new Response ( null , { status : 101 , webSocket : client } ) ;
223255 }
@@ -377,10 +409,10 @@ export class ConnectionDO implements DurableObject {
377409 const { notifyPreview : _stripped , ...msgForBrowser } = msg ;
378410 this . broadcastToBrowsers ( JSON . stringify ( msgForBrowser ) ) ;
379411
380- // Send push notification if no browser session is in foreground
412+ // Send push notification unless a device is (or was recently) in the foreground
381413 if (
382414 ( msg . type === "agent.text" || msg . type === "agent.media" || msg . type === "agent.a2ui" ) &&
383- this . foregroundSessions . size === 0 &&
415+ ! this . shouldSuppressPush ( ) &&
384416 ( this . env . FCM_SERVICE_ACCOUNT_JSON || this . env . APNS_AUTH_KEY )
385417 ) {
386418 this . sendPushNotifications ( msg ) . catch ( ( err ) => {
@@ -393,7 +425,7 @@ export class ConnectionDO implements DurableObject {
393425 ws : WebSocket ,
394426 msg : Record < string , unknown > ,
395427 ) : Promise < void > {
396- const attachment = ws . deserializeAttachment ( ) as { authenticated : boolean ; tag : string } | null ;
428+ const attachment = ws . deserializeAttachment ( ) as BrowserAttachment | null ;
397429
398430 // Handle browser auth — verify JWT token
399431 if ( msg . type === "auth" ) {
@@ -444,15 +476,29 @@ export class ConnectionDO implements DurableObject {
444476 return ;
445477 }
446478
447- // Handle foreground/background state tracking for push notifications
479+ // ---- Presence / focus tracking (stored in WS attachment, hibernation-safe) ----
448480 if ( msg . type === "foreground.enter" ) {
449- const tag = attachment . tag ;
450- if ( tag ) this . foregroundSessions . add ( tag ) ;
481+ ws . serializeAttachment ( {
482+ ...attachment ,
483+ foreground : true ,
484+ sessionKey : ( msg . sessionKey as string ) ?? attachment . sessionKey ?? null ,
485+ backgroundAt : null ,
486+ } satisfies BrowserAttachment ) ;
451487 return ;
452488 }
453489 if ( msg . type === "foreground.leave" ) {
454- const tag = attachment . tag ;
455- if ( tag ) this . foregroundSessions . delete ( tag ) ;
490+ ws . serializeAttachment ( {
491+ ...attachment ,
492+ foreground : false ,
493+ backgroundAt : Date . now ( ) ,
494+ } satisfies BrowserAttachment ) ;
495+ return ;
496+ }
497+ if ( msg . type === "focus.update" ) {
498+ ws . serializeAttachment ( {
499+ ...attachment ,
500+ sessionKey : ( msg . sessionKey as string ) ?? null ,
501+ } satisfies BrowserAttachment ) ;
456502 return ;
457503 }
458504
@@ -668,6 +714,39 @@ export class ConnectionDO implements DurableObject {
668714 }
669715 }
670716
717+ // ---- Presence helpers ----
718+
719+ /**
720+ * Determine whether push notifications should be suppressed because a device
721+ * is (or was very recently) in the foreground.
722+ *
723+ * Checks three layers:
724+ * 1. Any connected browser socket with `foreground === true`
725+ * 2. Background grace: socket went background < BG_GRACE_MS ago
726+ * 3. Disconnect grace: socket disconnected < DC_GRACE_MS ago
727+ */
728+ private shouldSuppressPush ( ) : boolean {
729+ const now = Date . now ( ) ;
730+
731+ // 1 + 2: scan connected browser sockets
732+ const sockets = this . state . getWebSockets ( ) ;
733+ for ( const s of sockets ) {
734+ const att = s . deserializeAttachment ( ) as BrowserAttachment | null ;
735+ if ( ! att || ! att . tag ?. startsWith ( "browser:" ) || ! att . authenticated ) continue ;
736+ if ( att . foreground ) return true ;
737+ if ( att . backgroundAt && now - att . backgroundAt < BG_GRACE_MS ) return true ;
738+ }
739+
740+ // 3: recently disconnected sessions
741+ for ( const [ tag , disconnectedAt ] of this . recentDisconnects ) {
742+ if ( now - disconnectedAt < DC_GRACE_MS ) return true ;
743+ // Expired — prune
744+ this . recentDisconnects . delete ( tag ) ;
745+ }
746+
747+ return false ;
748+ }
749+
671750 // ---- Push notifications ----
672751
673752 /**
0 commit comments