tail-fin-twitter 0.1.0

Twitter/X adapter for tail-fin: timeline, search via GraphQL API
Documentation
use night_fury_core::BrowserSession;
use tail_fin_common::TailFinError;

use crate::types::{ActionResult, MediaItem, Trend};

pub(crate) async fn browser_post(
    session: &BrowserSession,
    text: &str,
) -> Result<ActionResult, TailFinError> {
    session.navigate("https://x.com/compose/tweet").await?;
    let _ = session.wait_for_network_idle(15000, 1000).await;
    tokio::time::sleep(std::time::Duration::from_secs(2)).await;

    let escaped = escape_js_string(text);
    let js = format!(
        r#"(async () => {{
            try {{
                const box = document.querySelector('[data-testid="tweetTextarea_0"]');
                if (!box) return JSON.stringify({{ status: "failed", message: "Could not find tweet composer." }});
                box.focus();
                const dt = new DataTransfer();
                dt.setData('text/plain', "{escaped}");
                box.dispatchEvent(new ClipboardEvent('paste', {{ clipboardData: dt, bubbles: true, cancelable: true }}));
                await new Promise(r => setTimeout(r, 1000));
                const btn = document.querySelector('[data-testid="tweetButton"]') || document.querySelector('[data-testid="tweetButtonInline"]');
                if (btn && !btn.disabled) {{ btn.click(); return JSON.stringify({{ status: "success", message: "Tweet posted." }}); }}
                return JSON.stringify({{ status: "failed", message: "Tweet button disabled or not found." }});
            }} catch (e) {{ return JSON.stringify({{ status: "failed", message: e.toString() }}); }}
        }})()"#
    );

    parse_action_result(session.eval(&js).await?)
}

pub(crate) async fn browser_like(
    session: &BrowserSession,
    tweet_url: &str,
) -> Result<ActionResult, TailFinError> {
    session.navigate(tweet_url).await?;
    let _ = session.wait_for_network_idle(15000, 1000).await;
    tokio::time::sleep(std::time::Duration::from_secs(2)).await;

    let js = r#"(async () => {
        try {
            for (let i = 0; i < 20; i++) {
                const unlike = document.querySelector('[data-testid="unlike"]');
                if (unlike) return JSON.stringify({ status: "success", message: "Tweet is already liked." });
                const btn = document.querySelector('[data-testid="like"]');
                if (btn) {
                    btn.click();
                    await new Promise(r => setTimeout(r, 1000));
                    const verify = document.querySelector('[data-testid="unlike"]');
                    if (verify) return JSON.stringify({ status: "success", message: "Tweet liked." });
                    return JSON.stringify({ status: "failed", message: "Like action did not register." });
                }
                await new Promise(r => setTimeout(r, 500));
            }
            return JSON.stringify({ status: "failed", message: "Like button not found." });
        } catch (e) { return JSON.stringify({ status: "failed", message: e.toString() }); }
    })()"#;

    parse_action_result(session.eval(js).await?)
}

pub(crate) async fn browser_follow(
    session: &BrowserSession,
    username: &str,
    follow: bool,
) -> Result<ActionResult, TailFinError> {
    let username = username.trim_start_matches('@');
    session
        .navigate(&format!("https://x.com/{}", username))
        .await?;
    let _ = session.wait_for_network_idle(15000, 1000).await;
    tokio::time::sleep(std::time::Duration::from_secs(2)).await;

    let (target_testid, verify_testid, action_word) = if follow {
        ("-follow", "-unfollow", "followed")
    } else {
        ("-unfollow", "-follow", "unfollowed")
    };

    let js = format!(
        r#"(async () => {{
            try {{
                for (let i = 0; i < 20; i++) {{
                    const btn = document.querySelector('[data-testid$="{target_testid}"]');
                    if (btn) {{
                        btn.click();
                        await new Promise(r => setTimeout(r, 1500));
                        const confirm = document.querySelector('[data-testid="confirmationSheetConfirm"]');
                        if (confirm) {{ confirm.click(); await new Promise(r => setTimeout(r, 1000)); }}
                        return JSON.stringify({{ status: "success", message: "Successfully {action_word} @{username}." }});
                    }}
                    const verify = document.querySelector('[data-testid$="{verify_testid}"]');
                    if (verify) return JSON.stringify({{ status: "success", message: "Already {action_word} @{username}." }});
                    await new Promise(r => setTimeout(r, 500));
                }}
                return JSON.stringify({{ status: "failed", message: "Button not found." }});
            }} catch (e) {{ return JSON.stringify({{ status: "failed", message: e.toString() }}); }}
        }})()"#
    );

    parse_action_result(session.eval(&js).await?)
}

