Skip to content

Commit 27a3973

Browse files
committed
feat(meta): retry Instagram login prompts with inline error banner
When Instagram's login AJAX returns `kind:'error'` (rejected credentials, bad 2FA code, or failed auth-platform challenge), re-prompt the user up to 3 times instead of killing the session. The previous failure reason is surfaced in the dialog via a new optional `error` field on RequestInputPayload. Terminal error thrown only after all attempts are exhausted, with site-specific prefix ("Too many failed login attempts", "Two-factor verification failed", "Challenge verification failed"). Adds a local `promptWithRetry` helper; `two_factor`, `auth_platform`, and `checkpoint` branches are unchanged (not retried at the credentials layer — each owns its own retry loop where applicable). Env-var bypass (USER_LOGIN_INSTAGRAM/USER_PASSWORD_INSTAGRAM) still fails terminally on first rejection to preserve today's behavior.
1 parent 847677a commit 27a3973

2 files changed

Lines changed: 122 additions & 62 deletions

File tree

connectors/meta/instagram-api-playwright.js

Lines changed: 120 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,25 @@ const DISCOVERY_POLL_MS = 250;
2626
let PLATFORM_LOGIN = process.env.USER_LOGIN_INSTAGRAM || '';
2727
let PLATFORM_PASSWORD = process.env.USER_PASSWORD_INSTAGRAM || '';
2828

29+
const MAX_LOGIN_ATTEMPTS = 3;
30+
31+
// Re-prompt up to maxAttempts times. attempt(values, attemptIndex) returns:
32+
// { ok: true, value } → return value
33+
// { ok: false, error: 'msg' } → re-prompt with `error: 'msg'`
34+
// throws → propagate (e.g. user cancelled)
35+
const promptWithRetry = async (buildSpec, attempt, maxAttempts = MAX_LOGIN_ATTEMPTS) => {
36+
let lastError = null;
37+
for (let i = 0; i < maxAttempts; i++) {
38+
const spec = buildSpec();
39+
if (lastError) spec.error = lastError;
40+
const values = await page.requestInput(spec);
41+
const result = await attempt(values, i);
42+
if (result.ok) return result.value;
43+
lastError = result.error;
44+
}
45+
throw new Error('Too many failed attempts: ' + (lastError || 'unknown reason'));
46+
};
47+
2948
// ─── In-page fetch helper ────────────────────────────────────
3049

