Appearance
A complete session, every error answered
This page builds on Your first program. It is one program, the whole session, in the order a person walks it: it starts the Android app on a virtual device, logs in (answering the two-factor screens a protected account shows), saves the session, lands on the timeline, walks it by swipes, opens the first post, likes, comments on and reposts it, then opens Reels and watches a few. Every error it can meet is answered. You can run it as it is, after Get access and install, with IG_USERNAME, IG_PASSWORD and IG_TOTP_SECRET set (leave the last one empty for an account without an authenticator app).
What to know before you read it:
- The account and its secrets are read from the environment (
IG_USERNAME,IG_PASSWORD,IG_TOTP_SECRET). A code that Instagram sends during the run is never read at start-up: the challenge branch below waits for it when it is asked for (Codes that arrive during the run). How the authenticator setup key works is on Log in. seed: usernamegives the account the same virtual phone on every run (How the Simulator works).- The session is saved the moment the login reaches the feed, before any action: a stop later in the program leaves the login behind in
session.json, a two-factor login included. More in Keep a session. - A challenge is not an error of
main. Instagram may interrupt a request with a challenge; the screens answer it with the branchesdefineOtherwisedescribes once, run the interrupted effect again, andmainnever sees it. More in Challenges: answer them once withotherwise. - Every other error is answered by its tag with
catchTag/catchTags; there is nocatchAll. Each is answered where it belongs: the login's errors next to the login, the feed actions' next to the feed, and only what any request can fail with (what Instagram decided, the network, the screens) at the end of the session. The tag of an error is the name of its class. The exhaustiveness check at the end of the example fails the build when an effect gains an error the example does not answer. More in Errors. post.openComments({})takes an options object;{}means no options.
ts
// The exhaustive example the quick start shows, typechecked as written.
import {
type AndroidAppLaunchLoginScreen,
AndroidIgpapi,
type AndroidMainError,
ChallengeSelectVerifyMethodScreen,
ChallengeStepNotModelledError,
ChallengeVerifyCodeScreen,
defineOtherwise,
FeedTimelinePostItem,
FeedTimelineScreen,
LoginTwoFactorApproveFromAnotherDeviceScreen,
LoginTwoFactorChooseMethodScreen,
ReelsViewerReelItem,
totpCodeOf,
UnexpectedScreenError,
} from '@igpapi/android';
import { Data, Effect } from 'effect';
import { writeFile } from 'node:fs/promises';
import { createInterface } from 'node:readline/promises';
/** Placeholders: the account and its secrets come from the environment. */
const username = process.env['IG_USERNAME'] ?? '';
const password = process.env['IG_PASSWORD'] ?? '';
// The account's authenticator setup key: the base32 secret Instagram showed when
// the account set its authenticator app up. The six-digit code rotates every
// 30 s, so `totpCodeOf` computes it from the key at the moment of submit, never
// once at startup.
const totpSecret = process.env['IG_TOTP_SECRET'] ?? '';
/** The code did not arrive: nobody typed it, or your system did not answer. */
class CodeUnavailableError extends Data.TaggedError('CodeUnavailableError')<{
readonly kind: 'email';
}> {}
/**
* Waits for the code Instagram sends by email, at the moment a challenge asks for it
* ("Codes that arrive during the run"). Here a person types it at the terminal; your program
* can read an inbox instead.
*/
const readCode = (kind: 'email') =>
Effect.tryPromise({
try: async () => {
const terminal = createInterface({ input: process.stdin, output: process.stdout });
try {
return (await terminal.question(`The ${kind} code: `)).trim();
} finally {
terminal.close();
}
},
catch: () => new CodeUnavailableError({ kind }),
}).pipe(Effect.timeout('5 minutes'));
/** The session cannot go on without the person: the step it stopped on, and why. */
class SessionStoppedError extends Data.TaggedError('SessionStoppedError')<{
readonly step: string;
readonly detail: string;
}> {}
/** Stops the session at a step. Every handler below is an Effect that stops it with the reason. */
const stopped = (step: string, detail: string) => Effect.fail(new SessionStoppedError({ step, detail }));
/** How many approval polls a headless caller waits before it takes "Try another way" (the app polls every 7.5 s). */
const APPROVAL_POLLS = 8;
/**
* The challenge branches, 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 call, so a challenge Instagram raises anywhere in the program is
* answered the same way.
*/
const challenges = defineOtherwise({
[ChallengeSelectVerifyMethodScreen.id]: method => method.chooseVerificationMethod('email'),
[ChallengeVerifyCodeScreen.id]: screen => Effect.flatMap(readCode('email'), code => screen.submitCode(code)),
});
/**
* Steps 1 and 2: logs in on the login screen a fresh install shows. Its `logIn`
* continues the same process through the app's launch and answers the feed,
* or — for a two-factor account — the screen that asks for the second step. The
* errors of the login and the two-factor screens are answered right here.
*/
const logInToTheFeed = (home: AndroidAppLaunchLoginScreen) =>
Effect.gen(function* () {
const next = yield* home.logIn({ username, password });
if (FeedTimelineScreen.is(next)) return next;
if (LoginTwoFactorApproveFromAnotherDeviceScreen.is(next)) {
// The person approves the login on another device; the caller owns the pace.
// A headless caller bounds the wait and takes "Try another way" past the bound.
let check = yield* next.checkApproval();
for (let poll = 0; check.status === 'still-waiting' && poll < APPROVAL_POLLS; poll += 1) {
yield* Effect.sleep('7500 millis');
check = yield* next.checkApproval();
}
if (check.status !== 'still-waiting') return check;
// Nobody approved in time: "Try another way" → the method picker → the
// authentication app → the code computed from the setup key right now.
const picker = yield* next.tryAnotherWay();
const codeScreen = yield* picker.chooseAuthenticationApp();
const done = yield* codeScreen.submitCode(totpCodeOf(totpSecret));
if (!FeedTimelineScreen.is(done))
return yield* stopped('two-factor', 'the code led to a screen the login does not reach');
return done;
}
if (LoginTwoFactorChooseMethodScreen.is(next)) {
// The server's default is another method: the authentication app is the one with an action.
const codeScreen = yield* next.chooseAuthenticationApp();
const done = yield* codeScreen.submitCode(totpCodeOf(totpSecret));
if (!FeedTimelineScreen.is(done))
return yield* stopped('two-factor', 'the code led to a screen the login does not reach');
return done;
}
return yield* stopped('login', 'the login escalated to a screen the example does not handle');
}).pipe(
Effect.catchTags({
// ── The login ───────────────────────────────────────────────────────
// The password is wrong.
IgLoginBadPasswordError: () => stopped('login', 'The password is wrong.'),
// The server refused the login with an answer the flow does not recognise.
LoginBloksRejectedError: () => stopped('login', 'Instagram rejected the login.'),
// The login escalated to last-resort account recovery instead of a session.
LoginAccountRecoveryRequiredError: () => stopped('login', 'Instagram offers account recovery.'),
LoginAccountRecoveryContinueError: () => stopped('login', 'The recovery dialog could not continue.'),
// The login answer, or the screen behind it, could not be read: the excerpt says what the server sent.
LoginBloksParseError: error => stopped('login', `The login answer could not be read: ${error.bodyExcerpt}`),
// The session holds no password-encryption key, so the password cannot be sent.
PasswordEncryptionKeysUnavailableError: () => stopped('login', 'The device has no encryption key.'),
// The process was already launched by an earlier login.
AppAlreadyStartedError: () => stopped('login', 'The process was already started.'),
// ── Two-factor ────────────────────────────────────────────────────────
// The login was declined on the other device.
LoginTwoFactorApprovalDeclinedError: () => stopped('two-factor', 'The login was declined.'),
// The authentication app's code was rejected.
LoginTwoFactorAuthenticationAppCodeRejectedError: () => stopped('two-factor', 'The code was wrong.'),
// A server-issued value the next call must echo is missing or unknown.
TwoFactorBloksIssuedIdMissingError: () => stopped('two-factor', 'A server-issued id is missing.'),
TwoFactorBloksLoginResponseNotFoundError: () => stopped('two-factor', 'The code outcome is unreadable.'),
LoginTwoFactorEntrypointParseError: () => stopped('two-factor', 'The entrypoint could not be read.'),
AccountRecoveryBloksIssuedParamsMissingError: () => stopped('recovery', 'A server-issued parameter is missing.'),
}),
);
/**
* Step 3, on the feed the login reached and before anything is done with it:
* saves the session. The feed is the session — nothing is above it yet — so its
* checkpoint restores this login in a later program ("Keep a session"), a
* two-factor login included, whatever the steps after this one come to.
*/
const saveTheSession = (feed: Effect.Success<ReturnType<typeof logInToTheFeed>>) =>
Effect.gen(function* () {
const checkpoint = yield* feed.checkpoint();
// A failed write is the program's own environment, not an answer of Instagram: it ends the call.
yield* Effect.promise(() => writeFile('session.json', JSON.stringify(checkpoint)));
return feed;
}).pipe(
Effect.catchTags({
// An item's key is not unique in its list, so the checkpoint's trail would
// be ambiguous.
ScreenTrailAmbiguousItemError: () => stopped('screen', 'An item of the feed is not an identity.'),
}),
);
/**
* Steps 4 and 5, on the saved feed: walks the timeline by swipes,
* opens the first post and likes, comments and reposts it, then opens Reels
* and watches a few. The errors only the feed's actions raise are answered
* right here.
*/
const useTheFeed = (feed: Effect.Success<ReturnType<typeof logInToTheFeed>>) =>
Effect.gen(function* () {
// The timeline is a swipeable feed: `current` is the item in front.
let item = feed.current;
for (let swipes = 0; item !== undefined && !FeedTimelinePostItem.is(item) && swipes < 10; swipes += 1) {
item = yield* feed.swipeToNext();
}
if (item === undefined || !FeedTimelinePostItem.is(item)) {
return yield* stopped('timeline', 'no post in front');
}
// `open()` opens the post screen. `data` is what the screen shows.
const post = yield* item.open();
console.log('post:', post.data);
// An action's result carries the server answers its run received under `raw`.
const liked = yield* post.like();
console.log('like answers:', liked.raw);
const comments = yield* post.openComments({});
yield* comments.comment('Great shot!');
yield* comments.back();
yield* post.repost('Worth a second look');
yield* post.back();
// The bottom bar's Reels tab opens the reels viewer, a swipeable feed too.
const viewer = yield* feed.openReelsTab();
const watched: string[] = [];
let reel = viewer.current;
for (let seen = 0; reel !== undefined && seen < 3; seen += 1) {
if (ReelsViewerReelItem.is(reel)) watched.push(reel.key);
// Viewing is the item's visit: the swipe leaves this reel and enters the next.
reel = yield* viewer.swipeToNext();
}
return { _tag: 'Watched' as const, post: post.data.mediaId, reels: watched };
}).pipe(
Effect.catchTags({
// The repost text is too long, or the build takes none.
MediaRepostTextTooLongError: () => stopped('repost', 'The repost text is too long.'),
// `back` has no screen below the root.
}),
);
/**
* The whole session: log in, save the session, use the feed. Every error the effects can fail
* with is answered by its tag — no `catchAll`, no untyped catch. The steps
* above answer the errors only they raise; the handlers here answer what any
* request of any step can fail with. A challenge is not one of them: the
* screens answer it with `challenges` and run the interrupted effect again, so
* `main` never sees an `IgCheckpointError` (that is what `AndroidMainError`
* reads). The exhaustiveness check at the end of this file pins that the list is
* complete.
*/
const session = (home: AndroidAppLaunchLoginScreen) =>
logInToTheFeed(home).pipe(
Effect.flatMap(saveTheSession),
Effect.flatMap(useTheFeed),
Effect.catchTags({
// ── What Instagram decided ────────────────────────────────────────────
// Instagram suspended, disabled or deactivated the account.
IgAccountSuspendedError: () => stopped('account', 'The account was suspended.'),
IgAccountDisabledError: () => stopped('account', 'The account was disabled.'),
IgInactiveUserError: () => stopped('account', 'The account is inactive.'),
IgPrivateUserError: () => stopped('account', 'The account is private.'),
// The session is gone.
IgLoginRequiredError: () => stopped('session', 'Instagram asks for a login.'),
IgUserLoggedOutError: () => stopped('session', 'The session was logged out.'),
IgCookieConsentRequiredError: () => stopped('session', 'Instagram asks for cookie consent.'),
// Instagram blocked the device, the address or the traffic.
IgSentryBlockError: () => stopped('blocked', 'Instagram refused this device.'),
IgIpBlockError: () => stopped('blocked', 'Instagram refused this address.'),
IgSpamError: () => stopped('blocked', 'Instagram flagged the traffic as spam.'),
// Instagram limited the traffic.
IgRateLimitError: () => stopped('limited', 'Instagram rate-limited the device.'),
IgBloks429Error: () => stopped('limited', 'Instagram throttled the screens.'),
IgNotFoundError: () => stopped('request', 'Instagram found no such resource.'),
// A request of any step can be answered with a demand for a code, which no
// screen carries; a wrong one is refused the same way.
IgLoginTwoFactorRequiredError: () => stopped('two-factor', 'Instagram asks for a code.'),
// ── The network and the answer ────────────────────────────────────────
TransportError: error => stopped('network', error.message),
ParseError: error => stopped('answer', error.message),
SchemaError: error => stopped('answer', error.message),
GraphqlRootFieldMissingError: () => stopped('answer', 'The answer carries no root field.'),
RequestError: error => stopped('request', `HTTP ${error.status}`),
// ── The screens ───────────────────────────────────────────────────────
ScreenEnterConcurrentError: () => stopped('screen', 'Another transition was running.'),
ScreenEdgeInputError: () => stopped('screen', 'The next screen refused the input.'),
ScreenDataError: error => stopped('screen', `The screen answer drifted: ${error.detail}`),
// The slide the program swiped a carousel to is not one of the post's, or the post's slides are not loaded.
CarouselSlideNotFoundError: error => stopped('screen', `The post holds no slide ${error.index}.`),
CarouselSlidesNotLoadedError: error =>
stopped('screen', `Slide ${error.index} is not loaded: open the post again.`),
}),
// Every step stops the session the same way: with a refusal the caller reads.
Effect.catchTag('SessionStoppedError', error =>
Effect.succeed({ _tag: 'Refused' as const, step: error.step, detail: error.detail }),
),
);
// ─── Exhaustiveness ─────────────────────────────────────────────────────────
/**
* The error channel after the handlers above. It must be `never`: a `false`
* here means an error the example does not answer yet, and the example cannot
* silently miss one that a later change adds to an effect. `AndroidMainError`
* is what a screen action or an edge can still fail with — everything but the
* challenge, which `otherwise` answered before `main` could see it.
*/
type NoUnhandledErrors<E> = [E] extends [never] ? true : false;
export const everyErrorIsAnswered: NoUnhandledErrors<AndroidMainError<Effect.Error<ReturnType<typeof session>>>> = true;
// ─── The run the README shows ───────────────────────────────────────────────
// One call is one app process, birth to death: it launches the app on the
// virtual device, hands the program the login screen, closes the process when
// the program ends, and answers every challenge Instagram raises with
// `challenges`. What the branches cannot settle rejects the call, never `main`.
const answer = await AndroidIgpapi.install({
seed: username,
otherwise: challenges,
main: session,
}).then(
(done: Effect.Success<ReturnType<typeof session>>) => ({ _tag: 'Done' as const, done }),
(rejected: unknown) => ({ _tag: 'Rejected' as const, error: rejected }),
);
if (answer._tag === 'Rejected') {
// A typed failure of the call — the same tagged object: the start's own, a
// branch's, or the screen no branch answers.
if (answer.error instanceof UnexpectedScreenError) {
console.log('no branch answers the screen:', answer.error.expected, '->', answer.error.found);
} else if (answer.error instanceof ChallengeStepNotModelledError) {
console.log('the challenge asks for a step the Simulator cannot answer:', answer.error.surface);
} else {
throw answer.error;
}
} else {
switch (answer.done._tag) {
case 'Watched':
console.log('watched the post', answer.done.post, 'and the reels', answer.done.reels);
break;
case 'Refused':
console.log('the session stopped:', answer.done.step, answer.done.detail);
break;
}
}
export { answer, session };Where to go next
- How the Simulator works: the model behind the code.
- Ways to start the app: the
install,launchandresume…entries, and when to choose each. - Work with screens: handles, edges,
back()and the screen in focus. - Screens: what a person can do on each screen.
- Errors: the typed errors and what to do with each.