diff --git a/lib/queue/client.ts b/lib/queue/client.ts index b07f1ffee..f76554a3a 100644 --- a/lib/queue/client.ts +++ b/lib/queue/client.ts @@ -47,6 +47,10 @@ export interface ProcessPostbackJob { payload: string; mid?: string; fallback?: boolean; + // Set on the delayed second pass of a follow-gate check. Instagram does not + // report a brand-new follow immediately, so the first `false` is re-checked + // later instead of rejecting the tap outright. + followRecheck?: boolean; } // Scheduled after the link is delivered, to send the appreciation follow-up. diff --git a/lib/queue/dm-worker.ts b/lib/queue/dm-worker.ts index ec5f12d75..db6af2abf 100644 --- a/lib/queue/dm-worker.ts +++ b/lib/queue/dm-worker.ts @@ -52,6 +52,18 @@ import { const BACKOFF_DELAYS = [5 * 60 * 1000, 15 * 60 * 1000, 45 * 60 * 1000]; +// How long to wait before re-checking a follow that came back false. +// +// `is_user_follow_business` does not reflect a brand-new follow right away, and +// the follow gate asks people to follow and tap a button that is sitting in +// front of them — so tapping seconds after following is the normal case, not +// the exception. Rejecting on the first `false` therefore turns away the exact +// people who did what was asked, and they get told to follow an account they +// already follow. +const FOLLOW_RECHECK_DELAY_MS = Number( + process.env.FOLLOW_RECHECK_DELAY_MS ?? 60_000 +); + function formatError(error: unknown): string { if (error instanceof MetaApiError) { return `${error.name} ${error.code}: ${error.message}`; @@ -886,6 +898,50 @@ async function processPostback(job: Job): Promise { }); if (follows === false) { if (fallback) return; + + // First `false` on a button tap: give the follow time to register and + // look again, rather than rejecting someone who just followed. + // + // The job id is bucketed by the recheck window, not fixed per user. + // BullMQ keeps completed jobs (removeOnComplete: count 1000) and silently + // drops an add whose id is still retained, so a fixed id let a person be + // re-checked once and then never again — their next false tap did + // nothing at all, no link and no prompt. Bucketing still collapses a burst + // of taps into a single re-check, which is what the fixed id was for. + if (!job.data.followRecheck) { + const window = Math.floor(Date.now() / FOLLOW_RECHECK_DELAY_MS); + await getDMQueue().add( + POSTBACK_JOB_NAME, + { ...job.data, followRecheck: true }, + { + delay: FOLLOW_RECHECK_DELAY_MS, + jobId: `postback_recheck_${automation.id}_${userId}_${window}`, + } + ); + return; + } + + // Second `false`: they are genuinely not following. Record it — this + // branch used to return without writing anything at all, so a gate that + // turned people away left no trace and its rejection rate could not be + // measured, only guessed at from complaints. + await prisma.operationalEvent + .create({ + data: { + workspaceId: automation.workspaceId, + source: "WORKER", + level: "INFO", + message: "Follow gate rejected a button tap", + payload: { + automationId: automation.id, + automationName: automation.name, + userId, + commenterName, + }, + }, + }) + .catch(() => {}); + const promptText = renderMessageWithoutLink({ message: automation.followPromptMessage ||