3150
const fetchApi = async (url, options) => {
@@ -253,7 +272,7 @@ const handleAuthPlatformChallenge = async (challengeUrl) => {
253272
await safeGoto(fullUrl);
254273
await page.sleep(2000);
255274

256-
const { code } = await page.requestInput({
275+
const buildSpec = () => ({
257276
message: 'Enter Instagram 2FA code',
258277
schema: {
259278
type: 'object',
@@ -263,46 +282,52 @@ const handleAuthPlatformChallenge = async (challengeUrl) => {
263282
required: ['code'],
264283
},
265284
});
266-
const trimmedCode = String(code || '').trim();
267-
if (!/^\d{4,8}$/.test(trimmedCode)) {
268-
throw new Error('Invalid challenge code supplied: "' + code + '"');
269-
}
270285

271-
await page.setData('status', 'Submitting challenge code...');
272-
const submitResult = await page.evaluate(`
273-
(() => {
274-
const input = document.querySelector('input[type="text"]');
275-
if (!input) return { ok: false, reason: 'no text input found' };
276-
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
277-
setter.call(input, ${JSON.stringify(trimmedCode)});
278-
input.dispatchEvent(new Event('input', { bubbles: true }));
279-
input.dispatchEvent(new Event('change', { bubbles: true }));
280-
const buttons = Array.from(document.querySelectorAll('[role="button"], button'));
281-
const cont = buttons.find(b => (b.textContent || '').trim().toLowerCase() === 'continue');
282-
if (!cont) return { ok: false, reason: 'no Continue button found' };
283-
cont.click();
284-
return { ok: true };
285-
})()
286-
`);
287-
if (!submitResult || submitResult.ok !== true) {
288-
throw new Error(
289-
'Failed to submit challenge code: ' +
290-
((submitResult && submitResult.reason) || 'unknown')
291-
);
292-
}
286+
try {
287+
await promptWithRetry(buildSpec, async (values) => {
288+
const trimmedCode = String((values && values.code) || '').trim();
289+
if (!/^\d{4,8}$/.test(trimmedCode)) {
290+
return { ok: false, error: 'Invalid code format — must be 4-8 digits' };
291+
}
292+
293+
await page.setData('status', 'Submitting challenge code...');
294+
const submitResult = await page.evaluate(`
295+
(() => {
296+
const input = document.querySelector('input[type="text"]');
297+
if (!input) return { ok: false, reason: 'no text input found' };
298+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
299+
setter.call(input, ${JSON.stringify(trimmedCode)});
300+
input.dispatchEvent(new Event('input', { bubbles: true }));
301+
input.dispatchEvent(new Event('change', { bubbles: true }));
302+
const buttons = Array.from(document.querySelectorAll('[role="button"], button'));
303+
const cont = buttons.find(b => (b.textContent || '').trim().toLowerCase() === 'continue');
304+
if (!cont) return { ok: false, reason: 'no Continue button found' };
305+
cont.click();
306+
return { ok: true };
307+
})()
308+
`);
309+
if (!submitResult || submitResult.ok !== true) {
310+
const reason = (submitResult && submitResult.reason) || 'unknown';
311+
return { ok: false, error: 'Failed to submit code: ' + reason };
312+
}
293313

294-
await page.setData('status', 'Waiting for session cookie...');
295-
for (let attempt = 0; attempt < 15; attempt++) {
296-
await page.sleep(1000);
297-
const ds = await readDsUserId();
298-
if (ds) {
299-
await page.setData('status', 'Challenge cleared');
300-
return;
314+
await page.setData('status', 'Waiting for session cookie...');
315+
for (let pollAttempt = 0; pollAttempt < 15; pollAttempt++) {
316+
await page.sleep(1000);
317+
const ds = await readDsUserId();
318+
if (ds) {
319+
await page.setData('status', 'Challenge cleared');
320+
return { ok: true, value: undefined };
321+
}
322+
}
323+
return { ok: false, error: 'Code rejected — verification cookie never appeared' };
324+
});
325+
} catch (e) {
326+
if (e && typeof e.message === 'string' && e.message.startsWith('Too many failed attempts: ')) {
327+
throw new Error('Challenge verification failed: ' + e.message.slice('Too many failed attempts: '.length));
301328
}
329+
throw e;
302330
}
303-
throw new Error(
304-
'Challenge code submitted but ds_user_id cookie never appeared — code may have been rejected'
305-
);
306331
};
307332

308333
const performLogin = async () => {
@@ -311,8 +336,26 @@ const performLogin = async () => {
311336
throw new Error('csrftoken cookie missing after visiting instagram.com — cannot submit login');
312337
}
313338

314-
if (!PLATFORM_LOGIN || !PLATFORM_PASSWORD) {
315-
const creds = await page.requestInput({
339+
const submitCredentials = async () => {
340+
await page.setData('status', 'Submitting login credentials...');
341+
const wrappedPwd = IG_PWD_PREFIX + Math.floor(Date.now() / 1000) + ':' + PLATFORM_PASSWORD;
342+
return await postLoginAjax(LOGIN_URL, csrftoken, {
343+
username: PLATFORM_LOGIN,
344+
enc_password: wrappedPwd,
345+
queryParams: '{}',
346+
optIntoOneTap: 'false',
347+
trustedDeviceRecords: '{}',
348+
});
349+
};
350+
351+
let result;
352+
if (PLATFORM_LOGIN && PLATFORM_PASSWORD) {
353+
result = await submitCredentials();
354+
if (result.kind === 'error') {
355+
throw new Error('Instagram login failed: ' + (result.message || 'unknown reason'));
356+
}
357+
} else {
358+
const buildCredsSpec = () => ({
316359
message: 'Log in to Instagram',
317360
schema: {
318361
type: 'object',
@@ -323,43 +366,58 @@ const performLogin = async () => {
323366
required: ['username', 'password'],
324367
},
325368
});
326-
PLATFORM_LOGIN = creds.username;
327-
PLATFORM_PASSWORD = creds.password;
328-
}
329369

330-
await page.setData('status', 'Submitting login credentials...');
331-
const wrappedPwd = IG_PWD_PREFIX + Math.floor(Date.now() / 1000) + ':' + PLATFORM_PASSWORD;
332-
const result = await postLoginAjax(LOGIN_URL, csrftoken, {
333-
username: PLATFORM_LOGIN,
334-
enc_password: wrappedPwd,
335-
queryParams: '{}',
336-
optIntoOneTap: 'false',
337-
trustedDeviceRecords: '{}',
338-
});
370+
try {
371+
result = await promptWithRetry(buildCredsSpec, async (creds) => {
372+
PLATFORM_LOGIN = creds.username;
373+
PLATFORM_PASSWORD = creds.password;
374+
const inner = await submitCredentials();
375+
if (inner.kind === 'ok') return { ok: true, value: { kind: 'ok' } };
376+
if (inner.kind === 'two_factor') return { ok: true, value: inner };
377+
if (inner.kind === 'auth_platform') return { ok: true, value: inner };
378+
if (inner.kind === 'checkpoint') return { ok: true, value: inner };
379+
return { ok: false, error: inner.message || 'Login failed' };
380+
});
381+
} catch (e) {
382+
if (e && typeof e.message === 'string' && e.message.startsWith('Too many failed attempts: ')) {
383+
throw new Error('Too many failed login attempts: ' + e.message.slice('Too many failed attempts: '.length));
384+
}
385+
throw e;
386+
}
387+
}
339388

340389
if (result.kind === 'ok') {
341390
return;
342391
}
343392

344393
if (result.kind === 'two_factor') {
345-
const { code } = await page.requestInput({
394+
const buildCodeSpec = () => ({
346395
message: 'Enter your Instagram two-factor verification code',
347396
schema: {
348397
type: 'object',
349398
properties: { code: { type: 'string', title: '6-digit verification code' } },
350399
required: ['code'],
351400
},
352401
});
353-
const refreshedCsrf = (await readCsrfToken()) || csrftoken;
354-
const second = await postLoginAjax(TWO_FACTOR_URL, refreshedCsrf, {
355-
username: PLATFORM_LOGIN,
356-
verificationCode: String(code).trim(),
357-
identifier: result.info.twoFactorIdentifier,
358-
queryParams: '{}',
359-
trust_signal_v2: 'true',
360-
});
361-
if (second.kind !== 'ok') {
362-
throw new Error('Two-factor verification failed: ' + (second.message || second.kind));
402+
403+
try {
404+
await promptWithRetry(buildCodeSpec, async (values) => {
405+
const refreshedCsrf = (await readCsrfToken()) || csrftoken;
406+
const second = await postLoginAjax(TWO_FACTOR_URL, refreshedCsrf, {
407+
username: PLATFORM_LOGIN,
408+
verificationCode: String((values && values.code) || '').trim(),
409+
identifier: result.info.twoFactorIdentifier,
410+
queryParams: '{}',
411+
trust_signal_v2: 'true',
412+
});
413+
if (second.kind === 'ok') return { ok: true, value: undefined };
414+
return { ok: false, error: second.message || second.kind };
415+
});
416+
} catch (e) {
417+
if (e && typeof e.message === 'string' && e.message.startsWith('Too many failed attempts: ')) {
418+
throw new Error('Two-factor verification failed: ' + e.message.slice('Too many failed attempts: '.length));
419+
}
420+
throw e;
363421
}
364422
return;
365423
}

types/connector.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ export interface RequestInputPayload {
7979
message: string;
8080
/** JSON Schema describing the expected response shape (optional) */
8181
schema?: Record<string, unknown>;
82+
/** Optional error message to display from a previous failed attempt (e.g., "Incorrect password"). */
83+
error?: string;
8284
}
8385

8486
/**

0 commit comments

Comments
 (0)