Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/queue/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
56 changes: 56 additions & 0 deletions lib/queue/dm-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down Expand Up @@ -886,6 +898,50 @@ async function processPostback(job: Job<ProcessPostbackJob>): Promise<void> {
});
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 ||
Expand Down