diff --git a/agent_reach/channels/twitter.py b/agent_reach/channels/twitter.py index 2c33a88..6971edd 100644 --- a/agent_reach/channels/twitter.py +++ b/agent_reach/channels/twitter.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""Twitter/X — check if twitter-cli (public-clis/twitter-cli) is available.""" +"""Twitter/X — check if twitter-cli or bird CLI is available.""" import shutil import subprocess @@ -9,7 +9,7 @@ from .base import Channel class TwitterChannel(Channel): name = "twitter" description = "Twitter/X 推文" - backends = ["twitter-cli"] + backends = ["twitter-cli", "bird CLI (legacy)"] tier = 1 def can_handle(self, url: str) -> bool: @@ -18,24 +18,32 @@ class TwitterChannel(Channel): return "x.com" in d or "twitter.com" in d def check(self, config=None): + # Prefer twitter-cli, fallback to bird/birdx twitter = shutil.which("twitter") - if not twitter: + bird = shutil.which("bird") or shutil.which("birdx") + + if twitter: + return self._check_twitter_cli(twitter) + elif bird: + return self._check_bird(bird) + else: return "warn", ( - "twitter-cli 未安装。安装方式:\n" + "Twitter CLI 未安装。安装方式:\n" " pipx install twitter-cli\n" "或:\n" " uv tool install twitter-cli" ) + def _check_twitter_cli(self, binary: str): try: r = subprocess.run( - [twitter, "status"], capture_output=True, + [binary, "status"], capture_output=True, encoding="utf-8", errors="replace", timeout=10 ) output = (r.stdout or "") + (r.stderr or "") if r.returncode == 0 and "ok: true" in output: return "ok", ( - "完整可用(搜索、读推文、时间线、长文/Article、" + "twitter-cli 完整可用(搜索、读推文、时间线、长文/Article、" "用户查询、Thread)" ) if "not_authenticated" in output: @@ -51,3 +59,24 @@ class TwitterChannel(Channel): ) except Exception: return "warn", "twitter-cli 已安装但连接失败" + + def _check_bird(self, binary: str): + try: + r = subprocess.run( + [binary, "check"], capture_output=True, + encoding="utf-8", errors="replace", timeout=10 + ) + output = (r.stdout or "") + (r.stderr or "") + if r.returncode == 0: + return "ok", "bird CLI 可用(读取、搜索推文,含长文/X Article)" + if "Missing credentials" in output or "missing" in output.lower(): + return "warn", ( + "bird CLI 已安装但未配置认证。设置环境变量:\n" + " export AUTH_TOKEN=\"xxx\"\n" + " export CT0=\"yyy\"" + ) + return "warn", ( + "bird CLI 已安装但认证检查失败。" + ) + except Exception: + return "warn", "bird CLI 已安装但连接失败" diff --git a/docs/update.md b/docs/update.md index 30d630f..ed180ed 100644 --- a/docs/update.md +++ b/docs/update.md @@ -71,15 +71,9 @@ which yt-dlp >/dev/null 2>&1 || pip install yt-dlp 2>/dev/null which gh >/dev/null 2>&1 || echo "GitHub CLI not installed — see https://cli.github.com" ``` -### Step 4: Clean up deprecated tools +### Step 4: Coexistence (DO NOT uninstall old tools) -```bash -# Remove bird CLI (repo deleted, replaced by twitter-cli) -which bird >/dev/null 2>&1 && npm uninstall -g @steipete/bird 2>/dev/null && echo "✅ Removed deprecated bird CLI" - -# Note: browser_cookie3 is still supported as fallback, no need to remove -# Note: miku_ai is no longer needed (WeChat now uses Exa), but no harm keeping it -``` +**IMPORTANT: Never uninstall any existing tools the user already has installed.** Old tools (bird, browser_cookie3, miku_ai, etc.) may still be working on the user's machine even if their upstream repos are archived or deleted. Agent Reach supports both old and new tools as fallback. Only install new tools alongside existing ones. ### Step 5: Verify diff --git a/tests/test_twitter_channel.py b/tests/test_twitter_channel.py index e92f9d6..a84b044 100644 --- a/tests/test_twitter_channel.py +++ b/tests/test_twitter_channel.py @@ -13,22 +13,25 @@ def _cp(stdout="", stderr="", returncode=0): return m +# --- twitter-cli tests --- + def test_check_twitter_cli_found_and_auth_ok(): """twitter-cli found + twitter status ok → ok.""" channel = TwitterChannel() - with patch("shutil.which", return_value="/usr/local/bin/twitter"), patch( + with patch("shutil.which", side_effect=lambda name: "/usr/local/bin/twitter" if name == "twitter" else None), patch( "subprocess.run", return_value=_cp(stdout="ok: true\nusername: testuser\n", returncode=0), ): status, message = channel.check() assert status == "ok" + assert "twitter-cli" in message assert "完整可用" in message def test_check_twitter_cli_found_auth_missing(): """twitter-cli found + not_authenticated → warn about auth.""" channel = TwitterChannel() - with patch("shutil.which", return_value="/usr/local/bin/twitter"), patch( + with patch("shutil.which", side_effect=lambda name: "/usr/local/bin/twitter" if name == "twitter" else None), patch( "subprocess.run", return_value=_cp( stderr="ok: false\nerror:\n code: not_authenticated\n", @@ -40,8 +43,44 @@ def test_check_twitter_cli_found_auth_missing(): assert "未认证" in message -def test_check_twitter_cli_not_found(): - """twitter-cli not found → warn with install hint.""" +# --- bird CLI fallback tests --- + +def test_check_bird_fallback_auth_ok(): + """No twitter-cli, but bird found + bird check ok → ok.""" + channel = TwitterChannel() + def which_side_effect(name): + if name == "bird": + return "/usr/local/bin/bird" + return None + with patch("shutil.which", side_effect=which_side_effect), patch( + "subprocess.run", + return_value=_cp(stdout="Authenticated as @user\n", returncode=0), + ): + status, message = channel.check() + assert status == "ok" + assert "bird" in message + + +def test_check_bird_fallback_auth_missing(): + """No twitter-cli, bird found but Missing credentials → warn.""" + channel = TwitterChannel() + def which_side_effect(name): + if name == "bird": + return "/usr/local/bin/bird" + return None + with patch("shutil.which", side_effect=which_side_effect), patch( + "subprocess.run", + return_value=_cp(stderr="Missing credentials\n", returncode=1), + ): + status, message = channel.check() + assert status == "warn" + assert "未配置认证" in message + + +# --- neither installed --- + +def test_check_nothing_installed(): + """Neither twitter-cli nor bird → warn with install hint.""" channel = TwitterChannel() with patch("shutil.which", return_value=None): status, message = channel.check() @@ -49,24 +88,21 @@ def test_check_twitter_cli_not_found(): assert "twitter-cli" in message -def test_check_twitter_cli_generic_failure(): - """twitter status returns 1 without not_authenticated → generic warn.""" +# --- twitter-cli preferred over bird --- + +def test_twitter_cli_preferred_over_bird(): + """When both are installed, twitter-cli is used.""" channel = TwitterChannel() - with patch("shutil.which", return_value="/usr/local/bin/twitter"), patch( + def which_side_effect(name): + if name == "twitter": + return "/usr/local/bin/twitter" + if name == "bird": + return "/usr/local/bin/bird" + return None + with patch("shutil.which", side_effect=which_side_effect), patch( "subprocess.run", - return_value=_cp(stderr="some error\n", returncode=1), + return_value=_cp(stdout="ok: true\n", returncode=0), ): 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 + assert status == "ok" + assert "twitter-cli" in message