Skip to content

Challenges: answer them once with otherwise ​

Instagram can interrupt almost any request with a challenge: a code to enter, a captcha, a notice to confirm. You describe once what your program does about each challenge screen, and every entry applies that description for you. Your main never sees the challenge: the interrupted effect simply runs again once the challenge is closed. This page shows how to describe the branches and what happens when they cannot settle a screen.

Instagram, and the error class IgCheckpointError, call a challenge a "checkpoint". This site says challenge, so it is not confused with a checkpoint of your own session (Keep a session).

When a challenge happens ​

A challenge can be raised:

  • over the start of any entry, install included;
  • over any effect the screens run inside main: a screen's action, edge or swipe, a feed's next page, a request of the start-up's background requests, the launch's first screen, a restore.

A challenge step is not the screen you asked for, and handing it to main would make every call narrow a union. So it is never handed over: you describe it once, in otherwise.

Describe the branches ​

defineOtherwise builds the constant. Each key is a challenge screen of the platform (a screen id, so a screen this build does not have is a type error), and each value is a handler that receives that screen's own handle.

  • The branch runs, and then the screen in front is read again, so a chain composes without anything listing it: the method choice, then the code entry, then the feed, then main.
  • A branch returns nothing that matters. Its job is only to get past the detour, and the entry resolves with main's result.
  • One constant serves every entry that can meet a challenge: install, launch and the three resume… calls. resumeSilently accepts it although it sends nothing, so the same constant goes everywhere.
  • A code is read inside the branch, when the screen asks for it, never at start-up (Codes that arrive during the run).
ts
import {
  AndroidIgpapi,
  ChallengeSelectVerifyMethodScreen,
  ChallengeVerifyCodeScreen,
  defineOtherwise,
  FeedTimelinePostItem,
  FeedTimelineScreen,
} from '@igpapi/android';
import { Effect } from 'effect';
import { readFile } from 'node:fs/promises';
import { createInterface } from 'node:readline/promises';

// The checkpoint saved in "Your first program".
const saved = JSON.parse(await readFile('session.json', 'utf8'));

// A code is read at the moment the screen asks for it ("Codes that arrive during the run").
const readCode = (kind: 'sms') =>
  Effect.promise(async () => {
    const terminal = createInterface({ input: process.stdin, output: process.stdout });
    const code = await terminal.question(`The ${kind} code: `);
    terminal.close();
    return code.trim();
  });

// The branches are described once: each key is a screen of the platform, and each handler receives
// that screen's own handle. The same constant goes to every entry that can meet a challenge, install included.
const branches = defineOtherwise({
  [ChallengeSelectVerifyMethodScreen.id]: method => method.chooseVerificationMethod('sms'),
  [ChallengeVerifyCodeScreen.id]: screen => Effect.flatMap(readCode('sms'), code => screen.submitCode(code)),
});

// `main` receives exactly the screen named here. A challenge the server raises over the start or over an
// action inside `main` runs its branch, the screen in front is read again (the next challenge step, then the
// feed), the interrupted effect runs again, and `main` goes on from it.
const liked = await AndroidIgpapi.launch({
  checkpoint: saved,
  screen: FeedTimelineScreen,
  otherwise: branches,
  main: feed =>
    Effect.gen(function* () {
      const post = feed.items.find(item => FeedTimelinePostItem.is(item));
      if (post === undefined) return;
      yield* post.like();
    }),
});
console.log(liked);

A complete otherwise for every challenge ​

