fix(whatsapp): unknown-sender quota RPC fails open to the throttled greeting path (#1991)
* fix(whatsapp): unknown-sender quota RPC fails open to the throttled greeting path (#1599) When check_and_increment_whatsapp_sender_quota errored, handleUnknownSender logged, wrote a fail-closed trace row and returned: a transient DB hiccup silenced a first-time sender at the exact moment they were trying to link. The limiter being unavailable now falls through to the existing greeting path, whose own throttle (1 M1 per hour for text, 10-minute media burst, 3 per day, fail-closed on its own read error) and the single-use link-code claim already bound outbound volume. A thrown RPC (network) is treated the same as a PostgREST error. Over-quota (ok: false) is untouched: silent by design, decline trace kept. In degraded mode a valid code still binds and gets M3; a bad code gets the throttled M1 instead of M2, because only the quota bounds M2. The greeting dispositions carry a ' (quota limiter unavailable)' suffix so support can tell the two modes apart; suffix rather than prefix because last-event.ts matches dispositions with startsWith. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(whatsapp): answer a bad link code with a throttled M2 in degraded mode Review finding on #1991: withholding M2 while the quota RPC is down left the worst sub-path of the linking moment silent. A sender greeted with M1 inside the last hour who then sends an expired or mistyped code fell through to the greeting path, where the 1/hour rule declined M1, so they heard nothing at all; exactly the silence issue #1599 targets. M2 now gets its own small bound instead of being withheld: a new badCodeThrottled read in lib/conversation.ts (mirrors greetingThrottled, keyed on raw_payload->>template = m2_bad_code: 1 per 10 minutes, 3 per day per phone hash, fail-closed on read error). In degraded mode a bad code sends M2 when that throttle allows and otherwise falls through to the existing M1/silence path. The normal path is untouched: the short-circuit only does the extra read when the quota RPC was unavailable. The M2 trace disposition carries the degraded suffix. Tests: the replaced "withholds M2" case now asserts M2 goes out once under its own throttle with the degraded trace suffix; new cases cover a repeated bad code inside the 10 min window (silent skipped trace via the greeting throttle), the 3/day cap, and an unreadable M2 window failing closed to the M1 path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
f0af4ad4ee
commit
57ff2eda96
@@ -445,6 +445,189 @@ describe('POST /webhook', () => {
|
||||
await route.handler(signedRequest(envelope({ messages: [textMessage('hej')] })))
|
||||
expect(findCalls('whatsapp_messages', 'insert')).toHaveLength(0)
|
||||
})
|
||||
|
||||
// #1599: an UNAVAILABLE limiter (RPC error or throw) fails open into the
|
||||
// throttled greeting path. Over-quota (ok: false) above stays silent.
|
||||
describe('quota RPC unavailable (fail open, #1599)', () => {
|
||||
const rpcError = { data: null, error: { message: 'PGRST202 could not find function' } }
|
||||
|
||||
it('media still earns the throttled M1, no content persisted', async () => {
|
||||
const { enqueue, findCalls } = mockSupabase()
|
||||
enqueue({ data: null }) // no active link
|
||||
enqueue(rpcError) // sender quota RPC unavailable
|
||||
enqueue({ data: [] }) // greeting throttle: nothing sent before
|
||||
enqueue({ data: null, error: null }) // trace row insert ('done': no cap query)
|
||||
|
||||
const response = await route.handler(
|
||||
signedRequest(envelope({ messages: [imageMessage()] })),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(sendTextMock).toHaveBeenCalledTimes(1)
|
||||
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m1Unlinked)
|
||||
// The hard rules hold in degraded mode: no media touch, no checkmark.
|
||||
expect(vi.mocked(downloadMedia)).not.toHaveBeenCalled()
|
||||
expect(sendReactionMock).not.toHaveBeenCalled()
|
||||
// Exactly one trace row (no fail-closed 'skipped' insert ahead of it:
|
||||
// a second insert would 23505 on the wamid and read as a redelivery).
|
||||
const inserts = findCalls('whatsapp_messages', 'insert')
|
||||
expect(inserts).toHaveLength(1)
|
||||
const [row] = inserts[0] as [Record<string, unknown>]
|
||||
expect(row.processing_status).toBe('done')
|
||||
expect(row.error_message).toContain('greeted')
|
||||
expect(row.error_message).toContain('quota limiter unavailable')
|
||||
expect(row.phone_link_id).toBeNull()
|
||||
expect(row.body_text).toBeUndefined()
|
||||
expect(row.media_id).toBeUndefined()
|
||||
expect(row.raw_payload).toBeUndefined()
|
||||
expect(kickMock).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('inside the greeting window stays silent (the throttle is the remaining cap)', async () => {
|
||||
const { enqueue, findCalls } = mockSupabase()
|
||||
enqueue({ data: null })
|
||||
enqueue(rpcError)
|
||||
enqueue({ data: [{ created_at: new Date().toISOString() }] }) // greeted within the hour
|
||||
enqueue({ count: 0 }) // decline-trace day cap
|
||||
enqueue({ data: null, error: null }) // declined trace insert
|
||||
|
||||
await route.handler(signedRequest(envelope({ messages: [textMessage('hej')] })))
|
||||
expect(sendTextMock).not.toHaveBeenCalled()
|
||||
const inserts = findCalls('whatsapp_messages', 'insert')
|
||||
expect(inserts).toHaveLength(1)
|
||||
const [row] = inserts[0] as [Record<string, unknown>]
|
||||
expect(row.processing_status).toBe('skipped')
|
||||
expect(row.error_message).toContain('greeting throttled')
|
||||
expect(row.error_message).toContain('quota limiter unavailable')
|
||||
})
|
||||
|
||||
it('still binds a valid link code and replies M3', async () => {
|
||||
const { enqueue, findCall } = mockSupabase()
|
||||
enqueue({ data: null }) // no active link
|
||||
enqueue(rpcError) // quota unavailable
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'code-1',
|
||||
user_id: 'user-1',
|
||||
expires_at: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
||||
used_at: null,
|
||||
},
|
||||
}) // code lookup
|
||||
enqueue({ data: { id: 'code-1' } }) // code claim
|
||||
enqueue({ data: null }) // revoke by phone hash
|
||||
enqueue({ data: null }) // revoke by user
|
||||
enqueue({ data: { id: 'link-9', user_id: 'user-1' } }) // link insert
|
||||
enqueue({ data: { id: 'conv-9' } }) // conversation insert
|
||||
enqueue({ data: null }) // content-free code-message row
|
||||
enqueue({ data: [{ company_id: 'company-1' }] }) // memberships
|
||||
enqueue({ data: { name: 'Bolaget AB' } }) // company name
|
||||
|
||||
const response = await route.handler(
|
||||
signedRequest(
|
||||
envelope({
|
||||
contacts: [{ wa_id: '46701234567', profile: { name: 'Jakob' } }],
|
||||
messages: [textMessage('ac-7kp4qf')],
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const [linkRow] = findCall('whatsapp_phone_links', 'insert') as [Record<string, unknown>]
|
||||
expect(linkRow.user_id).toBe('user-1')
|
||||
expect(sendTextMock).toHaveBeenCalledTimes(1)
|
||||
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m3Linked)
|
||||
})
|
||||
|
||||
it('a bad code still gets M2 under its own throttle, even inside the M1 hour', async () => {
|
||||
// The greeting throttle is never consulted on this path: only the
|
||||
// M2 window read gates the reply, so a sender greeted with M1 five
|
||||
// minutes ago is still told their code was rejected.
|
||||
const { enqueue, findCalls } = mockSupabase()
|
||||
enqueue({ data: null }) // no active link
|
||||
enqueue(rpcError) // quota unavailable
|
||||
enqueue({ data: null }) // code lookup: nothing
|
||||
enqueue({ data: [] }) // M2 throttle window: no M2 sent before
|
||||
enqueue({ data: null, error: null }) // trace row insert ('done')
|
||||
|
||||
await route.handler(signedRequest(envelope({ messages: [textMessage('ac-zzzz99')] })))
|
||||
expect(sendTextMock).toHaveBeenCalledTimes(1)
|
||||
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m2BadCode)
|
||||
const inserts = findCalls('whatsapp_messages', 'insert')
|
||||
expect(inserts).toHaveLength(1)
|
||||
const [row] = inserts[0] as [Record<string, unknown>]
|
||||
expect(row.error_message).toContain('invalid link code')
|
||||
expect(row.error_message).toContain('quota limiter unavailable')
|
||||
expect(row.body_text).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a repeated bad code inside the M2 window falls through and stays silent', async () => {
|
||||
const { enqueue, findCalls } = mockSupabase()
|
||||
enqueue({ data: null })
|
||||
enqueue(rpcError)
|
||||
enqueue({ data: null }) // code lookup: nothing
|
||||
enqueue({
|
||||
data: [{ created_at: new Date(Date.now() - 2 * 60 * 1000).toISOString() }],
|
||||
}) // M2 sent 2 min ago: inside the 10 min window
|
||||
enqueue({ data: [{ created_at: new Date().toISOString() }] }) // M1 within the hour
|
||||
enqueue({ count: 0 }) // decline-trace day cap
|
||||
enqueue({ data: null, error: null }) // declined trace insert
|
||||
|
||||
await route.handler(signedRequest(envelope({ messages: [textMessage('ac-zzzz99')] })))
|
||||
expect(sendTextMock).not.toHaveBeenCalled()
|
||||
const inserts = findCalls('whatsapp_messages', 'insert')
|
||||
expect(inserts).toHaveLength(1)
|
||||
const [row] = inserts[0] as [Record<string, unknown>]
|
||||
expect(row.processing_status).toBe('skipped')
|
||||
expect(row.error_message).toContain('greeting throttled')
|
||||
})
|
||||
|
||||
it('the fourth M2 of the day is withheld (daily cap)', async () => {
|
||||
const { enqueue } = mockSupabase()
|
||||
enqueue({ data: null })
|
||||
enqueue(rpcError)
|
||||
enqueue({ data: null }) // code lookup: nothing
|
||||
enqueue({
|
||||
data: [
|
||||
{ created_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString() },
|
||||
{ created_at: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString() },
|
||||
{ created_at: new Date(Date.now() - 9 * 60 * 60 * 1000).toISOString() },
|
||||
],
|
||||
}) // BAD_CODE_DAY_MAX already sent today, all outside the 10 min window
|
||||
enqueue({ data: [] }) // greeting throttle: nothing sent before
|
||||
enqueue({ data: null, error: null }) // trace row insert ('done')
|
||||
|
||||
await route.handler(signedRequest(envelope({ messages: [textMessage('ac-zzzz99')] })))
|
||||
expect(sendTextMock).toHaveBeenCalledTimes(1)
|
||||
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m1Unlinked)
|
||||
})
|
||||
|
||||
it('an unreadable M2 window fails closed to the M1 path', async () => {
|
||||
const { enqueue } = mockSupabase()
|
||||
enqueue({ data: null })
|
||||
enqueue(rpcError)
|
||||
enqueue({ data: null }) // code lookup: nothing
|
||||
enqueue({ data: null, error: { message: 'read failed' } }) // M2 window unreadable
|
||||
enqueue({ data: [] }) // greeting throttle: nothing sent before
|
||||
enqueue({ data: null, error: null }) // trace row insert ('done')
|
||||
|
||||
await route.handler(signedRequest(envelope({ messages: [textMessage('ac-zzzz99')] })))
|
||||
expect(sendTextMock).toHaveBeenCalledTimes(1)
|
||||
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m1Unlinked)
|
||||
})
|
||||
|
||||
it('a throwing RPC (network) is treated like an RPC error', async () => {
|
||||
const mock = mockSupabase()
|
||||
mock.enqueue({ data: null }) // no active link
|
||||
mock.supabase.rpc.mockRejectedValueOnce(new Error('fetch failed'))
|
||||
mock.enqueue({ data: [] }) // greeting throttle: nothing sent before
|
||||
mock.enqueue({ data: null, error: null }) // trace row insert ('done')
|
||||
|
||||
const response = await route.handler(
|
||||
signedRequest(envelope({ messages: [imageMessage()] })),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(sendTextMock).toHaveBeenCalledTimes(1)
|
||||
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m1Unlinked)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('link codes', () => {
|
||||
|
||||
@@ -51,6 +51,7 @@ import { kickInboundProcessing } from './lib/process-inbound'
|
||||
import { CHAT_ALLOWED_MIME_TYPES, normalizeChatMime } from './lib/chat-mime'
|
||||
import {
|
||||
DEBOUNCE_WINDOW_MS,
|
||||
badCodeThrottled,
|
||||
getContext,
|
||||
getOrCreateConversation,
|
||||
greetingThrottled,
|
||||
@@ -67,6 +68,13 @@ const log = createLogger('whatsapp-inbox')
|
||||
// ── Unknown-sender budgets ───────────────────────────────────
|
||||
// Pre-binding limiter (check_and_increment_whatsapp_sender_quota): caps how
|
||||
// much handling an unbound phone can consume at all. Beyond it: silence.
|
||||
// When the limiter itself cannot be reached (RPC error or throw), the sender
|
||||
// falls through to the greeting path instead (#1599): its own throttle
|
||||
// (greetingThrottled in lib/conversation.ts: 1 M1 per hour for text, a
|
||||
// 10-minute burst window for media, 3 per day, fail-closed on its own read
|
||||
// error) remains the outbound volume cap. M2 (bad code) gets its own small
|
||||
// fail-closed throttle in that mode (badCodeThrottled in lib/conversation.ts:
|
||||
// 1 per 10 minutes, 3 per day) since this quota no longer bounds it.
|
||||
const UNKNOWN_SENDER_MINUTE_MAX = 15
|
||||
const UNKNOWN_SENDER_DAY_MAX = 200
|
||||
// Declined-message trace rows (issue #1552) are capped per hash and day so an
|
||||
@@ -164,47 +172,72 @@ async function recordUnknownSenderMessage(
|
||||
* Unknown/unlinked sender. Hard rules: never download media, never persist
|
||||
* message content or raw payloads, never touch any LLM. The only DB writes
|
||||
* are the quota counters, a consumed link code, and outbound reply rows.
|
||||
* The quota RPC failing open (#1599) never relaxes these: the degraded path
|
||||
* is the same greeting path, under the same hard rules.
|
||||
*/
|
||||
async function handleUnknownSender(
|
||||
supabase: SupabaseClient,
|
||||
msg: ParsedInboundMessage,
|
||||
phoneHash: string,
|
||||
): Promise<void> {
|
||||
const { data: quota, error: quotaError } = await supabase.rpc(
|
||||
'check_and_increment_whatsapp_sender_quota',
|
||||
{
|
||||
p_phone_hash: phoneHash,
|
||||
p_minute_max: UNKNOWN_SENDER_MINUTE_MAX,
|
||||
p_day_max: UNKNOWN_SENDER_DAY_MAX,
|
||||
},
|
||||
)
|
||||
if (quotaError) {
|
||||
// Fail closed for unknown senders: without the limiter we send nothing.
|
||||
// The decline itself is still recorded (#1552): support must be able to
|
||||
// answer "what happened to my message" even for this path.
|
||||
log.warn('sender quota RPC failed; staying silent', { error: quotaError.message })
|
||||
await recordUnknownSenderMessage(
|
||||
supabase, msg, phoneHash, 'skipped', 'Unknown sender: quota check failed, declined fail-closed',
|
||||
// Fail OPEN when the limiter is unavailable (#1599): a transient DB error
|
||||
// must not silence a first-time sender at the linking moment. The greeting
|
||||
// throttle below (itself fail-closed) remains the outbound cap and the
|
||||
// link-code claim is single-use regardless. Over-quota (ok: false) stays
|
||||
// silent by design. No fail-closed trace insert here: the path below writes
|
||||
// this message's trace row (#1552), and a second insert would 23505 on the
|
||||
// wamid and be misread as a redelivery.
|
||||
let quota: { ok?: boolean } | null = null
|
||||
let quotaUnavailable = false
|
||||
try {
|
||||
const { data, error } = await supabase.rpc(
|
||||
'check_and_increment_whatsapp_sender_quota',
|
||||
{
|
||||
p_phone_hash: phoneHash,
|
||||
p_minute_max: UNKNOWN_SENDER_MINUTE_MAX,
|
||||
p_day_max: UNKNOWN_SENDER_DAY_MAX,
|
||||
},
|
||||
)
|
||||
return
|
||||
if (error) {
|
||||
quotaUnavailable = true
|
||||
log.warn('sender quota RPC failed; failing open to the throttled greeting path', {
|
||||
error: error.message,
|
||||
})
|
||||
} else {
|
||||
quota = data as { ok?: boolean } | null
|
||||
}
|
||||
} catch (err) {
|
||||
quotaUnavailable = true
|
||||
log.warn('sender quota RPC threw; failing open to the throttled greeting path', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
if ((quota as { ok?: boolean } | null)?.ok === false) {
|
||||
if (quota?.ok === false) {
|
||||
await recordUnknownSenderMessage(
|
||||
supabase, msg, phoneHash, 'skipped', 'Unknown sender: over pre-binding quota, declined',
|
||||
)
|
||||
return
|
||||
}
|
||||
// Disposition suffix (never a prefix: lib/last-event.ts matches these by
|
||||
// startsWith) so support can tell a degraded-mode greeting from a normal one.
|
||||
const degraded = quotaUnavailable ? ' (quota limiter unavailable)' : ''
|
||||
|
||||
const copy = botCopy('sv')
|
||||
|
||||
if (msg.type === 'text' && looksLikeLinkCode(msg.text)) {
|
||||
const consumed = await consumeLinkCode(supabase, msg.text ?? '')
|
||||
if (!consumed) {
|
||||
// A bad code earns M2. Normally the pre-binding quota bounds M2; in
|
||||
// degraded mode it gets its own small fail-closed throttle instead
|
||||
// (badCodeThrottled: 1 per 10 min, 3 per day), so a sender greeted with
|
||||
// M1 inside the last hour who then sends an expired or mistyped code is
|
||||
// still told the code was rejected instead of falling into silence.
|
||||
// The short-circuit keeps the extra read off the normal path.
|
||||
if (!consumed && (!quotaUnavailable || !(await badCodeThrottled(supabase, phoneHash)))) {
|
||||
// Trace row first: a Meta redelivery of a message already answered
|
||||
// (including a redelivered CONSUMED code, whose row the success path
|
||||
// wrote) dedupes on the wamid instead of earning a second reply.
|
||||
const traced = await recordUnknownSenderMessage(
|
||||
supabase, msg, phoneHash, 'done', 'Unknown sender: invalid link code, M2 sent',
|
||||
supabase, msg, phoneHash, 'done', `Unknown sender: invalid link code, M2 sent${degraded}`,
|
||||
)
|
||||
if (traced === 'duplicate') return
|
||||
await sendText(supabase, {
|
||||
@@ -216,49 +249,55 @@ async function handleUnknownSender(
|
||||
return
|
||||
}
|
||||
|
||||
const { link, conversationId } = await createPhoneLink(supabase, {
|
||||
userId: consumed.userId,
|
||||
phone: msg.from,
|
||||
profileName: msg.profileName,
|
||||
})
|
||||
if (consumed) {
|
||||
const { link, conversationId } = await createPhoneLink(supabase, {
|
||||
userId: consumed.userId,
|
||||
phone: msg.from,
|
||||
profileName: msg.profileName,
|
||||
})
|
||||
|
||||
// Persist a content-free row for the code message so a Meta redelivery
|
||||
// of the same wamid dedupes instead of falling into the keyword path.
|
||||
await supabase.from('whatsapp_messages').insert({
|
||||
direction: 'inbound',
|
||||
wamid: msg.wamid,
|
||||
sender_phone_hash: phoneHash,
|
||||
phone_link_id: link.id,
|
||||
conversation_id: conversationId,
|
||||
message_type: 'text',
|
||||
processing_status: 'done',
|
||||
})
|
||||
// Persist a content-free row for the code message so a Meta redelivery
|
||||
// of the same wamid dedupes instead of falling into the keyword path.
|
||||
await supabase.from('whatsapp_messages').insert({
|
||||
direction: 'inbound',
|
||||
wamid: msg.wamid,
|
||||
sender_phone_hash: phoneHash,
|
||||
phone_link_id: link.id,
|
||||
conversation_id: conversationId,
|
||||
message_type: 'text',
|
||||
processing_status: 'done',
|
||||
})
|
||||
|
||||
const { data: memberships } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id')
|
||||
.eq('user_id', consumed.userId)
|
||||
const companyIds = [...new Set((memberships ?? []).map((m) => m.company_id as string))]
|
||||
const { data: memberships } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id')
|
||||
.eq('user_id', consumed.userId)
|
||||
const companyIds = [...new Set((memberships ?? []).map((m) => m.company_id as string))]
|
||||
|
||||
let companyName: string | null = null
|
||||
if (companyIds.length === 1) {
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('name')
|
||||
.eq('id', companyIds[0])
|
||||
.maybeSingle()
|
||||
companyName = (company as { name?: string } | null)?.name ?? null
|
||||
let companyName: string | null = null
|
||||
if (companyIds.length === 1) {
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('name')
|
||||
.eq('id', companyIds[0])
|
||||
.maybeSingle()
|
||||
companyName = (company as { name?: string } | null)?.name ?? null
|
||||
}
|
||||
|
||||
await sendText(supabase, {
|
||||
to: msg.from,
|
||||
body: copy.m3Linked({ companyName, companyCount: Math.max(companyIds.length, 1) }),
|
||||
template: TEMPLATE.m3Linked,
|
||||
senderPhoneHash: phoneHash,
|
||||
phoneLinkId: link.id,
|
||||
conversationId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await sendText(supabase, {
|
||||
to: msg.from,
|
||||
body: copy.m3Linked({ companyName, companyCount: Math.max(companyIds.length, 1) }),
|
||||
template: TEMPLATE.m3Linked,
|
||||
senderPhoneHash: phoneHash,
|
||||
phoneLinkId: link.id,
|
||||
conversationId,
|
||||
})
|
||||
return
|
||||
// Limiter unavailable and the M2 throttle declined (repeat bad codes,
|
||||
// or its window was unreadable): fall through to the throttled M1
|
||||
// below, which also tells the sender how to fetch a fresh code.
|
||||
}
|
||||
|
||||
// Anything else from an unknown number: the AI-disclosure greeting, hard
|
||||
@@ -269,12 +308,12 @@ async function handleUnknownSender(
|
||||
const carriesMedia = msg.type === 'image' || msg.type === 'document'
|
||||
if (await greetingThrottled(supabase, phoneHash, { media: carriesMedia })) {
|
||||
await recordUnknownSenderMessage(
|
||||
supabase, msg, phoneHash, 'skipped', 'Unknown sender: greeting throttled, declined',
|
||||
supabase, msg, phoneHash, 'skipped', `Unknown sender: greeting throttled, declined${degraded}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const traced = await recordUnknownSenderMessage(
|
||||
supabase, msg, phoneHash, 'done', 'Unknown sender: greeted, M1 sent',
|
||||
supabase, msg, phoneHash, 'done', `Unknown sender: greeted, M1 sent${degraded}`,
|
||||
)
|
||||
if (traced === 'duplicate') return
|
||||
await sendText(supabase, {
|
||||
|
||||
@@ -73,6 +73,43 @@ export async function greetingThrottled(
|
||||
return rows.some((r) => new Date(r.created_at).getTime() > windowStart)
|
||||
}
|
||||
|
||||
// M2 (bad link code) is normally bounded by the pre-binding sender quota RPC.
|
||||
// When that limiter is unavailable and the webhook fails open (#1599), this
|
||||
// small throttle bounds M2 instead: 1 per 10 minutes, 3 per day, per phone
|
||||
// hash. Without it, a sender greeted with M1 inside the last hour who then
|
||||
// sends an expired or mistyped code would hear nothing at all in degraded
|
||||
// mode, which is exactly the silent-linking-moment #1599 targets.
|
||||
const BAD_CODE_WINDOW_MS = 10 * 60 * 1000
|
||||
const BAD_CODE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
const BAD_CODE_DAY_MAX = 3
|
||||
|
||||
/** True when another M2 (bad code) reply to this phone hash would exceed the
|
||||
* degraded-mode cap. Fails CLOSED: if the window cannot be read, no M2 goes
|
||||
* out (the caller falls through to the throttled M1 path instead). */
|
||||
export async function badCodeThrottled(
|
||||
supabase: SupabaseClient,
|
||||
phoneHash: string,
|
||||
): Promise<boolean> {
|
||||
const since = new Date(Date.now() - BAD_CODE_DAY_MS).toISOString()
|
||||
const { data, error } = await supabase
|
||||
.from('whatsapp_messages')
|
||||
.select('created_at')
|
||||
.eq('direction', 'outbound')
|
||||
.eq('sender_phone_hash', phoneHash)
|
||||
.eq('raw_payload->>template', TEMPLATE.m2BadCode)
|
||||
.gte('created_at', since)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(BAD_CODE_DAY_MAX)
|
||||
if (error) {
|
||||
log.warn('bad-code throttle window unreadable; withholding M2', { error: error.message })
|
||||
return true
|
||||
}
|
||||
const rows = (data ?? []) as Array<{ created_at: string }>
|
||||
if (rows.length >= BAD_CODE_DAY_MAX) return true
|
||||
const windowStart = Date.now() - BAD_CODE_WINDOW_MS
|
||||
return rows.some((r) => new Date(r.created_at).getTime() > windowStart)
|
||||
}
|
||||
|
||||
export const COMPANY_PIN_TTL_MS = 8 * 60 * 60 * 1000
|
||||
export const QUESTION_TTL_MS = 48 * 60 * 60 * 1000
|
||||
export const LATE_ANSWER_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
Reference in New Issue
Block a user