@@ -141,28 +141,54 @@ export async function identifyBotSuspects(params: {
141141 agentDiversity . map ( ( a ) => [ a . user_id ! , Number ( a . distinctAgents24h ) ] ) ,
142142 )
143143
144- // Max inter-message quiet gap in the 24h window (in hours). A gap ≥ 4h is
145- // a strong "user slept" counter-signal — bots don't take circadian breaks.
146- // Uses LAG() so it needs a CTE; run as raw SQL.
144+ // Largest gap of usage (in hours) within the observation window — where
145+ // the window is bounded by GREATEST(user.created_at, now - 24h). For each
146+ // user we consider three kinds of gap: window_start → first msg, gaps
147+ // between consecutive msgs, and last msg → now. Max of those is the
148+ // quiet gap.
149+ //
150+ // Clipping the window to signup matters: a 0.2d-old account can only
151+ // plausibly have a gap up to its age. Without the clip, LAG() on an empty
152+ // pre-window history would silently omit any leading-boundary gap, so a
153+ // fresh bot with dense activity reads as "low quiet gap" correctly — but
154+ // for heavy accounts that only started hitting us within the last few
155+ // hours, we also want to count post-activity quiet time toward the gap.
156+ const nowIso = now . toISOString ( )
147157 const quietGaps = await db . execute ( sql `
148- WITH ordered AS (
149- SELECT user_id, finished_at ,
150- LAG(finished_at) OVER (PARTITION BY user_id ORDER BY finished_at ) AS prev
151- FROM ${ schema . message }
152- WHERE user_id IN (${ sql . join (
158+ WITH bounds AS (
159+ SELECT id AS user_id ,
160+ GREATEST(created_at, ${ cutoffIso } ::timestamptz ) AS window_start
161+ FROM ${ schema . user }
162+ WHERE id IN (${ sql . join (
153163 userIds . map ( ( id ) => sql `${ id } ` ) ,
154164 sql `, ` ,
155165 ) } )
156- AND agent_id IN (${ sql . join (
166+ ),
167+ msgs AS (
168+ SELECT m.user_id, m.finished_at, b.window_start
169+ FROM ${ schema . message } m
170+ JOIN bounds b ON b.user_id = m.user_id
171+ WHERE m.finished_at >= b.window_start
172+ AND m.agent_id IN (${ sql . join (
157173 FREEBUFF_ROOT_AGENT_IDS . map ( ( a ) => sql `${ a } ` ) ,
158174 sql `, ` ,
159175 ) } )
160- AND finished_at >= ${ cutoffIso } ::timestamptz
176+ ),
177+ gaps AS (
178+ SELECT user_id,
179+ finished_at,
180+ COALESCE(
181+ LAG(finished_at) OVER (PARTITION BY user_id ORDER BY finished_at),
182+ window_start
183+ ) AS prev
184+ FROM msgs
161185 )
162186 SELECT user_id,
163- MAX(EXTRACT(EPOCH FROM (finished_at - prev))) / 3600.0 AS max_gap_hours
164- FROM ordered
165- WHERE prev IS NOT NULL
187+ GREATEST(
188+ MAX(EXTRACT(EPOCH FROM (finished_at - prev)) / 3600.0),
189+ EXTRACT(EPOCH FROM (${ nowIso } ::timestamptz - MAX(finished_at))) / 3600.0
190+ ) AS max_gap_hours
191+ FROM gaps
166192 GROUP BY user_id
167193 ` )
168194 const quietGapByUser = new Map < string , number > ( )
0 commit comments