pub(crate) async fn browser_delete(
    session: &BrowserSession,
    tweet_url: &str,
) -> Result<ActionResult, TailFinError> {
    session.navigate(tweet_url).await?;
    let _ = session.wait_for_network_idle(15000, 1000).await;
    tokio::time::sleep(std::time::Duration::from_secs(2)).await;

    let js = r#"(async () => {
        try {
            const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
            const articles = Array.from(document.querySelectorAll('article'));
            if (!articles.length) return JSON.stringify({ status: "failed", message: "Tweet not found on page." });

            for (const article of articles) {
                const buttons = Array.from(article.querySelectorAll('button,[role="button"]'));
                const moreMenu = buttons.find(el => visible(el) && (el.getAttribute('aria-label') || '').trim() === 'More');
                if (!moreMenu) continue;

                moreMenu.click();
                await new Promise(r => setTimeout(r, 1000));

                const items = Array.from(document.querySelectorAll('[role="menuitem"]'));
                const deleteBtn = items.find(item => {
                    const text = (item.textContent || '').trim();
                    return text.includes('Delete') && !text.includes('List');
                });
                if (!deleteBtn) {
                    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
                    await new Promise(r => setTimeout(r, 500));
                    continue;
                }

                deleteBtn.click();
                await new Promise(r => setTimeout(r, 1000));

                const confirmBtn = document.querySelector('[data-testid="confirmationSheetConfirm"]');
                if (confirmBtn) {
                    confirmBtn.click();
                    return JSON.stringify({ status: "success", message: "Tweet deleted." });
                }
                return JSON.stringify({ status: "failed", message: "Delete confirmation did not appear." });
            }
            return JSON.stringify({ status: "failed", message: "Delete option not found. This tweet may not belong to you." });
        } catch (e) { return JSON.stringify({ status: "failed", message: e.toString() }); }
    })()"#;

    parse_action_result(session.eval(js).await?)
}

pub(crate) async fn browser_block(
    session: &BrowserSession,
    username: &str,
    block: bool,
) -> Result<ActionResult, TailFinError> {
    let username = username.trim_start_matches('@');
    session
        .navigate(&format!("https://x.com/{}", username))
        .await?;
    let _ = session.wait_for_network_idle(15000, 1000).await;
    tokio::time::sleep(std::time::Duration::from_secs(2)).await;

    let js = if block {
        format!(
            r#"(async () => {{
                try {{
                    const blocked = document.querySelector('[data-testid$="-unblock"]');
                    if (blocked) return JSON.stringify({{ status: "success", message: "Already blocking @{username}." }});
                    const moreBtn = document.querySelector('[data-testid="userActions"]');
                    if (!moreBtn) return JSON.stringify({{ status: "failed", message: "User actions menu not found." }});
                    moreBtn.click();
                    await new Promise(r => setTimeout(r, 1000));
                    const items = document.querySelectorAll('[role="menuitem"]');
                    let blockItem = null;
                    for (const item of items) {{ if (item.textContent && item.textContent.includes('Block')) {{ blockItem = item; break; }} }}
                    if (!blockItem) return JSON.stringify({{ status: "failed", message: "Block option not found." }});
                    blockItem.click();
                    await new Promise(r => setTimeout(r, 1000));
                    const confirm = document.querySelector('[data-testid="confirmationSheetConfirm"]');
                    if (confirm) {{ confirm.click(); await new Promise(r => setTimeout(r, 1000)); }}
                    return JSON.stringify({{ status: "success", message: "Blocked @{username}." }});
                }} catch (e) {{ return JSON.stringify({{ status: "failed", message: e.toString() }}); }}
            }})()"#
        )
    } else {
        format!(
            r#"(async () => {{
                try {{
                    for (let i = 0; i < 20; i++) {{
                        const followBtn = document.querySelector('[data-testid="{username}-follow"]');
                        if (followBtn) return JSON.stringify({{ status: "success", message: "Not blocking @{username} (already unblocked)." }});
                        const unblockBtn = document.querySelector('[data-testid="{username}-unblock"]') || document.querySelector('[data-testid$="-unblock"]');
                        if (unblockBtn) {{
                            unblockBtn.click();
                            await new Promise(r => setTimeout(r, 1000));
                            const confirm = document.querySelector('[data-testid="confirmationSheetConfirm"]');
                            if (confirm) {{ confirm.click(); await new Promise(r => setTimeout(r, 1000)); }}
                            return JSON.stringify({{ status: "success", message: "Unblocked @{username}." }});
                        }}
                        await new Promise(r => setTimeout(r, 500));
                    }}
                    return JSON.stringify({{ status: "failed", message: "Unblock button not found." }});
                }} catch (e) {{ return JSON.stringify({{ status: "failed", message: e.toString() }}); }}
            }})()"#
        )
    };

    parse_action_result(session.eval(&js).await?)
}