The constant below has a branch for every challenge screen that has an action. The values it needs are yours to supply: what you know about the account is read at start-up, the codes are asked for when a screen needs them, and a reCAPTCHA token comes from a solving service you bring (the screen's data.siteKey is the key to solve). A suspension is answered with disagreeWithDecision(). The screens that wait for the account's owner or for Instagram's review (the video selfie of the authenticity wizard, the account review) cannot be passed by a branch, so their branches fail with an error of your own: the call rejects with it, and your program decides what to do, for example tell the owner or ask again later. Each screen's actions are on the challenge screens page.

ts
import {
  AndroidIgpapi,
  ChallengeAccountSuspendedScreen,
  ChallengeAccountTemporarilyLockedScreen,
  ChallengeAddBirthdayScreen,
  ChallengeAuthenticityWizardScreen,
  ChallengeAwaitingReviewScreen,
  ChallengeContactPointCodeScreen,
  ChallengeEscalationInformationScreen,
  ChallengeLoginReviewScreen,
  ChallengeRecaptchaScreen,
  ChallengeReviewContactPointChangeScreen,
  ChallengeScrapingWarningScreen,
  ChallengeSelectVerifyMethodScreen,
  ChallengeSetContactPointScreen,
  ChallengeSetNewPasswordScreen,
  ChallengeSubmitPhoneScreen,
  ChallengeTextCaptchaScreen,
  ChallengeThanksForProvidingInfoScreen,
  ChallengeVerifyCodeScreen,
  ChallengeWebRecaptchaScreen,
  ChallengeWebSetNewPasswordScreen,
  defineOtherwise,
  FeedTimelineScreen,
} from '@igpapi/android';
import { Data, Effect } from 'effect';
import { readFile } from 'node:fs/promises';
import { createInterface } from 'node:readline/promises';

/** What you know about the account, read at start-up: nothing here arrives during the run. */
const account = {
  phoneNumber: process.env['IG_PHONE'] ?? '',
  email: process.env['IG_EMAIL'] ?? '',
  newPassword: process.env['IG_NEW_PASSWORD'] ?? '',
  age: 30,
};

/** A step only the account's owner can pass (a video selfie, a review): the call stops with it, and your program acts on it later. */
class OwnerActionRequiredError extends Data.TaggedError('OwnerActionRequiredError')<{
  readonly screen: string;
}> {}

/** A person (or your system) did not answer in time. */
class AnswerUnavailableError extends Data.TaggedError('AnswerUnavailableError')<{
  readonly what: string;
}> {}

/** Waits for a code or a text at the moment a step asks for it ("Codes that arrive during the run"). */
const ask = (what: string) =>
  Effect.tryPromise({
    try: async () => {
      const terminal = createInterface({ input: process.stdin, output: process.stdout });
      try {
        return (await terminal.question(`${what}: `)).trim();
      } finally {
        terminal.close();
      }
    },
    catch: () => new AnswerUnavailableError({ what }),
  }).pipe(Effect.timeout('5 minutes'));

/** The captcha solver is yours: call your solving service with the site key and return the token. */
const solveRecaptcha = (siteKey: string) => ask(`The reCAPTCHA token for the site key ${siteKey}`);

const challenges = defineOtherwise({
  // Where the code goes, and the code itself.
  [ChallengeSelectVerifyMethodScreen.id]: method => method.chooseVerificationMethod('email'),
  [ChallengeVerifyCodeScreen.id]: screen => Effect.flatMap(ask('The security code'), code => screen.submitCode(code)),
  [ChallengeContactPointCodeScreen.id]: screen =>
    Effect.flatMap(ask('The confirmation code'), code => screen.submitCode(code)),
  // Details Instagram asks for.
  [ChallengeSubmitPhoneScreen.id]: screen => screen.submitPhoneNumber(account.phoneNumber),
  [ChallengeSetContactPointScreen.id]: screen => screen.submitContactPoint(account.email),
  [ChallengeSetNewPasswordScreen.id]: screen => screen.submitNewPassword(account.newPassword),
  [ChallengeAddBirthdayScreen.id]: screen => screen.submitAge(account.age),
  // Confirmations: the login was yours, the contact point change was yours.
  [ChallengeLoginReviewScreen.id]: screen => screen.confirmItWasMe(),
  [ChallengeReviewContactPointChangeScreen.id]: screen => screen.confirmContactPoint(),
  // Notices to acknowledge or close.
  [ChallengeAccountTemporarilyLockedScreen.id]: screen => screen.acknowledge(),
  [ChallengeScrapingWarningScreen.id]: screen => screen.acknowledge(),
  [ChallengeEscalationInformationScreen.id]: screen => screen.close(),
  [ChallengeThanksForProvidingInfoScreen.id]: screen => screen.close(),
  [ChallengeWebSetNewPasswordScreen.id]: screen => screen.close(),
  // A suspension: disagree with the decision, which moves the flow on to its next step.
  [ChallengeAccountSuspendedScreen.id]: screen => screen.disagreeWithDecision(),
  // Steps that wait for the account's owner or for Instagram's review: the branch fails with an error of yours,
  // the call rejects with it, and your program decides what to do (notify the owner, ask again later).
  [ChallengeAuthenticityWizardScreen.id]: screen => Effect.fail(new OwnerActionRequiredError({ screen: screen.id })),
  [ChallengeAwaitingReviewScreen.id]: screen => Effect.fail(new OwnerActionRequiredError({ screen: screen.id })),
  // Captchas: the token comes from your solver and the site key of the screen.
  [ChallengeRecaptchaScreen.id]: screen =>
    Effect.flatMap(solveRecaptcha(screen.data.siteKey), token => screen.submitRecaptcha(token)),
  [ChallengeWebRecaptchaScreen.id]: screen =>
    Effect.flatMap(ask('The reCAPTCHA token'), token => screen.submitRecaptcha(token)),
  [ChallengeTextCaptchaScreen.id]: screen =>
    Effect.flatMap(ask('The characters of the captcha'), characters => screen.submitCaptcha(characters)),
});

const saved = JSON.parse(await readFile('session.json', 'utf8'));

await AndroidIgpapi.launch({
  checkpoint: saved,
  screen: FeedTimelineScreen,
  otherwise: challenges,
  main: feed => Effect.sync(() => feed.items.length),
});

What runs again once the challenge closes ​

The effect the challenge interrupted runs again, whole. For a screen's action, the requests it had already sent before the refused one (a login's first requests, then its login request) are sent a second time, and the action goes on from there. The start is never re-run as a whole. An effect interrupted again right after its challenge closed does not loop: the call rejects with UnexpectedScreenError (expected is the screen the effect ran on, found is the challenge the app shows again). A failure that is not a challenge passes through untouched.

What otherwise cannot settle ​

A screen no branch covers, or one the app is still on after its branch ran, rejects the call with the typed UnexpectedScreenError. It names the screen the entry expected and the one the app shows, so you know which branch (or which screen:) is missing. An error a branch raises (a wrong code, a captcha that fails) rejects the call as the same tagged object.

Limits ​

  • otherwise answers what the screens run. A lower-level effect that is not a screen action, called in main, keeps its IgCheckpointError: answer it with catchTag (Effects inside main).
  • The challenge screens, with their actions, are on the challenge screens page.

The pages of this site are the README files of the repository. The API reference is generated from the source.