diff --git a/extension/dist/background.js b/extension/dist/background.js index 1a354bb1..3ba6120d 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,739 +1,1127 @@ -const DAEMON_PORT = 19825; -const DAEMON_HOST = "localhost"; -const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; -const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; -const WS_RECONNECT_BASE_DELAY = 2e3; -const WS_RECONNECT_MAX_DELAY = 5e3; - -const attached = /* @__PURE__ */ new Set(); +//#region src/protocol.ts +/** Default daemon port */ +var DAEMON_PORT = 19825; +var DAEMON_HOST = "localhost"; +var DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; +/** Lightweight health-check endpoint — probed before each WebSocket attempt. */ +var DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; +/** Base reconnect delay for extension WebSocket (ms) */ +var WS_RECONNECT_BASE_DELAY = 2e3; +/** Max reconnect delay (ms) — kept short since daemon is long-lived */ +var WS_RECONNECT_MAX_DELAY = 5e3; +//#endregion +//#region src/cdp.ts +/** +* CDP execution via chrome.debugger API. +* +* chrome.debugger only needs the "debugger" permission — no host_permissions. +* It can attach to any http/https tab. Avoid chrome:// and chrome-extension:// +* tabs (resolveTabId in background.ts filters them). +*/ +var attached = /* @__PURE__ */ new Set(); +var networkCaptures = /* @__PURE__ */ new Map(); +/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ function isDebuggableUrl$1(url) { - if (!url) return true; - return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); + if (!url) return true; + return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); } async function ensureAttached(tabId, aggressiveRetry = false) { - try { - const tab = await chrome.tabs.get(tabId); - if (!isDebuggableUrl$1(tab.url)) { - attached.delete(tabId); - throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`); - } - } catch (e) { - if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e; - attached.delete(tabId); - throw new Error(`Tab ${tabId} no longer exists`); - } - if (attached.has(tabId)) { - try { - await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { - expression: "1", - returnByValue: true - }); - return; - } catch { - attached.delete(tabId); - } - } - const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2; - const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500; - let lastError = ""; - for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) { - try { - try { - await chrome.debugger.detach({ tabId }); - } catch { - } - await chrome.debugger.attach({ tabId }, "1.3"); - lastError = ""; - break; - } catch (e) { - lastError = e instanceof Error ? e.message : String(e); - if (attempt < MAX_ATTACH_RETRIES) { - console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`); - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); - try { - const tab = await chrome.tabs.get(tabId); - if (!isDebuggableUrl$1(tab.url)) { - lastError = `Tab URL changed to ${tab.url} during retry`; - break; - } - } catch { - lastError = `Tab ${tabId} no longer exists`; - break; - } - } - } - } - if (lastError) { - let finalUrl = "unknown"; - let finalWindowId = "unknown"; - try { - const tab = await chrome.tabs.get(tabId); - finalUrl = tab.url ?? "undefined"; - finalWindowId = String(tab.windowId); - } catch { - } - console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`); - const hint = lastError.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : ""; - throw new Error(`attach failed: ${lastError}${hint}`); - } - attached.add(tabId); - try { - await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); - } catch { - } + try { + const tab = await chrome.tabs.get(tabId); + if (!isDebuggableUrl$1(tab.url)) { + attached.delete(tabId); + throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`); + } + } catch (e) { + if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e; + attached.delete(tabId); + throw new Error(`Tab ${tabId} no longer exists`); + } + if (attached.has(tabId)) try { + await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + expression: "1", + returnByValue: true + }); + return; + } catch { + attached.delete(tabId); + } + const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2; + const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500; + let lastError = ""; + for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) try { + try { + await chrome.debugger.detach({ tabId }); + } catch {} + await chrome.debugger.attach({ tabId }, "1.3"); + lastError = ""; + break; + } catch (e) { + lastError = e instanceof Error ? e.message : String(e); + if (attempt < MAX_ATTACH_RETRIES) { + console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`); + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + try { + const tab = await chrome.tabs.get(tabId); + if (!isDebuggableUrl$1(tab.url)) { + lastError = `Tab URL changed to ${tab.url} during retry`; + break; + } + } catch { + lastError = `Tab ${tabId} no longer exists`; + break; + } + } + } + if (lastError) { + let finalUrl = "unknown"; + let finalWindowId = "unknown"; + try { + const tab = await chrome.tabs.get(tabId); + finalUrl = tab.url ?? "undefined"; + finalWindowId = String(tab.windowId); + } catch {} + console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`); + const hint = lastError.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : ""; + throw new Error(`attach failed: ${lastError}${hint}`); + } + attached.add(tabId); + try { + await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); + } catch {} } async function evaluate(tabId, expression, aggressiveRetry = false) { - const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; - for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) { - try { - await ensureAttached(tabId, aggressiveRetry); - const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { - expression, - returnByValue: true, - awaitPromise: true - }); - if (result.exceptionDetails) { - const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; - throw new Error(errMsg); - } - return result.result?.value; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const isNavigateError = msg.includes("Inspected target navigated") || msg.includes("Target closed"); - const isAttachError = isNavigateError || msg.includes("attach failed") || msg.includes("Debugger is not attached") || msg.includes("chrome-extension://"); - if (isAttachError && attempt < MAX_EVAL_RETRIES) { - attached.delete(tabId); - const retryMs = isNavigateError ? 200 : 500; - await new Promise((resolve) => setTimeout(resolve, retryMs)); - continue; - } - throw e; - } - } - throw new Error("evaluate: max retries exhausted"); -} -const evaluateAsync = evaluate; + const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; + for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) try { + await ensureAttached(tabId, aggressiveRetry); + const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true + }); + if (result.exceptionDetails) { + const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; + throw new Error(errMsg); + } + return result.result?.value; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const isNavigateError = msg.includes("Inspected target navigated") || msg.includes("Target closed"); + if ((isNavigateError || msg.includes("attach failed") || msg.includes("Debugger is not attached") || msg.includes("chrome-extension://")) && attempt < MAX_EVAL_RETRIES) { + attached.delete(tabId); + const retryMs = isNavigateError ? 200 : 500; + await new Promise((resolve) => setTimeout(resolve, retryMs)); + continue; + } + throw e; + } + throw new Error("evaluate: max retries exhausted"); +} +var evaluateAsync = evaluate; +/** +* Capture a screenshot via CDP Page.captureScreenshot. +* Returns base64-encoded image data. +*/ async function screenshot(tabId, options = {}) { - await ensureAttached(tabId); - const format = options.format ?? "png"; - if (options.fullPage) { - const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); - const size = metrics.cssContentSize || metrics.contentSize; - if (size) { - await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { - mobile: false, - width: Math.ceil(size.width), - height: Math.ceil(size.height), - deviceScaleFactor: 1 - }); - } - } - try { - const params = { format }; - if (format === "jpeg" && options.quality !== void 0) { - params.quality = Math.max(0, Math.min(100, options.quality)); - } - const result = await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params); - return result.data; - } finally { - if (options.fullPage) { - await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => { - }); - } - } + await ensureAttached(tabId); + const format = options.format ?? "png"; + if (options.fullPage) { + const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); + const size = metrics.cssContentSize || metrics.contentSize; + if (size) await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { + mobile: false, + width: Math.ceil(size.width), + height: Math.ceil(size.height), + deviceScaleFactor: 1 + }); + } + try { + const params = { format }; + if (format === "jpeg" && options.quality !== void 0) params.quality = Math.max(0, Math.min(100, options.quality)); + return (await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params)).data; + } finally { + if (options.fullPage) await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => {}); + } } +/** +* Set local file paths on a file input element via CDP DOM.setFileInputFiles. +* This bypasses the need to send large base64 payloads through the message channel — +* Chrome reads the files directly from the local filesystem. +* +* @param tabId - Target tab ID +* @param files - Array of absolute local file paths +* @param selector - CSS selector to find the file input (optional, defaults to first file input) +*/ async function setFileInputFiles(tabId, files, selector) { - await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "DOM.enable"); - const doc = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument"); - const query = selector || 'input[type="file"]'; - const result = await chrome.debugger.sendCommand({ tabId }, "DOM.querySelector", { - nodeId: doc.root.nodeId, - selector: query - }); - if (!result.nodeId) { - throw new Error(`No element found matching selector: ${query}`); - } - await chrome.debugger.sendCommand({ tabId }, "DOM.setFileInputFiles", { - files, - nodeId: result.nodeId - }); + await ensureAttached(tabId); + await chrome.debugger.sendCommand({ tabId }, "DOM.enable"); + const doc = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument"); + const query = selector || "input[type=\"file\"]"; + const result = await chrome.debugger.sendCommand({ tabId }, "DOM.querySelector", { + nodeId: doc.root.nodeId, + selector: query + }); + if (!result.nodeId) throw new Error(`No element found matching selector: ${query}`); + await chrome.debugger.sendCommand({ tabId }, "DOM.setFileInputFiles", { + files, + nodeId: result.nodeId + }); +} +async function insertText(tabId, text) { + await ensureAttached(tabId); + await chrome.debugger.sendCommand({ tabId }, "Input.insertText", { text }); +} +function normalizeCapturePatterns(pattern) { + return String(pattern || "").split("|").map((part) => part.trim()).filter(Boolean); +} +function shouldCaptureUrl(url, patterns) { + if (!url) return false; + if (!patterns.length) return true; + return patterns.some((pattern) => url.includes(pattern)); +} +function normalizeHeaders(headers) { + if (!headers || typeof headers !== "object") return {}; + const out = {}; + for (const [key, value] of Object.entries(headers)) out[String(key)] = String(value); + return out; +} +function getOrCreateNetworkCaptureEntry(tabId, requestId, fallback) { + const state = networkCaptures.get(tabId); + if (!state) return null; + const existingIndex = state.requestToIndex.get(requestId); + if (existingIndex !== void 0) return state.entries[existingIndex] || null; + const url = fallback?.url || ""; + if (!shouldCaptureUrl(url, state.patterns)) return null; + const entry = { + kind: "cdp", + url, + method: fallback?.method || "GET", + requestHeaders: fallback?.requestHeaders || {}, + timestamp: Date.now() + }; + state.entries.push(entry); + state.requestToIndex.set(requestId, state.entries.length - 1); + return entry; +} +async function startNetworkCapture(tabId, pattern) { + await ensureAttached(tabId); + await chrome.debugger.sendCommand({ tabId }, "Network.enable"); + networkCaptures.set(tabId, { + patterns: normalizeCapturePatterns(pattern), + entries: [], + requestToIndex: /* @__PURE__ */ new Map() + }); +} +async function readNetworkCapture(tabId) { + const state = networkCaptures.get(tabId); + if (!state) return []; + const entries = state.entries.slice(); + state.entries = []; + state.requestToIndex.clear(); + return entries; } async function detach(tabId) { - if (!attached.has(tabId)) return; - attached.delete(tabId); - try { - await chrome.debugger.detach({ tabId }); - } catch { - } + if (!attached.has(tabId)) return; + attached.delete(tabId); + networkCaptures.delete(tabId); + try { + await chrome.debugger.detach({ tabId }); + } catch {} } function registerListeners() { - chrome.tabs.onRemoved.addListener((tabId) => { - attached.delete(tabId); - }); - chrome.debugger.onDetach.addListener((source) => { - if (source.tabId) attached.delete(source.tabId); - }); - chrome.tabs.onUpdated.addListener(async (tabId, info) => { - if (info.url && !isDebuggableUrl$1(info.url)) { - await detach(tabId); - } - }); -} - -let ws = null; -let reconnectTimer = null; -let reconnectAttempts = 0; -const _origLog = console.log.bind(console); -const _origWarn = console.warn.bind(console); -const _origError = console.error.bind(console); + chrome.tabs.onRemoved.addListener((tabId) => { + attached.delete(tabId); + networkCaptures.delete(tabId); + }); + chrome.debugger.onDetach.addListener((source) => { + if (source.tabId) { + attached.delete(source.tabId); + networkCaptures.delete(source.tabId); + } + }); + chrome.tabs.onUpdated.addListener(async (tabId, info) => { + if (info.url && !isDebuggableUrl$1(info.url)) await detach(tabId); + }); + chrome.debugger.onEvent.addListener(async (source, method, params) => { + const tabId = source.tabId; + if (!tabId) return; + const state = networkCaptures.get(tabId); + if (!state) return; + if (method === "Network.requestWillBeSent") { + const requestId = String(params?.requestId || ""); + const request = params?.request; + const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { + url: request?.url, + method: request?.method, + requestHeaders: normalizeHeaders(request?.headers) + }); + if (!entry) return; + entry.requestBodyKind = request?.hasPostData ? "string" : "empty"; + entry.requestBodyPreview = String(request?.postData || "").slice(0, 4e3); + try { + const postData = await chrome.debugger.sendCommand({ tabId }, "Network.getRequestPostData", { requestId }); + if (postData?.postData) { + entry.requestBodyKind = "string"; + entry.requestBodyPreview = postData.postData.slice(0, 4e3); + } + } catch {} + return; + } + if (method === "Network.responseReceived") { + const requestId = String(params?.requestId || ""); + const response = params?.response; + const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { url: response?.url }); + if (!entry) return; + entry.responseStatus = response?.status; + entry.responseContentType = response?.mimeType || ""; + entry.responseHeaders = normalizeHeaders(response?.headers); + return; + } + if (method === "Network.loadingFinished") { + const requestId = String(params?.requestId || ""); + const stateEntryIndex = state.requestToIndex.get(requestId); + if (stateEntryIndex === void 0) return; + const entry = state.entries[stateEntryIndex]; + if (!entry) return; + try { + const body = await chrome.debugger.sendCommand({ tabId }, "Network.getResponseBody", { requestId }); + if (typeof body?.body === "string") entry.responsePreview = body.base64Encoded ? `base64:${body.body.slice(0, 4e3)}` : body.body.slice(0, 4e3); + } catch {} + } + }); +} +//#endregion +//#region src/background.ts +var ws = null; +var reconnectTimer = null; +var reconnectAttempts = 0; +var _origLog = console.log.bind(console); +var _origWarn = console.warn.bind(console); +var _origError = console.error.bind(console); function forwardLog(level, args) { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - try { - const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); - ws.send(JSON.stringify({ type: "log", level, msg, ts: Date.now() })); - } catch { - } + if (!ws || ws.readyState !== WebSocket.OPEN) return; + try { + const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); + ws.send(JSON.stringify({ + type: "log", + level, + msg, + ts: Date.now() + })); + } catch {} } console.log = (...args) => { - _origLog(...args); - forwardLog("info", args); + _origLog(...args); + forwardLog("info", args); }; console.warn = (...args) => { - _origWarn(...args); - forwardLog("warn", args); + _origWarn(...args); + forwardLog("warn", args); }; console.error = (...args) => { - _origError(...args); - forwardLog("error", args); + _origError(...args); + forwardLog("error", args); }; +/** +* Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket +* connection. fetch() failures are silently catchable; new WebSocket() is not +* — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any +* JS handler can intercept it. By keeping the probe inside connect() every +* call site remains unchanged and the guard can never be accidentally skipped. +*/ async function connect() { - if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; - try { - const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) }); - if (!res.ok) return; - } catch { - return; - } - try { - ws = new WebSocket(DAEMON_WS_URL); - } catch { - scheduleReconnect(); - return; - } - ws.onopen = () => { - console.log("[opencli] Connected to daemon"); - reconnectAttempts = 0; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - ws?.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version })); - }; - ws.onmessage = async (event) => { - try { - const command = JSON.parse(event.data); - const result = await handleCommand(command); - ws?.send(JSON.stringify(result)); - } catch (err) { - console.error("[opencli] Message handling error:", err); - } - }; - ws.onclose = () => { - console.log("[opencli] Disconnected from daemon"); - ws = null; - scheduleReconnect(); - }; - ws.onerror = () => { - ws?.close(); - }; -} -const MAX_EAGER_ATTEMPTS = 6; + if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + try { + if (!(await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) })).ok) return; + } catch { + return; + } + try { + ws = new WebSocket(DAEMON_WS_URL); + } catch { + scheduleReconnect(); + return; + } + ws.onopen = () => { + console.log("[opencli] Connected to daemon"); + reconnectAttempts = 0; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + ws?.send(JSON.stringify({ + type: "hello", + version: chrome.runtime.getManifest().version + })); + }; + ws.onmessage = async (event) => { + try { + const result = await handleCommand(JSON.parse(event.data)); + ws?.send(JSON.stringify(result)); + } catch (err) { + console.error("[opencli] Message handling error:", err); + } + }; + ws.onclose = () => { + console.log("[opencli] Disconnected from daemon"); + ws = null; + scheduleReconnect(); + }; + ws.onerror = () => { + ws?.close(); + }; +} +/** +* After MAX_EAGER_ATTEMPTS (reaching 60s backoff), stop scheduling reconnects. +* The keepalive alarm (~24s) will still call connect() periodically, but at a +* much lower frequency — reducing console noise when the daemon is not running. +*/ +var MAX_EAGER_ATTEMPTS = 6; function scheduleReconnect() { - if (reconnectTimer) return; - reconnectAttempts++; - if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; - const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - void connect(); - }, delay); -} -const automationSessions = /* @__PURE__ */ new Map(); -const WINDOW_IDLE_TIMEOUT = 3e4; + if (reconnectTimer) return; + reconnectAttempts++; + if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; + const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, delay); +} +var automationSessions = /* @__PURE__ */ new Map(); +var WINDOW_IDLE_TIMEOUT = 3e4; function getWorkspaceKey(workspace) { - return workspace?.trim() || "default"; + return workspace?.trim() || "default"; } function resetWindowIdleTimer(workspace) { - const session = automationSessions.get(workspace); - if (!session) return; - if (session.idleTimer) clearTimeout(session.idleTimer); - session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT; - session.idleTimer = setTimeout(async () => { - const current = automationSessions.get(workspace); - if (!current) return; - try { - await chrome.windows.remove(current.windowId); - console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`); - } catch { - } - automationSessions.delete(workspace); - }, WINDOW_IDLE_TIMEOUT); + const session = automationSessions.get(workspace); + if (!session) return; + if (session.idleTimer) clearTimeout(session.idleTimer); + session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT; + session.idleTimer = setTimeout(async () => { + const current = automationSessions.get(workspace); + if (!current) return; + if (!current.owned) { + console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`); + automationSessions.delete(workspace); + return; + } + try { + await chrome.windows.remove(current.windowId); + console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`); + } catch {} + automationSessions.delete(workspace); + }, WINDOW_IDLE_TIMEOUT); } +/** Get or create the dedicated automation window. +* @param initialUrl — if provided (http/https), used as the initial page instead of about:blank. +* This avoids an extra blank-page→target-domain navigation on first command. +*/ async function getAutomationWindow(workspace, initialUrl) { - const existing = automationSessions.get(workspace); - if (existing) { - try { - await chrome.windows.get(existing.windowId); - return existing.windowId; - } catch { - automationSessions.delete(workspace); - } - } - const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; - const win = await chrome.windows.create({ - url: startUrl, - focused: false, - width: 1280, - height: 900, - type: "normal" - }); - const session = { - windowId: win.id, - idleTimer: null, - idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT - }; - automationSessions.set(workspace, session); - console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`); - resetWindowIdleTimer(workspace); - const tabs = await chrome.tabs.query({ windowId: win.id }); - if (tabs[0]?.id) { - await new Promise((resolve) => { - const timeout = setTimeout(resolve, 500); - const listener = (tabId, info) => { - if (tabId === tabs[0].id && info.status === "complete") { - chrome.tabs.onUpdated.removeListener(listener); - clearTimeout(timeout); - resolve(); - } - }; - if (tabs[0].status === "complete") { - clearTimeout(timeout); - resolve(); - } else { - chrome.tabs.onUpdated.addListener(listener); - } - }); - } - return session.windowId; + const existing = automationSessions.get(workspace); + if (existing) try { + await chrome.windows.get(existing.windowId); + return existing.windowId; + } catch { + automationSessions.delete(workspace); + } + const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; + const win = await chrome.windows.create({ + url: startUrl, + focused: false, + width: 1280, + height: 900, + type: "normal" + }); + const session = { + windowId: win.id, + idleTimer: null, + idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT, + owned: true, + preferredTabId: null + }; + automationSessions.set(workspace, session); + console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`); + resetWindowIdleTimer(workspace); + const tabs = await chrome.tabs.query({ windowId: win.id }); + if (tabs[0]?.id) await new Promise((resolve) => { + const timeout = setTimeout(resolve, 500); + const listener = (tabId, info) => { + if (tabId === tabs[0].id && info.status === "complete") { + chrome.tabs.onUpdated.removeListener(listener); + clearTimeout(timeout); + resolve(); + } + }; + if (tabs[0].status === "complete") { + clearTimeout(timeout); + resolve(); + } else chrome.tabs.onUpdated.addListener(listener); + }); + return session.windowId; } chrome.windows.onRemoved.addListener((windowId) => { - for (const [workspace, session] of automationSessions.entries()) { - if (session.windowId === windowId) { - console.log(`[opencli] Automation window closed (${workspace})`); - if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(workspace); - } - } + for (const [workspace, session] of automationSessions.entries()) if (session.windowId === windowId) { + console.log(`[opencli] Automation window closed (${workspace})`); + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(workspace); + } }); -let initialized = false; +var initialized = false; function initialize() { - if (initialized) return; - initialized = true; - chrome.alarms.create("keepalive", { periodInMinutes: 0.4 }); - registerListeners(); - void connect(); - console.log("[opencli] OpenCLI extension initialized"); + if (initialized) return; + initialized = true; + chrome.alarms.create("keepalive", { periodInMinutes: .4 }); + registerListeners(); + connect(); + console.log("[opencli] OpenCLI extension initialized"); } chrome.runtime.onInstalled.addListener(() => { - initialize(); + initialize(); }); chrome.runtime.onStartup.addListener(() => { - initialize(); + initialize(); }); chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === "keepalive") void connect(); + if (alarm.name === "keepalive") connect(); }); chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { - if (msg?.type === "getStatus") { - sendResponse({ - connected: ws?.readyState === WebSocket.OPEN, - reconnecting: reconnectTimer !== null - }); - } - return false; + if (msg?.type === "getStatus") sendResponse({ + connected: ws?.readyState === WebSocket.OPEN, + reconnecting: reconnectTimer !== null + }); + return false; }); async function handleCommand(cmd) { - const workspace = getWorkspaceKey(cmd.workspace); - resetWindowIdleTimer(workspace); - try { - switch (cmd.action) { - case "exec": - return await handleExec(cmd, workspace); - case "navigate": - return await handleNavigate(cmd, workspace); - case "tabs": - return await handleTabs(cmd, workspace); - case "cookies": - return await handleCookies(cmd); - case "screenshot": - return await handleScreenshot(cmd, workspace); - case "close-window": - return await handleCloseWindow(cmd, workspace); - case "cdp": - return await handleCdp(cmd, workspace); - case "sessions": - return await handleSessions(cmd); - case "set-file-input": - return await handleSetFileInput(cmd, workspace); - default: - return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` }; - } - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } -} -const BLANK_PAGE = "about:blank"; + const workspace = getWorkspaceKey(cmd.workspace); + resetWindowIdleTimer(workspace); + try { + switch (cmd.action) { + case "exec": return await handleExec(cmd, workspace); + case "navigate": return await handleNavigate(cmd, workspace); + case "tabs": return await handleTabs(cmd, workspace); + case "cookies": return await handleCookies(cmd); + case "screenshot": return await handleScreenshot(cmd, workspace); + case "close-window": return await handleCloseWindow(cmd, workspace); + case "cdp": return await handleCdp(cmd, workspace); + case "sessions": return await handleSessions(cmd); + case "set-file-input": return await handleSetFileInput(cmd, workspace); + case "insert-text": return await handleInsertText(cmd, workspace); + case "bind-current": return await handleBindCurrent(cmd, workspace); + case "network-capture-start": return await handleNetworkCaptureStart(cmd, workspace); + case "network-capture-read": return await handleNetworkCaptureRead(cmd, workspace); + default: return { + id: cmd.id, + ok: false, + error: `Unknown action: ${cmd.action}` + }; + } + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } +} +/** Internal blank page used when no user URL is provided. */ +var BLANK_PAGE = "about:blank"; +/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ function isDebuggableUrl(url) { - if (!url) return true; - return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); + if (!url) return true; + return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); } +/** Check if a URL is safe for user-facing navigation (http/https only). */ function isSafeNavigationUrl(url) { - return url.startsWith("http://") || url.startsWith("https://"); + return url.startsWith("http://") || url.startsWith("https://"); } +/** Minimal URL normalization for same-page comparison: root slash + default port only. */ function normalizeUrlForComparison(url) { - if (!url) return ""; - try { - const parsed = new URL(url); - if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") { - parsed.port = ""; - } - const pathname = parsed.pathname === "/" ? "" : parsed.pathname; - return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`; - } catch { - return url; - } + if (!url) return ""; + try { + const parsed = new URL(url); + if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") parsed.port = ""; + const pathname = parsed.pathname === "/" ? "" : parsed.pathname; + return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`; + } catch { + return url; + } } function isTargetUrl(currentUrl, targetUrl) { - return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); + return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); +} +function matchesDomain(url, domain) { + if (!url) return false; + try { + const parsed = new URL(url); + return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`); + } catch { + return false; + } +} +function matchesBindCriteria(tab, cmd) { + if (!tab.id || !isDebuggableUrl(tab.url)) return false; + if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false; + if (cmd.matchPathPrefix) try { + if (!new URL(tab.url).pathname.startsWith(cmd.matchPathPrefix)) return false; + } catch { + return false; + } + return true; +} +function setWorkspaceSession(workspace, session) { + const existing = automationSessions.get(workspace); + if (existing?.idleTimer) clearTimeout(existing.idleTimer); + automationSessions.set(workspace, { + ...session, + idleTimer: null, + idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT + }); } +/** +* Resolve target tab in the automation window, returning both the tabId and +* the Tab object (when available) so callers can skip a redundant chrome.tabs.get(). +*/ async function resolveTab(tabId, workspace, initialUrl) { - if (tabId !== void 0) { - try { - const tab = await chrome.tabs.get(tabId); - const session = automationSessions.get(workspace); - const matchesSession = session ? tab.windowId === session.windowId : false; - if (isDebuggableUrl(tab.url) && matchesSession) return { tabId, tab }; - if (session && !matchesSession && isDebuggableUrl(tab.url)) { - console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`); - try { - await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 }); - const moved = await chrome.tabs.get(tabId); - if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) { - return { tabId, tab: moved }; - } - } catch (moveErr) { - console.warn(`[opencli] Failed to move tab back: ${moveErr}`); - } - } else if (!isDebuggableUrl(tab.url)) { - console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); - } - } catch { - console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); - } - } - const windowId = await getAutomationWindow(workspace, initialUrl); - const tabs = await chrome.tabs.query({ windowId }); - const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url)); - if (debuggableTab?.id) return { tabId: debuggableTab.id, tab: debuggableTab }; - const reuseTab = tabs.find((t) => t.id); - if (reuseTab?.id) { - await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); - await new Promise((resolve) => setTimeout(resolve, 300)); - try { - const updated = await chrome.tabs.get(reuseTab.id); - if (isDebuggableUrl(updated.url)) return { tabId: reuseTab.id, tab: updated }; - console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); - } catch { - } - } - const newTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: true }); - if (!newTab.id) throw new Error("Failed to create tab in automation window"); - return { tabId: newTab.id, tab: newTab }; + if (tabId !== void 0) try { + const tab = await chrome.tabs.get(tabId); + const session = automationSessions.get(workspace); + const matchesSession = session ? session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId : false; + if (isDebuggableUrl(tab.url) && matchesSession) return { + tabId, + tab + }; + if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) { + console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`); + try { + await chrome.tabs.move(tabId, { + windowId: session.windowId, + index: -1 + }); + const moved = await chrome.tabs.get(tabId); + if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) return { + tabId, + tab: moved + }; + } catch (moveErr) { + console.warn(`[opencli] Failed to move tab back: ${moveErr}`); + } + } else if (!isDebuggableUrl(tab.url)) console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); + } catch { + console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); + } + const existingSession = automationSessions.get(workspace); + if (existingSession?.preferredTabId !== null) try { + const preferredTab = await chrome.tabs.get(existingSession.preferredTabId); + if (isDebuggableUrl(preferredTab.url)) return { + tabId: preferredTab.id, + tab: preferredTab + }; + } catch { + automationSessions.delete(workspace); + } + const windowId = await getAutomationWindow(workspace, initialUrl); + const tabs = await chrome.tabs.query({ windowId }); + const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url)); + if (debuggableTab?.id) return { + tabId: debuggableTab.id, + tab: debuggableTab + }; + const reuseTab = tabs.find((t) => t.id); + if (reuseTab?.id) { + await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); + await new Promise((resolve) => setTimeout(resolve, 300)); + try { + const updated = await chrome.tabs.get(reuseTab.id); + if (isDebuggableUrl(updated.url)) return { + tabId: reuseTab.id, + tab: updated + }; + console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); + } catch {} + } + const newTab = await chrome.tabs.create({ + windowId, + url: BLANK_PAGE, + active: true + }); + if (!newTab.id) throw new Error("Failed to create tab in automation window"); + return { + tabId: newTab.id, + tab: newTab + }; } +/** Convenience wrapper returning just the tabId (used by most handlers) */ async function resolveTabId(tabId, workspace, initialUrl) { - const resolved = await resolveTab(tabId, workspace, initialUrl); - return resolved.tabId; + return (await resolveTab(tabId, workspace, initialUrl)).tabId; } async function listAutomationTabs(workspace) { - const session = automationSessions.get(workspace); - if (!session) return []; - try { - return await chrome.tabs.query({ windowId: session.windowId }); - } catch { - automationSessions.delete(workspace); - return []; - } + const session = automationSessions.get(workspace); + if (!session) return []; + if (session.preferredTabId !== null) try { + return [await chrome.tabs.get(session.preferredTabId)]; + } catch { + automationSessions.delete(workspace); + return []; + } + try { + return await chrome.tabs.query({ windowId: session.windowId }); + } catch { + automationSessions.delete(workspace); + return []; + } } async function listAutomationWebTabs(workspace) { - const tabs = await listAutomationTabs(workspace); - return tabs.filter((tab) => isDebuggableUrl(tab.url)); + return (await listAutomationTabs(workspace)).filter((tab) => isDebuggableUrl(tab.url)); } async function handleExec(cmd, workspace) { - if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" }; - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - const aggressive = workspace.startsWith("operate:"); - const data = await evaluateAsync(tabId, cmd.code, aggressive); - return { id: cmd.id, ok: true, data }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } + if (!cmd.code) return { + id: cmd.id, + ok: false, + error: "Missing code" + }; + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + const aggressive = workspace.startsWith("operate:"); + const data = await evaluateAsync(tabId, cmd.code, aggressive); + return { + id: cmd.id, + ok: true, + data + }; + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } async function handleNavigate(cmd, workspace) { - if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" }; - if (!isSafeNavigationUrl(cmd.url)) { - return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" }; - } - const resolved = await resolveTab(cmd.tabId, workspace, cmd.url); - const tabId = resolved.tabId; - const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); - const beforeNormalized = normalizeUrlForComparison(beforeTab.url); - const targetUrl = cmd.url; - if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) { - return { - id: cmd.id, - ok: true, - data: { title: beforeTab.title, url: beforeTab.url, tabId, timedOut: false } - }; - } - await detach(tabId); - await chrome.tabs.update(tabId, { url: targetUrl }); - let timedOut = false; - await new Promise((resolve) => { - let settled = false; - let checkTimer = null; - let timeoutTimer = null; - const finish = () => { - if (settled) return; - settled = true; - chrome.tabs.onUpdated.removeListener(listener); - if (checkTimer) clearTimeout(checkTimer); - if (timeoutTimer) clearTimeout(timeoutTimer); - resolve(); - }; - const isNavigationDone = (url) => { - return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized; - }; - const listener = (id, info, tab2) => { - if (id !== tabId) return; - if (info.status === "complete" && isNavigationDone(tab2.url ?? info.url)) { - finish(); - } - }; - chrome.tabs.onUpdated.addListener(listener); - checkTimer = setTimeout(async () => { - try { - const currentTab = await chrome.tabs.get(tabId); - if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) { - finish(); - } - } catch { - } - }, 100); - timeoutTimer = setTimeout(() => { - timedOut = true; - console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); - finish(); - }, 15e3); - }); - let tab = await chrome.tabs.get(tabId); - const session = automationSessions.get(workspace); - if (session && tab.windowId !== session.windowId) { - console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${session.windowId}`); - try { - await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 }); - tab = await chrome.tabs.get(tabId); - } catch (moveErr) { - console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`); - } - } - return { - id: cmd.id, - ok: true, - data: { title: tab.title, url: tab.url, tabId, timedOut } - }; + if (!cmd.url) return { + id: cmd.id, + ok: false, + error: "Missing url" + }; + if (!isSafeNavigationUrl(cmd.url)) return { + id: cmd.id, + ok: false, + error: "Blocked URL scheme -- only http:// and https:// are allowed" + }; + const resolved = await resolveTab(cmd.tabId, workspace, cmd.url); + const tabId = resolved.tabId; + const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); + const beforeNormalized = normalizeUrlForComparison(beforeTab.url); + const targetUrl = cmd.url; + if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) return { + id: cmd.id, + ok: true, + data: { + title: beforeTab.title, + url: beforeTab.url, + tabId, + timedOut: false + } + }; + await detach(tabId); + await chrome.tabs.update(tabId, { url: targetUrl }); + let timedOut = false; + await new Promise((resolve) => { + let settled = false; + let checkTimer = null; + let timeoutTimer = null; + const finish = () => { + if (settled) return; + settled = true; + chrome.tabs.onUpdated.removeListener(listener); + if (checkTimer) clearTimeout(checkTimer); + if (timeoutTimer) clearTimeout(timeoutTimer); + resolve(); + }; + const isNavigationDone = (url) => { + return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized; + }; + const listener = (id, info, tab) => { + if (id !== tabId) return; + if (info.status === "complete" && isNavigationDone(tab.url ?? info.url)) finish(); + }; + chrome.tabs.onUpdated.addListener(listener); + checkTimer = setTimeout(async () => { + try { + const currentTab = await chrome.tabs.get(tabId); + if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) finish(); + } catch {} + }, 100); + timeoutTimer = setTimeout(() => { + timedOut = true; + console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); + finish(); + }, 15e3); + }); + let tab = await chrome.tabs.get(tabId); + const session = automationSessions.get(workspace); + if (session && tab.windowId !== session.windowId) { + console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${session.windowId}`); + try { + await chrome.tabs.move(tabId, { + windowId: session.windowId, + index: -1 + }); + tab = await chrome.tabs.get(tabId); + } catch (moveErr) { + console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`); + } + } + return { + id: cmd.id, + ok: true, + data: { + title: tab.title, + url: tab.url, + tabId, + timedOut + } + }; } async function handleTabs(cmd, workspace) { - switch (cmd.op) { - case "list": { - const tabs = await listAutomationWebTabs(workspace); - const data = tabs.map((t, i) => ({ - index: i, - tabId: t.id, - url: t.url, - title: t.title, - active: t.active - })); - return { id: cmd.id, ok: true, data }; - } - case "new": { - if (cmd.url && !isSafeNavigationUrl(cmd.url)) { - return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" }; - } - const windowId = await getAutomationWindow(workspace); - const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true }); - return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } }; - } - case "close": { - if (cmd.index !== void 0) { - const tabs = await listAutomationWebTabs(workspace); - const target = tabs[cmd.index]; - if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; - await chrome.tabs.remove(target.id); - await detach(target.id); - return { id: cmd.id, ok: true, data: { closed: target.id } }; - } - const tabId = await resolveTabId(cmd.tabId, workspace); - await chrome.tabs.remove(tabId); - await detach(tabId); - return { id: cmd.id, ok: true, data: { closed: tabId } }; - } - case "select": { - if (cmd.index === void 0 && cmd.tabId === void 0) - return { id: cmd.id, ok: false, error: "Missing index or tabId" }; - if (cmd.tabId !== void 0) { - const session = automationSessions.get(workspace); - let tab; - try { - tab = await chrome.tabs.get(cmd.tabId); - } catch { - return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} no longer exists` }; - } - if (!session || tab.windowId !== session.windowId) { - return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} is not in the automation window` }; - } - await chrome.tabs.update(cmd.tabId, { active: true }); - return { id: cmd.id, ok: true, data: { selected: cmd.tabId } }; - } - const tabs = await listAutomationWebTabs(workspace); - const target = tabs[cmd.index]; - if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; - await chrome.tabs.update(target.id, { active: true }); - return { id: cmd.id, ok: true, data: { selected: target.id } }; - } - default: - return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` }; - } + switch (cmd.op) { + case "list": { + const data = (await listAutomationWebTabs(workspace)).map((t, i) => ({ + index: i, + tabId: t.id, + url: t.url, + title: t.title, + active: t.active + })); + return { + id: cmd.id, + ok: true, + data + }; + } + case "new": { + if (cmd.url && !isSafeNavigationUrl(cmd.url)) return { + id: cmd.id, + ok: false, + error: "Blocked URL scheme -- only http:// and https:// are allowed" + }; + const windowId = await getAutomationWindow(workspace); + const tab = await chrome.tabs.create({ + windowId, + url: cmd.url ?? BLANK_PAGE, + active: true + }); + return { + id: cmd.id, + ok: true, + data: { + tabId: tab.id, + url: tab.url + } + }; + } + case "close": { + if (cmd.index !== void 0) { + const target = (await listAutomationWebTabs(workspace))[cmd.index]; + if (!target?.id) return { + id: cmd.id, + ok: false, + error: `Tab index ${cmd.index} not found` + }; + await chrome.tabs.remove(target.id); + await detach(target.id); + return { + id: cmd.id, + ok: true, + data: { closed: target.id } + }; + } + const tabId = await resolveTabId(cmd.tabId, workspace); + await chrome.tabs.remove(tabId); + await detach(tabId); + return { + id: cmd.id, + ok: true, + data: { closed: tabId } + }; + } + case "select": { + if (cmd.index === void 0 && cmd.tabId === void 0) return { + id: cmd.id, + ok: false, + error: "Missing index or tabId" + }; + if (cmd.tabId !== void 0) { + const session = automationSessions.get(workspace); + let tab; + try { + tab = await chrome.tabs.get(cmd.tabId); + } catch { + return { + id: cmd.id, + ok: false, + error: `Tab ${cmd.tabId} no longer exists` + }; + } + if (!session || tab.windowId !== session.windowId) return { + id: cmd.id, + ok: false, + error: `Tab ${cmd.tabId} is not in the automation window` + }; + await chrome.tabs.update(cmd.tabId, { active: true }); + return { + id: cmd.id, + ok: true, + data: { selected: cmd.tabId } + }; + } + const target = (await listAutomationWebTabs(workspace))[cmd.index]; + if (!target?.id) return { + id: cmd.id, + ok: false, + error: `Tab index ${cmd.index} not found` + }; + await chrome.tabs.update(target.id, { active: true }); + return { + id: cmd.id, + ok: true, + data: { selected: target.id } + }; + } + default: return { + id: cmd.id, + ok: false, + error: `Unknown tabs op: ${cmd.op}` + }; + } } async function handleCookies(cmd) { - if (!cmd.domain && !cmd.url) { - return { id: cmd.id, ok: false, error: "Cookie scope required: provide domain or url to avoid dumping all cookies" }; - } - const details = {}; - if (cmd.domain) details.domain = cmd.domain; - if (cmd.url) details.url = cmd.url; - const cookies = await chrome.cookies.getAll(details); - const data = cookies.map((c) => ({ - name: c.name, - value: c.value, - domain: c.domain, - path: c.path, - secure: c.secure, - httpOnly: c.httpOnly, - expirationDate: c.expirationDate - })); - return { id: cmd.id, ok: true, data }; + if (!cmd.domain && !cmd.url) return { + id: cmd.id, + ok: false, + error: "Cookie scope required: provide domain or url to avoid dumping all cookies" + }; + const details = {}; + if (cmd.domain) details.domain = cmd.domain; + if (cmd.url) details.url = cmd.url; + const data = (await chrome.cookies.getAll(details)).map((c) => ({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + secure: c.secure, + httpOnly: c.httpOnly, + expirationDate: c.expirationDate + })); + return { + id: cmd.id, + ok: true, + data + }; } async function handleScreenshot(cmd, workspace) { - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - const data = await screenshot(tabId, { - format: cmd.format, - quality: cmd.quality, - fullPage: cmd.fullPage - }); - return { id: cmd.id, ok: true, data }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } -} -const CDP_ALLOWLIST = /* @__PURE__ */ new Set([ - // Agent DOM context - "Accessibility.getFullAXTree", - "DOM.getDocument", - "DOM.getBoxModel", - "DOM.getContentQuads", - "DOM.querySelectorAll", - "DOM.scrollIntoViewIfNeeded", - "DOMSnapshot.captureSnapshot", - // Native input events - "Input.dispatchMouseEvent", - "Input.dispatchKeyEvent", - "Input.insertText", - // Page metrics & screenshots - "Page.getLayoutMetrics", - "Page.captureScreenshot", - // Runtime.enable needed for CDP attach setup (Runtime.evaluate goes through 'exec' action) - "Runtime.enable", - // Emulation (used by screenshot full-page) - "Emulation.setDeviceMetricsOverride", - "Emulation.clearDeviceMetricsOverride" + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + const data = await screenshot(tabId, { + format: cmd.format, + quality: cmd.quality, + fullPage: cmd.fullPage + }); + return { + id: cmd.id, + ok: true, + data + }; + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } +} +/** CDP methods permitted via the 'cdp' passthrough action. */ +var CDP_ALLOWLIST = new Set([ + "Accessibility.getFullAXTree", + "DOM.getDocument", + "DOM.getBoxModel", + "DOM.getContentQuads", + "DOM.querySelectorAll", + "DOM.scrollIntoViewIfNeeded", + "DOMSnapshot.captureSnapshot", + "Input.dispatchMouseEvent", + "Input.dispatchKeyEvent", + "Input.insertText", + "Page.getLayoutMetrics", + "Page.captureScreenshot", + "Runtime.enable", + "Emulation.setDeviceMetricsOverride", + "Emulation.clearDeviceMetricsOverride" ]); async function handleCdp(cmd, workspace) { - if (!cmd.cdpMethod) return { id: cmd.id, ok: false, error: "Missing cdpMethod" }; - if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) { - return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` }; - } - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - const aggressive = workspace.startsWith("operate:"); - await ensureAttached(tabId, aggressive); - const data = await chrome.debugger.sendCommand( - { tabId }, - cmd.cdpMethod, - cmd.cdpParams ?? {} - ); - return { id: cmd.id, ok: true, data }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } + if (!cmd.cdpMethod) return { + id: cmd.id, + ok: false, + error: "Missing cdpMethod" + }; + if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) return { + id: cmd.id, + ok: false, + error: `CDP method not permitted: ${cmd.cdpMethod}` + }; + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + await ensureAttached(tabId, workspace.startsWith("operate:")); + const data = await chrome.debugger.sendCommand({ tabId }, cmd.cdpMethod, cmd.cdpParams ?? {}); + return { + id: cmd.id, + ok: true, + data + }; + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } async function handleCloseWindow(cmd, workspace) { - const session = automationSessions.get(workspace); - if (session) { - try { - await chrome.windows.remove(session.windowId); - } catch { - } - if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(workspace); - } - return { id: cmd.id, ok: true, data: { closed: true } }; + const session = automationSessions.get(workspace); + if (session) { + if (session.owned) try { + await chrome.windows.remove(session.windowId); + } catch {} + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(workspace); + } + return { + id: cmd.id, + ok: true, + data: { closed: true } + }; } async function handleSetFileInput(cmd, workspace) { - if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) { - return { id: cmd.id, ok: false, error: "Missing or empty files array" }; - } - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - await setFileInputFiles(tabId, cmd.files, cmd.selector); - return { id: cmd.id, ok: true, data: { count: cmd.files.length } }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } + if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) return { + id: cmd.id, + ok: false, + error: "Missing or empty files array" + }; + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + await setFileInputFiles(tabId, cmd.files, cmd.selector); + return { + id: cmd.id, + ok: true, + data: { count: cmd.files.length } + }; + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } +} +async function handleInsertText(cmd, workspace) { + if (typeof cmd.text !== "string") return { + id: cmd.id, + ok: false, + error: "Missing text payload" + }; + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + await insertText(tabId, cmd.text); + return { + id: cmd.id, + ok: true, + data: { inserted: true } + }; + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } +} +async function handleNetworkCaptureStart(cmd, workspace) { + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + await startNetworkCapture(tabId, cmd.pattern); + return { + id: cmd.id, + ok: true, + data: { started: true } + }; + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } +} +async function handleNetworkCaptureRead(cmd, workspace) { + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + const data = await readNetworkCapture(tabId); + return { + id: cmd.id, + ok: true, + data + }; + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } async function handleSessions(cmd) { - const now = Date.now(); - const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ - workspace, - windowId: session.windowId, - tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length, - idleMsRemaining: Math.max(0, session.idleDeadlineAt - now) - }))); - return { id: cmd.id, ok: true, data }; + const now = Date.now(); + const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ + workspace, + windowId: session.windowId, + tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length, + idleMsRemaining: Math.max(0, session.idleDeadlineAt - now) + }))); + return { + id: cmd.id, + ok: true, + data + }; +} +async function handleBindCurrent(cmd, workspace) { + const activeTabs = await chrome.tabs.query({ + active: true, + lastFocusedWindow: true + }); + const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); + const allTabs = await chrome.tabs.query({}); + const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? allTabs.find((tab) => matchesBindCriteria(tab, cmd)); + if (!boundTab?.id) return { + id: cmd.id, + ok: false, + error: cmd.matchDomain || cmd.matchPathPrefix ? `No visible tab matching ${cmd.matchDomain ?? "domain"}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ""}` : "No active debuggable tab found" + }; + setWorkspaceSession(workspace, { + windowId: boundTab.windowId, + owned: false, + preferredTabId: boundTab.id + }); + resetWindowIdleTimer(workspace); + console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); + return { + id: cmd.id, + ok: true, + data: { + tabId: boundTab.id, + windowId: boundTab.windowId, + url: boundTab.url, + title: boundTab.title, + workspace + } + }; } +//#endregion diff --git a/extension/src/background.ts b/extension/src/background.ts index 29766cd0..da132a9d 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -117,6 +117,8 @@ type AutomationSession = { windowId: number; idleTimer: ReturnType | null; idleDeadlineAt: number; + owned: boolean; + preferredTabId: number | null; }; const automationSessions = new Map(); @@ -134,6 +136,11 @@ function resetWindowIdleTimer(workspace: string): void { session.idleTimer = setTimeout(async () => { const current = automationSessions.get(workspace); if (!current) return; + if (!current.owned) { + console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`); + automationSessions.delete(workspace); + return; + } try { await chrome.windows.remove(current.windowId); console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`); @@ -177,6 +184,8 @@ async function getAutomationWindow(workspace: string, initialUrl?: string): Prom windowId: win.id!, idleTimer: null, idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT, + owned: true, + preferredTabId: null, }; automationSessions.set(workspace, session); console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`); @@ -279,6 +288,14 @@ async function handleCommand(cmd: Command): Promise { return await handleSessions(cmd); case 'set-file-input': return await handleSetFileInput(cmd, workspace); + case 'insert-text': + return await handleInsertText(cmd, workspace); + case 'bind-current': + return await handleBindCurrent(cmd, workspace); + case 'network-capture-start': + return await handleNetworkCaptureStart(cmd, workspace); + case 'network-capture-read': + return await handleNetworkCaptureRead(cmd, workspace); default: return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` }; } @@ -326,7 +343,31 @@ function isTargetUrl(currentUrl: string | undefined, targetUrl: string): boolean return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); } -function setWorkspaceSession(workspace: string, session: Pick): void { +function matchesDomain(url: string | undefined, domain: string): boolean { + if (!url) return false; + try { + const parsed = new URL(url); + return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`); + } catch { + return false; + } +} + +function matchesBindCriteria(tab: chrome.tabs.Tab, cmd: Command): boolean { + if (!tab.id || !isDebuggableUrl(tab.url)) return false; + if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false; + if (cmd.matchPathPrefix) { + try { + const parsed = new URL(tab.url!); + if (!parsed.pathname.startsWith(cmd.matchPathPrefix)) return false; + } catch { + return false; + } + } + return true; +} + +function setWorkspaceSession(workspace: string, session: Omit): void { const existing = automationSessions.get(workspace); if (existing?.idleTimer) clearTimeout(existing.idleTimer); automationSessions.set(workspace, { @@ -348,9 +389,11 @@ async function resolveTab(tabId: number | undefined, workspace: string, initialU try { const tab = await chrome.tabs.get(tabId); const session = automationSessions.get(workspace); - const matchesSession = session ? tab.windowId === session.windowId : false; + const matchesSession = session + ? (session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId) + : false; if (isDebuggableUrl(tab.url) && matchesSession) return { tabId, tab }; - if (session && !matchesSession && isDebuggableUrl(tab.url)) { + if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) { // Tab drifted to another window but content is still valid. // Try to move it back instead of abandoning it. console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`); @@ -371,6 +414,16 @@ async function resolveTab(tabId: number | undefined, workspace: string, initialU } } + const existingSession = automationSessions.get(workspace); + if (existingSession?.preferredTabId !== null) { + try { + const preferredTab = await chrome.tabs.get(existingSession.preferredTabId); + if (isDebuggableUrl(preferredTab.url)) return { tabId: preferredTab.id!, tab: preferredTab }; + } catch { + automationSessions.delete(workspace); + } + } + // Get (or create) the automation window const windowId = await getAutomationWindow(workspace, initialUrl); @@ -408,6 +461,14 @@ async function resolveTabId(tabId: number | undefined, workspace: string, initia async function listAutomationTabs(workspace: string): Promise { const session = automationSessions.get(workspace); if (!session) return []; + if (session.preferredTabId !== null) { + try { + return [await chrome.tabs.get(session.preferredTabId)]; + } catch { + automationSessions.delete(workspace); + return []; + } + } try { return await chrome.tabs.query({ windowId: session.windowId }); } catch { @@ -681,10 +742,12 @@ async function handleCdp(cmd: Command, workspace: string): Promise { async function handleCloseWindow(cmd: Command, workspace: string): Promise { const session = automationSessions.get(workspace); if (session) { - try { - await chrome.windows.remove(session.windowId); - } catch { - // Window may already be closed + if (session.owned) { + try { + await chrome.windows.remove(session.windowId); + } catch { + // Window may already be closed + } } if (session.idleTimer) clearTimeout(session.idleTimer); automationSessions.delete(workspace); @@ -705,6 +768,39 @@ async function handleSetFileInput(cmd: Command, workspace: string): Promise { + if (typeof cmd.text !== 'string') { + return { id: cmd.id, ok: false, error: 'Missing text payload' }; + } + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + await executor.insertText(tabId, cmd.text); + return { id: cmd.id, ok: true, data: { inserted: true } }; + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +async function handleNetworkCaptureStart(cmd: Command, workspace: string): Promise { + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + await executor.startNetworkCapture(tabId, cmd.pattern); + return { id: cmd.id, ok: true, data: { started: true } }; + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +async function handleNetworkCaptureRead(cmd: Command, workspace: string): Promise { + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + const data = await executor.readNetworkCapture(tabId); + return { id: cmd.id, ok: true, data }; + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + async function handleSessions(cmd: Command): Promise { const now = Date.now(); const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ @@ -716,11 +812,49 @@ async function handleSessions(cmd: Command): Promise { return { id: cmd.id, ok: true, data }; } +async function handleBindCurrent(cmd: Command, workspace: string): Promise { + const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); + const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); + const allTabs = await chrome.tabs.query({}); + const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd)) + ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd)) + ?? allTabs.find((tab) => matchesBindCriteria(tab, cmd)); + if (!boundTab?.id) { + return { + id: cmd.id, + ok: false, + error: cmd.matchDomain || cmd.matchPathPrefix + ? `No visible tab matching ${cmd.matchDomain ?? 'domain'}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ''}` + : 'No active debuggable tab found', + }; + } + + setWorkspaceSession(workspace, { + windowId: boundTab.windowId, + owned: false, + preferredTabId: boundTab.id, + }); + resetWindowIdleTimer(workspace); + console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); + return { + id: cmd.id, + ok: true, + data: { + tabId: boundTab.id, + windowId: boundTab.windowId, + url: boundTab.url, + title: boundTab.title, + workspace, + }, + }; +} + export const __test__ = { handleNavigate, isTargetUrl, handleTabs, handleSessions, + handleBindCurrent, resolveTabId, resetWindowIdleTimer, getSession: (workspace: string = 'default') => automationSessions.get(workspace) ?? null, @@ -734,9 +868,11 @@ export const __test__ = { } setWorkspaceSession(workspace, { windowId, + owned: true, + preferredTabId: null, }); }, - setSession: (workspace: string, session: { windowId: number }) => { + setSession: (workspace: string, session: { windowId: number; owned: boolean; preferredTabId: number | null }) => { setWorkspaceSession(workspace, session); }, }; diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index cbb8b2f7..23c1d695 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -8,6 +8,27 @@ const attached = new Set(); +type NetworkCaptureEntry = { + kind: 'cdp'; + url: string; + method: string; + requestHeaders?: Record; + requestBodyKind?: string; + requestBodyPreview?: string; + responseStatus?: number; + responseContentType?: string; + responseHeaders?: Record; + responsePreview?: string; + timestamp: number; +}; + +type NetworkCaptureState = { + patterns: string[]; + entries: NetworkCaptureEntry[]; + requestToIndex: Map; +}; + +const networkCaptures = new Map(); /** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ function isDebuggableUrl(url?: string): boolean { if (!url) return true; // empty/undefined = tab still loading, allow it @@ -241,18 +262,100 @@ export async function setFileInputFiles( }); } +export async function insertText( + tabId: number, + text: string, +): Promise { + await ensureAttached(tabId); + await chrome.debugger.sendCommand({ tabId }, 'Input.insertText', { text }); +} + +function normalizeCapturePatterns(pattern?: string): string[] { + return String(pattern || '') + .split('|') + .map((part) => part.trim()) + .filter(Boolean); +} + +function shouldCaptureUrl(url: string | undefined, patterns: string[]): boolean { + if (!url) return false; + if (!patterns.length) return true; + return patterns.some((pattern) => url.includes(pattern)); +} + +function normalizeHeaders(headers: unknown): Record { + if (!headers || typeof headers !== 'object') return {}; + const out: Record = {}; + for (const [key, value] of Object.entries(headers as Record)) { + out[String(key)] = String(value); + } + return out; +} + +function getOrCreateNetworkCaptureEntry(tabId: number, requestId: string, fallback?: { + url?: string; + method?: string; + requestHeaders?: Record; +}): NetworkCaptureEntry | null { + const state = networkCaptures.get(tabId); + if (!state) return null; + const existingIndex = state.requestToIndex.get(requestId); + if (existingIndex !== undefined) { + return state.entries[existingIndex] || null; + } + const url = fallback?.url || ''; + if (!shouldCaptureUrl(url, state.patterns)) return null; + const entry: NetworkCaptureEntry = { + kind: 'cdp', + url, + method: fallback?.method || 'GET', + requestHeaders: fallback?.requestHeaders || {}, + timestamp: Date.now(), + }; + state.entries.push(entry); + state.requestToIndex.set(requestId, state.entries.length - 1); + return entry; +} + +export async function startNetworkCapture( + tabId: number, + pattern?: string, +): Promise { + await ensureAttached(tabId); + await chrome.debugger.sendCommand({ tabId }, 'Network.enable'); + networkCaptures.set(tabId, { + patterns: normalizeCapturePatterns(pattern), + entries: [], + requestToIndex: new Map(), + }); +} + +export async function readNetworkCapture(tabId: number): Promise { + const state = networkCaptures.get(tabId); + if (!state) return []; + const entries = state.entries.slice(); + state.entries = []; + state.requestToIndex.clear(); + return entries; +} + export async function detach(tabId: number): Promise { if (!attached.has(tabId)) return; attached.delete(tabId); + networkCaptures.delete(tabId); try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ } } export function registerListeners(): void { chrome.tabs.onRemoved.addListener((tabId) => { attached.delete(tabId); + networkCaptures.delete(tabId); }); chrome.debugger.onDetach.addListener((source) => { - if (source.tabId) attached.delete(source.tabId); + if (source.tabId) { + attached.delete(source.tabId); + networkCaptures.delete(source.tabId); + } }); // Invalidate attached cache when tab URL changes to non-debuggable chrome.tabs.onUpdated.addListener(async (tabId, info) => { @@ -260,4 +363,78 @@ export function registerListeners(): void { await detach(tabId); } }); + chrome.debugger.onEvent.addListener(async (source, method, params) => { + const tabId = source.tabId; + if (!tabId) return; + const state = networkCaptures.get(tabId); + if (!state) return; + + if (method === 'Network.requestWillBeSent') { + const requestId = String(params?.requestId || ''); + const request = params?.request as { + url?: string; + method?: string; + headers?: Record; + postData?: string; + hasPostData?: boolean; + } | undefined; + const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { + url: request?.url, + method: request?.method, + requestHeaders: normalizeHeaders(request?.headers), + }); + if (!entry) return; + entry.requestBodyKind = request?.hasPostData ? 'string' : 'empty'; + entry.requestBodyPreview = String(request?.postData || '').slice(0, 4000); + try { + const postData = await chrome.debugger.sendCommand({ tabId }, 'Network.getRequestPostData', { requestId }) as { postData?: string }; + if (postData?.postData) { + entry.requestBodyKind = 'string'; + entry.requestBodyPreview = postData.postData.slice(0, 4000); + } + } catch { + // Optional; some requests do not expose postData. + } + return; + } + + if (method === 'Network.responseReceived') { + const requestId = String(params?.requestId || ''); + const response = params?.response as { + url?: string; + mimeType?: string; + status?: number; + headers?: Record; + } | undefined; + const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { + url: response?.url, + }); + if (!entry) return; + entry.responseStatus = response?.status; + entry.responseContentType = response?.mimeType || ''; + entry.responseHeaders = normalizeHeaders(response?.headers); + return; + } + + if (method === 'Network.loadingFinished') { + const requestId = String(params?.requestId || ''); + const stateEntryIndex = state.requestToIndex.get(requestId); + if (stateEntryIndex === undefined) return; + const entry = state.entries[stateEntryIndex]; + if (!entry) return; + try { + const body = await chrome.debugger.sendCommand({ tabId }, 'Network.getResponseBody', { requestId }) as { + body?: string; + base64Encoded?: boolean; + }; + if (typeof body?.body === 'string') { + entry.responsePreview = body.base64Encoded + ? `base64:${body.body.slice(0, 4000)}` + : body.body.slice(0, 4000); + } + } catch { + // Optional; bodies are unavailable for some requests (e.g. uploads). + } + } + }); } diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index 381761c2..3ed5ce86 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -5,7 +5,20 @@ * Everything else is just JS code sent via 'exec'. */ -export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'cdp'; +export type Action = + | 'exec' + | 'navigate' + | 'tabs' + | 'cookies' + | 'screenshot' + | 'close-window' + | 'sessions' + | 'set-file-input' + | 'insert-text' + | 'bind-current' + | 'network-capture-start' + | 'network-capture-read' + | 'cdp'; export interface Command { /** Unique request ID */ @@ -26,6 +39,10 @@ export interface Command { index?: number; /** Cookie domain filter */ domain?: string; + /** Optional hostname/domain to require for current-tab binding */ + matchDomain?: string; + /** Optional pathname prefix to require for current-tab binding */ + matchPathPrefix?: string; /** Screenshot format: png (default) or jpeg */ format?: 'png' | 'jpeg'; /** JPEG quality (0-100), only for jpeg format */ @@ -36,6 +53,10 @@ export interface Command { files?: string[]; /** CSS selector for file input element (set-file-input action) */ selector?: string; + /** Raw text payload for insert-text action */ + text?: string; + /** URL substring filter pattern for network capture actions */ + pattern?: string; /** CDP method name for 'cdp' action (e.g. 'Accessibility.getFullAXTree') */ cdpMethod?: string; /** CDP method params for 'cdp' action */ diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index b6b9ab83..002850dc 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -21,7 +21,7 @@ function generateId(): string { export interface DaemonCommand { id: string; - action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'cdp'; + action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind-current' | 'network-capture-start' | 'network-capture-read' | 'cdp'; tabId?: number; code?: string; workspace?: string; @@ -29,6 +29,8 @@ export interface DaemonCommand { op?: string; index?: number; domain?: string; + matchDomain?: string; + matchPathPrefix?: string; format?: 'png' | 'jpeg'; quality?: number; fullPage?: boolean; @@ -37,6 +39,10 @@ export interface DaemonCommand { files?: string[]; /** CSS selector for file input element (set-file-input action) */ selector?: string; + /** Raw text payload for insert-text action */ + text?: string; + /** URL substring filter pattern for network capture */ + pattern?: string; cdpMethod?: string; cdpParams?: Record; } @@ -163,3 +169,7 @@ export async function listSessions(): Promise { const result = await sendCommand('sessions'); return Array.isArray(result) ? result : []; } + +export async function bindCurrentTab(workspace: string, opts: { matchDomain?: string; matchPathPrefix?: string } = {}): Promise { + return sendCommand('bind-current', { workspace, ...opts }); +} diff --git a/src/browser/page.ts b/src/browser/page.ts index 73db6b44..e97f6955 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -120,6 +120,9 @@ export class Page extends BasePage { await sendCommand('close-window', { ...this._wsOpt() }); } catch { // Window may already be closed or daemon may be down + } finally { + this._tabId = undefined; + this._lastUrl = null; } } @@ -151,6 +154,19 @@ export class Page extends BasePage { return base64; } + async startNetworkCapture(pattern: string = ''): Promise { + await sendCommand('network-capture-start', { + pattern, + ...this._cmdOpts(), + }); + } + + async readNetworkCapture(): Promise { + const result = await sendCommand('network-capture-read', { + ...this._cmdOpts(), + }); + return Array.isArray(result) ? result : []; + } /** * Set local file paths on a file input element via CDP DOM.setFileInputFiles. * Chrome reads the files directly from the local filesystem, avoiding the @@ -167,6 +183,16 @@ export class Page extends BasePage { } } + async insertText(text: string): Promise { + const result = await sendCommand('insert-text', { + text, + ...this._cmdOpts(), + }) as { inserted?: boolean }; + if (!result?.inserted) { + throw new Error('insertText returned no inserted flag — command may not be supported by the extension'); + } + } + async cdp(method: string, params: Record = {}): Promise { return sendCommand('cdp', { cdpMethod: method, @@ -287,4 +313,3 @@ export class Page extends BasePage { }); } } - diff --git a/src/build-manifest.ts b/src/build-manifest.ts index 5e9f1bd2..d2cee791 100644 --- a/src/build-manifest.ts +++ b/src/build-manifest.ts @@ -33,6 +33,7 @@ export interface ManifestEntry { type?: string; default?: unknown; required?: boolean; + valueRequired?: boolean; positional?: boolean; help?: string; choices?: string[]; @@ -62,6 +63,7 @@ function toManifestArgs(args: CliCommand['args']): ManifestEntry['args'] { type: arg.type ?? 'str', default: arg.default, required: !!arg.required, + valueRequired: !!arg.valueRequired || undefined, positional: arg.positional || undefined, help: arg.help ?? '', choices: arg.choices, diff --git a/src/clis/instagram/_shared/private-publish.test.ts b/src/clis/instagram/_shared/private-publish.test.ts new file mode 100644 index 00000000..77e3db3e --- /dev/null +++ b/src/clis/instagram/_shared/private-publish.test.ts @@ -0,0 +1,827 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterAll, describe, expect, it } from 'vitest'; + +import type { InstagramProtocolCaptureEntry } from './protocol-capture.js'; +import { + buildConfigureBody, + buildConfigureSidecarPayload, + buildConfigureToStoryPhotoPayload, + buildConfigureToStoryVideoPayload, + deriveInstagramJazoest, + derivePrivateApiContextFromCapture, + extractInstagramRuntimeInfo, + getInstagramFeedNormalizedDimensions, + getInstagramStoryNormalizedDimensions, + isInstagramFeedAspectRatioAllowed, + isInstagramStoryAspectRatioAllowed, + publishStoryViaPrivateApi, + publishMediaViaPrivateApi, + publishImagesViaPrivateApi, + readImageAsset, + resolveInstagramPrivatePublishConfig, +} from './private-publish.js'; + +const tempDirs: string[] = []; + +function createTempFile(name: string, bytes: Buffer): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-instagram-private-')); + tempDirs.push(dir); + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, bytes); + return filePath; +} + +afterAll(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('instagram private publish helpers', () => { + it('derives the private API context from captured instagram request headers', () => { + const entries: InstagramProtocolCaptureEntry[] = [ + { + kind: 'cdp' as never, + url: 'https://www.instagram.com/api/v1/feed/timeline/', + method: 'GET', + requestHeaders: { + 'X-ASBD-ID': '359341', + 'X-CSRFToken': 'csrf-token', + 'X-IG-App-ID': '936619743392459', + 'X-IG-WWW-Claim': 'hmac.claim', + 'X-Instagram-AJAX': '1036517563', + 'X-Web-Session-ID': 'abc:def:ghi', + }, + timestamp: Date.now(), + }, + ]; + + expect(derivePrivateApiContextFromCapture(entries)).toEqual({ + asbdId: '359341', + csrfToken: 'csrf-token', + igAppId: '936619743392459', + igWwwClaim: 'hmac.claim', + instagramAjax: '1036517563', + webSessionId: 'abc:def:ghi', + }); + }); + + it('derives jazoest from the csrf token', () => { + expect(deriveInstagramJazoest('SJ_btbvfkpAVFKCN_tJstW')).toBe('22047'); + }); + + it('extracts app id, rollout hash, and csrf token from instagram html', () => { + const html = ` + + + + + + `; + expect(extractInstagramRuntimeInfo(html)).toEqual({ + appId: '936619743392459', + csrfToken: 'csrf-from-html', + instagramAjax: '1036523242', + }); + }); + + it('resolves private publish config from capture, runtime html, and cookies', async () => { + const entries: InstagramProtocolCaptureEntry[] = [ + { + kind: 'cdp' as never, + url: 'https://www.instagram.com/api/v1/feed/timeline/', + method: 'GET', + requestHeaders: { + 'X-ASBD-ID': '359341', + 'X-IG-WWW-Claim': 'hmac.claim', + 'X-Web-Session-ID': 'abc:def:ghi', + }, + timestamp: Date.now(), + }, + ]; + const page = { + goto: async () => undefined, + wait: async () => undefined, + getCookies: async () => [{ name: 'csrftoken', value: 'csrf-cookie', domain: 'instagram.com' }], + startNetworkCapture: async () => undefined, + readNetworkCapture: async () => entries, + evaluate: async () => ({ + appId: '936619743392459', + csrfToken: 'csrf-from-html', + instagramAjax: '1036523242', + }), + } as any; + + await expect(resolveInstagramPrivatePublishConfig(page)).resolves.toEqual({ + apiContext: { + asbdId: '359341', + csrfToken: 'csrf-from-html', + igAppId: '936619743392459', + igWwwClaim: 'hmac.claim', + instagramAjax: '1036523242', + webSessionId: 'abc:def:ghi', + }, + jazoest: deriveInstagramJazoest('csrf-from-html'), + }); + }); + + it('retries transient private publish config resolution failures and then succeeds', async () => { + const entries: InstagramProtocolCaptureEntry[] = [ + { + kind: 'cdp' as never, + url: 'https://www.instagram.com/api/v1/feed/timeline/', + method: 'GET', + requestHeaders: { + 'X-ASBD-ID': '359341', + 'X-IG-WWW-Claim': 'hmac.claim', + 'X-Web-Session-ID': 'abc:def:ghi', + }, + timestamp: Date.now(), + }, + ]; + let evaluateAttempts = 0; + const page = { + goto: async () => undefined, + wait: async () => undefined, + getCookies: async () => [{ name: 'csrftoken', value: 'csrf-cookie', domain: 'instagram.com' }], + startNetworkCapture: async () => undefined, + readNetworkCapture: async () => entries, + evaluate: async () => { + evaluateAttempts += 1; + if (evaluateAttempts === 1) { + throw new TypeError('fetch failed'); + } + return { + appId: '936619743392459', + csrfToken: 'csrf-from-html', + instagramAjax: '1036523242', + }; + }, + } as any; + + await expect(resolveInstagramPrivatePublishConfig(page)).resolves.toEqual({ + apiContext: { + asbdId: '359341', + csrfToken: 'csrf-from-html', + igAppId: '936619743392459', + igWwwClaim: 'hmac.claim', + instagramAjax: '1036523242', + webSessionId: 'abc:def:ghi', + }, + jazoest: deriveInstagramJazoest('csrf-from-html'), + }); + expect(evaluateAttempts).toBe(2); + }); + + it('builds the single-image configure form body', () => { + expect(buildConfigureBody({ + uploadId: '1775134280303', + caption: 'hello private route', + jazoest: '22047', + })).toBe( + 'archive_only=false&caption=hello+private+route&clips_share_preview_to_feed=1' + + '&disable_comments=0&disable_oa_reuse=false&igtv_share_preview_to_feed=1' + + '&is_meta_only_post=0&is_unified_video=1&like_and_view_counts_disabled=0' + + '&media_share_flow=creation_flow&share_to_facebook=&share_to_fb_destination_type=USER' + + '&source_type=library&upload_id=1775134280303&video_subtitles_enabled=0&jazoest=22047' + ); + }); + + it('builds the carousel configure_sidecar JSON payload', () => { + expect(buildConfigureSidecarPayload({ + uploadIds: ['1', '3', '2'], + caption: 'hello carousel', + clientSidecarId: '1775134574348', + jazoest: '22047', + })).toEqual({ + archive_only: false, + caption: 'hello carousel', + children_metadata: [ + { upload_id: '1' }, + { upload_id: '3' }, + { upload_id: '2' }, + ], + client_sidecar_id: '1775134574348', + disable_comments: '0', + is_meta_only_post: false, + is_open_to_public_submission: false, + like_and_view_counts_disabled: 0, + media_share_flow: 'creation_flow', + share_to_facebook: '', + share_to_fb_destination_type: 'USER', + source_type: 'library', + jazoest: '22047', + }); + }); + + it('reads png and jpeg image assets with mime type and dimensions', () => { + const png = createTempFile('sample.png', Buffer.from( + '89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082', + 'hex', + )); + const jpeg = createTempFile('sample.jpg', Buffer.from( + 'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9', + 'hex', + )); + + expect(readImageAsset(png)).toMatchObject({ + mimeType: 'image/png', + width: 3, + height: 5, + }); + expect(readImageAsset(jpeg)).toMatchObject({ + mimeType: 'image/jpeg', + width: 6, + height: 4, + }); + }); + + it('computes feed-safe aspect-ratio normalization targets', () => { + expect(isInstagramFeedAspectRatioAllowed(1080, 1350)).toBe(true); + expect(isInstagramFeedAspectRatioAllowed(1179, 2556)).toBe(false); + expect(getInstagramFeedNormalizedDimensions(1179, 2556)).toEqual({ + width: 2045, + height: 2556, + }); + expect(getInstagramFeedNormalizedDimensions(2120, 1140)).toBeNull(); + }); + + it('computes story-safe aspect-ratio normalization targets', () => { + expect(isInstagramStoryAspectRatioAllowed(1080, 1920)).toBe(true); + expect(isInstagramStoryAspectRatioAllowed(1080, 1080)).toBe(false); + expect(getInstagramStoryNormalizedDimensions(1080, 1080)).toEqual({ + width: 1080, + height: 1440, + }); + }); + + it('builds the single-photo configure_to_story payload', () => { + expect(buildConfigureToStoryPhotoPayload({ + uploadId: '1775134280303', + width: 1080, + height: 1920, + now: () => 1_775_134_280_303, + jazoest: '22047', + })).toMatchObject({ + source_type: '4', + upload_id: '1775134280303', + configure_mode: 1, + edits: { + crop_original_size: [1080, 1920], + crop_center: [0, 0], + crop_zoom: 1.3333334, + }, + extra: { + source_width: 1080, + source_height: 1920, + }, + jazoest: '22047', + }); + }); + + it('builds the single-video configure_to_story payload', () => { + expect(buildConfigureToStoryVideoPayload({ + uploadId: '1775134280303', + width: 1080, + height: 1920, + durationMs: 12500, + now: () => 1_775_134_280_303, + jazoest: '22047', + })).toMatchObject({ + source_type: '4', + upload_id: '1775134280303', + configure_mode: 1, + poster_frame_index: 0, + length: 12.5, + extra: { + source_width: 1080, + source_height: 1920, + }, + jazoest: '22047', + }); + }); + + it('publishes a single image through rupload + configure', async () => { + const jpeg = createTempFile('private-single.jpg', Buffer.from( + 'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9', + 'hex', + )); + const calls: Array<{ url: string; init?: { method?: string; headers?: Record; body?: unknown } }> = []; + const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record; body?: unknown }) => { + calls.push({ url: String(url), init }); + if (String(url).includes('/rupload_igphoto/')) { + return new Response('{"upload_id":"111","status":"ok"}', { status: 200 }); + } + return new Response('{"media":{"code":"ABC123"}}', { status: 200 }); + }; + + const response = await publishImagesViaPrivateApi({ + page: {} as never, + imagePaths: [jpeg], + caption: 'private single', + apiContext: { + asbdId: '359341', + csrfToken: 'csrf-token', + igAppId: '936619743392459', + igWwwClaim: 'hmac.claim', + instagramAjax: '1036517563', + webSessionId: 'abc:def:ghi', + }, + jazoest: '22047', + now: () => 111, + fetcher, + }); + + expect(calls).toHaveLength(2); + expect(calls[0]?.url).toContain('https://i.instagram.com/rupload_igphoto/fb_uploader_111'); + expect(calls[0]?.init?.headers).toMatchObject({ + 'Content-Type': 'image/jpeg', + 'X-Entity-Length': String(fs.statSync(jpeg).size), + 'X-Entity-Name': 'fb_uploader_111', + 'X-IG-App-ID': '936619743392459', + }); + expect(calls[1]?.url).toBe('https://www.instagram.com/api/v1/media/configure/'); + expect(String(calls[1]?.init?.body || '')).toContain('upload_id=111'); + expect(response).toEqual({ code: 'ABC123', uploadIds: ['111'] }); + }); + + it('publishes a single image story through rupload + configure_to_story', async () => { + const jpeg = createTempFile('private-story.jpg', Buffer.from( + 'FFD8FFE000104A46494600010100000100010000FFC00011080010000903012200021101031101FFD9', + 'hex', + )); + const calls: Array<{ url: string; init?: { method?: string; headers?: Record; body?: unknown } }> = []; + const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record; body?: unknown }) => { + calls.push({ url: String(url), init }); + if (String(url).includes('/rupload_igphoto/')) { + return new Response('{"upload_id":"111","status":"ok"}', { status: 200 }); + } + return new Response('{"media":{"pk":"1234567890"}}', { status: 200 }); + }; + + const response = await publishStoryViaPrivateApi({ + page: {} as never, + mediaItem: { type: 'image', filePath: jpeg }, + content: '', + currentUserId: '61236465677', + apiContext: { + asbdId: '359341', + csrfToken: 'csrf-token', + igAppId: '936619743392459', + igWwwClaim: 'hmac.claim', + instagramAjax: '1036517563', + webSessionId: 'abc:def:ghi', + }, + jazoest: '22047', + now: () => 111, + fetcher, + prepareMediaAsset: async () => ({ + type: 'image', + asset: { + filePath: jpeg, + fileName: path.basename(jpeg), + mimeType: 'image/jpeg', + width: 1080, + height: 1920, + byteLength: fs.statSync(jpeg).size, + bytes: fs.readFileSync(jpeg), + }, + }), + }); + + expect(calls).toHaveLength(2); + expect(calls[0]?.url).toContain('/rupload_igphoto/fb_uploader_111'); + expect(calls[1]?.url).toBe('https://i.instagram.com/api/v1/media/configure_to_story/'); + expect(String(calls[1]?.init?.body || '')).toContain('signed_body='); + expect(response).toEqual({ mediaPk: '1234567890', uploadId: '111' }); + }); + + it('publishes a single video story through rupload + cover + configure_to_story?video=1', async () => { + const video = createTempFile('private-story.mp4', Buffer.from('story-video')); + const coverBytes = Buffer.from( + 'FFD8FFE000104A46494600010100000100010000FFC00011080010000903012200021101031101FFD9', + 'hex', + ); + const calls: Array<{ url: string; init?: { method?: string; headers?: Record; body?: unknown } }> = []; + const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record; body?: unknown }) => { + calls.push({ url: String(url), init }); + if (String(url).includes('/rupload_igvideo/')) { + return new Response('{"upload_id":"222","status":"ok"}', { status: 200 }); + } + if (String(url).includes('/rupload_igphoto/')) { + return new Response('{"upload_id":"222","status":"ok"}', { status: 200 }); + } + return new Response('{"media":{"pk":"9988776655"}}', { status: 200 }); + }; + + const response = await publishStoryViaPrivateApi({ + page: {} as never, + mediaItem: { type: 'video', filePath: video }, + content: '', + currentUserId: '61236465677', + apiContext: { + asbdId: '359341', + csrfToken: 'csrf-token', + igAppId: '936619743392459', + igWwwClaim: 'hmac.claim', + instagramAjax: '1036517563', + webSessionId: 'abc:def:ghi', + }, + jazoest: '22047', + now: () => 222, + fetcher, + prepareMediaAsset: async () => ({ + type: 'video', + asset: { + filePath: video, + fileName: path.basename(video), + mimeType: 'video/mp4', + width: 1080, + height: 1920, + durationMs: 12500, + byteLength: fs.statSync(video).size, + bytes: fs.readFileSync(video), + coverImage: { + filePath: '/tmp/cover.jpg', + fileName: 'cover.jpg', + mimeType: 'image/jpeg', + width: 1080, + height: 1920, + byteLength: coverBytes.length, + bytes: coverBytes, + }, + }, + }), + }); + + expect(calls).toHaveLength(4); + expect(calls[0]?.url).toContain('/rupload_igvideo/fb_uploader_222'); + expect(calls[1]?.url).toContain('/rupload_igphoto/fb_uploader_222'); + expect(calls[2]?.url).toBe('https://i.instagram.com/api/v1/media/configure_to_story/'); + expect(calls[3]?.url).toBe('https://i.instagram.com/api/v1/media/configure_to_story/?video=1'); + expect(String(calls[2]?.init?.body || '')).toContain('signed_body='); + expect(String(calls[3]?.init?.body || '')).toContain('signed_body='); + expect(response).toEqual({ mediaPk: '9988776655', uploadId: '222' }); + }); + + it('publishes a carousel through rupload + configure_sidecar', async () => { + const first = createTempFile('private-carousel-1.jpg', Buffer.from( + 'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9', + 'hex', + )); + const second = createTempFile('private-carousel-2.png', Buffer.from( + '89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082', + 'hex', + )); + const calls: Array<{ url: string; init?: { method?: string; headers?: Record; body?: unknown } }> = []; + let uploadCounter = 0; + const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record; body?: unknown }) => { + calls.push({ url: String(url), init }); + if (String(url).includes('/rupload_igphoto/')) { + uploadCounter += 1; + return new Response(JSON.stringify({ upload_id: String(200 + uploadCounter), status: 'ok' }), { status: 200 }); + } + return new Response('{"media":{"code":"SIDE123"}}', { status: 200 }); + }; + + const response = await publishImagesViaPrivateApi({ + page: {} as never, + imagePaths: [first, second], + caption: 'private carousel', + apiContext: { + asbdId: '359341', + csrfToken: 'csrf-token', + igAppId: '936619743392459', + igWwwClaim: 'hmac.claim', + instagramAjax: '1036517563', + webSessionId: 'abc:def:ghi', + }, + jazoest: '22047', + now: () => 200, + fetcher, + prepareAsset: async (filePath) => readImageAsset(filePath), + }); + + expect(calls).toHaveLength(3); + expect(calls[2]?.url).toBe('https://www.instagram.com/api/v1/media/configure_sidecar/'); + expect(JSON.parse(String(calls[2]?.init?.body || '{}'))).toMatchObject({ + caption: 'private carousel', + client_sidecar_id: '200', + children_metadata: [{ upload_id: '201' }, { upload_id: '202' }], + }); + expect(response).toEqual({ code: 'SIDE123', uploadIds: ['201', '202'] }); + }); + + it('uses prepared assets when private carousel upload needs aspect-ratio normalization', async () => { + const first = createTempFile('private-carousel-normalize-1.jpg', Buffer.from( + 'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9', + 'hex', + )); + const second = createTempFile('private-carousel-normalize-2.png', Buffer.from( + '89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082', + 'hex', + )); + const calls: Array<{ url: string; init?: { method?: string; headers?: Record; body?: unknown } }> = []; + let uploadCounter = 0; + const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record; body?: unknown }) => { + calls.push({ url: String(url), init }); + if (String(url).includes('/rupload_igphoto/')) { + uploadCounter += 1; + return new Response(JSON.stringify({ upload_id: String(400 + uploadCounter), status: 'ok' }), { status: 200 }); + } + return new Response('{"media":{"code":"SIDEPAD"}}', { status: 200 }); + }; + + const preparedBytes = Buffer.from( + '89504E470D0A1A0A0000000D49484452000007FD000009FC08060000008D6F26E50000000049454E44AE426082', + 'hex', + ); + + const response = await publishImagesViaPrivateApi({ + page: {} as never, + imagePaths: [first, second], + caption: 'private carousel normalized', + apiContext: { + asbdId: '359341', + csrfToken: 'csrf-token', + igAppId: '936619743392459', + igWwwClaim: 'hmac.claim', + instagramAjax: '1036517563', + webSessionId: 'abc:def:ghi', + }, + jazoest: '22047', + now: () => 400, + fetcher, + prepareAsset: async (filePath) => { + if (filePath === second) { + return { + filePath: '/tmp/normalized.png', + fileName: 'normalized.png', + mimeType: 'image/png', + width: 2045, + height: 2556, + byteLength: preparedBytes.length, + bytes: preparedBytes, + cleanupPath: '/tmp/normalized.png', + }; + } + return readImageAsset(filePath); + }, + }); + + const secondUploadHeaders = calls[1]?.init?.headers ?? {}; + expect(JSON.parse(String(secondUploadHeaders['X-Instagram-Rupload-Params'] || '{}'))).toMatchObject({ + upload_media_width: 2045, + upload_media_height: 2556, + }); + expect(response).toEqual({ code: 'SIDEPAD', uploadIds: ['401', '402'] }); + }); + + it('includes the response body when configure_sidecar returns a 400', async () => { + const first = createTempFile('private-carousel-error-1.jpg', Buffer.from( + 'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9', + 'hex', + )); + const second = createTempFile('private-carousel-error-2.png', Buffer.from( + '89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082', + 'hex', + )); + let uploadCounter = 0; + const fetcher = async (url: string | URL) => { + if (String(url).includes('/rupload_igphoto/')) { + uploadCounter += 1; + return new Response(JSON.stringify({ upload_id: String(300 + uploadCounter), status: 'ok' }), { status: 200 }); + } + return new Response('{"message":"children_metadata invalid"}', { status: 400 }); + }; + + await expect(publishImagesViaPrivateApi({ + page: {} as never, + imagePaths: [first, second], + caption: 'private carousel', + apiContext: { + asbdId: '359341', + csrfToken: 'csrf-token', + igAppId: '936619743392459', + igWwwClaim: 'hmac.claim', + instagramAjax: '1036517563', + webSessionId: 'abc:def:ghi', + }, + jazoest: '22047', + now: () => 300, + fetcher, + prepareAsset: async (filePath) => readImageAsset(filePath), + })).rejects.toThrow('children_metadata invalid'); + }); + + it('retries transient rupload fetch failures and still completes the carousel publish', async () => { + const first = createTempFile('private-carousel-retry-1.jpg', Buffer.from( + 'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9', + 'hex', + )); + const second = createTempFile('private-carousel-retry-2.png', Buffer.from( + '89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082', + 'hex', + )); + const calls: string[] = []; + let firstUploadAttempts = 0; + let uploadCounter = 0; + const fetcher = async (url: string | URL) => { + const value = String(url); + calls.push(value); + if (value.includes('/rupload_igphoto/')) { + firstUploadAttempts += value.includes('fb_uploader_501') ? 1 : 0; + if (value.includes('fb_uploader_501') && firstUploadAttempts === 1) { + throw new TypeError('fetch failed'); + } + uploadCounter += 1; + return new Response(JSON.stringify({ upload_id: String(500 + uploadCounter), status: 'ok' }), { status: 200 }); + } + return new Response('{"media":{"code":"SIDERETRY"}}', { status: 200 }); + }; + + const response = await publishImagesViaPrivateApi({ + page: {} as never, + imagePaths: [first, second], + caption: 'private carousel retry', + apiContext: { + asbdId: '359341', + csrfToken: 'csrf-token', + igAppId: '936619743392459', + igWwwClaim: 'hmac.claim', + instagramAjax: '1036517563', + webSessionId: 'abc:def:ghi', + }, + jazoest: '22047', + now: () => 500, + fetcher, + prepareAsset: async (filePath) => readImageAsset(filePath), + }); + + expect(calls.filter((url) => url.includes('fb_uploader_501'))).toHaveLength(2); + expect(response).toEqual({ code: 'SIDERETRY', uploadIds: ['501', '502'] }); + }); + + it('does not retry transient configure_sidecar fetch failures to avoid duplicate posts', async () => { + const first = createTempFile('private-carousel-no-retry-1.jpg', Buffer.from( + 'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9', + 'hex', + )); + const second = createTempFile('private-carousel-no-retry-2.png', Buffer.from( + '89504E470D0A1A0A0000000D49484452000000030000000508060000008D6F26E50000000049454E44AE426082', + 'hex', + )); + const calls: string[] = []; + let uploadCounter = 0; + const fetcher = async (url: string | URL) => { + const value = String(url); + calls.push(value); + if (value.includes('/rupload_igphoto/')) { + uploadCounter += 1; + return new Response(JSON.stringify({ upload_id: String(600 + uploadCounter), status: 'ok' }), { status: 200 }); + } + throw new TypeError('fetch failed'); + }; + + await expect(publishImagesViaPrivateApi({ + page: {} as never, + imagePaths: [first, second], + caption: 'private no retry configure', + apiContext: { + asbdId: '359341', + csrfToken: 'csrf-token', + igAppId: '936619743392459', + igWwwClaim: 'hmac.claim', + instagramAjax: '1036517563', + webSessionId: 'abc:def:ghi', + }, + jazoest: '22047', + now: () => 600, + fetcher, + prepareAsset: async (filePath) => readImageAsset(filePath), + })).rejects.toThrow('fetch failed'); + + expect(calls.filter((url) => url.includes('configure_sidecar'))).toHaveLength(1); + }); + + it('publishes a mixed image/video carousel and polls configure_sidecar until transcoding finishes', async () => { + const image = createTempFile('mixed-private-image.jpg', Buffer.from( + 'FFD8FFE000104A46494600010100000100010000FFC00011080004000603012200021101031101FFD9', + 'hex', + )); + const video = createTempFile('mixed-private-video.mp4', Buffer.from('video-binary')); + const coverBytes = Buffer.from( + 'FFD8FFE000104A46494600010100000100010000FFC00011080168028003012200021101031101FFD9', + 'hex', + ); + const calls: Array<{ url: string; init?: { method?: string; headers?: Record; body?: unknown } }> = []; + let configureAttempts = 0; + const fetcher = async (url: string | URL, init?: { method?: string; headers?: Record; body?: unknown }) => { + const value = String(url); + calls.push({ url: value, init }); + if (value.includes('/rupload_igphoto/') && value.includes('fb_uploader_701')) { + return new Response('{"upload_id":"701","status":"ok"}', { status: 200 }); + } + if (value.includes('/rupload_igvideo/') && value.includes('fb_uploader_702')) { + return new Response('{"media_id":17944674009157009,"status":"ok"}', { status: 200 }); + } + if (value.includes('/rupload_igphoto/') && value.includes('fb_uploader_702')) { + return new Response('{"upload_id":"702","status":"ok"}', { status: 200 }); + } + configureAttempts += 1; + if (configureAttempts === 1) { + return new Response('{"message":"Transcode not finished yet.","status":"fail"}', { status: 202 }); + } + return new Response('{"status":"ok","media":{"code":"MIXEDSIDE123"}}', { status: 200 }); + }; + + const response = await publishMediaViaPrivateApi({ + page: {} as never, + mediaItems: [ + { type: 'image', filePath: image }, + { type: 'video', filePath: video }, + ], + caption: 'mixed private carousel', + apiContext: { + asbdId: '359341', + csrfToken: 'csrf-token', + igAppId: '936619743392459', + igWwwClaim: 'hmac.claim', + instagramAjax: '1036517563', + webSessionId: 'abc:def:ghi', + }, + jazoest: '22047', + now: () => 700, + fetcher, + prepareMediaAsset: async (item) => { + if (item.type === 'image') { + return { + type: 'image' as const, + asset: readImageAsset(item.filePath), + }; + } + return { + type: 'video' as const, + asset: { + filePath: item.filePath, + fileName: 'mixed-private-video.mp4', + mimeType: 'video/mp4', + width: 640, + height: 360, + durationMs: 28245, + byteLength: 12, + bytes: Buffer.from('video-binary'), + coverImage: { + filePath: '/tmp/mixed-private-cover.jpg', + fileName: 'mixed-private-cover.jpg', + mimeType: 'image/jpeg', + width: 640, + height: 360, + byteLength: coverBytes.length, + bytes: coverBytes, + }, + }, + }; + }, + waitMs: async () => undefined, + }); + + expect(calls).toHaveLength(5); + expect(calls[0]?.url).toContain('/rupload_igphoto/fb_uploader_701'); + expect(calls[1]?.url).toContain('/rupload_igvideo/fb_uploader_702'); + expect(calls[2]?.url).toContain('/rupload_igphoto/fb_uploader_702'); + expect(JSON.parse(String(calls[1]?.init?.headers?.['X-Instagram-Rupload-Params'] || '{}'))).toMatchObject({ + media_type: 2, + upload_id: '702', + upload_media_width: 640, + upload_media_height: 360, + upload_media_duration_ms: 28245, + video_edit_params: { + crop_width: 360, + crop_height: 360, + crop_x1: 140, + crop_y1: 0, + trim_start: 0, + trim_end: 28.245, + mute: false, + }, + }); + expect(JSON.parse(String(calls[2]?.init?.headers?.['X-Instagram-Rupload-Params'] || '{}'))).toMatchObject({ + media_type: 2, + upload_id: '702', + upload_media_width: 640, + upload_media_height: 360, + }); + expect(JSON.parse(String(calls[3]?.init?.body || '{}'))).toMatchObject({ + caption: 'mixed private carousel', + client_sidecar_id: '700', + children_metadata: [{ upload_id: '701' }, { upload_id: '702' }], + }); + expect(response).toEqual({ code: 'MIXEDSIDE123', uploadIds: ['701', '702'] }); + }); +}); diff --git a/src/clis/instagram/_shared/private-publish.ts b/src/clis/instagram/_shared/private-publish.ts new file mode 100644 index 00000000..11db5c77 --- /dev/null +++ b/src/clis/instagram/_shared/private-publish.ts @@ -0,0 +1,1303 @@ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { CommandExecutionError } from '../../../errors.js'; +import type { BrowserCookie, IPage } from '../../../types.js'; +import type { InstagramProtocolCaptureEntry } from './protocol-capture.js'; +import { instagramPrivateApiFetch } from './protocol-capture.js'; +import { + buildReadInstagramRuntimeInfoJs, + extractInstagramRuntimeInfo, + type InstagramRuntimeInfo, +} from './runtime-info.js'; +export { + buildReadInstagramRuntimeInfoJs, + extractInstagramRuntimeInfo, + type InstagramRuntimeInfo, + resolveInstagramRuntimeInfo, +} from './runtime-info.js'; + +export interface InstagramPrivateApiContext { + asbdId: string; + csrfToken: string; + igAppId: string; + igWwwClaim: string; + instagramAjax: string; + webSessionId: string; +} + +export interface InstagramImageAsset { + filePath: string; + fileName: string; + mimeType: string; + width: number; + height: number; + byteLength: number; + bytes: Buffer; +} + +export interface PreparedInstagramImageAsset extends InstagramImageAsset { + cleanupPath?: string; +} + +export type InstagramMediaKind = 'image' | 'video'; + +export interface InstagramMediaItem { + type: InstagramMediaKind; + filePath: string; +} + +export interface InstagramVideoAsset { + filePath: string; + fileName: string; + mimeType: string; + width: number; + height: number; + durationMs: number; + byteLength: number; + bytes: Buffer; + coverImage: PreparedInstagramImageAsset; + cleanupPaths?: string[]; +} + +export type PreparedInstagramMediaAsset = + | { type: 'image'; asset: PreparedInstagramImageAsset } + | { type: 'video'; asset: InstagramVideoAsset }; + +type StoryPayloadInput = { + uploadId: string; + width: number; + height: number; + now?: () => number; + jazoest: string; +}; + +type StoryVideoPayloadInput = StoryPayloadInput & { + durationMs: number; +}; + +type PrivateApiFetchInit = { + method?: string; + headers?: Record; + body?: unknown; +}; + +type PrivateApiFetchLike = (url: string | URL, init?: PrivateApiFetchInit) => Promise; + +const INSTAGRAM_MIN_FEED_ASPECT_RATIO = 4 / 5; +const INSTAGRAM_MAX_FEED_ASPECT_RATIO = 1.91; +const INSTAGRAM_MIN_STORY_ASPECT_RATIO = 9 / 16; +const INSTAGRAM_MAX_STORY_ASPECT_RATIO = 3 / 4; +const INSTAGRAM_PRIVATE_PAD_COLOR = 'FFFFFF'; +const INSTAGRAM_HOME_URL = 'https://www.instagram.com/'; +const INSTAGRAM_PRIVATE_CAPTURE_PATTERN = '/api/v1/|/graphql/'; +const INSTAGRAM_PRIVATE_CONFIG_RETRY_BUDGET = 2; +const INSTAGRAM_PRIVATE_UPLOAD_RETRY_BUDGET = 2; +const INSTAGRAM_PRIVATE_SIDECAR_TRANSCODE_ATTEMPTS = 20; +const INSTAGRAM_PRIVATE_SIDECAR_TRANSCODE_WAIT_MS = 2000; +const INSTAGRAM_MAX_STORY_VIDEO_DURATION_MS = 15_000; +const INSTAGRAM_STORY_SIG_KEY = '19ce5f445dbfd9d29c59dc2a78c616a7fc090a8e018b9267bc4240a30244c53b'; +const INSTAGRAM_STORY_SIG_KEY_VERSION = '4'; +const INSTAGRAM_STORY_DEVICE = { + manufacturer: 'samsung', + model: 'SM-G930F', + android_version: 24, + android_release: '7.0', +} as const; + +export function derivePrivateApiContextFromCapture( + entries: InstagramProtocolCaptureEntry[], +): InstagramPrivateApiContext | null { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const headers = entries[index]?.requestHeaders ?? {}; + const context = { + asbdId: String(headers['X-ASBD-ID'] || ''), + csrfToken: String(headers['X-CSRFToken'] || ''), + igAppId: String(headers['X-IG-App-ID'] || ''), + igWwwClaim: String(headers['X-IG-WWW-Claim'] || ''), + instagramAjax: String(headers['X-Instagram-AJAX'] || ''), + webSessionId: String(headers['X-Web-Session-ID'] || ''), + }; + if ( + context.asbdId + && context.csrfToken + && context.igAppId + && context.igWwwClaim + && context.instagramAjax + && context.webSessionId + ) { + return context; + } + } + return null; +} + +function derivePartialPrivateApiContextFromCapture( + entries: InstagramProtocolCaptureEntry[], +): Partial { + const context: Partial = {}; + for (let index = entries.length - 1; index >= 0; index -= 1) { + const headers = entries[index]?.requestHeaders ?? {}; + if (!context.asbdId && headers['X-ASBD-ID']) context.asbdId = String(headers['X-ASBD-ID']); + if (!context.csrfToken && headers['X-CSRFToken']) context.csrfToken = String(headers['X-CSRFToken']); + if (!context.igAppId && headers['X-IG-App-ID']) context.igAppId = String(headers['X-IG-App-ID']); + if (!context.igWwwClaim && headers['X-IG-WWW-Claim']) context.igWwwClaim = String(headers['X-IG-WWW-Claim']); + if (!context.instagramAjax && headers['X-Instagram-AJAX']) context.instagramAjax = String(headers['X-Instagram-AJAX']); + if (!context.webSessionId && headers['X-Web-Session-ID']) context.webSessionId = String(headers['X-Web-Session-ID']); + } + return context; +} + +export function deriveInstagramJazoest(value: string): string { + if (!value) return ''; + const sum = Array.from(value).reduce((total, char) => total + char.charCodeAt(0), 0); + return `2${sum}`; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isTransientPrivateFetchError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /fetch failed|network|socket hang up|econnreset|etimedout/i.test(message); +} + +function getCookieValue(cookies: BrowserCookie[], name: string): string { + return cookies.find((cookie) => cookie.name === name)?.value || ''; +} + +export async function resolveInstagramPrivatePublishConfig(page: IPage): Promise<{ + apiContext: InstagramPrivateApiContext; + jazoest: string; +}> { + let lastError: unknown; + for (let attempt = 0; attempt < INSTAGRAM_PRIVATE_CONFIG_RETRY_BUDGET; attempt += 1) { + try { + if (typeof page.startNetworkCapture === 'function') { + await page.startNetworkCapture(INSTAGRAM_PRIVATE_CAPTURE_PATTERN); + } + await page.goto(`${INSTAGRAM_HOME_URL}?__opencli_private_probe=${Date.now()}`); + await page.wait({ time: 2 }); + + const [cookies, runtime, entries] = await Promise.all([ + page.getCookies({ domain: 'instagram.com' }), + page.evaluate(buildReadInstagramRuntimeInfoJs()) as Promise, + typeof page.readNetworkCapture === 'function' + ? page.readNetworkCapture() as Promise + : Promise.resolve([]), + ]); + + const captureEntries = (Array.isArray(entries) ? entries : []) as InstagramProtocolCaptureEntry[]; + const capturedContext = derivePrivateApiContextFromCapture(captureEntries) + ?? derivePartialPrivateApiContextFromCapture(captureEntries); + + const csrfToken = runtime?.csrfToken || getCookieValue(cookies, 'csrftoken') || capturedContext.csrfToken || ''; + const igAppId = runtime?.appId || capturedContext.igAppId || ''; + const instagramAjax = runtime?.instagramAjax || capturedContext.instagramAjax || ''; + if (!csrfToken) { + throw new CommandExecutionError('Instagram private route could not derive CSRF token from browser session'); + } + if (!igAppId) { + throw new CommandExecutionError('Instagram private route could not derive X-IG-App-ID from instagram runtime'); + } + if (!instagramAjax) { + throw new CommandExecutionError('Instagram private route could not derive X-Instagram-AJAX from instagram runtime'); + } + const asbdId = capturedContext.asbdId || ''; + const igWwwClaim = capturedContext.igWwwClaim || ''; + const webSessionId = capturedContext.webSessionId || ''; + + return { + apiContext: { + asbdId, + csrfToken, + igAppId, + igWwwClaim, + instagramAjax, + webSessionId, + }, + jazoest: deriveInstagramJazoest(csrfToken), + }; + } catch (error) { + lastError = error; + if (!isTransientPrivateFetchError(error) || attempt >= INSTAGRAM_PRIVATE_CONFIG_RETRY_BUDGET - 1) { + throw error; + } + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} + +export function buildConfigureBody(input: { + uploadId: string; + caption: string; + jazoest: string; +}): string { + const body = new URLSearchParams(); + body.set('archive_only', 'false'); + body.set('caption', input.caption); + body.set('clips_share_preview_to_feed', '1'); + body.set('disable_comments', '0'); + body.set('disable_oa_reuse', 'false'); + body.set('igtv_share_preview_to_feed', '1'); + body.set('is_meta_only_post', '0'); + body.set('is_unified_video', '1'); + body.set('like_and_view_counts_disabled', '0'); + body.set('media_share_flow', 'creation_flow'); + body.set('share_to_facebook', ''); + body.set('share_to_fb_destination_type', 'USER'); + body.set('source_type', 'library'); + body.set('upload_id', input.uploadId); + body.set('video_subtitles_enabled', '0'); + body.set('jazoest', input.jazoest); + return body.toString(); +} + +export function buildConfigureSidecarPayload(input: { + uploadIds: string[]; + caption: string; + clientSidecarId: string; + jazoest: string; +}): Record { + return { + archive_only: false, + caption: input.caption, + children_metadata: input.uploadIds.map((uploadId) => ({ upload_id: uploadId })), + client_sidecar_id: input.clientSidecarId, + disable_comments: '0', + is_meta_only_post: false, + is_open_to_public_submission: false, + like_and_view_counts_disabled: 0, + media_share_flow: 'creation_flow', + share_to_facebook: '', + share_to_fb_destination_type: 'USER', + source_type: 'library', + jazoest: input.jazoest, + }; +} + +function inferMimeType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + switch (ext) { + case '.png': + return 'image/png'; + case '.webp': + return 'image/webp'; + case '.jpg': + case '.jpeg': + default: + return 'image/jpeg'; + } +} + +function readPngDimensions(bytes: Buffer): { width: number; height: number } | null { + if (bytes.length < 24) return null; + if (bytes.subarray(0, 8).toString('hex').toUpperCase() !== '89504E470D0A1A0A') return null; + if (bytes.subarray(12, 16).toString('ascii') !== 'IHDR') return null; + return { + width: bytes.readUInt32BE(16), + height: bytes.readUInt32BE(20), + }; +} + +function readJpegDimensions(bytes: Buffer): { width: number; height: number } | null { + if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null; + let offset = 2; + while (offset + 9 < bytes.length) { + if (bytes[offset] !== 0xff) { + offset += 1; + continue; + } + const marker = bytes[offset + 1]; + offset += 2; + if (marker === 0xd8 || marker === 0xd9) continue; + if (offset + 2 > bytes.length) break; + const segmentLength = bytes.readUInt16BE(offset); + if (segmentLength < 2 || offset + segmentLength > bytes.length) break; + const isStartOfFrame = marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker); + if (isStartOfFrame && segmentLength >= 7) { + return { + height: bytes.readUInt16BE(offset + 3), + width: bytes.readUInt16BE(offset + 5), + }; + } + offset += segmentLength; + } + return null; +} + +function readWebpDimensions(bytes: Buffer): { width: number; height: number } | null { + if (bytes.length < 30) return null; + if (bytes.subarray(0, 4).toString('ascii') !== 'RIFF' || bytes.subarray(8, 12).toString('ascii') !== 'WEBP') { + return null; + } + + const chunkType = bytes.subarray(12, 16).toString('ascii'); + if (chunkType === 'VP8X' && bytes.length >= 30) { + return { + width: 1 + bytes.readUIntLE(24, 3), + height: 1 + bytes.readUIntLE(27, 3), + }; + } + + if (chunkType === 'VP8 ' && bytes.length >= 30) { + return { + width: bytes.readUInt16LE(26) & 0x3fff, + height: bytes.readUInt16LE(28) & 0x3fff, + }; + } + + if (chunkType === 'VP8L' && bytes.length >= 25) { + const bits = bytes.readUInt32LE(21); + return { + width: (bits & 0x3fff) + 1, + height: ((bits >> 14) & 0x3fff) + 1, + }; + } + + return null; +} + +function readImageDimensions(filePath: string, bytes: Buffer): { width: number; height: number } { + const ext = path.extname(filePath).toLowerCase(); + const dimensions = ext === '.png' + ? readPngDimensions(bytes) + : ext === '.webp' + ? readWebpDimensions(bytes) + : readJpegDimensions(bytes); + if (!dimensions) { + throw new CommandExecutionError(`Failed to read image dimensions for ${filePath}`); + } + return dimensions; +} + +export function readImageAsset(filePath: string): InstagramImageAsset { + const bytes = fs.readFileSync(filePath); + const { width, height } = readImageDimensions(filePath, bytes); + return { + filePath, + fileName: path.basename(filePath), + mimeType: inferMimeType(filePath), + width, + height, + byteLength: bytes.length, + bytes, + }; +} + +export function isInstagramFeedAspectRatioAllowed(width: number, height: number): boolean { + const ratio = width / Math.max(height, 1); + return ratio >= INSTAGRAM_MIN_FEED_ASPECT_RATIO - 0.001 + && ratio <= INSTAGRAM_MAX_FEED_ASPECT_RATIO + 0.001; +} + +export function getInstagramFeedNormalizedDimensions( + width: number, + height: number, +): { width: number; height: number } | null { + const ratio = width / Math.max(height, 1); + if (ratio < INSTAGRAM_MIN_FEED_ASPECT_RATIO) { + return { + width: Math.ceil(height * INSTAGRAM_MIN_FEED_ASPECT_RATIO), + height, + }; + } + if (ratio > INSTAGRAM_MAX_FEED_ASPECT_RATIO) { + return { + width, + height: Math.ceil(width / INSTAGRAM_MAX_FEED_ASPECT_RATIO), + }; + } + return null; +} + +export function isInstagramStoryAspectRatioAllowed(width: number, height: number): boolean { + const ratio = width / Math.max(height, 1); + return ratio >= INSTAGRAM_MIN_STORY_ASPECT_RATIO - 0.001 + && ratio <= INSTAGRAM_MAX_STORY_ASPECT_RATIO + 0.001; +} + +export function getInstagramStoryNormalizedDimensions( + width: number, + height: number, +): { width: number; height: number } | null { + const ratio = width / Math.max(height, 1); + if (ratio < INSTAGRAM_MIN_STORY_ASPECT_RATIO) { + return { + width: Math.ceil(height * INSTAGRAM_MIN_STORY_ASPECT_RATIO), + height, + }; + } + if (ratio > INSTAGRAM_MAX_STORY_ASPECT_RATIO) { + return { + width, + height: Math.ceil(width / INSTAGRAM_MAX_STORY_ASPECT_RATIO), + }; + } + return null; +} + +function buildPrivateNormalizedImagePath(filePath: string): string { + const parsed = path.parse(filePath); + return path.join( + os.tmpdir(), + `opencli-instagram-private-${parsed.name}-${crypto.randomUUID()}${parsed.ext || '.png'}`, + ); +} + +export function prepareImageAssetForPrivateUpload(filePath: string): PreparedInstagramImageAsset { + const asset = readImageAsset(filePath); + const normalizedDimensions = getInstagramFeedNormalizedDimensions(asset.width, asset.height); + if (!normalizedDimensions) { + return asset; + } + + if (process.platform !== 'darwin') { + throw new CommandExecutionError( + `Instagram private publish does not support auto-normalizing ${asset.fileName} on ${process.platform}`, + `Use images within ${INSTAGRAM_MIN_FEED_ASPECT_RATIO.toFixed(2)}-${INSTAGRAM_MAX_FEED_ASPECT_RATIO.toFixed(2)} aspect ratio, or use the UI route`, + ); + } + + const outputPath = buildPrivateNormalizedImagePath(filePath); + const result = spawnSync('sips', [ + '--padToHeightWidth', + String(normalizedDimensions.height), + String(normalizedDimensions.width), + '--padColor', + INSTAGRAM_PRIVATE_PAD_COLOR, + filePath, + '--out', + outputPath, + ], { + encoding: 'utf8', + }); + + if (result.error || result.status !== 0 || !fs.existsSync(outputPath)) { + const detail = [result.error?.message, result.stderr, result.stdout] + .map((value) => String(value || '').trim()) + .filter(Boolean) + .join(' '); + throw new CommandExecutionError( + `Instagram private publish failed to normalize ${asset.fileName}`, + detail || 'sips padToHeightWidth failed', + ); + } + + return { + ...readImageAsset(outputPath), + cleanupPath: outputPath, + }; +} + +export function prepareImageAssetForPrivateStoryUpload(filePath: string): PreparedInstagramImageAsset { + const asset = readImageAsset(filePath); + const normalizedDimensions = getInstagramStoryNormalizedDimensions(asset.width, asset.height); + if (!normalizedDimensions) { + return asset; + } + + if (process.platform !== 'darwin') { + throw new CommandExecutionError( + `Instagram private story publish does not support auto-normalizing ${asset.fileName} on ${process.platform}`, + `Use images within ${INSTAGRAM_MIN_STORY_ASPECT_RATIO.toFixed(2)}-${INSTAGRAM_MAX_STORY_ASPECT_RATIO.toFixed(2)} aspect ratio, or use the UI route`, + ); + } + + const outputPath = buildPrivateNormalizedImagePath(filePath); + const result = spawnSync('sips', [ + '--padToHeightWidth', + String(normalizedDimensions.height), + String(normalizedDimensions.width), + '--padColor', + INSTAGRAM_PRIVATE_PAD_COLOR, + filePath, + '--out', + outputPath, + ], { + encoding: 'utf8', + }); + + if (result.error || result.status !== 0 || !fs.existsSync(outputPath)) { + const detail = [result.error?.message, result.stderr, result.stdout] + .map((value) => String(value || '').trim()) + .filter(Boolean) + .join(' '); + throw new CommandExecutionError( + `Instagram private story publish failed to normalize ${asset.fileName}`, + detail || 'sips padToHeightWidth failed', + ); + } + + return { + ...readImageAsset(outputPath), + cleanupPath: outputPath, + }; +} + +function runSwiftJsonScript(script: string, args: string[], stage: string): T { + const scriptPath = path.join(os.tmpdir(), `opencli-instagram-${crypto.randomUUID()}.swift`); + fs.writeFileSync(scriptPath, script); + try { + const result = spawnSync('swift', [scriptPath, ...args], { + encoding: 'utf8', + }); + if (result.error || result.status !== 0) { + const detail = [result.error?.message, result.stderr, result.stdout] + .map((value) => String(value || '').trim()) + .filter(Boolean) + .join(' '); + throw new CommandExecutionError( + `Instagram private publish failed to ${stage}`, + detail || 'swift helper failed', + ); + } + return JSON.parse(String(result.stdout || '{}')) as T; + } catch (error) { + if (error instanceof CommandExecutionError) throw error; + throw new CommandExecutionError( + `Instagram private publish failed to ${stage}`, + error instanceof Error ? error.message : String(error), + ); + } finally { + fs.rmSync(scriptPath, { force: true }); + } +} + +function readVideoMetadata(filePath: string): { width: number; height: number; durationMs: number } { + if (process.platform !== 'darwin') { + throw new CommandExecutionError( + `Instagram private mixed-media publish does not support reading video metadata on ${process.platform}`, + 'Use macOS for private mixed-media publishing, or rely on the UI fallback', + ); + } + + const metadata = runSwiftJsonScript<{ width?: number; height?: number; durationMs?: number }>(` +import AVFoundation +import Foundation + +let path = CommandLine.arguments[1] +let url = URL(fileURLWithPath: path) +let asset = AVURLAsset(url: url) +guard let track = asset.tracks(withMediaType: .video).first else { + fputs("{\\"error\\":\\"missing-video-track\\"}", stderr) + exit(1) +} +let transformed = track.naturalSize.applying(track.preferredTransform) +let width = Int(abs(transformed.width.rounded())) +let height = Int(abs(transformed.height.rounded())) +let durationMs = Int((CMTimeGetSeconds(asset.duration) * 1000.0).rounded()) +let payload: [String: Int] = [ + "width": width, + "height": height, + "durationMs": durationMs, +] +let data = try JSONSerialization.data(withJSONObject: payload, options: []) +FileHandle.standardOutput.write(data) +`, [filePath], 'read video metadata'); + + if (!metadata.width || !metadata.height || !metadata.durationMs) { + throw new CommandExecutionError(`Instagram private publish failed to read video metadata for ${filePath}`); + } + return { + width: metadata.width, + height: metadata.height, + durationMs: metadata.durationMs, + }; +} + +function buildPrivateVideoCoverPath(filePath: string): string { + const parsed = path.parse(filePath); + return path.join( + os.tmpdir(), + `opencli-instagram-private-video-cover-${parsed.name}-${crypto.randomUUID()}.jpg`, + ); +} + +function buildPrivateStoryVideoPath(filePath: string): string { + const parsed = path.parse(filePath); + return path.join( + os.tmpdir(), + `opencli-instagram-story-video-${parsed.name}-${crypto.randomUUID()}${parsed.ext || '.mp4'}`, + ); +} + +function generateVideoCoverImage(filePath: string): PreparedInstagramImageAsset { + if (process.platform !== 'darwin') { + throw new CommandExecutionError( + `Instagram private mixed-media publish does not support generating video covers on ${process.platform}`, + 'Use macOS for private mixed-media publishing, or rely on the UI fallback', + ); + } + + const outputPath = buildPrivateVideoCoverPath(filePath); + runSwiftJsonScript<{ ok?: boolean }>(` +import AVFoundation +import AppKit +import Foundation + +let inputPath = CommandLine.arguments[1] +let outputPath = CommandLine.arguments[2] +let asset = AVURLAsset(url: URL(fileURLWithPath: inputPath)) +let generator = AVAssetImageGenerator(asset: asset) +generator.appliesPreferredTrackTransform = true +let image = try generator.copyCGImage(at: CMTime(seconds: 0, preferredTimescale: 600), actualTime: nil) +let rep = NSBitmapImageRep(cgImage: image) +guard let data = rep.representation(using: .jpeg, properties: [.compressionFactor: 0.9]) else { + fputs("{\\"error\\":\\"jpeg-encode-failed\\"}", stderr) + exit(1) +} +try data.write(to: URL(fileURLWithPath: outputPath)) +let payload = ["ok": true] +let json = try JSONSerialization.data(withJSONObject: payload, options: []) +FileHandle.standardOutput.write(json) +`, [filePath, outputPath], 'generate video cover'); + + return { + ...readImageAsset(outputPath), + cleanupPath: outputPath, + }; +} + +export function readVideoAsset(filePath: string): InstagramVideoAsset { + const bytes = fs.readFileSync(filePath); + const metadata = readVideoMetadata(filePath); + const coverImage = generateVideoCoverImage(filePath); + return { + filePath, + fileName: path.basename(filePath), + mimeType: 'video/mp4', + width: metadata.width, + height: metadata.height, + durationMs: metadata.durationMs, + byteLength: bytes.length, + bytes, + coverImage, + cleanupPaths: coverImage.cleanupPath ? [coverImage.cleanupPath] : [], + }; +} + +function trimVideoForInstagramStory(filePath: string, maxDurationMs: number): string { + if (process.platform !== 'darwin') { + throw new CommandExecutionError( + `Instagram private story publish does not support trimming long videos on ${process.platform}`, + 'Use macOS for private story video publishing, or trim the video to 15 seconds first', + ); + } + + const outputPath = buildPrivateStoryVideoPath(filePath); + runSwiftJsonScript<{ ok?: boolean }>(` +import AVFoundation +import Foundation + +let inputPath = CommandLine.arguments[1] +let outputPath = CommandLine.arguments[2] +let durationMs = Int(CommandLine.arguments[3]) ?? 15000 +let asset = AVURLAsset(url: URL(fileURLWithPath: inputPath)) +guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else { + fputs("{\\"error\\":\\"missing-export-session\\"}", stderr) + exit(1) +} +exportSession.outputURL = URL(fileURLWithPath: outputPath) +exportSession.outputFileType = .mp4 +exportSession.shouldOptimizeForNetworkUse = true +exportSession.timeRange = CMTimeRange( + start: .zero, + duration: CMTime(seconds: Double(durationMs) / 1000.0, preferredTimescale: 600) +) +let semaphore = DispatchSemaphore(value: 0) +exportSession.exportAsynchronously { + semaphore.signal() +} +semaphore.wait() +if exportSession.status != .completed { + let message = exportSession.error?.localizedDescription ?? "export-failed" + fputs(message, stderr) + exit(1) +} +let payload = ["ok": true] +let json = try JSONSerialization.data(withJSONObject: payload, options: []) +FileHandle.standardOutput.write(json) +`, [filePath, outputPath, String(maxDurationMs)], 'trim story video'); + + return outputPath; +} + +function prepareVideoAssetForPrivateStoryUpload(filePath: string): InstagramVideoAsset { + const asset = readVideoAsset(filePath); + if (asset.durationMs <= INSTAGRAM_MAX_STORY_VIDEO_DURATION_MS) { + return asset; + } + + const trimmedPath = trimVideoForInstagramStory(filePath, INSTAGRAM_MAX_STORY_VIDEO_DURATION_MS); + const trimmedAsset = readVideoAsset(trimmedPath); + return { + ...trimmedAsset, + cleanupPaths: [ + ...(trimmedAsset.cleanupPaths || []), + trimmedPath, + ], + }; +} + +function toUnixSeconds(now: () => number): number { + const value = now(); + return value > 10_000_000_000 ? Math.floor(value / 1000) : Math.floor(value); +} + +export function buildConfigureToStoryPhotoPayload(input: StoryPayloadInput): Record { + const now = input.now ?? (() => Date.now()); + const timestamp = toUnixSeconds(now); + return { + source_type: '4', + upload_id: input.uploadId, + story_media_creation_date: String(timestamp - 17), + client_shared_at: String(timestamp - 5), + client_timestamp: String(timestamp), + configure_mode: 1, + edits: { + crop_original_size: [input.width, input.height], + crop_center: [0, 0], + crop_zoom: 1.3333334, + }, + extra: { + source_width: input.width, + source_height: input.height, + }, + jazoest: input.jazoest, + }; +} + +export function buildConfigureToStoryVideoPayload(input: StoryVideoPayloadInput): Record { + const now = input.now ?? (() => Date.now()); + const timestamp = toUnixSeconds(now); + const durationSeconds = Number((input.durationMs / 1000).toFixed(3)); + return { + source_type: '4', + upload_id: input.uploadId, + story_media_creation_date: String(timestamp - 17), + client_shared_at: String(timestamp - 5), + client_timestamp: String(timestamp), + configure_mode: 1, + poster_frame_index: 0, + length: durationSeconds, + audio_muted: false, + filter_type: '0', + video_result: 'deprecated', + extra: { + source_width: input.width, + source_height: input.height, + }, + jazoest: input.jazoest, + }; +} + +function buildFormEncodedBodyFromPayload(payload: Record): string { + const body = new URLSearchParams(); + for (const [key, value] of Object.entries(payload)) { + if (value === undefined || value === null) continue; + if (typeof value === 'object') { + body.set(key, JSON.stringify(value)); + continue; + } + body.set(key, String(value)); + } + return body.toString(); +} + +function buildSignedBody(payload: Record): string { + const jsonPayload = JSON.stringify(payload); + const signature = crypto + .createHmac('sha256', INSTAGRAM_STORY_SIG_KEY) + .update(jsonPayload) + .digest('hex'); + const body = new URLSearchParams(); + body.set('ig_sig_key_version', INSTAGRAM_STORY_SIG_KEY_VERSION); + body.set('signed_body', `${signature}.${jsonPayload}`); + return body.toString(); +} + +function buildPrivateApiHeaders(context: InstagramPrivateApiContext): Record { + return Object.fromEntries(Object.entries({ + 'X-ASBD-ID': context.asbdId, + 'X-CSRFToken': context.csrfToken, + 'X-IG-App-ID': context.igAppId, + 'X-IG-WWW-Claim': context.igWwwClaim, + 'X-Instagram-AJAX': context.instagramAjax, + 'X-Web-Session-ID': context.webSessionId, + }).filter(([, value]) => !!value)); +} + +function buildRuploadHeaders( + asset: InstagramImageAsset, + uploadId: string, + context: InstagramPrivateApiContext, +): Record { + return { + ...buildPrivateApiHeaders(context), + 'Accept': '*/*', + 'Content-Type': asset.mimeType, + 'Offset': '0', + 'X-Entity-Length': String(asset.byteLength), + 'X-Entity-Name': `fb_uploader_${uploadId}`, + 'X-Entity-Type': asset.mimeType, + 'X-Instagram-Rupload-Params': JSON.stringify({ + media_type: 1, + upload_id: uploadId, + upload_media_height: asset.height, + upload_media_width: asset.width, + }), + }; +} + +function buildVideoEditParams(asset: InstagramVideoAsset): Record { + const cropSize = Math.min(asset.width, asset.height); + const trimEndSeconds = Number((asset.durationMs / 1000).toFixed(3)); + return { + crop_height: cropSize, + crop_width: cropSize, + crop_x1: Math.max(0, Math.floor((asset.width - cropSize) / 2)), + crop_y1: Math.max(0, Math.floor((asset.height - cropSize) / 2)), + mute: false, + trim_end: trimEndSeconds, + trim_start: 0, + }; +} + +function buildVideoRuploadHeaders( + asset: InstagramVideoAsset, + uploadId: string, + context: InstagramPrivateApiContext, +): Record { + return { + ...buildPrivateApiHeaders(context), + 'Accept': '*/*', + 'Offset': '0', + 'X-Entity-Length': String(asset.byteLength), + 'X-Entity-Name': `fb_uploader_${uploadId}`, + 'X-Instagram-Rupload-Params': JSON.stringify({ + 'client-passthrough': '1', + 'is_unified_video': '0', + 'is_sidecar': '1', + 'media_type': 2, + 'for_album': false, + 'video_format': '', + 'upload_id': uploadId, + 'upload_media_duration_ms': asset.durationMs, + 'upload_media_height': asset.height, + 'upload_media_width': asset.width, + 'video_transform': null, + 'video_edit_params': buildVideoEditParams(asset), + }), + }; +} + +function buildStoryVideoRuploadHeaders( + asset: InstagramVideoAsset, + uploadId: string, + context: InstagramPrivateApiContext, +): Record { + return { + ...buildPrivateApiHeaders(context), + 'Accept': '*/*', + 'Offset': '0', + 'X-Entity-Length': String(asset.byteLength), + 'X-Entity-Name': `fb_uploader_${uploadId}`, + 'X-Instagram-Rupload-Params': JSON.stringify({ + 'client-passthrough': '1', + 'media_type': 2, + 'upload_id': uploadId, + 'upload_media_duration_ms': asset.durationMs, + 'upload_media_height': asset.height, + 'upload_media_width': asset.width, + 'video_transform': null, + 'video_edit_params': buildVideoEditParams(asset), + }), + }; +} + +function buildVideoCoverRuploadHeaders( + asset: InstagramVideoAsset, + uploadId: string, + context: InstagramPrivateApiContext, +): Record { + return { + ...buildPrivateApiHeaders(context), + 'Accept': '*/*', + 'Content-Type': asset.coverImage.mimeType, + 'Offset': '0', + 'X-Entity-Length': String(asset.coverImage.byteLength), + 'X-Entity-Name': `fb_uploader_${uploadId}`, + 'X-Entity-Type': asset.coverImage.mimeType, + 'X-Instagram-Rupload-Params': JSON.stringify({ + media_type: 2, + upload_id: uploadId, + upload_media_height: asset.height, + upload_media_width: asset.width, + }), + }; +} + +async function parseJsonResponse(response: Response, stage: string): Promise { + const text = await response.text(); + let data: any; + try { + data = text ? JSON.parse(text) : {}; + } catch { + throw new CommandExecutionError(`Instagram private publish ${stage} returned invalid JSON`); + } + if (!response.ok) { + const detail = text ? ` ${text.slice(0, 500)}` : ''; + throw new CommandExecutionError(`Instagram private publish ${stage} failed: ${response.status}${detail}`); + } + return data; +} + +async function fetchPrivateUploadWithRetry( + fetcher: PrivateApiFetchLike, + url: string, + init: PrivateApiFetchInit, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < INSTAGRAM_PRIVATE_UPLOAD_RETRY_BUDGET; attempt += 1) { + try { + return await fetcher(url, init); + } catch (error) { + lastError = error; + if (!isTransientPrivateFetchError(error) || attempt >= INSTAGRAM_PRIVATE_UPLOAD_RETRY_BUDGET - 1) { + throw error; + } + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} + +async function prepareInstagramMediaAsset(item: InstagramMediaItem): Promise { + if (item.type === 'video') { + return { + type: 'video', + asset: readVideoAsset(item.filePath), + }; + } + return { + type: 'image', + asset: prepareImageAssetForPrivateUpload(item.filePath), + }; +} + +function cleanupPreparedMediaAssets(assets: PreparedInstagramMediaAsset[]): void { + for (const prepared of assets) { + if (prepared.type === 'image') { + if (prepared.asset.cleanupPath) { + fs.rmSync(prepared.asset.cleanupPath, { force: true }); + } + continue; + } + for (const cleanupPath of prepared.asset.cleanupPaths || []) { + fs.rmSync(cleanupPath, { force: true }); + } + if (prepared.asset.coverImage.cleanupPath) { + fs.rmSync(prepared.asset.coverImage.cleanupPath, { force: true }); + } + } +} + +async function uploadPreparedMediaAsset( + fetcher: PrivateApiFetchLike, + prepared: PreparedInstagramMediaAsset, + uploadId: string, + context: InstagramPrivateApiContext, + mode: 'feed' | 'story' = 'feed', +): Promise { + if (prepared.type === 'image') { + const response = await fetchPrivateUploadWithRetry(fetcher, `https://i.instagram.com/rupload_igphoto/fb_uploader_${uploadId}`, { + method: 'POST', + headers: buildRuploadHeaders(prepared.asset, uploadId, context), + body: prepared.asset.bytes, + }); + const json = await parseJsonResponse(response, 'upload'); + if (String(json?.status || '') !== 'ok') { + throw new CommandExecutionError(`Instagram private publish upload failed for ${prepared.asset.fileName}`); + } + return; + } + + const videoResponse = await fetchPrivateUploadWithRetry(fetcher, `https://i.instagram.com/rupload_igvideo/fb_uploader_${uploadId}`, { + method: 'POST', + headers: mode === 'story' + ? buildStoryVideoRuploadHeaders(prepared.asset, uploadId, context) + : buildVideoRuploadHeaders(prepared.asset, uploadId, context), + body: prepared.asset.bytes, + }); + const videoJson = await parseJsonResponse(videoResponse, 'video upload'); + if (String(videoJson?.status || '') !== 'ok') { + throw new CommandExecutionError(`Instagram private publish video upload failed for ${prepared.asset.fileName}`); + } + + const coverResponse = await fetchPrivateUploadWithRetry(fetcher, `https://i.instagram.com/rupload_igphoto/fb_uploader_${uploadId}`, { + method: 'POST', + headers: buildVideoCoverRuploadHeaders(prepared.asset, uploadId, context), + body: prepared.asset.coverImage.bytes, + }); + const coverJson = await parseJsonResponse(coverResponse, 'video cover upload'); + if (String(coverJson?.status || '') !== 'ok') { + throw new CommandExecutionError(`Instagram private publish video cover upload failed for ${prepared.asset.fileName}`); + } +} + +async function publishSidecarWithRetry(input: { + fetcher: PrivateApiFetchLike; + payload: Record; + apiContext: InstagramPrivateApiContext; + waitMs?: (ms: number) => Promise; +}): Promise<{ code?: string }> { + const waitMs = input.waitMs ?? sleep; + const requestInit: PrivateApiFetchInit = { + method: 'POST', + headers: { + ...buildPrivateApiHeaders(input.apiContext), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input.payload), + }; + + for (let attempt = 0; attempt < INSTAGRAM_PRIVATE_SIDECAR_TRANSCODE_ATTEMPTS; attempt += 1) { + const response = await input.fetcher('https://www.instagram.com/api/v1/media/configure_sidecar/', requestInit); + const text = await response.text(); + let json: any = {}; + try { + json = text ? JSON.parse(text) : {}; + } catch { + throw new CommandExecutionError('Instagram private publish configure_sidecar returned invalid JSON'); + } + + if (!response.ok) { + const detail = text ? ` ${text.slice(0, 500)}` : ''; + throw new CommandExecutionError(`Instagram private publish configure_sidecar failed: ${response.status}${detail}`); + } + + const message = String(json?.message || ''); + if ( + response.status === 202 + || /transcode not finished yet/i.test(message) + ) { + if (attempt >= INSTAGRAM_PRIVATE_SIDECAR_TRANSCODE_ATTEMPTS - 1) { + throw new CommandExecutionError( + 'Instagram private publish configure_sidecar timed out waiting for video transcode', + text.slice(0, 500), + ); + } + await waitMs(INSTAGRAM_PRIVATE_SIDECAR_TRANSCODE_WAIT_MS); + continue; + } + + if (String(json?.status || '').toLowerCase() === 'fail') { + throw new CommandExecutionError( + 'Instagram private publish configure_sidecar failed', + message || text.slice(0, 500), + ); + } + + return { code: json?.media?.code }; + } + + throw new CommandExecutionError('Instagram private publish configure_sidecar failed'); +} + +export async function publishMediaViaPrivateApi(input: { + page: unknown; + mediaItems: InstagramMediaItem[]; + caption: string; + apiContext: InstagramPrivateApiContext; + jazoest: string; + now?: () => number; + fetcher?: PrivateApiFetchLike; + prepareMediaAsset?: (item: InstagramMediaItem) => PreparedInstagramMediaAsset | Promise; + waitMs?: (ms: number) => Promise; +}): Promise<{ code?: string; uploadIds: string[] }> { + const now = input.now ?? (() => Date.now()); + const clientSidecarId = String(now()); + const uploadIds = input.mediaItems.length > 1 + ? input.mediaItems.map((_, index) => String(now() + index + 1)) + : [String(now())]; + const fetcher: PrivateApiFetchLike = input.fetcher ?? ((url, init) => instagramPrivateApiFetch(input.page as any, url, init as any)); + const prepareMediaAsset = input.prepareMediaAsset ?? prepareInstagramMediaAsset; + const assets = await Promise.all(input.mediaItems.map((item) => prepareMediaAsset(item))); + + try { + for (let index = 0; index < assets.length; index += 1) { + const asset = assets[index]!; + const uploadId = uploadIds[index]!; + await uploadPreparedMediaAsset(fetcher, asset, uploadId, input.apiContext); + } + + if (uploadIds.length === 1) { + if (assets[0]?.type !== 'image') { + throw new CommandExecutionError('Instagram private publish only supports single-video uploads through instagram reel'); + } + const response = await fetcher('https://www.instagram.com/api/v1/media/configure/', { + method: 'POST', + headers: { + ...buildPrivateApiHeaders(input.apiContext), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: buildConfigureBody({ + uploadId: uploadIds[0]!, + caption: input.caption, + jazoest: input.jazoest, + }), + }); + const json = await parseJsonResponse(response, 'configure'); + return { code: json?.media?.code, uploadIds }; + } + + const result = await publishSidecarWithRetry({ + fetcher, + payload: buildConfigureSidecarPayload({ + uploadIds, + caption: input.caption, + clientSidecarId, + jazoest: input.jazoest, + }), + apiContext: input.apiContext, + waitMs: input.waitMs, + }); + return { code: result.code, uploadIds }; + } finally { + cleanupPreparedMediaAssets(assets); + } +} + +export async function publishImagesViaPrivateApi(input: { + page: unknown; + imagePaths: string[]; + caption: string; + apiContext: InstagramPrivateApiContext; + jazoest: string; + now?: () => number; + fetcher?: PrivateApiFetchLike; + prepareAsset?: (filePath: string) => PreparedInstagramImageAsset | Promise; + waitMs?: (ms: number) => Promise; +}): Promise<{ code?: string; uploadIds: string[] }> { + return publishMediaViaPrivateApi({ + page: input.page, + mediaItems: input.imagePaths.map((filePath) => ({ type: 'image' as const, filePath })), + caption: input.caption, + apiContext: input.apiContext, + jazoest: input.jazoest, + now: input.now, + fetcher: input.fetcher, + waitMs: input.waitMs, + prepareMediaAsset: input.prepareAsset + ? async (item) => ({ + type: 'image' as const, + asset: await input.prepareAsset!(item.filePath), + }) + : undefined, + }); +} + +export async function publishStoryViaPrivateApi(input: { + page: unknown; + mediaItem: InstagramMediaItem; + content: string; + apiContext: InstagramPrivateApiContext; + jazoest: string; + currentUserId?: string; + now?: () => number; + fetcher?: PrivateApiFetchLike; + prepareMediaAsset?: (item: InstagramMediaItem) => PreparedInstagramMediaAsset | Promise; +}): Promise<{ mediaPk?: string; uploadId: string }> { + const now = input.now ?? (() => Date.now()); + const uploadId = String(now()); + const fetcher: PrivateApiFetchLike = input.fetcher ?? ((url, init) => instagramPrivateApiFetch(input.page as any, url, init as any)); + const prepareMediaAsset = input.prepareMediaAsset ?? (async (item: InstagramMediaItem) => item.type === 'video' + ? { type: 'video' as const, asset: prepareVideoAssetForPrivateStoryUpload(item.filePath) } + : { type: 'image' as const, asset: prepareImageAssetForPrivateStoryUpload(item.filePath) }); + const prepared = await prepareMediaAsset(input.mediaItem); + const currentUserId = input.currentUserId + || ('getCookies' in (input.page as any) + ? String((await ((input.page as IPage).getCookies?.({ domain: 'instagram.com' }) ?? Promise.resolve([] as BrowserCookie[]))) + .find((cookie) => cookie.name === 'ds_user_id')?.value || '') + : ''); + if (!currentUserId) { + throw new CommandExecutionError('Instagram story publish could not derive current user id from browser session'); + } + + const signedPayloadBase = { + _csrftoken: input.apiContext.csrfToken, + _uid: currentUserId, + _uuid: crypto.randomUUID(), + device: INSTAGRAM_STORY_DEVICE, + }; + const buildSignedStoryPhotoBody = (width: number, height: number) => buildSignedBody({ + ...buildConfigureToStoryPhotoPayload({ + uploadId, + width, + height, + now, + jazoest: input.jazoest, + }), + ...signedPayloadBase, + }); + const buildSignedStoryVideoBody = (width: number, height: number, durationMs: number) => buildSignedBody({ + ...buildConfigureToStoryVideoPayload({ + uploadId, + width, + height, + durationMs, + now, + jazoest: input.jazoest, + }), + ...signedPayloadBase, + }); + + try { + await uploadPreparedMediaAsset(fetcher, prepared, uploadId, input.apiContext, 'story'); + + if (prepared.type === 'image') { + const response = await fetcher('https://i.instagram.com/api/v1/media/configure_to_story/', { + method: 'POST', + headers: { + ...buildPrivateApiHeaders(input.apiContext), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: buildSignedStoryPhotoBody(prepared.asset.width, prepared.asset.height), + }); + const json = await parseJsonResponse(response, 'configure_to_story'); + return { + mediaPk: String(json?.media?.pk || json?.media?.id || '').split('_')[0] || undefined, + uploadId, + }; + } + + await parseJsonResponse(await fetcher('https://i.instagram.com/api/v1/media/configure_to_story/', { + method: 'POST', + headers: { + ...buildPrivateApiHeaders(input.apiContext), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: buildSignedStoryPhotoBody(prepared.asset.width, prepared.asset.height), + }), 'configure_to_story cover'); + + const response = await fetcher('https://i.instagram.com/api/v1/media/configure_to_story/?video=1', { + method: 'POST', + headers: { + ...buildPrivateApiHeaders(input.apiContext), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: buildSignedStoryVideoBody(prepared.asset.width, prepared.asset.height, prepared.asset.durationMs), + }); + const json = await parseJsonResponse(response, 'configure_to_story'); + return { + mediaPk: String(json?.media?.pk || json?.media?.id || '').split('_')[0] || undefined, + uploadId, + }; + } finally { + cleanupPreparedMediaAssets([prepared]); + } +} diff --git a/src/clis/instagram/_shared/protocol-capture.test.ts b/src/clis/instagram/_shared/protocol-capture.test.ts new file mode 100644 index 00000000..084a635b --- /dev/null +++ b/src/clis/instagram/_shared/protocol-capture.test.ts @@ -0,0 +1,148 @@ +import * as fs from 'node:fs'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { BrowserCookie, IPage } from '../../../types.js'; +import { + buildInstallInstagramProtocolCaptureJs, + buildReadInstagramProtocolCaptureJs, + dumpInstagramProtocolCaptureIfEnabled, + instagramPrivateApiFetch, + installInstagramProtocolCapture, + readInstagramProtocolCapture, +} from './protocol-capture.js'; + +describe('instagram protocol capture helpers', () => { + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.OPENCLI_INSTAGRAM_CAPTURE; + try { fs.rmSync('/tmp/instagram_post_protocol_trace.json', { force: true }); } catch {} + }); + + it('installs the protocol capture patch in page context', async () => { + const evaluate = vi.fn().mockResolvedValue({ ok: true }); + const page = { evaluate } as unknown as IPage; + + await installInstagramProtocolCapture(page); + + expect(evaluate).toHaveBeenCalledTimes(1); + expect(String(evaluate.mock.calls[0]?.[0] || '')).toContain('__opencli_ig_protocol_capture'); + expect(String(evaluate.mock.calls[0]?.[0] || '')).toContain('/media/configure_sidecar/'); + }); + + it('prefers native page network capture when available', async () => { + const startNetworkCapture = vi.fn().mockResolvedValue(undefined); + const evaluate = vi.fn(); + const page = { startNetworkCapture, evaluate } as unknown as IPage; + + await installInstagramProtocolCapture(page); + + expect(startNetworkCapture).toHaveBeenCalledTimes(1); + expect(evaluate).not.toHaveBeenCalled(); + }); + + it('reads and normalizes captured protocol entries', async () => { + const evaluate = vi.fn().mockResolvedValue({ + data: [{ kind: 'fetch', url: 'https://www.instagram.com/api/v1/media/configure/' }], + errors: ['ignored'], + }); + const page = { evaluate } as unknown as IPage; + + const result = await readInstagramProtocolCapture(page); + + expect(String(evaluate.mock.calls[0]?.[0] || '')).toContain('__opencli_ig_protocol_capture'); + expect(result).toEqual({ + data: [{ kind: 'fetch', url: 'https://www.instagram.com/api/v1/media/configure/' }], + errors: ['ignored'], + }); + }); + + it('prefers native page network capture reads when available', async () => { + const readNetworkCapture = vi.fn().mockResolvedValue([ + { kind: 'cdp', url: 'https://www.instagram.com/rupload_igphoto/test', method: 'POST' }, + ]); + const evaluate = vi.fn(); + const page = { readNetworkCapture, evaluate } as unknown as IPage; + + const result = await readInstagramProtocolCapture(page); + + expect(readNetworkCapture).toHaveBeenCalledTimes(1); + expect(evaluate).not.toHaveBeenCalled(); + expect(result).toEqual({ + data: [{ kind: 'cdp', url: 'https://www.instagram.com/rupload_igphoto/test', method: 'POST' }], + errors: [], + }); + }); + + it('dumps protocol traces to /tmp only when capture env is enabled', async () => { + process.env.OPENCLI_INSTAGRAM_CAPTURE = '1'; + const page = { + evaluate: vi.fn().mockResolvedValue({ + data: [{ kind: 'fetch', url: 'https://www.instagram.com/rupload_igphoto/test' }], + errors: [], + }), + } as unknown as IPage; + + await dumpInstagramProtocolCaptureIfEnabled(page); + + const raw = fs.readFileSync('/tmp/instagram_post_protocol_trace.json', 'utf8'); + expect(raw).toContain('rupload_igphoto'); + }); + + it('does not dump protocol traces when capture env is disabled', async () => { + const page = { + evaluate: vi.fn(), + } as unknown as IPage; + + await dumpInstagramProtocolCaptureIfEnabled(page); + + expect(page.evaluate).not.toHaveBeenCalled(); + expect(fs.existsSync('/tmp/instagram_post_protocol_trace.json')).toBe(false); + }); +}); + +describe('instagram private api fetch', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses browser cookies to build instagram private api requests', async () => { + const getCookies = vi.fn() + .mockResolvedValueOnce([{ name: 'sessionid', value: 'sess', domain: '.instagram.com' } satisfies BrowserCookie]) + .mockResolvedValueOnce([ + { name: 'csrftoken', value: 'csrf', domain: '.instagram.com' } satisfies BrowserCookie, + { name: 'sessionid', value: 'sess', domain: '.instagram.com' } satisfies BrowserCookie, + ]); + const evaluate = vi.fn().mockResolvedValue({ + appId: 'dynamic-app-id', + csrfToken: 'csrf', + instagramAjax: 'dynamic-rollout', + }); + const page = { getCookies, evaluate } as unknown as IPage; + const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await instagramPrivateApiFetch(page, 'https://www.instagram.com/api/v1/media/configure/', { + method: 'POST', + body: 'caption=test', + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://www.instagram.com/api/v1/media/configure/', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'X-CSRFToken': 'csrf', + 'X-IG-App-ID': 'dynamic-app-id', + 'Cookie': expect.stringContaining('sessionid=sess'), + }), + body: 'caption=test', + }), + ); + }); + + it('exposes stable browser-side JS builders', () => { + expect(buildInstallInstagramProtocolCaptureJs()).toContain('/rupload_igphoto/'); + expect(buildReadInstagramProtocolCaptureJs()).toContain('__opencli_ig_protocol_capture'); + }); +}); diff --git a/src/clis/instagram/_shared/protocol-capture.ts b/src/clis/instagram/_shared/protocol-capture.ts new file mode 100644 index 00000000..ec3e38b9 --- /dev/null +++ b/src/clis/instagram/_shared/protocol-capture.ts @@ -0,0 +1,321 @@ +import * as fs from 'node:fs'; + +import type { BrowserCookie, IPage } from '../../../types.js'; +import { resolveInstagramRuntimeInfo } from './runtime-info.js'; + +const DEFAULT_CAPTURE_VAR = '__opencli_ig_protocol_capture'; +const DEFAULT_CAPTURE_ERRORS_VAR = '__opencli_ig_protocol_capture_errors'; +const TRACE_OUTPUT_PATH = '/tmp/instagram_post_protocol_trace.json'; +const INSTAGRAM_PROTOCOL_CAPTURE_PATTERN = [ + '/rupload_igphoto/', + '/rupload_igvideo/', + '/api/v1/', + '/media/configure/', + '/media/configure_sidecar/', + '/media/configure_to_story/', + '/api/graphql/', +].join('|'); + +export interface InstagramProtocolCaptureEntry { + kind: 'fetch' | 'xhr'; + url: string; + method: string; + requestHeaders?: Record; + requestBodyKind?: string; + requestBodyPreview?: string; + responseStatus?: number; + responseContentType?: string; + responsePreview?: string; + timestamp: number; +} + +export function buildInstallInstagramProtocolCaptureJs( + captureVar: string = DEFAULT_CAPTURE_VAR, + captureErrorsVar: string = DEFAULT_CAPTURE_ERRORS_VAR, +): string { + return ` + (() => { + const CAPTURE_VAR = ${JSON.stringify(captureVar)}; + const CAPTURE_ERRORS_VAR = ${JSON.stringify(captureErrorsVar)}; + const PATCH_GUARD = CAPTURE_VAR + '_patched'; + const FILTERS = [ + '/rupload_igphoto/', + '/rupload_igvideo/', + '/api/v1/', + '/media/configure/', + '/media/configure_sidecar/', + '/media/configure_to_story/', + '/api/graphql/', + ]; + + const shouldCapture = (url) => { + const value = String(url || ''); + return FILTERS.some((filter) => value.includes(filter)); + }; + + const normalizeHeaders = (headersLike) => { + const out = {}; + try { + if (!headersLike) return out; + if (headersLike instanceof Headers) { + headersLike.forEach((value, key) => { out[key] = value; }); + return out; + } + if (Array.isArray(headersLike)) { + for (const pair of headersLike) { + if (Array.isArray(pair) && pair.length >= 2) out[String(pair[0])] = String(pair[1]); + } + return out; + } + if (typeof headersLike === 'object') { + for (const [key, value] of Object.entries(headersLike)) out[key] = String(value); + } + } catch {} + return out; + }; + + const summarizeBody = async (body) => { + if (body == null) return { kind: 'empty', preview: '' }; + try { + if (typeof body === 'string') { + return { kind: 'string', preview: body.slice(0, 1000) }; + } + if (body instanceof URLSearchParams) { + return { kind: 'urlencoded', preview: body.toString().slice(0, 1000) }; + } + if (body instanceof FormData) { + const parts = []; + for (const [key, value] of body.entries()) { + if (value instanceof File) { + parts.push(key + '=File(' + value.name + ',' + value.type + ',' + value.size + ')'); + } else { + parts.push(key + '=' + String(value)); + } + } + return { kind: 'formdata', preview: parts.join('&').slice(0, 2000) }; + } + if (body instanceof Blob) { + return { kind: 'blob', preview: 'Blob(' + body.type + ',' + body.size + ')' }; + } + if (body instanceof ArrayBuffer) { + return { kind: 'arraybuffer', preview: 'ArrayBuffer(' + body.byteLength + ')' }; + } + if (ArrayBuffer.isView(body)) { + return { kind: 'typed-array', preview: body.constructor.name + '(' + body.byteLength + ')' }; + } + return { kind: typeof body, preview: String(body).slice(0, 1000) }; + } catch (error) { + return { kind: 'unknown', preview: 'body-preview-error:' + String(error) }; + } + }; + + const capture = async (kind, url, method, headers, body, response) => { + if (!shouldCapture(url)) return; + try { + const bodyInfo = await summarizeBody(body); + const contentType = response?.headers?.get?.('content-type') || ''; + let responsePreview = ''; + try { + if (response && typeof response.clone === 'function') { + const clone = response.clone(); + responsePreview = (await clone.text()).slice(0, 4000); + } + } catch (error) { + responsePreview = 'response-preview-error:' + String(error); + } + window[CAPTURE_VAR].push({ + kind, + url: String(url || ''), + method: String(method || 'GET').toUpperCase(), + requestHeaders: normalizeHeaders(headers), + requestBodyKind: bodyInfo.kind, + requestBodyPreview: bodyInfo.preview, + responseStatus: response?.status, + responseContentType: contentType, + responsePreview, + timestamp: Date.now(), + }); + } catch (error) { + window[CAPTURE_ERRORS_VAR].push(String(error)); + } + }; + + if (!Array.isArray(window[CAPTURE_VAR])) window[CAPTURE_VAR] = []; + if (!Array.isArray(window[CAPTURE_ERRORS_VAR])) window[CAPTURE_ERRORS_VAR] = []; + if (window[PATCH_GUARD]) return { ok: true }; + + const origFetch = window.fetch; + window.fetch = async function(...args) { + const input = args[0]; + const init = args[1] || {}; + const url = typeof input === 'string' + ? input + : input instanceof Request + ? input.url + : String(input || ''); + const method = init.method || (input instanceof Request ? input.method : 'GET'); + const headers = init.headers || (input instanceof Request ? input.headers : undefined); + const body = init.body || (input instanceof Request ? input.body : undefined); + const response = await origFetch.apply(this, args); + capture('fetch', url, method, headers, body, response); + return response; + }; + + const origOpen = XMLHttpRequest.prototype.open; + const origSend = XMLHttpRequest.prototype.send; + const origSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader; + + XMLHttpRequest.prototype.open = function(method, url) { + this.__opencli_method = method; + this.__opencli_url = url; + this.__opencli_headers = {}; + return origOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.setRequestHeader = function(name, value) { + try { + this.__opencli_headers = this.__opencli_headers || {}; + this.__opencli_headers[String(name)] = String(value); + } catch {} + return origSetRequestHeader.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function(body) { + this.addEventListener('load', () => { + if (!shouldCapture(this.__opencli_url)) return; + try { + window[CAPTURE_VAR].push({ + kind: 'xhr', + url: String(this.__opencli_url || ''), + method: String(this.__opencli_method || 'GET').toUpperCase(), + requestHeaders: this.__opencli_headers || {}, + requestBodyKind: body == null ? 'empty' : (body instanceof FormData ? 'formdata' : typeof body), + requestBodyPreview: body == null ? '' : (body instanceof FormData ? '[formdata]' : String(body).slice(0, 2000)), + responseStatus: this.status, + responseContentType: this.getResponseHeader('content-type') || '', + responsePreview: String(this.responseText || '').slice(0, 4000), + timestamp: Date.now(), + }); + } catch (error) { + window[CAPTURE_ERRORS_VAR].push(String(error)); + } + }); + return origSend.apply(this, arguments); + }; + + window[PATCH_GUARD] = true; + return { ok: true }; + })() + `; +} + +export function buildReadInstagramProtocolCaptureJs( + captureVar: string = DEFAULT_CAPTURE_VAR, + captureErrorsVar: string = DEFAULT_CAPTURE_ERRORS_VAR, +): string { + return ` + (() => { + const data = Array.isArray(window[${JSON.stringify(captureVar)}]) ? window[${JSON.stringify(captureVar)}] : []; + const errors = Array.isArray(window[${JSON.stringify(captureErrorsVar)}]) ? window[${JSON.stringify(captureErrorsVar)}] : []; + window[${JSON.stringify(captureVar)}] = []; + window[${JSON.stringify(captureErrorsVar)}] = []; + return { data, errors }; + })() + `; +} + +export async function installInstagramProtocolCapture(page: IPage): Promise { + if (typeof page.startNetworkCapture === 'function') { + try { + await page.startNetworkCapture(INSTAGRAM_PROTOCOL_CAPTURE_PATTERN); + return; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes('Unknown action') && !message.includes('network-capture')) { + throw error; + } + } + } + await page.evaluate(buildInstallInstagramProtocolCaptureJs()); +} + +export async function readInstagramProtocolCapture(page: IPage): Promise<{ + data: InstagramProtocolCaptureEntry[]; + errors: string[]; +}> { + if (typeof page.readNetworkCapture === 'function') { + try { + const data = await page.readNetworkCapture(); + return { + data: Array.isArray(data) ? data as InstagramProtocolCaptureEntry[] : [], + errors: [], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes('Unknown action') && !message.includes('network-capture')) { + throw error; + } + } + } + const result = await page.evaluate(buildReadInstagramProtocolCaptureJs()) as { + data?: InstagramProtocolCaptureEntry[]; + errors?: string[]; + }; + return { + data: Array.isArray(result?.data) ? result.data : [], + errors: Array.isArray(result?.errors) ? result.errors : [], + }; +} + +export async function dumpInstagramProtocolCaptureIfEnabled(page: IPage): Promise { + if (process.env.OPENCLI_INSTAGRAM_CAPTURE !== '1') return; + const payload = await readInstagramProtocolCapture(page); + fs.writeFileSync(TRACE_OUTPUT_PATH, JSON.stringify(payload, null, 2)); +} + +function buildCookieHeader(cookies: BrowserCookie[]): string { + return cookies + .filter((cookie) => cookie?.name && cookie?.value) + .map((cookie) => `${cookie.name}=${cookie.value}`) + .join('; '); +} + +export async function instagramPrivateApiFetch( + page: IPage, + input: string | URL, + init: { + method?: 'GET' | 'POST'; + headers?: Record; + body?: unknown; + } = {}, +): Promise { + const url = String(input); + const [urlCookies, domainCookies] = await Promise.all([ + page.getCookies({ url }), + page.getCookies({ domain: 'instagram.com' }), + ]); + const merged = new Map(); + for (const cookie of domainCookies) merged.set(cookie.name, cookie); + for (const cookie of urlCookies) merged.set(cookie.name, cookie); + const cookieHeader = buildCookieHeader(Array.from(merged.values())); + const csrf = merged.get('csrftoken')?.value || ''; + const initHeaders = init.headers ?? {}; + const requestedAppIdHeader = Object.entries(initHeaders).find(([key]) => key.toLowerCase() === 'x-ig-app-id')?.[1] || ''; + const runtimeInfo = requestedAppIdHeader ? null : await resolveInstagramRuntimeInfo(page); + const appId = requestedAppIdHeader || runtimeInfo?.appId || ''; + const hasContentType = Object.keys(init.headers ?? {}).some((key) => key.toLowerCase() === 'content-type'); + + return fetch(url, { + method: init.method ?? 'GET', + headers: { + 'Accept': 'application/json, text/plain, */*', + 'X-CSRFToken': csrf, + 'X-Requested-With': 'XMLHttpRequest', + 'Origin': 'https://www.instagram.com', + 'Referer': 'https://www.instagram.com/', + ...(appId ? { 'X-IG-App-ID': appId } : {}), + ...(cookieHeader ? { 'Cookie': cookieHeader } : {}), + ...(typeof init.body === 'string' && !hasContentType ? { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' } : {}), + ...initHeaders, + }, + ...(init.body !== undefined ? { body: init.body as BodyInit } : {}), + }); +} diff --git a/src/clis/instagram/_shared/runtime-info.ts b/src/clis/instagram/_shared/runtime-info.ts new file mode 100644 index 00000000..8cae35aa --- /dev/null +++ b/src/clis/instagram/_shared/runtime-info.ts @@ -0,0 +1,91 @@ +import type { BrowserCookie, IPage } from '../../../types.js'; + +export interface InstagramRuntimeInfo { + appId: string; + csrfToken: string; + instagramAjax: string; +} + +function pickMatch(input: string, patterns: RegExp[]): string { + for (const pattern of patterns) { + const match = input.match(pattern); + if (!match) continue; + for (let index = 1; index < match.length; index += 1) { + if (match[index]) return match[index]!; + } + return match[0] || ''; + } + return ''; +} + +export function extractInstagramRuntimeInfo(html: string): InstagramRuntimeInfo { + return { + appId: pickMatch(html, [ + /"X-IG-App-ID":"(\d+)"/, + /"appId":"(\d+)"/, + /"app_id":"(\d+)"/, + /"instagramWebAppId":"(\d+)"/, + ]), + csrfToken: pickMatch(html, [ + /"csrf_token":"([^"]+)"/, + /"csrfToken":"([^"]+)"/, + ]), + instagramAjax: pickMatch(html, [ + /"rollout_hash":"([^"]+)"/, + /"X-Instagram-AJAX":"([^"]+)"/, + /"Instagram-AJAX":"([^"]+)"/, + ]), + }; +} + +export function buildReadInstagramRuntimeInfoJs(): string { + return ` + (() => { + const html = document.documentElement?.outerHTML || ''; + const pick = (patterns) => { + for (const pattern of patterns) { + const match = html.match(new RegExp(pattern, 'i')); + if (!match) continue; + for (let index = 1; index < match.length; index += 1) { + if (match[index]) return match[index]; + } + return match[0] || ''; + } + return ''; + }; + return { + appId: pick([ + '"X-IG-App-ID":"(\\\\d+)"', + '"appId":"(\\\\d+)"', + '"app_id":"(\\\\d+)"', + '"instagramWebAppId":"(\\\\d+)"', + ]), + csrfToken: pick([ + '"csrf_token":"([^"]+)"', + '"csrfToken":"([^"]+)"', + ]), + instagramAjax: pick([ + '"rollout_hash":"([^"]+)"', + '"X-Instagram-AJAX":"([^"]+)"', + '"Instagram-AJAX":"([^"]+)"', + ]), + }; + })() + `; +} + +function getCookieValue(cookies: BrowserCookie[], name: string): string { + return cookies.find((cookie) => cookie.name === name)?.value || ''; +} + +export async function resolveInstagramRuntimeInfo(page: IPage): Promise { + const [runtime, cookies] = await Promise.all([ + page.evaluate(buildReadInstagramRuntimeInfoJs()) as Promise, + page.getCookies({ domain: 'instagram.com' }), + ]); + return { + appId: runtime?.appId || '', + csrfToken: runtime?.csrfToken || getCookieValue(cookies, 'csrftoken') || '', + instagramAjax: runtime?.instagramAjax || '', + }; +} diff --git a/src/clis/instagram/note.test.ts b/src/clis/instagram/note.test.ts new file mode 100644 index 00000000..3263a3a0 --- /dev/null +++ b/src/clis/instagram/note.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ArgumentError } from '../../errors.js'; +import { getRegistry } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import './note.js'; + +function createPageMock(): IPage { + return { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(undefined), + getCookies: vi.fn().mockResolvedValue([]), + snapshot: vi.fn().mockResolvedValue(undefined), + click: vi.fn().mockResolvedValue(undefined), + typeText: vi.fn().mockResolvedValue(undefined), + pressKey: vi.fn().mockResolvedValue(undefined), + scrollTo: vi.fn().mockResolvedValue(undefined), + getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }), + wait: vi.fn().mockResolvedValue(undefined), + tabs: vi.fn().mockResolvedValue([]), + closeTab: vi.fn().mockResolvedValue(undefined), + newTab: vi.fn().mockResolvedValue(undefined), + selectTab: vi.fn().mockResolvedValue(undefined), + networkRequests: vi.fn().mockResolvedValue([]), + consoleMessages: vi.fn().mockResolvedValue([]), + scroll: vi.fn().mockResolvedValue(undefined), + autoScroll: vi.fn().mockResolvedValue(undefined), + installInterceptor: vi.fn().mockResolvedValue(undefined), + getInterceptedRequests: vi.fn().mockResolvedValue([]), + waitForCapture: vi.fn().mockResolvedValue(undefined), + screenshot: vi.fn().mockResolvedValue(''), + setFileInput: vi.fn().mockResolvedValue(undefined), + insertText: vi.fn().mockResolvedValue(undefined), + getCurrentUrl: vi.fn().mockResolvedValue(null), + }; +} + +describe('instagram note registration', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('registers the note command with a required positional content arg', () => { + const cmd = getRegistry().get('instagram/note'); + expect(cmd).toBeDefined(); + expect(cmd?.browser).toBe(true); + expect(cmd?.args.some((arg) => arg.name === 'content' && arg.positional && arg.required)).toBe(true); + }); + + it('rejects missing note content before browser work', async () => { + const page = createPageMock(); + const cmd = getRegistry().get('instagram/note'); + + await expect(cmd!.func!(page, {})).rejects.toThrow(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('rejects blank note content before browser work', async () => { + const page = createPageMock(); + const cmd = getRegistry().get('instagram/note'); + + await expect(cmd!.func!(page, { content: ' ' })).rejects.toThrow(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('rejects note content longer than 60 characters before browser work', async () => { + const page = createPageMock(); + const cmd = getRegistry().get('instagram/note'); + + await expect(cmd!.func!(page, { content: 'x'.repeat(61) })).rejects.toThrow(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('publishes a note through the web inbox mutation', async () => { + const page = createPageMock(); + const cmd = getRegistry().get('instagram/note'); + vi.mocked(page.evaluate).mockResolvedValue({ + ok: true, + noteId: '17849203563031468', + }); + + const rows = await cmd!.func!(page, { content: 'hello note' }) as Array>; + + expect(page.goto).toHaveBeenCalledWith('https://www.instagram.com/direct/inbox/'); + expect(page.evaluate).toHaveBeenCalledTimes(1); + expect(rows).toEqual([{ + status: '✅ Posted', + detail: 'Instagram note published successfully', + noteId: '17849203563031468', + }]); + }); +}); diff --git a/src/clis/instagram/note.ts b/src/clis/instagram/note.ts new file mode 100644 index 00000000..0b925308 --- /dev/null +++ b/src/clis/instagram/note.ts @@ -0,0 +1,254 @@ +import { ArgumentError, CommandExecutionError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +type InstagramNoteSuccessRow = { + status: string; + detail: string; + noteId: string; +}; + +type BrowserNoteResult = { + ok?: boolean; + stage?: string; + status?: number; + text?: string; + noteId?: string; +}; + +const INSTAGRAM_INBOX_URL = 'https://www.instagram.com/direct/inbox/'; +const INSTAGRAM_NOTE_DOC_ID = '25155183657506484'; +const INSTAGRAM_NOTE_MUTATION_NAME = 'usePolarisCreateInboxTrayItemSubmitMutation'; +const INSTAGRAM_NOTE_ROOT_FIELD = 'xdt_create_inbox_tray_item'; + +function requirePage(page: IPage | null): IPage { + if (!page) throw new CommandExecutionError('Browser session required for instagram note'); + return page; +} + +function validateInstagramNoteArgs(kwargs: Record): void { + if (kwargs.content === undefined) { + throw new ArgumentError( + 'Argument "content" is required.', + 'Provide a note text, for example: opencli instagram note "hello"', + ); + } +} + +function normalizeInstagramNoteContent(kwargs: Record): string { + const content = String(kwargs.content ?? '').trim(); + if (!content) { + throw new ArgumentError( + 'Instagram note content cannot be empty.', + 'Provide a non-empty note text, for example: opencli instagram note "hello"', + ); + } + if (Array.from(content).length > 60) { + throw new ArgumentError( + 'Instagram note content must be 60 characters or fewer.', + 'Shorten the note text and try again.', + ); + } + return content; +} + +function buildNoteSuccessResult(noteId: string): InstagramNoteSuccessRow[] { + return [{ + status: '✅ Posted', + detail: 'Instagram note published successfully', + noteId, + }]; +} + +function buildPublishInstagramNoteJs(content: string): string { + return ` + (async () => { + const input = ${JSON.stringify({ content })}; + const html = document.documentElement?.outerHTML || ''; + const scripts = Array.from(document.scripts || []) + .map((script) => script.textContent || '') + .join('\\n'); + const source = html + '\\n' + scripts; + const pick = (patterns) => { + for (const pattern of patterns) { + const match = source.match(pattern); + if (!match) continue; + for (let index = 1; index < match.length; index += 1) { + if (match[index]) return match[index]; + } + return match[0] || ''; + } + return ''; + }; + const readCookie = (name) => { + const prefix = name + '='; + const part = document.cookie + .split('; ') + .find((cookie) => cookie.startsWith(prefix)); + return part ? decodeURIComponent(part.slice(prefix.length)) : ''; + }; + const actorId = pick([ + /"actorID":"(\\d+)"/, + /"actor_id":"(\\d+)"/, + /"viewerId":"(\\d+)"/, + ]); + const fbDtsg = pick([ + /(NAF[a-zA-Z0-9:_-]{20,})/, + /(NAf[a-zA-Z0-9:_-]{20,})/, + ]); + const lsd = pick([ + /"LSD",\\[\\],\\{"token":"([^"]+)"\\}/, + /"lsd":"([^"]+)"/, + ]); + const appId = pick([ + /"X-IG-App-ID":"(\\d+)"/, + /"instagramWebAppId":"(\\d+)"/, + /"appId":"(\\d+)"/, + ]); + const asbdId = pick([ + /"X-ASBD-ID":"(\\d+)"/, + /"asbd_id":"(\\d+)"/, + ]); + const spinR = pick([/"__spin_r":(\\d+)/]); + const spinB = pick([/"__spin_b":"([^"]+)"/]); + const spinT = pick([/"__spin_t":(\\d+)/]); + const csrfToken = readCookie('csrftoken') || pick([ + /"csrf_token":"([^"]+)"/, + /"csrfToken":"([^"]+)"/, + ]); + const jazoest = fbDtsg + ? '2' + Array.from(fbDtsg).reduce((total, char) => total + char.charCodeAt(0), 0) + : ''; + + if (!actorId || !fbDtsg || !lsd || !appId || !csrfToken || !spinR || !spinB || !spinT || !jazoest) { + return { + ok: false, + stage: 'config', + text: JSON.stringify({ + actorId: Boolean(actorId), + fbDtsg: Boolean(fbDtsg), + lsd: Boolean(lsd), + appId: Boolean(appId), + csrfToken: Boolean(csrfToken), + spinR: Boolean(spinR), + spinB: Boolean(spinB), + spinT: Boolean(spinT), + jazoest: Boolean(jazoest), + }), + }; + } + + const variables = { + input: { + actor_id: actorId, + client_mutation_id: '1', + additional_params: { + note_create_params: { + note_style: 0, + text: input.content, + }, + }, + audience: 0, + inbox_tray_item_type: 'note', + }, + }; + + const body = new URLSearchParams(); + body.set('av', actorId); + body.set('__user', '0'); + body.set('__a', '1'); + body.set('__req', '1'); + body.set('__hs', ''); + body.set('dpr', String(window.devicePixelRatio || 1)); + body.set('__ccg', 'UNKNOWN'); + body.set('__rev', spinR); + body.set('__s', ''); + body.set('__hsi', ''); + body.set('__dyn', ''); + body.set('__csr', ''); + body.set('__comet_req', '7'); + body.set('fb_dtsg', fbDtsg); + body.set('jazoest', jazoest); + body.set('lsd', lsd); + body.set('__spin_r', spinR); + body.set('__spin_b', spinB); + body.set('__spin_t', spinT); + body.set('fb_api_caller_class', 'RelayModern'); + body.set('fb_api_req_friendly_name', ${JSON.stringify(INSTAGRAM_NOTE_MUTATION_NAME)}); + body.set('variables', JSON.stringify(variables)); + body.set('server_timestamps', 'true'); + body.set('doc_id', ${JSON.stringify(INSTAGRAM_NOTE_DOC_ID)}); + + const headers = { + Accept: '*/*', + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-ASBD-ID': asbdId || undefined, + 'X-CSRFToken': csrfToken, + 'X-FB-Friendly-Name': ${JSON.stringify(INSTAGRAM_NOTE_MUTATION_NAME)}, + 'X-FB-LSD': lsd, + 'X-IG-App-ID': appId, + 'X-Root-Field-Name': ${JSON.stringify(INSTAGRAM_NOTE_ROOT_FIELD)}, + }; + + const response = await fetch('/graphql/query', { + method: 'POST', + credentials: 'include', + headers, + body: body.toString(), + }); + const text = await response.text(); + const normalizedText = text.replace(/^for \\(;;\\);?/, '').trim(); + let data = null; + try { + data = JSON.parse(normalizedText); + } catch {} + + const rootField = ${JSON.stringify(INSTAGRAM_NOTE_ROOT_FIELD)}; + const note = data?.data?.[rootField]?.inbox_tray_item; + const noteId = String(note?.inbox_tray_item_id || note?.id || ''); + if (response.ok && noteId) { + return { + ok: true, + stage: 'publish', + noteId, + text: String(note?.note_dict?.text || input.content || ''), + }; + } + + return { + ok: false, + stage: 'publish', + status: response.status, + text: normalizedText || text, + }; + })() + `; +} + +cli({ + site: 'instagram', + name: 'note', + description: 'Publish a text Instagram note', + domain: 'www.instagram.com', + strategy: Strategy.UI, + browser: true, + timeoutSeconds: 120, + args: [ + { name: 'content', positional: true, required: true, help: 'Note text (max 60 characters)' }, + ], + columns: ['status', 'detail', 'noteId'], + validateArgs: validateInstagramNoteArgs, + func: async (page: IPage | null, kwargs) => { + const browserPage = requirePage(page); + const content = normalizeInstagramNoteContent(kwargs as Record); + await browserPage.goto(INSTAGRAM_INBOX_URL); + await browserPage.wait({ time: 2 }); + const result = await browserPage.evaluate(buildPublishInstagramNoteJs(content)) as BrowserNoteResult; + if (!result?.ok) { + throw new CommandExecutionError( + `Instagram note publish failed at ${String(result?.stage || 'unknown')}: ${String(result?.text || 'unknown error')}`, + ); + } + return buildNoteSuccessResult(String(result.noteId || '')); + }, +}); diff --git a/src/clis/instagram/post.test.ts b/src/clis/instagram/post.test.ts new file mode 100644 index 00000000..8dea2491 --- /dev/null +++ b/src/clis/instagram/post.test.ts @@ -0,0 +1,1716 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AuthRequiredError, CommandExecutionError } from '../../errors.js'; +import { getRegistry } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import * as privatePublish from './_shared/private-publish.js'; +import { buildClickActionJs, buildEnsureComposerOpenJs, buildInspectUploadStageJs, buildPublishStatusProbeJs } from './post.js'; +import './post.js'; + +const tempDirs: string[] = []; + +function createTempImage(name = 'demo.jpg', bytes = Buffer.from([0xff, 0xd8, 0xff, 0xd9])): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-instagram-post-')); + tempDirs.push(dir); + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, bytes); + return filePath; +} + +function createTempVideo(name = 'demo.mp4', bytes = Buffer.from('video')): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-instagram-post-video-')); + tempDirs.push(dir); + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, bytes); + return filePath; +} + +function withInitialDialogDismiss(results: unknown[]): unknown[] { + return [{ ok: false }, ...results]; +} + +function createPageMock(evaluateResults: unknown[], overrides: Partial = {}): IPage { + const evaluate = vi.fn(); + for (const result of evaluateResults) { + evaluate.mockResolvedValueOnce(result); + } + + return { + goto: vi.fn().mockResolvedValue(undefined), + evaluate, + getCookies: vi.fn().mockResolvedValue([]), + snapshot: vi.fn().mockResolvedValue(undefined), + click: vi.fn().mockResolvedValue(undefined), + typeText: vi.fn().mockResolvedValue(undefined), + pressKey: vi.fn().mockResolvedValue(undefined), + scrollTo: vi.fn().mockResolvedValue(undefined), + getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }), + wait: vi.fn().mockResolvedValue(undefined), + tabs: vi.fn().mockResolvedValue([]), + closeTab: vi.fn().mockResolvedValue(undefined), + newTab: vi.fn().mockResolvedValue(undefined), + selectTab: vi.fn().mockResolvedValue(undefined), + networkRequests: vi.fn().mockResolvedValue([]), + consoleMessages: vi.fn().mockResolvedValue([]), + scroll: vi.fn().mockResolvedValue(undefined), + autoScroll: vi.fn().mockResolvedValue(undefined), + installInterceptor: vi.fn().mockResolvedValue(undefined), + getInterceptedRequests: vi.fn().mockResolvedValue([]), + waitForCapture: vi.fn().mockResolvedValue(undefined), + screenshot: vi.fn().mockResolvedValue(''), + setFileInput: vi.fn().mockResolvedValue(undefined), + insertText: undefined, + getCurrentUrl: vi.fn().mockResolvedValue(null), + ...overrides, + }; +} + +afterAll(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + delete process.env.OPENCLI_INSTAGRAM_CAPTURE; +}); + +describe('instagram auth detection', () => { + it('does not treat generic homepage text containing "log in" as an auth failure', () => { + const globalState = globalThis as typeof globalThis & { + document?: unknown; + window?: unknown; + }; + + const originalDocument = globalState.document; + const originalWindow = globalState.window; + + globalState.document = { + body: { innerText: 'Suggested for you Log in to see more content' }, + querySelector: () => null, + querySelectorAll: () => [], + } as unknown as Document; + globalState.window = { location: { pathname: '/' } } as unknown as Window & typeof globalThis; + + try { + expect(eval(buildEnsureComposerOpenJs()) as { ok: boolean; reason?: string }).toEqual({ ok: true }); + } finally { + globalState.document = originalDocument; + globalState.window = originalWindow; + } + }); +}); + +describe('instagram publish status detection', () => { + it('does not treat unrelated page text as share failure while the sharing dialog is still visible', () => { + const globalState = globalThis as typeof globalThis & { + document?: unknown; + window?: unknown; + HTMLElement?: unknown; + }; + + class MockHTMLElement {} + + const visibleDialog = new MockHTMLElement() as MockHTMLElement & { + textContent: string; + querySelector: () => null; + getBoundingClientRect: () => { width: number; height: number }; + }; + visibleDialog.textContent = 'Sharing'; + visibleDialog.querySelector = () => null; + visibleDialog.getBoundingClientRect = () => ({ width: 100, height: 100 }); + + const originalDocument = globalState.document; + const originalWindow = globalState.window; + const originalHTMLElement = globalState.HTMLElement; + + globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; + globalState.document = { + querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [visibleDialog] : [], + } as unknown as Document; + globalState.window = { + location: { href: 'https://www.instagram.com/' }, + getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), + } as unknown as Window & typeof globalThis; + + try { + expect(eval(buildPublishStatusProbeJs()) as { failed?: boolean; settled?: boolean; ok?: boolean }).toEqual({ + ok: false, + failed: false, + settled: false, + url: '', + }); + } finally { + globalState.document = originalDocument; + globalState.window = originalWindow; + globalState.HTMLElement = originalHTMLElement; + } + }); + + it('does not treat a stale visible error dialog as share failure while sharing is still in progress', () => { + const globalState = globalThis as typeof globalThis & { + document?: unknown; + window?: unknown; + HTMLElement?: unknown; + }; + + class MockHTMLElement {} + + const sharingDialog = new MockHTMLElement() as MockHTMLElement & { + textContent: string; + querySelector: () => null; + getBoundingClientRect: () => { width: number; height: number }; + }; + sharingDialog.textContent = 'Sharing'; + sharingDialog.querySelector = () => null; + sharingDialog.getBoundingClientRect = () => ({ width: 100, height: 100 }); + + const staleErrorDialog = new MockHTMLElement() as MockHTMLElement & { + textContent: string; + querySelector: () => null; + getBoundingClientRect: () => { width: number; height: number }; + }; + staleErrorDialog.textContent = 'Something went wrong. Please try again. Try again'; + staleErrorDialog.querySelector = () => null; + staleErrorDialog.getBoundingClientRect = () => ({ width: 100, height: 100 }); + + const originalDocument = globalState.document; + const originalWindow = globalState.window; + const originalHTMLElement = globalState.HTMLElement; + + globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; + globalState.document = { + querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [sharingDialog, staleErrorDialog] : [], + } as unknown as Document; + globalState.window = { + location: { href: 'https://www.instagram.com/' }, + getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), + } as unknown as Window & typeof globalThis; + + try { + expect(eval(buildPublishStatusProbeJs()) as { failed?: boolean; settled?: boolean; ok?: boolean }).toEqual({ + ok: false, + failed: false, + settled: false, + url: '', + }); + } finally { + globalState.document = originalDocument; + globalState.window = originalWindow; + globalState.HTMLElement = originalHTMLElement; + } + }); + + it('prefers explicit post-shared success over stale visible error text', () => { + const globalState = globalThis as typeof globalThis & { + document?: unknown; + window?: unknown; + HTMLElement?: unknown; + }; + + class MockHTMLElement {} + + const sharedDialog = new MockHTMLElement() as MockHTMLElement & { + textContent: string; + querySelector: () => null; + getBoundingClientRect: () => { width: number; height: number }; + }; + sharedDialog.textContent = 'Post shared Your post has been shared.'; + sharedDialog.querySelector = () => null; + sharedDialog.getBoundingClientRect = () => ({ width: 100, height: 100 }); + + const staleErrorDialog = new MockHTMLElement() as MockHTMLElement & { + textContent: string; + querySelector: () => null; + getBoundingClientRect: () => { width: number; height: number }; + }; + staleErrorDialog.textContent = 'Something went wrong. Please try again. Try again'; + staleErrorDialog.querySelector = () => null; + staleErrorDialog.getBoundingClientRect = () => ({ width: 100, height: 100 }); + + const originalDocument = globalState.document; + const originalWindow = globalState.window; + const originalHTMLElement = globalState.HTMLElement; + + globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; + globalState.document = { + querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [sharedDialog, staleErrorDialog] : [], + } as unknown as Document; + globalState.window = { + location: { href: 'https://www.instagram.com/' }, + getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), + } as unknown as Window & typeof globalThis; + + try { + expect(eval(buildPublishStatusProbeJs()) as { failed?: boolean; settled?: boolean; ok?: boolean }).toEqual({ + ok: true, + failed: false, + settled: false, + url: '', + }); + } finally { + globalState.document = originalDocument; + globalState.window = originalWindow; + globalState.HTMLElement = originalHTMLElement; + } + }); +}); + +describe('instagram click action detection', () => { + it('matches aria-label-only Next buttons in the media dialog', () => { + const globalState = globalThis as typeof globalThis & { + document?: unknown; + window?: unknown; + HTMLElement?: unknown; + }; + + class MockHTMLElement { + textContent = ''; + ariaLabel = ''; + clicked = false; + querySelectorAll = (_selector: string) => [] as unknown[]; + querySelector = (_selector: string) => null as unknown; + getAttribute(name: string): string | null { + if (name === 'aria-label') return this.ariaLabel || null; + return null; + } + getBoundingClientRect() { + return { width: 100, height: 40 }; + } + click() { + this.clicked = true; + } + } + + const nextButton = new MockHTMLElement(); + nextButton.ariaLabel = 'Next'; + + const dialog = new MockHTMLElement(); + dialog.textContent = 'Crop Back Select crop Open media gallery'; + dialog.querySelector = (selector: string) => selector === 'input[type="file"]' ? {} as Element : null; + dialog.querySelectorAll = (selector: string) => selector === 'button, div[role="button"]' ? [nextButton] : []; + + const body = new MockHTMLElement(); + + const originalDocument = globalState.document; + const originalWindow = globalState.window; + const originalHTMLElement = globalState.HTMLElement; + + globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; + globalState.document = { + body, + querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [dialog] : [], + } as unknown as Document; + globalState.window = { + getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), + } as unknown as Window & typeof globalThis; + + try { + expect(eval(buildClickActionJs(['Next', '下一步'], 'media')) as { ok: boolean; label?: string }).toEqual({ + ok: true, + label: 'Next', + }); + expect(nextButton.clicked).toBe(true); + } finally { + globalState.document = originalDocument; + globalState.window = originalWindow; + globalState.HTMLElement = originalHTMLElement; + } + }); + + it('does not click a body-level Next button when media scope has no matching dialog controls', () => { + const globalState = globalThis as typeof globalThis & { + document?: unknown; + window?: unknown; + HTMLElement?: unknown; + }; + + class MockHTMLElement { + textContent = ''; + ariaLabel = ''; + clicked = false; + children: unknown[] = []; + querySelectorAll = (_selector: string) => this.children; + querySelector = (_selector: string) => null as unknown; + getAttribute(name: string): string | null { + if (name === 'aria-label') return this.ariaLabel || null; + return null; + } + getBoundingClientRect() { + return { width: 100, height: 40 }; + } + click() { + this.clicked = true; + } + } + + const bodyNext = new MockHTMLElement(); + bodyNext.ariaLabel = 'Next'; + + const errorDialog = new MockHTMLElement(); + errorDialog.textContent = 'Something went wrong Try again'; + errorDialog.children = []; + + const body = new MockHTMLElement(); + body.children = [bodyNext]; + + const originalDocument = globalState.document; + const originalWindow = globalState.window; + const originalHTMLElement = globalState.HTMLElement; + + globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; + globalState.document = { + body, + querySelectorAll: (selector: string) => selector === '[role="dialog"]' ? [errorDialog] : [], + } as unknown as Document; + globalState.window = { + getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), + } as unknown as Window & typeof globalThis; + + try { + expect(eval(buildClickActionJs(['Next', '下一步'], 'media')) as { ok: boolean }).toEqual({ ok: false }); + expect(bodyNext.clicked).toBe(false); + } finally { + globalState.document = originalDocument; + globalState.window = originalWindow; + globalState.HTMLElement = originalHTMLElement; + } + }); +}); + +describe('instagram upload stage detection', () => { + it('does not treat a body-level Next button as upload preview when the visible dialog is an error', () => { + const globalState = globalThis as typeof globalThis & { + document?: unknown; + window?: unknown; + HTMLElement?: unknown; + }; + + class MockHTMLElement { + textContent = ''; + ariaLabel = ''; + children: unknown[] = []; + querySelectorAll = (_selector: string) => this.children; + querySelector = (_selector: string) => null as unknown; + getAttribute(name: string): string | null { + if (name === 'aria-label') return this.ariaLabel || null; + return null; + } + getBoundingClientRect() { + return { width: 100, height: 40 }; + } + } + + const bodyNext = new MockHTMLElement(); + bodyNext.ariaLabel = 'Next'; + + const errorDialog = new MockHTMLElement(); + errorDialog.textContent = 'Something went wrong. Please try again. Try again'; + + const body = new MockHTMLElement(); + body.children = [bodyNext]; + + const originalDocument = globalState.document; + const originalWindow = globalState.window; + const originalHTMLElement = globalState.HTMLElement; + + globalState.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; + globalState.document = { + body, + querySelectorAll: (selector: string) => { + if (selector === '[role="dialog"]') return [errorDialog]; + return []; + }, + } as unknown as Document; + globalState.window = { + getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), + } as unknown as Window & typeof globalThis; + + try { + expect(eval(buildInspectUploadStageJs()) as { state: string; detail: string }).toEqual({ + state: 'failed', + detail: 'Something went wrong. Please try again. Try again', + }); + } finally { + globalState.document = originalDocument; + globalState.window = originalWindow; + globalState.HTMLElement = originalHTMLElement; + } + }); +}); + +describe('instagram post registration', () => { + beforeEach(() => { + vi.spyOn(privatePublish, 'resolveInstagramPrivatePublishConfig').mockResolvedValue({ + apiContext: { + asbdId: '', + csrfToken: 'csrf-token', + igAppId: '936619743392459', + igWwwClaim: '', + instagramAjax: '1036523242', + webSessionId: '', + }, + jazoest: '22047', + }); + vi.spyOn(privatePublish, 'publishImagesViaPrivateApi').mockRejectedValue( + new CommandExecutionError('Instagram private publish configure failed: 400 {"message":"fallback to ui"}'), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('registers the post command with a required-value media arg', () => { + const cmd = getRegistry().get('instagram/post'); + expect(cmd).toBeDefined(); + expect(cmd?.browser).toBe(true); + expect(cmd?.timeoutSeconds).toBe(300); + expect(cmd?.args.some((arg) => arg.name === 'media' && !arg.required && arg.valueRequired)).toBe(true); + expect(cmd?.args.some((arg) => arg.name === 'content' && !arg.required && arg.positional)).toBe(true); + }); + + it('prefers the private route by default and returns without touching UI upload steps when private publish succeeds', async () => { + const imagePath = createTempImage('private-default.jpg'); + const privateSpy = vi.spyOn(privatePublish, 'publishImagesViaPrivateApi').mockResolvedValueOnce({ + code: 'PRIVATEDEFAULT123', + uploadIds: ['111'], + }); + const evaluate = vi.fn(async (js: string) => { + if (js.includes('sharing') && js.includes('create new post')) return { ok: false }; + if (js.includes('window.location?.pathname')) return { ok: true }; + if (js.includes('const data = Array.isArray(window[') && js.includes('__opencli_ig_protocol_capture')) return { data: [], errors: [] }; + return { ok: true }; + }); + const page = createPageMock([], { + evaluate, + getCookies: vi.fn().mockResolvedValue([{ name: 'csrftoken', value: 'csrf-token', domain: 'instagram.com' }]), + }); + const cmd = getRegistry().get('instagram/post'); + + const result = await cmd!.func!(page, { media: imagePath, content: 'private default' }); + + expect(privateSpy).toHaveBeenCalledTimes(1); + expect(page.setFileInput).not.toHaveBeenCalled(); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/PRIVATEDEFAULT123/', + }, + ]); + privateSpy.mockRestore(); + }); + + it('falls back to the UI route when the default private route fails safely before publishing', async () => { + const imagePath = createTempImage('private-fallback-ui.jpg'); + const privateSpy = vi.spyOn(privatePublish, 'publishImagesViaPrivateApi').mockRejectedValueOnce( + new CommandExecutionError('Instagram private publish configure_sidecar failed: 400 {"message":"Uploaded image is invalid"}'), + ); + const evaluate = vi.fn(async (js: string) => { + if (js.includes('sharing') && js.includes('create new post')) return { ok: false }; + if (js.includes('window.location?.pathname')) return { ok: true }; + if (js.includes('data-opencli-ig-upload-index')) return { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }; + if (js.includes("dispatchEvent(new Event('input'")) return { ok: true }; + if (js.includes('const hasPreviewUi =')) return { ok: true, state: 'preview' }; + if (js.includes("scope === 'media'")) return { ok: true, label: 'Next' }; + if (js.includes("scope === 'caption'")) return { ok: true, label: 'Share' }; + if (js.includes('post shared') && js.includes('your post has been shared')) return { ok: true, url: 'https://www.instagram.com/p/PRIVATEFALLBACK123/' }; + return { ok: true }; + }); + const page = createPageMock([], { + evaluate, + getCookies: vi.fn().mockResolvedValue([{ name: 'csrftoken', value: 'csrf-token', domain: 'instagram.com' }]), + }); + const cmd = getRegistry().get('instagram/post'); + + const result = await cmd!.func!(page, { media: imagePath, content: 'private fallback' }); + + expect(privateSpy).toHaveBeenCalledTimes(1); + expect(page.setFileInput).toHaveBeenCalledWith([imagePath], '[data-opencli-ig-upload-index="0"]'); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/PRIVATEFALLBACK123/', + }, + ]); + privateSpy.mockRestore(); + }); + + it('falls back to the UI route for mixed-media posts when the private route fails safely before publishing', async () => { + const imagePath = createTempImage('mixed-fallback-image.jpg'); + const videoPath = createTempVideo('mixed-fallback-video.mp4'); + const privateSpy = vi.spyOn(privatePublish, 'publishMediaViaPrivateApi').mockRejectedValueOnce( + new CommandExecutionError('Instagram private publish configure_sidecar failed: 400 {"message":"fallback to ui"}'), + ); + const evaluate = vi.fn(async (js: string) => { + if (js.includes('sharing') && js.includes('create new post')) return { ok: false }; + if (js.includes('window.location?.pathname')) return { ok: true }; + if (js.includes('data-opencli-ig-upload-index')) return { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }; + if (js.includes("dispatchEvent(new Event('input'")) return { ok: true }; + if (js.includes('const hasPreviewUi =')) return { ok: true, state: 'preview' }; + if (js.includes("scope === 'media'")) return { ok: true, label: 'Next' }; + if (js.includes("scope === 'caption'")) return { ok: true, label: 'Share' }; + if (js.includes('post shared') && js.includes('your post has been shared')) return { ok: true, url: 'https://www.instagram.com/p/MIXEDFALLBACK123/' }; + return { ok: true }; + }); + const page = createPageMock([], { + evaluate, + getCookies: vi.fn().mockResolvedValue([{ name: 'csrftoken', value: 'csrf-token', domain: 'instagram.com' }]), + }); + const cmd = getRegistry().get('instagram/post'); + + const result = await cmd!.func!(page, { + media: `${imagePath},${videoPath}`, + content: 'mixed ui fallback', + }); + + expect(privateSpy).toHaveBeenCalledTimes(1); + expect(page.setFileInput).toHaveBeenCalledWith([imagePath, videoPath], '[data-opencli-ig-upload-index="0"]'); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: '2-item mixed-media carousel post shared successfully', + url: 'https://www.instagram.com/p/MIXEDFALLBACK123/', + }, + ]); + privateSpy.mockRestore(); + }); + + it('prefers the private route by default for mixed-media posts and preserves input order', async () => { + const imagePath = createTempImage('mixed-default.jpg'); + const videoPath = createTempVideo('mixed-default.mp4'); + const privateSpy = vi.spyOn(privatePublish, 'publishMediaViaPrivateApi').mockResolvedValueOnce({ + code: 'MIXEDPRIVATE123', + uploadIds: ['111', '222'], + }); + const page = createPageMock([], { + evaluate: vi.fn(async () => ({ ok: true })), + getCookies: vi.fn().mockResolvedValue([{ name: 'csrftoken', value: 'csrf-token', domain: 'instagram.com' }]), + }); + const cmd = getRegistry().get('instagram/post'); + + const result = await cmd!.func!(page, { + media: `${imagePath},${videoPath}`, + content: 'mixed private default', + }); + + expect(privateSpy).toHaveBeenCalledWith(expect.objectContaining({ + mediaItems: [ + { type: 'image', filePath: imagePath }, + { type: 'video', filePath: videoPath }, + ], + caption: 'mixed private default', + })); + expect(page.setFileInput).not.toHaveBeenCalled(); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: '2-item mixed-media carousel post shared successfully', + url: 'https://www.instagram.com/p/MIXEDPRIVATE123/', + }, + ]); + privateSpy.mockRestore(); + }); + + it('rejects missing --media before browser work', async () => { + const page = createPageMock([]); + const cmd = getRegistry().get('instagram/post'); + + await expect(cmd!.func!(page, { + content: 'missing media', + })).rejects.toThrow('Argument "media" is required.'); + }); + + it('rejects empty or invalid --media inputs', async () => { + const imagePath = createTempImage('invalid-media-image.jpg'); + const page = createPageMock([]); + const cmd = getRegistry().get('instagram/post'); + + await expect(cmd!.func!(page, { + media: '', + })).rejects.toThrow('Argument "media" is required.'); + + await expect(cmd!.func!(page, { + media: `${imagePath},/tmp/does-not-exist.mp4`, + })).rejects.toThrow('Media file not found'); + }); + + it('uploads a single image, fills caption, and shares the post', async () => { + const imagePath = createTempImage(); + const page = createPageMock(withInitialDialogDismiss([ + { ok: true }, + { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }, + { ok: true }, + { ok: true }, + { ok: false }, + { ok: true, label: 'Next' }, + { ok: true }, + { ok: true }, + { ok: true }, + { ok: true, label: 'Share' }, + { ok: true, url: 'https://www.instagram.com/p/ABC123xyz/' }, + ])); + + const cmd = getRegistry().get('instagram/post'); + const result = await cmd!.func!(page, { + media: imagePath, + content: 'hello from opencli', + }); + + expect(page.goto).toHaveBeenCalledWith('https://www.instagram.com/'); + expect(page.setFileInput).toHaveBeenCalledWith([imagePath], '[data-opencli-ig-upload-index="0"]'); + expect((page.evaluate as any).mock.calls.some((args: any[]) => String(args[0]).includes("dispatchEvent(new Event('change'"))).toBe(true); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/ABC123xyz/', + }, + ]); + }); + + it('uploads multiple images as a carousel and shares the post', async () => { + const firstImagePath = createTempImage('carousel-1.jpg'); + const secondImagePath = createTempImage('carousel-2.jpg'); + const page = createPageMock(withInitialDialogDismiss([ + { ok: true }, + { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }, + { ok: true }, + { ok: true }, + { ok: false }, + { ok: true, label: 'Next' }, + { ok: true }, + { ok: true }, + { ok: true }, + { ok: true, label: 'Share' }, + { ok: true, url: 'https://www.instagram.com/p/CAROUSEL123/' }, + ])); + + const cmd = getRegistry().get('instagram/post'); + const result = await cmd!.func!(page, { + media: `${firstImagePath},${secondImagePath}`, + content: 'hello carousel', + }); + + expect(page.setFileInput).toHaveBeenCalledWith( + [firstImagePath, secondImagePath], + '[data-opencli-ig-upload-index="0"]', + ); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: '2-image carousel post shared successfully', + url: 'https://www.instagram.com/p/CAROUSEL123/', + }, + ]); + }); + + it('installs and dumps protocol capture when OPENCLI_INSTAGRAM_CAPTURE is enabled', async () => { + process.env.OPENCLI_INSTAGRAM_CAPTURE = '1'; + const imagePath = createTempImage('capture-enabled.jpg'); + const evaluate = vi.fn(async (js: string) => { + if (js.includes('__opencli_ig_protocol_capture') && js.includes('PATCH_GUARD')) return { ok: true }; + if (js.includes('const data = Array.isArray(window[') && js.includes('__opencli_ig_protocol_capture')) { + return { data: [], errors: [] }; + } + if (js.includes('sharing') && js.includes('create new post')) return { ok: false }; + if (js.includes('window.location?.pathname')) return { ok: true }; + if (js.includes('data-opencli-ig-upload-index')) return { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }; + if (js.includes("dispatchEvent(new Event('input'")) return { ok: true }; + if (js.includes('const hasPreviewUi =')) return { ok: true, state: 'preview' }; + if (js.includes("scope === 'media'")) return { ok: true, label: 'Next' }; + if (js.includes("scope === 'caption'")) return { ok: true, label: 'Share' }; + if (js.includes('post shared') && js.includes('your post has been shared')) return { ok: true, url: 'https://www.instagram.com/p/CAPTURE123/' }; + return { ok: true }; + }); + const page = createPageMock([], { evaluate }); + const cmd = getRegistry().get('instagram/post'); + + const result = await cmd!.func!(page, { + media: imagePath, + content: 'capture enabled', + }); + + const evaluateCalls = evaluate.mock.calls.map((args) => String(args[0])); + expect(evaluateCalls.some((js) => js.includes('__opencli_ig_protocol_capture') && js.includes('PATCH_GUARD'))).toBe(true); + expect(evaluateCalls.some((js) => js.includes('const data = Array.isArray(window[') && js.includes('__opencli_ig_protocol_capture'))).toBe(true); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/CAPTURE123/', + }, + ]); + + delete process.env.OPENCLI_INSTAGRAM_CAPTURE; + }); + + it('retries media Next when preview is visible before the button becomes clickable', async () => { + const firstImagePath = createTempImage('carousel-delay-1.jpg'); + const secondImagePath = createTempImage('carousel-delay-2.jpg'); + let nextAttempts = 0; + const evaluate = vi.fn(async (js: string) => { + if (js.includes('sharing') && js.includes('create new post')) return { ok: false }; + if (js.includes('window.location?.pathname')) return { ok: true }; + if (js.includes('data-opencli-ig-upload-index')) return { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }; + if (js.includes("dispatchEvent(new Event('input'")) return { ok: true }; + if (js.includes('const hasVisibleButtonInDialogs')) return { state: 'preview', detail: 'Crop Back Next Select crop' }; + if (js.includes("dialogText.includes('write a caption')") || js.includes("const editable = document.querySelector('textarea, [contenteditable=\"true\"]');")) { + return { ok: nextAttempts >= 2 }; + } + if (js.includes("!labels.includes(text) && !labels.includes(aria)")) { + nextAttempts += 1; + if (nextAttempts === 1) return { ok: false }; + return { ok: true, label: 'Next' }; + } + if (js.includes('ClipboardEvent') && js.includes('textarea')) return { ok: true, mode: 'textarea' }; + if (js.includes('readLexicalText')) return { ok: true }; + if (js.includes('post shared') && js.includes('your post has been shared')) return { ok: true, url: 'https://www.instagram.com/p/CAROUSELRETRY123/' }; + return { ok: true }; + }); + const page = createPageMock([], { evaluate }); + + const cmd = getRegistry().get('instagram/post'); + const result = await cmd!.func!(page, { + media: `${firstImagePath},${secondImagePath}`, + content: 'hello delayed carousel', + }); + + expect(result).toEqual([ + { + status: '✅ Posted', + detail: '2-image carousel post shared successfully', + url: 'https://www.instagram.com/p/CAROUSELRETRY123/', + }, + ]); + }); + + it('retries the whole carousel flow when preview briefly appears and then degrades into an upload error before Next is usable', async () => { + const firstImagePath = createTempImage('carousel-race-1.jpg'); + const secondImagePath = createTempImage('carousel-race-2.jpg'); + let composerRuns = 0; + let uploadStageChecks = 0; + let secondAttemptAdvanced = false; + const evaluate = vi.fn(async (js: string) => { + if (js.includes('sharing') && js.includes('create new post')) return { ok: false }; + if (js.includes('window.location?.pathname')) { + composerRuns += 1; + return { ok: true }; + } + if (js.includes('data-opencli-ig-upload-index')) return { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }; + if (js.includes("dispatchEvent(new Event('input'")) return { ok: true }; + if (js.includes('const hasVisibleButtonInDialogs')) { + uploadStageChecks += 1; + if (composerRuns === 1 && uploadStageChecks === 1) { + return { state: 'preview', detail: 'Crop Back Next Select crop' }; + } + if (composerRuns === 1) { + return { state: 'failed', detail: 'Something went wrong. Please try again.' }; + } + return { state: 'preview', detail: 'Crop Back Next Select crop' }; + } + if (js.includes("dialogText.includes('write a caption')") || js.includes("const editable = document.querySelector('textarea, [contenteditable=\"true\"]');")) { + return { ok: composerRuns >= 2 && secondAttemptAdvanced }; + } + if (js.includes("!labels.includes(text) && !labels.includes(aria)")) { + if (composerRuns === 1) return { ok: false }; + secondAttemptAdvanced = true; + return { ok: true, label: 'Next' }; + } + if (js.includes('button[aria-label="Close"]')) return { ok: true }; + if (js.includes('ClipboardEvent') && js.includes('textarea')) return { ok: true, mode: 'textarea' }; + if (js.includes('readLexicalText')) return { ok: true }; + if (js.includes('post shared') && js.includes('your post has been shared')) return { ok: true, url: 'https://www.instagram.com/p/CAROUSELFRESH123/' }; + return { ok: true }; + }); + const page = createPageMock([], { evaluate }); + + const cmd = getRegistry().get('instagram/post'); + const result = await cmd!.func!(page, { + media: `${firstImagePath},${secondImagePath}`, + content: 'hello recovered carousel', + }); + + expect(result).toEqual([ + { + status: '✅ Posted', + detail: '2-image carousel post shared successfully', + url: 'https://www.instagram.com/p/CAROUSELFRESH123/', + }, + ]); + }); + + it('uploads a single image and shares it without a caption when content is omitted', async () => { + const imagePath = createTempImage('no-caption.jpg'); + const evaluate = vi.fn(async (js: string) => { + if (js.includes('sharing') && js.includes('create new post')) return { ok: false }; + if (js.includes('window.location?.pathname')) return { ok: true }; + if (js.includes('data-opencli-ig-upload-index')) return { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }; + if (js.includes("dispatchEvent(new Event('input'")) return { ok: true }; + if (js.includes('const hasPreviewUi =')) return { ok: true, state: 'preview' }; + if (js.includes("scope === 'media'")) return { ok: true, label: 'Next' }; + if (js.includes("scope === 'caption'")) return { ok: true, label: 'Share' }; + if (js.includes('const editable = document.querySelector(\'textarea, [contenteditable="true"]\');')) return { ok: true }; + if (js.includes('post shared') && js.includes('your post has been shared')) return { ok: true, url: 'https://www.instagram.com/p/NOCAPTION123/' }; + return { ok: false }; + }); + const page = createPageMock([], { evaluate }); + + const cmd = getRegistry().get('instagram/post'); + const result = await cmd!.func!(page, { + media: imagePath, + }); + + const evaluateCalls = (page.evaluate as any).mock.calls.map((args: any[]) => String(args[0])); + expect(evaluateCalls.some((js: string) => js.includes('Write a caption'))).toBe(false); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/NOCAPTION123/', + }, + ]); + }); + + it('falls back to browser-side file injection when the extension does not support set-file-input', async () => { + const imagePath = createTempImage('legacy-extension.jpg'); + const evaluate = vi.fn(async (js: string) => { + if (js.includes('sharing') && js.includes('create new post')) return { ok: false }; + if (js.includes('window.location?.pathname')) return { ok: true }; + if (js.includes('data-opencli-ig-upload-index')) return { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }; + if (js.includes('__opencliInstagramUpload_') && js.includes('] = [];')) return { ok: true }; + if (js.includes('parts.push(chunk)')) return { ok: true, count: 1 }; + if (js.includes('File input not found for fallback injection')) return { ok: true, count: 1 }; + if (js.includes('const hasPreviewUi =')) return { ok: true, state: 'preview' }; + if (js.includes("scope === 'caption'")) return { ok: true, label: 'Share' }; + if (js.includes("scope === 'media'")) return { ok: true, label: 'Next' }; + if (js.includes('labels.includes(text)')) return { ok: false }; + if (js.includes('ClipboardEvent') && js.includes('textarea')) return { ok: true, mode: 'textarea' }; + if (js.includes('readLexicalText')) return { ok: true }; + if (js.includes('couldn') && js.includes('your post has been shared')) return { ok: true, url: 'https://www.instagram.com/p/LEGACY123/' }; + return { ok: true }; + }); + const page = createPageMock([], { + evaluate, + setFileInput: vi.fn().mockRejectedValue(new Error('Unknown action: set-file-input')), + }); + + const cmd = getRegistry().get('instagram/post'); + const result = await cmd!.func!(page, { + media: imagePath, + content: 'legacy bridge fallback', + }); + + expect(page.setFileInput).toHaveBeenCalledWith([imagePath], '[data-opencli-ig-upload-index="0"]'); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/LEGACY123/', + }, + ]); + }); + + it('chunks large legacy fallback uploads instead of embedding the whole image in one evaluate payload', async () => { + const imagePath = createTempImage('legacy-large.jpg', Buffer.alloc(900 * 1024, 1)); + const evaluate = vi.fn(async (js: string) => { + if (js.includes('sharing') && js.includes('create new post')) return { ok: false }; + if (js.includes('window.location?.pathname')) return { ok: true }; + if (js.includes('data-opencli-ig-upload-index')) return { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }; + if (js.includes('window[') && js.includes('] = [];')) return { ok: true }; + if (js.includes('parts.push(chunk)')) return { ok: true, count: 1 }; + if (js.includes('File input not found for fallback injection')) return { ok: true, count: 1 }; + if (js.includes('const hasPreviewUi =')) return { ok: true, state: 'preview' }; + if (js.includes("scope === 'caption'")) return { ok: true, label: 'Share' }; + if (js.includes("scope === 'media'")) return { ok: true, label: 'Next' }; + if (js.includes('labels.includes(text)')) return { ok: false }; + if (js.includes('ClipboardEvent') && js.includes('textarea')) return { ok: true, mode: 'textarea' }; + if (js.includes('readLexicalText')) return { ok: true }; + if (js.includes('couldn') && js.includes('your post has been shared')) return { ok: true, url: 'https://www.instagram.com/p/LARGELEGACY123/' }; + return { ok: true }; + }); + const page = createPageMock([], { + evaluate, + setFileInput: vi.fn().mockRejectedValue(new Error('Unknown action: set-file-input')), + }); + + const cmd = getRegistry().get('instagram/post'); + const result = await cmd!.func!(page, { + media: imagePath, + content: 'legacy large bridge fallback', + }); + + const chunkCalls = evaluate.mock.calls.filter((args) => String(args[0]).includes('parts.push(chunk)')); + expect(chunkCalls.length).toBeGreaterThan(1); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/LARGELEGACY123/', + }, + ]); + }); + + it('fails clearly when Browser Bridge file upload support is unavailable', async () => { + const imagePath = createTempImage('missing-bridge.jpg'); + const page = createPageMock([], { setFileInput: undefined }); + const cmd = getRegistry().get('instagram/post'); + + await expect(cmd!.func!(page, { + media: imagePath, + content: 'hello from opencli', + })).rejects.toThrow(CommandExecutionError); + }); + + it('maps login-gated composer access to AuthRequiredError', async () => { + const imagePath = createTempImage('auth.jpg'); + const page = createPageMock(withInitialDialogDismiss([ + { ok: false, reason: 'auth' }, + ])); + const cmd = getRegistry().get('instagram/post'); + + await expect(cmd!.func!(page, { + media: imagePath, + content: 'login required', + })).rejects.toThrow(AuthRequiredError); + }); + + it('captures a debug screenshot when the upload preview never appears', async () => { + const imagePath = createTempImage('no-preview.jpg'); + const page = createPageMock(withInitialDialogDismiss([ + { ok: true }, + { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }, + { ok: true }, + { ok: false }, + { ok: false }, + { ok: false }, + { ok: false }, + { ok: false }, + { ok: false }, + { ok: false }, + { ok: false }, + { ok: false }, + { ok: false }, + { ok: false }, + { ok: false }, + { ok: false }, + ])); + const cmd = getRegistry().get('instagram/post'); + + await expect(cmd!.func!(page, { + media: imagePath, + content: 'preview missing', + })).rejects.toThrow('Instagram image preview did not appear after upload'); + + expect(page.screenshot).toHaveBeenCalledWith({ path: '/tmp/instagram_post_preview_debug.png' }); + }); + + it('fails clearly when Instagram shows an upload-stage error dialog', async () => { + const imagePath = createTempImage('upload-error.jpg'); + const page = createPageMock(withInitialDialogDismiss([ + { ok: true }, + { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }, + { ok: true }, + { ok: false, state: 'failed', detail: 'Something went wrong. Please try again.' }, + ])); + const cmd = getRegistry().get('instagram/post'); + + await expect(cmd!.func!(page, { + media: imagePath, + content: 'upload should fail clearly', + })).rejects.toThrow('Instagram image upload failed'); + }); + + it('treats crop/next preview UI as success even if stale error text is still visible', async () => { + const imagePath = createTempImage('upload-preview-wins.jpg'); + const page = createPageMock(withInitialDialogDismiss([ + { ok: true }, + { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }, + { ok: true }, + { + ok: false, + state: 'preview', + detail: 'Something went wrong. Please try again. Crop Back Next Select crop Select zoom Open media gallery', + }, + { ok: false }, + { ok: true, label: 'Next' }, + { ok: true }, + { ok: true }, + { ok: true }, + { ok: true, label: 'Share' }, + { ok: true, url: 'https://www.instagram.com/p/PREVIEWWINS123/' }, + ])); + const cmd = getRegistry().get('instagram/post'); + + const result = await cmd!.func!(page, { + media: imagePath, + content: 'preview state wins over stale error text', + }); + + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/PREVIEWWINS123/', + }, + ]); + }); + + it('retries the same upload selector once after an upload-stage error and can still succeed', async () => { + const imagePath = createTempImage('upload-retry.jpg'); + const setFileInput = vi.fn().mockResolvedValue(undefined); + let uploadProbeCount = 0; + const evaluate = vi.fn(async (js: string) => { + if (js.includes('sharing') && js.includes('create new post')) return { ok: false }; + if (js.includes('window.location?.pathname')) return { ok: true }; + if (js.includes('data-opencli-ig-upload-index')) return { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }; + if (js.includes("dispatchEvent(new Event('input'")) return { ok: true }; + if (js.includes('const failed =') && js.includes('const hasCaption =')) { + uploadProbeCount += 1; + return uploadProbeCount === 1 + ? { ok: false, state: 'failed', detail: 'Something went wrong. Please try again.' } + : { ok: true, state: 'preview' }; + } + if (js.includes('button[aria-label="Close"]')) return { ok: true }; + if (js.includes("scope === 'media'")) return { ok: true, label: 'Next' }; + if (js.includes('ClipboardEvent') && js.includes('textarea')) return { ok: true, mode: 'textarea' }; + if (js.includes('readLexicalText')) return { ok: true }; + if (js.includes("scope === 'caption'")) return { ok: true, label: 'Share' }; + if (js.includes('post shared') && js.includes('your post has been shared')) return { ok: true, url: 'https://www.instagram.com/p/UPLOADRETRY123/' }; + return { ok: true }; + }); + const page = createPageMock([], { setFileInput, evaluate }); + const cmd = getRegistry().get('instagram/post'); + + const result = await cmd!.func!(page, { + media: imagePath, + content: 'upload retry succeeds', + }); + + expect(setFileInput).toHaveBeenCalledTimes(1); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/UPLOADRETRY123/', + }, + ]); + }); + + it('clicks upload Try again in-place before resetting the whole flow when Instagram shows an upload error dialog', async () => { + const imagePath = createTempImage('upload-inline-retry.jpg'); + let uploadProbeCount = 0; + const evaluate = vi.fn(async (js: string) => { + if (js.includes('sharing') && js.includes('create new post')) return { ok: false }; + if (js.includes('window.location?.pathname')) return { ok: true }; + if (js.includes('data-opencli-ig-upload-index')) return { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }; + if (js.includes("dispatchEvent(new Event('input'")) return { ok: true }; + if (js.includes('const hasVisibleButtonInDialogs')) { + uploadProbeCount += 1; + return uploadProbeCount === 1 + ? { state: 'failed', detail: 'Something went wrong. Please try again.' } + : { state: 'preview', detail: 'Crop Back Next Select crop' }; + } + if (js.includes('something went wrong') && js.includes('label === \'try again\'')) return { ok: true }; + if (js.includes("dialogText.includes('write a caption')") || js.includes("const editable = document.querySelector('textarea, [contenteditable=\"true\"]');")) { + return { ok: true }; + } + if (js.includes("!labels.includes(text) && !labels.includes(aria)")) { + if (js.includes('"Share"')) return { ok: true, label: 'Share' }; + return { ok: true, label: 'Next' }; + } + if (js.includes('ClipboardEvent') && js.includes('textarea')) return { ok: true, mode: 'textarea' }; + if (js.includes('readLexicalText')) return { ok: true }; + if (js.includes('post shared') && js.includes('your post has been shared')) return { ok: true, url: 'https://www.instagram.com/p/UPLOADINLINERETRY123/' }; + return { ok: true }; + }); + const page = createPageMock([], { evaluate }); + const cmd = getRegistry().get('instagram/post'); + + const result = await cmd!.func!(page, { + media: imagePath, + content: 'upload inline retry succeeds', + }); + + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/UPLOADINLINERETRY123/', + }, + ]); + }); + + it('retries max-size carousel upload failures beyond the expanded large-carousel budget before succeeding', async () => { + const paths = [ + createTempImage('carousel-10-1.jpg'), + createTempImage('carousel-10-2.jpg'), + createTempImage('carousel-10-3.jpg'), + createTempImage('carousel-10-4.jpg'), + createTempImage('carousel-10-5.jpg'), + createTempImage('carousel-10-6.jpg'), + createTempImage('carousel-10-7.jpg'), + createTempImage('carousel-10-8.jpg'), + createTempImage('carousel-10-9.jpg'), + createTempImage('carousel-10-10.jpg'), + ]; + const setFileInput = vi.fn().mockResolvedValue(undefined); + let uploadProbeCount = 0; + const evaluate = vi.fn(async (js: string) => { + if (js.includes('sharing') && js.includes('create new post')) return { ok: false }; + if (js.includes('window.location?.pathname')) return { ok: true }; + if (js.includes('data-opencli-ig-upload-index')) return { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }; + if (js.includes("dispatchEvent(new Event('input'")) return { ok: true }; + if (js.includes('const hasVisibleButtonInDialogs')) { + uploadProbeCount += 1; + if (uploadProbeCount <= 16) { + return { state: 'failed', detail: 'Something went wrong. Please try again.' }; + } + return { state: 'preview', detail: 'Crop Back Next Select crop' }; + } + if (js.includes('button[aria-label="Close"]')) return { ok: true }; + if (js.includes("dialogText.includes('write a caption')") || js.includes("const editable = document.querySelector('textarea, [contenteditable=\"true\"]');")) { + return { ok: true }; + } + if (js.includes("!labels.includes(text) && !labels.includes(aria)")) { + if (js.includes('"Share"')) return { ok: true, label: 'Share' }; + return { ok: true, label: 'Next' }; + } + if (js.includes('post shared') && js.includes('your post has been shared')) return { ok: true, url: 'https://www.instagram.com/p/CAROUSEL10RETRY123/' }; + return { ok: true }; + }); + const page = createPageMock([], { setFileInput, evaluate }); + const cmd = getRegistry().get('instagram/post'); + + const result = await cmd!.func!(page, { + media: paths.join(','), + }); + + expect(setFileInput).toHaveBeenCalledTimes(5); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: '10-image carousel post shared successfully', + url: 'https://www.instagram.com/p/CAROUSEL10RETRY123/', + }, + ]); + }); + + it('forces a fresh home reload before retrying after an upload-stage error', async () => { + const imagePath = createTempImage('upload-fresh-reload.jpg'); + const gotoUrls: string[] = []; + const goto = vi.fn(async (url: string) => { + gotoUrls.push(String(url)); + }); + let uploadProbeCount = 0; + let advancedToCaption = false; + const evaluate = vi.fn(async (js: string) => { + if (js.includes('window.location?.pathname')) return { ok: true }; + if (js.includes('data-opencli-ig-upload-index')) return { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }; + if (js.includes("dispatchEvent(new Event('input'")) return { ok: true }; + if (js.includes("dialogText.includes('write a caption')") || js.includes("const editable = document.querySelector('textarea, [contenteditable=\"true\"]');")) { + return { ok: advancedToCaption }; + } + if (js.includes('const hasPreviewUi =')) { + uploadProbeCount += 1; + if (uploadProbeCount === 1) { + return { ok: false, state: 'failed', detail: 'Something went wrong. Please try again.' }; + } + return gotoUrls.some((url) => url.includes('__opencli_reset=')) + ? { ok: true, state: 'preview' } + : { ok: false, state: 'failed', detail: 'Something went wrong. Please try again.' }; + } + if (js.includes('button[aria-label="Close"]')) return { ok: false }; + if (js.includes("scope === 'media'")) { + advancedToCaption = true; + return { ok: true, label: 'Next' }; + } + if (js.includes('ClipboardEvent') && js.includes('textarea')) return { ok: true, mode: 'textarea' }; + if (js.includes('readLexicalText')) return { ok: true }; + if (js.includes("scope === 'caption'")) return { ok: true, label: 'Share' }; + if (js.includes('post shared') && js.includes('your post has been shared')) return { ok: true, url: 'https://www.instagram.com/p/FRESHRELOAD123/' }; + return { ok: false }; + }); + const page = createPageMock([], { + goto, + evaluate, + setFileInput: vi.fn().mockResolvedValue(undefined), + }); + const cmd = getRegistry().get('instagram/post'); + + const result = await cmd!.func!(page, { + media: imagePath, + content: 'fresh reload after upload failure', + }); + + expect(gotoUrls.some((url) => url.includes('__opencli_reset='))).toBe(true); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/FRESHRELOAD123/', + }, + ]); + }); + + it('retries the share action in-place when Instagram shows a visible try-again share failure dialog', async () => { + const imagePath = createTempImage('share-retry.jpg'); + let shareStatusChecks = 0; + const evaluate = vi.fn(async (js: string) => { + if (js.includes('sharing') && js.includes('create new post')) return { ok: false }; + if (js.includes('window.location?.pathname')) return { ok: true }; + if (js.includes('data-opencli-ig-upload-index')) return { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }; + if (js.includes("dispatchEvent(new Event('input'")) return { ok: true }; + if (js.includes('const hasVisibleButtonInDialogs')) return { state: 'preview', detail: 'Crop Back Next Select crop' }; + if (js.includes("dialogText.includes('write a caption')") || js.includes("const editable = document.querySelector('textarea, [contenteditable=\"true\"]');")) { + return { ok: true }; + } + if (js.includes("!labels.includes(text) && !labels.includes(aria)")) { + if (js.includes('"Share"')) return { ok: true, label: 'Share' }; + return { ok: true, label: 'Next' }; + } + if (js.includes('ClipboardEvent') && js.includes('textarea')) return { ok: true, mode: 'textarea' }; + if (js.includes('readLexicalText')) return { ok: true }; + if (js.includes('post shared') && js.includes('your post has been shared')) { + shareStatusChecks += 1; + return shareStatusChecks === 1 + ? { ok: false, failed: true, settled: false, url: '' } + : { ok: true, failed: false, settled: false, url: 'https://www.instagram.com/p/SHARERETRY123/' }; + } + if (js.includes('post couldn') && js.includes('try again')) return { ok: true }; + return { ok: true }; + }); + const page = createPageMock([], { evaluate }); + const cmd = getRegistry().get('instagram/post'); + + const result = await cmd!.func!(page, { + media: imagePath, + content: 'share retry succeeds', + }); + + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/SHARERETRY123/', + }, + ]); + }); + + it('re-resolves the upload input when the tagged selector goes stale before setFileInput runs', async () => { + const imagePath = createTempImage('stale-selector.jpg'); + const setFileInput = vi.fn() + .mockRejectedValueOnce(new Error('No element found matching selector: [data-opencli-ig-upload-index="0"]')) + .mockResolvedValueOnce(undefined); + const page = createPageMock(withInitialDialogDismiss([ + { ok: true }, + { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }, + { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }, + { ok: true }, + { ok: true }, + { ok: false }, + { ok: true, label: 'Next' }, + { ok: true }, + { ok: true }, + { ok: true }, + { ok: true, label: 'Share' }, + { ok: true, url: 'https://www.instagram.com/p/STALE123/' }, + ]), { setFileInput }); + const cmd = getRegistry().get('instagram/post'); + + const result = await cmd!.func!(page, { + media: imagePath, + content: 'stale selector recovery', + }); + + expect(setFileInput).toHaveBeenCalledTimes(2); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/STALE123/', + }, + ]); + }); + + it('re-resolves the upload input when CDP loses the matched file-input node before setFileInput runs', async () => { + const imagePath = createTempImage('stale-node-id.jpg'); + const setFileInput = vi.fn() + .mockRejectedValueOnce(new Error('{"code":-32000,"message":"Could not find node with given id"}')) + .mockResolvedValueOnce(undefined); + const page = createPageMock(withInitialDialogDismiss([ + { ok: true }, + { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }, + { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }, + { ok: true }, + { ok: true }, + { ok: false }, + { ok: true, label: 'Next' }, + { ok: true }, + { ok: true }, + { ok: true }, + { ok: true, label: 'Share' }, + { ok: true, url: 'https://www.instagram.com/p/STALEID123/' }, + ]), { setFileInput }); + const cmd = getRegistry().get('instagram/post'); + + const result = await cmd!.func!(page, { + media: imagePath, + content: 'stale node id recovery', + }); + + expect(setFileInput).toHaveBeenCalledTimes(2); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/STALEID123/', + }, + ]); + }); + + it('retries opening the home composer instead of navigating to the broken /create/select route', async () => { + const imagePath = createTempImage('retry-composer.jpg'); + const page = createPageMock(withInitialDialogDismiss([ + { ok: true }, + { ok: false }, + { ok: true }, + { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }, + { ok: true }, + { ok: true }, + { ok: false }, + { ok: true, label: 'Next' }, + { ok: true }, + { ok: true }, + { ok: true }, + { ok: true, label: 'Share' }, + { ok: true, url: 'https://www.instagram.com/p/FALLBACK123/' }, + ])); + const cmd = getRegistry().get('instagram/post'); + + const result = await cmd!.func!(page, { + media: imagePath, + content: 'retry composer', + }); + + const gotoCalls = (page.goto as any).mock.calls.map((args: any[]) => String(args[0])); + expect(gotoCalls.every((url: string) => !url.includes('/create/select'))).toBe(true); + expect(gotoCalls.some((url: string) => url === 'https://www.instagram.com/')).toBe(true); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/FALLBACK123/', + }, + ]); + }); + + it('clicks Next twice when Instagram shows an intermediate preview step before the caption editor', async () => { + const imagePath = createTempImage('double-next.jpg'); + let nextClicks = 0; + const evaluate = vi.fn(async (js: string) => { + if (js.includes('sharing') && js.includes('create new post')) return { ok: false }; + if (js.includes('window.location?.pathname')) return { ok: true }; + if (js.includes('data-opencli-ig-upload-index')) return { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }; + if (js.includes("dispatchEvent(new Event('input'")) return { ok: true }; + if (js.includes('const hasVisibleButtonInDialogs')) return { state: 'preview', detail: 'Crop Back Next Select crop' }; + if (js.includes("dialogText.includes('write a caption')") || js.includes("const editable = document.querySelector('textarea, [contenteditable=\"true\"]');")) { + return { ok: nextClicks >= 2 }; + } + if (js.includes("!labels.includes(text) && !labels.includes(aria)")) { + if (js.includes('"Share"')) return { ok: true, label: 'Share' }; + nextClicks += 1; + return { ok: true, label: 'Next' }; + } + if (js.includes('ClipboardEvent') && js.includes('textarea')) return { ok: true, mode: 'textarea' }; + if (js.includes('readLexicalText')) return { ok: true }; + if (js.includes('post shared') && js.includes('your post has been shared')) return { ok: true, url: 'https://www.instagram.com/p/DOUBLE123/' }; + return { ok: true }; + }); + const page = createPageMock([], { evaluate }); + const cmd = getRegistry().get('instagram/post'); + + const result = await cmd!.func!(page, { + media: imagePath, + content: 'double next flow', + }); + + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/DOUBLE123/', + }, + ]); + }); + + it('tries the next upload input when the first candidate never opens the preview', async () => { + const imagePath = createTempImage('second-input.jpg'); + const setFileInput = vi.fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + const page = createPageMock(withInitialDialogDismiss([ + { ok: true }, + { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]', '[data-opencli-ig-upload-index="1"]'] }, + { ok: true }, + { ok: false }, + { ok: false }, + { ok: false }, + { ok: false }, + { ok: false }, + { ok: false }, + { ok: false }, + { ok: false }, + { ok: true }, + { ok: true }, + { ok: false }, + { ok: true, label: 'Next' }, + { ok: true }, + { ok: true }, + { ok: true }, + { ok: true, label: 'Share' }, + { ok: true, url: 'https://www.instagram.com/p/SECOND123/' }, + ]), { setFileInput }); + + const cmd = getRegistry().get('instagram/post'); + const result = await cmd!.func!(page, { + media: imagePath, + content: 'second input works', + }); + + expect(setFileInput).toHaveBeenNthCalledWith(1, [imagePath], '[data-opencli-ig-upload-index="0"]'); + expect(setFileInput).toHaveBeenNthCalledWith(2, [imagePath], '[data-opencli-ig-upload-index="1"]'); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/SECOND123/', + }, + ]); + }); + + it('fails fast when Instagram reports that the post could not be shared', async () => { + const imagePath = createTempImage('share-failed.jpg'); + const page = createPageMock(withInitialDialogDismiss([ + { ok: true }, + { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }, + { ok: true }, + { ok: true }, + { ok: false }, + { ok: true, label: 'Next' }, + { ok: true }, + { ok: true }, + { ok: true }, + { ok: true, label: 'Share' }, + { ok: false, failed: true, url: '' }, + ])); + + const cmd = getRegistry().get('instagram/post'); + + await expect(cmd!.func!(page, { + media: imagePath, + content: 'share should fail', + })).rejects.toThrow('Instagram post share failed'); + }); + + it('keeps waiting across the full publish timeout window instead of fast-forwarding after 30 polls', async () => { + const imagePath = createTempImage('slow-share.jpg'); + const page = createPageMock(withInitialDialogDismiss([ + { ok: true }, + { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }, + { ok: true }, + { ok: true }, + { ok: false }, + { ok: true, label: 'Next' }, + { ok: true }, + { ok: true }, + { ok: true }, + { ok: true, label: 'Share' }, + ...Array.from({ length: 35 }, () => ({ ok: false, failed: false, settled: false, url: '' })), + { ok: true, url: 'https://www.instagram.com/p/SLOWSHARE123/' }, + ])); + const cmd = getRegistry().get('instagram/post'); + + const result = await cmd!.func!(page, { + media: imagePath, + content: 'slow share eventually succeeds', + }); + + const waitCalls = (page.wait as any).mock.calls.filter((args: any[]) => args[0]?.time === 1); + expect(waitCalls.length).toBeGreaterThanOrEqual(35); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/SLOWSHARE123/', + }, + ]); + }); + + it('does not retry the upload flow after Share has already been clicked', async () => { + const imagePath = createTempImage('no-duplicate-retry.jpg'); + const setFileInput = vi.fn().mockResolvedValue(undefined); + const page = createPageMock(withInitialDialogDismiss([ + { ok: true }, + { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }, + { ok: true }, + { ok: true }, + { ok: false }, + { ok: true, label: 'Next' }, + { ok: true }, + { ok: true }, + { ok: true }, + { ok: true, label: 'Share' }, + ...Array.from({ length: 30 }, () => ({ ok: false, failed: false, url: '' })), + ]), { setFileInput }); + + const cmd = getRegistry().get('instagram/post'); + + await expect(cmd!.func!(page, { + media: imagePath, + content: 'share observation stalled', + })).rejects.toThrow('Instagram post share confirmation did not appear'); + + expect(setFileInput).toHaveBeenCalledTimes(1); + }); + + it('recovers the latest post URL from the current logged-in profile when success does not navigate to /p/', async () => { + const imagePath = createTempImage('url-recovery.jpg'); + const evaluate = vi.fn(async (js: string) => { + if (js.includes('const data = Array.isArray(window[') && js.includes('__opencli_ig_protocol_capture')) { + return { data: [], errors: [] }; + } + if (js.includes('fetch(') && js.includes('/api/v1/users/') && js.includes('X-IG-App-ID')) { + return js.includes('dynamic-runtime-app-id') + ? { ok: true, username: 'tsezi_ray' } + : { ok: false }; + } + if (js.includes('window.location?.pathname')) return { ok: true }; + if (js.includes('data-opencli-ig-upload-index')) return { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }; + if (js.includes("dispatchEvent(new Event('input'")) return { ok: true }; + if (js.includes('const hasPreviewUi =')) return { ok: true, state: 'preview' }; + if (js.includes("scope === 'media'")) return { ok: true, label: 'Next' }; + if (js.includes("scope === 'caption'")) return { ok: true, label: 'Share' }; + if (js.includes('post shared') && js.includes('your post has been shared')) return { ok: true, url: '' }; + if (js.includes('const hrefs = Array.from(document.querySelectorAll(\'a[href*="/p/"]\'))')) { + const calls = evaluate.mock.calls.filter(([script]) => + typeof script === 'string' && script.includes('const hrefs = Array.from(document.querySelectorAll(\'a[href*="/p/"]\'))'), + ).length; + return calls === 1 + ? { ok: true, hrefs: ['/tsezi_ray/p/PINNED111/', '/tsezi_ray/p/OLD222/'] } + : { ok: true, hrefs: ['/tsezi_ray/p/PINNED111/', '/tsezi_ray/p/OLD222/', '/tsezi_ray/p/RECOVER123/'] }; + } + if (js.includes('document.documentElement?.outerHTML')) { + return { + appId: 'dynamic-runtime-app-id', + csrfToken: 'csrf-token', + instagramAjax: 'dynamic-rollout', + }; + } + return { ok: true }; + }); + const page = createPageMock([], { + evaluate, + getCookies: vi.fn().mockResolvedValue([{ name: 'ds_user_id', value: '61236465677', domain: 'instagram.com' }]), + }); + + const cmd = getRegistry().get('instagram/post'); + const result = await cmd!.func!(page, { + media: imagePath, + content: 'url recovery', + }); + + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/tsezi_ray/p/RECOVER123/', + }, + ]); + }); + + it('treats a closed composer as a successful share and falls back to URL recovery', async () => { + const imagePath = createTempImage('share-settled.jpg'); + const page = createPageMock([ + { appId: 'dynamic-runtime-app-id', csrfToken: 'csrf-token', instagramAjax: 'dynamic-rollout' }, + { ok: true, username: 'tsezi_ray' }, + { ok: true, hrefs: ['/p/OLD111/'] }, + { ok: false }, + { ok: true }, + { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }, + { ok: true }, + { ok: true }, + { ok: false }, + { ok: true, label: 'Next' }, + { ok: true }, + { ok: true }, + { ok: true }, + { ok: true, label: 'Share' }, + { ok: false, failed: false, settled: true, url: '' }, + { ok: false, failed: false, settled: true, url: '' }, + { ok: false, failed: false, settled: true, url: '' }, + { appId: 'dynamic-runtime-app-id', csrfToken: 'csrf-token', instagramAjax: 'dynamic-rollout' }, + { ok: true, username: 'tsezi_ray' }, + { ok: true, hrefs: ['/p/OLD111/', '/p/RECOVER789/'] }, + ], { + getCookies: vi.fn().mockResolvedValue([{ name: 'ds_user_id', value: '61236465677', domain: 'instagram.com' }]), + }); + + const cmd = getRegistry().get('instagram/post'); + const result = await cmd!.func!(page, { + media: imagePath, + content: 'share settled recovery', + }); + + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/RECOVER789/', + }, + ]); + }); + + it('accepts standard /p/... profile links during URL recovery', async () => { + const imagePath = createTempImage('url-recovery-standard-shape.jpg'); + const page = createPageMock([ + { appId: 'dynamic-runtime-app-id', csrfToken: 'csrf-token', instagramAjax: 'dynamic-rollout' }, + { ok: true, username: 'tsezi_ray' }, + { ok: true, hrefs: ['/p/PINNED111/', '/p/OLD222/'] }, + { ok: false }, + { ok: true }, + { ok: true, selectors: ['[data-opencli-ig-upload-index="0"]'] }, + { ok: true }, + { ok: true }, + { ok: false }, + { ok: true, label: 'Next' }, + { ok: true }, + { ok: true }, + { ok: true }, + { ok: true, label: 'Share' }, + { ok: true, url: '' }, + { appId: 'dynamic-runtime-app-id', csrfToken: 'csrf-token', instagramAjax: 'dynamic-rollout' }, + { ok: true, username: 'tsezi_ray' }, + { ok: true, hrefs: ['/p/PINNED111/', '/p/OLD222/', '/p/RECOVER456/'] }, + ], { + getCookies: vi.fn().mockResolvedValue([{ name: 'ds_user_id', value: '61236465677', domain: 'instagram.com' }]), + }); + + const cmd = getRegistry().get('instagram/post'); + const result = await cmd!.func!(page, { + media: imagePath, + content: 'url recovery standard shape', + }); + + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single image post shared successfully', + url: 'https://www.instagram.com/p/RECOVER456/', + }, + ]); + }); +}); diff --git a/src/clis/instagram/post.ts b/src/clis/instagram/post.ts new file mode 100644 index 00000000..a8eccbb3 --- /dev/null +++ b/src/clis/instagram/post.ts @@ -0,0 +1,1620 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { cli, Strategy } from '../../registry.js'; +import { ArgumentError, AuthRequiredError, CommandExecutionError } from '../../errors.js'; +import type { IPage } from '../../types.js'; +import { + installInstagramProtocolCapture, + readInstagramProtocolCapture, + type InstagramProtocolCaptureEntry, +} from './_shared/protocol-capture.js'; +import { + publishMediaViaPrivateApi, + publishImagesViaPrivateApi, + resolveInstagramPrivatePublishConfig, +} from './_shared/private-publish.js'; +import { resolveInstagramRuntimeInfo } from './_shared/runtime-info.js'; + +const INSTAGRAM_HOME_URL = 'https://www.instagram.com/'; +const SUPPORTED_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']); +const SUPPORTED_VIDEO_EXTENSIONS = new Set(['.mp4']); +const MAX_MEDIA_ITEMS = 10; +const INSTAGRAM_PROTOCOL_TRACE_OUTPUT_PATH = '/tmp/instagram_post_protocol_trace.json'; + +type InstagramProtocolDrain = () => Promise; +type InstagramSuccessRow = { + status: string; + detail: string; + url: string; +}; +type InstagramPostMediaItem = { + type: 'image' | 'video'; + filePath: string; +}; + +async function gotoInstagramHome(page: IPage, forceReload = false): Promise { + if (forceReload) { + await page.goto(`${INSTAGRAM_HOME_URL}?__opencli_reset=${Date.now()}`); + await page.wait({ time: 1 }); + } + await page.goto(INSTAGRAM_HOME_URL); +} + +export function buildEnsureComposerOpenJs(): string { + return ` + (() => { + const path = window.location?.pathname || ''; + const onLoginRoute = /\\/accounts\\/login\\/?/.test(path); + const hasLoginField = !!document.querySelector('input[name="username"], input[name="password"]'); + const hasLoginButton = Array.from(document.querySelectorAll('button, div[role="button"]')).some((el) => { + const text = (el.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + return text === 'log in' || text === 'login' || text === '登录'; + }); + + if (onLoginRoute || (hasLoginField && hasLoginButton)) { + return { ok: false, reason: 'auth' }; + } + + const alreadyOpen = document.querySelector('input[type="file"]'); + if (alreadyOpen) return { ok: true }; + + const labels = ['Create', 'New post', 'Post', '创建', '新帖子']; + const nodes = Array.from(document.querySelectorAll('a, button, div[role="button"], svg[aria-label], [aria-label]')); + for (const node of nodes) { + const text = ((node.textContent || '') + ' ' + (node.getAttribute?.('aria-label') || '')).trim(); + if (labels.some((label) => text.toLowerCase().includes(label.toLowerCase()))) { + const clickable = node.closest('a, button, div[role="button"]') || node; + if (clickable instanceof HTMLElement) { + clickable.click(); + return { ok: true }; + } + } + } + + return { ok: true }; + })() + `; +} + +export function buildPublishStatusProbeJs(): string { + return ` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el)); + const dialogText = dialogs + .map((el) => (el.textContent || '').replace(/\\s+/g, ' ').trim()) + .join(' '); + const url = window.location.href; + const visibleText = dialogText.toLowerCase(); + const sharingVisible = /sharing/.test(visibleText); + const shared = /post shared|your post has been shared|已分享|已发布/.test(visibleText) + || /\\/p\\//.test(url); + const failed = !shared && !sharingVisible && ( + /couldn['’]t be shared|could not be shared|failed to share|share failed|无法分享|分享失败/.test(visibleText) + || (/something went wrong/.test(visibleText) && /try again/.test(visibleText)) + ); + const composerOpen = dialogs.some((dialog) => + !!dialog.querySelector('textarea, [contenteditable="true"], input[type="file"]') + || /write a caption|add location|advanced settings|select from computer|crop|filters|adjustments|sharing/.test((dialog.textContent || '').toLowerCase()) + ); + const settled = !shared && !composerOpen && !/sharing/.test(visibleText); + return { ok: shared, failed, settled, url: /\\/p\\//.test(url) ? url : '' }; + })() + `; +} + +function requirePage(page: IPage | null): IPage { + if (!page) throw new CommandExecutionError('Browser session required for instagram post'); + return page; +} + +function validateMixedMediaItems(inputs: string[]): InstagramPostMediaItem[] { + if (!inputs.length) { + throw new ArgumentError( + 'Argument "media" is required.', + 'Provide --media /path/to/file.jpg or --media /path/a.jpg,/path/b.mp4', + ); + } + if (inputs.length > MAX_MEDIA_ITEMS) { + throw new ArgumentError(`Too many media items: ${inputs.length}`, `Instagram carousel posts support at most ${MAX_MEDIA_ITEMS} items`); + } + + const items = inputs.map((input) => { + const resolved = path.resolve(String(input || '').trim()); + if (!resolved) { + throw new ArgumentError('Media path cannot be empty'); + } + if (!fs.existsSync(resolved)) { + throw new ArgumentError(`Media file not found: ${resolved}`); + } + const ext = path.extname(resolved).toLowerCase(); + if (SUPPORTED_IMAGE_EXTENSIONS.has(ext)) { + return { type: 'image' as const, filePath: resolved }; + } + if (SUPPORTED_VIDEO_EXTENSIONS.has(ext)) { + return { type: 'video' as const, filePath: resolved }; + } + throw new ArgumentError(`Unsupported media format: ${ext}`, 'Supported formats: images (.jpg, .jpeg, .png, .webp) and videos (.mp4)'); + }); + + return items; +} + +function normalizePostMediaItems(kwargs: Record): InstagramPostMediaItem[] { + const media = String(kwargs.media ?? '').trim(); + return validateMixedMediaItems(media.split(',').map((part) => part.trim()).filter(Boolean)); +} + +function validateInstagramPostArgs(kwargs: Record): void { + const media = kwargs.media; + if (media === undefined) { + throw new ArgumentError( + 'Argument "media" is required.', + 'Provide --media /path/to/file.jpg or --media /path/a.jpg,/path/b.mp4', + ); + } +} + +function isSafePrivateRouteFallbackError(error: unknown): boolean { + if (!(error instanceof CommandExecutionError)) return false; + return error.message.startsWith('Instagram private publish') + || error.message.startsWith('Instagram private route'); +} + +function buildInstagramSuccessResult(mediaItems: InstagramPostMediaItem[], url: string): InstagramSuccessRow[] { + return [{ + status: '✅ Posted', + detail: describePostDetail(mediaItems), + url, + }]; +} + +function buildFallbackHint(privateError: unknown, uiError: unknown): string { + const privateMessage = privateError instanceof Error ? privateError.message : String(privateError); + const uiMessage = uiError instanceof Error ? uiError.message : String(uiError); + return `Private route failed first: ${privateMessage}. UI fallback then failed: ${uiMessage}`; +} + +async function executePrivateInstagramPost(input: { + page: IPage; + mediaItems: InstagramPostMediaItem[]; + content: string; + existingPostPaths: Set; +}): Promise { + const privateConfig = await resolveInstagramPrivatePublishConfig(input.page); + const privateResult = input.mediaItems.every((item) => item.type === 'image') + ? await publishImagesViaPrivateApi({ + page: input.page, + imagePaths: input.mediaItems.map((item) => item.filePath), + caption: input.content, + apiContext: privateConfig.apiContext, + jazoest: privateConfig.jazoest, + }) + : await publishMediaViaPrivateApi({ + page: input.page, + mediaItems: input.mediaItems, + caption: input.content, + apiContext: privateConfig.apiContext, + jazoest: privateConfig.jazoest, + }); + const url = privateResult.code + ? new URL(`/p/${privateResult.code}/`, INSTAGRAM_HOME_URL).toString() + : await resolveLatestPostUrl(input.page, input.existingPostPaths); + return buildInstagramSuccessResult(input.mediaItems, url); +} + +async function executeUiInstagramPost(input: { + page: IPage; + mediaItems: InstagramPostMediaItem[]; + content: string; + existingPostPaths: Set; + commandAttemptBudget: number; + preUploadDelaySeconds: number; + uploadAttemptBudget: number; + previewProbeWindowSeconds: number; + finalPreviewWaitSeconds: number; + preShareDelaySeconds: number; + inlineUploadRetryBudget: number; + installProtocolCapture: () => Promise; + drainProtocolCapture: InstagramProtocolDrain; + forceFreshStart?: boolean; +}): Promise { + let lastError: unknown; + let lastSpecificCommandError: CommandExecutionError | null = null; + for (let attempt = 0; attempt < input.commandAttemptBudget; attempt++) { + let shareClicked = false; + try { + await gotoInstagramHome(input.page, input.forceFreshStart || attempt > 0); + await input.installProtocolCapture(); + await input.page.wait({ time: 2 }); + await dismissResidualDialogs(input.page); + + await ensureComposerOpen(input.page); + const uploadSelectors = await resolveUploadSelectors(input.page, input.mediaItems); + if (input.preUploadDelaySeconds > 0) { + await input.page.wait({ time: input.preUploadDelaySeconds }); + } + let uploaded = false; + let uploadFailure: CommandExecutionError | null = null; + for (const selector of uploadSelectors) { + let activeSelector = selector; + for (let uploadAttempt = 0; uploadAttempt < input.uploadAttemptBudget; uploadAttempt++) { + await uploadMedia(input.page, input.mediaItems, activeSelector); + const uploadState = await waitForPreviewMaybe(input.page, input.previewProbeWindowSeconds); + if (uploadState.state === 'preview') { + uploaded = true; + break; + } + if (uploadState.state === 'failed') { + uploadFailure = makeUploadFailure(uploadState.detail); + for (let inlineRetry = 0; inlineRetry < input.inlineUploadRetryBudget; inlineRetry++) { + const clickedRetry = await clickVisibleUploadRetry(input.page); + if (!clickedRetry) break; + await input.page.wait({ time: 3 }); + const retriedState = await waitForPreviewMaybe(input.page, Math.max(3, Math.floor(input.previewProbeWindowSeconds / 2))); + if (retriedState.state === 'preview') { + uploaded = true; + break; + } + if (retriedState.state !== 'failed') break; + } + if (uploaded) break; + await dismissUploadErrorDialog(input.page); + await dismissResidualDialogs(input.page); + if (uploadAttempt < input.uploadAttemptBudget - 1) { + try { + await input.drainProtocolCapture(); + await gotoInstagramHome(input.page, true); + await input.installProtocolCapture(); + await input.page.wait({ time: 2 }); + await dismissResidualDialogs(input.page); + await ensureComposerOpen(input.page); + activeSelector = await resolveFreshUploadSelector(input.page, activeSelector, input.mediaItems); + if (input.preUploadDelaySeconds > 0) { + await input.page.wait({ time: input.preUploadDelaySeconds }); + } + } catch { + throw uploadFailure; + } + await input.page.wait({ time: 1.5 }); + continue; + } + break; + } + break; + } + if (uploaded) break; + } + if (!uploaded) { + if (uploadFailure) throw uploadFailure; + await waitForPreview(input.page, input.finalPreviewWaitSeconds); + } + try { + await advanceToCaptionEditor(input.page); + } catch (error) { + await rethrowUploadFailureIfPresent(input.page, error); + } + if (input.content) { + await fillCaption(input.page, input.content); + await ensureCaptionFilled(input.page, input.content); + } + if (input.preShareDelaySeconds > 0) { + await input.page.wait({ time: input.preShareDelaySeconds }); + } + await clickAction(input.page, ['Share', '分享'], 'caption'); + shareClicked = true; + let url = ''; + try { + url = await waitForPublishSuccess(input.page); + } catch (error) { + if ( + error instanceof CommandExecutionError + && error.message === 'Instagram post share failed' + && await clickVisibleShareRetry(input.page) + ) { + await input.page.wait({ time: Math.max(2, input.preShareDelaySeconds) }); + url = await waitForPublishSuccess(input.page); + } else { + throw error; + } + } + await input.drainProtocolCapture(); + if (!url) { + url = await resolveLatestPostUrl(input.page, input.existingPostPaths); + } + + return buildInstagramSuccessResult(input.mediaItems, url); + } catch (error) { + lastError = error; + if (error instanceof CommandExecutionError && error.message !== 'Failed to open Instagram post composer') { + lastSpecificCommandError = error; + } + if (error instanceof AuthRequiredError) throw error; + if (shareClicked) { + throw error; + } + if (!(error instanceof CommandExecutionError) || attempt === input.commandAttemptBudget - 1) { + if (error instanceof CommandExecutionError && error.message === 'Failed to open Instagram post composer' && lastSpecificCommandError) { + throw lastSpecificCommandError; + } + throw error; + } + let resetWindow = false; + if (input.mediaItems.length >= 10 && input.page.closeWindow) { + try { + await input.drainProtocolCapture(); + await input.page.closeWindow(); + resetWindow = true; + } catch { + // Best-effort: a fresh automation window is safer than reusing a polluted one. + } + } + if (!resetWindow) { + await dismissResidualDialogs(input.page); + await input.page.wait({ time: 1 }); + } + } + } + + throw lastError instanceof Error ? lastError : new CommandExecutionError('Instagram post failed'); +} + +async function ensureComposerOpen(page: IPage): Promise { + const result = await page.evaluate(buildEnsureComposerOpenJs()) as { ok?: boolean; reason?: string }; + + if (!result?.ok) { + if (result?.reason === 'auth') throw new AuthRequiredError('www.instagram.com', 'Instagram login required before posting'); + throw new CommandExecutionError('Failed to open Instagram post composer'); + } +} + +async function dismissResidualDialogs(page: IPage): Promise { + for (let attempt = 0; attempt < 4; attempt++) { + const result = await page.evaluate(` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + + const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')) + .filter((el) => el instanceof HTMLElement && isVisible(el)); + for (const dialog of dialogs) { + const text = (dialog.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + if (!text) continue; + if ( + text.includes('post shared') + || text.includes('your post has been shared') + || text.includes('something went wrong') + || text.includes('sharing') + || text.includes('create new post') + || text.includes('crop') + || text.includes('edit') + ) { + const close = dialog.querySelector('[aria-label="Close"], button[aria-label="Close"], div[role="button"][aria-label="Close"]'); + if (close instanceof HTMLElement && isVisible(close)) { + close.click(); + return { ok: true }; + } + const closeByText = Array.from(dialog.querySelectorAll('button, div[role="button"]')).find((el) => { + const buttonText = (el.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + return isVisible(el) && (buttonText === 'close' || buttonText === 'cancel' || buttonText === '取消'); + }); + if (closeByText instanceof HTMLElement) { + closeByText.click(); + return { ok: true }; + } + } + } + + return { ok: false }; + })() + `) as { ok?: boolean }; + + if (!result?.ok) return; + await page.wait({ time: 0.5 }); + } +} + +async function findUploadSelectors(page: IPage, mediaItems: InstagramPostMediaItem[]): Promise { + const includesVideo = mediaItems.some((item) => item.type === 'video'); + const result = await page.evaluate(` + ((includesVideo) => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + const hasButtonText = (root, labels) => { + if (!root || !(root instanceof Element)) return false; + return Array.from(root.querySelectorAll('button, div[role="button"], span')) + .some((el) => { + const text = (el.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + return labels.some((label) => text === label.toLowerCase()); + }); + }; + + const inputs = Array.from(document.querySelectorAll('input[type="file"]')); + const candidates = inputs.filter((el) => { + if (!(el instanceof HTMLInputElement)) return false; + if (el.disabled) return false; + const accept = (el.getAttribute('accept') || '').toLowerCase(); + if (!accept) return true; + if (includesVideo) return true; + return accept.includes('image') || accept.includes('.jpg') || accept.includes('.jpeg') || accept.includes('.png') || accept.includes('.webp'); + }); + + const dialogInputs = candidates.filter((el) => { + const dialog = el.closest('[role="dialog"]'); + return hasButtonText(dialog, ['Select from computer', '从电脑中选择']); + }); + + const visibleDialogInputs = dialogInputs.filter((el) => { + const dialog = el.closest('[role="dialog"]'); + return dialog instanceof HTMLElement && isVisible(dialog); + }); + + const pickerInputs = candidates.filter((el) => { + return hasButtonText(el.parentElement, ['Select from computer', '从电脑中选择']); + }); + + const primary = visibleDialogInputs.length + ? [visibleDialogInputs[visibleDialogInputs.length - 1]] + : dialogInputs.length + ? [dialogInputs[dialogInputs.length - 1]] + : []; + const ordered = [...primary, ...pickerInputs, ...candidates] + .filter((el, index, arr) => arr.indexOf(el) === index); + if (!ordered.length) return { ok: false }; + + document.querySelectorAll('[data-opencli-ig-upload-index]').forEach((el) => el.removeAttribute('data-opencli-ig-upload-index')); + const selectors = ordered.map((input, index) => { + input.setAttribute('data-opencli-ig-upload-index', String(index)); + return '[data-opencli-ig-upload-index="' + index + '"]'; + }); + return { ok: true, selectors }; + })(${JSON.stringify(includesVideo)}) + `) as { ok?: boolean; selectors?: string[] }; + + if (!result?.ok || !result.selectors?.length) { + throw new CommandExecutionError('Instagram upload input not found', 'Open the new-post composer in a logged-in browser session and retry'); + } + return result.selectors; +} + +async function resolveUploadSelectors(page: IPage, mediaItems: InstagramPostMediaItem[]): Promise { + try { + return await findUploadSelectors(page, mediaItems); + } catch (error) { + if (!(error instanceof CommandExecutionError) || !error.message.includes('upload input not found')) { + throw error; + } + + await ensureComposerOpen(page); + await page.wait({ time: 1.5 }); + + try { + return await findUploadSelectors(page, mediaItems); + } catch (retryError) { + if (!(retryError instanceof CommandExecutionError) || !retryError.message.includes('upload input not found')) { + throw retryError; + } + + await gotoInstagramHome(page, true); + await page.wait({ time: 2 }); + await dismissResidualDialogs(page); + await ensureComposerOpen(page); + await page.wait({ time: 2 }); + return findUploadSelectors(page, mediaItems); + } + } +} + +function extractSelectorIndex(selector: string): number | null { + const match = selector.match(/data-opencli-ig-upload-index="(\d+)"/); + if (!match) return null; + const index = Number.parseInt(match[1] || '', 10); + return Number.isNaN(index) ? null : index; +} + +async function resolveFreshUploadSelector(page: IPage, previousSelector: string, mediaItems: InstagramPostMediaItem[]): Promise { + const selectors = await resolveUploadSelectors(page, mediaItems); + const index = extractSelectorIndex(previousSelector); + if (index !== null && selectors[index]) return selectors[index]!; + return selectors[0] || previousSelector; +} + +async function injectImageViaBrowser(page: IPage, imagePaths: string[], selector: string): Promise { + const images = imagePaths.map((imagePath) => { + const ext = path.extname(imagePath).toLowerCase(); + const mimeType = ext === '.png' + ? 'image/png' + : ext === '.webp' + ? 'image/webp' + : 'image/jpeg'; + + return { + name: path.basename(imagePath), + type: mimeType, + base64: fs.readFileSync(imagePath).toString('base64'), + }; + }); + const chunkKey = `__opencliInstagramUpload_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const chunkSize = 256 * 1024; + + await page.evaluate(` + (() => { + window[${JSON.stringify(chunkKey)}] = []; + return { ok: true }; + })() + `); + + const payload = JSON.stringify(images); + for (let offset = 0; offset < payload.length; offset += chunkSize) { + const chunk = payload.slice(offset, offset + chunkSize); + await page.evaluate(` + (() => { + const key = ${JSON.stringify(chunkKey)}; + const chunk = ${JSON.stringify(chunk)}; + const parts = Array.isArray(window[key]) ? window[key] : []; + parts.push(chunk); + window[key] = parts; + return { ok: true, count: parts.length }; + })() + `); + } + + const result = await page.evaluate(` + (() => { + const selector = ${JSON.stringify(selector)}; + const key = ${JSON.stringify(chunkKey)}; + const payload = JSON.parse(Array.isArray(window[key]) ? window[key].join('') : '[]'); + + const cleanup = () => { try { delete window[key]; } catch {} }; + const input = document.querySelector(selector); + if (!(input instanceof HTMLInputElement)) { + cleanup(); + return { ok: false, error: 'File input not found for fallback injection' }; + } + + try { + const dt = new DataTransfer(); + for (const img of payload) { + const binary = atob(img.base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + const blob = new Blob([bytes], { type: img.type }); + const file = new File([blob], img.name, { type: img.type }); + dt.items.add(file); + } + Object.defineProperty(input, 'files', { value: dt.files, configurable: true }); + input.dispatchEvent(new Event('change', { bubbles: true })); + input.dispatchEvent(new Event('input', { bubbles: true })); + cleanup(); + return { ok: true, count: dt.files.length }; + } catch (error) { + cleanup(); + return { ok: false, error: String(error) }; + } + })() + `) as { ok?: boolean; error?: string }; + + if (!result?.ok) { + throw new CommandExecutionError(result?.error || 'Instagram fallback file injection failed'); + } +} + +async function dispatchUploadEvents(page: IPage, selector: string): Promise { + await page.evaluate(` + (() => { + const input = document.querySelector(${JSON.stringify(selector)}); + if (!(input instanceof HTMLInputElement)) return { ok: false }; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + return { ok: true }; + })() + `); +} + +type UploadStageState = { + state: 'preview' | 'failed' | 'pending'; + detail?: string; +}; + +export function buildInspectUploadStageJs(): string { + return ` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el)); + const visibleTexts = dialogs.map((el) => (el.textContent || '').replace(/\\s+/g, ' ').trim()); + const dialogText = visibleTexts.join(' '); + const combined = dialogText.toLowerCase(); + const hasVisibleButtonInDialogs = (labels) => { + return dialogs.some((dialog) => + Array.from(dialog.querySelectorAll('button, div[role="button"]')).some((el) => { + const text = (el.textContent || '').replace(/\\s+/g, ' ').trim(); + const aria = (el.getAttribute?.('aria-label') || '').replace(/\\s+/g, ' ').trim(); + return isVisible(el) && (labels.includes(text) || labels.includes(aria)); + }) + ); + }; + const hasCaption = dialogs.some((dialog) => !!dialog.querySelector('textarea, [contenteditable="true"]')); + const hasPicker = hasVisibleButtonInDialogs(['Select from computer', '从电脑中选择']); + const hasNext = hasVisibleButtonInDialogs(['Next', '下一步']); + const hasPreviewUi = hasCaption + || (!hasPicker && hasNext) + || /crop|select crop|select zoom|open media gallery|filters|adjustments|裁剪|缩放|滤镜|调整/.test(combined); + const failed = /something went wrong|please try again|couldn['’]t upload|could not upload|upload failed|try again|出错|失败/.test(combined); + if (hasPreviewUi) return { state: 'preview', detail: dialogText || '' }; + if (failed) return { state: 'failed', detail: dialogText || 'Something went wrong' }; + return { state: 'pending', detail: dialogText || '' }; + })() + `; +} + +async function inspectUploadStage(page: IPage): Promise { + const result = await page.evaluate(buildInspectUploadStageJs()) as UploadStageState & { ok?: boolean }; + + if (result?.state) return result; + if (result?.ok === true) return { state: 'preview', detail: result.detail }; + return { state: 'pending', detail: result?.detail }; +} + +function makeUploadFailure(detail?: string): CommandExecutionError { + return new CommandExecutionError( + 'Instagram image upload failed', + detail ? `Instagram rejected the upload: ${detail}` : 'Instagram rejected the upload before the preview stage', + ); +} + +async function uploadMedia(page: IPage, mediaItems: InstagramPostMediaItem[], selector: string): Promise { + const mediaPaths = mediaItems.map((item) => item.filePath); + if (!page.setFileInput) { + throw new CommandExecutionError( + 'Instagram posting requires Browser Bridge file upload support', + 'Use Browser Bridge or another browser mode that supports setFileInput', + ); + } + + let activeSelector = selector; + for (let attempt = 0; attempt < 2; attempt++) { + try { + await page.setFileInput(mediaPaths, activeSelector); + await dispatchUploadEvents(page, activeSelector); + return; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const staleSelector = message.includes('No element found matching selector') + || message.includes('Could not find node with given id') + || message.includes('No node with given id found'); + if (staleSelector && attempt === 0) { + activeSelector = await resolveFreshUploadSelector(page, activeSelector, mediaItems); + continue; + } + if (!message.includes('Unknown action') && !message.includes('set-file-input') && !message.includes('not supported')) { + throw error; + } + if (mediaItems.some((item) => item.type === 'video')) { + throw new CommandExecutionError( + 'Instagram mixed-media posting requires Browser Bridge file upload support', + 'Use Browser Bridge or another browser mode that supports setFileInput for image/video uploads', + ); + } + await injectImageViaBrowser(page, mediaPaths, activeSelector); + return; + } + } +} + +function describePostDetail(mediaItems: InstagramPostMediaItem[]): string { + if (mediaItems.every((item) => item.type === 'image')) { + return mediaItems.length === 1 + ? 'Single image post shared successfully' + : `${mediaItems.length}-image carousel post shared successfully`; + } + return mediaItems.length === 1 + ? 'Single mixed-media post shared successfully' + : `${mediaItems.length}-item mixed-media carousel post shared successfully`; +} + +function getCommandAttemptBudget(mediaItems: InstagramPostMediaItem[]): number { + if (mediaItems.length >= 10) return 6; + if (mediaItems.length >= 5) return 4; + return 3; +} + +function getPreUploadDelaySeconds(mediaItems: InstagramPostMediaItem[]): number { + if (mediaItems.length >= 10) return 3; + if (mediaItems.length >= 5) return 1.5; + return 0; +} + +function getUploadAttemptBudget(mediaItems: InstagramPostMediaItem[]): number { + if (mediaItems.length >= 10) return 3; + if (mediaItems.length >= 5) return 3; + return 2; +} + +function getPreviewProbeWindowSeconds(mediaItems: InstagramPostMediaItem[]): number { + if (mediaItems.length >= 10) return 6; + if (mediaItems.length >= 5) return 6; + return 4; +} + +function getFinalPreviewWaitSeconds(mediaItems: InstagramPostMediaItem[]): number { + if (mediaItems.length >= 10) return 12; + if (mediaItems.length >= 5) return 16; + return 12; +} + +function getPreShareDelaySeconds(mediaItems: InstagramPostMediaItem[]): number { + if (mediaItems.length >= 10) return 4; + if (mediaItems.length >= 5) return 3; + return 0; +} + +function getInlineUploadRetryBudget(mediaItems: InstagramPostMediaItem[]): number { + if (mediaItems.length >= 10) return 3; + if (mediaItems.length >= 5) return 2; + return 1; +} + +async function dismissUploadErrorDialog(page: IPage): Promise { + const result = await page.evaluate(` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el)); + for (const dialog of dialogs) { + const text = (dialog.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + if (!text.includes('something went wrong') && !text.includes('try again') && !text.includes('失败') && !text.includes('出错')) continue; + const close = dialog.querySelector('[aria-label="Close"], button[aria-label="Close"], div[role="button"][aria-label="Close"]'); + if (close instanceof HTMLElement && isVisible(close)) { + close.click(); + return { ok: true }; + } + } + return { ok: false }; + })() + `) as { ok?: boolean }; + + return !!result?.ok; +} + +async function clickVisibleUploadRetry(page: IPage): Promise { + const result = await page.evaluate(` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el)); + for (const dialog of dialogs) { + const text = (dialog.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + if (!text.includes('something went wrong') && !text.includes('try again') && !text.includes('失败') && !text.includes('出错')) continue; + const retry = Array.from(dialog.querySelectorAll('button, div[role="button"]')).find((el) => { + const label = ((el.textContent || '') + ' ' + (el.getAttribute?.('aria-label') || '')) + .replace(/\\s+/g, ' ') + .trim() + .toLowerCase(); + return isVisible(el) && ( + label === 'try again' + || label === 'retry' + || label === '再试一次' + || label === '重试' + ); + }); + if (retry instanceof HTMLElement) { + retry.click(); + return { ok: true }; + } + } + return { ok: false }; + })() + `) as { ok?: boolean }; + + return !!result?.ok; +} + +async function waitForPreview(page: IPage, maxWaitSeconds = 12): Promise { + const attempts = Math.max(1, Math.ceil(maxWaitSeconds)); + for (let attempt = 0; attempt < attempts; attempt++) { + const state = await inspectUploadStage(page); + if (state.state === 'preview') return; + if (state.state === 'failed') { + await page.screenshot({ path: '/tmp/instagram_post_preview_debug.png' }); + throw makeUploadFailure('Inspect /tmp/instagram_post_preview_debug.png. ' + (state.detail || '')); + } + if (attempt < attempts - 1) await page.wait({ time: 1 }); + } + + await page.screenshot({ path: '/tmp/instagram_post_preview_debug.png' }); + throw new CommandExecutionError( + 'Instagram image preview did not appear after upload', + 'The selected file input may not match the active composer; inspect /tmp/instagram_post_preview_debug.png', + ); +} + +async function waitForPreviewMaybe(page: IPage, maxWaitSeconds = 4): Promise { + const attempts = Math.max(1, Math.ceil(maxWaitSeconds * 2)); + for (let attempt = 0; attempt < attempts; attempt++) { + const state = await inspectUploadStage(page); + if (state.state !== 'pending') return state; + if (attempt < attempts - 1) await page.wait({ time: 0.5 }); + } + return { state: 'pending' }; +} + +export function buildClickActionJs(labels: string[], scope: 'any' | 'media' | 'caption' = 'any'): string { + return ` + ((labels, scope) => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + + const matchesScope = (dialog) => { + if (!(dialog instanceof HTMLElement) || !isVisible(dialog)) return false; + const text = (dialog.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + if (scope === 'caption') { + return !!dialog.querySelector('textarea, [contenteditable="true"]') + || text.includes('write a caption') + || text.includes('add location') + || text.includes('add collaborators') + || text.includes('accessibility') + || text.includes('advanced settings'); + } + if (scope === 'media') { + return !!dialog.querySelector('input[type="file"]') + || text.includes('select from computer') + || text.includes('crop') + || text.includes('filters') + || text.includes('adjustments') + || text.includes('open media gallery') + || text.includes('select crop') + || text.includes('select zoom'); + } + return true; + }; + + const containers = scope !== 'any' + ? Array.from(document.querySelectorAll('[role="dialog"]')).filter(matchesScope) + : [document.body]; + + for (const container of containers) { + const nodes = Array.from(container.querySelectorAll('button, div[role="button"]')); + for (const node of nodes) { + const text = (node.textContent || '').replace(/\\s+/g, ' ').trim(); + const aria = (node.getAttribute?.('aria-label') || '').replace(/\\s+/g, ' ').trim(); + if (!text && !aria) continue; + if (!labels.includes(text) && !labels.includes(aria)) continue; + if (node instanceof HTMLElement && isVisible(node) && node.getAttribute('aria-disabled') !== 'true') { + node.click(); + return { ok: true, label: text || aria }; + } + } + } + return { ok: false }; + })(${JSON.stringify(labels)}, ${JSON.stringify(scope)}) + `; +} + +async function clickAction(page: IPage, labels: string[], scope: 'any' | 'media' | 'caption' = 'any'): Promise { + const result = await page.evaluate(buildClickActionJs(labels, scope)) as { ok?: boolean; label?: string }; + + if (!result?.ok) { + throw new CommandExecutionError(`Instagram action button not found: ${labels.join(' / ')}`); + } + return result.label || labels[0]!; +} + +async function clickVisibleShareRetry(page: IPage): Promise { + const result = await page.evaluate(` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + + const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el)); + for (const dialog of dialogs) { + const text = (dialog.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + if (!text.includes('post couldn') && !text.includes('could not be shared') && !text.includes('share failed')) continue; + + const retry = Array.from(dialog.querySelectorAll('button, div[role="button"]')).find((el) => { + const label = ((el.textContent || '') + ' ' + (el.getAttribute?.('aria-label') || '')) + .replace(/\\s+/g, ' ') + .trim() + .toLowerCase(); + return isVisible(el) && ( + label === 'try again' + || label === 'retry' + || label === '再试一次' + || label === '重试' + ); + }); + + if (retry instanceof HTMLElement) { + retry.click(); + return { ok: true }; + } + } + + return { ok: false }; + })() + `) as { ok?: boolean }; + + return !!result?.ok; +} + +async function hasCaptionEditor(page: IPage): Promise { + const result = await page.evaluate(` + (() => { + const editable = document.querySelector('textarea, [contenteditable="true"]'); + return { ok: !!editable }; + })() + `) as { ok?: boolean }; + + return !!result?.ok; +} + +async function isCaptionStage(page: IPage): Promise { + const result = await page.evaluate(` + (() => { + const editable = document.querySelector('textarea, [contenteditable="true"]'); + const dialogText = Array.from(document.querySelectorAll('[role="dialog"]')) + .map((el) => (el.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase()) + .join(' '); + return { + ok: !!editable + || dialogText.includes('write a caption') + || dialogText.includes('add location') + || dialogText.includes('add collaborators') + || dialogText.includes('advanced settings'), + }; + })() + `) as { ok?: boolean }; + + return !!result?.ok; +} + +async function advanceToCaptionEditor(page: IPage): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + if (await isCaptionStage(page)) { + return; + } + try { + await clickAction(page, ['Next', '下一步'], 'media'); + } catch (error) { + if (error instanceof CommandExecutionError) { + await page.wait({ time: 1.5 }); + if (await isCaptionStage(page)) { + return; + } + const uploadState = await inspectUploadStage(page); + if (uploadState.state === 'failed') { + throw makeUploadFailure(uploadState.detail); + } + if (attempt < 2) { + continue; + } + } + throw error; + } + await page.wait({ time: 1.5 }); + if (await hasCaptionEditor(page)) { + return; + } + const uploadState = await inspectUploadStage(page); + if (uploadState.state === 'failed') { + throw makeUploadFailure(uploadState.detail); + } + } + + await page.screenshot({ path: '/tmp/instagram_post_caption_debug.png' }); + throw new CommandExecutionError( + 'Instagram caption editor did not appear', + 'Instagram may have changed the publish flow; inspect /tmp/instagram_post_caption_debug.png', + ); +} + +async function waitForCaptionEditor(page: IPage): Promise { + if (!(await hasCaptionEditor(page))) { + await page.screenshot({ path: '/tmp/instagram_post_caption_debug.png' }); + throw new CommandExecutionError( + 'Instagram caption editor did not appear', + 'Instagram may have changed the publish flow; inspect /tmp/instagram_post_caption_debug.png', + ); + } +} + +async function rethrowUploadFailureIfPresent(page: IPage, originalError: unknown): Promise { + const uploadState = await inspectUploadStage(page); + if (uploadState.state === 'failed') { + throw makeUploadFailure(uploadState.detail); + } + throw originalError; +} + +async function focusCaptionEditorForNativeInsert(page: IPage): Promise { + const result = await page.evaluate(` + (() => { + const textarea = document.querySelector('[aria-label="Write a caption..."], textarea'); + if (textarea instanceof HTMLTextAreaElement) { + textarea.focus(); + textarea.select(); + return { ok: true, kind: 'textarea' }; + } + + const editor = document.querySelector('[aria-label="Write a caption..."][contenteditable="true"]') + || document.querySelector('[contenteditable="true"]'); + if (!(editor instanceof HTMLElement)) return { ok: false }; + + const lexical = editor.__lexicalEditor; + try { + if (lexical && typeof lexical.getEditorState === 'function' && typeof lexical.parseEditorState === 'function') { + const emptyState = { + root: { + children: [{ + children: [], + direction: null, + format: '', + indent: 0, + textFormat: 0, + textStyle: '', + type: 'paragraph', + version: 1, + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }; + const nextState = lexical.parseEditorState(JSON.stringify(emptyState)); + try { + lexical.setEditorState(nextState, { tag: 'history-merge', discrete: true }); + } catch { + lexical.setEditorState(nextState); + } + } else { + editor.textContent = ''; + } + } catch { + editor.textContent = ''; + } + + editor.focus(); + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + const range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + selection.addRange(range); + } + + return { ok: true, kind: 'contenteditable' }; + })() + `) as { ok?: boolean }; + + return !!result?.ok; +} + +async function fillCaption(page: IPage, content: string): Promise { + if (page.insertText && await focusCaptionEditorForNativeInsert(page)) { + try { + await page.insertText(content); + await page.wait({ time: 0.3 }); + await page.evaluate(` + (() => { + const textarea = document.querySelector('[aria-label="Write a caption..."], textarea'); + if (textarea instanceof HTMLTextAreaElement) { + textarea.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, inputType: 'insertText' })); + textarea.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + textarea.blur(); + return { ok: true }; + } + + const editor = document.querySelector('[aria-label="Write a caption..."][contenteditable="true"]') + || document.querySelector('[contenteditable="true"]'); + if (!(editor instanceof HTMLElement)) return { ok: false }; + try { + editor.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, inputType: 'insertText' })); + } catch { + editor.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + } + editor.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + editor.blur(); + return { ok: true }; + })() + `); + return; + } catch { + // Fall back to browser-side editor manipulation below. + } + } + + const result = await page.evaluate(` + ((content) => { + const createParagraph = (text) => ({ + children: text + ? [{ detail: 0, format: 0, mode: 'normal', style: '', text, type: 'text', version: 1 }] + : [], + direction: null, + format: '', + indent: 0, + textFormat: 0, + textStyle: '', + type: 'paragraph', + version: 1, + }); + + const textarea = document.querySelector('[aria-label="Write a caption..."], textarea'); + if (textarea instanceof HTMLTextAreaElement) { + textarea.focus(); + const dt = new DataTransfer(); + dt.setData('text/plain', content); + textarea.dispatchEvent(new ClipboardEvent('paste', { + clipboardData: dt, + bubbles: true, + cancelable: true, + })); + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set; + setter?.call(textarea, content); + textarea.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + textarea.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + return { ok: true, mode: 'textarea' }; + } + + const editor = document.querySelector('[aria-label="Write a caption..."][contenteditable="true"]') + || document.querySelector('[contenteditable="true"]'); + if (editor instanceof HTMLElement) { + editor.focus(); + const lexical = editor.__lexicalEditor; + if (lexical && typeof lexical.getEditorState === 'function' && typeof lexical.parseEditorState === 'function') { + const currentState = lexical.getEditorState && lexical.getEditorState(); + const base = currentState && typeof currentState.toJSON === 'function' ? currentState.toJSON() : {}; + const lines = String(content).split(/\\r?\\n/); + const paragraphs = lines.map((line) => createParagraph(line)); + base.root = { + children: paragraphs.length ? paragraphs : [createParagraph('')], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1, + }; + + const nextState = lexical.parseEditorState(JSON.stringify(base)); + try { + lexical.setEditorState(nextState, { tag: 'history-merge', discrete: true }); + } catch { + lexical.setEditorState(nextState); + } + + editor.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + editor.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + const nextCurrentState = lexical.getEditorState && lexical.getEditorState(); + const pendingState = lexical._pendingEditorState; + return { + ok: true, + mode: 'lexical', + value: editor.textContent || '', + current: nextCurrentState && typeof nextCurrentState.toJSON === 'function' ? nextCurrentState.toJSON() : null, + pending: pendingState && typeof pendingState.toJSON === 'function' ? pendingState.toJSON() : null, + }; + } + + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + const range = document.createRange(); + range.selectNodeContents(editor); + selection.addRange(range); + } + const dt = new DataTransfer(); + dt.setData('text/plain', content); + editor.dispatchEvent(new ClipboardEvent('paste', { + clipboardData: dt, + bubbles: true, + cancelable: true, + })); + return { ok: true, mode: 'contenteditable', value: editor.textContent || '' }; + } + + return { ok: false }; + })(${JSON.stringify(content)}) + `) as { ok?: boolean }; + + if (!result?.ok) { + throw new CommandExecutionError('Failed to fill Instagram caption'); + } +} + +async function captionMatches(page: IPage, content: string): Promise { + const result = await page.evaluate(` + ((content) => { + const normalized = content.trim(); + const readLexicalText = (node) => { + if (!node || typeof node !== 'object') return ''; + if (node.type === 'text' && typeof node.text === 'string') return node.text; + if (!Array.isArray(node.children)) return ''; + if (node.type === 'root') { + return node.children.map((child) => readLexicalText(child)).join('\\n'); + } + if (node.type === 'paragraph') { + return node.children.map((child) => readLexicalText(child)).join(''); + } + return node.children.map((child) => readLexicalText(child)).join(''); + }; + + const textarea = document.querySelector('[aria-label="Write a caption..."], textarea'); + if (textarea instanceof HTMLTextAreaElement) { + return { ok: textarea.value.trim() === normalized }; + } + + const editor = document.querySelector('[aria-label="Write a caption..."][contenteditable="true"]') + || document.querySelector('[contenteditable="true"]'); + if (editor instanceof HTMLElement) { + const lexical = editor.__lexicalEditor; + if (lexical && typeof lexical.getEditorState === 'function') { + const currentState = lexical.getEditorState(); + const pendingState = lexical._pendingEditorState; + const current = currentState && typeof currentState.toJSON === 'function' ? currentState.toJSON() : null; + const pending = pendingState && typeof pendingState.toJSON === 'function' ? pendingState.toJSON() : null; + const currentText = readLexicalText(current && current.root).trim(); + const pendingText = readLexicalText(pending && pending.root).trim(); + if (currentText === normalized || pendingText === normalized) { + return { ok: true, currentText, pendingText }; + } + } + + const text = (editor.textContent || '').replace(/\\u00a0/g, ' ').trim(); + if (text === normalized) return { ok: true }; + + const counters = Array.from(document.querySelectorAll('div, span')) + .map((el) => (el.textContent || '').replace(/\\s+/g, ' ').trim()) + .filter(Boolean); + const counter = counters.find((value) => /\\d+\\s*\\/\\s*2,?200/.test(value)); + if (counter) { + const match = counter.match(/(\\d+)\\s*\\/\\s*2,?200/); + if (match && Number(match[1]) >= normalized.length) return { ok: true }; + } + + return { ok: false, text, counter: counter || '' }; + } + + return { ok: false }; + })(${JSON.stringify(content)}) + `) as { ok?: boolean }; + + return !!result?.ok; +} + +async function ensureCaptionFilled(page: IPage, content: string): Promise { + for (let attempt = 0; attempt < 6; attempt++) { + if (await captionMatches(page, content)) { + return; + } + if (attempt < 5) { + await page.wait({ time: 0.5 }); + } + } + + await page.screenshot({ path: '/tmp/instagram_post_caption_fill_debug.png' }); + throw new CommandExecutionError( + 'Instagram caption did not stick before sharing', + 'Inspect /tmp/instagram_post_caption_fill_debug.png for the caption editor state', + ); +} + +async function waitForPublishSuccess(page: IPage): Promise { + let settledStreak = 0; + for (let attempt = 0; attempt < 90; attempt++) { + const result = await page.evaluate(buildPublishStatusProbeJs()) as { ok?: boolean; failed?: boolean; settled?: boolean; url?: string }; + + if (result?.failed) { + await page.screenshot({ path: '/tmp/instagram_post_share_debug.png' }); + throw new CommandExecutionError( + 'Instagram post share failed', + 'Inspect /tmp/instagram_post_share_debug.png for the share failure state', + ); + } + + if (result?.ok) { + return result.url || ''; + } + if (result?.settled) { + settledStreak += 1; + if (settledStreak >= 3) return ''; + } else { + settledStreak = 0; + } + if (attempt < 89) { + await page.wait({ time: 1 }); + } + } + + await page.screenshot({ path: '/tmp/instagram_post_share_debug.png' }); + throw new CommandExecutionError( + 'Instagram post share confirmation did not appear', + 'Inspect /tmp/instagram_post_share_debug.png for the final publish state', + ); +} + +async function resolveCurrentUserId(page: IPage): Promise { + const cookies = await page.getCookies({ domain: 'instagram.com' }); + return cookies.find((cookie) => cookie.name === 'ds_user_id')?.value || ''; +} + +async function resolveProfileUrl(page: IPage, currentUserId = ''): Promise { + if (currentUserId) { + const runtimeInfo = await resolveInstagramRuntimeInfo(page); + const apiResult = await page.evaluate(` + (async () => { + const userId = ${JSON.stringify(currentUserId)}; + const appId = ${JSON.stringify(runtimeInfo.appId || '')}; + try { + const res = await fetch( + 'https://www.instagram.com/api/v1/users/' + encodeURIComponent(userId) + '/info/', + { + credentials: 'include', + headers: appId ? { 'X-IG-App-ID': appId } : {}, + }, + ); + if (!res.ok) return { ok: false }; + const data = await res.json(); + const username = data?.user?.username || ''; + return { ok: !!username, username }; + } catch { + return { ok: false }; + } + })() + `) as { ok?: boolean; username?: string }; + + if (apiResult?.ok && apiResult.username) { + return new URL(`/${apiResult.username}/`, INSTAGRAM_HOME_URL).toString(); + } + } + + const result = await page.evaluate(` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + + const anchors = Array.from(document.querySelectorAll('a[href]')) + .filter((el) => el instanceof HTMLAnchorElement && isVisible(el)) + .map((el) => ({ + href: el.getAttribute('href') || '', + text: (el.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase(), + aria: (el.getAttribute('aria-label') || '').replace(/\\s+/g, ' ').trim().toLowerCase(), + })) + .filter((el) => /^\\/[^/?#]+\\/$/.test(el.href)); + + const explicitProfile = anchors.find((el) => el.text === 'profile' || el.aria === 'profile')?.href || ''; + const path = explicitProfile; + return { ok: !!path, path }; + })() + `) as { ok?: boolean; path?: string }; + + if (!result?.ok || !result.path) return ''; + return new URL(result.path, INSTAGRAM_HOME_URL).toString(); +} + +async function collectVisibleProfilePostPaths(page: IPage): Promise { + const result = await page.evaluate(` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + + const hrefs = Array.from(document.querySelectorAll('a[href*="/p/"]')) + .filter((el) => el instanceof HTMLAnchorElement && isVisible(el)) + .map((el) => el.getAttribute('href') || '') + .filter((href) => /^\\/(?:[^/?#]+\\/)?p\\/[^/?#]+\\/?$/.test(href)) + .filter((href, index, arr) => arr.indexOf(href) === index); + + return { ok: hrefs.length > 0, hrefs }; + })() + `) as { ok?: boolean; hrefs?: string[] }; + + return Array.isArray(result?.hrefs) ? result.hrefs.filter(Boolean) : []; +} + +async function captureExistingProfilePostPaths(page: IPage): Promise> { + const currentUserId = await resolveCurrentUserId(page); + if (!currentUserId) return new Set(); + + const profileUrl = await resolveProfileUrl(page, currentUserId); + if (!profileUrl) return new Set(); + + try { + await page.goto(profileUrl); + await page.wait({ time: 3 }); + return new Set(await collectVisibleProfilePostPaths(page)); + } catch { + return new Set(); + } +} + +async function resolveLatestPostUrl(page: IPage, existingPostPaths: ReadonlySet): Promise { + const currentUrl = await page.getCurrentUrl?.(); + if (currentUrl && /\/p\//.test(currentUrl)) return currentUrl; + + const currentUserId = await resolveCurrentUserId(page); + const profileUrl = await resolveProfileUrl(page, currentUserId); + if (!profileUrl) return ''; + + await page.goto(profileUrl); + await page.wait({ time: 4 }); + + for (let attempt = 0; attempt < 8; attempt++) { + const hrefs = await collectVisibleProfilePostPaths(page); + const href = hrefs.find((candidate) => !existingPostPaths.has(candidate)) || ''; + if (href) { + return new URL(href, INSTAGRAM_HOME_URL).toString(); + } + + if (attempt < 7) await page.wait({ time: 1 }); + } + + return ''; +} + +cli({ + site: 'instagram', + name: 'post', + description: 'Post an Instagram feed image or mixed-media carousel', + domain: 'www.instagram.com', + strategy: Strategy.UI, + browser: true, + timeoutSeconds: 300, + args: [ + { name: 'media', required: false, valueRequired: true, help: `Comma-separated media paths (images/videos, up to ${MAX_MEDIA_ITEMS})` }, + { name: 'content', positional: true, required: false, help: 'Caption text' }, + ], + columns: ['status', 'detail', 'url'], + validateArgs: validateInstagramPostArgs, + func: async (page: IPage | null, kwargs) => { + const browserPage = requirePage(page); + const mediaItems = normalizePostMediaItems(kwargs as Record); + const content = String(kwargs.content ?? '').trim(); + const existingPostPaths = await captureExistingProfilePostPaths(browserPage); + const commandAttemptBudget = getCommandAttemptBudget(mediaItems); + const preUploadDelaySeconds = getPreUploadDelaySeconds(mediaItems); + const uploadAttemptBudget = getUploadAttemptBudget(mediaItems); + const previewProbeWindowSeconds = getPreviewProbeWindowSeconds(mediaItems); + const finalPreviewWaitSeconds = getFinalPreviewWaitSeconds(mediaItems); + const preShareDelaySeconds = getPreShareDelaySeconds(mediaItems); + const inlineUploadRetryBudget = getInlineUploadRetryBudget(mediaItems); + const protocolCaptureEnabled = process.env.OPENCLI_INSTAGRAM_CAPTURE === '1'; + const protocolCaptureData: InstagramProtocolCaptureEntry[] = []; + const protocolCaptureErrors: string[] = []; + + const installProtocolCapture = async (): Promise => { + if (!protocolCaptureEnabled) return; + await installInstagramProtocolCapture(browserPage); + }; + + const drainProtocolCapture = async (): Promise => { + if (!protocolCaptureEnabled) return; + const payload = await readInstagramProtocolCapture(browserPage); + if (payload.data.length) protocolCaptureData.push(...payload.data); + if (payload.errors.length) protocolCaptureErrors.push(...payload.errors); + }; + + try { + try { + return await executePrivateInstagramPost({ + page: browserPage, + mediaItems, + content, + existingPostPaths, + }); + } catch (error) { + if (error instanceof AuthRequiredError || !isSafePrivateRouteFallbackError(error)) { + throw error; + } + try { + return await executeUiInstagramPost({ + page: browserPage, + mediaItems, + content, + existingPostPaths, + commandAttemptBudget, + preUploadDelaySeconds, + uploadAttemptBudget, + previewProbeWindowSeconds, + finalPreviewWaitSeconds, + preShareDelaySeconds, + inlineUploadRetryBudget, + installProtocolCapture, + drainProtocolCapture, + forceFreshStart: true, + }); + } catch (uiError) { + if (uiError instanceof AuthRequiredError) throw uiError; + if (uiError instanceof CommandExecutionError) { + throw new CommandExecutionError(uiError.message, buildFallbackHint(error, uiError)); + } + throw uiError; + } + } + } finally { + if (protocolCaptureEnabled) { + try { + await drainProtocolCapture(); + } catch { + // Best-effort: capture export should not hide the main command result. + } + fs.writeFileSync(INSTAGRAM_PROTOCOL_TRACE_OUTPUT_PATH, JSON.stringify({ + data: protocolCaptureData, + errors: protocolCaptureErrors, + }, null, 2)); + } + } + }, +}); diff --git a/src/clis/instagram/reel.test.ts b/src/clis/instagram/reel.test.ts new file mode 100644 index 00000000..5209a761 --- /dev/null +++ b/src/clis/instagram/reel.test.ts @@ -0,0 +1,191 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ArgumentError } from '../../errors.js'; +import { getRegistry } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import './reel.js'; + +const tempDirs: string[] = []; + +function createTempVideo(name = 'demo.mp4', bytes = Buffer.from('video')): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-instagram-reel-')); + tempDirs.push(dir); + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, bytes); + return filePath; +} + +function createPageMock(evaluateResults: unknown[], overrides: Partial = {}): IPage { + const evaluate = vi.fn(); + for (const result of evaluateResults) { + evaluate.mockResolvedValueOnce(result); + } + + return { + goto: vi.fn().mockResolvedValue(undefined), + evaluate, + getCookies: vi.fn().mockResolvedValue([]), + snapshot: vi.fn().mockResolvedValue(undefined), + click: vi.fn().mockResolvedValue(undefined), + typeText: vi.fn().mockResolvedValue(undefined), + pressKey: vi.fn().mockResolvedValue(undefined), + scrollTo: vi.fn().mockResolvedValue(undefined), + getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }), + wait: vi.fn().mockResolvedValue(undefined), + tabs: vi.fn().mockResolvedValue([]), + closeTab: vi.fn().mockResolvedValue(undefined), + newTab: vi.fn().mockResolvedValue(undefined), + selectTab: vi.fn().mockResolvedValue(undefined), + networkRequests: vi.fn().mockResolvedValue([]), + consoleMessages: vi.fn().mockResolvedValue([]), + scroll: vi.fn().mockResolvedValue(undefined), + autoScroll: vi.fn().mockResolvedValue(undefined), + installInterceptor: vi.fn().mockResolvedValue(undefined), + getInterceptedRequests: vi.fn().mockResolvedValue([]), + waitForCapture: vi.fn().mockResolvedValue(undefined), + screenshot: vi.fn().mockResolvedValue(''), + setFileInput: vi.fn().mockResolvedValue(undefined), + insertText: vi.fn().mockResolvedValue(undefined), + getCurrentUrl: vi.fn().mockResolvedValue(null), + ...overrides, + }; +} + +afterAll(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('instagram reel registration', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('registers the reel command with a required-value video arg', () => { + const cmd = getRegistry().get('instagram/reel'); + expect(cmd).toBeDefined(); + expect(cmd?.browser).toBe(true); + expect(cmd?.args.some((arg) => arg.name === 'video' && !arg.required && arg.valueRequired)).toBe(true); + expect(cmd?.args.some((arg) => arg.name === 'content' && arg.positional && !arg.required)).toBe(true); + }); + + it('rejects missing --video before browser work', async () => { + const page = createPageMock([]); + const cmd = getRegistry().get('instagram/reel'); + + await expect(cmd!.func!(page, { content: 'hello reel' })).rejects.toThrow(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('rejects unsupported video formats', async () => { + const videoPath = createTempVideo('demo.mov'); + const page = createPageMock([]); + const cmd = getRegistry().get('instagram/reel'); + + await expect(cmd!.func!(page, { video: videoPath })).rejects.toThrow('Unsupported video format'); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('uploads a reel video without caption and shares it', async () => { + const videoPath = createTempVideo(); + const page = createPageMock([ + { ok: false }, // dismiss residual dialogs + { ok: true }, // ensure composer open + { ok: true }, // composer upload input ready + { ok: true, selectors: ['[data-opencli-reel-upload-index="0"]', '[data-opencli-reel-upload-index="1"]'] }, // resolve upload selector + { count: 1 }, // file bound to input + { state: 'preview', detail: 'Crop Back Next' }, // preview detected + { ok: true, label: 'OK' }, // dismiss reels nux + { ok: true, label: 'Next' }, // move from crop to edit + { state: 'edit' }, // edit stage + { ok: true, label: 'Next' }, // move from edit to composer + { state: 'composer' }, // composer stage + { ok: true, label: 'Share' }, // share + { ok: true, url: 'https://www.instagram.com/reel/REEL123/' }, // success + ]); + + const cmd = getRegistry().get('instagram/reel'); + const result = await cmd!.func!(page, { video: videoPath }); + + expect(page.setFileInput).toHaveBeenCalledWith([videoPath], '[data-opencli-reel-upload-index="0"]'); + expect(page.insertText).not.toHaveBeenCalled(); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single reel shared successfully', + url: 'https://www.instagram.com/reel/REEL123/', + }, + ]); + }); + + it('copies query-style local video filenames to a safe temp upload path before setFileInput', async () => { + const videoPath = createTempVideo('demo.mp4?sign=abc&t=123video.MP4'); + const page = createPageMock([ + { ok: false }, + { ok: true }, + { ok: true }, + { ok: true, selectors: ['[data-opencli-reel-upload-index="0"]'] }, + { count: 1 }, + { state: 'preview', detail: 'Crop Back Next' }, + { ok: true, label: 'OK' }, + { ok: true, label: 'Next' }, + { state: 'edit' }, + { ok: true, label: 'Next' }, + { state: 'composer' }, + { ok: true, label: 'Share' }, + { ok: true, url: 'https://www.instagram.com/reel/REELSAFE123/' }, + ]); + + const cmd = getRegistry().get('instagram/reel'); + await cmd!.func!(page, { video: videoPath }); + + const uploadPaths = (page.setFileInput as any).mock.calls[0]?.[0] ?? []; + expect(uploadPaths).toHaveLength(1); + expect(uploadPaths[0]).not.toBe(videoPath); + expect(String(uploadPaths[0])).toContain('opencli-instagram-video-real'); + expect(String(uploadPaths[0]).toLowerCase()).toContain('.mp4'); + }); + + it('uploads a reel video with caption and shares it', async () => { + const videoPath = createTempVideo('captioned.mp4'); + const page = createPageMock([ + { ok: false }, // dismiss residual dialogs + { ok: true }, // ensure composer open + { ok: true }, // composer upload input ready + { ok: true, selectors: ['[data-opencli-reel-upload-index="0"]'] }, // resolve upload selector + { count: 1 }, // file bound to input + { state: 'preview', detail: 'Crop Back Next' }, // preview detected + { ok: true, label: 'OK' }, // dismiss reels nux + { ok: true, label: 'Next' }, // move from crop to edit + { state: 'edit' }, // edit stage + { ok: true, label: 'Next' }, // move from edit to composer + { state: 'composer' }, // composer stage + { ok: true }, // focus caption editor + { ok: true }, // post-insert event dispatch + { ok: true }, // caption matches + { ok: true, label: 'Share' }, // share + { ok: true, url: 'https://www.instagram.com/reel/REEL456/' }, // success + ]); + + const cmd = getRegistry().get('instagram/reel'); + const result = await cmd!.func!(page, { video: videoPath, content: 'hello reel' }); + + expect(page.insertText).toHaveBeenCalledWith('hello reel'); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single reel shared successfully', + url: 'https://www.instagram.com/reel/REEL456/', + }, + ]); + }); +}); diff --git a/src/clis/instagram/reel.ts b/src/clis/instagram/reel.ts new file mode 100644 index 00000000..9c16d1a8 --- /dev/null +++ b/src/clis/instagram/reel.ts @@ -0,0 +1,886 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { Page as BrowserPage } from '../../browser/page.js'; +import { cli, Strategy } from '../../registry.js'; +import { ArgumentError, AuthRequiredError, CommandExecutionError } from '../../errors.js'; +import type { BrowserCookie, IPage } from '../../types.js'; +import { + buildClickActionJs, + buildEnsureComposerOpenJs, + buildInspectUploadStageJs, +} from './post.js'; +import { resolveInstagramRuntimeInfo } from './_shared/runtime-info.js'; + +const INSTAGRAM_HOME_URL = 'https://www.instagram.com/'; +const SUPPORTED_VIDEO_EXTENSIONS = new Set(['.mp4']); +const INSTAGRAM_REEL_TIMEOUT_SECONDS = 600; + +type InstagramReelSuccessRow = { + status: string; + detail: string; + url: string; +}; + +type ReelStageState = { + state: 'crop' | 'edit' | 'composer' | 'failed' | 'pending'; + detail?: string; +}; + +type PreparedVideoUpload = { + originalPath: string; + uploadPath: string; + cleanupPath?: string; +}; + +function requirePage(page: IPage | null): IPage { + if (!page) throw new CommandExecutionError('Browser session required for instagram reel'); + return page; +} + +async function gotoInstagramHome(page: IPage, forceReload = false): Promise { + if (forceReload) { + await page.goto(`${INSTAGRAM_HOME_URL}?__opencli_reset=${Date.now()}`); + await page.wait({ time: 1 }); + } + await page.goto(INSTAGRAM_HOME_URL); +} + +function validateVideoPath(input: unknown): string { + const resolved = path.resolve(String(input || '').trim()); + if (!resolved) { + throw new ArgumentError('Video path cannot be empty'); + } + if (!fs.existsSync(resolved)) { + throw new ArgumentError(`Video file not found: ${resolved}`); + } + const ext = path.extname(resolved).toLowerCase(); + if (!SUPPORTED_VIDEO_EXTENSIONS.has(ext)) { + throw new ArgumentError(`Unsupported video format: ${ext}`, 'Supported formats: .mp4'); + } + return resolved; +} + +function validateInstagramReelArgs(kwargs: Record): void { + if (kwargs.video === undefined) { + throw new ArgumentError( + 'Argument "video" is required.', + 'Provide --video /path/to/file.mp4', + ); + } +} + +function buildInstagramReelSuccessResult(url: string): InstagramReelSuccessRow[] { + return [{ + status: '✅ Posted', + detail: 'Single reel shared successfully', + url, + }]; +} + +function isRecoverableReelSessionError(error: unknown): boolean { + if (!(error instanceof CommandExecutionError)) return false; + return error.message === 'Instagram reel upload input not found' + || error.message === 'Instagram reel preview did not appear after upload' + || error.message === 'Instagram reel upload failed'; +} + +function buildSafeTempVideoPath(filePath: string): string { + const ext = path.extname(filePath).toLowerCase() || '.mp4'; + return path.join(os.tmpdir(), `opencli-instagram-video-real${ext}`); +} + +function prepareVideoUpload(filePath: string): PreparedVideoUpload { + const baseName = path.basename(filePath); + if (/^[a-zA-Z0-9._-]+$/.test(baseName)) { + return { originalPath: filePath, uploadPath: filePath }; + } + const uploadPath = buildSafeTempVideoPath(filePath); + fs.copyFileSync(filePath, uploadPath); + return { + originalPath: filePath, + uploadPath, + cleanupPath: uploadPath, + }; +} + +async function ensureComposerOpen(page: IPage): Promise { + const result = await page.evaluate(buildEnsureComposerOpenJs()) as { ok?: boolean; reason?: string }; + if (!result?.ok) { + if (result?.reason === 'auth') { + throw new AuthRequiredError('www.instagram.com', 'Instagram login required before posting a reel'); + } + throw new CommandExecutionError('Failed to open Instagram reel composer'); + } + for (let attempt = 0; attempt < 12; attempt += 1) { + const ready = await page.evaluate(` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + const inputs = Array.from(document.querySelectorAll('input[type="file"]')) + .filter((el) => el instanceof HTMLInputElement) + .filter((el) => { + const dialog = el.closest('[role="dialog"]'); + return dialog instanceof HTMLElement && isVisible(dialog); + }); + return { ok: inputs.length > 0 }; + })() + `) as { ok?: boolean }; + if (ready?.ok) return; + if (attempt < 11) await page.wait({ time: 0.5 }); + } + throw new CommandExecutionError('Instagram reel upload input not found', 'Open the new-post composer in a logged-in browser session and retry'); +} + +async function dismissResidualDialogs(page: IPage): Promise { + for (let attempt = 0; attempt < 4; attempt += 1) { + const result = await page.evaluate(` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + + const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')) + .filter((el) => el instanceof HTMLElement && isVisible(el)); + for (const dialog of dialogs) { + const text = (dialog.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + if (!text) continue; + if ( + text.includes('post shared') + || text.includes('your post has been shared') + || text.includes('your reel has been shared') + || text.includes('video posts are now reels') + || text.includes('something went wrong') + || text.includes('sharing') + || text.includes('create new post') + || text.includes('new reel') + || text.includes('crop') + || text.includes('edit') + ) { + const close = dialog.querySelector('[aria-label="Close"], button[aria-label="Close"], div[role="button"][aria-label="Close"]'); + if (close instanceof HTMLElement && isVisible(close)) { + close.click(); + return { ok: true }; + } + } + } + return { ok: false }; + })() + `) as { ok?: boolean }; + + if (!result?.ok) return; + await page.wait({ time: 0.5 }); + } +} + +async function resolveUploadSelectors(page: IPage): Promise { + const result = await page.evaluate(` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')) + .filter((el) => el instanceof HTMLElement && isVisible(el)); + const roots = dialogs.length ? dialogs : [document.body]; + const selectors = []; + let index = 0; + + for (const root of roots) { + const inputs = Array.from(root.querySelectorAll('input[type="file"]')); + for (const input of inputs) { + if (!(input instanceof HTMLInputElement)) continue; + if (input.disabled) continue; + const accept = (input.getAttribute('accept') || '').toLowerCase(); + if (accept && !accept.includes('video') && !accept.includes('.mp4')) continue; + input.setAttribute('data-opencli-reel-upload-index', String(index)); + selectors.push('[data-opencli-reel-upload-index="' + index + '"]'); + index += 1; + } + } + + return { ok: selectors.length > 0, selectors }; + })() + `) as { ok?: boolean; selectors?: string[] }; + + if (!result?.ok || !Array.isArray(result.selectors) || result.selectors.length === 0) { + throw new CommandExecutionError( + 'Instagram reel upload input not found', + 'Open the new-post composer in a logged-in browser session and retry', + ); + } + return result.selectors; +} + +async function uploadVideo(page: IPage, videoPath: string, selector: string): Promise { + if (!page.setFileInput) { + throw new CommandExecutionError( + 'Instagram reel upload requires Browser Bridge file upload support', + 'Use Browser Bridge or another browser mode that supports setFileInput', + ); + } + await page.setFileInput([videoPath], selector); +} + +async function readSelectedFileCount(page: IPage, selector: string): Promise { + const result = await page.evaluate(` + (() => { + const input = document.querySelector(${JSON.stringify(selector)}); + if (!(input instanceof HTMLInputElement)) return { count: null }; + return { count: input.files?.length || 0 }; + })() + `) as { count?: number | null }; + if (result?.count === null || result?.count === undefined) return null; + return Number(result.count); +} + +async function waitForVideoPreview(page: IPage, maxWaitSeconds = 20): Promise { + let lastDetail = ''; + for (let attempt = 0; attempt < maxWaitSeconds * 2; attempt += 1) { + const result = await page.evaluate(buildInspectUploadStageJs()) as { state?: string; detail?: string }; + lastDetail = String(result?.detail || '').trim(); + if (result?.state === 'preview') return; + if (result?.state === 'failed') { + throw new CommandExecutionError( + 'Instagram reel upload failed', + result.detail ? `Instagram rejected the reel upload: ${result.detail}` : 'Instagram rejected the reel upload before the preview stage', + ); + } + if (attempt < maxWaitSeconds * 2 - 1) await page.wait({ time: 0.5 }); + } + await page.screenshot({ path: '/tmp/instagram_reel_preview_debug.png' }); + throw new CommandExecutionError( + 'Instagram reel preview did not appear after upload', + lastDetail + ? `Inspect /tmp/instagram_reel_preview_debug.png. Last visible dialog text: ${lastDetail}` + : 'Inspect /tmp/instagram_reel_preview_debug.png for the upload state', + ); +} + +async function clickAction(page: IPage, labels: string[], scope: 'any' | 'media' | 'caption' = 'any'): Promise { + const result = await page.evaluate(buildClickActionJs(labels, scope)) as { ok?: boolean; label?: string }; + if (!result?.ok) { + throw new CommandExecutionError(`Instagram action button not found: ${labels.join(' / ')}`); + } + return result.label || labels[0] || ''; +} + +async function clickActionMaybe(page: IPage, labels: string[], scope: 'any' | 'media' | 'caption' = 'any'): Promise { + const result = await page.evaluate(buildClickActionJs(labels, scope)) as { ok?: boolean }; + return !!result?.ok; +} + +function buildInspectReelStageJs(): string { + return ` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el)); + const text = dialogs.map((el) => (el.textContent || '').replace(/\\s+/g, ' ').trim()).join(' '); + const lower = text.toLowerCase(); + const hasVisibleButton = (labels) => dialogs.some((dialog) => + Array.from(dialog.querySelectorAll('button, div[role="button"]')).some((el) => { + const value = (el.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + return isVisible(el) && labels.includes(value); + }) + ); + if (/something went wrong|please try again|share failed|couldn['’]t be shared|could not be shared|失败|出错/.test(lower)) { + return { state: 'failed', detail: text }; + } + if (/new reel|write a caption|add location|tag people/.test(lower) && hasVisibleButton(['share'])) { + return { state: 'composer', detail: text }; + } + if (/edit|cover photo|trim|video has no audio/.test(lower) && hasVisibleButton(['next'])) { + return { state: 'edit', detail: text }; + } + if (/crop|select crop|open media gallery/.test(lower) && hasVisibleButton(['next'])) { + return { state: 'crop', detail: text }; + } + return { state: 'pending', detail: text }; + })() + `; +} + +async function waitForReelStage(page: IPage, expected: ReelStageState['state'], maxWaitSeconds = 20): Promise { + for (let attempt = 0; attempt < maxWaitSeconds * 2; attempt += 1) { + const result = await page.evaluate(buildInspectReelStageJs()) as ReelStageState; + if (result?.state === expected) return; + if (result?.state === 'failed') { + throw new CommandExecutionError( + 'Instagram reel editor did not appear', + result.detail ? `Instagram reel flow failed: ${result.detail}` : 'Instagram reel flow failed before the next editor stage', + ); + } + if (attempt < maxWaitSeconds * 2 - 1) await page.wait({ time: 0.5 }); + } + throw new CommandExecutionError(`Instagram reel ${expected} editor did not appear`); +} + +async function focusCaptionEditor(page: IPage): Promise { + const result = await page.evaluate(` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el)); + for (const dialog of dialogs) { + const textarea = dialog.querySelector('[aria-label="Write a caption..."], textarea'); + if (textarea instanceof HTMLTextAreaElement && isVisible(textarea)) { + textarea.focus(); + textarea.select(); + return { ok: true, kind: 'textarea' }; + } + + const editor = dialog.querySelector('[aria-label="Write a caption..."][contenteditable="true"]') + || dialog.querySelector('[contenteditable="true"]'); + if (editor instanceof HTMLElement && isVisible(editor)) { + const lexical = editor.__lexicalEditor; + try { + if (lexical && typeof lexical.getEditorState === 'function' && typeof lexical.parseEditorState === 'function') { + const emptyState = { + root: { + children: [{ + children: [], + direction: null, + format: '', + indent: 0, + textFormat: 0, + textStyle: '', + type: 'paragraph', + version: 1, + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }; + const nextState = lexical.parseEditorState(JSON.stringify(emptyState)); + try { + lexical.setEditorState(nextState, { tag: 'history-merge', discrete: true }); + } catch { + lexical.setEditorState(nextState); + } + } else { + editor.textContent = ''; + } + } catch { + editor.textContent = ''; + } + + editor.focus(); + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + const range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + selection.addRange(range); + } + return { ok: true, kind: 'contenteditable' }; + } + } + return { ok: false }; + })() + `) as { ok?: boolean }; + return !!result?.ok; +} + +async function captionMatches(page: IPage, content: string): Promise { + const result = await page.evaluate(` + (() => { + const target = ${JSON.stringify(content.trim())}.replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim(); + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + const readLexicalText = (node) => { + if (!node || typeof node !== 'object') return ''; + if (node.type === 'text' && typeof node.text === 'string') return node.text; + if (!Array.isArray(node.children)) return ''; + if (node.type === 'root') return node.children.map((child) => readLexicalText(child)).join('\\n'); + if (node.type === 'paragraph') return node.children.map((child) => readLexicalText(child)).join(''); + return node.children.map((child) => readLexicalText(child)).join(''); + }; + const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el)); + for (const dialog of dialogs) { + const textarea = dialog.querySelector('[aria-label="Write a caption..."], textarea'); + if (textarea instanceof HTMLTextAreaElement && isVisible(textarea)) { + if (textarea.value.replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim() === target) { + return { ok: true }; + } + continue; + } + + const editor = dialog.querySelector('[aria-label="Write a caption..."][contenteditable="true"]') + || dialog.querySelector('[contenteditable="true"]'); + if (!(editor instanceof HTMLElement) || !isVisible(editor)) continue; + + const lexical = editor.__lexicalEditor; + if (lexical && typeof lexical.getEditorState === 'function') { + const currentState = lexical.getEditorState(); + const pendingState = lexical._pendingEditorState; + const current = currentState && typeof currentState.toJSON === 'function' ? currentState.toJSON() : null; + const pending = pendingState && typeof pendingState.toJSON === 'function' ? pendingState.toJSON() : null; + const currentText = readLexicalText(current && current.root).replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim(); + const pendingText = readLexicalText(pending && pending.root).replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim(); + if (currentText === target || pendingText === target) { + return { ok: true }; + } + } + + const value = (editor.textContent || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim(); + if (value === target) { + return { ok: true }; + } + } + return { ok: false }; + })() + `) as { ok?: boolean }; + return !!result?.ok; +} + +async function fillCaption(page: IPage, content: string): Promise { + const focused = await focusCaptionEditor(page); + if (!focused) { + throw new CommandExecutionError('Instagram reel caption editor did not appear'); + } + if (page.insertText) { + try { + await page.insertText(content); + await page.wait({ time: 0.3 }); + await page.evaluate(` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el)); + for (const dialog of dialogs) { + const textarea = dialog.querySelector('[aria-label="Write a caption..."], textarea'); + if (textarea instanceof HTMLTextAreaElement && isVisible(textarea)) { + textarea.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, inputType: 'insertText' })); + textarea.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + textarea.blur(); + return { ok: true }; + } + + const editor = dialog.querySelector('[aria-label="Write a caption..."][contenteditable="true"]') + || dialog.querySelector('[contenteditable="true"]'); + if (!(editor instanceof HTMLElement) || !isVisible(editor)) continue; + try { + editor.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, inputType: 'insertText' })); + } catch { + editor.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + } + editor.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + editor.blur(); + return { ok: true }; + } + return { ok: false }; + })() + `); + return; + } catch { + // Fall back to browser-side editor manipulation below. + } + } + await page.evaluate(` + ((content) => { + const createParagraph = (text) => ({ + children: text + ? [{ detail: 0, format: 0, mode: 'normal', style: '', text, type: 'text', version: 1 }] + : [], + direction: null, + format: '', + indent: 0, + textFormat: 0, + textStyle: '', + type: 'paragraph', + version: 1, + }); + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el)); + for (const dialog of dialogs) { + const textarea = dialog.querySelector('[aria-label="Write a caption..."], textarea'); + if (textarea instanceof HTMLTextAreaElement && isVisible(textarea)) { + textarea.focus(); + const dt = new DataTransfer(); + dt.setData('text/plain', content); + textarea.dispatchEvent(new ClipboardEvent('paste', { + clipboardData: dt, + bubbles: true, + cancelable: true, + })); + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set; + setter?.call(textarea, content); + textarea.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + textarea.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + textarea.blur(); + return { ok: true, mode: 'textarea' }; + } + + const editor = dialog.querySelector('[aria-label="Write a caption..."][contenteditable="true"]') + || dialog.querySelector('[contenteditable="true"]'); + if (!(editor instanceof HTMLElement) || !isVisible(editor)) continue; + + editor.focus(); + const lexical = editor.__lexicalEditor; + if (lexical && typeof lexical.getEditorState === 'function' && typeof lexical.parseEditorState === 'function') { + const currentState = lexical.getEditorState && lexical.getEditorState(); + const base = currentState && typeof currentState.toJSON === 'function' ? currentState.toJSON() : {}; + const lines = String(content).split(/\\r?\\n/); + const paragraphs = lines.map((line) => createParagraph(line)); + base.root = { + children: paragraphs.length ? paragraphs : [createParagraph('')], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1, + }; + + const nextState = lexical.parseEditorState(JSON.stringify(base)); + try { + lexical.setEditorState(nextState, { tag: 'history-merge', discrete: true }); + } catch { + lexical.setEditorState(nextState); + } + + editor.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + editor.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + editor.blur(); + return { ok: true, mode: 'lexical' }; + } + + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + const range = document.createRange(); + range.selectNodeContents(editor); + selection.addRange(range); + } + const dt = new DataTransfer(); + dt.setData('text/plain', content); + editor.dispatchEvent(new ClipboardEvent('paste', { + clipboardData: dt, + bubbles: true, + cancelable: true, + })); + editor.blur(); + return { ok: true, mode: 'contenteditable' }; + } + return { ok: false }; + })(${JSON.stringify(content)}) + `); +} + +async function ensureCaptionFilled(page: IPage, content: string): Promise { + for (let attempt = 0; attempt < 6; attempt += 1) { + if (await captionMatches(page, content)) return; + if (attempt < 5) await page.wait({ time: 0.5 }); + } + throw new CommandExecutionError('Instagram reel caption did not stick before sharing'); +} + +function buildReelPublishStatusProbeJs(): string { + return ` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter((el) => isVisible(el)); + const dialogText = dialogs.map((el) => (el.textContent || '').replace(/\\s+/g, ' ').trim()).join(' '); + const lower = dialogText.toLowerCase(); + const url = window.location.href; + const sharingVisible = /sharing/.test(lower); + const shared = /your reel has been shared|reel shared|已分享|已发布/.test(lower) || /\\/reel\\//.test(url); + const failed = !shared && !sharingVisible && ( + /couldn['’]t be shared|could not be shared|share failed|无法分享|分享失败/.test(lower) + || (/something went wrong/.test(lower) && /try again/.test(lower)) + ); + const composerOpen = dialogs.some((dialog) => + !!dialog.querySelector('textarea, [contenteditable="true"], input[type="file"]') + || /new reel|cover photo|trim|select from computer|crop|sharing/.test((dialog.textContent || '').toLowerCase()) + ); + const settled = !shared && !composerOpen && !sharingVisible; + return { ok: shared, failed, settled, url: /\\/reel\\//.test(url) ? url : '' }; + })() + `; +} + +async function waitForPublishSuccess(page: IPage): Promise { + let settledStreak = 0; + for (let attempt = 0; attempt < 120; attempt += 1) { + const result = await page.evaluate(buildReelPublishStatusProbeJs()) as { ok?: boolean; failed?: boolean; settled?: boolean; url?: string }; + if (result?.failed) { + throw new CommandExecutionError('Instagram reel share failed'); + } + if (result?.ok) { + return result.url || ''; + } + if (result?.settled) { + settledStreak += 1; + if (settledStreak >= 3) return ''; + } else { + settledStreak = 0; + } + if (attempt < 119) await page.wait({ time: 1 }); + } + throw new CommandExecutionError('Instagram reel share confirmation did not appear'); +} + +async function resolveCurrentUserId(page: IPage): Promise { + const cookies = await page.getCookies({ domain: 'instagram.com' }); + return cookies.find((cookie: BrowserCookie) => cookie.name === 'ds_user_id')?.value || ''; +} + +async function resolveProfileUrl(page: IPage, currentUserId = ''): Promise { + if (currentUserId) { + const runtimeInfo = await resolveInstagramRuntimeInfo(page); + const apiResult = await page.evaluate(` + (async () => { + const userId = ${JSON.stringify(currentUserId)}; + const appId = ${JSON.stringify(runtimeInfo.appId || '')}; + try { + const res = await fetch( + 'https://www.instagram.com/api/v1/users/' + encodeURIComponent(userId) + '/info/', + { + credentials: 'include', + headers: appId ? { 'X-IG-App-ID': appId } : {}, + }, + ); + if (!res.ok) return { ok: false }; + const data = await res.json(); + const username = data?.user?.username || ''; + return { ok: !!username, username }; + } catch { + return { ok: false }; + } + })() + `) as { ok?: boolean; username?: string }; + + if (apiResult?.ok && apiResult.username) { + return new URL(`/${apiResult.username}/`, INSTAGRAM_HOME_URL).toString(); + } + } + return ''; +} + +async function collectVisibleProfileMediaPaths(page: IPage): Promise { + const result = await page.evaluate(` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && rect.width > 0 + && rect.height > 0; + }; + const hrefs = Array.from(document.querySelectorAll('a[href*="/reel/"], a[href*="/p/"]')) + .filter((el) => el instanceof HTMLAnchorElement && isVisible(el)) + .map((el) => el.getAttribute('href') || '') + .filter((href) => /^\\/(?:[^/?#]+\\/)?(?:reel|p)\\/[^/?#]+\\/?$/.test(href)) + .filter((href, index, arr) => arr.indexOf(href) === index); + return { hrefs }; + })() + `) as { hrefs?: string[] }; + return Array.isArray(result?.hrefs) ? result.hrefs.filter(Boolean) : []; +} + +async function captureExistingProfileMediaPaths(page: IPage): Promise> { + const currentUserId = await resolveCurrentUserId(page); + if (!currentUserId) return new Set(); + const profileUrl = await resolveProfileUrl(page, currentUserId); + if (!profileUrl) return new Set(); + try { + await page.goto(profileUrl); + await page.wait({ time: 3 }); + return new Set(await collectVisibleProfileMediaPaths(page)); + } catch { + return new Set(); + } +} + +async function resolveLatestReelUrl(page: IPage, existingPaths: ReadonlySet): Promise { + const currentUrl = await page.getCurrentUrl?.(); + if (currentUrl && /\/reel\//.test(currentUrl)) return currentUrl; + + const currentUserId = await resolveCurrentUserId(page); + const profileUrl = await resolveProfileUrl(page, currentUserId); + if (!profileUrl) return ''; + + await page.goto(profileUrl); + await page.wait({ time: 4 }); + + for (let attempt = 0; attempt < 8; attempt += 1) { + const hrefs = await collectVisibleProfileMediaPaths(page); + const href = hrefs.find((candidate) => candidate.includes('/reel/') && !existingPaths.has(candidate)) + || hrefs.find((candidate) => !existingPaths.has(candidate)) + || ''; + if (href) { + return new URL(href, INSTAGRAM_HOME_URL).toString(); + } + if (attempt < 7) await page.wait({ time: 1 }); + } + return ''; +} + +cli({ + site: 'instagram', + name: 'reel', + description: 'Post an Instagram reel video', + domain: 'www.instagram.com', + strategy: Strategy.UI, + browser: true, + timeoutSeconds: INSTAGRAM_REEL_TIMEOUT_SECONDS, + args: [ + { name: 'video', required: false, valueRequired: true, help: 'Path to a single .mp4 video file' }, + { name: 'content', positional: true, required: false, help: 'Caption text' }, + ], + columns: ['status', 'detail', 'url'], + validateArgs: validateInstagramReelArgs, + func: async (page: IPage | null, kwargs) => { + const browserPage = requirePage(page); + const videoPath = validateVideoPath(kwargs.video); + const content = String(kwargs.content ?? '').trim(); + const preparedUpload = prepareVideoUpload(videoPath); + + const run = async ( + activePage: IPage, + existingMediaPaths: ReadonlySet = new Set(), + ): Promise => { + if (typeof activePage.startNetworkCapture === 'function') { + await activePage.startNetworkCapture('/rupload_igvideo/|/api/v1/|/reel/|/clips/|/media/|/configure|/upload'); + } + await gotoInstagramHome(activePage, true); + await activePage.wait({ time: 2 }); + await dismissResidualDialogs(activePage); + await ensureComposerOpen(activePage); + await activePage.wait({ time: 2 }); + const selectors = await resolveUploadSelectors(activePage); + let uploaded = false; + let uploadError: unknown; + for (const selector of selectors) { + try { + await uploadVideo(activePage, preparedUpload.uploadPath, selector); + const selectedFileCount = await readSelectedFileCount(activePage, selector); + if (selectedFileCount === 0) { + throw new CommandExecutionError('Instagram reel upload failed', 'The selected reel input never received the video file'); + } + await waitForVideoPreview(activePage, 10); + uploaded = true; + break; + } catch (error) { + uploadError = error; + } + } + if (!uploaded) { + throw uploadError instanceof Error + ? uploadError + : new CommandExecutionError('Instagram reel preview did not appear after upload'); + } + await clickActionMaybe(activePage, ['OK'], 'any'); + await clickAction(activePage, ['Next', '下一步'], 'media'); + await waitForReelStage(activePage, 'edit', 20); + await clickAction(activePage, ['Next', '下一步'], 'media'); + await waitForReelStage(activePage, 'composer', 20); + + if (content) { + await fillCaption(activePage, content); + await ensureCaptionFilled(activePage, content); + } + + await clickAction(activePage, ['Share', '分享'], 'caption'); + const sharedUrl = await waitForPublishSuccess(activePage); + const url = sharedUrl || await resolveLatestReelUrl(activePage, existingMediaPaths); + return buildInstagramReelSuccessResult(url); + }; + + try { + if (!process.env.VITEST) { + const runIsolated = async (): Promise => { + const isolatedPage = new BrowserPage(`site:instagram-reel-${Date.now()}`); + try { + return await run(isolatedPage, new Set()); + } finally { + await isolatedPage.closeWindow?.(); + } + }; + + try { + return await runIsolated(); + } catch (error) { + if (!isRecoverableReelSessionError(error)) throw error; + return await runIsolated(); + } + } + + const existingMediaPaths = await captureExistingProfileMediaPaths(browserPage); + return await run(browserPage, existingMediaPaths); + } finally { + if (preparedUpload.cleanupPath) { + fs.rmSync(preparedUpload.cleanupPath, { force: true }); + } + } + }, +}); diff --git a/src/clis/instagram/story.test.ts b/src/clis/instagram/story.test.ts new file mode 100644 index 00000000..a27bdfc9 --- /dev/null +++ b/src/clis/instagram/story.test.ts @@ -0,0 +1,191 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ArgumentError } from '../../errors.js'; +import { getRegistry } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import * as privatePublish from './_shared/private-publish.js'; +import './story.js'; + +const tempDirs: string[] = []; + +function createTempFile(name: string, bytes = Buffer.from('story-media')): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-instagram-story-')); + tempDirs.push(dir); + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, bytes); + return filePath; +} + +function createPageMock(evaluateResults: unknown[] = [], overrides: Partial = {}): IPage { + const evaluate = vi.fn(); + for (const result of evaluateResults) { + evaluate.mockResolvedValueOnce(result); + } + return { + goto: vi.fn().mockResolvedValue(undefined), + evaluate, + getCookies: vi.fn().mockResolvedValue([]), + snapshot: vi.fn().mockResolvedValue(undefined), + click: vi.fn().mockResolvedValue(undefined), + typeText: vi.fn().mockResolvedValue(undefined), + pressKey: vi.fn().mockResolvedValue(undefined), + scrollTo: vi.fn().mockResolvedValue(undefined), + getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }), + wait: vi.fn().mockResolvedValue(undefined), + tabs: vi.fn().mockResolvedValue([]), + closeTab: vi.fn().mockResolvedValue(undefined), + newTab: vi.fn().mockResolvedValue(undefined), + selectTab: vi.fn().mockResolvedValue(undefined), + networkRequests: vi.fn().mockResolvedValue([]), + consoleMessages: vi.fn().mockResolvedValue([]), + scroll: vi.fn().mockResolvedValue(undefined), + autoScroll: vi.fn().mockResolvedValue(undefined), + installInterceptor: vi.fn().mockResolvedValue(undefined), + getInterceptedRequests: vi.fn().mockResolvedValue([]), + waitForCapture: vi.fn().mockResolvedValue(undefined), + screenshot: vi.fn().mockResolvedValue(''), + setFileInput: vi.fn().mockResolvedValue(undefined), + insertText: vi.fn().mockResolvedValue(undefined), + getCurrentUrl: vi.fn().mockResolvedValue(null), + ...overrides, + }; +} + +afterAll(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('instagram story registration', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('registers the story command with a required-value media arg', () => { + const cmd = getRegistry().get('instagram/story'); + expect(cmd).toBeDefined(); + expect(cmd?.browser).toBe(true); + expect(cmd?.args.some((arg) => arg.name === 'media' && !arg.required && arg.valueRequired)).toBe(true); + expect(cmd?.args.some((arg) => arg.name === 'content')).toBe(false); + }); + + it('rejects missing --media before browser work', async () => { + const page = createPageMock(); + const cmd = getRegistry().get('instagram/story'); + + await expect(cmd!.func!(page, {})).rejects.toThrow(ArgumentError); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('rejects multiple media inputs for a single story', async () => { + const first = createTempFile('one.jpg'); + const second = createTempFile('two.mp4'); + const page = createPageMock(); + const cmd = getRegistry().get('instagram/story'); + + await expect(cmd!.func!(page, { media: `${first},${second}` })).rejects.toThrow('single media'); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('rejects unsupported story formats', async () => { + const filePath = createTempFile('story.mov'); + const page = createPageMock(); + const cmd = getRegistry().get('instagram/story'); + + await expect(cmd!.func!(page, { media: filePath })).rejects.toThrow('Unsupported story media format'); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('publishes a single image story through the private route', async () => { + const imagePath = createTempFile('story.jpg'); + const page = createPageMock([ + { appId: '936619743392459', csrfToken: '', instagramAjax: 'ajax' }, + { ok: true, username: 'tsezi_ray' }, + ], { + getCookies: vi.fn().mockResolvedValue([{ name: 'ds_user_id', value: '123', domain: 'instagram.com' }]), + }); + const cmd = getRegistry().get('instagram/story'); + + vi.spyOn(privatePublish, 'resolveInstagramPrivatePublishConfig').mockResolvedValue({ + apiContext: { + asbdId: '359341', + csrfToken: 'csrf-token', + igAppId: '936619743392459', + igWwwClaim: 'claim', + instagramAjax: 'ajax', + webSessionId: 'session', + }, + jazoest: '22047', + }); + vi.spyOn(privatePublish, 'publishStoryViaPrivateApi').mockResolvedValue({ + mediaPk: '1234567890', + uploadId: '1234567890', + }); + + const result = await cmd!.func!(page, { media: imagePath }); + + expect(privatePublish.publishStoryViaPrivateApi).toHaveBeenCalledWith(expect.objectContaining({ + page, + mediaItem: { type: 'image', filePath: imagePath }, + content: '', + })); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single story shared successfully', + url: 'https://www.instagram.com/stories/tsezi_ray/1234567890/', + }, + ]); + }); + + it('publishes a single video story through the private route', async () => { + const videoPath = createTempFile('story.mp4'); + const page = createPageMock([ + { appId: '936619743392459', csrfToken: '', instagramAjax: 'ajax' }, + { ok: true, username: 'tsezi_ray' }, + ], { + getCookies: vi.fn().mockResolvedValue([{ name: 'ds_user_id', value: '123', domain: 'instagram.com' }]), + }); + const cmd = getRegistry().get('instagram/story'); + + vi.spyOn(privatePublish, 'resolveInstagramPrivatePublishConfig').mockResolvedValue({ + apiContext: { + asbdId: '359341', + csrfToken: 'csrf-token', + igAppId: '936619743392459', + igWwwClaim: 'claim', + instagramAjax: 'ajax', + webSessionId: 'session', + }, + jazoest: '22047', + }); + vi.spyOn(privatePublish, 'publishStoryViaPrivateApi').mockResolvedValue({ + mediaPk: '9988776655', + uploadId: '9988776655', + }); + + const result = await cmd!.func!(page, { media: videoPath }); + + expect(privatePublish.publishStoryViaPrivateApi).toHaveBeenCalledWith(expect.objectContaining({ + page, + mediaItem: { type: 'video', filePath: videoPath }, + content: '', + })); + expect(result).toEqual([ + { + status: '✅ Posted', + detail: 'Single video story shared successfully', + url: 'https://www.instagram.com/stories/tsezi_ray/9988776655/', + }, + ]); + }); +}); diff --git a/src/clis/instagram/story.ts b/src/clis/instagram/story.ts new file mode 100644 index 00000000..3b4851dc --- /dev/null +++ b/src/clis/instagram/story.ts @@ -0,0 +1,151 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { ArgumentError, CommandExecutionError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { + publishStoryViaPrivateApi, + resolveInstagramPrivatePublishConfig, +} from './_shared/private-publish.js'; +import { resolveInstagramRuntimeInfo } from './_shared/runtime-info.js'; + +const INSTAGRAM_HOME_URL = 'https://www.instagram.com/'; +const SUPPORTED_STORY_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']); +const SUPPORTED_STORY_VIDEO_EXTENSIONS = new Set(['.mp4']); + +type InstagramStoryMediaItem = { + type: 'image' | 'video'; + filePath: string; +}; + +type InstagramStorySuccessRow = { + status: string; + detail: string; + url: string; +}; + +function requirePage(page: IPage | null): IPage { + if (!page) throw new CommandExecutionError('Browser session required for instagram story'); + return page; +} + +function validateInstagramStoryArgs(kwargs: Record): void { + if (kwargs.media === undefined) { + throw new ArgumentError( + 'Argument "media" is required.', + 'Provide --media /path/to/file.jpg or --media /path/to/file.mp4', + ); + } +} + +function normalizeStoryMediaItem(kwargs: Record): InstagramStoryMediaItem { + const raw = String(kwargs.media ?? '').trim(); + const parts = raw.split(',').map((part) => part.trim()).filter(Boolean); + if (parts.length === 0) { + throw new ArgumentError( + 'Argument "media" is required.', + 'Provide --media /path/to/file.jpg or --media /path/to/file.mp4', + ); + } + if (parts.length > 1) { + throw new ArgumentError( + 'Instagram story currently supports a single media item.', + 'Provide one image or one video path with --media', + ); + } + + const resolved = path.resolve(parts[0]!); + if (!fs.existsSync(resolved)) { + throw new ArgumentError(`Story media file not found: ${resolved}`); + } + const ext = path.extname(resolved).toLowerCase(); + if (SUPPORTED_STORY_IMAGE_EXTENSIONS.has(ext)) { + return { type: 'image', filePath: resolved }; + } + if (SUPPORTED_STORY_VIDEO_EXTENSIONS.has(ext)) { + return { type: 'video', filePath: resolved }; + } + throw new ArgumentError( + `Unsupported story media format: ${ext}`, + 'Supported formats: images (.jpg, .jpeg, .png, .webp) and videos (.mp4)', + ); +} + +async function resolveCurrentUserId(page: IPage): Promise { + const cookies = await page.getCookies({ domain: 'instagram.com' }); + return cookies.find((cookie) => cookie.name === 'ds_user_id')?.value || ''; +} + +async function resolveCurrentUsername(page: IPage, currentUserId = ''): Promise { + if (!currentUserId) return ''; + const runtimeInfo = await resolveInstagramRuntimeInfo(page); + const apiResult = await page.evaluate(` + (async () => { + const userId = ${JSON.stringify(currentUserId)}; + const appId = ${JSON.stringify(runtimeInfo.appId || '')}; + try { + const res = await fetch( + 'https://www.instagram.com/api/v1/users/' + encodeURIComponent(userId) + '/info/', + { + credentials: 'include', + headers: appId ? { 'X-IG-App-ID': appId } : {}, + }, + ); + if (!res.ok) return { ok: false }; + const data = await res.json(); + const username = data?.user?.username || ''; + return { ok: !!username, username }; + } catch { + return { ok: false }; + } + })() + `) as { ok?: boolean; username?: string }; + + return apiResult?.ok && apiResult.username ? apiResult.username : ''; +} + +function buildStorySuccessResult(mediaItem: InstagramStoryMediaItem, url: string): InstagramStorySuccessRow[] { + return [{ + status: '✅ Posted', + detail: mediaItem.type === 'video' + ? 'Single video story shared successfully' + : 'Single story shared successfully', + url, + }]; +} + +cli({ + site: 'instagram', + name: 'story', + description: 'Post a single Instagram story image or video', + domain: 'www.instagram.com', + strategy: Strategy.UI, + browser: true, + timeoutSeconds: 300, + args: [ + { name: 'media', required: false, valueRequired: true, help: 'Path to a single story image or video file' }, + ], + columns: ['status', 'detail', 'url'], + validateArgs: validateInstagramStoryArgs, + func: async (page: IPage | null, kwargs) => { + const browserPage = requirePage(page); + const mediaItem = normalizeStoryMediaItem(kwargs as Record); + const currentUserId = await resolveCurrentUserId(browserPage); + const privateConfig = await resolveInstagramPrivatePublishConfig(browserPage); + const storyResult = await publishStoryViaPrivateApi({ + page: browserPage, + mediaItem, + content: '', + apiContext: privateConfig.apiContext, + jazoest: privateConfig.jazoest, + currentUserId, + }); + const username = await resolveCurrentUsername(browserPage, currentUserId); + const mediaPk = storyResult.mediaPk || storyResult.uploadId; + const url = username && mediaPk + ? new URL(`/stories/${username}/${mediaPk}/`, INSTAGRAM_HOME_URL).toString() + : ''; + return buildStorySuccessResult(mediaItem, url); + }, +}); diff --git a/src/commanderAdapter.test.ts b/src/commanderAdapter.test.ts index f330e258..c7873dcb 100644 --- a/src/commanderAdapter.test.ts +++ b/src/commanderAdapter.test.ts @@ -125,6 +125,57 @@ describe('commanderAdapter boolean alias support', () => { }); }); +describe('commanderAdapter value-required optional options', () => { + const cmd: CliCommand = { + site: 'instagram', + name: 'post', + description: 'Post to Instagram', + browser: true, + args: [ + { name: 'image', valueRequired: true, help: 'Single image path' }, + { name: 'images', valueRequired: true, help: 'Comma-separated image paths' }, + { name: 'content', positional: true, required: false, help: 'Caption text' }, + ], + validateArgs: (kwargs) => { + if (!kwargs.image && !kwargs.images) { + throw new Error('media required'); + } + }, + func: vi.fn(), + }; + + beforeEach(() => { + mockExecuteCommand.mockReset(); + mockExecuteCommand.mockResolvedValue([]); + mockRenderOutput.mockReset(); + delete process.env.OPENCLI_VERBOSE; + process.exitCode = undefined; + }); + + it('requires a value when --image is present', async () => { + const program = new Command(); + program.exitOverride(); + const siteCmd = program.command('instagram'); + registerCommandToProgram(siteCmd, cmd); + + await expect( + program.parseAsync(['node', 'opencli', 'instagram', 'post', '--image']), + ).rejects.toMatchObject({ code: 'commander.optionMissingArgument' }); + expect(mockExecuteCommand).not.toHaveBeenCalled(); + }); + + it('runs validateArgs before executeCommand so missing media does not dispatch the browser command', async () => { + const program = new Command(); + const siteCmd = program.command('instagram'); + registerCommandToProgram(siteCmd, cmd); + + await program.parseAsync(['node', 'opencli', 'instagram', 'post', 'caption only']); + + expect(mockExecuteCommand).not.toHaveBeenCalled(); + expect(process.exitCode).toBeDefined(); + }); +}); + describe('commanderAdapter command aliases', () => { const cmd: CliCommand = { site: 'notebooklm', diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 27c78f02..15f8169d 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -62,7 +62,8 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi subCmd.argument(bracket, arg.help ?? ''); positionalArgs.push(arg); } else { - const flag = arg.required ? `--${arg.name} ` : `--${arg.name} [value]`; + const expectsValue = arg.required || arg.valueRequired; + const flag = expectsValue ? `--${arg.name} ` : `--${arg.name} [value]`; if (arg.required) subCmd.requiredOption(flag, arg.help ?? ''); else if (arg.default != null) subCmd.option(flag, arg.help ?? '', String(arg.default)); else subCmd.option(flag, arg.help ?? ''); @@ -93,6 +94,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi const v = optionsRecord[arg.name] ?? optionsRecord[camelName]; if (v !== undefined) kwargs[arg.name] = normalizeArgValue(arg.type, v, arg.name); } + cmd.validateArgs?.(kwargs); const verbose = optionsRecord.verbose === true; let format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table'; diff --git a/src/execution.ts b/src/execution.ts index 1791bafa..c700ea93 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -139,6 +139,7 @@ export async function executeCommand( let kwargs: CommandArgs; try { kwargs = coerceAndValidateArgs(cmd.args, rawKwargs); + cmd.validateArgs?.(kwargs); } catch (err) { if (err instanceof ArgumentError) throw err; throw new ArgumentError(getErrorMessage(err)); diff --git a/src/registry.ts b/src/registry.ts index eb9a12e7..f9a9d4f2 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -17,6 +17,7 @@ export interface Arg { type?: string; default?: unknown; required?: boolean; + valueRequired?: boolean; positional?: boolean; help?: string; choices?: string[]; @@ -47,6 +48,7 @@ export interface CliCommand { source?: string; footerExtra?: (kwargs: CommandArgs) => string | undefined; requiredEnv?: RequiredEnv[]; + validateArgs?: (kwargs: CommandArgs) => void; /** Deprecation note shown in help / execution warnings. */ deprecated?: boolean | string; /** Preferred replacement command, if any. */ diff --git a/src/serialization.ts b/src/serialization.ts index 114d39ce..27c13183 100644 --- a/src/serialization.ts +++ b/src/serialization.ts @@ -14,6 +14,7 @@ export type SerializedArg = { name: string; type: string; required: boolean; + valueRequired: boolean; positional: boolean; choices: string[]; default: unknown; @@ -26,6 +27,7 @@ export function serializeArg(a: Arg): SerializedArg { name: a.name, type: a.type ?? 'string', required: !!a.required, + valueRequired: !!a.valueRequired, positional: !!a.positional, choices: a.choices ?? [], default: a.default ?? null, diff --git a/src/types.ts b/src/types.ts index cb31594e..952a7e21 100644 --- a/src/types.ts +++ b/src/types.ts @@ -56,6 +56,8 @@ export interface IPage { getFormState(): Promise; wait(options: number | WaitOptions): Promise; tabs(): Promise; + closeTab?(index?: number): Promise; + newTab?(): Promise; selectTab(index: number): Promise; networkRequests(includeStatic?: boolean): Promise; consoleMessages(level?: string): Promise; @@ -65,11 +67,18 @@ export interface IPage { getInterceptedRequests(): Promise; waitForCapture(timeout?: number): Promise; screenshot(options?: ScreenshotOptions): Promise; + startNetworkCapture?(pattern?: string): Promise; + readNetworkCapture?(): Promise; /** * Set local file paths on a file input element via CDP DOM.setFileInputFiles. * Chrome reads the files directly — no base64 encoding or payload size limits. */ setFileInput?(files: string[], selector?: string): Promise; + /** + * Insert text via native CDP Input.insertText into the currently focused element. + * Useful for rich editors that ignore synthetic DOM value/text mutations. + */ + insertText?(text: string): Promise; closeWindow?(): Promise; /** Returns the current page URL, or null if unavailable. */ getCurrentUrl?(): Promise;