Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 27 additions & 33 deletions adapters/twitter/followers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
})()

Expand All @@ -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 {}
Expand Down
40 changes: 20 additions & 20 deletions adapters/twitter/following.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
4 changes: 2 additions & 2 deletions adapters/twitter/notifications.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ pipeline:
- wait: 5

- scroll:
times: 2
delayMs: 2000
count: 2
delay: 2000

- collect:
parse: |
Expand Down
103 changes: 84 additions & 19 deletions adapters/twitter/reply.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }} }];
}
Expand Down
28 changes: 25 additions & 3 deletions crates/autocli-pipeline/src/steps/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}})()"#
Expand Down Expand Up @@ -643,13 +643,15 @@ mod tests {
struct MockPage {
goto_url: std::sync::Mutex<Option<String>>,
evaluate_result: Value,
evaluate_expression: std::sync::Mutex<Option<String>>,
}

impl MockPage {
fn new(evaluate_result: Value) -> Self {
Self {
goto_url: std::sync::Mutex::new(None),
evaluate_result,
evaluate_expression: std::sync::Mutex::new(None),
}
}
}
Expand All @@ -673,7 +675,8 @@ mod tests {
async fn content(&self) -> Result<String, CliError> {
Ok("<html></html>".to_string())
}
async fn evaluate(&self, _expression: &str) -> Result<Value, CliError> {
async fn evaluate(&self, expression: &str) -> Result<Value, CliError> {
*self.evaluate_expression.lock().unwrap() = Some(expression.to_string());
Ok(self.evaluate_result.clone())
}
async fn wait_for_selector(
Expand Down Expand Up @@ -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;
Expand Down