diff --git a/adapters/twitter/followers.yaml b/adapters/twitter/followers.yaml index 118ac87..88480c3 100644 --- a/adapters/twitter/followers.yaml +++ b/adapters/twitter/followers.yaml @@ -44,20 +44,14 @@ pipeline: pattern: Followers collect: false - # Step 5: Click the followers link (SPA navigation, preserves interceptor) + # Step 5: Navigate specifically to the full followers timeline. + # Avoid /verified_followers: it can legitimately be empty even when the + # account has ordinary followers. This mirrors current OpenCLI behavior. - evaluate: | - (async () => { - const target = data; - const selectors = [ - 'a[href="/' + target + '/verified_followers"]', - 'a[href="/' + target + '/followers"]', - ]; - for (const sel of selectors) { - const link = document.querySelector(sel); - if (link) { link.click(); return true; } - } - // Fallback: try navigating directly via SPA - window.location.href = 'https://x.com/' + target + '/followers'; + (() => { + const targetPath = '/' + data + '/followers'; + window.history.pushState({}, '', targetPath); + window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); return true; })() @@ -77,26 +71,26 @@ pipeline: const seen = new Map(); for (const req of requests) { try { - let instructions = req.data?.user?.result?.timeline?.timeline?.instructions; - if (!instructions) continue; - let addEntries = instructions.find(i => i.type === 'TimelineAddEntries') - || instructions.find(i => i.entries && Array.isArray(i.entries)); - if (!addEntries) continue; - for (const entry of addEntries.entries) { - if (!entry.entryId.startsWith('user-')) continue; - const item = entry.content?.itemContent?.user_results?.result; - if (!item || item.__typename !== 'User') continue; - const core = item.core || {}; - const legacy = item.legacy || {}; - const sn = core.screen_name || legacy.screen_name || 'unknown'; - if (!seen.has(sn)) { - seen.set(sn, true); - results.push({ - screen_name: sn, - name: core.name || legacy.name || 'unknown', - bio: legacy.description || item.profile_bio?.description || '', - followers: legacy.followers_count || legacy.normal_followers_count || 0 - }); + let instructions = req.data?.user?.result?.timeline_v2?.timeline?.instructions + || req.data?.user?.result?.timeline?.timeline?.instructions + || []; + for (const instruction of instructions) { + for (const entry of instruction.entries || []) { + if (!entry.entryId.startsWith('user-')) continue; + const item = entry.content?.itemContent?.user_results?.result; + if (!item || item.__typename !== 'User') continue; + const core = item.core || {}; + const legacy = item.legacy || {}; + const sn = core.screen_name || legacy.screen_name || 'unknown'; + if (!seen.has(sn)) { + seen.set(sn, true); + results.push({ + screen_name: sn, + name: core.name || legacy.name || 'unknown', + bio: legacy.description || item.profile_bio?.description || '', + followers: legacy.followers_count || legacy.normal_followers_count || 0 + }); + } } } } catch {} diff --git a/adapters/twitter/following.yaml b/adapters/twitter/following.yaml index 9aced8e..589348b 100644 --- a/adapters/twitter/following.yaml +++ b/adapters/twitter/following.yaml @@ -71,26 +71,26 @@ pipeline: const seen = new Map(); for (const req of requests) { try { - let instructions = req.data?.user?.result?.timeline?.timeline?.instructions; - if (!instructions) continue; - let addEntries = instructions.find(i => i.type === 'TimelineAddEntries') - || instructions.find(i => i.entries && Array.isArray(i.entries)); - if (!addEntries) continue; - for (const entry of addEntries.entries) { - if (!entry.entryId.startsWith('user-')) continue; - const item = entry.content?.itemContent?.user_results?.result; - if (!item || item.__typename !== 'User') continue; - const core = item.core || {}; - const legacy = item.legacy || {}; - const sn = core.screen_name || legacy.screen_name || 'unknown'; - if (!seen.has(sn)) { - seen.set(sn, true); - results.push({ - screen_name: sn, - name: core.name || legacy.name || 'unknown', - bio: legacy.description || item.profile_bio?.description || '', - followers: legacy.followers_count || legacy.normal_followers_count || 0 - }); + let instructions = req.data?.user?.result?.timeline_v2?.timeline?.instructions + || req.data?.user?.result?.timeline?.timeline?.instructions + || []; + for (const instruction of instructions) { + for (const entry of instruction.entries || []) { + if (!entry.entryId.startsWith('user-')) continue; + const item = entry.content?.itemContent?.user_results?.result; + if (!item || item.__typename !== 'User') continue; + const core = item.core || {}; + const legacy = item.legacy || {}; + const sn = core.screen_name || legacy.screen_name || 'unknown'; + if (!seen.has(sn)) { + seen.set(sn, true); + results.push({ + screen_name: sn, + name: core.name || legacy.name || 'unknown', + bio: legacy.description || item.profile_bio?.description || '', + followers: legacy.followers_count || legacy.normal_followers_count || 0 + }); + } } } } catch {} diff --git a/adapters/twitter/notifications.yaml b/adapters/twitter/notifications.yaml index 6090b05..e5bb8fa 100644 --- a/adapters/twitter/notifications.yaml +++ b/adapters/twitter/notifications.yaml @@ -32,8 +32,8 @@ pipeline: - wait: 5 - scroll: - times: 2 - delayMs: 2000 + count: 2 + delay: 2000 - collect: parse: | diff --git a/adapters/twitter/reply.yaml b/adapters/twitter/reply.yaml index cb9579c..0cde6b1 100644 --- a/adapters/twitter/reply.yaml +++ b/adapters/twitter/reply.yaml @@ -22,32 +22,97 @@ args: columns: [status, message, text] pipeline: + # Open a known-safe X page first, then parse the caller-supplied URL in JS. + # Navigating directly to args.url is rejected by some AutoCLI/extension paths + # before the browser ever reaches X. - navigate: - url: ${{ args.url }} - settleMs: 5000 + url: https://x.com/home + settleMs: 2500 + + - evaluate: | + (() => { + const first = String(${{ args.url | json }} || '').trim(); + const second = String(${{ args.text | json }} || '').trim(); + const statusRe = /^https?:\/\/(?:www\.)?(?:x\.com|twitter\.com|mobile\.twitter\.com)\/(?:[^/]+|i)\/status\/(\d+)(?:[/?#].*)?$/i; + const match = first.match(statusRe) || second.match(statusRe); + if (!match) throw new Error('Reply requires one x.com or twitter.com status URL argument.'); + return match[1]; + })() + + - navigate: + url: "https://x.com/compose/post?in_reply_to=${{ data }}" + settleMs: 2500 - evaluate: | (async () => { try { - const replyText = ${{ args.text | json }}; - const box = document.querySelector('[data-testid="tweetTextarea_0"]'); - if (box) { - box.focus(); - document.execCommand('insertText', false, replyText); - } else { - return [{ status: 'failed', message: 'Could not find the reply text area. Are you logged in?', text: replyText }]; - } - - await new Promise(r => setTimeout(r, 1000)); - - const btn = document.querySelector('[data-testid="tweetButtonInline"]'); - if (btn && !btn.disabled) { - btn.click(); - await new Promise(r => setTimeout(r, 3000)); - return [{ status: 'success', message: 'Reply posted successfully.', text: replyText }]; - } else { + const first = String(${{ args.url | json }} || '').trim(); + const second = String(${{ args.text | json }} || '').trim(); + const statusRe = /^https?:\/\/(?:www\.)?(?:x\.com|twitter\.com|mobile\.twitter\.com)\/(?:[^/]+|i)\/status\/\d+(?:[/?#].*)?$/i; + const replyText = statusRe.test(first) ? second : first; + const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0); + const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + + let box = null; + for (let i = 0; i < 30; i++) { + const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]')); + box = boxes.find(visible) || boxes[0] || null; + if (box) break; + await sleep(500); + } + if (!box) { + return [{ status: 'failed', message: 'Could not open the reply composer. Are you logged in?', text: replyText }]; + } + + box.focus(); + if (!document.execCommand('insertText', false, replyText)) { + const transfer = new DataTransfer(); + transfer.setData('text/plain', replyText); + box.dispatchEvent(new ClipboardEvent('paste', { + clipboardData: transfer, + bubbles: true, + cancelable: true, + })); + } + await sleep(1000); + + const normalize = (s) => String(s || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim(); + const actual = box.innerText || box.textContent || ''; + if (!normalize(actual).includes(normalize(replyText))) { + return [{ status: 'failed', message: 'Could not verify reply text in the composer after typing.', text: replyText }]; + } + + for (const toast of Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))) { + if (visible(toast)) toast.setAttribute('data-autocli-before-reply-toast', 'true'); + } + + let button = null; + for (let i = 0; i < 30; i++) { + const buttons = Array.from(document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')); + button = buttons.find(el => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true') || null; + if (button) break; + await sleep(500); + } + if (!button) { return [{ status: 'failed', message: 'Reply button is disabled or not found.', text: replyText }]; } + button.click(); + + for (let i = 0; i < 30; i++) { + await sleep(500); + const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]')) + .filter(el => visible(el) && !el.hasAttribute('data-autocli-before-reply-toast')); + const success = toasts.find(el => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || '')); + if (success) { + return [{ status: 'success', message: 'Reply posted successfully.', text: replyText }]; + } + const failure = toasts.find(el => /failed|error|try again|not sent|could not/i.test(el.textContent || '')); + if (failure) { + return [{ status: 'failed', message: (failure.textContent || 'Reply failed to post.').trim(), text: replyText }]; + } + } + + return [{ status: 'failed', message: 'Reply submission could not be confirmed. Check the tweet before retrying; the reply may already be live.', text: replyText }]; } catch (e) { return [{ status: 'failed', message: e.toString(), text: ${{ args.text | json }} }]; } diff --git a/crates/autocli-pipeline/src/steps/browser.rs b/crates/autocli-pipeline/src/steps/browser.rs index 89dbf3e..f9e2aba 100644 --- a/crates/autocli-pipeline/src/steps/browser.rs +++ b/crates/autocli-pipeline/src/steps/browser.rs @@ -597,8 +597,8 @@ impl StepHandler for CollectStep { let js = format!( r#"(() => {{ const args = {args_json}; - const requests = window.__opencli_intercepted || []; - window.__opencli_intercepted = []; + const requests = window.__autocli_intercepted || []; + window.__autocli_intercepted = []; const parseFn = {parse_fn}; return parseFn(requests); }})()"# @@ -643,6 +643,7 @@ mod tests { struct MockPage { goto_url: std::sync::Mutex>, evaluate_result: Value, + evaluate_expression: std::sync::Mutex>, } impl MockPage { @@ -650,6 +651,7 @@ mod tests { Self { goto_url: std::sync::Mutex::new(None), evaluate_result, + evaluate_expression: std::sync::Mutex::new(None), } } } @@ -673,7 +675,8 @@ mod tests { async fn content(&self) -> Result { Ok("".to_string()) } - async fn evaluate(&self, _expression: &str) -> Result { + async fn evaluate(&self, expression: &str) -> Result { + *self.evaluate_expression.lock().unwrap() = Some(expression.to_string()); Ok(self.evaluate_result.clone()) } async fn wait_for_selector( @@ -799,6 +802,25 @@ mod tests { assert_eq!(result, json!({"items": [1, 2, 3]})); } + #[tokio::test] + async fn test_collect_step_reads_autocli_interceptor_buffer() { + let mock = Arc::new(MockPage::new(json!([{"ok": true}]))); + let step = CollectStep; + let result = step + .execute( + Some(mock.clone()), + &json!({"parse": "(requests) => requests"}), + &json!(null), + &empty_args(), + ) + .await + .unwrap(); + assert_eq!(result, json!([{"ok": true}])); + let expression = mock.evaluate_expression.lock().unwrap().clone().unwrap(); + assert!(expression.contains("window.__autocli_intercepted")); + assert!(!expression.contains("window.__opencli_intercepted")); + } + #[tokio::test] async fn test_browser_step_requires_page() { let step = NavigateStep;