From 48f14b55ecbf0cb696a1049189f30cc8a33be770 Mon Sep 17 00:00:00 2001 From: valentinpanizza Date: Tue, 22 Sep 2026 21:07:37 -0300 Subject: [PATCH 1/2] fix(follow-gate): re-check a failed follow before rejecting, and record rejections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instagram does not report a brand-new follow immediately, and the gate asks people to follow and then tap a button sitting right in front of them — so tapping seconds after following is the normal case. Rejecting on the first false therefore turns away the exact people who did what was asked, and tells them to follow an account they already follow. Worse, the message they get back is the same prompt verbatim, so from their side the bot simply stopped responding. Several users on my instance tapped repeatedly (1.6 taps per person) trying to get a reaction. On the first false the tap is now re-queued with a delay instead of being rejected: if the follow has registered by then the link goes out on its own, with nothing more for the person to do. The job id is deterministic so repeated taps collapse into the one pending re-check rather than queueing a re-check each. FOLLOW_RECHECK_DELAY_MS (default 60s) tunes the wait. Only a second false rejects, and that now writes an OperationalEvent. The branch previously returned without recording anything, so a gate turning people away left no trace at all — its rejection rate could not be measured, only inferred from users complaining. --- lib/queue/client.ts | 4 ++++ lib/queue/dm-worker.ts | 50 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) 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..542bdbf64 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,44 @@ 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 + // deterministic job id means repeated taps collapse into the one pending + // re-check instead of queueing a re-check each. + if (!job.data.followRecheck) { + await getDMQueue().add( + POSTBACK_JOB_NAME, + { ...job.data, followRecheck: true }, + { + delay: FOLLOW_RECHECK_DELAY_MS, + jobId: `postback_recheck_${automation.id}_${userId}`, + } + ); + 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 || From 1dec73958fc129e07adf97e3a6de01e58ca48073 Mon Sep 17 00:00:00 2001 From: valentinpanizza Date: Tue, 22 Sep 2026 22:54:22 -0300 Subject: [PATCH 2/2] fix(follow-gate): bucket the re-check job id so a user can be re-checked again The re-check was queued with a fixed id per automation and user. BullMQ keeps completed jobs (removeOnComplete: count 1000) and silently drops an add whose id is still retained, so once someone had been re-checked, every later re-check for them vanished: their next false tap produced no link and no prompt, and nothing was logged. Caught testing a second tap on the same account. Bucket the id by the recheck window instead. A burst of taps still collapses into a single re-check, which is what the fixed id was for, but a tap in a later window gets a fresh id that no retained job can block. Co-Authored-By: Claude Opus 5.5 --- lib/queue/dm-worker.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/queue/dm-worker.ts b/lib/queue/dm-worker.ts index 542bdbf64..db6af2abf 100644 --- a/lib/queue/dm-worker.ts +++ b/lib/queue/dm-worker.ts @@ -900,16 +900,22 @@ async function processPostback(job: Job): Promise { 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 - // deterministic job id means repeated taps collapse into the one pending - // re-check instead of queueing a re-check each. + // 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}`, + jobId: `postback_recheck_${automation.id}_${userId}_${window}`, } ); return;