// ============================================================================ // INSTAGRAM PLATFORM ADAPTER // ============================================================================ // // Instagram uses two API surfaces: // 1. REST API (/api/v1/direct_v2/) — simpler, works for inbox listing // 2. GraphQL (/api/graphql) — what the web app uses, needed for thread messages // // Auth: CSRF token from csrftoken cookie + x-ig-app-id header. // The app ID (936619743392459) is hard-coded in the Instagram web app. // // ⚠️ STRICTEST anti-detection of all platforms. Instagram actively monitors // for automated access. Keep delays high, volumes low. // // ⚠️ VOLATILE: GraphQL doc_ids change with Instagram deploys. // // Verified: 2026-06-19 via HAR capture on instagram.com/direct // ============================================================================ import type { PlatformAdapter, ApiEndpoint, Conversation, Message, Participant, } from "../index.js"; // ── Constants ───────────────────────────────────────────────────────────── /** Instagram web app ID — hard-coded in their JS bundle */ export const IG_APP_ID = "936619743392459"; /** * ⚠️ VOLATILE — these doc_ids change when Instagram deploys new code. * Capture new IDs from Network tab on instagram.com/direct. * Filter by "graphql" and read `fb_api_req_friendly_name` + `doc_id` in the payload. * * ⚠️ THREAD-ID TRAP (the #1 cause of "GraphQL returns HTML / 0 messages"): * The REST inbox gives each thread a long `thread_id` * (e.g. "340282366841710301244259535260865788759"). GraphQL thread-fetch * and message-send do NOT accept that — they want the short `thread_fbid` / * `ig_thread_igid` (e.g. "1360243288701783"). The thread payload returns * BOTH (`thread_id` ↔ `thread_fbid`/`id`), so capture `thread_fbid` to drive * GraphQL. The REST per-thread endpoint, by contrast, takes the long * `thread_id` directly — which is why it's the reliable read path. */ export const DOC_IDS = { /** PolarisDirectInboxQuery — lists conversations */ inboxQuery: "27228858046797698", /** IGDSlideAsyncFetchAndInsertIGDViewerThreadQuery — fetches a thread's messages * (keyed by `thread_fbid`). Observed live 2026-06-21. */ threadFetch: "27110549851904846", /** IGDirectTextSendMutation — sends a text message (keyed by `ig_thread_igid`). * Observed live 2026-06-21. */ textSend: "26911679871773184", /** @deprecated IGDThreadDetailQuery — returned HTML as of 2026-06-21 (stale). * Kept for reference; use `threadFetch` or the REST per-thread endpoint. */ threadDetail: "27530161873341603", } as const; // ── Raw API Types ───────────────────────────────────────────────────────── /** REST inbox response (/api/v1/direct_v2/inbox/) */ export interface InstagramInboxResponse { inbox: { threads: InstagramRawThread[]; has_older: boolean; unseen_count: number; }; pending_requests_total: number; } export interface InstagramRawThread { thread_id: string; thread_title: string; users: InstagramRawUser[]; items: InstagramRawItem[]; last_activity_at: string; // epoch microseconds as string read_state: number; is_group: boolean; named: boolean; } export interface InstagramRawUser { pk: number | string; username: string; full_name: string; profile_pic_url: string; is_verified: boolean; } export interface InstagramRawItem { item_id: string; user_id: number; timestamp: string; // epoch microseconds as string item_type: "text" | "media" | "reel_share" | "link" | "like" | "action_log" | string; text?: string; media?: { image_versions2?: { candidates: Array<{ url: string; width: number }> } }; link?: { text: string; link_url: string }; } /** GraphQL thread detail response */ export interface InstagramThreadDetailResponse { data: { get_slide_thread_nullable: { as_ig_direct_thread: { slide_messages: { edges: Array<{ node: InstagramRawGraphQLMessage; }>; }; }; }; }; } export interface InstagramRawGraphQLMessage { message_id?: string; /** the IGDSlideAsyncFetch query returns `id` rather than `message_id` */ id?: string; sender_fbid: string; sender?: { user_dict?: { username: string; full_name: string; }; name?: string; }; /** Present for TEXT + reactions ("🔥"). Empty for shares/stories. */ text_body: string; /** Human-readable summary of EVERY message, incl. non-text ones — * e.g. "You: 🔥", "You: replied to your story". The best fallback when * `text_body` is empty. Note the leading "You: " / ": " prefix. */ igd_snippet?: string; timestamp_ms: string; // epoch ms as string /** Observed: TEXT, MONTAGE_SHARE_XMA (story reply/reaction), and others. */ content_type: "TEXT" | "MEDIA" | "LINK" | "MONTAGE_SHARE_XMA" | string; content?: { __typename?: string; text_body?: string }; } // ── Normalization ───────────────────────────────────────────────────────── export function normalizeParticipant(user: InstagramRawUser): Participant { return { id: String(user.pk), name: user.full_name || user.username, username: user.username, avatarUrl: user.profile_pic_url, profileUrl: `https://www.instagram.com/${user.username}/`, }; } export function normalizeConversation( thread: InstagramRawThread, ): Conversation { const participants = thread.users.map(normalizeParticipant); const firstItem = thread.items[0]; const lastMessage: Message | null = firstItem ? { id: firstItem.item_id, senderId: String(firstItem.user_id), text: firstItem.text ?? "", timestamp: new Date(Number(firstItem.timestamp) / 1000).toISOString(), type: firstItem.item_type === "text" ? "text" : "other", } : null; return { platform: "instagram", conversationId: thread.thread_id, conversationType: thread.is_group ? "group_dm" : "dm", name: thread.named ? thread.thread_title : participants.map((p) => p.name || p.username).join(", "), participants, lastMessage, lastActivityAt: new Date( Number(thread.last_activity_at) / 1000, ).toISOString(), unreadCount: 0, // REST API doesn't reliably return per-thread unread }; } export function normalizeMessage(msg: InstagramRawGraphQLMessage): Message { // text_body covers TEXT + reactions; fall back to igd_snippet (strip the // "You: " / ": " prefix) for shares/stories so nothing renders empty. const snippet = msg.igd_snippet?.replace(/^[^:]{1,40}:\s/, ""); return { id: msg.message_id ?? msg.id ?? "", senderId: String(msg.sender_fbid), senderName: msg.sender?.user_dict?.full_name ?? msg.sender?.user_dict?.username ?? msg.sender?.name, text: msg.text_body || snippet || `[${msg.content_type ?? "message"}]`, timestamp: new Date(Number(msg.timestamp_ms)).toISOString(), type: msg.content_type === "TEXT" ? "text" : "other", }; } // ── Adapter Definition ──────────────────────────────────────────────────── const listConversations: ApiEndpoint = { name: "direct_v2/inbox (REST)", method: "GET", urlTemplate: "https://www.instagram.com/api/v1/direct_v2/inbox/", headers: { "x-csrftoken": "{csrfToken}", "x-ig-app-id": IG_APP_ID, accept: "application/json", }, description: "REST endpoint for listing DM threads. Returns threads with participant " + "info and the most recent item per thread. Simpler than GraphQL and " + "proven working as of June 2026.", rateLimiting: "HIGH RISK. Instagram is the strictest platform. Do not call more than " + "once per 5 minutes. Single sync per session is safest.", verification: { status: "live_verified", lastChecked: "2026-06-21", source: "hydra_runtime", notes: "Used by Hydra extension for inbox reads; keep volume very low.", }, }; const fetchMessages: ApiEndpoint = { name: "IGDSlideAsyncFetchAndInsertIGDViewerThreadQuery (GraphQL)", method: "POST", urlTemplate: "https://www.instagram.com/api/graphql", headers: { "x-csrftoken": "{csrfToken}", "x-ig-app-id": IG_APP_ID, "x-fb-friendly-name": "IGDSlideAsyncFetchAndInsertIGDViewerThreadQuery", "x-asbd-id": "359341", "content-type": "application/x-www-form-urlencoded", accept: "*/*", }, bodyTemplate: { doc_id: DOC_IDS.threadFetch, variables: JSON.stringify({ thread_fbid: "{threadFbid}", // ⚠️ NOT the long REST thread_id — see DOC_IDS note min_uq_seq_id: "{cursor}", __relay_internal__pv__IGDPinnedThreadsRenderEnabledGKrelayprovider: true, __relay_internal__pv__IGDMaxUnreadMessagesCountrelayprovider: 5, __relay_internal__pv__PolarisAIGMAccountLabelEnabledrelayprovider: false, __relay_internal__pv__IGDThreadListActionsEnabledGKrelayprovider: true, }), fb_api_req_friendly_name: "IGDSlideAsyncFetchAndInsertIGDViewerThreadQuery", }, description: "GraphQL thread-fetch. Returns get_slide_thread_nullable.as_ig_direct_thread" + ".slide_messages.edges[].node. Keyed by thread_fbid (short id), NOT the long " + "REST thread_id. doc_id is VOLATILE. In practice the REST per-thread endpoint " + "(fetchMessagesREST) is more reliable because we already have the thread_id.", rateLimiting: "VERY HIGH RISK. Space requests 2-5 seconds apart. " + "Max 5 threads per sync. Unusual patterns trigger account warnings.", verification: { status: "documented", lastChecked: "2026-06-21", source: "manual_capture", notes: "Documented from capture; REST per-thread endpoint is preferred when long thread_id is available.", }, }; const sendMessage: ApiEndpoint = { name: "IGDirectTextSendMutation (GraphQL, WRITE)", method: "POST", urlTemplate: "https://www.instagram.com/api/graphql", headers: { "x-csrftoken": "{csrfToken}", "x-ig-app-id": IG_APP_ID, "x-fb-friendly-name": "IGDirectTextSendMutation", "x-fb-lsd": "{lsd}", // required for writes; read from the page's config "x-asbd-id": "359341", "content-type": "application/x-www-form-urlencoded", accept: "*/*", }, bodyTemplate: { doc_id: DOC_IDS.textSend, fb_dtsg: "{fbDtsg}", // required CSRF-style token for writes, from page config variables: JSON.stringify({ ig_thread_igid: "{threadFbid}", // the short thread_fbid, same as fetch offline_threading_id: "{otid}", // client-generated 19-digit id text: { sensitive_string_value: "{text}" }, send_attribution: "igd_web_chat_tab:in_thread", recipient_igids: null, mentioned_user_ids: [], }), fb_api_req_friendly_name: "IGDirectTextSendMutation", }, description: "WRITE. Sends a text DM. Executes in the page context (same origin/session) " + "so it's indistinguishable from the user. Requires fb_dtsg + x-fb-lsd from the " + "page config. Response: { xig_direct_text_send_with_slide_messaging_response: " + "{ message_id, timestamp_ms } }. Gate behind explicit user approval.", rateLimiting: "MAXIMUM RISK. Writes are the most heavily monitored action. Human cadence " + "only — one message at a time, on explicit approval. Never bulk-send.", verification: { status: "experimental", lastChecked: "2026-06-21", source: "manual_capture", notes: "Documented for completeness. Do not treat as product-safe without a fresh controlled send test.", }, }; export const instagramAdapter: PlatformAdapter = { platform: "instagram", name: "Instagram", color: "#E1306C", emoji: "📷", auth: { requiredTokens: ["csrfToken"], extractionMethod: "Content script on *.instagram.com extracts csrftoken cookie value.", sources: [ { type: "cookie", name: "csrfToken", selector: "csrftoken", description: "The csrftoken cookie. Used as x-csrftoken header.", }, ], }, endpoints: { listConversations, fetchMessages, sendMessage, // REST per-thread — the RELIABLE read path (keyed by the long thread_id we // already have from the inbox). Prefer this over the GraphQL fetch. fetchMessagesREST: { name: "direct_v2/threads/{threadId} (REST, primary read)", method: "GET", urlTemplate: "https://www.instagram.com/api/v1/direct_v2/threads/{threadId}/", headers: { "x-csrftoken": "{csrfToken}", "x-ig-app-id": IG_APP_ID, accept: "application/json", }, description: "Per-thread messages, keyed by the long thread_id from the inbox. " + "Returns thread.items[] with item_type (text, reel_share, story_share, " + "like, media…). Non-text items carry no `text` — label them by item_type. " + "More reliable than GraphQL because we already hold the thread_id.", rateLimiting: "Space 2-5s apart, max 5 threads per sync.", verification: { status: "live_verified", lastChecked: "2026-06-21", source: "hydra_runtime", notes: "Preferred Hydra read path for full thread fetches because it uses the inbox's long thread_id.", }, }, }, antiDetection: { minDelayMs: 2000, maxDelayMs: 5000, maxMessageThreads: 5, notes: "Instagram is the STRICTEST platform. They actively detect automation. " + "NEVER inject DOM elements. NEVER expose web-accessible resources. " + "NEVER modify the Instagram UI. Keep volume extremely low. " + "A spike from 5 DMs/day to 50 will trigger algorithmic flags. " + "The x-ig-app-id header is REQUIRED — requests without it are rejected.", }, buildDeepLink: (conv) => { return `https://instagram.com/direct/t/${conv.conversationId}`; }, lastVerified: "2026-06-21", volatileIds: DOC_IDS, changelog: [ { date: "2026-06-21", description: "Live capture: IGDThreadDetailQuery (27530161873341603) now returns HTML " + "(stale). Current thread-fetch is IGDSlideAsyncFetchAndInsertIGDViewerThread" + "Query (27110549851904846), keyed by thread_fbid — NOT the long REST " + "thread_id (the #1 fetch bug). Documented the REST per-thread endpoint as " + "the reliable read path, the thread_id↔thread_fbid mapping, igd_snippet as " + "the non-text label source, and added the IGDirectTextSendMutation write " + "endpoint (26911679871773184).", }, { date: "2026-06-19", description: "Initial adapter. REST for inbox, GraphQL for thread messages. " + "Both verified working. GraphQL doc_ids captured from HAR.", }, ], };