pub(crate) async fn browser_reply(
    session: &BrowserSession,
    tweet_url: &str,
    text: &str,
) -> Result<ActionResult, TailFinError> {
    let tweet_id = crate::util::extract_tweet_id(tweet_url);
    let compose_url = format!("https://x.com/compose/post?in_reply_to={}", tweet_id);
    session.navigate(&compose_url).await?;
    let _ = session.wait_for_network_idle(15000, 1000).await;
    tokio::time::sleep(std::time::Duration::from_secs(3)).await;

    let escaped = escape_js_string(text);
    let js = format!(
        r#"(async () => {{
            try {{
                const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]'));
                const box = boxes.find(el => el.offsetParent !== null) || boxes[0];
                if (!box) return JSON.stringify({{ status: "failed", message: "Reply text area not found." }});
                box.focus();
                const dt = new DataTransfer();
                dt.setData('text/plain', "{escaped}");
                box.dispatchEvent(new ClipboardEvent('paste', {{ clipboardData: dt, bubbles: true, cancelable: true }}));
                await new Promise(r => setTimeout(r, 1000));
                const buttons = Array.from(document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]'));
                const btn = buttons.find(el => el.offsetParent !== null && !el.disabled);
                if (!btn) return JSON.stringify({{ status: "failed", message: "Reply button disabled or not found." }});
                btn.click();
                return JSON.stringify({{ status: "success", message: "Reply posted." }});
            }} catch (e) {{ return JSON.stringify({{ status: "failed", message: e.toString() }}); }}
        }})()"#
    );

    parse_action_result(session.eval(&js).await?)
}

pub(crate) async fn browser_trending(
    session: &BrowserSession,
    count: usize,
) -> Result<Vec<Trend>, TailFinError> {
    session
        .navigate("https://x.com/explore/tabs/trending")
        .await?;
    let _ = session.wait_for_network_idle(15000, 1000).await;
    tokio::time::sleep(std::time::Duration::from_secs(3)).await;

    let js = r#"(() => {
        const items = [];
        const cells = document.querySelectorAll('[data-testid="trend"]');
        cells.forEach((cell) => {
            const text = cell.textContent || '';
            if (text.includes('Promoted')) return;
            const container = cell.querySelector(':scope > div');
            if (!container) return;
            const divs = container.children;
            if (divs.length < 2) return;
            const topic = divs[1].textContent.trim();
            if (!topic) return;
            const catText = divs[0].textContent.trim();
            const category = catText.replace(/^\d+\s*/, '').replace(/^\xB7\s*/, '').trim();
            let tweets = 'N/A';
            for (let j = 2; j < divs.length; j++) {
                if (divs[j].matches('[data-testid="caret"]') || divs[j].querySelector('[data-testid="caret"]')) continue;
                const t = divs[j].textContent.trim();
                if (t && /\d/.test(t)) { tweets = t; break; }
            }
            items.push(JSON.stringify({ rank: items.length + 1, topic, tweets, category }));
        });
        return '[' + items.join(',') + ']';
    })()"#;

    let result = session.eval(js).await?;
    let s = result.as_str().unwrap_or("[]");
    let trends: Vec<Trend> = serde_json::from_str(s).unwrap_or_default();
    Ok(trends.into_iter().take(count).collect())
}

pub(crate) async fn browser_download(
    session: &BrowserSession,
    username: &str,
    count: usize,
) -> Result<Vec<MediaItem>, TailFinError> {
    let username = username.trim_start_matches('@');
    session
        .navigate(&format!("https://x.com/{}/media", username))
        .await?;
    let _ = session.wait_for_network_idle(15000, 1000).await;
    tokio::time::sleep(std::time::Duration::from_secs(3)).await;

    for _ in 0..(count / 5 + 1) {
        session
            .eval("window.scrollBy(0, window.innerHeight)")
            .await?;
        tokio::time::sleep(std::time::Duration::from_secs(2)).await;
    }

    let js = r#"(() => {
        const media = [];
        const seen = new Set();
        document.querySelectorAll('img[src*="pbs.twimg.com/media"]').forEach(img => {
            let src = img.src || '';
            src = src.replace(/&name=\w+$/, '&name=large');
            if (!src.includes('&name=')) src = src + '&name=large';
            if (!seen.has(src)) { seen.add(src); media.push(JSON.stringify({ type: 'image', url: src })); }
        });
        document.querySelectorAll('video').forEach(video => {
            const src = video.src || '';
            if (src && !seen.has(src)) { seen.add(src); media.push(JSON.stringify({ type: 'video', url: src })); }
        });
        return '[' + media.join(',') + ']';
    })()"#;

    let result = session.eval(js).await?;
    let s = result.as_str().unwrap_or("[]");
    let media: Vec<MediaItem> = serde_json::from_str(s).unwrap_or_default();
    Ok(media.into_iter().take(count).collect())
}

// ── helpers ──────────────────────────────────────────────────────────────────

fn escape_js_string(s: &str) -> String {
    s.replace('\\', "\\\\")
        .replace('"', "\\\"")
        .replace('\n', "\\n")
}

fn parse_action_result(value: serde_json::Value) -> Result<ActionResult, TailFinError> {
    let s = value
        .as_str()
        .unwrap_or(r#"{"status":"failed","message":"no response"}"#);
    Ok(serde_json::from_str(s).unwrap_or(ActionResult {
        status: "failed".into(),
        message: s.to_string(),
    }))
}