diff --git a/agent_reach/channels/twitter.py b/agent_reach/channels/twitter.py index ae23250..2c33a88 100644 --- a/agent_reach/channels/twitter.py +++ b/agent_reach/channels/twitter.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""Twitter/X — check if bird CLI (@steipete/bird) is available.""" +"""Twitter/X — check if twitter-cli (public-clis/twitter-cli) is available.""" import shutil import subprocess @@ -9,7 +9,7 @@ from .base import Channel class TwitterChannel(Channel): name = "twitter" description = "Twitter/X 推文" - backends = ["bird CLI"] + backends = ["twitter-cli"] tier = 1 def can_handle(self, url: str) -> bool: @@ -18,33 +18,36 @@ class TwitterChannel(Channel): return "x.com" in d or "twitter.com" in d def check(self, config=None): - bird = shutil.which("bird") or shutil.which("birdx") - if not bird: + twitter = shutil.which("twitter") + if not twitter: return "warn", ( - "bird CLI 未安装。搜索可通过 Exa 替代。安装:\n" - " npm install -g @steipete/bird" + "twitter-cli 未安装。安装方式:\n" + " pipx install twitter-cli\n" + "或:\n" + " uv tool install twitter-cli" ) try: r = subprocess.run( - [bird, "check"], capture_output=True, + [twitter, "status"], capture_output=True, encoding="utf-8", errors="replace", timeout=10 ) output = (r.stdout or "") + (r.stderr or "") - if r.returncode == 0: - return "ok", "完整可用(读取、搜索推文,含长文/X Article)" - # bird check returns 1 when auth is missing - if "Missing credentials" in output or "missing" in output.lower(): + if r.returncode == 0 and "ok: true" in output: + return "ok", ( + "完整可用(搜索、读推文、时间线、长文/Article、" + "用户查询、Thread)" + ) + if "not_authenticated" in output: return "warn", ( - "bird CLI 已安装但未配置认证。设置环境变量:\n" - " export AUTH_TOKEN=\"xxx\"\n" - " export CT0=\"yyy\"\n" - "或运行:\n" - " agent-reach configure twitter-cookies \"auth_token=xxx; ct0=yyy\"" + "twitter-cli 已安装但未认证。设置方式:\n" + " export TWITTER_AUTH_TOKEN=\"xxx\"\n" + " export TWITTER_CT0=\"yyy\"\n" + "或确保已在浏览器中登录 x.com" ) return "warn", ( - "bird CLI 已安装但认证检查失败。运行:\n" - " agent-reach configure twitter-cookies \"auth_token=xxx; ct0=yyy\"" + "twitter-cli 已安装但认证检查失败。运行:\n" + " twitter -v status 查看详细信息" ) except Exception: - return "warn", "bird CLI 已安装但连接失败" + return "warn", "twitter-cli 已安装但连接失败" diff --git a/agent_reach/cli.py b/agent_reach/cli.py index 1c368d4..8551354 100644 --- a/agent_reach/cli.py +++ b/agent_reach/cli.py @@ -502,24 +502,36 @@ def _install_system_deps(): except Exception: print(" [!] Node.js install failed. Try: apt install nodejs npm, or nvm install 22, or download from https://nodejs.org") - # ── bird CLI (for Twitter search) ── - if shutil.which("bird") or shutil.which("birdx"): - print(" ✅ bird CLI already installed") + # ── twitter-cli (for Twitter search) ── + if shutil.which("twitter"): + print(" ✅ twitter-cli already installed") else: - if shutil.which("npm"): + if shutil.which("pipx"): try: subprocess.run( - ["npm", "install", "-g", "@steipete/bird"], + ["pipx", "install", "twitter-cli"], capture_output=True, encoding="utf-8", errors="replace", timeout=120, ) - if shutil.which("bird") or shutil.which("birdx"): - print(" ✅ bird CLI installed (Twitter search + timeline)") + if shutil.which("twitter"): + print(" ✅ twitter-cli installed (Twitter search + timeline + article)") else: - print(" -- bird CLI install failed (optional — Twitter reading still works via Jina)") + print(" -- twitter-cli install failed (optional — Twitter reading still works via Jina)") except Exception: - print(" -- bird CLI install failed (optional — Twitter reading still works via Jina)") + print(" -- twitter-cli install failed (optional — Twitter reading still works via Jina)") + elif shutil.which("uv"): + try: + subprocess.run( + ["uv", "tool", "install", "twitter-cli"], + capture_output=True, encoding="utf-8", errors="replace", timeout=120, + ) + if shutil.which("twitter"): + print(" ✅ twitter-cli installed (Twitter search + timeline + article)") + else: + print(" -- twitter-cli install failed (optional)") + except Exception: + print(" -- twitter-cli install failed (optional)") else: - print(" -- bird CLI requires Node.js (optional — Twitter reading still works via Jina)") + print(" -- twitter-cli requires pipx or uv (optional — Twitter reading still works via Jina)") # ── undici (proxy support for Node.js fetch) ── npm_cmd = shutil.which("npm") @@ -533,7 +545,7 @@ def _install_system_deps(): subprocess.run([npm_cmd, "install", "-g", "undici"], capture_output=True, encoding="utf-8", errors="replace", timeout=60) print(" ✅ undici installed (Node.js proxy support)") except Exception: - print(" -- undici install failed (optional — bird may not work behind proxies)") + print(" -- undici install failed (optional — may not work behind proxies)") # ── yt-dlp JS runtime config (YouTube requires external JS runtime) ── if shutil.which("node"): @@ -733,7 +745,7 @@ def _install_system_deps_safe(): deps = [ ("gh", ["gh"], "GitHub CLI", "https://cli.github.com — or: apt install gh / brew install gh"), ("node", ["node", "npm"], "Node.js", "https://nodejs.org — or: apt install nodejs npm"), - ("bird", ["bird", "birdx"], "bird CLI (Twitter)", "npm install -g @steipete/bird"), + ("twitter", ["twitter"], "twitter-cli (Twitter)", "pipx install twitter-cli"), ] missing = [] @@ -786,7 +798,7 @@ def _install_system_deps_dryrun(): checks = [ ("gh CLI", ["gh"], "apt install gh / brew install gh"), ("Node.js", ["node"], "curl NodeSource setup | bash + apt install nodejs"), - ("bird CLI", ["bird", "birdx"], "npm install -g @steipete/bird"), + ("twitter-cli", ["twitter"], "pipx install twitter-cli"), ] for label, binaries, method in checks: @@ -1000,57 +1012,30 @@ def _cmd_configure(args): config.set("twitter_auth_token", auth_token) config.set("twitter_ct0", ct0) - # Sync credentials to bird CLI env - try: - import json - # Legacy: sync to xfetch session.json for backward compat - xfetch_dir = os.path.join(os.path.expanduser("~"), ".config", "xfetch") - os.makedirs(xfetch_dir, exist_ok=True) - session_path = os.path.join(xfetch_dir, "session.json") - session_data = {} - if os.path.exists(session_path): - with open(session_path, "r", encoding="utf-8") as sf: - session_data = json.load(sf) - session_data["authToken"] = auth_token - session_data["ct0"] = ct0 - with open(session_path, "w", encoding="utf-8") as sf: - json.dump(session_data, sf, indent=2) - os.chmod(session_path, 0o600) - - # bird CLI: write shell-sourceable credentials.env - bird_dir = os.path.join(os.path.expanduser("~"), ".config", "bird") - os.makedirs(bird_dir, exist_ok=True) - env_path = os.path.join(bird_dir, "credentials.env") - with open(env_path, "w", encoding="utf-8") as f: - f.write(f'AUTH_TOKEN="{auth_token}"\n') - f.write(f'CT0="{ct0}"\n') - os.chmod(env_path, 0o600) - - print("✅ Twitter cookies configured (synced to bird)!") - except Exception as e: - print("✅ Twitter cookies configured!") - print(f"[!] Could not sync to bird credentials: {e}") + # Sync credentials to twitter-cli env + print("✅ Twitter cookies configured!") print("Testing Twitter access...", end=" ") try: import subprocess - bird = shutil.which("bird") or shutil.which("birdx") - if not bird: - print("[!] bird CLI not installed. Run: npm install -g @steipete/bird") + twitter_bin = shutil.which("twitter") + if not twitter_bin: + print("[!] twitter-cli not installed. Run: pipx install twitter-cli") else: import os env = os.environ.copy() - env["AUTH_TOKEN"] = auth_token - env["CT0"] = ct0 + env["TWITTER_AUTH_TOKEN"] = auth_token + env["TWITTER_CT0"] = ct0 result = subprocess.run( - [bird, "search", "test", "-n", "1"], + [twitter_bin, "status"], capture_output=True, encoding="utf-8", errors="replace", timeout=15, env=env, ) - if result.returncode == 0 and result.stdout.strip(): - print("✅ Twitter Advanced works!") + output = (result.stdout or "") + (result.stderr or "") + if "ok: true" in output: + print("✅ Twitter access works!") else: - print(f"[!] Test returned no results (cookies might be wrong)") + print("[!] Auth check failed (cookies might be wrong)") except Exception as e: print(f"[X] Failed: {e}") else: @@ -1367,7 +1352,7 @@ def _cmd_uninstall(args): print() print("Optional: remove tools installed by Agent Reach:") print(" npm uninstall -g mcporter") - print(" npm uninstall -g @steipete/bird") + print(" pipx uninstall twitter-cli") print(" npm uninstall -g undici") diff --git a/agent_reach/skill/SKILL.md b/agent_reach/skill/SKILL.md index d070160..64f0d57 100644 --- a/agent_reach/skill/SKILL.md +++ b/agent_reach/skill/SKILL.md @@ -58,7 +58,7 @@ curl -s "https://r.jina.ai/URL" gh search repos "query" --sort stars --limit 10 # Twitter 搜索 -bird search "query" -n 10 +twitter search "query" --limit 10 # YouTube/B站字幕 yt-dlp --write-sub --skip-download -o "/tmp/%(id)s" "URL" diff --git a/agent_reach/skill/references/social.md b/agent_reach/skill/references/social.md index 3a9802c..eecc6b7 100644 --- a/agent_reach/skill/references/social.md +++ b/agent_reach/skill/references/social.md @@ -35,23 +35,30 @@ mcporter call 'douyin.extract_douyin_text(share_link: "https://v.douyin.com/xxx/ > **无需登录** -## Twitter/X (bird CLI) +## Twitter/X (twitter-cli) ```bash # 搜索推文 -bird search "query" -n 10 +twitter search "query" --limit 10 -# 读取单条推文 (支持 /status/ 和 /article/ URL) -bird read URL_OR_ID +# 读取单条推文(含回复) +twitter tweet URL_OR_ID + +# 读取长文 / X Article +twitter article URL_OR_ID # 用户时间线 -bird user-tweets @username -n 20 +twitter user-posts @username --limit 20 -# 读取完整 thread -bird thread URL_OR_ID +# 用户资料 +twitter user @username + +# 首页时间线 +twitter feed --limit 20 ``` -> **需要配置**: `agent-reach configure twitter-auth ...` 或通过环境变量配置。 +> **安装**: `pipx install twitter-cli` 或 `uv tool install twitter-cli` +> **认证**: 设置 `TWITTER_AUTH_TOKEN` + `TWITTER_CT0` 环境变量,或确保浏览器已登录 x.com。 ## 微博 / Weibo diff --git a/tests/test_twitter_channel.py b/tests/test_twitter_channel.py index 426c780..e92f9d6 100644 --- a/tests/test_twitter_channel.py +++ b/tests/test_twitter_channel.py @@ -13,58 +13,60 @@ def _cp(stdout="", stderr="", returncode=0): return m -def test_check_bird_found_and_auth_ok(): - """bird found + bird check returns 0 → ok.""" +def test_check_twitter_cli_found_and_auth_ok(): + """twitter-cli found + twitter status ok → ok.""" channel = TwitterChannel() - with patch("shutil.which", side_effect=lambda name: "/usr/local/bin/bird" if name == "bird" else None), patch( + with patch("shutil.which", return_value="/usr/local/bin/twitter"), patch( "subprocess.run", - return_value=_cp(stdout="Authenticated as @user\n", returncode=0), + return_value=_cp(stdout="ok: true\nusername: testuser\n", returncode=0), ): status, message = channel.check() assert status == "ok" assert "完整可用" in message -def test_check_bird_found_auth_missing(): - """bird found + bird check returns 1 with 'Missing credentials' → warn about auth.""" +def test_check_twitter_cli_found_auth_missing(): + """twitter-cli found + not_authenticated → warn about auth.""" channel = TwitterChannel() - with patch("shutil.which", side_effect=lambda name: "/usr/local/bin/bird" if name == "bird" else None), patch( + with patch("shutil.which", return_value="/usr/local/bin/twitter"), patch( "subprocess.run", - return_value=_cp(stderr="Missing credentials: AUTH_TOKEN and CT0 required\n", returncode=1), + return_value=_cp( + stderr="ok: false\nerror:\n code: not_authenticated\n", + returncode=1, + ), ): status, message = channel.check() assert status == "warn" - assert "未配置认证" in message + assert "未认证" in message -def test_check_bird_not_found(): - """bird not found → warn with install hint for @steipete/bird.""" +def test_check_twitter_cli_not_found(): + """twitter-cli not found → warn with install hint.""" channel = TwitterChannel() with patch("shutil.which", return_value=None): status, message = channel.check() assert status == "warn" - assert "@steipete/bird" in message + assert "twitter-cli" in message -def test_check_birdx_binary_accepted(): - """birdx symlink is accepted as an alternative binary name.""" +def test_check_twitter_cli_generic_failure(): + """twitter status returns 1 without not_authenticated → generic warn.""" channel = TwitterChannel() - with patch("shutil.which", side_effect=lambda name: "/usr/local/bin/birdx" if name == "birdx" else None), patch( + with patch("shutil.which", return_value="/usr/local/bin/twitter"), patch( "subprocess.run", - return_value=_cp(stdout="Authenticated as @user\n", returncode=0), - ): - status, message = channel.check() - assert status == "ok" - assert "完整可用" in message - - -def test_check_bird_auth_failure_generic(): - """bird check returns 1 without 'Missing credentials' → generic auth failure warn.""" - channel = TwitterChannel() - with patch("shutil.which", side_effect=lambda name: "/usr/local/bin/bird" if name == "bird" else None), patch( - "subprocess.run", - return_value=_cp(stderr="Error: token expired\n", returncode=1), + return_value=_cp(stderr="some error\n", returncode=1), ): status, message = channel.check() assert status == "warn" assert "认证检查失败" in message + + +def test_check_twitter_cli_exception(): + """twitter status throws exception → warn.""" + channel = TwitterChannel() + with patch("shutil.which", return_value="/usr/local/bin/twitter"), patch( + "subprocess.run", side_effect=Exception("timeout"), + ): + status, message = channel.check() + assert status == "warn" + assert "连接失败" in message