feat(twitter): migrate from xreach to bird CLI

- Replace xreach CLI with bird (@steipete/bird) as Twitter/X backend
- bird uses AUTH_TOKEN/CT0 env vars (simpler than xreach's session.json)
- Accept both 'bird' and 'birdx' binary names
- Remove version detection logic (bird v0.8.0 is the baseline)
- Write credentials.env to ~/.config/bird/ for easy sourcing
- Keep xfetch session.json sync for backward compatibility
- Update SKILL.md commands: bird search/read/user-tweets/thread
- Update install/uninstall to use npm @steipete/bird
- All 52 tests pass
This commit is contained in:
Panniantong
2026-03-23 08:51:22 +01:00
parent c1af1cad14
commit 7ae0cd8c0a
13 changed files with 199 additions and 193 deletions
+50 -19
View File
@@ -2,7 +2,7 @@
from unittest.mock import patch, Mock
from agent_reach.channels.twitter import _detect_xreach_version, TwitterChannel
from agent_reach.channels.twitter import TwitterChannel
def _cp(stdout="", stderr="", returncode=0):
@@ -13,27 +13,58 @@ def _cp(stdout="", stderr="", returncode=0):
return m
def test_detect_xreach_version_prefers_npm_when_cli_version_is_stale():
with patch("shutil.which", return_value="/opt/homebrew/bin/npm"), patch(
"subprocess.run",
side_effect=[
_cp(stdout="0.3.0\n"),
_cp(stdout='{"dependencies":{"xreach-cli":{"version":"0.3.2"}}}'),
],
):
assert _detect_xreach_version("/opt/homebrew/bin/xreach") == "0.3.2"
def test_twitter_channel_does_not_false_warn_when_npm_has_newer_xreach():
def test_check_bird_found_and_auth_ok():
"""bird found + bird check returns 0 → ok."""
channel = TwitterChannel()
with patch("shutil.which", side_effect=lambda name: "/opt/homebrew/bin/xreach" if name == "xreach" else "/opt/homebrew/bin/npm"), patch(
with patch("shutil.which", side_effect=lambda name: "/usr/local/bin/bird" if name == "bird" else None), patch(
"subprocess.run",
side_effect=[
_cp(stdout="0.3.0\n"),
_cp(stdout='{"dependencies":{"xreach-cli":{"version":"0.3.2"}}}'),
_cp(stdout="authenticated\n", returncode=0),
],
return_value=_cp(stdout="Authenticated as @user\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."""
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="Missing credentials: AUTH_TOKEN and CT0 required\n", returncode=1),
):
status, message = channel.check()
assert status == "warn"
assert "未配置认证" in message
def test_check_bird_not_found():
"""bird not found → warn with install hint for @steipete/bird."""
channel = TwitterChannel()
with patch("shutil.which", return_value=None):
status, message = channel.check()
assert status == "warn"
assert "@steipete/bird" in message
def test_check_birdx_binary_accepted():
"""birdx symlink is accepted as an alternative binary name."""
channel = TwitterChannel()
with patch("shutil.which", side_effect=lambda name: "/usr/local/bin/birdx" if name == "birdx" else None), 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),
):
status, message = channel.check()
assert status == "warn"
assert "认证检查失败" in message