211 Commits
Author SHA1 Message Date
yuta 61f6efd816 fix: use package token for gitea npm publish
CI / test (push) Successful in 9m57s
Publish npm package / publish (push) Failing after 5m12s
2026-06-24 15:35:39 +09:00
yuta 69238834f2 fix: align gitea pnpm install config
CI / test (push) Successful in 10m1s
2026-06-24 15:08:49 +09:00
yuta 0e5c62b250 chore: release 0.7.0
CI / test (push) Failing after 5m6s
Publish npm package / publish (push) Failing after 5m8s
2026-06-24 14:57:47 +09:00
yuta 96535c33f9 feat: publish bird via gitea and nix
CI / test (push) Failing after 6m27s
2026-06-24 14:55:06 +09:00
yuta fd78705954 Merge pull request 'Use twitter safe relay' (#1) from remove-cookie-auth-use-twitter-safe-relay into main
CI / test (push) Has been cancelled
Reviewed-on: #1
2026-06-24 04:38:49 +09:00
yuta 6503bb1b76 Simplify relay request headers
CI / test (pull_request) Has been cancelled
2026-06-24 03:52:16 +09:00
yuta 1f6e228272 Use twitter safe relay
CI / test (pull_request) Has been cancelled
2026-06-24 03:39:34 +09:00
Peter Steinberger b771e827aa Merge pull request #48 from jsholmes/fix/tweet-detail-partial-errors
fix: handle partial errors in TweetDetail API responses
2026-01-12 07:33:40 +00:00
Peter Steinberger afdcad4f25 fix: allow partial tweet detail errors (#48) (thanks @jsholmes) 2026-01-12 07:33:25 +00:00
John HolmesandClaude Opus 4.5 35b8774b1c fix: handle partial errors in TweetDetail API responses
Twitter API sometimes returns partial errors (e.g., is_translatable field
failures) alongside valid thread data. Previously, any error caused the
entire request to fail even when usable data was present.

Now we check if threaded_conversation_with_injections_v2.instructions
exists before failing on errors, allowing successful thread fetching
despite non-fatal API errors.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-12 07:32:42 +00:00
Peter Steinberger 57c8e8e793 Merge pull request #44 from jsholmes/feat/likes-pagination
feat: add full pagination support to likes (parity with bookmarks)
2026-01-12 07:09:02 +00:00
Peter Steinberger afa7063a72 fix: guard pagination limits (#44) (thanks @jsholmes) 2026-01-12 07:07:47 +00:00
Peter Steinberger bc744a3bf9 feat: add full pagination support to likes (parity with bookmarks)
Library changes:
- Add getAllLikes(options) method for fetching all likes with cursor support
- Refactor getLikes to use private getLikesPaged helper
- Return nextCursor for resumable pagination
- Support maxPages option to limit pages fetched

CLI changes:
- Add --all flag to fetch all likes
- Add --max-pages to limit pagination
- Add --cursor to resume from previous fetch
- Output nextCursor in JSON mode for resumable pagination

This brings likes to feature parity with bookmarks.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

# Conflicts:
#	src/commands/users.ts
2026-01-12 07:06:18 +00:00
Peter Steinberger 9cfeda13b2 test: add search CLI pagination checks 2026-01-12 06:44:42 +00:00
Peter Steinberger 35f8fa1a38 Merge pull request #42 from pjtf93/feat/search-pagination
feat: add pagination support to search command
2026-01-12 06:31:15 +00:00
Peter Steinberger a782ca5d3a fix: search pagination output (#42) (thanks @pjtf93) 2026-01-12 06:30:20 +00:00
Pablo Tovar e23c20a6a2 feat: add pagination support to search command
Adds --all, --max-pages, and --cursor options to search command, enabling users to fetch all search results through automatic pagination. Follows the same pattern as bookmarks pagination for consistency.
2026-01-12 06:29:27 +00:00
Peter Steinberger f351414046 fix: harden home timeline live checks 2026-01-12 06:10:57 +00:00
Peter Steinberger 731c149c85 Merge pull request #39 from aavetis/news-feature
feat: Add Grok generated News headlines
2026-01-12 05:39:05 +00:00
Peter Steinberger c96ce5238f fix: tighten news output and parsing (#39) (thanks @aavetis) 2026-01-12 05:37:44 +00:00
aavetisandClaude Sonnet 4.5 fb092f5cec feat: add multi-tab fetching to news command for 5x more headlines
Completely rewrites the news command to fetch from multiple Explore tabs
(For You, News, Sports, Entertainment) using GenericTimelineById GraphQL
API instead of just the ExplorePage initialTimeline. This delivers 15+
AI-curated headlines instead of 3, a 5x improvement in content discovery.

Key Changes:
- Add GenericTimelineById query ID support with auto-refresh capability
- Implement multi-tab fetching with cross-tab headline deduplication
- Add CLI flags for granular tab filtering (--for-you, --news-only,
  --sports, --entertainment, --trending-only)
- Fetch from 4 tabs by default (excludes trending to reduce noise)
- Add early stopping optimization when count is reached
- Handle tab-level errors gracefully without failing entire request

Implementation:
- Added TIMELINE_IDS constant with base64 timeline identifiers
- Created fetchTimelineTab() method for GenericTimelineById requests
- Created parseTimelineTabItems() for new response structure
- Removed old parseNewsItems() and extractNewsItemsFromInstructions()
- Updated all 7 tests to mock GenericTimelineById responses

API Changes:
- NewsFetchOptions: Added `tabs?: ExploreTab[]` option
- ExploreTab type exported for library consumers
- Backward compatible - existing code continues to work

Documentation:
- Added comprehensive "News & Trending" section with examples
- Updated command reference with all new flags
- Added library usage examples with tab filtering
- Updated JSON schema documentation

Testing:
- All 268 tests passing
- Real-world verified: fetches 15+ AI headlines across tabs
- Tab filtering verified: --sports, --entertainment, etc all work

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-01-12 05:33:19 +00:00
aavetisandClaude Sonnet 4.5 57d1498f1d feat: add news command to fetch AI-curated headlines
Add comprehensive news/trending feature that fetches AI-generated news
headlines from Twitter's "For You" page. This feature specifically
targets the AI-curated news items that Twitter displays under "Today's
News" section, not generic trending hashtags.

- Uses Twitter's ExplorePage GraphQL API endpoint to access the Explore
  page timeline data where AI headlines are located
- AI headlines are identified by the explicit `is_ai_trend: true` flag
  in the API response
- Fallback heuristic detection for full-sentence headlines (5+ words)
  with "News" or time indicators in social context
- Headlines are found in "stories-*" modules within the initialTimeline
  structure under a "Today's News" header
- Follows the existing mixin pattern (withNews) for composability
- Supports both AI-only filtering and mixed trending/AI results

- `bird news` - Fetch news and trending topics (mixed results)
- `bird news --ai-only` - Fetch ONLY AI-curated headlines
- `bird news -n <count>` - Limit number of results
- `bird news --json` - Output as JSON
- `bird news --json-full` - Include raw API response
- `bird news --with-tweets` - Enrich with related tweets
- `bird trending` - Alias for news command
- AI headlines are clearly marked with "AI · " category prefix
- Automatic deduplication of duplicate headlines
- Rich formatting with category, time, post count, and URLs

- src/commands/news.ts - CLI command implementation
- src/lib/twitter-client-news.ts - Core news fetching functionality
- tests/commands.news.test.ts - Command validation tests (7 tests)
- tests/twitter-client-coverage.news.test.ts - API coverage tests (7 tests)

- README.md - Added documentation for news command
- src/cli/program.ts - Registered news command
- src/lib/index.ts - Exported news-related types
- src/lib/twitter-client.ts - Integrated withNews mixin
- src/lib/query-ids.json - Added ExplorePage query ID
- src/lib/twitter-client-constants.ts - Added ExplorePage constant
- src/lib/twitter-client-features.ts - Added buildExploreFeatures()
- scripts/update-query-ids.ts - Added ExplorePage to update script

- All 268 existing tests continue to pass
- Added 14 new tests covering command validation and API functionality
- Tested with real Twitter data confirming AI headline detection
- All TypeScript compilation and linting checks pass

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-01-12 05:32:06 +00:00
Peter Steinberger abc6db411d docs: add live longform test note 2026-01-12 05:22:00 +00:00
Peter Steinberger cbf99a12ab Merge pull request #36 from crcatala/feature/longform-tweet-rich-content
feat: support rich content in long-form tweets (X Articles)
2026-01-12 05:14:53 +00:00
Peter Steinberger 28695d2ef0 fix: harden article rich content rendering (#36) (thanks @crcatala) 2026-01-12 05:14:39 +00:00
cc-vpsandChristian Catalan 316cdf77be feat: support rich content in long-form tweets (X Articles)
Add support for extracting rich content from X's long-form tweets,
including embedded code snippets, markdown blocks, quoted tweets,
and other structured content that was previously lost.

Changes:
- Add fieldToggles with withArticleRichContentState to TweetDetail API request
- Implement Draft.js content_state parser (renderContentState) that converts
  blocks and entities to readable markdown format
- Add content_state type definition to GraphqlTweetResult

Supported content:
- Block types: paragraphs, headers, ordered/unordered lists, blockquotes
- Entity types: MARKDOWN (code blocks), DIVIDER, TWEET, LINK, IMAGE

No impact on regular tweets - rich content only adds payload when present.

Includes 20 unit tests for the parser and an opt-in live smoke test.

Co-authored-by: Christian Catalan <[email protected]>
2026-01-12 05:12:33 +00:00
Peter Steinberger e0b960db1a fix: repair search and user-tweets requests 2026-01-12 05:09:28 +00:00
Peter Steinberger b51258c4e8 Merge pull request #49 from steipete/refactor/pagination-cleanup
refactor: unify pagination helpers
2026-01-12 02:59:07 +00:00
Peter Steinberger d5a527d5fb refactor: unify pagination helpers 2026-01-12 02:58:00 +00:00
Peter Steinberger e4d2773d46 Merge pull request #35 from crcatala/feat/thread-replies-pagination
feat: add pagination support for thread and replies commands
2026-01-12 02:42:17 +00:00
Peter Steinberger 16f21800b5 refactor: align replies/thread pagination (#35) (thanks @crcatala) 2026-01-12 02:41:08 +00:00
cc-vpsandChristian Catalan 90037ce880 feat: add pagination support for thread and replies commands
Add --all, --max-pages, --delay, and --cursor options to both thread
and replies commands, enabling fetching of all conversation content
with rate-limit-friendly pagination.

Changes:
- Modified fetchTweetDetail() to accept optional cursor parameter
- Added getRepliesPaged() and getThreadPaged() methods with pagination loop
- Updated CLI commands with new options (--all, --max-pages, --delay, --cursor)
- Added TweetDetailPaginationOptions interface
- JSON output includes nextCursor for scripting/resumption

Options:
- --all: Fetch all pages (no hard limit)
- --max-pages <n>: Limit number of pages when using --all
- --delay <ms>: Delay between page fetches (default: 1000ms)
- --cursor <string>: Resume from a previous cursor

Follows patterns established by bookmarks command and PR #34 (user-tweets).

Co-authored-by: Christian Catalan <[email protected]>
2026-01-12 02:35:36 +00:00
Peter Steinberger c7e2ec0495 Merge pull request #47 from steipete/chore/bump-0.7.0-unreleased
chore: start 0.7.0 (unreleased)
2026-01-12 02:20:50 +00:00
Peter Steinberger 762738484a chore: start 0.7.0 (unreleased) 2026-01-12 02:19:54 +00:00
Peter Steinberger 2d1f784415 Merge pull request #46 from steipete/fix/ci-build-dist
fix: restore build:dist and gate in CI
2026-01-12 02:17:20 +00:00
Peter Steinberger c7acdbd3fd fix: restore build:dist and gate in CI 2026-01-12 02:16:37 +00:00
Peter Steinberger da5d81d11c Merge pull request #45 from steipete/refactor/user-tweets-cleanup
refactor: user lookup + paging output cleanup
2026-01-12 02:10:53 +00:00
Peter Steinberger 9dd9efa4e3 test: split user lookup coverage 2026-01-12 02:09:52 +00:00
Peter Steinberger 436d4dfb89 refactor: centralize paged tweet printing 2026-01-12 02:09:47 +00:00
Peter Steinberger 5c0d5d1068 refactor: dedupe REST user parsing 2026-01-12 02:09:42 +00:00
Peter Steinberger 0536006012 refactor: extract user lookup mixin 2026-01-12 02:09:37 +00:00
Peter Steinberger 2f0d239b62 Merge pull request #43 from steipete/test/user-tweets-more
test: expand user-tweets coverage
2026-01-12 01:48:44 +00:00
Peter Steinberger 11cc9fbd27 test: expand user-tweets coverage 2026-01-12 01:45:41 +00:00
Peter Steinberger 64b0460c4d Merge pull request #34 from crcatala/feat/user-tweets-command
feat: add user-tweets command to fetch tweets from a user's profile
2026-01-12 01:42:24 +00:00
Peter Steinberger 21701bad5a fix: repair pnpm patchedDependencies 2026-01-12 01:41:10 +00:00
Peter Steinberger 7b5f4b3f2d docs: document user-tweets 2026-01-12 01:35:04 +00:00
Peter Steinberger 4eac0927c1 fix: paginate user-tweets by --count 2026-01-12 01:35:00 +00:00
cc-vpsandChristian Catalan df98e827f6 feat: add user-tweets command to fetch tweets from a user's profile
Adds new CLI command to fetch tweets from any user's profile timeline.

Usage:
  bird user-tweets @username [-n count] [--pages N] [--json]

Features:
- GraphQL UserByScreenName for username → userId resolution
- Cursor-based pagination with configurable delay (rate-limit safe)
- Hard cap of 10 pages to prevent abuse
- Input validation via normalizeHandle()

Includes 17 unit tests and live smoke test.

Co-authored-by: Christian Catalan <[email protected]>
2026-01-12 01:28:04 +00:00
Peter Steinberger 46255e8a38 test: add followers cursor coverage 2026-01-12 00:57:24 +00:00
Peter Steinberger c115232135 Merge pull request #33 from VACInc/feat/following-pagination
feat(following): add pagination support for following list
2026-01-12 00:47:07 +00:00
Peter Steinberger dec78547fe test: split following/likes tests (#33) (thanks @VACInc) 2026-01-12 00:46:32 +00:00
Peter Steinberger baede65f77 fix: following pagination returns cursor and errors (#33) (thanks @VACInc) 2026-01-12 00:41:34 +00:00
Peter Steinberger 138b643d8a test: add print output coverage (#32) (thanks @odysseus0) 2026-01-11 23:38:44 +00:00
Peter Steinberger 5fc6f53871 Merge pull request #32 from odysseus0/feat/text-output
feat: improve text output with article preview, quote tweets, and media
2026-01-11 23:35:48 +00:00
Peter Steinberger ba22d86f70 docs: changelog for text output (#32) (thanks @odysseus0) 2026-01-11 23:35:35 +00:00
Peter Steinberger 15aa24d2b1 fix: honor output flags in text rendering 2026-01-11 23:35:03 +00:00
George ZhangandClaude Opus 4.5 a48aa6db88 fix: remove duplicate title field in GraphqlTweetResult article type
The article type had title defined twice (at lines 83 and 135), causing
TypeScript compilation to fail with TS2300: Duplicate identifier 'title'.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-11 23:35:03 +00:00
George ZhangandClaude Opus 4.5 d40c048333 fix: improve article detection and quote tweet display
- Use startsWith check instead of fragile length heuristic for
  detecting full article body vs preview mode
- Add media display for quote tweets (🖼️/🎬/🔄 indicators)
- Add preview_text to GraphqlTweetResult type definition to avoid
  type assertions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-11 23:35:03 +00:00
George Zhang 0475a2e1b6 test: add tests for article metadata extraction
- extractArticleMetadata: 6 tests for title/preview extraction
- mapTweetResult: 3 tests for article field inclusion
2026-01-11 23:35:03 +00:00
George Zhang cf78fce646 feat: improve text output with article preview, quote tweets, and media
Enhance the text output format to show more useful information:

- Article tweets: Show 📰 indicator with title + preview text in feeds,
  full body when reading single tweets
- Quote tweets: Display quoted content with ┌─ QT @user formatting
- Media: Show 🖼️/🎬/🔄 indicators with URLs for photos/videos/GIFs

Also refactors single tweet read to use shared printTweets function
for consistent formatting.
2026-01-11 23:35:03 +00:00
Peter Steinberger de49a66601 docs: add home live test steps 2026-01-11 11:39:35 +00:00
Peter Steinberger b29b446d18 Merge pull request #31 from odysseus0/feat/home-timeline
feat: add home timeline command
2026-01-11 11:37:57 +00:00
Peter Steinberger 94773c66dc fix: harden home timeline pagination (#31) (thanks @odysseus0) 2026-01-11 11:36:59 +00:00
George ZhangandClaude Opus 4.5 92742cac38 refactor: rename --latest to --following for clarity
Matches Twitter's UI terminology ("For You" vs "Following" tabs),
making the flag immediately understandable to users.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-11 11:35:57 +00:00
George ZhangandClaude Opus 4.5 5bb9e48252 refactor: move home query ID methods to mixin as private
Follow the pattern from twitter-client-timelines.ts where query ID
methods are private to the mixin that uses them, rather than protected
in the base class.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-11 11:35:57 +00:00
George Zhang 73b8cff1b2 test: add home timeline API tests
- getHomeTimeline: success, error, API error handling
- getHomeLatestTimeline: success, error handling
- pagination: deduplication across pages

Uses realistic fixture structure based on actual API responses.
2026-01-11 11:35:57 +00:00
George Zhang ba7b00eb29 feat: add home timeline command
Add `bird home` command to fetch the authenticated user's home timeline.

- Supports "For You" feed (default) and "Following" feed (--latest)
- Uses HomeTimeline and HomeLatestTimeline GraphQL operations
- Includes count option (-n), JSON output, and pagination

Usage:
  bird home           # Get "For You" feed
  bird home --latest  # Get "Following" (chronological) feed
  bird home -n 50     # Fetch 50 tweets
2026-01-11 11:35:57 +00:00
Peter Steinberger 05e54350c7 Merge pull request #30 from zheli/feature/list-timeline-pagination
Add pagination support for list timeline
2026-01-11 11:28:01 +00:00
Peter Steinberger 6cf177f92f fix: list timeline pagination (#30) (thanks @zheli) 2026-01-11 11:27:46 +00:00
Zhe LiandClaude Sonnet 4.5 ebcd1618f9 feat: make --max-pages imply --all for list-timeline
- Remove requirement for --max-pages to be used with --all
- Update help text to clarify --max-pages implies --all
- Add list-timeline documentation to README with examples

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-01-11 11:17:27 +00:00
Zhe Li 8fc208ca31 Apply suggestions from code review 2026-01-11 11:17:27 +00:00
Zhe LiandClaude Sonnet 4.5 803bd25b0c feat: add pagination support to list-timeline command
Adds --all, --max-pages, and --cursor options to list-timeline command, enabling users to fetch all tweets from a list through automatic pagination. Follows the same pattern as bookmarks pagination for consistency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-01-11 11:17:27 +00:00
Peter Steinberger bda980fa4d Merge pull request #29 from mafulafunk/feature/clickable-terminal-urls
feat: add clickable hyperlinks for URLs in terminal output
2026-01-11 11:11:54 +00:00
Peter Steinberger 0a4699711f fix: sanitize hyperlink OSC output (#29) (thanks @mafulafunk) 2026-01-11 11:11:21 +00:00
Martin FunkandClaude Opus 4.5 837b09fcad feat: add clickable hyperlinks for URLs in terminal output
Use OSC 8 escape sequences to make tweet and list URLs clickable
in supported terminals (iTerm2, Ghostty, Kitty, WezTerm, VS Code, etc.).

- Add hyperlink() helper function in output.ts
- Apply hyperlinks in printTweets() for tweet URLs
- Apply hyperlinks in printLists() for list URLs
- Add hyperlinks flag to OutputConfig (auto-disabled for non-TTY)
- Automatically falls back to plain text in --plain mode or when piped

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-11 11:10:34 +00:00
Peter Steinberger bbc90dbe08 docs: add manual live follow test 2026-01-11 11:09:48 +00:00
Peter Steinberger e39b430beb test: add users command pagination cases 2026-01-11 11:09:44 +00:00
Peter Steinberger f4e7b9c51c Merge pull request #28 from malpern/feat/following-followers-pagination
feat: add cursor-based pagination for following/followers commands
2026-01-11 11:02:40 +00:00
Peter Steinberger 092be1a782 fix: harden follow pagination output (#28) (thanks @malpern) 2026-01-11 11:02:09 +00:00
Micah AlpernandClaude Opus 4.5 39a477265c feat: add cursor-based pagination for following/followers commands
Add pagination support to `bird following` and `bird followers` commands,
similar to existing pagination in search/bookmarks/likes.

Changes:
- Add `cursor` parameter to `getFollowing()` and `getFollowers()` client methods
- Return `nextCursor` in `FollowingResult` for pagination continuation
- Add `--cursor <cursor>` CLI option for manual pagination
- Add `--all` CLI flag to automatically fetch all pages
- Add `--max-pages <number>` option to limit pages when using --all
- Add input validation for --max-pages (requires --all or --cursor)
- Add deduplication using Set to prevent duplicate users
- Add 1-second delay between pages to avoid overwhelming the API
- Update `-n/--count` description to clarify it's per-page
- Add unit tests for cursor parameter and nextCursor response

Usage examples:
  # Fetch first page (default 20 users)
  bird following

  # Fetch with specific page size
  bird following -n 50

  # Use cursor for next page
  bird following --cursor "CURSOR_FROM_PREVIOUS"

  # Fetch ALL following users automatically (with rate limiting)
  bird following --all --json

  # Limit to first 5 pages
  bird following --all --max-pages 5

  # Same options work for followers
  bird followers --all

Note: REST API fallback does not support cursor pagination.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-11 10:59:35 +00:00
Peter Steinberger ad125348d6 test: expand lists feature coverage 2026-01-11 10:58:47 +00:00
Peter Steinberger e782b08a5e Merge pull request #27 from zheli/fix/lists-api-features
Add required lists GraphQL feature flags
2026-01-11 10:43:56 +00:00
Peter Steinberger ad1d5f24cd fix: add lists feature flags (#27) (thanks @zheli) 2026-01-11 10:43:33 +00:00
Zhe Li 892fe25970 Update src/lib/twitter-client-features.ts 2026-01-11 10:42:17 +00:00
Zhe Li d1de42e91b fix: add required lists API features 2026-01-11 10:42:17 +00:00
Zhe LiandClaude Sonnet 4.5 ec09010122 fix: add required API features to buildListsFeatures
X/Twitter now requires vibe_api_enabled, responsive_web_text_conversations_enabled, tweetypie_unmention_optimization_enabled, and interactive_text_enabled to be explicitly set in lists API requests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-01-11 10:42:17 +00:00
Peter Steinberger 3c2107d5d1 Merge pull request #12 from titouv/paginate-fix
Paginate for bookmarks and likes
2026-01-11 10:32:51 +00:00
Peter Steinberger 2e03bd8c07 fix: paginate likes + avoid duplicate loops (#12) (thanks @titouv) 2026-01-11 10:31:47 +00:00
Titouan V 7548fd5ed1 paginate bookmarks and folders 2026-01-11 10:29:58 +00:00
Peter Steinberger f048c3d968 Merge pull request #40 from gakonst/fix/brave-cookies
fix: support Brave cookies on macOS
2026-01-11 02:50:37 +00:00
Peter Steinberger 7c74b42bef fix: support Brave cookies on macOS (#40) (thanks @gakonst) 2026-01-11 03:49:49 +01:00
Georgios Konstantopoulos 1284c3ae02 fix: support Brave cookies on macOS 2026-01-11 03:48:54 +01:00
Peter Steinberger 3dce3dd247 Merge pull request #38 from philipp-spiess/main
fix: add post_ctas_fetch_enabled feature flag
2026-01-11 02:21:24 +00:00
Peter Steinberger b8009939a2 fix: add post_ctas_fetch_enabled flag (#38) (thanks @philipp-spiess) 2026-01-11 03:20:53 +01:00
Philipp Spiess f08c94e2a8 fix: add post_ctas_fetch_enabled feature flag
Twitter API now requires this flag, otherwise returns HTTP 400.
2026-01-08 16:14:51 +01:00
Peter Steinberger 9b5e575f92 chore(release): 0.6.0 2026-01-05 04:01:04 +01:00
Peter Steinberger 6fbbaebb68 fix: improve unbookmark url handling 2026-01-05 03:51:56 +01:00
Mariano Belinky 04a0c98362 feat: add unbookmark command to remove bookmarks 2026-01-05 02:10:10 +00:00
Peter Steinberger 1d1ed6b515 chore: prep 0.6.0 unreleased 2026-01-05 01:40:43 +01:00
Peter Steinberger f5402f2b43 feat: add runtime feature overrides 2026-01-05 01:23:32 +01:00
Pedro Pinera Buendia a3eb372494 fix: add responsive_web_grok_annotations_enabled to all feature sets
X/Twitter now requires this feature flag in API requests.
Without it, requests fail with HTTP 400:
'The following features cannot be null: responsive_web_grok_annotations_enabled'

Fixes #24
2026-01-05 00:07:01 +00:00
Peter Steinberger a3e2dccc46 fix: restore tweet lookup in instructions 2026-01-04 23:42:34 +00:00
Peter Steinberger f765955116 fix: stabilize bookmark pagination cursors 2026-01-04 23:42:34 +00:00
Leon Ho 3442ba84c6 feat: add cursor-based pagination to bookmarks command
- Add --cursor option to resume pagination from a specific point
- Change --max-pages to enable pagination independently
- Return nextCursor in JSON output for subsequent requests

feat: add cursor param and nextCursor to pagination methods

not modifying max-pages
2026-01-04 23:42:34 +00:00
Peter Steinberger c4fa04e0b2 feat: include raw tweet data in mapping 2026-01-05 00:30:01 +01:00
Peter Steinberger 3728f7a4f5 test: add mapTweetResult media coverage 2026-01-03 18:40:05 +01:00
Peter Steinberger b83d065907 fix: restore list types and tidy tests 2026-01-03 18:34:32 +01:00
Peter Steinberger 00aee16c08 docs: update changelog for media output 2026-01-03 18:34:32 +01:00
Peter Steinberger d8643ae1d1 fix: improve media extraction 2026-01-03 18:34:32 +01:00
Nikita b4542b2213 test: add comprehensive tests for extractMedia function 2026-01-03 18:34:32 +01:00
Nikita 067c98c245 feat: add media extraction support for photos, videos, and GIFs 2026-01-03 18:34:32 +01:00
Peter Steinberger e537e102e3 fix: normalize list privacy parsing 2026-01-03 16:55:29 +01:00
Harper ReedandClaude Opus 4.5 0e8a1349c5 test(lists): add boundary and fragment tests for extractListId
- Test that 5-digit IDs (minimum length) are accepted
- Test that URLs with fragment identifiers work correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-03 16:55:29 +01:00
Harper ReedandClaude Opus 4.5 59e827e153 test(lists): improve test coverage and extract list ID parsing
- Extract extractListId to src/lib/extract-list-id.ts with proper tests
- Add json-full/includeRaw test for list timeline
- Add 404 retry tests for getOwnedLists and getListMemberships
- Add tests for skipping invalid list entries and missing owner handling
- Use shared TwitterClientPrivate type from fixtures

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-03 16:55:29 +01:00
Harper ReedandClaude Opus 4.5 7818440d65 feat(lists): add support for Twitter Lists
Add commands to fetch owned lists, list memberships, and list timelines:
- `bird lists` shows lists you own
- `bird lists --member-of` shows lists you're a member of
- `bird list-timeline <id-or-url>` fetches tweets from a list

Includes full --json and --json-full support for list timeline output.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-03 16:55:29 +01:00
Peter Steinberger 3db3aca0a0 style: lint fixes 2026-01-03 15:51:49 +01:00
Peter Steinberger dac4ba6456 docs: update changelog 2026-01-03 15:51:49 +01:00
Peter Steinberger b27643edbb fix: harden bookmark retry handling 2026-01-03 15:51:49 +01:00
Haoli 5b61f65a7c Revert local bookmark artifact ignore entries 2026-01-03 15:51:49 +01:00
Haoli 56ca7b9841 Add paged bookmark export with gating and retries 2026-01-03 15:51:49 +01:00
Peter Steinberger 533db2d210 test: cover json-full timelines 2026-01-03 06:59:47 +01:00
cc-vpsandChristian Catalan 3815ebfa03 feat: add --json-full flag to include raw API response in _raw field
Add a new --json-full flag to tweet-fetching commands that outputs JSON
with an additional _raw field containing the full GraphQL response.

This provides power users access to additional data not exposed in the
curated TweetData interface (media, entities, hashtags, cards, etc.)
while maintaining backward compatibility with --json.

Supported commands:
- read, replies, thread (TweetDetail API)
- search, mentions (SearchTimeline API)
- bookmarks (Bookmarks API)
- likes (Likes API)

Not supported: following, followers (uses mixed GraphQL/REST fallback
with different response structures)

Implementation:
- Add _raw?: GraphqlTweetResult optional field to TweetData type
- Add includeRaw option to mapTweetResult() and parseTweetsFromInstructions()
- Add TweetFetchOptions, SearchFetchOptions, TimelineFetchOptions interfaces
- Update client methods to accept options parameter
- Add --json-full flag to CLI commands
- Update help text to document the new flag
- Add comprehensive test coverage (16 tests)

Co-authored-by: Christian Catalan <[email protected]>
2026-01-03 06:54:36 +01:00
Peter Steinberger 42abae9c15 chore(release): 0.5.1 2026-01-01 17:52:57 +01:00
Peter Steinberger a38de2b0a9 fix(following): fallback to internal API 2026-01-01 17:09:36 +01:00
Peter Steinberger 31a9d55e95 docs: clarify internal API fallback 2026-01-01 16:57:09 +01:00
Peter Steinberger b43ab1f6ec style(cli): add more color 2026-01-01 16:57:03 +01:00
Peter Steinberger 8ae5b9b5d4 test(live): add live CLI suite 2026-01-01 16:53:24 +01:00
Peter Steinberger 1a610c366a fix(followers): fallback to legacy API 2026-01-01 16:53:19 +01:00
Peter Steinberger b68526e872 docs: update changelog 2026-01-01 16:34:46 +01:00
Peter Steinberger 5551d5fa66 test(help): cover help output 2026-01-01 16:34:44 +01:00
Peter Steinberger 6a238ed063 fix(help): improve help output 2026-01-01 16:34:41 +01:00
Peter Steinberger 12c9e4fe88 docs(changelog): start 0.5.1 cycle 2026-01-01 15:37:29 +01:00
Peter Steinberger 6463b06674 chore(release): 0.5.0 2026-01-01 14:44:09 +01:00
Peter Steinberger c78be5d1ea chore: fix lint issues 2026-01-01 14:13:32 +01:00
Peter Steinberger f1663c8609 test: split twitter client tests 2026-01-01 14:09:18 +01:00
Peter Steinberger 7a4782ddc4 refactor: split twitter client 2026-01-01 13:58:08 +01:00
Peter Steinberger 51bf79d9ed refactor(cli): split commands into modules 2026-01-01 13:32:30 +01:00
Peter Steinberger 8aae28315e fix(twitter-client): relax cursor typing 2026-01-01 13:32:30 +01:00
Peter Steinberger d8c2181dfb refactor: split cli and library entry 2026-01-01 12:31:02 +01:00
Peter Steinberger e9922c2901 docs: align readme with cli 2026-01-01 12:20:27 +01:00
Peter Steinberger d7fd0f94a5 test: expand search pagination coverage 2026-01-01 12:17:52 +01:00
Peter Steinberger 3cca0ee989 fix: paginate search results 2026-01-01 12:15:23 +01:00
Peter Steinberger cfd4045d3e test: cover default cookie timeout 2026-01-01 12:11:39 +01:00
Peter Steinberger b8af7ab6de docs: thank reporter in changelog 2026-01-01 12:10:56 +01:00
Peter Steinberger 19841a6a75 fix: default cookie timeout 30s 2026-01-01 12:10:36 +01:00
Peter Steinberger 14ad6cf73f fix: allow cookie timeout config 2026-01-01 12:00:02 +01:00
Peter Steinberger 42e5124174 fix: seed bookmark query ids 2026-01-01 11:52:51 +01:00
Peter Steinberger 11f8c8cb19 refactor: camelcase user json output 2026-01-01 11:42:36 +01:00
Peter Steinberger c94663a07c Merge pull request #6 from lockmeister/add-following-command
feat: add following and followers commands
2026-01-01 11:33:46 +01:00
Peter Steinberger f3977509dc style: satisfy formatting in tests 2026-01-01 11:32:41 +01:00
Peter Steinberger 578e38d427 build: refresh query ids 2026-01-01 11:31:55 +01:00
Peter Steinberger 9dccb6a3b9 chore: merge origin/main 2026-01-01 11:31:29 +01:00
Peter Steinberger 1235eda368 docs: add following/followers changelog 2026-01-01 11:25:38 +01:00
Peter Steinberger 97c73e939f test: cover following/followers 2026-01-01 11:25:32 +01:00
Peter Steinberger 9d715fb3a6 feat: add following/followers query refresh + retry 2026-01-01 11:25:21 +01:00
Peter Steinberger 64ffcd7461 Merge pull request #8 from alexknowshtml/feat/bookmarks-command
Add native quote tweet support to TweetData
2026-01-01 10:49:53 +01:00
Peter Steinberger 9b7dab7643 style: satisfy lint for quote depth 2026-01-01 10:48:34 +01:00
Peter Steinberger 68ec1d67bf docs: update changelog for quoted tweets 2026-01-01 10:47:26 +01:00
Peter Steinberger 48722d8462 chore: merge origin/main 2026-01-01 10:46:10 +01:00
Peter Steinberger 529403e386 docs: document quoted tweet depth 2026-01-01 10:45:25 +01:00
Peter Steinberger df6b92e39c test: cover quoted tweet depth 2026-01-01 10:45:20 +01:00
Peter Steinberger 071953e9f4 feat: add quote depth option for quoted tweets 2026-01-01 10:45:15 +01:00
Peter Steinberger fd0e6447d5 Merge pull request #9 from swairshah/main
feat: add likes command
2026-01-01 10:02:16 +01:00
Peter Steinberger ce4906a502 fix: use query params string for bookmark folder 2026-01-01 10:00:35 +01:00
Peter Steinberger d1a3d1f289 chore: merge origin/main 2026-01-01 10:00:06 +01:00
Peter Steinberger e5599c7731 build: update query id updater for likes 2026-01-01 09:55:01 +01:00
Peter Steinberger cbe81a1230 docs: add likes command docs 2026-01-01 09:54:56 +01:00
Peter Steinberger 1e6dda961d test: cover likes timeline handling 2026-01-01 09:54:53 +01:00
Peter Steinberger 9660f9092d refactor: share timeline features for likes 2026-01-01 09:54:49 +01:00
Peter Steinberger e3aa7e7ca9 Merge pull request #7 from tylerseymour/bookmark-collection-selection
feat: add folder support for bookmarks command
2026-01-01 09:15:28 +01:00
Peter Steinberger de209d96b2 docs: update changelog for bookmark folders 2026-01-01 09:13:45 +01:00
Peter Steinberger e541d904f2 test: cover bookmark folder parsing and count fallback 2026-01-01 09:13:41 +01:00
swairshahandClaude Opus 4.5 676bff71f3 feat: add likes command
Add `bird likes` command to fetch the authenticated user's liked tweets,
similar to the existing bookmarks command.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 14:02:08 -08:00
Alex HillmanandClaude Opus 4.5 c90d3f9ab4 Add native quote tweet support to TweetData
- Add quoted_status_result to GraphqlTweetResult type
- Add quotedTweet field to TweetData interface
- Extract and map quoted tweets in mapTweetResult()
- Works for getTweet, getBookmarks, search, etc.
- Document JSON output schema in README

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-31 21:58:48 +00:00
Tyler Seymour ee9c89018e feat: add folder support for bookmarks command 2025-12-31 15:20:36 -05:00
thelockmeister 34f45fece7 feat: add following and followers commands
Add two new commands to list users:
- `bird following` - list users that you (or another user) follow
- `bird followers` - list users that follow you (or another user)

Both commands support:
- `--user <userId>` to query another user's following/followers
- `-n <count>` to limit results
- `--json` for structured output

Uses Twitter's GraphQL Following/Followers endpoints with proper
feature flags and query ID rotation support.
2026-01-01 09:06:17 +13:00
Peter Steinberger f264e36cbe docs(changelog): start 0.4.2 cycle 2025-12-31 20:24:27 +01:00
Peter Steinberger 34615de95d chore(release): 0.4.1 2025-12-31 20:11:24 +01:00
Peter Steinberger 2a2a3a7c59 chore: run oxlint type-aware in lint 2025-12-31 19:59:31 +01:00
Peter Steinberger 94de794588 chore: tighten biome rules further 2025-12-31 19:52:23 +01:00
Peter Steinberger 354bf11471 docs: update changelog 2025-12-31 19:41:58 +01:00
Peter Steinberger fc77190929 chore: tighten lint rules and tests 2025-12-31 19:36:33 +01:00
Peter Steinberger 464b50b2fd chore(deps): use sweet-cookie 0.1.0 2025-12-31 12:16:42 +01:00
Peter Steinberger d820d7fbae Merge pull request #4 from alexknowshtml/feat/bookmarks-command
feat: add bookmarks command
2025-12-31 12:16:07 +01:00
Peter Steinberger 2ba0eff1e6 test: cover bookmarks 2025-12-31 12:04:24 +01:00
Alex HillmanandClaude Opus 4.5 9c553e46bf feat: add bookmarks command
Add `bird bookmarks` command to fetch the authenticated user's bookmarked tweets.

- New `getBookmarks()` method in TwitterClient
- Uses graphql_timeline_v2_bookmark_timeline feature flag
- Supports `--count` and `--json` options
- Follows existing patterns for query ID rotation and retry logic

Usage:
  bird bookmarks           # Get your recent bookmarks
  bird bookmarks -n 50     # Get 50 bookmarks
  bird bookmarks --json    # Output as JSON

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-30 22:44:52 +00:00
Peter Steinberger 5532e7ddc2 docs: note sweet-cookie cookie backend 2025-12-27 13:30:29 +01:00
Peter Steinberger c495fb5078 refactor(cookies): use sweet-cookie 2025-12-27 13:23:39 +01:00
Peter Steinberger c16726e9d8 fix: narrow tweet results 2025-12-26 22:40:45 +01:00
Peter Steinberger 2483e32c11 docs: release notes for 0.4.0 2025-12-26 22:39:13 +01:00
Peter Steinberger dfdb7452f6 docs: add undocumented API disclaimer 2025-12-26 22:34:25 +01:00
Peter Steinberger e8bc711c9e docs: mark 0.4.0 unreleased 2025-12-26 22:33:20 +01:00
Peter Steinberger a39948fbbd feat: add bird.fast GitHub Pages site 2025-12-26 22:24:13 +01:00
Peter Steinberger a518df5bb8 Create CNAME 2025-12-26 22:22:21 +01:00
Peter Steinberger 3db2145c2c chore(release): 0.4.0 2025-12-26 22:08:07 +01:00
Peter Steinberger a3d1ba29f7 test(cli): always print tweet URL 2025-12-26 22:04:08 +01:00
Peter Steinberger 1007d051e4 fix(tweet): fallback to statuses/update on 226 2025-12-26 21:57:36 +01:00
Peter Steinberger e8e98e641a refactor(auth)!: replace allow toggles with cookieSource 2025-12-26 21:28:27 +01:00
Peter Steinberger 008e550654 test: skip safari cookieSource on non-darwin 2025-12-26 21:18:41 +01:00
Peter Steinberger 1744f4cc24 feat(auth): allow selecting cookie source 2025-12-26 21:17:20 +01:00
Peter Steinberger bd777f6acc docs(changelog): start 0.3.1 2025-12-26 21:03:51 +01:00
Peter Steinberger fe07d133c5 chore: undo 0.3.1 bump 2025-12-26 20:55:31 +01:00
Peter Steinberger 9a18b49b58 chore: bump to 0.3.1 2025-12-26 20:53:21 +01:00
Peter Steinberger 67ed76b58f chore(release): 0.3.0 2025-12-26 20:49:55 +01:00
Peter Steinberger b5e701770a docs(readme): add homebrew install 2025-12-26 20:34:56 +01:00
Peter Steinberger ca5dabf302 docs(changelog): note coverage thresholds 2025-12-26 20:08:38 +01:00
Peter Steinberger b46b24a8c5 test: enforce coverage thresholds 2025-12-26 18:23:59 +01:00
Peter Steinberger a4686f90ee feat: add Safari cookie extraction 2025-12-26 17:51:41 +01:00
Peter Steinberger 02d12b4e87 refactor!: remove Sweetistics engine
bird is GraphQL-only; bump to 0.3.0
2025-12-26 17:41:43 +01:00
Peter Steinberger 013fa5da95 docs: explain GraphQL query ID refresh 2025-12-26 16:47:39 +01:00
Peter Steinberger f3495247a3 chore: bump to 0.2.1 2025-12-26 16:29:36 +01:00
131 changed files with 21514 additions and 5520 deletions
+1
View File
@@ -0,0 +1 @@
use flake
@@ -8,7 +8,7 @@ on:
jobs:
test:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -29,10 +29,13 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile --config.auto-install-peers=true
- name: Build dist
run: pnpm run build:dist
- name: Run tests
run: pnpm test
+43
View File
@@ -0,0 +1,43 @@
name: Publish npm package
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.auto-install-peers=true
- name: Build package
run: pnpm run build:dist
- name: Publish to Gitea Packages
run: |
npm config set @yuta:registry "$GITEA_NPM_REGISTRY"
npm config set -- "//${GITEA_NPM_REGISTRY#*//}:_authToken" "$NODE_AUTH_TOKEN"
npm publish --access public
env:
GITEA_NPM_REGISTRY: ${{ gitea.server_url }}/api/packages/${{ gitea.repository_owner }}/npm/
NODE_AUTH_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
+1
View File
@@ -0,0 +1 @@
auto-install-peers=false
+113
View File
@@ -1,5 +1,118 @@
# Changelog
## 0.7.0 — 2026-06-24
### Added
- `list-timeline` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#30) — thanks @zheli.
- `home` command for the "For You" and "Following" home timelines (#31) — thanks @odysseus0.
- Rich text output now shows article previews, quoted tweets, and media links (#32) — thanks @odysseus0.
- `user-tweets` command to fetch a user's profile timeline (#34) — thanks @crcatala.
- `replies` and `thread` now support pagination (`--all`, `--max-pages`, `--cursor`, `--delay`) (#35) — thanks @crcatala.
- Long-form article tweets now render rich Draft.js content blocks/entities (#36) — thanks @crcatala.
- `news`/`trending` command for Explore tabs with AI-curated headlines (#39) — thanks @aavetis.
- `search` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#42) — thanks @pjtf93.
- `likes` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#44) — thanks @jsholmes.
### Changed
- Library typing: `SearchResult` is now a discriminated union (so `error` only exists when `success: false`).
### Fixed
- macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst.
- Terminal hyperlinks now sanitize control characters before emitting OSC 8 sequences (#29) — thanks @mafulafunk.
- Following/followers pagination now guards repeat cursors and standardizes JSON output (#28) — thanks @malpern.
- Lists GraphQL feature flags updated to prevent 400s (#27) — thanks @zheli.
- Likes pagination now follows cursors and avoids stalling on duplicate pages (#12) — thanks @titouv.
- Tweet detail parsing now tolerates partial GraphQL errors when usable data exists (#48) — thanks @jsholmes.
- `pnpm run build:dist` now succeeds after tightening JSON/pagination option typing in tweet output commands.
- News output now respects `--tweets-per-item`, keeps unique IDs, and parses non-add entry instructions (#39) — thanks @aavetis.
### Tests
- Following: split following/likes tests + cover cursor handling (#33) — thanks @VACInc.
## 0.6.0 — 2026-01-05
### Added
- Bookmark exports now support pagination (`--all`, `--max-pages`) with retries (#15) — thanks @Nano1337.
- `lists` + `list-timeline` commands for Twitter Lists (#21) — thanks @harperreed
- Tweet JSON output now includes media items (photos, videos, GIFs) (#14) — thanks @Hormold
- Bookmarks can resume pagination from a cursor (#26) — thanks @leonho
- `unbookmark` command to remove bookmarked tweets (#22) — thanks @mbelinky.
### Changed
- Feature flags can be overridden at runtime via `features.json` (refreshable via `query-ids`).
### Fixed
- GraphQL feature flags now include `post_ctas_fetch_enabled` to avoid 400s (#38) — thanks @philipp-spiess.
## 0.5.1 — 2026-01-01
### Changed
- `bird --help` now includes explicit “Shortcuts” and “JSON Output” sections (documents `bird <tweet-id-or-url>` shorthand + `--json`).
- Release docs now include explicit npm publish verification steps.
### Fixed
- `pnpm bird --help` now works (dev script runs the CLI entrypoint, not the library entrypoint).
- `following`/`followers` now fall back to internal v1.1 REST endpoints when GraphQL returns `404`.
### Tests
- Add root help output regression test.
- Add opt-in live CLI test suite (real GraphQL calls; skipped by default; gated via `BIRD_LIVE=1`).
## 0.5.0 — 2026-01-01
### Added
- `likes` command to list your liked tweets (thanks @swairshah).
- Quoted tweet data in JSON output + `--quote-depth` (thanks @alexknowshtml).
- `following`/`followers` commands to list users (thanks @lockmeister).
### Changed
- Query ID updater now tracks the Likes GraphQL operation.
- Query ID updater now tracks Following/Followers GraphQL operations.
- Query ID updater now tracks BookmarkFolderTimeline and keeps bookmark query IDs seeded.
- `following`/`followers` JSON user fields are now camelCase (`followersCount`, `followingCount`, `isBlueVerified`, `profileImageUrl`, `createdAt`).
- Cookie extraction timeout is now configurable (default 30s on macOS) via `--cookie-timeout` / `BIRD_COOKIE_TIMEOUT_MS` (thanks @tylerseymour).
- Search now paginates beyond 20 results when using `-n` (thanks @ryanh-ai).
- Library exports are now separated from the CLI entrypoint for easier embedding.
## 0.4.1 — 2025-12-31
### Added
- `bookmarks` command to list your bookmarked tweets.
- `bookmarks --folder-id` to fetch bookmark folders (thanks @tylerseymour).
### Changed
- Cookie extraction now uses `@steipete/sweet-cookie` (drops `sqlite3` CLI + custom browser readers in `bird`).
- Query ID updater now tracks the Bookmarks GraphQL operation.
- Lint rules stricter (block statements, no-negation-else, useConst/useTemplate, top-level regex, import extension enforcement).
- `pnpm lint` now runs both Biome and oxlint (type-aware).
### Tests
- Coverage thresholds raised to 90% statements/lines/functions (80% branches).
- Added targeted Twitter client coverage suites.
## 0.4.0 — 2025-12-26
### Added
- Cookie source selection: `--cookie-source safari|chrome|firefox` (repeatable) + `cookieSource` config (string or array).
### Fixed
- `tweet`/`reply`: fallback to `statuses/update.json` when GraphQL `CreateTweet` returns error 226 (“automated request”).
### Breaking
- Remove `allowSafari`/`allowChrome`/`allowFirefox` config toggles in favor of `cookieSource` ordering.
## 0.3.0 — 2025-12-26
### Added
- Safari cookie extraction (`Cookies.binarycookies`) + `allowSafari` config toggle.
### Changed
- Removed the Sweetistics engine + fallback. `bird` is GraphQL-only.
- Browser cookie fallback order: Safari → Chrome → Firefox.
### Tests
- Enforce coverage thresholds (>= 70% statements/branches/functions/lines) + expand unit coverage for version/output/Twitter client branches.
## 0.2.0 — 2025-12-26
### Added
+234 -39
View File
@@ -1,18 +1,43 @@
# bird 🐦 — fast X CLI for tweeting, replying, and reading
`bird` is a fast X CLI for tweeting, replying, and reading. It uses either GraphQL cookies or the Sweetistics API.
`bird` is a fast X CLI for tweeting, replying, and reading via X/Twitter GraphQL through twitter safe relay.
## Disclaimer
This project uses X/Twitter’s [**documented**](https://github.com/fa0311/TwitterInternalAPIDocument) internal web
GraphQL API through twitter safe relay. X can change endpoints, query IDs, and anti-bot behavior at any time —
**expect this to break without notice**.
## Install
Gitea Packages:
```bash
npm install -g @steipete/bird
# or
pnpm add -g @steipete/bird
npm config set @yuta:registry=https://git.yutakobayashi.com/api/packages/yuta/npm/
npm install -g @yuta/bird
# one-shot (no install)
npx -y @yuta/bird whoami
```
Nix flakes:
```bash
nix run git+https://git.yutakobayashi.com/yuta/bird -- --help
```
Homebrew (macOS, prebuilt Bun binary):
```bash
brew install steipete/tap/bird
```
## Quickstart
```bash
# Point bird at your running twitter safe relay
export TWITTER_RELAY_BASE_URL=http://localhost:6900
# Show the logged-in account
bird whoami
@@ -26,16 +51,111 @@ bird 1234567890123456789 --json
# Thread + replies
bird thread https://x.com/user/status/1234567890123456789
bird replies 1234567890123456789
bird replies 1234567890123456789 --max-pages 3 --json
bird thread 1234567890123456789 --max-pages 3 --json
# Search + mentions
bird search "from:steipete" -n 5
bird mentions -n 5
bird mentions --user @steipete -n 5
# User tweets (profile timeline)
bird user-tweets @steipete -n 20
bird user-tweets @steipete -n 50 --json
# Bookmarks
bird bookmarks -n 5
bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/<folder-id>
bird bookmarks --all --json
bird bookmarks --all --max-pages 2 --json
bird unbookmark 1234567890123456789
bird unbookmark https://x.com/user/status/1234567890123456789
# Likes
bird likes -n 5
# News and trending topics (AI-curated from Explore tabs)
bird news --ai-only -n 10
bird news --sports -n 5
# Lists
bird list-timeline 1234567890 -n 20
bird list-timeline https://x.com/i/lists/1234567890 --all --json
bird list-timeline 1234567890 --max-pages 3 --json
# Following (who you follow)
bird following -n 20
bird following --user 12345678 -n 10 # by user ID
# Followers (who follows you)
bird followers -n 20
bird followers --user 12345678 -n 10 # by user ID
# Refresh GraphQL query IDs cache (no rebuild)
bird query-ids --fresh
```
## News & Trending
Fetch AI-curated news and trending topics from X's Explore page tabs:
```bash
# Fetch 10 news items from all tabs (default: For You, News, Sports, Entertainment)
bird news -n 10
# Fetch only AI-curated news (filters out regular trends)
bird news --ai-only -n 20
# Fetch from specific tabs
bird news --news-only --ai-only -n 10
bird news --sports -n 15
bird news --entertainment --ai-only -n 5
# Include related tweets for each news item
bird news --with-tweets --tweets-per-item 3 -n 10
# Combine multiple tab filters
bird news --sports --entertainment -n 20
# JSON output
bird news --json -n 5
bird news --json-full --ai-only -n 10 # includes raw API response
```
Tab options (can be combined):
- `--for-you` — Fetch from For You tab only
- `--news-only` — Fetch from News tab only
- `--sports` — Fetch from Sports tab only
- `--entertainment` — Fetch from Entertainment tab only
- `--trending-only` — Fetch from Trending tab only
By default, the command fetches from For You, News, Sports, and Entertainment tabs (Trending excluded to reduce noise). Headlines are automatically deduplicated across tabs.
## Library
`bird` can be used as a library (same GraphQL client as the CLI):
```ts
import { TwitterClient } from '@yuta/bird';
const client = new TwitterClient({
relayBaseUrl: process.env.TWITTER_RELAY_BASE_URL
});
// Search for tweets
const searchResult = await client.search('from:steipete', 50);
// Fetch news and trending topics from all tabs (default: For You, News, Sports, Entertainment)
const newsResult = await client.getNews(10, { aiOnly: true });
// Fetch from specific tabs with related tweets
const sportsNews = await client.getNews(10, {
aiOnly: true,
withTweets: true,
tabs: ['sports', 'entertainment']
});
```
## Commands
- `bird tweet "<text>"` — post a new tweet.
@@ -44,38 +164,50 @@ bird query-ids --fresh
- `bird query-ids [--fresh] [--json]` — inspect or refresh cached GraphQL query IDs.
- `bird read <tweet-id-or-url> [--json]` — fetch tweet content as text or JSON.
- `bird <tweet-id-or-url> [--json]` — shorthand for `read` when only a URL or ID is provided.
- `bird replies <tweet-id-or-url> [--json]` — list replies to a tweet.
- `bird thread <tweet-id-or-url> [--json]` — show the full conversation thread.
- `bird search "<query>" [-n count] [--json]` — search for tweets matching a query.
- `bird replies <tweet-id-or-url> [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — list replies to a tweet.
- `bird thread <tweet-id-or-url> [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — show the full conversation thread.
- `bird search "<query>" [-n count] [--all] [--max-pages n] [--cursor string] [--json]` — search for tweets matching a query; `--max-pages` requires `--all` or `--cursor`.
- `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
- `bird whoami` — print which Twitter account your cookies belong to.
- `bird check` — show which credentials are available and where they were sourced from.
## Engines
- `--engine auto` (default) — use GraphQL first; if a Sweetistics API key is available, fall back on errors.
- `--engine graphql` — use Twitter/X GraphQL with cookies (Chrome/Firefox/env/flags).
- `--engine sweetistics` — use Sweetistics API key (no browser cookies needed).
Note: fallback to Sweetistics only happens in `--engine auto`. `--engine graphql` is strict (no fallback).
- `bird user-tweets <@handle> [-n count] [--cursor string] [--max-pages n] [--delay ms] [--json]` — get tweets from a user's profile timeline.
- `bird bookmarks [-n count] [--folder-id id] [--all] [--max-pages n] [--json]` — list your bookmarked tweets (or a specific bookmark folder); `--max-pages` requires `--all`.
- `bird unbookmark <tweet-id-or-url...>` — remove one or more bookmarks by tweet ID or URL.
- `bird likes [-n count] [--json]` — list your liked tweets.
- `bird news [-n count] [--ai-only] [--with-tweets] [--tweets-per-item n] [--for-you] [--news-only] [--sports] [--entertainment] [--trending-only] [--json]` — fetch news and trending topics from X's Explore tabs.
- `bird trending` — alias for `news` command.
- `bird list-timeline <list-id-or-url> [-n count] [--all] [--max-pages n] [--cursor string] [--json]` — get tweets from a list timeline; `--max-pages` implies `--all`.
- `bird following [--user <userId>] [-n count] [--json]` — list users that you (or another user) follow.
- `bird followers [--user <userId>] [-n count] [--json]` — list users that follow you (or another user).
- `bird whoami` — print which Twitter account your relay belongs to.
- `bird check` — verify relay configuration and account access.
- `bird likes [-n count] [--json]` — list your liked tweets.
- `bird news [-n count] [--ai-only] [--with-tweets] [--tweets-per-item n] [--for-you] [--news-only] [--sports] [--entertainment] [--trending-only] [--json]` — fetch news and trending topics from X's Explore tabs (fetches from For You, News, Sports, and Entertainment tabs by default).
- `bird trending` — alias for `news` command.
- `bird list-timeline <list-id-or-url> [-n count] [--all] [--max-pages n] [--cursor string] [--json]` — get tweets from a list timeline; `--max-pages` implies `--all`.
- `bird following [--user <userId>] [-n count] [--json]` — list users that you (or another user) follow.
- `bird followers [--user <userId>] [-n count] [--json]` — list users that follow you (or another user).
- `bird whoami` — print which Twitter account your relay belongs to.
- `bird check` — verify relay configuration and account access.
Global options:
- `--relay-base-url <url>`: twitter safe relay base URL (defaults to `TWITTER_RELAY_BASE_URL`).
- `--timeout <ms>`: abort requests after the given timeout (milliseconds).
- `--quote-depth <n>`: max quoted tweet depth in JSON output (default: 1; 0 disables).
- `--plain`: stable output (no emoji, no color).
- `--no-emoji`: disable emoji output.
- `--no-color`: disable ANSI colors (or set `NO_COLOR=1`).
- `--media <path>`: attach media file (repeatable, up to 4 images or 1 video).
- `--alt <text>`: alt text for the corresponding `--media` (repeatable).
## Authentication (GraphQL)
`bird` resolves credentials in this order:
`bird` does not handle Twitter authentication locally. It sends Twitter API-compatible requests to twitter safe relay,
and the relay handles authentication.
1. CLI flags: `--auth-token`, `--ct0`
2. Environment variables: `AUTH_TOKEN`, `CT0` (fallback: `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`)
3. Browser cookies (macOS): Firefox or Chrome profiles
Write operations:
- `tweet`/`reply` primarily use GraphQL (`CreateTweet`).
- If GraphQL returns error `226` (“automated request”), `bird` falls back to the legacy `statuses/update.json` endpoint.
Browser cookie sources:
- Firefox (default): `~/Library/Application Support/Firefox/Profiles/<profile>/cookies.sqlite`
- Chrome: `~/Library/Application Support/Google/Chrome/<Profile>/Cookies`
Configure the relay URL with `TWITTER_RELAY_BASE_URL` or `--relay-base-url`.
## Config (JSON5)
@@ -88,28 +220,90 @@ Example `~/.config/bird/config.json5`:
```json5
{
engine: "auto",
firefoxProfile: "default-release",
sweetisticsApiKey: "sweet-...",
allowFirefox: true,
allowChrome: false,
timeoutMs: 20000
relayBaseUrl: "http://localhost:6900",
timeoutMs: 20000,
quoteDepth: 1
}
```
Environment shortcuts:
- `SWEETISTICS_API_KEY`, `SWEETISTICS_BASE_URL`
- `BIRD_ENGINE`, `BIRD_TIMEOUT_MS`
- `TWITTER_RELAY_BASE_URL`
- `BIRD_TIMEOUT_MS`
- `BIRD_QUOTE_DEPTH`
## Output
- `--json` prints raw tweet objects for read/replies/thread/search/mentions.
- `--json` prints raw tweet objects for read/replies/thread/search/mentions/user-tweets/bookmarks/likes.
- When using `--json` with pagination (`--all`, `--cursor`, `--max-pages`, or for `user-tweets` when `-n > 20`), output is `{ tweets, nextCursor }`.
- `read` returns full text for Notes and Articles when present.
- Use `--plain` for stable, script-friendly output (no emoji, no color).
### JSON Schema
When using `--json`, tweet objects include:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Tweet ID |
| `text` | string | Full tweet text (includes Note/Article content when present) |
| `author` | object | `{ username, name }` |
| `authorId` | string? | Author's user ID |
| `createdAt` | string | Timestamp |
| `replyCount` | number | Number of replies |
| `retweetCount` | number | Number of retweets |
| `likeCount` | number | Number of likes |
| `conversationId` | string | Thread conversation ID |
| `inReplyToStatusId` | string? | Parent tweet ID (present if this is a reply) |
| `quotedTweet` | object? | Embedded quote tweet (same schema; depth controlled by `--quote-depth`) |
When using `--json` with `following`/`followers`, user objects include:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | User ID |
| `username` | string | Username/handle |
| `name` | string | Display name |
| `description` | string? | User bio |
| `followersCount` | number? | Followers count |
| `followingCount` | number? | Following count |
| `isBlueVerified` | boolean? | Blue verified flag |
| `profileImageUrl` | string? | Profile image URL |
| `createdAt` | string? | Account creation timestamp |
When using `--json` with `news`/`trending`, news objects include:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Unique identifier for the news item |
| `headline` | string | News headline or trend title |
| `category` | string? | Category (e.g., "AI · Technology", "Trending", "News") |
| `timeAgo` | string? | Relative time (e.g., "2h ago") |
| `postCount` | number? | Number of posts |
| `description` | string? | Item description |
| `url` | string? | URL to the trend or news article |
| `tweets` | array? | Related tweets (only when `--with-tweets` is used) |
| `_raw` | object? | Raw API response (only when `--json-full` is used) |
## Query IDs (GraphQL)
X rotates GraphQL query IDs frequently. `bird` caches refreshed IDs on disk and retries automatically on 404s.
X rotates GraphQL “query IDs” frequently. Each GraphQL operation is addressed as:
- `operationName` (e.g. `TweetDetail`, `CreateTweet`)
- `queryId` (rotating ID baked into X’s web client bundles)
`bird` ships with a baseline mapping in `src/lib/query-ids.json` (copied into `dist/` on build). At runtime,
it can refresh that mapping by scraping X’s public web client bundles and caching the result on disk.
Runtime cache:
- Default path: `~/.config/bird/query-ids-cache.json`
- Override path: `BIRD_QUERY_IDS_CACHE=/path/to/file.json`
- TTL: 24h (stale cache is still used, but marked “not fresh”)
Auto-recovery:
- On GraphQL `404` (query ID invalid), `bird` forces a refresh once and retries.
- For `TweetDetail`/`SearchTimeline`, `bird` also rotates through a small set of known fallback IDs to reduce
breakage while refreshing.
Refresh on demand:
@@ -124,19 +318,18 @@ Exit codes:
## Version
`bird --version` prints `package.json` version plus current git sha when available, e.g. `0.2.0 (3df7969b)`.
`bird --version` prints `package.json` version plus current git sha when available, e.g. `0.3.0 (3df7969b)`.
## Media uploads
- Attach media with `--media` (repeatable) and optional `--alt` per item.
- Up to 4 images/GIFs, or 1 video (no mixing). Supported: jpg, jpeg, png, webp, gif, mp4, mov.
- `--engine graphql`: images/GIFs + 1 video supported (uploads via Twitter legacy upload endpoint + cookies; video may take longer to process).
- `--engine sweetistics`: images + video supported (requires `SWEETISTICS_API_KEY`).
- Images/GIFs + 1 video supported (uploads via Twitter legacy upload endpoint through the relay; video may take longer to process).
Example:
```bash
bird --engine sweetistics tweet "hi" --media img.png --alt "desc"
bird tweet "hi" --media img.png --alt "desc"
```
## Development
@@ -147,6 +340,8 @@ pnpm install
pnpm run build # dist/ + bun binary
pnpm run build:dist # dist/ only
pnpm run build:binary
nix build .#bird
nix run .# -- --help
pnpm run dev tweet "Test"
pnpm run dev -- --plain check
@@ -157,4 +352,4 @@ pnpm run lint
## Notes
- GraphQL uses internal X endpoints and can be rate limited (429).
- Query IDs rotate; refresh them with `pnpm run graphql:update`.
- Query IDs rotate; refresh at runtime with `bird query-ids --fresh` (or update the baked baseline via `pnpm run graphql:update`).
+23 -2
View File
@@ -11,10 +11,31 @@
"rules": {
"recommended": true,
"complexity": {
"noForEach": "off"
"noForEach": "error"
},
"correctness": {
"useImportExtensions": {
"level": "error",
"options": {
"forceJsExtensions": true
}
}
},
"performance": {
"useTopLevelRegex": "error"
},
"style": {
"noNonNullAssertion": "off"
"noNegationElse": "error",
"noNonNullAssertion": "error",
"useBlockStatements": "error",
"useConst": "error",
"useTemplate": "error"
},
"suspicious": {
"noExplicitAny": "error"
},
"nursery": {
"useRegexpExec": "error"
}
}
},
+1
View File
@@ -0,0 +1 @@
+42
View File
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0b0d10" />
<title>404 — bird.fast</title>
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],500..900&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap"
/>
<link rel="stylesheet" href="./assets/site.css" />
</head>
<body class="is-404">
<div class="bg" aria-hidden="true">
<div class="bg__orb bg__orb--a"></div>
<div class="bg__orb bg__orb--b"></div>
<div class="bg__grid"></div>
<div class="bg__noise"></div>
</div>
<main class="notfound">
<a class="brand brand--solo" href="./" aria-label="bird.fast home">
<span class="brand__mark" aria-hidden="true">
<img src="./assets/mark.svg" width="26" height="26" alt="" />
</span>
<span class="brand__word">bird</span>
<span class="brand__dot">.fast</span>
</a>
<h1 class="title title--small">404</h1>
<p class="lede lede--small">That path doesn’t exist. The CLI does.</p>
<div class="cta">
<a class="btn btn--hot" href="./">Go home</a>
<a class="btn btn--ghost" href="https://git.yutakobayashi.com/yuta/bird">Open Gitea</a>
</div>
</main>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
bird.fast
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="none">
<path
d="M19.7 10.3c-.9-2.8-3.5-4.8-6.5-4.8-3.8 0-6.9 3.1-6.9 6.9 0 3.9 3.1 7 6.9 7 .9 0 1.8-.2 2.6-.5 1.8 1 3.8 1.2 5.8.6-2.3-.4-3.6-1.4-4.2-2.9 1.6-1.2 2.6-3.1 2.6-5.2 0-.3 0-.6-.1-.9h.9l2.5-2.1-3.6.1z"
fill="rgba(244,241,233,0.92)"
/>
<path
d="M22.9 9.6l-1.7 1.4h-1.1c-.3-1-.9-1.9-1.6-2.7l4.4-.2z"
fill="rgba(183,255,106,0.88)"
/>
</svg>

After

Width:  |  Height:  |  Size: 439 B

+479
View File
@@ -0,0 +1,479 @@
:root {
--bg: #0b0d10;
--fg: #f4f1e9;
--muted: rgba(244, 241, 233, 0.66);
--dim: rgba(244, 241, 233, 0.12);
--edge: rgba(244, 241, 233, 0.16);
--hot: #b7ff6a;
--hot2: #6afcff;
--ink: rgba(8, 9, 12, 0.72);
--serif: "Fraunces", ui-serif, "Iowan Old Style", "Apple Garamond", serif;
--sans: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--radius: 18px;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.55);
--max: 1080px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: var(--sans);
color: var(--fg);
background: var(--bg);
overflow-x: hidden;
}
a { color: inherit; text-underline-offset: 4px; }
a:hover { text-decoration-thickness: 2px; }
.skip {
position: absolute;
left: 12px;
top: 12px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--edge);
background: rgba(0, 0, 0, 0.7);
transform: translateY(-140%);
transition: transform 180ms ease;
z-index: 999;
}
.skip:focus { transform: translateY(0); outline: none; }
.bg { position: fixed; inset: 0; pointer-events: none; z-index: 0; }
.bg__orb {
position: absolute;
width: 900px;
height: 900px;
border-radius: 999px;
filter: blur(48px);
opacity: 0.55;
transform: translate3d(0, 0, 0);
}
.bg__orb--a {
left: -320px;
top: -280px;
background: radial-gradient(circle at 30% 30%, rgba(183, 255, 106, 0.95), rgba(183, 255, 106, 0.0) 62%);
}
.bg__orb--b {
right: -360px;
bottom: -320px;
background: radial-gradient(circle at 30% 30%, rgba(106, 252, 255, 0.95), rgba(106, 252, 255, 0.0) 62%);
}
.bg__grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, rgba(244, 241, 233, 0.06) 1px, transparent 1px),
linear-gradient(to bottom, rgba(244, 241, 233, 0.06) 1px, transparent 1px);
background-size: 64px 64px;
opacity: 0.22;
mask-image: radial-gradient(circle at 48% 25%, black 0%, black 45%, transparent 72%);
}
.bg__noise {
position: absolute;
inset: 0;
opacity: 0.09;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)' opacity='.48'/%3E%3C/svg%3E");
}
.top { position: sticky; top: 0; z-index: 10; backdrop-filter: blur(14px); }
.top__inner {
max-width: var(--max);
margin: 0 auto;
padding: 18px 18px;
display: flex;
align-items: center;
justify-content: space-between;
}
.top::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(244, 241, 233, 0.16), transparent);
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
text-decoration: none;
}
.brand__mark {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 10px;
border: 1px solid rgba(244, 241, 233, 0.18);
background: rgba(0, 0, 0, 0.18);
box-shadow: 0 10px 38px rgba(0, 0, 0, 0.38);
}
.brand__word {
font-family: var(--serif);
letter-spacing: -0.02em;
font-variation-settings: "opsz" 144;
font-weight: 780;
font-size: 18px;
}
.brand__dot {
font-family: var(--mono);
font-weight: 500;
font-size: 13px;
color: var(--muted);
transform: translateY(1px);
}
.nav { display: flex; gap: 14px; }
.nav__link {
font-size: 13px;
color: var(--muted);
text-decoration: none;
padding: 10px 10px;
border-radius: 12px;
border: 1px solid transparent;
}
.nav__link:hover {
color: var(--fg);
border-color: rgba(244, 241, 233, 0.14);
background: rgba(0, 0, 0, 0.18);
}
.nav__link:focus-visible { outline: 2px solid rgba(183, 255, 106, 0.55); outline-offset: 2px; }
main { position: relative; z-index: 1; }
.hero { padding: 64px 18px 26px; }
.hero__inner { max-width: var(--max); margin: 0 auto; }
.kicker {
margin: 0 0 10px;
font-family: var(--mono);
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(244, 241, 233, 0.62);
}
.title {
margin: 0 0 14px;
font-family: var(--serif);
font-weight: 820;
letter-spacing: -0.05em;
line-height: 0.95;
font-size: clamp(54px, 8vw, 118px);
font-variation-settings: "opsz" 144;
text-shadow: 0 22px 70px rgba(0, 0, 0, 0.55);
}
.title__dot {
font-family: var(--mono);
font-weight: 500;
letter-spacing: -0.02em;
font-size: 0.7em;
margin-left: 8px;
color: rgba(183, 255, 106, 0.92);
text-shadow: 0 0 0 transparent;
}
.lede {
margin: 0 0 24px;
max-width: 70ch;
font-size: 16px;
line-height: 1.6;
color: var(--muted);
}
.cta { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 12px 14px;
border-radius: 14px;
text-decoration: none;
border: 1px solid rgba(244, 241, 233, 0.14);
background: rgba(0, 0, 0, 0.14);
color: var(--fg);
font-size: 13px;
box-shadow: 0 16px 60px rgba(0, 0, 0, 0.32);
}
.btn:hover { border-color: rgba(244, 241, 233, 0.22); }
.btn:focus-visible { outline: 2px solid rgba(106, 252, 255, 0.55); outline-offset: 2px; }
.btn--hot {
border-color: rgba(183, 255, 106, 0.35);
background: linear-gradient(180deg, rgba(183, 255, 106, 0.22), rgba(183, 255, 106, 0.08));
}
.btn--hot:hover { border-color: rgba(183, 255, 106, 0.55); }
.btn--ghost { color: rgba(244, 241, 233, 0.78); }
.terminal {
margin-top: 24px;
border-radius: var(--radius);
border: 1px solid rgba(244, 241, 233, 0.14);
background: rgba(0, 0, 0, 0.30);
box-shadow: var(--shadow);
overflow: hidden;
}
.terminal__bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.25);
border-bottom: 1px solid rgba(244, 241, 233, 0.10);
}
.terminal__dots { display: inline-flex; gap: 6px; }
.terminal__dots i {
width: 9px;
height: 9px;
border-radius: 999px;
background: rgba(244, 241, 233, 0.16);
}
.terminal__label {
font-family: var(--mono);
font-size: 11px;
color: rgba(244, 241, 233, 0.55);
}
.terminal__body { position: relative; padding: 14px 14px 16px; }
.terminal pre { margin: 0; overflow-x: auto; }
.terminal code {
font-family: var(--mono);
font-size: 13px;
line-height: 1.55;
color: rgba(244, 241, 233, 0.86);
}
.copy {
position: absolute;
right: 12px;
top: 12px;
border: 1px solid rgba(244, 241, 233, 0.14);
border-radius: 12px;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.22);
color: rgba(244, 241, 233, 0.86);
font-family: var(--mono);
font-size: 12px;
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease;
}
.copy:hover { border-color: rgba(244, 241, 233, 0.25); transform: translateY(-1px); }
.copy:focus-visible { outline: 2px solid rgba(183, 255, 106, 0.55); outline-offset: 2px; }
.copy__done { display: none; }
.copy.is-done .copy__idle { display: none; }
.copy.is-done .copy__done { display: inline; color: rgba(183, 255, 106, 0.92); }
.badges {
margin-top: 16px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.badge {
text-decoration: none;
border-radius: 16px;
border: 1px solid rgba(244, 241, 233, 0.12);
background: rgba(0, 0, 0, 0.14);
padding: 12px 12px;
display: flex;
justify-content: space-between;
gap: 12px;
box-shadow: 0 12px 44px rgba(0, 0, 0, 0.28);
}
.badge:hover { border-color: rgba(244, 241, 233, 0.22); }
.badge__k { font-family: var(--mono); font-size: 12px; color: rgba(244, 241, 233, 0.62); }
.badge__v { font-family: var(--mono); font-size: 12px; color: rgba(244, 241, 233, 0.90); }
.stripe { padding: 44px 18px; }
.stripe__inner { max-width: var(--max); margin: 0 auto; }
.stripe--tight { padding-top: 28px; }
.stripe--end { padding-bottom: 64px; }
.h2 {
margin: 0 0 10px;
font-family: var(--serif);
font-weight: 760;
letter-spacing: -0.03em;
font-size: 28px;
font-variation-settings: "opsz" 144;
}
.h3 { margin: 0 0 8px; font-weight: 650; font-size: 16px; }
.p { margin: 0 0 14px; line-height: 1.6; color: rgba(244, 241, 233, 0.80); }
.p--muted { color: var(--muted); }
.cards { display: grid; gap: 12px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.card {
border-radius: var(--radius);
border: 1px solid rgba(244, 241, 233, 0.12);
background: rgba(0, 0, 0, 0.16);
padding: 16px 16px 14px;
box-shadow: 0 18px 70px rgba(0, 0, 0, 0.28);
}
.code {
margin: 12px 0 0;
border-radius: 14px;
padding: 12px 12px;
background: rgba(0, 0, 0, 0.30);
border: 1px solid rgba(244, 241, 233, 0.10);
overflow-x: auto;
}
.code code { font-family: var(--mono); font-size: 12px; line-height: 1.55; color: rgba(244, 241, 233, 0.86); }
.cmds { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
.cmd {
border-radius: 999px;
padding: 10px 12px;
border: 1px solid rgba(244, 241, 233, 0.14);
background: rgba(0, 0, 0, 0.14);
color: rgba(244, 241, 233, 0.78);
font-family: var(--mono);
font-size: 12px;
cursor: pointer;
}
.cmd:hover { border-color: rgba(244, 241, 233, 0.24); color: rgba(244, 241, 233, 0.92); }
.cmd.is-on {
border-color: rgba(183, 255, 106, 0.42);
background: linear-gradient(180deg, rgba(183, 255, 106, 0.18), rgba(183, 255, 106, 0.05));
color: rgba(244, 241, 233, 0.96);
}
.cmd:focus-visible { outline: 2px solid rgba(183, 255, 106, 0.55); outline-offset: 2px; }
.demo {
margin-top: 14px;
position: relative;
border-radius: var(--radius);
border: 1px solid rgba(244, 241, 233, 0.12);
background: rgba(0, 0, 0, 0.20);
padding: 14px 14px 16px;
box-shadow: 0 18px 70px rgba(0, 0, 0, 0.28);
}
.demo__pre { margin: 0; overflow-x: auto; }
.demo__pre code { font-family: var(--mono); font-size: 13px; line-height: 1.55; color: rgba(244, 241, 233, 0.86); }
.copy--demo { top: 10px; right: 10px; }
.steps { display: grid; gap: 12px; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-top: 14px; }
.step {
border-radius: var(--radius);
border: 1px solid rgba(244, 241, 233, 0.12);
background: rgba(0, 0, 0, 0.14);
padding: 16px 16px 14px;
box-shadow: 0 18px 70px rgba(0, 0, 0, 0.26);
}
.step__num {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.12em;
color: rgba(244, 241, 233, 0.56);
margin-bottom: 10px;
}
.pill {
display: inline-block;
padding: 1px 7px 2px;
border-radius: 999px;
border: 1px solid rgba(244, 241, 233, 0.14);
background: rgba(0, 0, 0, 0.20);
font-family: var(--mono);
font-size: 12px;
color: rgba(244, 241, 233, 0.86);
}
.footerline {
margin-top: 16px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
color: rgba(244, 241, 233, 0.60);
}
.tiny { font-size: 12px; color: rgba(244, 241, 233, 0.72); }
.tiny--muted { color: rgba(244, 241, 233, 0.52); }
.sep { opacity: 0.5; }
.notice {
margin-top: 18px;
border-radius: var(--radius);
border: 1px solid rgba(244, 241, 233, 0.12);
background: rgba(0, 0, 0, 0.22);
padding: 14px 14px;
display: grid;
grid-template-columns: 34px 1fr;
gap: 12px;
align-items: start;
box-shadow: 0 18px 70px rgba(0, 0, 0, 0.26);
}
.notice__icon {
width: 34px;
height: 34px;
border-radius: 12px;
display: grid;
place-items: center;
font-family: var(--mono);
font-weight: 600;
border: 1px solid rgba(244, 241, 233, 0.14);
background: rgba(0, 0, 0, 0.18);
color: rgba(244, 241, 233, 0.86);
}
.notice__title {
margin: 0 0 4px;
font-weight: 700;
letter-spacing: -0.01em;
}
.notice__text { margin: 0; color: var(--muted); line-height: 1.6; }
.notice--warn {
border-color: rgba(183, 255, 106, 0.18);
background: linear-gradient(180deg, rgba(183, 255, 106, 0.10), rgba(0, 0, 0, 0.22));
}
.notice--warn .notice__icon {
border-color: rgba(183, 255, 106, 0.28);
background: rgba(183, 255, 106, 0.08);
color: rgba(183, 255, 106, 0.92);
}
.bottom {
position: relative;
z-index: 1;
padding: 28px 18px 40px;
}
.bottom__inner { max-width: var(--max); margin: 0 auto; }
/* 404 */
body.is-404 { display: grid; place-items: center; }
.notfound { max-width: 640px; padding: 18px; text-align: left; }
.brand--solo { margin-bottom: 26px; }
.title--small { font-size: clamp(56px, 10vw, 96px); margin-bottom: 6px; }
.lede--small { max-width: 54ch; }
/* reveal */
[data-reveal] { opacity: 0; transform: translateY(14px); filter: blur(6px); }
body.is-ready [data-reveal] {
opacity: 1;
transform: translateY(0);
filter: blur(0);
transition: opacity 620ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 620ms cubic-bezier(0.2, 0.8, 0.2, 1),
filter 620ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
body.is-ready [data-reveal]:nth-child(1) { transition-delay: 70ms; }
body.is-ready [data-reveal]:nth-child(2) { transition-delay: 140ms; }
body.is-ready [data-reveal]:nth-child(3) { transition-delay: 210ms; }
body.is-ready [data-reveal]:nth-child(4) { transition-delay: 280ms; }
body.is-ready [data-reveal]:nth-child(5) { transition-delay: 350ms; }
body.is-ready [data-reveal]:nth-child(6) { transition-delay: 420ms; }
@media (prefers-reduced-motion: reduce) {
[data-reveal] { opacity: 1; transform: none; filter: none; }
body.is-ready [data-reveal] { transition: none; }
.copy { transition: none; }
}
@media (max-width: 920px) {
.badges { grid-template-columns: 1fr; }
.cards { grid-template-columns: 1fr; }
.steps { grid-template-columns: 1fr; }
.nav { display: none; }
}
+80
View File
@@ -0,0 +1,80 @@
const by = (sel, root = document) => root.querySelector(sel);
const all = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const demoSnippets = {
tweet: `bird tweet "ship it"`,
reply: `bird reply https://x.com/user/status/1234567890123456789 "same"`,
read: `bird read https://x.com/user/status/1234567890123456789`,
thread: `bird thread 1234567890123456789`,
search: `bird search "from:steipete" -n 5`,
mentions: `bird mentions -n 5`,
};
const setDemo = (key) => {
const code = by("#demoCode");
if (!code) return;
const value = demoSnippets[key] ?? demoSnippets.tweet;
code.textContent = value;
};
const wireTabs = () => {
const buttons = all("[data-cmd]");
if (buttons.length === 0) return;
buttons.forEach((b) => {
b.addEventListener("click", () => {
const key = b.getAttribute("data-cmd") ?? "tweet";
buttons.forEach((x) => {
const on = x === b;
x.classList.toggle("is-on", on);
x.setAttribute("aria-selected", on ? "true" : "false");
});
setDemo(key);
});
});
};
const wireCopy = () => {
all("[data-copy]").forEach((btn) => {
btn.addEventListener("click", async () => {
const sel = btn.getAttribute("data-copy");
if (!sel) return;
const node = by(sel);
if (!node) return;
const text = node.textContent ?? "";
try {
await navigator.clipboard.writeText(text);
btn.classList.add("is-done");
window.setTimeout(() => btn.classList.remove("is-done"), 900);
} catch {
// noop
}
});
});
};
const wireOrbs = () => {
const a = by(".bg__orb--a");
const b = by(".bg__orb--b");
if (!a || !b) return;
let raf = 0;
const onMove = (e) => {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const x = e.clientX / window.innerWidth;
const y = e.clientY / window.innerHeight;
a.style.transform = `translate3d(${(x - 0.4) * 80}px, ${(y - 0.4) * 70}px, 0)`;
b.style.transform = `translate3d(${(x - 0.6) * 90}px, ${(y - 0.6) * 80}px, 0)`;
});
};
window.addEventListener("mousemove", onMove, { passive: true });
};
document.documentElement.classList.add("js");
window.setTimeout(() => document.body.classList.add("is-ready"), 20);
wireTabs();
wireCopy();
wireOrbs();
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#b7ff6a" stop-opacity="0.95" />
<stop offset="1" stop-color="#6afcff" stop-opacity="0.85" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="#0b0d10" />
<rect x="5" y="5" width="54" height="54" rx="12" fill="rgba(255,255,255,0.06)" stroke="rgba(255,255,255,0.10)" />
<path
d="M46 24c-2.1-6.5-8.1-11-15.2-11C21.9 13 15 19.9 15 28.8 15 37.8 21.9 45 30.8 45c2.1 0 4.2-.4 6.1-1.2 4.1 2.2 8.8 2.7 13.4 1.3-5.4-.9-8.4-3.3-9.7-6.7 3.7-2.9 6-7.4 6-12.1 0-.7-.1-1.4-.2-2.1H48l6-5-8.4.1z"
fill="url(#g)"
/>
</svg>

After

Width:  |  Height:  |  Size: 715 B

+233
View File
@@ -0,0 +1,233 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0b0d10" />
<title>bird.fast — fast X CLI</title>
<meta
name="description"
content="bird is a fast X CLI for tweeting, replying, and reading via the X/Twitter GraphQL API through twitter safe relay."
/>
<link rel="canonical" href="https://bird.fast/" />
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
<meta property="og:title" content="bird.fast" />
<meta property="og:description" content="Fast X CLI for tweeting, replying, and reading." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://bird.fast/" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],500..900&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap"
/>
<link rel="stylesheet" href="./assets/site.css" />
</head>
<body>
<a class="skip" href="#content">Skip to content</a>
<div class="bg" aria-hidden="true">
<div class="bg__orb bg__orb--a"></div>
<div class="bg__orb bg__orb--b"></div>
<div class="bg__grid"></div>
<div class="bg__noise"></div>
</div>
<header class="top">
<div class="top__inner">
<a class="brand" href="./" aria-label="bird.fast home">
<span class="brand__mark" aria-hidden="true">
<img src="./assets/mark.svg" width="26" height="26" alt="" />
</span>
<span class="brand__word">bird</span>
<span class="brand__dot">.fast</span>
</a>
<nav class="nav">
<a class="nav__link" href="#install">Install</a>
<a class="nav__link" href="#commands">Commands</a>
<a class="nav__link" href="https://git.yutakobayashi.com/yuta/bird">Gitea</a>
</nav>
</div>
</header>
<main id="content">
<section class="hero">
<div class="hero__inner">
<p class="kicker" data-reveal>Fast X CLI. Cookie auth. Zero fuss.</p>
<h1 class="title" data-reveal>
<span class="title__big">bird</span><span class="title__dot">.fast</span>
</h1>
<p class="lede" data-reveal>
Tweet, reply, read threads, search, mentions — powered by X/Twitter GraphQL and your existing
browser session.
</p>
<div class="cta" data-reveal>
<a class="btn btn--hot" href="#install">Install</a>
<a class="btn btn--ghost" href="https://git.yutakobayashi.com/yuta/bird#quickstart">Quickstart</a>
</div>
<div class="terminal" data-reveal>
<div class="terminal__bar">
<span class="terminal__dots" aria-hidden="true">
<i></i><i></i><i></i>
</span>
<span class="terminal__label">copy &amp; run</span>
</div>
<div class="terminal__body">
<pre><code id="installCmd">npm install -g @yuta/bird
bird whoami
bird tweet "hi from bird.fast"</code></pre>
<button class="copy" type="button" data-copy="#installCmd">
<span class="copy__idle">Copy</span>
<span class="copy__done" aria-hidden="true">Copied</span>
</button>
</div>
</div>
<div class="badges" data-reveal>
<a class="badge" href="https://www.npmjs.com/package/@yuta/bird">
<span class="badge__k">npm</span>
<span class="badge__v">@yuta/bird</span>
</a>
<a class="badge" href="https://git.yutakobayashi.com/yuta/bird">
<span class="badge__k">repo</span>
<span class="badge__v">yuta/bird</span>
</a>
<a class="badge" href="https://git.yutakobayashi.com/yuta/bird#authentication-graphql">
<span class="badge__k">auth</span>
<span class="badge__v">relay</span>
</a>
</div>
</div>
</section>
<section class="stripe" id="install">
<div class="stripe__inner">
<h2 class="h2">Install</h2>
<div class="cards">
<article class="card">
<h3 class="h3">npm / pnpm / bun</h3>
<p class="p">Pick your poison. Node ≥ 20.</p>
<pre class="code"><code>npm install -g @yuta/bird
pnpm add -g @yuta/bird
bun add -g @yuta/bird</code></pre>
</article>
<article class="card">
<h3 class="h3">One-shot</h3>
<p class="p">No install. Just prove it works.</p>
<pre class="code"><code>bunx @yuta/bird whoami</code></pre>
</article>
<article class="card">
<h3 class="h3">Homebrew (macOS)</h3>
<p class="p">Prebuilt Bun binary.</p>
<pre class="code"><code>brew install steipete/tap/bird</code></pre>
</article>
</div>
</div>
</section>
<section class="stripe stripe--tight" id="commands">
<div class="stripe__inner">
<h2 class="h2">Commands</h2>
<p class="p p--muted">Everything you need for a fast “read → reply → move on” loop.</p>
<div class="cmds" role="tablist" aria-label="bird commands">
<button class="cmd is-on" type="button" data-cmd="tweet" role="tab" aria-selected="true">
tweet
</button>
<button class="cmd" type="button" data-cmd="reply" role="tab" aria-selected="false">reply</button>
<button class="cmd" type="button" data-cmd="read" role="tab" aria-selected="false">read</button>
<button class="cmd" type="button" data-cmd="thread" role="tab" aria-selected="false">thread</button>
<button class="cmd" type="button" data-cmd="search" role="tab" aria-selected="false">search</button>
<button class="cmd" type="button" data-cmd="mentions" role="tab" aria-selected="false">
mentions
</button>
</div>
<div class="demo" aria-live="polite">
<pre class="demo__pre"><code id="demoCode">bird tweet "ship it"</code></pre>
<button class="copy copy--demo" type="button" data-copy="#demoCode">
<span class="copy__idle">Copy</span>
<span class="copy__done" aria-hidden="true">Copied</span>
</button>
</div>
</div>
</section>
<section class="stripe stripe--end">
<div class="stripe__inner">
<h2 class="h2">How it works</h2>
<div class="steps">
<article class="step">
<div class="step__num">01</div>
<h3 class="h3">Use your existing session</h3>
<p class="p p--muted">
Cookie auth. No password prompt. Reads from Safari/Chrome/Firefox (configurable).
</p>
</article>
<article class="step">
<div class="step__num">02</div>
<h3 class="h3">GraphQL first</h3>
<p class="p p--muted">
Talks to X’s internal endpoints. Query IDs rotate — bird auto-refreshes and caches them.
</p>
</article>
<article class="step">
<div class="step__num">03</div>
<h3 class="h3">Fast, scriptable output</h3>
<p class="p p--muted">
Human output by default. Flip to <span class="pill">--json</span> or
<span class="pill">--plain</span> when you need stability.
</p>
</article>
</div>
<aside class="notice notice--warn" role="note" aria-label="Disclaimer">
<div class="notice__icon" aria-hidden="true">!</div>
<div class="notice__body">
<p class="notice__title">Documented internal API.</p>
<p class="notice__text">
bird uses X/Twitter’s
<a href="https://github.com/fa0311/TwitterInternalAPIDocument">documented</a> internal web GraphQL
endpoints and rotating query IDs. X can change things anytime — expect breakage without notice.
</p>
</div>
</aside>
<div class="footerline">
<a class="tiny" href="https://git.yutakobayashi.com/yuta/bird#authentication-graphql">
Authentication details
</a>
<span class="sep" aria-hidden="true">•</span>
<a class="tiny" href="https://git.yutakobayashi.com/yuta/bird#config-json5">Config (JSON5)</a>
<span class="sep" aria-hidden="true">•</span>
<a class="tiny" href="https://git.yutakobayashi.com/yuta/bird/issues">Issues</a>
</div>
</div>
</section>
</main>
<footer class="bottom">
<div class="bottom__inner">
<p class="tiny">
MIT licensed.
</p>
<p class="tiny tiny--muted">bird.fast is the landing page; the project lives on Gitea.</p>
</div>
</footer>
<script src="./assets/site.js" type="module"></script>
</body>
</html>
+39 -21
View File
@@ -1,56 +1,74 @@
# Releasing bird 0.1.x
# Releasing bird
Target destinations:
- npm: `@steipete/bird`
- npm: `@yuta/bird`
- Gitea Packages: `https://git.yutakobayashi.com/api/packages/yuta/npm/`
- Nix flake: `packages.<system>.bird` and `apps.<system>.default`
- Homebrew: tap formula in `steipete/homebrew-tap` (e.g., `bird.rb`)
## Checklist (npm + GitHub)
## Checklist (npm + Gitea)
1) Version bump
- Update `package.json` `version` (semver, e.g., `0.1.0`).
- Update `package.json` `version` (semver, e.g., `0.5.0`).
- Update `CHANGELOG.md` and tag the release section.
2) Clean build & tests
- `pnpm install`
- `pnpm test`
- `pnpm run build`
- `nix build .#bird`
- `nix flake check`
3) Publish to npm (scoped)
3) Publish to npmjs (scoped)
- Ensure you are logged in (`npm whoami`).
- `npm publish --access public` (from repo root). Package name is `@steipete/bird`.
- `npm publish --access public` (from repo root). Package name is `@yuta/bird`.
- Verify:
- `npm view @yuta/bird version`
- `npx -y @yuta/bird@<version> --help`
4) Git tag & GitHub release
- `git tag v0.1.0 && git push origin v0.1.0`
- Create GitHub release from the tag. Include changelog notes and attach optional binary (see below).
4) Publish to Gitea Packages
- Create a personal access token with package write permission and save it as the repository secret `PACKAGE_TOKEN`.
- Push a `v<version>` tag or run the `Publish npm package` workflow manually.
- The workflow writes npm auth for `https://git.yutakobayashi.com/api/packages/yuta/npm/` and publishes `@yuta/bird`.
- Verify:
- `npm view @yuta/bird version --registry=https://git.yutakobayashi.com/api/packages/yuta/npm/`
- `npm exec --yes --registry=https://git.yutakobayashi.com/api/packages/yuta/npm/ @yuta/bird@<version> -- --help`
5) Git tag & Gitea release
- `git tag v<version> && git push origin v<version>`
- Create a Gitea release from the tag. Include changelog notes and attach optional binary (see below).
## Optional: attach compiled binary
If you want a single-file binary for Homebrew/GitHub assets:
If you want a single-file binary for Homebrew/Gitea release assets:
- Build: `pnpm run binary` (uses Bun to produce `./bird`).
- Upload `bird` to the GitHub release and use it for the Homebrew tarball.
- Upload `bird` to the Gitea release and use it for the Homebrew tarball.
## Homebrew tap update (steipete/homebrew-tap)
1) Package the binary
- From repo root: `tar -czf bird-macos-universal-v0.1.0.tar.gz bird`
- Compute SHA: `shasum -a 256 bird-macos-universal-v0.1.0.tar.gz`
- From repo root: `tar -czf bird-macos-universal-v<version>.tar.gz bird`
- Compute SHA: `shasum -a 256 bird-macos-universal-v<version>.tar.gz`
2) Update formula in tap repo
- File: `homebrew-tap/bird.rb` (create if absent). Model it after `poltergeist.rb`.
- Fields to update:
- `url "https://github.com/steipete/bird/releases/download/v0.1.0/bird-macos-universal-v0.1.0.tar.gz"`
- `url "https://git.yutakobayashi.com/yuta/bird/releases/download/v<version>/bird-macos-universal-v<version>.tar.gz"`
- `sha256 "<calculated_sha>"`
- `version "0.1.0"`
- `version "<version>"`
- Install block: `bin.install "bird"`
- `test do`: minimal `assert_match "0.1.0", shell_output("#{bin}/bird --version")`
- `test do`: minimal `assert_match "<version>", shell_output("#{bin}/bird --version")`
3) Push tap changes
- `git add bird.rb && git commit -m "bird 0.1.0" && git push`
## Release order suggestion
1) Merge to `main` and tag.
2) Publish npm.
3) Build binary, upload to GitHub release.
4) Update Homebrew tap with new URL/SHA.
2) Publish npmjs.
3) Let Gitea Actions publish the package to Gitea Packages.
4) Build binary, upload to Gitea release.
5) Update Homebrew tap with new URL/SHA.
## Notes
- Scoped npm name (`@steipete/bird`) requires `--access public` on first publish.
- Scoped npm name (`@yuta/bird`) requires `--access public` on first publish.
- Gitea npm registry auth follows Gitea's package registry format:
`npm config set @yuta:registry=https://git.yutakobayashi.com/api/packages/yuta/npm/`.
- Homebrew formula assumes macOS universal binary; adjust URL/name if you ship per-arch.
- Config defaults (JSON5) and Firefox/Chrome cookie selection are documented in `README.md` — keep that in sync for each release.
- Config defaults (JSON5) and twitter safe relay setup are documented in `README.md` — keep that in sync for each release.
+31
View File
@@ -0,0 +1,31 @@
# Testing
## Unit tests (default)
- `pnpm test`
## Live tests (hits Twitter/X)
Runs the CLI against real Twitter/X GraphQL endpoints to verify read-only commands still work.
Requirements:
- `TWITTER_RELAY_BASE_URL` pointing at a running twitter safe relay
- Network access
Run:
- `pnpm test:live`
- `pnpm bird following --all --max-pages 2 --json`
- `pnpm bird list-timeline <list-id> --all --max-pages 2 --json`
- `pnpm bird search "from:steipete" --all --max-pages 2 --json`
- `pnpm bird home --count 5 --json`
- `pnpm bird home --count 5 --following --json`
Notes:
- Live tests are skipped unless `BIRD_LIVE=1` (set by `pnpm test:live`).
- Search query is configurable via `BIRD_LIVE_SEARCH_QUERY`.
- Command timeout is configurable via `BIRD_LIVE_TIMEOUT_MS` (ms).
- Spawned CLI `NODE_ENV` defaults to `production` (override with `BIRD_LIVE_NODE_ENV`).
- If you don't tweet, set `BIRD_LIVE_TWEET_ID` to a known tweet ID to use for `read/replies/thread`.
- Long-form article coverage: set `BIRD_LIVE_LONGFORM_TWEET_ID` to a known article tweet ID (example: `2007184284944322584` from @X; refresh by finding a tweet with `article` via `bird user-tweets X -n 20 --json`).
- Optional: set `BIRD_LIVE_BOOKMARK_FOLDER_ID` to exercise `bookmarks --folder-id`.
- `bird query-ids --fresh` live coverage: set `BIRD_LIVE_QUERY_IDS_FRESH=1`.
- The live suite may hit internal X endpoints (v1.1 REST) as fallback through the relay.
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1781577229,
"narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "567a49d1913ce81ac6e9582e3553dd90a955875f",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+148
View File
@@ -0,0 +1,148 @@
{
description = "bird - fast X CLI for tweeting, replying, and reading";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs =
{ self, ... }@inputs:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"aarch64-darwin"
];
forEachSupportedSystem =
f:
inputs.nixpkgs.lib.genAttrs supportedSystems (
system:
f {
inherit system;
pkgs = import inputs.nixpkgs {
inherit system;
overlays = [ inputs.self.overlays.default ];
};
}
);
in
{
overlays.default = final: prev: rec {
bird = final.callPackage (
{
fetchPnpmDeps,
lib,
makeWrapper,
nodejs-slim_22,
pnpm_10,
pnpmConfigHook,
stdenv,
}:
let
packageJson = builtins.fromJSON (builtins.readFile ./package.json);
pnpm = pnpm_10.override { nodejs-slim = nodejs-slim_22; };
in
stdenv.mkDerivation (finalAttrs: {
pname = "bird";
version = packageJson.version;
src = lib.cleanSource ./.;
pnpmDeps = fetchPnpmDeps {
inherit (finalAttrs) pname version src;
inherit pnpm;
fetcherVersion = 4;
pnpmInstallFlags = [ "--config.auto-install-peers=true" ];
hash = "sha256-nB6IPSG/2XSVJVNHwtDFVL3ocwVWEzmR7o3o3rNSKP0=";
};
nativeBuildInputs = [
makeWrapper
nodejs-slim_22
pnpm
pnpmConfigHook
];
pnpmInstallFlags = [ "--config.auto-install-peers=true" ];
buildPhase = ''
runHook preBuild
./node_modules/.bin/tsc
node scripts/copy-dist-assets.js
CI=true pnpm prune --prod --config.auto-install-peers=true
find node_modules -xtype l -delete
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/lib/bird $out/bin
cp -r dist node_modules package.json README.md CHANGELOG.md LICENSE $out/lib/bird/
chmod +x $out/lib/bird/dist/cli.js
makeWrapper ${lib.getExe nodejs-slim_22} $out/bin/bird \
--add-flags "$out/lib/bird/dist/cli.js"
runHook postInstall
'';
meta = {
description = packageJson.description;
homepage = "https://bird.fast/";
license = lib.licenses.mit;
mainProgram = "bird";
};
})
) { };
nodejs = prev.nodejs_22;
yarn = (prev.yarn.override { inherit nodejs; });
};
packages = forEachSupportedSystem (
{ pkgs, ... }:
{
default = pkgs.bird;
bird = pkgs.bird;
}
);
apps = forEachSupportedSystem (
{ system, ... }:
{
default = {
type = "app";
program = "${self.packages.${system}.default}/bin/bird";
meta.description = "Run bird";
};
}
);
checks = forEachSupportedSystem (
{ pkgs, ... }:
{
default = pkgs.bird;
}
);
devShells = forEachSupportedSystem (
{ pkgs, system }:
{
default = pkgs.mkShellNoCC {
packages = with pkgs; [
nodejs
pnpm
yarn
bun
self.formatter.${system}
];
};
}
);
formatter = forEachSupportedSystem ({ pkgs, ... }: pkgs.nixfmt);
};
}
+21 -9
View File
@@ -1,11 +1,16 @@
{
"name": "@steipete/bird",
"version": "0.2.0",
"name": "@yuta/bird",
"version": "0.7.0",
"description": "CLI tool for tweeting and replying via Twitter/X GraphQL API",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"bird": "dist/index.js"
"bird": "dist/cli.js"
},
"repository": {
"type": "git",
"url": "https://git.yutakobayashi.com/yuta/bird.git"
},
"files": [
"dist",
@@ -16,20 +21,25 @@
"scripts": {
"build": "pnpm run build:dist && pnpm run build:binary",
"build:dist": "tsc && node scripts/copy-dist-assets.js",
"build:binary": "BIRD_VERSION=$(node -p \"require('./package.json').version\") BIRD_GIT_SHA=$(git rev-parse --short=8 HEAD 2>/dev/null || true) bun build --compile --minify --env=BIRD_* src/index.ts --outfile bird",
"build:binary": "BIRD_VERSION=$(node -p \"require('./package.json').version\") BIRD_GIT_SHA=$(git rev-parse --short=8 HEAD 2>/dev/null || true) bun build --compile --minify --env=BIRD_* src/cli.ts --outfile bird",
"dev": "tsx src/index.ts",
"bird": "pnpm run build:dist && node dist/index.js",
"bird": "pnpm run build:dist && node dist/cli.js",
"test": "vitest run",
"test:watch": "vitest",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"test:live": "pnpm run build:dist && BIRD_LIVE=1 vitest run --no-file-parallelism tests/live/live.test.ts",
"test:live:all": "pnpm run build:dist && BIRD_LIVE=1 vitest run --no-file-parallelism tests/live/live-all.test.ts",
"lint": "pnpm run lint:biome && pnpm run lint:oxlint",
"lint:biome": "biome check .",
"lint:oxlint": "oxlint --type-aware --tsconfig tsconfig.oxlint.json --import-plugin --node-plugin --vitest-plugin --deny-warnings src tests scripts",
"lint:fix": "pnpm run lint:biome:fix && pnpm run lint:oxlint:fix",
"lint:biome:fix": "biome check --write .",
"lint:oxlint:fix": "oxlint --type-aware --tsconfig tsconfig.oxlint.json --import-plugin --node-plugin --vitest-plugin --deny-warnings --fix src tests scripts",
"format": "biome format --write .",
"binary": "pnpm run build:binary",
"graphql:update": "tsx scripts/update-query-ids.ts"
},
"dependencies": {
"commander": "^14.0.2",
"devalue": "^5.6.1",
"json5": "^2.2.3",
"kleur": "^4.1.5"
},
@@ -37,11 +47,13 @@
"@biomejs/biome": "^2.3.10",
"@types/node": "^25.0.3",
"@vitest/coverage-v8": "4.0.16",
"oxlint": "^1.36.0",
"oxlint-tsgolint": "^0.10.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.16"
},
"engines": {
"node": ">=20"
"node": ">=22"
}
}
+172 -8
View File
@@ -11,9 +11,6 @@ importers:
commander:
specifier: ^14.0.2
version: 14.0.2
devalue:
specifier: ^5.6.1
version: 5.6.1
json5:
specifier: ^2.2.3
version: 2.2.3
@@ -30,6 +27,12 @@ importers:
'@vitest/coverage-v8':
specifier: 4.0.16
version: 4.0.16([email protected](@types/[email protected])([email protected]))
oxlint:
specifier: ^1.36.0
version: 1.36.0([email protected])
oxlint-tsgolint:
specifier: ^0.10.0
version: 0.10.0
tsx:
specifier: ^4.21.0
version: 4.21.0
@@ -85,24 +88,28 @@ packages:
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@biomejs/[email protected]':
resolution: {integrity: sha512-hhPw2V3/EpHKsileVOFynuWiKRgFEV48cLe0eA+G2wO4SzlwEhLEB9LhlSrVeu2mtSn205W283LkX7Fh48CaxA==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@biomejs/[email protected]':
resolution: {integrity: sha512-QTfHZQh62SDFdYc2nfmZFuTm5yYb4eO1zwfB+90YxUumRCR171tS1GoTX5OD0wrv4UsziMPmrePMtkTnNyYG3g==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [musl]
'@biomejs/[email protected]':
resolution: {integrity: sha512-wwAkWD1MR95u+J4LkWP74/vGz+tRrIQvr8kfMMJY8KOQ8+HMVleREOcPYsQX82S7uueco60L58Wc6M1I9WA9Dw==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@biomejs/[email protected]':
resolution: {integrity: sha512-o7lYc9n+CfRbHvkjPhm8s9FgbKdYZu5HCcGVMItLjz93EhgJ8AM44W+QckDqLA9MKDNFrR8nPbO4b73VC5kGGQ==}
@@ -282,6 +289,80 @@ packages:
'@jridgewell/[email protected]':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-mhBF/pjey0UdLL1ocU46Fqta+uJuRfqrLfDpcViRg17BtDiUNd8JY9iN2FOoS2HGSCAgCUjZ0AZkwkHwFs/VTw==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-roLi34mw/i1z+NS7luboix55SXyhVv38dNUTcRDkk+0lNPzI9ngrM+1y1N2oBSUmz5o9OZGnfJJ7BSGCw/fFEQ==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-HL9NThPH1V2F6l9XhwNmhQZUknN4m4yQYEvQFFGfZTYN6cvEEBIiqfF4KvBUg8c0xadMbQlW+Ug7/ybA9Nn+CA==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-Tw8QNq8ab+4+qE5krvJyMA66v6XE3GoiISRD5WmJ7YOxUnu//jSw/bBm7OYf/TNEZyeV0BTR7zXzhT5R+VFWlQ==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-LTogmTRwpwQqVaH1Ama8Wd5/VVZWBSF8v5qTbeT628+1F5Kt1V5eHBvyFh4oN18UCZlgqrh7DqkDhsieXUaC8Q==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-ygqxx8EmNWy9/wCQS5uXq9k/o2EyYNwNxY1ZHNzlmZC/kV06Aemx5OBDafefawBNqH7xTZPfccUrjdiy+QlTrw==}
cpu: [x64]
os: [win32]
'@oxlint/[email protected]':
resolution: {integrity: sha512-MJkj82GH+nhvWKJhSIM6KlZ8tyGKdogSQXtNdpIyP02r/tTayFJQaAEWayG2Jhsn93kske+nimg5MYFhwO/rlg==}
cpu: [arm64]
os: [darwin]
'@oxlint/[email protected]':
resolution: {integrity: sha512-VvEhfkqj/99dCTqOcfkyFXOSbx4lIy5u2m2GHbK4WCMDySokOcMTNRHGw8fH/WgQ5cDrDMSTYIGQTmnBGi9tiQ==}
cpu: [x64]
os: [darwin]
'@oxlint/[email protected]':
resolution: {integrity: sha512-EMx92X5q+hHc3olTuj/kgkx9+yP0p/AVs4yvHbUfzZhBekXNpUWxWvg4hIKmQWn+Ee2j4o80/0ACGO0hDYJ9mg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/[email protected]':
resolution: {integrity: sha512-7YCxtrPIctVYLqWrWkk8pahdCxch6PtsaucfMLC7TOlDt4nODhnQd4yzEscKqJ8Gjrw1bF4g+Ngob1gB+Qr9Fw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/[email protected]':
resolution: {integrity: sha512-lnaJVlx5r3NWmoOMesfQXJSf78jHTn8Z+sdAf795Kgteo72+qGC1Uax2SToCJVN2J8PNG3oRV5bLriiCNR2i6Q==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/[email protected]':
resolution: {integrity: sha512-AhuEU2Qdl66lSfTGu/Htirq8r/8q2YnZoG3yEXLMQWnPMn7efy8spD/N1NA7kH0Hll+cdfwgQkQqC2G4MS2lPQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/[email protected]':
resolution: {integrity: sha512-GlWCBjUJY2QgvBFuNRkiRJu7K/djLmM0UQKfZV8IN+UXbP/JbjZHWKRdd4LXlQmzoz7M5Hd6p+ElCej8/90FCg==}
cpu: [arm64]
os: [win32]
'@oxlint/[email protected]':
resolution: {integrity: sha512-J+Vc00Utcf8p77lZPruQgb0QnQXuKnFogN88kCnOqs2a83I+vTBB8ILr0+L9sTwVRvIDMSC0pLdeQH4svWGFZg==}
cpu: [x64]
os: [win32]
'@rollup/[email protected]':
resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==}
cpu: [arm]
@@ -316,56 +397,67 @@ packages:
resolution: {integrity: sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==}
@@ -469,9 +561,6 @@ packages:
supports-color:
optional: true
[email protected]:
resolution: {integrity: sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==}
[email protected]:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
@@ -560,6 +649,20 @@ packages:
[email protected]:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
[email protected]:
resolution: {integrity: sha512-LDDSIu5J/4D4gFUuQQIEQpAC6maNEbMg4nC8JL/+Pe0cUDR86dtVZ09E2x5MwCh8f9yfktoaxt5x6UIVyzrajg==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-IicUdXfXgI8OKrDPnoSjvBfeEF8PkKtm+CoLlg4LYe4ypc8U+T4r7730XYshdBGZdelg+JRw8GtCb2w/KaaZvw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.10.0'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
[email protected]:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -850,6 +953,48 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
@@ -1003,8 +1148,6 @@ snapshots:
dependencies:
ms: 2.1.3
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -1104,6 +1247,27 @@ snapshots:
[email protected]: {}
[email protected]:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.10.0
'@oxlint-tsgolint/darwin-x64': 0.10.0
'@oxlint-tsgolint/linux-arm64': 0.10.0
'@oxlint-tsgolint/linux-x64': 0.10.0
'@oxlint-tsgolint/win32-arm64': 0.10.0
'@oxlint-tsgolint/win32-x64': 0.10.0
[email protected]([email protected]):
optionalDependencies:
'@oxlint/darwin-arm64': 1.36.0
'@oxlint/darwin-x64': 1.36.0
'@oxlint/linux-arm64-gnu': 1.36.0
'@oxlint/linux-arm64-musl': 1.36.0
'@oxlint/linux-x64-gnu': 1.36.0
'@oxlint/linux-x64-musl': 1.36.0
'@oxlint/win32-arm64': 1.36.0
'@oxlint/win32-x64': 1.36.0
oxlint-tsgolint: 0.10.0
[email protected]: {}
[email protected]: {}
+2
View File
@@ -1,2 +1,4 @@
allowBuilds:
esbuild: true
onlyBuiltDependencies:
- esbuild
+4 -1
View File
@@ -7,10 +7,13 @@ const files = [
from: resolve(root, 'src/lib/query-ids.json'),
to: resolve(root, 'dist/lib/query-ids.json'),
},
{
from: resolve(root, 'src/lib/features.json'),
to: resolve(root, 'dist/lib/features.json'),
},
];
for (const file of files) {
mkdirSync(dirname(file.to), { recursive: true });
writeFileSync(file.to, readFileSync(file.from));
}
+11 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env tsx
/**
* Fetches current Twitter/X GraphQL query IDs from public client bundles and
* updates src/lib/query-ids.json. Inspired by Sweetistics' extractor.
* updates src/lib/query-ids.json.
*/
import fs from 'node:fs/promises';
@@ -11,8 +11,18 @@ const TARGET_OPERATIONS = [
'CreateTweet',
'CreateRetweet',
'FavoriteTweet',
'DeleteBookmark',
'TweetDetail',
'SearchTimeline',
'Bookmarks',
'BookmarkFolderTimeline',
'Following',
'Followers',
'Likes',
'ExploreSidebar',
'ExplorePage',
'GenericTimelineById',
'TrendHistory',
] as const;
type OperationName = (typeof TARGET_OPERATIONS)[number];
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env node
/**
* bird - CLI tool for posting tweets and replies
*
* Usage:
* bird tweet "Hello world!"
* bird reply <tweet-id> "This is a reply"
* bird reply <tweet-url> "This is a reply"
* bird read <tweet-id-or-url>
*/
import { createProgram, KNOWN_COMMANDS } from './cli/program.js';
import { createCliContext } from './cli/shared.js';
import { resolveCliInvocation } from './lib/cli-args.js';
const rawArgs: string[] = process.argv.slice(2);
const normalizedArgs: string[] = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs;
const ctx = createCliContext(normalizedArgs);
const program = createProgram(ctx);
const { argv, showHelp } = resolveCliInvocation(normalizedArgs, KNOWN_COMMANDS);
if (showHelp) {
program.outputHelp();
process.exit(0);
}
if (argv) {
program.parse(argv);
} else {
program.parse(['node', 'bird', ...normalizedArgs]);
}
+79
View File
@@ -0,0 +1,79 @@
export type PaginationCmdOpts = {
all?: boolean;
maxPages?: string;
cursor?: string;
delay?: string;
};
export function parsePositiveIntFlag(
raw: string | undefined,
flagName: string,
): { ok: true; value: number | undefined } | { ok: false; error: string } {
if (raw === undefined) {
return { ok: true, value: undefined };
}
const value = Number.parseInt(raw, 10);
if (!Number.isFinite(value) || value <= 0) {
return { ok: false, error: `Invalid ${flagName}. Expected a positive integer.` };
}
return { ok: true, value };
}
export function parseNonNegativeIntFlag(
raw: string | undefined,
flagName: string,
defaultValue: number,
): { ok: true; value: number } | { ok: false; error: string } {
const value = Number.parseInt(raw ?? String(defaultValue), 10);
if (!Number.isFinite(value) || value < 0) {
return { ok: false, error: `Invalid ${flagName}. Expected a non-negative integer.` };
}
return { ok: true, value };
}
export function parsePaginationFlags(
cmdOpts: PaginationCmdOpts,
opts?: {
maxPagesImpliesPagination?: boolean;
defaultDelayMs?: number;
includeDelay?: boolean;
},
):
| {
ok: true;
usePagination: boolean;
maxPages?: number;
cursor?: string;
pageDelayMs?: number;
}
| { ok: false; error: string } {
const maxPagesImpliesPagination = opts?.maxPagesImpliesPagination ?? false;
const includeDelay = opts?.includeDelay ?? false;
const defaultDelayMs = opts?.defaultDelayMs ?? 1000;
const maxPages = parsePositiveIntFlag(cmdOpts.maxPages, '--max-pages');
if (!maxPages.ok) {
return maxPages;
}
const usePagination = Boolean(
cmdOpts.all || cmdOpts.cursor || (maxPagesImpliesPagination && maxPages.value !== undefined),
);
let pageDelayMs: number | undefined;
if (includeDelay) {
const delay = parseNonNegativeIntFlag(cmdOpts.delay, '--delay', defaultDelayMs);
if (!delay.ok) {
return delay;
}
pageDelayMs = delay.value;
}
return {
ok: true,
usePagination,
maxPages: maxPages.value,
cursor: cmdOpts.cursor,
pageDelayMs,
};
}
+143
View File
@@ -0,0 +1,143 @@
import { Command } from 'commander';
import { registerBookmarksCommand } from '../commands/bookmarks.js';
import { registerCheckCommand } from '../commands/check.js';
import { registerHelpCommand } from '../commands/help.js';
import { registerHomeCommand } from '../commands/home.js';
import { registerListsCommand } from '../commands/lists.js';
import { registerNewsCommand } from '../commands/news.js';
import { registerPostCommands } from '../commands/post.js';
import { registerQueryIdsCommand } from '../commands/query-ids.js';
import { registerReadCommands } from '../commands/read.js';
import { registerSearchCommands } from '../commands/search.js';
import { registerUnbookmarkCommand } from '../commands/unbookmark.js';
import { registerUserTweetsCommand } from '../commands/user-tweets.js';
import { registerUserCommands } from '../commands/users.js';
import { getCliVersion } from '../lib/version.js';
import type { CliContext } from './shared.js';
export const KNOWN_COMMANDS = new Set([
'tweet',
'reply',
'query-ids',
'read',
'replies',
'thread',
'search',
'mentions',
'bookmarks',
'unbookmark',
'following',
'followers',
'likes',
'lists',
'list-timeline',
'home',
'user-tweets',
'news',
'trending',
'help',
'whoami',
'check',
]);
export function createProgram(ctx: CliContext): Command {
const program: Command = new Command();
program.configureHelp({
showGlobalOptions: true,
styleTitle: (t) => ctx.colors.section(t),
styleUsage: (t) => ctx.colors.description(t),
styleCommandText: (t) => ctx.colors.command(t),
styleCommandDescription: (t) => ctx.colors.muted(t),
styleOptionTerm: (t) => ctx.colors.option(t),
styleOptionText: (t) => ctx.colors.option(t),
styleOptionDescription: (t) => ctx.colors.muted(t),
styleArgumentTerm: (t) => ctx.colors.argument(t),
styleArgumentText: (t) => ctx.colors.argument(t),
styleArgumentDescription: (t) => ctx.colors.muted(t),
styleSubcommandTerm: (t) => ctx.colors.command(t),
styleSubcommandText: (t) => ctx.colors.command(t),
styleSubcommandDescription: (t) => ctx.colors.muted(t),
styleDescriptionText: (t) => ctx.colors.muted(t),
});
const collect = (value: string, previous: string[] = []): string[] => {
previous.push(value);
return previous;
};
program.addHelpText(
'beforeAll',
() =>
`${ctx.colors.banner('bird')} ${ctx.colors.muted(getCliVersion())} ${ctx.colors.subtitle(
'— fast X CLI for tweeting, replying, and reading',
)}`,
);
program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion());
const formatExample = (command: string, description: string): string =>
`${ctx.colors.command(` ${command}`)}\n${ctx.colors.muted(` ${description}`)}`;
program.addHelpText(
'afterAll',
() =>
`\n${ctx.colors.section('Examples')}\n${[
formatExample('bird whoami', 'Show the relay account'),
formatExample('bird --relay-base-url http://localhost:6900 whoami', 'Use a specific twitter safe relay'),
formatExample('bird tweet "hello from bird"', 'Send a tweet'),
formatExample(
'bird 1234567890123456789 --json',
'Read a tweet (ID or URL shorthand for `read`) and print JSON',
),
].join('\n\n')}\n\n${ctx.colors.section('Shortcuts')}\n${[
formatExample('bird <tweet-id-or-url> [--json]', 'Shorthand for `bird read <tweet-id-or-url>`'),
].join('\n\n')}\n\n${ctx.colors.section('JSON Output')}\n${ctx.colors.muted(
` Add ${ctx.colors.option('--json')} to: read, replies, thread, search, mentions, bookmarks, likes, following, followers, lists, list-timeline, user-tweets, query-ids`,
)}\n${ctx.colors.muted(
` Add ${ctx.colors.option('--json-full')} to include raw API response in ${ctx.colors.argument('_raw')} field (tweet commands only)`,
)}\n${ctx.colors.muted(` (Run ${ctx.colors.command('bird <command> --help')} to see per-command flags.)`)}`,
);
program.addHelpText(
'afterAll',
() =>
`\n\n${ctx.colors.section('Config')}\n${ctx.colors.muted(
` Reads ${ctx.colors.argument('~/.config/bird/config.json5')} and ${ctx.colors.argument('./.birdrc.json5')} (JSON5)`,
)}\n${ctx.colors.muted(
` Supports: relayBaseUrl, timeoutMs, quoteDepth`,
)}\n\n${ctx.colors.section('Env')}\n${ctx.colors.muted(
` ${ctx.colors.option('TWITTER_RELAY_BASE_URL')}, ${ctx.colors.option('NO_COLOR')}, ${ctx.colors.option('BIRD_TIMEOUT_MS')}, ${ctx.colors.option('BIRD_QUOTE_DEPTH')}`,
)}`,
);
program
.option('--relay-base-url <url>', 'twitter safe relay base URL', ctx.config.relayBaseUrl)
.option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect)
.option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect)
.option('--timeout <ms>', 'Request timeout in milliseconds')
.option('--quote-depth <depth>', 'Max quoted tweet depth (default: 1; 0 disables)')
.option('--plain', 'Plain output (stable, no emoji, no color)')
.option('--no-emoji', 'Disable emoji output')
.option('--no-color', 'Disable ANSI colors (or set NO_COLOR)');
program.hook('preAction', (_thisCommand, actionCommand) => {
ctx.applyOutputFromCommand(actionCommand);
});
registerHelpCommand(program, ctx);
registerQueryIdsCommand(program, ctx);
registerPostCommands(program, ctx);
registerReadCommands(program, ctx);
registerSearchCommands(program, ctx);
registerBookmarksCommand(program, ctx);
registerUnbookmarkCommand(program, ctx);
registerListsCommand(program, ctx);
registerHomeCommand(program, ctx);
registerUserCommands(program, ctx);
registerUserTweetsCommand(program, ctx);
registerNewsCommand(program, ctx);
registerCheckCommand(program, ctx);
return program;
}
+376
View File
@@ -0,0 +1,376 @@
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { Command } from 'commander';
import JSON5 from 'json5';
import kleur from 'kleur';
import { extractTweetId } from '../lib/extract-tweet-id.js';
import {
hyperlink,
labelPrefix,
type OutputConfig,
resolveOutputConfigFromArgv,
resolveOutputConfigFromCommander,
statusPrefix,
} from '../lib/output.js';
import type { TweetData } from '../lib/twitter-client.js';
export type BirdConfig = {
relayBaseUrl?: string;
timeoutMs?: number;
quoteDepth?: number;
};
export type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer };
export type CliContext = {
isTty: boolean;
getOutput: () => OutputConfig;
colors: {
banner: (t: string) => string;
subtitle: (t: string) => string;
section: (t: string) => string;
bullet: (t: string) => string;
command: (t: string) => string;
option: (t: string) => string;
argument: (t: string) => string;
description: (t: string) => string;
muted: (t: string) => string;
accent: (t: string) => string;
};
p: (kind: Parameters<typeof statusPrefix>[0]) => string;
l: (kind: Parameters<typeof labelPrefix>[0]) => string;
config: BirdConfig;
applyOutputFromCommand: (command: Command) => void;
resolveTimeoutFromOptions: (options: { timeout?: string | number }) => number | undefined;
resolveQuoteDepthFromOptions: (options: { quoteDepth?: string | number }) => number | undefined;
resolveRelayBaseUrlFromOptions: (opts: RelayOptions) => string | undefined;
loadMedia: (opts: { media: string[]; alts: string[] }) => MediaSpec[];
printTweets: (tweets: TweetData[], opts?: { json?: boolean; emptyMessage?: string; showSeparator?: boolean }) => void;
printTweetsResult: (
result: {
tweets?: TweetData[];
nextCursor?: string;
},
opts: {
json: boolean;
usePagination: boolean;
emptyMessage: string;
},
) => void;
extractTweetId: (tweetIdOrUrl: string) => string;
};
function resolveTimeoutMs(...values: Array<string | number | undefined | null>): number | undefined {
for (const value of values) {
if (value === undefined || value === null || value === '') {
continue;
}
const parsed = typeof value === 'number' ? value : Number(value);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return undefined;
}
function resolveQuoteDepth(...values: Array<string | number | undefined | null>): number | undefined {
for (const value of values) {
if (value === undefined || value === null || value === '') {
continue;
}
const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10);
if (Number.isFinite(parsed) && parsed >= 0) {
return Math.floor(parsed);
}
}
return undefined;
}
function detectMime(path: string): string | null {
const ext = path.toLowerCase();
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) {
return 'image/jpeg';
}
if (ext.endsWith('.png')) {
return 'image/png';
}
if (ext.endsWith('.webp')) {
return 'image/webp';
}
if (ext.endsWith('.gif')) {
return 'image/gif';
}
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) {
return 'video/mp4';
}
if (ext.endsWith('.mov')) {
return 'video/quicktime';
}
return null;
}
function readConfigFile(path: string, warn: (message: string) => void): Partial<BirdConfig> {
if (!existsSync(path)) {
return {};
}
try {
const raw = readFileSync(path, 'utf8');
const parsed = JSON5.parse(raw) as Partial<BirdConfig>;
return parsed ?? {};
} catch (error) {
warn(`Failed to parse config at ${path}: ${error instanceof Error ? error.message : String(error)}`);
return {};
}
}
function loadConfig(warn: (message: string) => void): BirdConfig {
const globalPath = join(homedir(), '.config', 'bird', 'config.json5');
const localPath = join(process.cwd(), '.birdrc.json5');
return {
...readConfigFile(globalPath, warn),
...readConfigFile(localPath, warn),
};
}
type RelayOptions = {
relayBaseUrl?: string;
};
export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEnv = process.env): CliContext {
const isTty = process.stdout.isTTY;
let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, env, isTty);
kleur.enabled = output.color;
const wrap =
(styler: (text: string) => string): ((text: string) => string) =>
(text: string): string =>
isTty ? styler(text) : text;
const colors = {
banner: wrap((t) => kleur.bold().blue(t)),
subtitle: wrap((t) => kleur.dim(t)),
section: wrap((t) => kleur.bold().white(t)),
bullet: wrap((t) => kleur.blue(t)),
command: wrap((t) => kleur.bold().cyan(t)),
option: wrap((t) => kleur.cyan(t)),
argument: wrap((t) => kleur.magenta(t)),
description: wrap((t) => kleur.white(t)),
muted: wrap((t) => kleur.gray(t)),
accent: wrap((t) => kleur.green(t)),
};
const p = (kind: Parameters<typeof statusPrefix>[0]): string => {
const prefix = statusPrefix(kind, output);
if (output.plain || !output.color) {
return prefix;
}
if (kind === 'ok') {
return kleur.green(prefix);
}
if (kind === 'warn') {
return kleur.yellow(prefix);
}
if (kind === 'err') {
return kleur.red(prefix);
}
if (kind === 'info') {
return kleur.cyan(prefix);
}
return kleur.gray(prefix);
};
const l = (kind: Parameters<typeof labelPrefix>[0]): string => {
const prefix = labelPrefix(kind, output);
if (output.plain || !output.color) {
return prefix;
}
if (kind === 'url') {
return kleur.cyan(prefix);
}
if (kind === 'date') {
return kleur.magenta(prefix);
}
if (kind === 'source') {
return kleur.gray(prefix);
}
if (kind === 'engine') {
return kleur.blue(prefix);
}
if (kind === 'credentials') {
return kleur.yellow(prefix);
}
if (kind === 'user') {
return kleur.cyan(prefix);
}
if (kind === 'userId') {
return kleur.magenta(prefix);
}
if (kind === 'email') {
return kleur.green(prefix);
}
return kleur.gray(prefix);
};
const config = loadConfig((message) => {
console.error(colors.muted(`${p('warn')}${message}`));
});
function applyOutputFromCommand(command: Command): void {
const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean };
output = resolveOutputConfigFromCommander(opts, env, isTty);
kleur.enabled = output.color;
}
function resolveTimeoutFromOptions(options: { timeout?: string | number }): number | undefined {
return resolveTimeoutMs(options.timeout, config.timeoutMs, env.BIRD_TIMEOUT_MS);
}
function resolveQuoteDepthFromOptions(options: { quoteDepth?: string | number }): number | undefined {
return resolveQuoteDepth(options.quoteDepth, config.quoteDepth, env.BIRD_QUOTE_DEPTH);
}
function resolveRelayBaseUrlFromOptions(opts: RelayOptions): string | undefined {
return opts.relayBaseUrl || config.relayBaseUrl || env.TWITTER_RELAY_BASE_URL;
}
function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] {
if (opts.media.length === 0) {
return [];
}
const specs: MediaSpec[] = [];
for (const [index, path] of opts.media.entries()) {
const mime = detectMime(path);
if (!mime) {
throw new Error(`Unsupported media type for ${path}. Supported: jpg, jpeg, png, webp, gif, mp4, mov`);
}
const buffer = readFileSync(path);
specs.push({ path, mime, buffer, alt: opts.alts[index] });
}
const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length;
if (videoCount > 1) {
throw new Error('Only one video can be attached');
}
if (videoCount === 1 && specs.length > 1) {
throw new Error('Video cannot be combined with other media');
}
if (specs.length > 4) {
throw new Error('Maximum 4 media attachments');
}
return specs;
}
function printTweets(
tweets: TweetData[],
opts: { json?: boolean; emptyMessage?: string; showSeparator?: boolean } = {},
) {
if (opts.json) {
console.log(JSON.stringify(tweets, null, 2));
return;
}
if (tweets.length === 0) {
console.log(opts.emptyMessage ?? 'No tweets found.');
return;
}
const useEmoji = output.emoji && !output.plain;
const articleLabel = useEmoji ? '📰' : 'Article:';
const mediaLabel = (type: 'photo' | 'video' | 'animated_gif'): string => {
if (useEmoji) {
return type === 'video' ? '🎬' : type === 'animated_gif' ? '🔄' : '🖼️';
}
return type === 'video' ? 'VIDEO:' : type === 'animated_gif' ? 'GIF:' : 'PHOTO:';
};
const quotePrefix = useEmoji ? { top: '┌─', mid: '│ ', bot: '└─' } : { top: '> ', mid: '> ', bot: '> ' };
for (const tweet of tweets) {
console.log(`\n@${tweet.author.username} (${tweet.author.name}):`);
// Display tweet text, with article indicator if present
if (tweet.article) {
// Full body mode: text starts with article title (from extractArticleText)
// Preview mode: text is short tweet intro that doesn't start with title
const hasFullBody = tweet.text.startsWith(tweet.article.title);
if (hasFullBody) {
console.log(`${articleLabel} ${tweet.text}`);
} else {
console.log(`${articleLabel} ${tweet.article.title}`);
if (tweet.article.previewText) {
console.log(` ${tweet.article.previewText}`);
}
}
} else {
console.log(tweet.text);
}
// Display media attachments
if (tweet.media && tweet.media.length > 0) {
for (const m of tweet.media) {
console.log(`${mediaLabel(m.type)} ${m.url}`);
}
}
// Display quoted tweet
if (tweet.quotedTweet) {
console.log(`${quotePrefix.top} QT @${tweet.quotedTweet.author.username}:`);
const qtText = tweet.quotedTweet.article
? `${articleLabel} ${tweet.quotedTweet.article.title}`
: tweet.quotedTweet.text;
// Indent and truncate quoted tweet text
const maxLen = 280;
const truncated = qtText.length > maxLen ? `${qtText.slice(0, maxLen)}...` : qtText;
for (const line of truncated.split('\n').slice(0, 4)) {
console.log(`${quotePrefix.mid}${line}`);
}
// Display quoted tweet media
if (tweet.quotedTweet.media && tweet.quotedTweet.media.length > 0) {
for (const m of tweet.quotedTweet.media) {
console.log(`${quotePrefix.mid}${mediaLabel(m.type)} ${m.url}`);
}
}
console.log(
`${quotePrefix.bot} https://x.com/${tweet.quotedTweet.author.username}/status/${tweet.quotedTweet.id}`,
);
}
if (tweet.createdAt) {
console.log(`${l('date')}${tweet.createdAt}`);
}
const tweetUrl = `https://x.com/${tweet.author.username}/status/${tweet.id}`;
console.log(`${l('url')}${hyperlink(tweetUrl, tweetUrl, output)}`);
if (opts.showSeparator ?? true) {
console.log('─'.repeat(50));
}
}
}
function printTweetsResult(
result: { tweets?: TweetData[]; nextCursor?: string },
opts: { json: boolean; usePagination: boolean; emptyMessage: string },
) {
const tweets = result.tweets ?? [];
if (opts.json && opts.usePagination) {
console.log(JSON.stringify({ tweets, nextCursor: result.nextCursor ?? null }, null, 2));
return;
}
printTweets(tweets, { json: opts.json, emptyMessage: opts.emptyMessage });
}
return {
isTty,
getOutput: () => output,
colors,
p,
l,
config,
applyOutputFromCommand,
resolveTimeoutFromOptions,
resolveQuoteDepthFromOptions,
resolveRelayBaseUrlFromOptions,
loadMedia,
printTweets,
printTweetsResult,
extractTweetId,
};
}
+76
View File
@@ -0,0 +1,76 @@
import type { Command } from 'commander';
import { parsePositiveIntFlag } from '../cli/pagination.js';
import type { CliContext } from '../cli/shared.js';
import { extractBookmarkFolderId } from '../lib/extract-bookmark-folder-id.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerBookmarksCommand(program: Command, ctx: CliContext): void {
program
.command('bookmarks')
.description('Get your bookmarked tweets')
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
.option('--folder-id <id>', 'Bookmark folder (collection) id')
.option('--all', 'Fetch all bookmarks (paged)')
.option('--max-pages <number>', 'Stop after N pages when using --all')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(
async (cmdOpts: {
count?: string;
json?: boolean;
jsonFull?: boolean;
folderId?: string;
all?: boolean;
maxPages?: string;
cursor?: string;
}) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const maxPagesParsed = parsePositiveIntFlag(cmdOpts.maxPages, '--max-pages');
if (!maxPagesParsed.ok) {
console.error(`${ctx.p('err')}${maxPagesParsed.error}`);
process.exit(1);
}
const maxPages = maxPagesParsed.value;
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const usePagination = Boolean(cmdOpts.all || cmdOpts.cursor);
if (maxPages !== undefined && !usePagination) {
console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`);
process.exit(1);
}
if (!usePagination && (!Number.isFinite(count) || count <= 0)) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
const client = new TwitterClient({ relayBaseUrl, timeoutMs });
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
if (cmdOpts.folderId && !folderId) {
console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
process.exit(1);
}
const includeRaw = cmdOpts.jsonFull ?? false;
const timelineOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages, cursor: cmdOpts.cursor };
const result = folderId
? usePagination
? await client.getAllBookmarkFolderTimeline(folderId, paginationOptions)
: await client.getBookmarkFolderTimeline(folderId, count, timelineOptions)
: usePagination
? await client.getAllBookmarks(paginationOptions)
: await client.getBookmarks(count, timelineOptions);
if (result.success) {
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweetsResult(result, { json: isJson, usePagination, emptyMessage });
} else {
console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
process.exit(1);
}
},
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerCheckCommand(program: Command, ctx: CliContext): void {
program
.command('check')
.description('Check twitter safe relay availability')
.action(async () => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
console.log(`${ctx.p('info')}Relay check`);
console.log('-'.repeat(40));
if (!relayBaseUrl) {
console.log(`${ctx.p('err')}TWITTER_RELAY_BASE_URL: not set`);
process.exit(1);
}
console.log(`${ctx.p('ok')}relay: ${relayBaseUrl}`);
const client = new TwitterClient({ relayBaseUrl, timeoutMs });
const result = await client.getCurrentUser();
if (result.success && result.user) {
console.log(`${ctx.l('user')}@${result.user.username} (${result.user.name})`);
console.log(`${ctx.l('userId')}${result.user.id}`);
console.log(`\n${ctx.p('ok')}Ready to use twitter safe relay.`);
} else {
console.log(`${ctx.p('err')}Relay check failed: ${result.error ?? 'Unknown error'}`);
process.exit(1);
}
});
}
+23
View File
@@ -0,0 +1,23 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export function registerHelpCommand(program: Command, ctx: CliContext): void {
program
.command('help [command]')
.description('Show help for a command')
.action((commandName?: string) => {
if (!commandName) {
program.outputHelp();
return;
}
const cmd = program.commands.find((c) => c.name() === commandName);
if (!cmd) {
console.error(`${ctx.p('err')}Unknown command: ${commandName}`);
process.exitCode = 2;
return;
}
cmd.outputHelp();
});
}
+41
View File
@@ -0,0 +1,41 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerHomeCommand(program: Command, ctx: CliContext): void {
program
.command('home')
.description('Get your home timeline ("For You" feed)')
.option('-n, --count <number>', 'Number of tweets to fetch', '20')
.option('--following', 'Get "Following" feed (chronological) instead of "For You"')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (cmdOpts: { count?: string; following?: boolean; json?: boolean; jsonFull?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
if (!Number.isFinite(count) || count <= 0) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
const client = new TwitterClient({ relayBaseUrl, timeoutMs });
const includeRaw = cmdOpts.jsonFull ?? false;
const result = cmdOpts.following
? await client.getHomeLatestTimeline(count, { includeRaw })
: await client.getHomeTimeline(count, { includeRaw });
if (result.success) {
const feedType = cmdOpts.following ? 'Following' : 'For You';
const emptyMessage = `No tweets found in ${feedType} timeline.`;
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweets(result.tweets, { json: isJson, emptyMessage });
} else {
console.error(`${ctx.p('err')}Failed to fetch home timeline: ${result.error}`);
process.exit(1);
}
});
}
+135
View File
@@ -0,0 +1,135 @@
// ABOUTME: CLI command for fetching Twitter Lists.
// ABOUTME: Supports listing owned lists, memberships, and list timelines.
import type { Command } from 'commander';
import { parsePaginationFlags } from '../cli/pagination.js';
import type { CliContext } from '../cli/shared.js';
import { extractListId } from '../lib/extract-list-id.js';
import { hyperlink } from '../lib/output.js';
import type { TwitterList } from '../lib/twitter-client.js';
import { TwitterClient } from '../lib/twitter-client.js';
function printLists(lists: TwitterList[], ctx: CliContext): void {
if (lists.length === 0) {
console.log('No lists found.');
return;
}
for (const list of lists) {
const visibility = list.isPrivate ? '[private]' : '[public]';
console.log(`${list.name} ${ctx.colors.muted(visibility)}`);
if (list.description) {
console.log(` ${list.description.slice(0, 100)}${list.description.length > 100 ? '...' : ''}`);
}
console.log(` ${ctx.p('info')}${list.memberCount?.toLocaleString() ?? 0} members`);
if (list.owner) {
console.log(` ${ctx.colors.muted(`Owner: @${list.owner.username}`)}`);
}
const listUrl = `https://x.com/i/lists/${list.id}`;
console.log(` ${ctx.colors.accent(hyperlink(listUrl, listUrl, ctx.getOutput()))}`);
console.log('──────────────────────────────────────────────────');
}
}
export function registerListsCommand(program: Command, ctx: CliContext): void {
program
.command('lists')
.description('Get your Twitter lists')
.option('--member-of', 'Show lists you are a member of (instead of owned lists)')
.option('-n, --count <number>', 'Number of lists to fetch', '100')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { memberOf?: boolean; count?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '100', 10);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs });
const result = cmdOpts.memberOf ? await client.getListMemberships(count) : await client.getOwnedLists(count);
if (result.success && result.lists) {
if (cmdOpts.json) {
console.log(JSON.stringify(result.lists, null, 2));
} else {
const emptyMessage = cmdOpts.memberOf ? 'You are not a member of any lists.' : 'You do not own any lists.';
if (result.lists.length === 0) {
console.log(emptyMessage);
} else {
printLists(result.lists, ctx);
}
}
} else {
console.error(`${ctx.p('err')}Failed to fetch lists: ${result.error}`);
process.exit(1);
}
});
program
.command('list-timeline <list-id-or-url>')
.description('Get tweets from a list timeline')
.option('-n, --count <number>', 'Number of tweets to fetch', '20')
.option('--all', 'Fetch all tweets from list (paged). WARNING: your account might get banned using this flag')
.option('--max-pages <number>', 'Fetch N pages (implies --all)')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(
async (
listIdOrUrl: string,
cmdOpts: {
count?: string;
json?: boolean;
jsonFull?: boolean;
all?: boolean;
maxPages?: string;
cursor?: string;
},
) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const pagination = parsePaginationFlags(cmdOpts, { maxPagesImpliesPagination: true });
if (!pagination.ok) {
console.error(`${ctx.p('err')}${pagination.error}`);
process.exit(1);
}
const listId = extractListId(listIdOrUrl);
if (!listId) {
console.error(`${ctx.p('err')}Invalid list ID or URL. Expected numeric ID or https://x.com/i/lists/<id>.`);
process.exit(2);
}
const usePagination = pagination.usePagination;
if (!usePagination && (!Number.isFinite(count) || count <= 0)) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const timelineOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages: pagination.maxPages, cursor: pagination.cursor };
const result = usePagination
? await client.getAllListTimeline(listId, paginationOptions)
: await client.getListTimeline(listId, count, timelineOptions);
if (result.success) {
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweetsResult(result, {
json: isJson,
usePagination,
emptyMessage: 'No tweets found in this list.',
});
} else {
console.error(`${ctx.p('err')}Failed to fetch list timeline: ${result.error}`);
process.exit(1);
}
},
);
}
+162
View File
@@ -0,0 +1,162 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { TwitterClient } from '../lib/twitter-client.js';
import type { ExploreTab, NewsItem } from '../lib/twitter-client-news.js';
function formatPostCount(count: number): string {
if (count >= 1_000_000) {
return `${(count / 1_000_000).toFixed(1)}M`;
}
if (count >= 1_000) {
return `${(count / 1_000).toFixed(1)}K`;
}
return String(count);
}
function printNewsItems(
items: NewsItem[],
ctx: CliContext,
opts: { json?: boolean; emptyMessage?: string; tweetLimit?: number } = {},
): void {
if (opts.json) {
console.log(JSON.stringify(items, null, 2));
return;
}
if (items.length === 0) {
console.log(opts.emptyMessage ?? 'No news items found.');
return;
}
for (const item of items) {
const categoryLabel = item.category ? `[${item.category}]` : '';
console.log(`\n${ctx.colors.accent(categoryLabel)} ${ctx.colors.command(item.headline)}`);
if (item.description) {
console.log(` ${ctx.colors.muted(item.description)}`);
}
const meta: string[] = [];
if (item.timeAgo) {
meta.push(item.timeAgo);
}
if (item.postCount) {
meta.push(`${formatPostCount(item.postCount)} posts`);
}
if (meta.length > 0) {
console.log(` ${ctx.colors.muted(meta.join(' | '))}`);
}
if (item.url) {
console.log(` ${ctx.l('url')}${item.url}`);
}
// Print related tweets if available
if (item.tweets && item.tweets.length > 0) {
console.log(` ${ctx.colors.section('Related tweets:')}`);
const tweetLimit = opts.tweetLimit ?? item.tweets.length;
for (const tweet of item.tweets.slice(0, tweetLimit)) {
console.log(
` @${tweet.author.username}: ${tweet.text.slice(0, 100)}${tweet.text.length > 100 ? '...' : ''}`,
);
}
}
console.log(ctx.colors.muted('─'.repeat(50)));
}
}
export function registerNewsCommand(program: Command, ctx: CliContext): void {
program
.command('news')
.alias('trending')
.description('Fetch AI-curated news and trending topics from Explore tabs')
.option('-n, --count <number>', 'Number of items to fetch', '10')
.option('--ai-only', 'Show only AI-curated news items')
.option('--with-tweets', 'Also fetch related tweets for each news item')
.option('--tweets-per-item <number>', 'Number of tweets to fetch per news item (default: 5)', '5')
.option('--for-you', 'Fetch only from For You tab')
.option('--news-only', 'Fetch only from News tab')
.option('--sports', 'Fetch only from Sports tab')
.option('--entertainment', 'Fetch only from Entertainment tab')
.option('--trending-only', 'Fetch only from Trending tab')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(
async (cmdOpts: {
count?: string;
aiOnly?: boolean;
withTweets?: boolean;
tweetsPerItem?: string;
forYou?: boolean;
newsOnly?: boolean;
sports?: boolean;
entertainment?: boolean;
trendingOnly?: boolean;
json?: boolean;
jsonFull?: boolean;
}) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10);
const tweetsPerItem = Number.parseInt(cmdOpts.tweetsPerItem || '5', 10);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
if (Number.isNaN(count) || count < 1) {
console.error(`${ctx.p('err')}--count must be a positive number`);
process.exit(1);
}
if (Number.isNaN(tweetsPerItem) || tweetsPerItem < 1) {
console.error(`${ctx.p('err')}--tweets-per-item must be a positive number`);
process.exit(1);
}
// Determine which tabs to fetch from
const tabs: ExploreTab[] = [];
if (cmdOpts.forYou) {
tabs.push('forYou');
}
if (cmdOpts.newsOnly) {
tabs.push('news');
}
if (cmdOpts.sports) {
tabs.push('sports');
}
if (cmdOpts.entertainment) {
tabs.push('entertainment');
}
if (cmdOpts.trendingOnly) {
tabs.push('trending');
}
// If no specific tabs selected, use defaults (all tabs except trending)
const tabsToFetch = tabs.length > 0 ? tabs : undefined;
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const withTweets = cmdOpts.withTweets ?? false;
const aiOnly = cmdOpts.aiOnly ?? false;
const result = await client.getNews(count, {
includeRaw,
withTweets,
tweetsPerItem,
aiOnly,
tabs: tabsToFetch,
});
if (result.success) {
printNewsItems(result.items, ctx, {
json: cmdOpts.json || cmdOpts.jsonFull,
emptyMessage: 'No news items found.',
tweetLimit: withTweets ? tweetsPerItem : undefined,
});
} else {
console.error(`${ctx.p('err')}Failed to fetch news: ${result.error}`);
process.exit(1);
}
},
);
}
+89
View File
@@ -0,0 +1,89 @@
import type { Command } from 'commander';
import type { CliContext, MediaSpec } from '../cli/shared.js';
import { formatTweetUrlLine } from '../lib/output.js';
import { TwitterClient } from '../lib/twitter-client.js';
async function uploadMediaOrExit(
client: TwitterClient,
media: MediaSpec[],
ctx: CliContext,
): Promise<string[] | undefined> {
if (media.length === 0) {
return undefined;
}
const uploaded: string[] = [];
for (const item of media) {
const res = await client.uploadMedia({ data: item.buffer, mimeType: item.mime, alt: item.alt });
if (!res.success || !res.mediaId) {
console.error(`${ctx.p('err')}Media upload failed: ${res.error ?? 'Unknown error'}`);
process.exit(1);
}
uploaded.push(res.mediaId);
}
return uploaded;
}
export function registerPostCommands(program: Command, ctx: CliContext): void {
program
.command('tweet')
.description('Post a new tweet')
.argument('<text>', 'Tweet text')
.action(async (text: string) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
let media: MediaSpec[] = [];
try {
media = ctx.loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
} catch (error) {
console.error(`${ctx.p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const mediaIds = await uploadMediaOrExit(client, media, ctx);
const result = await client.tweet(text, mediaIds);
if (result.success) {
console.log(`${ctx.p('ok')}Tweet posted successfully!`);
console.log(formatTweetUrlLine(result.tweetId, ctx.getOutput()));
} else {
console.error(`${ctx.p('err')}Failed to post tweet: ${result.error}`);
process.exit(1);
}
});
program
.command('reply')
.description('Reply to an existing tweet')
.argument('<tweet-id-or-url>', 'Tweet ID or URL to reply to')
.argument('<text>', 'Reply text')
.action(async (tweetIdOrUrl: string, text: string) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
let media: MediaSpec[] = [];
try {
media = ctx.loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
} catch (error) {
console.error(`${ctx.p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
console.error(`${ctx.p('info')}Replying to tweet: ${tweetId}`);
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const mediaIds = await uploadMediaOrExit(client, media, ctx);
const result = await client.reply(text, tweetId, mediaIds);
if (result.success) {
console.log(`${ctx.p('ok')}Reply posted successfully!`);
console.log(formatTweetUrlLine(result.tweetId, ctx.getOutput()));
} else {
console.error(`${ctx.p('err')}Failed to post reply: ${result.error}`);
process.exit(1);
}
});
}
+103
View File
@@ -0,0 +1,103 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import {
type FeatureOverrides,
getFeatureOverridesSnapshot,
refreshFeatureOverridesCache,
} from '../lib/runtime-features.js';
import { runtimeQueryIds } from '../lib/runtime-query-ids.js';
function countFeatureOverrides(overrides: FeatureOverrides): number {
let count = 0;
if (overrides.global) {
count += Object.keys(overrides.global).length;
}
if (overrides.sets) {
for (const setOverrides of Object.values(overrides.sets)) {
count += Object.keys(setOverrides).length;
}
}
return count;
}
export function registerQueryIdsCommand(program: Command, ctx: CliContext): void {
program
.command('query-ids')
.description('Show or refresh cached Twitter GraphQL query IDs')
.option('--json', 'Output as JSON')
.option('--fresh', 'Force refresh (downloads X client bundles)', false)
.action(async (cmdOpts: { json?: boolean; fresh?: boolean }) => {
const operations = [
'CreateTweet',
'CreateRetweet',
'FavoriteTweet',
'TweetDetail',
'SearchTimeline',
'UserArticlesTweets',
'Bookmarks',
'Following',
'Followers',
'Likes',
];
if (cmdOpts.fresh) {
console.error(`${ctx.p('info')}Refreshing GraphQL query IDs…`);
await runtimeQueryIds.refresh(operations, { force: true });
console.error(`${ctx.p('info')}Refreshing feature overrides…`);
await refreshFeatureOverridesCache();
}
const featureSnapshot = getFeatureOverridesSnapshot();
const info = await runtimeQueryIds.getSnapshotInfo();
if (!info) {
if (cmdOpts.json) {
console.log(
JSON.stringify(
{
cached: false,
cachePath: runtimeQueryIds.cachePath,
featuresPath: featureSnapshot.cachePath,
features: featureSnapshot.overrides,
},
null,
2,
),
);
return;
}
console.log(`${ctx.p('warn')}No cached query IDs yet.`);
console.log(`${ctx.p('info')}Run: bird query-ids --fresh`);
console.log(`features_path: ${featureSnapshot.cachePath}`);
return;
}
if (cmdOpts.json) {
console.log(
JSON.stringify(
{
cached: true,
cachePath: info.cachePath,
fetchedAt: info.snapshot.fetchedAt,
isFresh: info.isFresh,
ageMs: info.ageMs,
ids: info.snapshot.ids,
discovery: info.snapshot.discovery,
featuresPath: featureSnapshot.cachePath,
features: featureSnapshot.overrides,
},
null,
2,
),
);
return;
}
console.log(`${ctx.p('ok')}GraphQL query IDs cached`);
console.log(`path: ${info.cachePath}`);
console.log(`fetched_at: ${info.snapshot.fetchedAt}`);
console.log(`fresh: ${info.isFresh ? 'yes' : 'no'}`);
console.log(`ops: ${Object.keys(info.snapshot.ids).length}`);
console.log(`features_path: ${featureSnapshot.cachePath}`);
console.log(`features: ${countFeatureOverrides(featureSnapshot.overrides)}`);
});
}
+174
View File
@@ -0,0 +1,174 @@
import type { Command } from 'commander';
import { parsePaginationFlags } from '../cli/pagination.js';
import type { CliContext } from '../cli/shared.js';
import { formatStatsLine } from '../lib/output.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerReadCommands(program: Command, ctx: CliContext): void {
program
.command('read')
.description('Read/fetch a tweet by ID or URL')
.argument('<tweet-id-or-url>', 'Tweet ID or URL to read')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean; jsonFull?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const result = await client.getTweet(tweetId, { includeRaw });
if (result.success && result.tweet) {
if (cmdOpts.json || cmdOpts.jsonFull) {
console.log(JSON.stringify(result.tweet, null, 2));
} else {
ctx.printTweets([result.tweet], { showSeparator: false });
console.log(formatStatsLine(result.tweet, ctx.getOutput()));
}
} else {
console.error(`${ctx.p('err')}Failed to read tweet: ${result.error}`);
process.exit(1);
}
});
program
.command('replies')
.description('List replies to a tweet (by ID or URL)')
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
.option('--all', 'Fetch all replies (paged)')
.option('--max-pages <number>', 'Fetch N pages (implies pagination)')
.option('--delay <ms>', 'Delay in ms between page fetches', '1000')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(
async (
tweetIdOrUrl: string,
cmdOpts: {
all?: boolean;
maxPages?: string;
delay?: string;
cursor?: string;
json?: boolean;
jsonFull?: boolean;
},
) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const pagination = parsePaginationFlags(cmdOpts, { maxPagesImpliesPagination: true, includeDelay: true });
if (!pagination.ok) {
console.error(`${ctx.p('err')}${pagination.error}`);
process.exit(1);
}
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const result = pagination.usePagination
? await client.getRepliesPaged(tweetId, {
includeRaw,
maxPages: pagination.maxPages,
cursor: pagination.cursor,
pageDelayMs: pagination.pageDelayMs,
})
: await client.getReplies(tweetId, { includeRaw });
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
if (result.tweets) {
ctx.printTweetsResult(result, {
json: isJson,
usePagination: pagination.usePagination,
emptyMessage: 'No replies found.',
});
// Show pagination hint if there's more
if (result.nextCursor && !isJson) {
console.error(`${ctx.p('info')}More replies available. Use --cursor "${result.nextCursor}" to continue.`);
}
}
if (!result.success) {
console.error(`${ctx.p('err')}Failed to fetch replies: ${result.error}`);
process.exit(1);
}
},
);
program
.command('thread')
.description('Show the full conversation thread containing the tweet')
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
.option('--all', 'Fetch all thread tweets (paged)')
.option('--max-pages <number>', 'Fetch N pages (implies pagination)')
.option('--delay <ms>', 'Delay in ms between page fetches', '1000')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(
async (
tweetIdOrUrl: string,
cmdOpts: {
all?: boolean;
maxPages?: string;
delay?: string;
cursor?: string;
json?: boolean;
jsonFull?: boolean;
},
) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const pagination = parsePaginationFlags(cmdOpts, { maxPagesImpliesPagination: true, includeDelay: true });
if (!pagination.ok) {
console.error(`${ctx.p('err')}${pagination.error}`);
process.exit(1);
}
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const result = pagination.usePagination
? await client.getThreadPaged(tweetId, {
includeRaw,
maxPages: pagination.maxPages,
cursor: pagination.cursor,
pageDelayMs: pagination.pageDelayMs,
})
: await client.getThread(tweetId, { includeRaw });
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
if (result.tweets) {
ctx.printTweetsResult(result, {
json: isJson,
usePagination: pagination.usePagination,
emptyMessage: 'No thread tweets found.',
});
// Show pagination hint if there's more
if (result.nextCursor && !isJson) {
console.error(
`${ctx.p('info')}More thread tweets available. Use --cursor "${result.nextCursor}" to continue.`,
);
}
}
if (!result.success) {
console.error(`${ctx.p('err')}Failed to fetch thread: ${result.error}`);
process.exit(1);
}
},
);
}
+122
View File
@@ -0,0 +1,122 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { mentionsQueryFromUserOption, normalizeHandle } from '../lib/normalize-handle.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerSearchCommands(program: Command, ctx: CliContext): void {
program
.command('search')
.description('Search for tweets')
.argument('<query>', 'Search query (e.g., "@clawdbot" or "from:clawdbot")')
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
.option('--all', 'Fetch all search results (paged)')
.option('--max-pages <number>', 'Stop after N pages when using --all')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(
async (
query: string,
cmdOpts: {
count?: string;
all?: boolean;
maxPages?: string;
cursor?: string;
json?: boolean;
jsonFull?: boolean;
},
) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10);
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const usePagination = cmdOpts.all || cmdOpts.cursor;
if (maxPages !== undefined && !usePagination) {
console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`);
process.exit(1);
}
if (!usePagination && (!Number.isFinite(count) || count <= 0)) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) {
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`);
process.exit(1);
}
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const searchOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages, cursor: cmdOpts.cursor };
const result = usePagination
? await client.getAllSearchResults(query, paginationOptions)
: await client.search(query, count, searchOptions);
if (result.success) {
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweetsResult(result, {
json: isJson,
usePagination: Boolean(usePagination),
emptyMessage: 'No tweets found.',
});
} else {
console.error(`${ctx.p('err')}Search failed: ${result.error}`);
process.exit(1);
}
},
);
program
.command('mentions')
.description('Find tweets mentioning a user (defaults to current user)')
.option('-u, --user <handle>', 'User handle (e.g. @steipete)')
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean; jsonFull?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10);
const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user);
if (fromUserOpt.error) {
console.error(`${ctx.p('err')}${fromUserOpt.error}`);
process.exit(2);
}
let query: string | null = fromUserOpt.query;
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
if (!query) {
const who = await client.getCurrentUser();
const handle = normalizeHandle(who.user?.username);
if (handle) {
query = `@${handle}`;
} else {
console.error(
`${ctx.p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user <handle>.`,
);
process.exit(1);
}
}
const includeRaw = cmdOpts.jsonFull ?? false;
const result = await client.search(query, count, { includeRaw });
if (result.success) {
ctx.printTweets(result.tweets, {
json: cmdOpts.json || cmdOpts.jsonFull,
emptyMessage: 'No mentions found.',
});
} else {
console.error(`${ctx.p('err')}Failed to fetch mentions: ${result.error}`);
process.exit(1);
}
});
}
+33
View File
@@ -0,0 +1,33 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerUnbookmarkCommand(program: Command, ctx: CliContext): void {
program
.command('unbookmark')
.description('Remove bookmarked tweets')
.argument('<tweet-id-or-url...>', 'Tweet IDs or URLs to remove from bookmarks')
.action(async (tweetIdOrUrls: string[]) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs });
let failures = 0;
for (const input of tweetIdOrUrls) {
const tweetId = ctx.extractTweetId(input);
const result = await client.unbookmark(tweetId);
if (result.success) {
console.log(`${ctx.p('ok')}Removed bookmark for ${tweetId}`);
} else {
failures += 1;
console.error(`${ctx.p('err')}Failed to remove bookmark for ${tweetId}: ${result.error}`);
}
}
if (failures > 0) {
process.exit(1);
}
});
}
+133
View File
@@ -0,0 +1,133 @@
import type { Command } from 'commander';
import { parseNonNegativeIntFlag, parsePositiveIntFlag } from '../cli/pagination.js';
import type { CliContext } from '../cli/shared.js';
import { normalizeHandle } from '../lib/normalize-handle.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerUserTweetsCommand(program: Command, ctx: CliContext): void {
const formatExample = (cmd: string, desc: string): string =>
` ${ctx.colors.command(cmd)}\n ${ctx.colors.muted(desc)}`;
program
.command('user-tweets')
.description("Get tweets from a user's profile timeline")
.argument('<handle>', 'Username to fetch tweets from (e.g., @steipete or steipete)')
.option('-n, --count <number>', 'Number of tweets to fetch', '20')
.option('--max-pages <number>', 'Stop after N pages (max: 10)')
.option('--delay <ms>', 'Delay in ms between page fetches', '1000')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.addHelpText(
'after',
() =>
`\n${ctx.colors.section('Command Examples')}\n${[
formatExample('bird user-tweets @steipete', 'Get recent tweets from a user'),
formatExample('bird user-tweets steipete -n 10', 'Get 10 tweets (@ is optional)'),
formatExample('bird user-tweets @steipete -n 50', 'Fetch 50 tweets (paged)'),
formatExample('bird user-tweets @steipete --max-pages 3 -n 200', 'Safety cap (max 3 pages)'),
formatExample('bird user-tweets @steipete --json', 'Output as JSON'),
formatExample('bird user-tweets @steipete --cursor "DAABCg..."', 'Resume from cursor'),
].join('\n')}`,
)
.action(
async (
handle: string,
cmdOpts: {
count?: string;
maxPages?: string;
delay?: string;
cursor?: string;
json?: boolean;
jsonFull?: boolean;
},
) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const maxPagesParsed = parsePositiveIntFlag(cmdOpts.maxPages, '--max-pages');
if (!maxPagesParsed.ok) {
console.error(`${ctx.p('err')}${maxPagesParsed.error}`);
process.exit(2);
}
const maxPages = maxPagesParsed.value;
const delayParsed = parseNonNegativeIntFlag(cmdOpts.delay, '--delay', 1000);
if (!delayParsed.ok) {
console.error(`${ctx.p('err')}${delayParsed.error}`);
process.exit(2);
}
const pageDelayMs = delayParsed.value;
// Validate inputs
if (!Number.isFinite(count) || count <= 0) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(2);
}
const pageSize = 20;
const hardMaxPages = 10;
const hardMaxTweets = pageSize * hardMaxPages;
if (count > hardMaxTweets) {
console.error(
`${ctx.p('err')}Invalid --count. Max ${hardMaxTweets} tweets per run (safety cap: ${hardMaxPages} pages). Use --cursor to continue.`,
);
process.exit(2);
}
if (maxPages !== undefined && maxPages > hardMaxPages) {
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer (max: ${hardMaxPages}).`);
process.exit(2);
}
// Normalize handle (strip @ if present)
const username = normalizeHandle(handle);
if (!username) {
console.error(`${ctx.p('err')}Invalid handle: ${handle}`);
process.exit(2);
}
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
// Look up user ID from username
console.error(`${ctx.p('info')}Looking up @${username}...`);
const userLookup = await client.getUserIdByUsername(username);
if (!userLookup.success || !userLookup.userId) {
console.error(`${ctx.p('err')}${userLookup.error || `Could not find user @${username}`}`);
process.exit(1);
}
const displayName = userLookup.name
? `${userLookup.name} (@${userLookup.username})`
: `@${userLookup.username}`;
console.error(`${ctx.p('info')}Fetching tweets from ${displayName}...`);
const includeRaw = cmdOpts.jsonFull ?? false;
const wantsPaginationOutput = Boolean(cmdOpts.cursor) || maxPages !== undefined || count > pageSize;
const result = await client.getUserTweetsPaged(userLookup.userId, count, {
includeRaw,
maxPages,
cursor: cmdOpts.cursor,
pageDelayMs,
});
if (result.success) {
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweetsResult(result, {
json: isJson,
usePagination: wantsPaginationOutput,
emptyMessage: `No tweets found for @${username}.`,
});
// Show pagination hint if there's more
if (result.nextCursor && !cmdOpts.json && !cmdOpts.jsonFull) {
console.error(`${ctx.p('info')}More tweets available. Use --cursor "${result.nextCursor}" to continue.`);
}
} else {
console.error(`${ctx.p('err')}Failed to fetch tweets: ${result.error}`);
process.exit(1);
}
},
);
}
+390
View File
@@ -0,0 +1,390 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { TwitterClient } from '../lib/twitter-client.js';
import type { TwitterUser } from '../lib/twitter-client-types.js';
export function registerUserCommands(program: Command, ctx: CliContext): void {
program
.command('following')
.description('Get users that you (or another user) follow')
.option('--user <userId>', 'User ID to get following for (defaults to current user)')
.option('-n, --count <number>', 'Number of users to fetch per page', '20')
.option('--cursor <cursor>', 'Cursor for pagination (from previous response)')
.option('--all', 'Fetch all users (paginate automatically)')
.option('--max-pages <number>', 'Stop after N pages when using --all')
.option('--json', 'Output as JSON')
.action(
async (cmdOpts: {
user?: string;
count?: string;
cursor?: string;
all?: boolean;
maxPages?: string;
json?: boolean;
}) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
const usePagination = cmdOpts.all || cmdOpts.cursor;
if (maxPages !== undefined && !cmdOpts.all) {
console.error(`${ctx.p('err')}--max-pages requires --all.`);
process.exit(1);
}
if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) {
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`);
process.exit(1);
}
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs });
let userId = cmdOpts.user;
if (!userId) {
const currentUser = await client.getCurrentUser();
if (!currentUser.success || !currentUser.user?.id) {
console.error(`${ctx.p('err')}Failed to get current user: ${currentUser.error || 'Unknown error'}`);
process.exit(1);
}
userId = currentUser.user.id;
}
if (cmdOpts.all) {
// Fetch all pages
const allUsers: TwitterUser[] = [];
const seen = new Set<string>();
let cursor: string | undefined = cmdOpts.cursor;
let pageNum = 0;
let nextCursor: string | undefined;
while (true) {
pageNum++;
if (!cmdOpts.json) {
console.error(`${ctx.p('info')}Fetching page ${pageNum}...`);
}
const result = await client.getFollowing(userId, count, cursor);
if (!result.success || !result.users) {
console.error(`${ctx.p('err')}Failed to fetch following: ${result.error}`);
process.exit(1);
}
let added = 0;
for (const user of result.users) {
if (!seen.has(user.id)) {
seen.add(user.id);
allUsers.push(user);
added += 1;
}
}
const pageCursor = result.nextCursor;
if (!pageCursor || result.users.length === 0 || added === 0 || pageCursor === cursor) {
nextCursor = undefined;
break;
}
if (maxPages && pageNum >= maxPages) {
nextCursor = pageCursor;
break;
}
cursor = pageCursor;
// Rate limit: wait between pages to avoid overwhelming the API
await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (cmdOpts.json) {
console.log(JSON.stringify({ users: allUsers, nextCursor: nextCursor ?? null }, null, 2));
} else {
console.error(`${ctx.p('info')}Total: ${allUsers.length} users`);
if (nextCursor) {
console.error(`${ctx.p('info')}Stopped at --max-pages. Use --cursor to continue.`);
console.error(`${ctx.p('info')}Next cursor: ${nextCursor}`);
}
for (const user of allUsers) {
console.log(`@${user.username} (${user.name})`);
if (user.description) {
console.log(` ${user.description.slice(0, 100)}${user.description.length > 100 ? '...' : ''}`);
}
if (user.followersCount !== undefined) {
console.log(` ${ctx.p('info')}${user.followersCount.toLocaleString()} followers`);
}
console.log('──────────────────────────────────────────────────');
}
}
} else {
// Single page fetch
const result = await client.getFollowing(userId, count, cmdOpts.cursor);
if (result.success && result.users) {
if (cmdOpts.json) {
if (usePagination) {
console.log(JSON.stringify({ users: result.users, nextCursor: result.nextCursor ?? null }, null, 2));
} else {
console.log(JSON.stringify(result.users, null, 2));
}
} else {
if (result.users.length === 0) {
console.log('No users found.');
} else {
for (const user of result.users) {
console.log(`@${user.username} (${user.name})`);
if (user.description) {
console.log(` ${user.description.slice(0, 100)}${user.description.length > 100 ? '...' : ''}`);
}
if (user.followersCount !== undefined) {
console.log(` ${ctx.p('info')}${user.followersCount.toLocaleString()} followers`);
}
console.log('──────────────────────────────────────────────────');
}
if (result.nextCursor) {
console.error(`${ctx.p('info')}Next cursor: ${result.nextCursor}`);
}
}
}
} else {
console.error(`${ctx.p('err')}Failed to fetch following: ${result.error}`);
process.exit(1);
}
}
},
);
program
.command('followers')
.description('Get users that follow you (or another user)')
.option('--user <userId>', 'User ID to get followers for (defaults to current user)')
.option('-n, --count <number>', 'Number of users to fetch per page', '20')
.option('--cursor <cursor>', 'Cursor for pagination (from previous response)')
.option('--all', 'Fetch all users (paginate automatically)')
.option('--max-pages <number>', 'Stop after N pages when using --all')
.option('--json', 'Output as JSON')
.action(
async (cmdOpts: {
user?: string;
count?: string;
cursor?: string;
all?: boolean;
maxPages?: string;
json?: boolean;
}) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
const usePagination = cmdOpts.all || cmdOpts.cursor;
if (maxPages !== undefined && !cmdOpts.all) {
console.error(`${ctx.p('err')}--max-pages requires --all.`);
process.exit(1);
}
if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) {
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`);
process.exit(1);
}
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs });
let userId = cmdOpts.user;
if (!userId) {
const currentUser = await client.getCurrentUser();
if (!currentUser.success || !currentUser.user?.id) {
console.error(`${ctx.p('err')}Failed to get current user: ${currentUser.error || 'Unknown error'}`);
process.exit(1);
}
userId = currentUser.user.id;
}
if (cmdOpts.all) {
// Fetch all pages
const allUsers: TwitterUser[] = [];
const seen = new Set<string>();
let cursor: string | undefined = cmdOpts.cursor;
let pageNum = 0;
let nextCursor: string | undefined;
while (true) {
pageNum++;
if (!cmdOpts.json) {
console.error(`${ctx.p('info')}Fetching page ${pageNum}...`);
}
const result = await client.getFollowers(userId, count, cursor);
if (!result.success || !result.users) {
console.error(`${ctx.p('err')}Failed to fetch followers: ${result.error}`);
process.exit(1);
}
let added = 0;
for (const user of result.users) {
if (!seen.has(user.id)) {
seen.add(user.id);
allUsers.push(user);
added += 1;
}
}
const pageCursor = result.nextCursor;
if (!pageCursor || result.users.length === 0 || added === 0 || pageCursor === cursor) {
nextCursor = undefined;
break;
}
if (maxPages && pageNum >= maxPages) {
nextCursor = pageCursor;
break;
}
cursor = pageCursor;
// Rate limit: wait between pages to avoid overwhelming the API
await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (cmdOpts.json) {
console.log(JSON.stringify({ users: allUsers, nextCursor: nextCursor ?? null }, null, 2));
} else {
console.error(`${ctx.p('info')}Total: ${allUsers.length} users`);
if (nextCursor) {
console.error(`${ctx.p('info')}Stopped at --max-pages. Use --cursor to continue.`);
console.error(`${ctx.p('info')}Next cursor: ${nextCursor}`);
}
for (const user of allUsers) {
console.log(`@${user.username} (${user.name})`);
if (user.description) {
console.log(` ${user.description.slice(0, 100)}${user.description.length > 100 ? '...' : ''}`);
}
if (user.followersCount !== undefined) {
console.log(` ${ctx.p('info')}${user.followersCount.toLocaleString()} followers`);
}
console.log('──────────────────────────────────────────────────');
}
}
} else {
// Single page fetch
const result = await client.getFollowers(userId, count, cmdOpts.cursor);
if (result.success && result.users) {
if (cmdOpts.json) {
if (usePagination) {
console.log(JSON.stringify({ users: result.users, nextCursor: result.nextCursor ?? null }, null, 2));
} else {
console.log(JSON.stringify(result.users, null, 2));
}
} else {
if (result.users.length === 0) {
console.log('No users found.');
} else {
for (const user of result.users) {
console.log(`@${user.username} (${user.name})`);
if (user.description) {
console.log(` ${user.description.slice(0, 100)}${user.description.length > 100 ? '...' : ''}`);
}
if (user.followersCount !== undefined) {
console.log(` ${ctx.p('info')}${user.followersCount.toLocaleString()} followers`);
}
console.log('──────────────────────────────────────────────────');
}
if (result.nextCursor) {
console.error(`${ctx.p('info')}Next cursor: ${result.nextCursor}`);
}
}
}
} else {
console.error(`${ctx.p('err')}Failed to fetch followers: ${result.error}`);
process.exit(1);
}
}
},
);
program
.command('likes')
.description('Get your liked tweets')
.option('-n, --count <number>', 'Number of likes to fetch', '20')
.option('--all', 'Fetch all likes (paged)')
.option('--max-pages <number>', 'Stop after N pages when using --all')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(
async (cmdOpts: {
count?: string;
json?: boolean;
jsonFull?: boolean;
all?: boolean;
maxPages?: string;
cursor?: string;
}) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const usePagination = cmdOpts.all || cmdOpts.cursor;
if (maxPages !== undefined && !usePagination) {
console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`);
process.exit(1);
}
if (!usePagination && (!Number.isFinite(count) || count <= 0)) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) {
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`);
process.exit(1);
}
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const timelineOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages, cursor: cmdOpts.cursor };
const result = usePagination
? await client.getAllLikes(paginationOptions)
: await client.getLikes(count, timelineOptions);
if (result.success) {
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweetsResult(result, {
json: isJson,
usePagination: Boolean(usePagination),
emptyMessage: 'No liked tweets found.',
});
} else {
console.error(`${ctx.p('err')}Failed to fetch likes: ${result.error}`);
process.exit(1);
}
},
);
program
.command('whoami')
.description('Show which Twitter account the relay belongs to')
.action(async () => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const result = await client.getCurrentUser();
if (result.success && result.user) {
console.log(`${ctx.l('user')}@${result.user.username} (${result.user.name})`);
console.log(`${ctx.l('userId')}${result.user.id}`);
console.log(`${ctx.l('engine')}twitter-safe-relay`);
console.log(`${ctx.l('credentials')}${relayBaseUrl ?? 'TWITTER_RELAY_BASE_URL'}`);
} else {
console.error(`${ctx.p('err')}Failed to determine current user: ${result.error ?? 'Unknown error'}`);
process.exit(1);
}
});
}
+1 -1229
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -8,7 +8,9 @@ const TWEET_ID_REGEX = /^\d{8,}$/;
export function looksLikeTweetInput(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
if (!trimmed) {
return false;
}
return TWEET_URL_REGEX.test(trimmed) || TWEET_ID_REGEX.test(trimmed);
}
-387
View File
@@ -1,387 +0,0 @@
/**
* Chrome cookie extraction for Twitter authentication
* Uses macOS sqlite3 CLI and keychain for decryption - no native dependencies!
*/
import { execSync } from 'node:child_process';
import { createDecipheriv, pbkdf2Sync } from 'node:crypto';
import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export interface TwitterCookies {
authToken: string | null;
ct0: string | null;
source: string | null;
}
export interface CookieExtractionResult {
cookies: TwitterCookies;
warnings: string[];
}
function normalizeValue(value: unknown): string | null {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
return null;
}
function getChromeCookiesPath(profile?: string): string {
const home = process.env.HOME || '';
const profileDir = profile || 'Default';
return join(home, 'Library', 'Application Support', 'Google', 'Chrome', profileDir, 'Cookies');
}
function getFirefoxProfilesRoot(): string | null {
const home = process.env.HOME || '';
if (process.platform === 'darwin') {
return join(home, 'Library', 'Application Support', 'Firefox', 'Profiles');
}
if (process.platform === 'linux') {
return join(home, '.mozilla', 'firefox');
}
if (process.platform === 'win32') {
const appData = process.env.APPDATA;
if (!appData) return null;
return join(appData, 'Mozilla', 'Firefox', 'Profiles');
}
return null;
}
function pickFirefoxProfile(profilesRoot: string, profile?: string): string | null {
if (profile) {
const candidate = join(profilesRoot, profile, 'cookies.sqlite');
return existsSync(candidate) ? candidate : null;
}
const entries = readdirSync(profilesRoot, { withFileTypes: true });
const defaultRelease = entries.find((entry) => entry.isDirectory() && entry.name.includes('default-release'));
const targetDir = defaultRelease?.name ?? entries.find((e) => e.isDirectory())?.name;
if (!targetDir) return null;
const candidate = join(profilesRoot, targetDir, 'cookies.sqlite');
return existsSync(candidate) ? candidate : null;
}
function getFirefoxCookiesPath(profile?: string): string | null {
const profilesRoot = getFirefoxProfilesRoot();
if (!profilesRoot || !existsSync(profilesRoot)) return null;
return pickFirefoxProfile(profilesRoot, profile);
}
/**
* Decrypt Chrome cookie value using macOS keychain
* Chrome encrypts cookies with a key stored in the keychain
*/
function decryptCookieValue(encryptedHex: string): string | null {
try {
// Convert hex to buffer
const encryptedValue = Buffer.from(encryptedHex, 'hex');
if (encryptedValue.length < 4) {
return null;
}
const version = encryptedValue.subarray(0, 3).toString('utf8');
if (version !== 'v10' && version !== 'v11') {
// Not encrypted, just return as string
return encryptedValue.toString('utf8');
}
// Get encryption key from keychain
const keyOutput = execSync('security find-generic-password -s "Chrome Safe Storage" -w 2>/dev/null || echo ""', {
encoding: 'utf8',
}).trim();
if (!keyOutput) {
return null;
}
// Derive the key using PBKDF2
const salt = 'saltysalt';
const iterations = 1003;
const keyLength = 16;
const derivedKey = pbkdf2Sync(keyOutput, salt, iterations, keyLength, 'sha1');
// Decrypt using AES-128-CBC with empty IV (16 bytes of 0x20/space)
const iv = Buffer.alloc(16, 0x20);
const encryptedData = encryptedValue.subarray(3); // Skip "v10" or "v11" prefix
const decipher = createDecipheriv('aes-128-cbc', derivedKey, iv);
decipher.setAutoPadding(true);
let decrypted = decipher.update(encryptedData);
decrypted = Buffer.concat([decrypted, decipher.final()]);
// Chrome v10 cookies have key material prepended before the actual value
// Twitter cookies are hex strings, so extract the longest hex sequence
const decryptedStr = decrypted.toString('utf8');
const hexMatch = decryptedStr.match(/[a-f0-9]{32,}/i);
if (hexMatch) {
return hexMatch[0];
}
// Fallback: keep only printable ASCII characters
return decryptedStr.replace(/[^\x20-\x7E]/g, '');
} catch {
return null;
}
}
/**
* Extract Twitter cookies from Chrome browser using sqlite3 CLI
* @param profile - Chrome profile name (optional, uses Default if not specified)
*/
export async function extractCookiesFromChrome(profile?: string): Promise<CookieExtractionResult> {
const warnings: string[] = [];
const cookies: TwitterCookies = {
authToken: null,
ct0: null,
source: null,
};
const cookiesPath = getChromeCookiesPath(profile);
if (!existsSync(cookiesPath)) {
warnings.push(`Chrome cookies database not found at: ${cookiesPath}`);
return { cookies, warnings };
}
// Chrome locks the database, so we need to copy it
let tempDir: string | null = null;
try {
tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-'));
const tempDbPath = join(tempDir, 'Cookies');
copyFileSync(cookiesPath, tempDbPath);
// Also copy the WAL and SHM files if they exist
const walPath = `${cookiesPath}-wal`;
const shmPath = `${cookiesPath}-shm`;
if (existsSync(walPath)) {
copyFileSync(walPath, `${tempDbPath}-wal`);
}
if (existsSync(shmPath)) {
copyFileSync(shmPath, `${tempDbPath}-shm`);
}
// Use sqlite3 CLI to query cookies (no native deps required!)
const query = `SELECT name, hex(encrypted_value) as encrypted_hex FROM cookies WHERE host_key IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com') AND name IN ('auth_token', 'ct0');`;
const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, {
encoding: 'utf8',
maxBuffer: 1024 * 1024,
}).trim();
if (result) {
for (const line of result.split('\n')) {
const [name, encryptedHex] = line.split('|');
if (!name || !encryptedHex) continue;
const decryptedValue = decryptCookieValue(encryptedHex);
if (decryptedValue) {
if (name === 'auth_token' && !cookies.authToken) {
cookies.authToken = decryptedValue;
} else if (name === 'ct0' && !cookies.ct0) {
cookies.ct0 = decryptedValue;
}
}
}
}
if (cookies.authToken || cookies.ct0) {
cookies.source = profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warnings.push(`Failed to read Chrome cookies: ${message}`);
} finally {
// Cleanup temp files
if (tempDir) {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
}
}
if (!cookies.authToken && !cookies.ct0) {
warnings.push('No Twitter cookies found in Chrome. Make sure you are logged into x.com in Chrome.');
}
return { cookies, warnings };
}
/**
* Extract Twitter cookies from Firefox browser using sqlite3 CLI
* @param profile - Firefox profile directory name (optional, auto-detected)
*/
export async function extractCookiesFromFirefox(profile?: string): Promise<CookieExtractionResult> {
const warnings: string[] = [];
const cookies: TwitterCookies = {
authToken: null,
ct0: null,
source: null,
};
const cookiesPath = getFirefoxCookiesPath(profile);
if (!cookiesPath) {
warnings.push('Firefox cookies database not found.');
return { cookies, warnings };
}
let tempDir: string | null = null;
try {
tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-'));
const tempDbPath = join(tempDir, 'cookies.sqlite');
copyFileSync(cookiesPath, tempDbPath);
const query = `SELECT name, value FROM moz_cookies WHERE host IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com') AND name IN ('auth_token', 'ct0');`;
const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, {
encoding: 'utf8',
maxBuffer: 1024 * 1024,
}).trim();
if (result) {
for (const line of result.split('\n')) {
const [name, value] = line.split('|');
if (!name || !value) continue;
if (name === 'auth_token' && !cookies.authToken) {
cookies.authToken = value;
} else if (name === 'ct0' && !cookies.ct0) {
cookies.ct0 = value;
}
}
}
if (cookies.authToken || cookies.ct0) {
cookies.source = profile ? `Firefox profile "${profile}"` : 'Firefox default profile';
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warnings.push(`Failed to read Firefox cookies: ${message}`);
} finally {
if (tempDir) {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
}
}
if (!cookies.authToken && !cookies.ct0) {
warnings.push(
'No Twitter cookies found in Firefox. Make sure you are logged into x.com in Firefox and the profile exists.',
);
}
return { cookies, warnings };
}
/**
* Resolve Twitter credentials from multiple sources
* Priority: CLI args > environment variables > Chrome cookies
*/
export async function resolveCredentials(options: {
authToken?: string;
ct0?: string;
chromeProfile?: string;
firefoxProfile?: string;
allowChrome?: boolean;
allowFirefox?: boolean;
}): Promise<CookieExtractionResult> {
const warnings: string[] = [];
const cookies: TwitterCookies = {
authToken: null,
ct0: null,
source: null,
};
// 1. CLI arguments (highest priority)
if (options.authToken) {
cookies.authToken = options.authToken;
cookies.source = 'CLI argument';
}
if (options.ct0) {
cookies.ct0 = options.ct0;
if (!cookies.source) cookies.source = 'CLI argument';
}
// 2. Environment variables
const envAuthKeys = ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'];
const envCt0Keys = ['CT0', 'TWITTER_CT0'];
if (!cookies.authToken) {
for (const key of envAuthKeys) {
const value = normalizeValue(process.env[key]);
if (value) {
cookies.authToken = value;
cookies.source = `env ${key}`;
break;
}
}
}
if (!cookies.ct0) {
for (const key of envCt0Keys) {
const value = normalizeValue(process.env[key]);
if (value) {
cookies.ct0 = value;
if (!cookies.source) cookies.source = `env ${key}`;
break;
}
}
}
const allowFirefox = options.allowFirefox ?? true;
const allowChrome = options.allowChrome ?? true;
// 3. Firefox cookies (preferred browser fallback)
if (allowFirefox && (!cookies.authToken || !cookies.ct0)) {
const firefoxResult = await extractCookiesFromFirefox(options.firefoxProfile);
warnings.push(...firefoxResult.warnings);
if (!cookies.authToken && firefoxResult.cookies.authToken) {
cookies.authToken = firefoxResult.cookies.authToken;
cookies.source = firefoxResult.cookies.source;
}
if (!cookies.ct0 && firefoxResult.cookies.ct0) {
cookies.ct0 = firefoxResult.cookies.ct0;
if (!cookies.source) cookies.source = firefoxResult.cookies.source;
}
}
// 4. Chrome cookies (secondary browser fallback)
if (allowChrome && (!cookies.authToken || !cookies.ct0)) {
const chromeResult = await extractCookiesFromChrome(options.chromeProfile);
warnings.push(...chromeResult.warnings);
if (!cookies.authToken && chromeResult.cookies.authToken) {
cookies.authToken = chromeResult.cookies.authToken;
cookies.source = chromeResult.cookies.source;
}
if (!cookies.ct0 && chromeResult.cookies.ct0) {
cookies.ct0 = chromeResult.cookies.ct0;
if (!cookies.source) cookies.source = chromeResult.cookies.source;
}
}
// Validation
if (!cookies.authToken) {
warnings.push(
'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome/Firefox',
);
}
if (!cookies.ct0) {
warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome/Firefox');
}
return { cookies, warnings };
}
-19
View File
@@ -1,19 +0,0 @@
export type EngineMode = 'graphql' | 'sweetistics' | 'auto';
export function resolveEngineMode(value?: string): EngineMode {
const normalized = (value || 'auto').toLowerCase();
if (normalized === 'graphql' || normalized === 'sweetistics' || normalized === 'auto') {
return normalized;
}
return 'auto';
}
export function shouldUseSweetistics(engine: EngineMode, _hasApiKey: boolean): boolean {
if (engine === 'sweetistics') return true;
if (engine === 'graphql') return false;
return false; // auto: GraphQL primary, Sweetistics only as fallback
}
export function shouldAllowSweetisticsFallback(engine: EngineMode, hasApiKey: boolean): boolean {
return engine === 'auto' && hasApiKey;
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Extract bookmark folder (collection) ID from an X/Twitter bookmarks URL.
*/
const BOOKMARK_FOLDER_URL_REGEX = /(?:twitter\.com|x\.com)\/i\/bookmarks\/(\d+)/i;
const BOOKMARK_FOLDER_ID_REGEX = /^\d{5,}$/;
export function extractBookmarkFolderId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) {
return null;
}
const urlMatch = BOOKMARK_FOLDER_URL_REGEX.exec(trimmed);
if (urlMatch) {
return urlMatch[1];
}
if (BOOKMARK_FOLDER_ID_REGEX.test(trimmed)) {
return trimmed;
}
return null;
}
+20
View File
@@ -0,0 +1,20 @@
// ABOUTME: Extract list ID from an X/Twitter list URL.
// ABOUTME: Returns list ID or null if input is invalid.
const LIST_URL_REGEX = /(?:twitter\.com|x\.com)\/i\/lists\/(\d+)/i;
const LIST_ID_REGEX = /^\d{5,}$/;
export function extractListId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) {
return null;
}
const urlMatch = LIST_URL_REGEX.exec(trimmed);
if (urlMatch) {
return urlMatch[1];
}
if (LIST_ID_REGEX.test(trimmed)) {
return trimmed;
}
return null;
}
+3 -1
View File
@@ -1,9 +1,11 @@
/**
* Extract tweet ID from a Twitter/X URL or return the input unchanged if it's already an ID.
*/
const TWEET_URL_REGEX = /(?:twitter\.com|x\.com)\/(?:\w+\/status|i\/web\/status)\/(\d+)/i;
export function extractTweetId(input: string): string {
// If it's a URL, extract the tweet ID
const urlMatch = input.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/);
const urlMatch = TWEET_URL_REGEX.exec(input);
if (urlMatch) {
return urlMatch[1];
}
+5
View File
@@ -0,0 +1,5 @@
{
"global": {
"responsive_web_grok_annotations_enabled": false
}
}
+17
View File
@@ -0,0 +1,17 @@
export { runtimeQueryIds } from './runtime-query-ids.js';
export {
type CurrentUserResult,
type FollowingResult,
type GetTweetResult,
type SearchResult,
type TweetData,
TwitterClient,
type TwitterClientOptions,
type TwitterUser,
} from './twitter-client.js';
export type { HomeTimelineFetchOptions } from './twitter-client-home.js';
export type { ExploreTab, NewsFetchOptions, NewsItem, NewsResult } from './twitter-client-news.js';
export type { SearchFetchOptions } from './twitter-client-search.js';
export type { TimelineFetchOptions } from './twitter-client-timelines.js';
export type { TweetFetchOptions } from './twitter-client-tweet-detail.js';
export type { TweetResult, UploadMediaResult } from './twitter-client-types.js';
+14 -4
View File
@@ -1,13 +1,21 @@
const HANDLE_REGEX = /^[A-Za-z0-9_]{1,15}$/;
export function normalizeHandle(input?: string | null): string | null {
const raw = (input ?? '').trim();
if (!raw) return null;
if (!raw) {
return null;
}
const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw;
const handle = withoutAt.trim();
if (!handle) return null;
if (!handle) {
return null;
}
// X/Twitter handles are traditionally max 15 chars; keep strict to avoid surprising queries.
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null;
if (!HANDLE_REGEX.test(handle)) {
return null;
}
return handle;
}
@@ -16,7 +24,9 @@ export function mentionsQueryFromUserOption(userOption?: string | null): {
query: string | null;
error: string | null;
} {
if (typeof userOption === 'undefined') return { query: null, error: null };
if (typeof userOption === 'undefined') {
return { query: null, error: null };
}
const handle = normalizeHandle(userOption);
if (!handle) {
+48 -8
View File
@@ -2,6 +2,7 @@ export type OutputConfig = {
plain: boolean;
emoji: boolean;
color: boolean;
hyperlinks: boolean;
};
export type StatusKind = 'ok' | 'warn' | 'err' | 'info' | 'hint';
@@ -33,8 +34,9 @@ export function resolveOutputConfigFromArgv(argv: string[], env: NodeJS.ProcessE
const plain = argv.includes('--plain');
const emoji = !plain && !argv.includes('--no-emoji');
const color = !plain && !argv.includes('--no-color') && defaultColor;
const hyperlinks = !plain && isTty;
return { plain, emoji, color };
return { plain, emoji, color, hyperlinks };
}
export function resolveOutputConfigFromCommander(
@@ -48,19 +50,28 @@ export function resolveOutputConfigFromCommander(
const plain = Boolean(opts.plain);
const emoji = !plain && (opts.emoji ?? true);
const color = !plain && (opts.color ?? true) && defaultColor;
const hyperlinks = !plain && isTty;
return { plain, emoji, color };
return { plain, emoji, color, hyperlinks };
}
export function statusPrefix(kind: StatusKind, cfg: OutputConfig): string {
if (cfg.plain) return `${STATUS[kind].plain} `;
if (cfg.emoji) return `${STATUS[kind].emoji} `;
if (cfg.plain) {
return `${STATUS[kind].plain} `;
}
if (cfg.emoji) {
return `${STATUS[kind].emoji} `;
}
return `${STATUS[kind].text} `;
}
export function labelPrefix(kind: LabelKind, cfg: OutputConfig): string {
if (cfg.plain) return `${LABELS[kind].plain} `;
if (cfg.emoji) return `${LABELS[kind].emoji} `;
if (cfg.plain) {
return `${LABELS[kind].plain} `;
}
if (cfg.emoji) {
return `${LABELS[kind].emoji} `;
}
return `${LABELS[kind].text} `;
}
@@ -72,7 +83,36 @@ export function formatStatsLine(
const retweetCount = stats.retweetCount ?? 0;
const replyCount = stats.replyCount ?? 0;
if (cfg.plain) return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`;
if (!cfg.emoji) return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`;
if (cfg.plain) {
return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`;
}
if (!cfg.emoji) {
return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`;
}
return `❤️ ${likeCount} 🔁 ${retweetCount} 💬 ${replyCount}`;
}
export function formatTweetUrl(tweetId: string): string {
return `https://x.com/i/status/${tweetId}`;
}
/**
* Wraps a URL in OSC 8 escape sequences to make it clickable in supported terminals.
* Falls back to plain text when not in a TTY or when hyperlinks are disabled.
*/
export function hyperlink(url: string, text?: string, cfg?: OutputConfig): string {
const displayText = text ?? url;
// Only use hyperlinks when explicitly enabled (requires TTY and not plain mode)
if (!cfg?.hyperlinks) {
return displayText;
}
const safeUrl = url.replaceAll('\x1b', '').replaceAll('\x07', '');
const safeText = displayText.replaceAll('\x1b', '').replaceAll('\x07', '');
// OSC 8 hyperlink: \x1b]8;;URL\x07TEXT\x1b]8;;\x07
return `\x1b]8;;${safeUrl}\x07${safeText}\x1b]8;;\x07`;
}
export function formatTweetUrlLine(tweetId: string, cfg: OutputConfig): string {
const url = formatTweetUrl(tweetId);
return `${labelPrefix('url', cfg)}${hyperlink(url, url, cfg)}`;
}
+73
View File
@@ -0,0 +1,73 @@
export type CursorPage<T> =
| {
success: true;
items: T[];
cursor?: string;
}
| {
success: false;
error: string;
};
export type CursorPaginationResult<T> =
| {
success: true;
items: T[];
nextCursor?: string;
}
| {
success: false;
error: string;
items?: T[];
nextCursor?: string;
};
export async function paginateCursor<T>(opts: {
cursor?: string;
maxPages?: number;
pageDelayMs?: number;
sleep: (ms: number) => Promise<void>;
getKey: (item: T) => string;
fetchPage: (cursor?: string) => Promise<CursorPage<T>>;
}): Promise<CursorPaginationResult<T>> {
const { maxPages, pageDelayMs = 1000 } = opts;
const seen = new Set<string>();
const items: T[] = [];
let cursor: string | undefined = opts.cursor;
let pagesFetched = 0;
while (true) {
if (pagesFetched > 0 && pageDelayMs > 0) {
await opts.sleep(pageDelayMs);
}
const page = await opts.fetchPage(cursor);
if (!page.success) {
if (items.length > 0) {
return { success: false, error: page.error, items, nextCursor: cursor };
}
return page;
}
pagesFetched += 1;
for (const item of page.items) {
const key = opts.getKey(item);
if (seen.has(key)) {
continue;
}
seen.add(key);
items.push(item);
}
const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor) {
return { success: true, items, nextCursor: undefined };
}
if (maxPages !== undefined && pagesFetched >= maxPages) {
return { success: true, items, nextCursor: pageCursor };
}
cursor = pageCursor;
}
}
+13 -3
View File
@@ -1,7 +1,17 @@
{
"CreateTweet": "Uf3io9zVp1DsYxrmL5FJ7g",
"CreateTweet": "nmdAQXJDxw6-0KKF2on7eA",
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
"TweetDetail": "97JF30KziU00483E_8elBA",
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ"
"DeleteBookmark": "Wlmlj2-xzyS1GN3a6cj-mQ",
"TweetDetail": "_NvJCnIjOW__EP5-RF197A",
"SearchTimeline": "6AAys3t42mosm_yTI_QENg",
"Bookmarks": "RV1g3b8n_SGOHwkqKYSCFw",
"BookmarkFolderTimeline": "KJIQpsvxrTfRIlbaRIySHQ",
"Following": "mWYeougg_ocJS2Vr1Vt28w",
"Followers": "SFYY3WsgwjlXSLlfnEUE4A",
"Likes": "ETJflBunfqNa1uE1mBPCaw",
"ExploreSidebar": "lpSN4M6qpimkF4nRFPE3nQ",
"ExplorePage": "kheAINB_4pzRDqkzG3K-ng",
"GenericTimelineById": "uGSr7alSjR9v6QJAIaqSKQ",
"TrendHistory": "Sj4T-jSB9pr0Mxtsc1UKZQ"
}
+184
View File
@@ -0,0 +1,184 @@
import { existsSync, readFileSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import path from 'node:path';
// biome-ignore lint/correctness/useImportExtensions: JSON module import doesn't use .js extension.
import defaultOverrides from './features.json' with { type: 'json' };
export type FeatureOverrides = {
global?: Record<string, boolean>;
sets?: Record<string, Record<string, boolean>>;
};
type NormalizedFeatureOverrides = {
global: Record<string, boolean>;
sets: Record<string, Record<string, boolean>>;
};
export type FeatureOverridesSnapshot = {
cachePath: string;
overrides: FeatureOverrides;
};
const DEFAULT_CACHE_FILENAME = 'features.json';
let cachedOverrides: NormalizedFeatureOverrides | null = null;
function normalizeFeatureMap(value: unknown): Record<string, boolean> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const result: Record<string, boolean> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
if (typeof entry === 'boolean') {
result[key] = entry;
}
}
return result;
}
function normalizeOverrides(value: unknown): NormalizedFeatureOverrides {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { global: {}, sets: {} };
}
const record = value as Record<string, unknown>;
const global = normalizeFeatureMap(record.global);
const sets: Record<string, Record<string, boolean>> = {};
const rawSets =
record.sets && typeof record.sets === 'object' && !Array.isArray(record.sets)
? (record.sets as Record<string, unknown>)
: {};
for (const [setName, setValue] of Object.entries(rawSets)) {
const normalized = normalizeFeatureMap(setValue);
if (Object.keys(normalized).length > 0) {
sets[setName] = normalized;
}
}
return { global, sets };
}
function mergeOverrides(
base: NormalizedFeatureOverrides,
next: NormalizedFeatureOverrides,
): NormalizedFeatureOverrides {
const sets: Record<string, Record<string, boolean>> = { ...base.sets };
for (const [setName, overrides] of Object.entries(next.sets)) {
const existing = sets[setName];
sets[setName] = existing ? { ...existing, ...overrides } : { ...overrides };
}
return {
global: { ...base.global, ...next.global },
sets,
};
}
function toFeatureOverrides(overrides: NormalizedFeatureOverrides): FeatureOverrides {
const result: FeatureOverrides = {};
if (Object.keys(overrides.global).length > 0) {
result.global = overrides.global;
}
const setEntries = Object.entries(overrides.sets).filter(([, value]) => Object.keys(value).length > 0);
if (setEntries.length > 0) {
result.sets = Object.fromEntries(setEntries);
}
return result;
}
function resolveFeaturesCachePath(): string {
const override = process.env.BIRD_FEATURES_CACHE ?? process.env.BIRD_FEATURES_PATH;
if (override && override.trim().length > 0) {
return path.resolve(override.trim());
}
return path.join(homedir(), '.config', 'bird', DEFAULT_CACHE_FILENAME);
}
function readOverridesFromFile(cachePath: string): NormalizedFeatureOverrides | null {
if (!existsSync(cachePath)) {
return null;
}
try {
const raw = readFileSync(cachePath, 'utf8');
return normalizeOverrides(JSON.parse(raw));
} catch {
return null;
}
}
function readOverridesFromEnv(): NormalizedFeatureOverrides | null {
const raw = process.env.BIRD_FEATURES_JSON;
if (!raw || raw.trim().length === 0) {
return null;
}
try {
return normalizeOverrides(JSON.parse(raw));
} catch {
return null;
}
}
function writeOverridesToDisk(cachePath: string, overrides: NormalizedFeatureOverrides): Promise<void> {
const payload = toFeatureOverrides(overrides);
return mkdir(path.dirname(cachePath), { recursive: true }).then(() =>
writeFile(cachePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'),
);
}
export function loadFeatureOverrides(): NormalizedFeatureOverrides {
if (cachedOverrides) {
return cachedOverrides;
}
const base = normalizeOverrides(defaultOverrides);
const fromFile = readOverridesFromFile(resolveFeaturesCachePath());
const fromEnv = readOverridesFromEnv();
let merged = base;
if (fromFile) {
merged = mergeOverrides(merged, fromFile);
}
if (fromEnv) {
merged = mergeOverrides(merged, fromEnv);
}
cachedOverrides = merged;
return merged;
}
export function getFeatureOverridesSnapshot(): FeatureOverridesSnapshot {
const overrides = toFeatureOverrides(loadFeatureOverrides());
return {
cachePath: resolveFeaturesCachePath(),
overrides,
};
}
export function applyFeatureOverrides(setName: string, base: Record<string, boolean>): Record<string, boolean> {
const overrides = loadFeatureOverrides();
const globalOverrides = overrides.global;
const setOverrides = overrides.sets[setName];
if (Object.keys(globalOverrides).length === 0 && (!setOverrides || Object.keys(setOverrides).length === 0)) {
return base;
}
if (setOverrides) {
return {
...base,
...globalOverrides,
...setOverrides,
};
}
return {
...base,
...globalOverrides,
};
}
export async function refreshFeatureOverridesCache(): Promise<FeatureOverridesSnapshot> {
const cachePath = resolveFeaturesCachePath();
const base = normalizeOverrides(defaultOverrides);
const fromFile = readOverridesFromFile(cachePath);
const merged = mergeOverrides(base, fromFile ?? { global: {}, sets: {} });
await writeOverridesToDisk(cachePath, merged);
cachedOverrides = null;
return { cachePath, overrides: toFeatureOverrides(merged) };
}
export function clearFeatureOverridesCache(): void {
cachedOverrides = null;
}
+59 -21
View File
@@ -3,9 +3,9 @@ import { homedir } from 'node:os';
import path from 'node:path';
const DEFAULT_CACHE_FILENAME = 'query-ids-cache.json';
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
const DEFAULT_TTL_MS: number = 24 * 60 * 60 * 1000;
const DISCOVERY_PAGES = [
const DISCOVERY_PAGES: string[] = [
'https://x.com/?lang=en',
'https://x.com/explore',
'https://x.com/notifications',
@@ -13,6 +13,7 @@ const DISCOVERY_PAGES = [
];
const BUNDLE_URL_REGEX = /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g;
const QUERY_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
const OPERATION_PATTERNS = [
{
@@ -67,6 +68,15 @@ export type RuntimeQueryIdsOptions = {
fetchImpl?: typeof fetch;
};
export type RuntimeQueryIdStore = {
cachePath: string;
ttlMs: number;
getSnapshotInfo: () => Promise<RuntimeQueryIdSnapshotInfo | null>;
getQueryId: (operationName: string) => Promise<string | null>;
refresh: (operationNames: string[], opts?: { force?: boolean }) => Promise<RuntimeQueryIdSnapshotInfo | null>;
clearMemory: () => void;
};
async function fetchText(fetchImpl: typeof fetch, url: string): Promise<string> {
const response = await fetchImpl(url, { headers: HEADERS });
if (!response.ok) {
@@ -85,7 +95,9 @@ function resolveDefaultCachePath(): string {
}
function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
if (!raw || typeof raw !== 'object') return null;
if (!raw || typeof raw !== 'object') {
return null;
}
const record = raw as Record<string, unknown>;
const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null;
const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null;
@@ -93,10 +105,14 @@ function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
const discovery =
record.discovery && typeof record.discovery === 'object' ? (record.discovery as Record<string, unknown>) : null;
if (!fetchedAt || !ttlMs || !ids || !discovery) return null;
if (!fetchedAt || !ttlMs || !ids || !discovery) {
return null;
}
const pages = Array.isArray(discovery.pages) ? discovery.pages : null;
const bundles = Array.isArray(discovery.bundles) ? discovery.bundles : null;
if (!pages || !bundles) return null;
if (!pages || !bundles) {
return null;
}
const normalizedIds: Record<string, string> = {};
for (const [key, value] of Object.entries(ids)) {
@@ -120,9 +136,7 @@ async function readSnapshotFromDisk(cachePath: string): Promise<RuntimeQueryIdSn
try {
const raw = await readFile(cachePath, 'utf8');
return parseSnapshot(JSON.parse(raw));
} catch (error) {
const code = error && typeof error === 'object' ? String((error as { code?: unknown }).code ?? '') : '';
if (code !== 'ENOENT') return null;
} catch {
return null;
}
}
@@ -161,15 +175,27 @@ function extractOperations(
pattern.regex.lastIndex = 0;
while (true) {
const match = pattern.regex.exec(bundleContents);
if (match === null) break;
if (match === null) {
break;
}
const operationName = match[pattern.operationGroup];
const queryId = match[pattern.queryIdGroup];
if (!operationName || !queryId) continue;
if (!targets.has(operationName)) continue;
if (!/^[a-zA-Z0-9_-]+$/.test(queryId)) continue;
if (discovered.has(operationName)) continue;
if (!operationName || !queryId) {
continue;
}
if (!targets.has(operationName)) {
continue;
}
if (!QUERY_ID_REGEX.test(queryId)) {
continue;
}
if (discovered.has(operationName)) {
continue;
}
discovered.set(operationName, { queryId, bundle: bundleLabel });
if (discovered.size === targets.size) return;
if (discovered.size === targets.size) {
return;
}
}
}
}
@@ -186,7 +212,9 @@ async function fetchAndExtract(
const chunk = bundleUrls.slice(i, i + CONCURRENCY);
await Promise.all(
chunk.map(async (url) => {
if (discovered.size === targets.size) return;
if (discovered.size === targets.size) {
return;
}
const label = url.split('/').at(-1) ?? url;
try {
const js = await fetchText(fetchImpl, url);
@@ -196,13 +224,15 @@ async function fetchAndExtract(
}
}),
);
if (discovered.size === targets.size) break;
if (discovered.size === targets.size) {
break;
}
}
return discovered;
}
export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}) {
export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}): RuntimeQueryIdStore {
const fetchImpl = options.fetchImpl ?? fetch;
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath();
@@ -212,7 +242,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
let refreshInFlight: Promise<RuntimeQueryIdSnapshotInfo | null> | null = null;
const loadSnapshot = async (): Promise<RuntimeQueryIdSnapshot | null> => {
if (memorySnapshot) return memorySnapshot;
if (memorySnapshot) {
return memorySnapshot;
}
if (!loadOnce) {
loadOnce = (async () => {
const fromDisk = await readSnapshotFromDisk(cachePath);
@@ -225,7 +257,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
const getSnapshotInfo = async (): Promise<RuntimeQueryIdSnapshotInfo | null> => {
const snapshot = await loadSnapshot();
if (!snapshot) return null;
if (!snapshot) {
return null;
}
const fetchedAtMs = new Date(snapshot.fetchedAt).getTime();
const ageMs = Number.isFinite(fetchedAtMs) ? Math.max(0, Date.now() - fetchedAtMs) : Number.POSITIVE_INFINITY;
const effectiveTtl = Number.isFinite(snapshot.ttlMs) ? snapshot.ttlMs : ttlMs;
@@ -235,7 +269,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
const getQueryId = async (operationName: string): Promise<string | null> => {
const info = await getSnapshotInfo();
if (!info) return null;
if (!info) {
return null;
}
return info.snapshot.ids[operationName] ?? null;
};
@@ -243,7 +279,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
operationNames: string[],
opts: { force?: boolean } = {},
): Promise<RuntimeQueryIdSnapshotInfo | null> => {
if (refreshInFlight) return refreshInFlight;
if (refreshInFlight) {
return refreshInFlight;
}
refreshInFlight = (async () => {
const current = await getSnapshotInfo();
-537
View File
@@ -1,537 +0,0 @@
import { parse as devalueParse } from 'devalue';
export interface SweetisticsClientOptions {
baseUrl: string;
apiKey: string;
userAgent?: string;
timeoutMs?: number;
}
export interface SweetisticsTweetResult {
success: boolean;
tweetId?: string;
error?: string;
}
export interface SweetisticsTweet {
id: string;
text: string;
author: {
username: string;
name: string;
};
createdAt?: string;
replyCount?: number;
retweetCount?: number;
likeCount?: number;
conversationId?: string;
inReplyToStatusId?: string;
}
export interface SweetisticsTimelineResult {
success: boolean;
tweets?: SweetisticsTweet[];
error?: string;
}
export interface SweetisticsReadResult {
success: boolean;
tweet?: SweetisticsTweet;
error?: string;
}
export interface SweetisticsUserResult {
success: boolean;
user?: {
id: string;
username?: string | null;
name?: string | null;
email?: string | null;
profileImageUrl?: string | null;
};
error?: string;
}
type TrpcResponseEnvelope = { result?: { data?: unknown; error?: unknown } };
function normalizeBaseUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) return 'https://sweetistics.com';
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed;
}
export class SweetisticsClient {
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly userAgent?: string;
private readonly timeoutMs: number;
// Prevent hanging requests; keep Sweetistics calls snappy for CLI users.
private static readonly DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
constructor(options: SweetisticsClientOptions) {
this.baseUrl = normalizeBaseUrl(options.baseUrl);
this.apiKey = options.apiKey.trim();
this.userAgent = options.userAgent;
this.timeoutMs = options.timeoutMs ?? SweetisticsClient.DEFAULT_REQUEST_TIMEOUT_MS;
if (!this.apiKey) {
throw new Error('Sweetistics API key is required');
}
}
async tweet(text: string, replyToTweetId?: string, mediaIds?: string[]): Promise<SweetisticsTweetResult> {
const payload: Record<string, unknown> = { text };
if (replyToTweetId) {
payload.replyToTweetId = replyToTweetId;
}
if (mediaIds && mediaIds.length > 0) {
payload.mediaIds = mediaIds;
}
let response: Response;
try {
response = await this.fetchWithTimeout(`${this.baseUrl}/api/actions/tweet`, {
method: 'POST',
headers: {
authorization: `Bearer ${this.apiKey}`,
'content-type': 'application/json',
...(this.userAgent ? { 'user-agent': this.userAgent } : {}),
},
body: JSON.stringify(payload),
});
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
let data: unknown;
try {
data = await response.json();
} catch (error) {
return {
success: false,
error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
const success =
typeof (data as { success?: unknown })?.success === 'boolean' ? (data as { success: boolean }).success : false;
const tweetId =
typeof (data as { tweetId?: unknown })?.tweetId === 'string' ? (data as { tweetId?: string }).tweetId : undefined;
const errorMessage =
typeof (data as { error?: unknown })?.error === 'string' ? (data as { error?: string }).error : undefined;
if (!response.ok || !success) {
const reason = errorMessage || `HTTP ${response.status}`;
return { success: false, error: reason };
}
return { success: true, tweetId };
}
async read(tweetId: string): Promise<SweetisticsReadResult> {
// Public REST route that returns a single tweet record
const url = `${this.baseUrl}/api/tweets/${encodeURIComponent(tweetId)}`;
let response: Response;
try {
response = await this.fetchWithTimeout(url, {
headers: {
authorization: `Bearer ${this.apiKey}`,
...(this.userAgent ? { 'user-agent': this.userAgent } : {}),
},
});
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
if (!response.ok) {
return { success: false, error: `HTTP ${response.status}` };
}
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
let data: any;
try {
data = await response.json();
} catch (error) {
return {
success: false,
error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
if (!data?.id || !data?.author) {
return { success: false, error: 'Malformed tweet payload from Sweetistics' };
}
const tweet: SweetisticsTweet = {
id: String(data.id),
text: String(data.text ?? ''),
author: {
username: String(data.author.username ?? ''),
name: String(data.author.name ?? ''),
},
createdAt: data.createdAt,
replyCount: data.metrics?.replyCount,
retweetCount: data.metrics?.retweetCount,
likeCount: data.metrics?.likeCount,
conversationId: data.conversationId,
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
inReplyToStatusId: data.referencedTweets?.find?.((r: any) => r?.type === 'replied_to')?.id,
};
return { success: true, tweet };
}
async replies(tweetId: string): Promise<SweetisticsTimelineResult> {
return this.fetchConversation(tweetId, { excludeRoot: true });
}
async thread(tweetId: string): Promise<SweetisticsTimelineResult> {
return this.fetchConversation(tweetId, { excludeRoot: false });
}
async search(query: string, count: number): Promise<SweetisticsTimelineResult> {
const payload = {
query,
capabilities: [
{
resource: 'tweets',
source: 'postgres',
limit: Math.max(1, Math.min(count, 50)),
offset: 0,
detail: 'summary',
includeRetweets: true,
},
],
};
let response: Response;
try {
response = await this.fetchWithTimeout(`${this.baseUrl}/api/trpc/search.execute?batch=1`, {
method: 'POST',
headers: {
authorization: `Bearer ${this.apiKey}`,
'content-type': 'application/json',
...(this.userAgent ? { 'user-agent': this.userAgent } : {}),
},
body: JSON.stringify({ 0: { json: payload } }),
});
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
if (!response.ok) {
return { success: false, error: `HTTP ${response.status}` };
}
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
let body: any;
try {
body = await response.json();
} catch (error) {
return {
success: false,
error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
const envelope = Array.isArray(body) ? body[0] : body;
if (envelope?.error) {
const message = envelope.error?.message ?? 'Unknown search error';
return { success: false, error: message };
}
const dataField = envelope?.result?.data;
if (!dataField) {
return { success: false, error: 'Missing data in Sweetistics search response' };
}
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
let parsed: any;
try {
parsed = typeof dataField === 'string' ? devalueParse(dataField) : dataField;
} catch (error) {
return {
success: false,
error: `Sweetistics search decode failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
const tweetsSection = parsed?.tweets;
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
const items: any[] = tweetsSection?.items ?? [];
if (items.length === 0) {
return { success: true, tweets: [] };
}
const tweets: SweetisticsTweet[] = items.map((item) => {
const authorUsername = item.authorUsername || item.username || item.full?.author?.username || '';
const authorName = item.authorName || item.full?.author?.name || authorUsername;
const createdAt =
typeof item.createdAt === 'string'
? item.createdAt
: (item.createdAt?.toString?.() ??
(Array.isArray(item.createdAt) && item.createdAt[0] === 'Date' ? item.createdAt[1] : undefined));
const metrics = item.full?.metrics ?? item.full ?? item;
return {
id: String(item.id),
text: String(item.text ?? item.full?.text ?? ''),
author: { username: String(authorUsername), name: String(authorName) },
createdAt,
replyCount: metrics?.replyCount ?? metrics?.replies ?? 0,
retweetCount: metrics?.retweetCount ?? metrics?.retweets ?? 0,
likeCount: metrics?.likeCount ?? metrics?.likes ?? 0,
conversationId: item.conversationId ?? item.full?.conversationId,
inReplyToStatusId: item.full?.inReplyToStatusId ?? undefined,
};
});
return { success: true, tweets };
}
async getCurrentUser(): Promise<SweetisticsUserResult> {
const headers = {
authorization: `Bearer ${this.apiKey}`,
'content-type': 'application/json',
...(this.userAgent ? { 'user-agent': this.userAgent } : {}),
} satisfies Record<string, string>;
let response: Response;
try {
response = await this.fetchWithTimeout(`${this.baseUrl}/api/trpc/user.getCurrent?batch=1`, {
method: 'POST',
headers,
body: JSON.stringify({ 0: { json: null } }),
});
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
// Some deployments only allow GET for queries; fall back if POST is not allowed
if (response.status === 405) {
try {
response = await this.fetchWithTimeout(
`${this.baseUrl}/api/trpc/user.getCurrent?input=${encodeURIComponent('null')}`,
{
method: 'GET',
headers,
},
);
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
}
if (!response.ok) {
return { success: false, error: `HTTP ${response.status}` };
}
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
let body: any;
try {
body = await response.json();
} catch (error) {
return {
success: false,
error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
const envelope = Array.isArray(body) ? body[0] : body;
const maybeError = envelope?.error?.message || envelope?.result?.error?.message;
if (maybeError) {
return { success: false, error: String(maybeError) };
}
let data = envelope?.result?.data?.json ?? envelope?.result?.data ?? envelope?.json ?? envelope;
try {
if (typeof data === 'string') {
data = devalueParse(data);
} else if (data && typeof data === 'object' && 'json' in (data as Record<string, unknown>)) {
const inner = (data as Record<string, unknown>).json;
data = typeof inner === 'string' ? devalueParse(inner) : inner;
}
} catch (error) {
return {
success: false,
error: `Sweetistics response decode failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
const user = data && typeof data === 'object' ? data : null;
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
if (!user || typeof (user as any).id !== 'string') {
return { success: false, error: 'Malformed user payload from Sweetistics' };
}
return {
success: true,
user: {
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
id: String((user as any).id),
username:
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
(user as any).username ?? (user as any).twitterUsername ?? null,
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
name: (user as any).name ?? null,
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
email: (user as any).email ?? null,
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
profileImageUrl: (user as any).profileImageUrl ?? null,
},
};
}
private async fetchConversation(
tweetId: string,
options: { excludeRoot: boolean; force?: boolean } = { excludeRoot: false, force: true },
): Promise<SweetisticsTimelineResult> {
const url = new URL(`${this.baseUrl}/api/trpc/tweets.getConversation`);
const input: Record<string, unknown> = { tweetId };
// Always request a fresh conversation to avoid cached Sweetistics data unless explicitly disabled
if (options.force ?? true) {
input.force = 'true';
}
url.searchParams.set('input', JSON.stringify(input));
let response: Response;
try {
response = await this.fetchWithTimeout(url.toString(), {
headers: {
authorization: `Bearer ${this.apiKey}`,
...(this.userAgent ? { 'user-agent': this.userAgent } : {}),
},
});
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
if (!response.ok) {
return { success: false, error: `HTTP ${response.status}` };
}
let envelope: TrpcResponseEnvelope;
try {
envelope = (await response.json()) as TrpcResponseEnvelope;
} catch (error) {
return {
success: false,
error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
if (!envelope?.result?.data) {
return { success: false, error: 'Missing data in Sweetistics response' };
}
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
let parsed: any;
try {
const raw =
typeof envelope.result.data === 'string' ? devalueParse(envelope.result.data as string) : envelope.result.data;
parsed = raw;
} catch (error) {
return {
success: false,
error: `Sweetistics response decode failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
const tweetIds: string[] = Array.isArray(parsed?.tweetIds) ? parsed.tweetIds.map(String) : [];
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
const tweetsMap: Record<string, any> = parsed?.tweets ?? {};
if (tweetIds.length === 0 || Object.keys(tweetsMap).length === 0) {
return { success: false, error: 'Conversation empty or unavailable' };
}
const rootId = tweetId;
const filteredIds = options.excludeRoot ? tweetIds.filter((id) => id !== rootId) : tweetIds;
const tweets: SweetisticsTweet[] = filteredIds
.map((id) => tweetsMap[id])
.filter(Boolean)
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
.map((item: any) => ({
id: String(item.id),
text: String(item.text ?? ''),
author: {
username: String(item.author?.username ?? ''),
name: String(item.author?.name ?? ''),
},
createdAt: item.createdAt ?? item.timestamp ?? undefined,
replyCount: item.replyCount ?? item.metrics?.replyCount,
retweetCount: item.retweetCount ?? item.metrics?.retweetCount,
likeCount: item.likeCount ?? item.metrics?.likeCount,
conversationId: item.conversationId ?? undefined,
inReplyToStatusId: item.inReplyToStatusId ?? undefined,
}));
return { success: true, tweets };
}
async uploadMedia(input: {
data: string;
mimeType: string;
alt?: string;
}): Promise<{ success: boolean; mediaId?: string; error?: string }> {
try {
const response = await this.fetchWithTimeout(`${this.baseUrl}/api/actions/media/upload`, {
method: 'POST',
headers: {
authorization: `Bearer ${this.apiKey}`,
'content-type': 'application/json',
...(this.userAgent ? { 'user-agent': this.userAgent } : {}),
},
body: JSON.stringify(input),
});
let body: unknown;
try {
body = await response.json();
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
const mediaId =
typeof (body as { mediaId?: unknown })?.mediaId === 'string'
? (body as { mediaId: string }).mediaId
: undefined;
const errorMessage =
typeof (body as { error?: unknown })?.error === 'string' ? (body as { error: string }).error : undefined;
const errorCode =
typeof (body as { code?: unknown })?.code === 'string' ? (body as { code: string }).code : undefined;
if (!response.ok || !mediaId) {
const details = [errorCode, errorMessage].filter(Boolean).join(': ');
return { success: false, error: details.length > 0 ? details : `HTTP ${response.status}` };
}
return { success: true, mediaId };
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
}
private async fetchWithTimeout(url: string, init?: RequestInit): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(new Error(`Request timed out after ${this.timeoutMs}ms`)),
this.timeoutMs,
);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
private normalizeError(error: unknown): string {
if (error instanceof Error) {
if ((error as { name?: string }).name === 'AbortError') {
return 'Request timed out';
}
return error.message;
}
return String(error);
}
}
+115
View File
@@ -0,0 +1,115 @@
import { runtimeQueryIds } from './runtime-query-ids.js';
import { type OperationName, QUERY_IDS, TARGET_QUERY_ID_OPERATIONS } from './twitter-client-constants.js';
import type { CurrentUserResult, TwitterClientOptions } from './twitter-client-types.js';
import { normalizeQuoteDepth } from './twitter-client-utils.js';
const TRAILING_SLASHES = /\/+$/;
// biome-ignore lint/suspicious/noExplicitAny: TS mixin base constructor requirement.
export type Constructor<T = object> = new (...args: any[]) => T;
// biome-ignore lint/suspicious/noExplicitAny: TS mixin base constructor requirement.
export type AbstractConstructor<T = object> = abstract new (...args: any[]) => T;
export type Mixin<TBase extends AbstractConstructor<TwitterClientBase>, TAdded> = abstract new (
...args: ConstructorParameters<TBase>
) => TwitterClientBase & TAdded;
export abstract class TwitterClientBase {
protected relayBaseUrl: string;
protected timeoutMs?: number;
protected quoteDepth: number;
protected clientUserId?: string;
constructor(options: TwitterClientOptions) {
this.relayBaseUrl = this.normalizeRelayBaseUrl(options.relayBaseUrl ?? process.env.TWITTER_RELAY_BASE_URL);
this.timeoutMs = options.timeoutMs;
this.quoteDepth = normalizeQuoteDepth(options.quoteDepth);
}
protected abstract getCurrentUser(): Promise<CurrentUserResult>;
protected async sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
protected async getQueryId(operationName: OperationName): Promise<string> {
const cached = await runtimeQueryIds.getQueryId(operationName);
return cached ?? QUERY_IDS[operationName];
}
protected async refreshQueryIds(): Promise<void> {
if (process.env.NODE_ENV === 'test') {
return;
}
try {
await runtimeQueryIds.refresh(TARGET_QUERY_ID_OPERATIONS, { force: true });
} catch {
// ignore refresh failures; callers will fall back to baked-in IDs
}
}
protected async getTweetDetailQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('TweetDetail');
return Array.from(new Set([primary, '97JF30KziU00483E_8elBA', 'aFvUsJm2c-oDkJV75blV6g']));
}
protected async getSearchTimelineQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('SearchTimeline');
return Array.from(new Set([primary, 'M1jEez78PEfVfbQLvlWMvQ', '5h0kNbk3ii97rmfY6CdgAA', 'Tp1sewRU1AsZpBWhqCZicQ']));
}
protected async fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
const relayUrl = this.toRelayUrl(url);
if (!this.timeoutMs || this.timeoutMs <= 0) {
return fetch(relayUrl, init);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
try {
return await fetch(relayUrl, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
protected getHeaders(): Record<string, string> {
return {};
}
private normalizeRelayBaseUrl(value: string | undefined): string {
const trimmed = value?.trim();
if (!trimmed) {
throw new Error('TWITTER_RELAY_BASE_URL is required');
}
return trimmed.replace(TRAILING_SLASHES, '');
}
private toRelayUrl(url: string): string {
const parsed = new URL(url);
if (!['x.com', 'twitter.com', 'api.twitter.com', 'upload.twitter.com'].includes(parsed.hostname)) {
throw new Error(`Unsupported Twitter API host: ${parsed.hostname}`);
}
return `${this.relayBaseUrl}${parsed.pathname}${parsed.search}`;
}
protected getJsonHeaders(): Record<string, string> {
return { 'content-type': 'application/json' };
}
protected getUploadHeaders(): Record<string, string> {
return {};
}
protected async ensureClientUserId(): Promise<void> {
if (process.env.NODE_ENV === 'test') {
return;
}
if (this.clientUserId) {
return;
}
const result = await this.getCurrentUser();
if (result.success && result.user?.id) {
this.clientUserId = result.user.id;
}
}
}
+79
View File
@@ -0,0 +1,79 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE, TWITTER_GRAPHQL_POST_URL } from './twitter-client-constants.js';
import type { BookmarkMutationResult } from './twitter-client-types.js';
export interface TwitterClientBookmarkMethods {
unbookmark(tweetId: string): Promise<BookmarkMutationResult>;
}
export function withBookmarks<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientBookmarkMethods> {
abstract class TwitterClientBookmarks extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
async unbookmark(tweetId: string): Promise<BookmarkMutationResult> {
// TODO: verify if DeleteBookmark requires client user ID or additional payload fields; add ensureClientUserId() if needed (needs live API test).
const variables = { tweet_id: tweetId };
let queryId = await this.getQueryId('DeleteBookmark');
let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/DeleteBookmark`;
const buildBody = () => JSON.stringify({ variables, queryId });
let body = buildBody();
const parseResponse = async (response: Response): Promise<BookmarkMutationResult> => {
if (!response.ok) {
const text = await response.text();
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
}
const data = (await response.json()) as { errors?: Array<{ message: string }> };
if (data.errors && data.errors.length > 0) {
return { success: false, error: data.errors.map((e) => e.message).join(', ') };
}
return { success: true };
};
try {
let response = await this.fetchWithTimeout(urlWithOperation, {
method: 'POST',
headers: this.getJsonHeaders(),
body,
});
if (response.status === 404) {
await this.refreshQueryIds();
queryId = await this.getQueryId('DeleteBookmark');
urlWithOperation = `${TWITTER_API_BASE}/${queryId}/DeleteBookmark`;
body = buildBody();
response = await this.fetchWithTimeout(urlWithOperation, {
method: 'POST',
headers: this.getJsonHeaders(),
body,
});
if (response.status === 404) {
const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, {
method: 'POST',
headers: this.getJsonHeaders(),
body,
});
return parseResponse(retry);
}
}
return parseResponse(response);
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
}
return TwitterClientBookmarks;
}
+45
View File
@@ -0,0 +1,45 @@
// biome-ignore lint/correctness/useImportExtensions: JSON module import doesn't use .js extension.
import queryIds from './query-ids.json' with { type: 'json' };
export const TWITTER_API_BASE = 'https://x.com/i/api/graphql';
export const TWITTER_GRAPHQL_POST_URL = 'https://x.com/i/api/graphql';
export const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/i/media/upload.json';
export const TWITTER_MEDIA_METADATA_URL = 'https://x.com/i/api/1.1/media/metadata/create.json';
export const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/update.json';
// Query IDs rotate frequently; the values in query-ids.json are refreshed by
// scripts/update-query-ids.ts. The fallback values keep the client usable if
// the file is missing or incomplete.
export const FALLBACK_QUERY_IDS = {
CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg',
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
DeleteBookmark: 'Wlmlj2-xzyS1GN3a6cj-mQ',
TweetDetail: '97JF30KziU00483E_8elBA',
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
UserTweets: 'Wms1GvIiHXAPBaCr9KblaA',
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
Following: 'BEkNpEt5pNETESoqMsTEGA',
Followers: 'kuFUYP9eV1FPoEy4N-pi7w',
Likes: 'JR2gceKucIKcVNB_9JkhsA',
BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ',
ListOwnerships: 'wQcOSjSQ8NtgxIwvYl1lMg',
ListMemberships: 'BlEXXdARdSeL_0KyKHHvvg',
ListLatestTweetsTimeline: '2TemLyqrMpTeAmysdbnVqw',
ListByRestId: 'wXzyA5vM_aVkBL9G8Vp3kw',
HomeTimeline: 'edseUwk9sP5Phz__9TIRnA',
HomeLatestTimeline: 'iOEZpOdfekFsxSlPQCQtPg',
ExploreSidebar: 'lpSN4M6qpimkF4nRFPE3nQ',
ExplorePage: 'kheAINB_4pzRDqkzG3K-ng',
GenericTimelineById: 'uGSr7alSjR9v6QJAIaqSKQ',
TrendHistory: 'Sj4T-jSB9pr0Mxtsc1UKZQ',
} as const;
export type OperationName = keyof typeof FALLBACK_QUERY_IDS;
export const QUERY_IDS: Record<OperationName, string> = {
...FALLBACK_QUERY_IDS,
...(queryIds as Partial<Record<OperationName, string>>),
};
export const TARGET_QUERY_ID_OPERATIONS = Object.keys(FALLBACK_QUERY_IDS) as Array<OperationName>;
+359
View File
@@ -0,0 +1,359 @@
import { applyFeatureOverrides } from './runtime-features.js';
export function buildArticleFeatures(): Record<string, boolean> {
return applyFeatureOverrides('article', {
rweb_video_screen_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: true,
rweb_tipjar_consumption_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_grok_annotations_enabled: false,
responsive_web_jetfuel_frame: true,
post_ctas_fetch_enabled: true,
responsive_web_grok_share_attachment_enabled: true,
articles_preview_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
responsive_web_grok_show_grok_translated_post: false,
responsive_web_grok_analysis_button_from_backend: true,
creator_subscriptions_quote_tweet_preview_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_grok_image_annotation_enabled: true,
responsive_web_grok_imagine_annotation_enabled: true,
responsive_web_grok_community_note_auto_translation_is_enabled: false,
responsive_web_enhance_cards_enabled: false,
});
}
export function buildTweetDetailFeatures(): Record<string, boolean> {
return applyFeatureOverrides('tweetDetail', {
...buildArticleFeatures(),
responsive_web_graphql_exclude_directive_enabled: true,
communities_web_enable_tweet_community_results_fetch: true,
responsive_web_twitter_article_plain_text_enabled: true,
responsive_web_twitter_article_seed_tweet_detail_enabled: true,
responsive_web_twitter_article_seed_tweet_summary_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
tweet_awards_web_tipping_enabled: false,
creator_subscriptions_quote_tweet_preview_enabled: false,
verified_phone_label_enabled: false,
});
}
export function buildArticleFieldToggles(): Record<string, boolean> {
return {
withPayments: false,
withAuxiliaryUserLabels: false,
withArticleRichContentState: true,
withArticlePlainText: true,
withGrokAnalyze: false,
withDisallowedReplyControls: false,
};
}
export function buildSearchFeatures(): Record<string, boolean> {
return applyFeatureOverrides('search', {
rweb_video_screen_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: true,
rweb_tipjar_consumption_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_grok_annotations_enabled: false,
responsive_web_jetfuel_frame: true,
post_ctas_fetch_enabled: true,
responsive_web_grok_share_attachment_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
responsive_web_grok_show_grok_translated_post: false,
responsive_web_grok_analysis_button_from_backend: true,
creator_subscriptions_quote_tweet_preview_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
rweb_video_timestamps_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_grok_image_annotation_enabled: true,
responsive_web_grok_imagine_annotation_enabled: true,
responsive_web_grok_community_note_auto_translation_is_enabled: false,
articles_preview_enabled: true,
responsive_web_enhance_cards_enabled: false,
});
}
export function buildTweetCreateFeatures(): Record<string, boolean> {
return applyFeatureOverrides('tweetCreate', {
rweb_video_screen_enabled: true,
creator_subscriptions_tweet_preview_api_enabled: true,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_grok_annotations_enabled: false,
responsive_web_jetfuel_frame: true,
post_ctas_fetch_enabled: true,
responsive_web_grok_share_attachment_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
responsive_web_grok_show_grok_translated_post: false,
responsive_web_grok_analysis_button_from_backend: true,
creator_subscriptions_quote_tweet_preview_enabled: false,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: false,
rweb_tipjar_consumption_enabled: true,
verified_phone_label_enabled: false,
articles_preview_enabled: true,
responsive_web_grok_community_note_auto_translation_is_enabled: false,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
responsive_web_grok_image_annotation_enabled: true,
responsive_web_grok_imagine_annotation_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_enhance_cards_enabled: false,
});
}
export function buildTimelineFeatures(): Record<string, boolean> {
return applyFeatureOverrides('timeline', {
...buildSearchFeatures(),
blue_business_profile_image_shape_enabled: true,
responsive_web_text_conversations_enabled: false,
tweetypie_unmention_optimization_enabled: true,
vibe_api_enabled: true,
responsive_web_twitter_blue_verified_badge_is_enabled: true,
interactive_text_enabled: true,
longform_notetweets_richtext_consumption_enabled: true,
responsive_web_media_download_video_enabled: false,
});
}
export function buildBookmarksFeatures(): Record<string, boolean> {
return applyFeatureOverrides('bookmarks', {
...buildTimelineFeatures(),
graphql_timeline_v2_bookmark_timeline: true,
});
}
export function buildLikesFeatures(): Record<string, boolean> {
return applyFeatureOverrides('likes', buildTimelineFeatures());
}
export function buildListsFeatures(): Record<string, boolean> {
return applyFeatureOverrides('lists', {
rweb_video_screen_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: true,
rweb_tipjar_consumption_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_grok_annotations_enabled: false,
responsive_web_jetfuel_frame: true,
post_ctas_fetch_enabled: true,
responsive_web_grok_share_attachment_enabled: true,
articles_preview_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
responsive_web_grok_show_grok_translated_post: false,
responsive_web_grok_analysis_button_from_backend: true,
creator_subscriptions_quote_tweet_preview_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_grok_image_annotation_enabled: true,
responsive_web_grok_imagine_annotation_enabled: true,
responsive_web_grok_community_note_auto_translation_is_enabled: false,
responsive_web_enhance_cards_enabled: false,
blue_business_profile_image_shape_enabled: false,
responsive_web_text_conversations_enabled: false,
tweetypie_unmention_optimization_enabled: true,
vibe_api_enabled: false,
interactive_text_enabled: false,
});
}
export function buildHomeTimelineFeatures(): Record<string, boolean> {
return applyFeatureOverrides('homeTimeline', {
...buildTimelineFeatures(),
});
}
export function buildUserTweetsFeatures(): Record<string, boolean> {
return applyFeatureOverrides('userTweets', {
rweb_video_screen_enabled: false,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: false,
rweb_tipjar_consumption_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: true,
responsive_web_jetfuel_frame: true,
post_ctas_fetch_enabled: true,
responsive_web_grok_share_attachment_enabled: true,
responsive_web_grok_annotations_enabled: false,
articles_preview_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
responsive_web_grok_show_grok_translated_post: true,
responsive_web_grok_analysis_button_from_backend: true,
creator_subscriptions_quote_tweet_preview_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_grok_image_annotation_enabled: true,
responsive_web_grok_imagine_annotation_enabled: true,
responsive_web_grok_community_note_auto_translation_is_enabled: false,
responsive_web_enhance_cards_enabled: false,
});
}
export function buildFollowingFeatures(): Record<string, boolean> {
return applyFeatureOverrides('following', {
rweb_video_screen_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: false,
responsive_web_profile_redirect_enabled: true,
rweb_tipjar_consumption_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: true,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_grok_annotations_enabled: false,
responsive_web_jetfuel_frame: false,
post_ctas_fetch_enabled: true,
responsive_web_grok_share_attachment_enabled: false,
articles_preview_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: true,
responsive_web_grok_show_grok_translated_post: false,
responsive_web_grok_analysis_button_from_backend: false,
creator_subscriptions_quote_tweet_preview_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_grok_image_annotation_enabled: false,
responsive_web_grok_imagine_annotation_enabled: false,
responsive_web_grok_community_note_auto_translation_is_enabled: false,
responsive_web_enhance_cards_enabled: false,
});
}
export function buildExploreFeatures(): Record<string, boolean> {
return applyFeatureOverrides('explore', {
rweb_video_screen_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: true,
rweb_tipjar_consumption_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: true,
responsive_web_grok_analyze_post_followups_enabled: true,
responsive_web_grok_annotations_enabled: true,
responsive_web_jetfuel_frame: true,
responsive_web_grok_share_attachment_enabled: true,
articles_preview_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
responsive_web_grok_show_grok_translated_post: true,
responsive_web_grok_analysis_button_from_backend: true,
creator_subscriptions_quote_tweet_preview_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_grok_image_annotation_enabled: true,
responsive_web_grok_imagine_annotation_enabled: true,
responsive_web_grok_community_note_auto_translation_is_enabled: true,
responsive_web_enhance_cards_enabled: false,
// Additional features required for ExploreSidebar
post_ctas_fetch_enabled: true,
rweb_video_timestamps_enabled: true,
});
}
+201
View File
@@ -0,0 +1,201 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildHomeTimelineFeatures } from './twitter-client-features.js';
import type { GraphqlTweetResult, SearchResult, TweetData } from './twitter-client-types.js';
import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js';
const QUERY_UNSPECIFIED_REGEX = /query:\s*unspecified/i;
function isQueryIdMismatch(errors: Array<{ message?: string }>): boolean {
return errors.some((error) => QUERY_UNSPECIFIED_REGEX.test(error.message ?? ''));
}
/** Options for home timeline fetch methods */
export interface HomeTimelineFetchOptions {
/** Include raw GraphQL response in `_raw` field */
includeRaw?: boolean;
}
export interface TwitterClientHomeMethods {
getHomeTimeline(count?: number, options?: HomeTimelineFetchOptions): Promise<SearchResult>;
getHomeLatestTimeline(count?: number, options?: HomeTimelineFetchOptions): Promise<SearchResult>;
}
export function withHome<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientHomeMethods> {
abstract class TwitterClientHome extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
private async getHomeTimelineQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('HomeTimeline');
return Array.from(new Set([primary, 'edseUwk9sP5Phz__9TIRnA']));
}
private async getHomeLatestTimelineQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('HomeLatestTimeline');
return Array.from(new Set([primary, 'iOEZpOdfekFsxSlPQCQtPg']));
}
/**
* Get the authenticated user's "For You" home timeline
*/
async getHomeTimeline(count = 20, options: HomeTimelineFetchOptions = {}): Promise<SearchResult> {
return this.fetchHomeTimeline('HomeTimeline', count, options);
}
/**
* Get the authenticated user's "Following" (latest/chronological) home timeline
*/
async getHomeLatestTimeline(count = 20, options: HomeTimelineFetchOptions = {}): Promise<SearchResult> {
return this.fetchHomeTimeline('HomeLatestTimeline', count, options);
}
private async fetchHomeTimeline(
operation: 'HomeTimeline' | 'HomeLatestTimeline',
count: number,
options: HomeTimelineFetchOptions,
): Promise<SearchResult> {
const { includeRaw = false } = options;
const features = buildHomeTimelineFeatures();
const pageSize = 20;
const seen = new Set<string>();
const tweets: TweetData[] = [];
let cursor: string | undefined;
const fetchPage = async (pageCount: number, pageCursor?: string) => {
let lastError: string | undefined;
let had404 = false;
const queryIds =
operation === 'HomeTimeline'
? await this.getHomeTimelineQueryIds()
: await this.getHomeLatestTimelineQueryIds();
for (const queryId of queryIds) {
const variables = {
count: pageCount,
includePromotedContent: true,
latestControlAvailable: true,
requestContext: 'launch',
withCommunity: true,
...(pageCursor ? { cursor: pageCursor } : {}),
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const url = `${TWITTER_API_BASE}/${queryId}/${operation}?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
home?: {
home_timeline_urt?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
const errorMessage = data.errors.map((e) => e.message).join(', ');
return {
success: false as const,
error: errorMessage,
had404: had404 || isQueryIdMismatch(data.errors),
};
}
const instructions = data.data?.home?.home_timeline_urt?.instructions;
const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const nextCursor = extractCursorFromInstructions(instructions);
return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching home timeline', had404 };
};
const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => {
const firstAttempt = await fetchPage(pageCount, pageCursor);
if (firstAttempt.success) {
return firstAttempt;
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await fetchPage(pageCount, pageCursor);
if (secondAttempt.success) {
return secondAttempt;
}
return { success: false as const, error: secondAttempt.error };
}
return { success: false as const, error: firstAttempt.error };
};
while (tweets.length < count) {
const pageCount = Math.min(pageSize, count - tweets.length);
const page = await fetchWithRefresh(pageCount, cursor);
if (!page.success) {
return { success: false, error: page.error };
}
let added = 0;
for (const tweet of page.tweets) {
if (seen.has(tweet.id)) {
continue;
}
seen.add(tweet.id);
tweets.push(tweet);
added += 1;
if (tweets.length >= count) {
break;
}
}
if (!page.cursor || page.cursor === cursor || page.tweets.length === 0 || added === 0) {
break;
}
cursor = page.cursor;
}
return { success: true, tweets };
}
}
return TwitterClientHome;
}
+492
View File
@@ -0,0 +1,492 @@
// ABOUTME: Mixin for Twitter Lists GraphQL operations.
// ABOUTME: Provides methods to fetch user's owned lists, memberships, and list timelines.
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildListsFeatures } from './twitter-client-features.js';
import type { TimelineFetchOptions, TimelinePaginationOptions } from './twitter-client-timelines.js';
import type { GraphqlTweetResult, ListsResult, SearchResult, TweetData, TwitterList } from './twitter-client-types.js';
import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js';
export interface TwitterClientListMethods {
getOwnedLists(count?: number): Promise<ListsResult>;
getListMemberships(count?: number): Promise<ListsResult>;
getListTimeline(listId: string, count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
getAllListTimeline(listId: string, options?: TimelinePaginationOptions): Promise<SearchResult>;
}
interface GraphqlListResult {
id_str?: string;
name?: string;
description?: string;
member_count?: number;
subscriber_count?: number;
mode?: string;
created_at?: string;
user_results?: {
result?: {
rest_id?: string;
legacy?: {
screen_name?: string;
name?: string;
};
};
};
}
function parseList(listResult: GraphqlListResult): TwitterList | null {
if (!listResult.id_str || !listResult.name) {
return null;
}
const owner = listResult.user_results?.result;
return {
id: listResult.id_str,
name: listResult.name,
description: listResult.description,
memberCount: listResult.member_count,
subscriberCount: listResult.subscriber_count,
isPrivate: listResult.mode?.toLowerCase() === 'private',
createdAt: listResult.created_at,
owner: owner
? {
id: owner.rest_id ?? '',
username: owner.legacy?.screen_name ?? '',
name: owner.legacy?.name ?? '',
}
: undefined,
};
}
function parseListsFromInstructions(
instructions:
| Array<{
entries?: Array<{
content?: {
itemContent?: {
list?: GraphqlListResult;
};
};
}>;
}>
| undefined,
): TwitterList[] {
const lists: TwitterList[] = [];
if (!instructions) {
return lists;
}
for (const instruction of instructions) {
if (!instruction.entries) {
continue;
}
for (const entry of instruction.entries) {
const listResult = entry.content?.itemContent?.list;
if (listResult) {
const parsed = parseList(listResult);
if (parsed) {
lists.push(parsed);
}
}
}
}
return lists;
}
export function withLists<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientListMethods> {
abstract class TwitterClientLists extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
private async getListOwnershipsQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('ListOwnerships');
return Array.from(new Set([primary, 'wQcOSjSQ8NtgxIwvYl1lMg']));
}
private async getListMembershipsQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('ListMemberships');
return Array.from(new Set([primary, 'BlEXXdARdSeL_0KyKHHvvg']));
}
private async getListTimelineQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('ListLatestTweetsTimeline');
return Array.from(new Set([primary, '2TemLyqrMpTeAmysdbnVqw']));
}
/**
* Get lists owned by the authenticated user
*/
async getOwnedLists(count = 100): Promise<ListsResult> {
const userResult = await this.getCurrentUser();
if (!userResult.success || !userResult.user) {
return { success: false, error: userResult.error ?? 'Could not determine current user' };
}
const variables = {
userId: userResult.user.id,
count,
isListMembershipShown: true,
isListMemberTargetUserId: userResult.user.id,
};
const features = buildListsFeatures();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const tryOnce = async () => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getListOwnershipsQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/ListOwnerships?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
list?: GraphqlListResult;
};
};
}>;
}>;
};
};
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const lists = parseListsFromInstructions(instructions);
return { success: true as const, lists, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching owned lists', had404 };
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, lists: firstAttempt.lists };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, lists: secondAttempt.lists };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
/**
* Get lists the authenticated user is a member of
*/
async getListMemberships(count = 100): Promise<ListsResult> {
const userResult = await this.getCurrentUser();
if (!userResult.success || !userResult.user) {
return { success: false, error: userResult.error ?? 'Could not determine current user' };
}
const variables = {
userId: userResult.user.id,
count,
isListMembershipShown: true,
isListMemberTargetUserId: userResult.user.id,
};
const features = buildListsFeatures();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const tryOnce = async () => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getListMembershipsQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/ListMemberships?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
list?: GraphqlListResult;
};
};
}>;
}>;
};
};
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const lists = parseListsFromInstructions(instructions);
return { success: true as const, lists, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching list memberships', had404 };
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, lists: firstAttempt.lists };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, lists: secondAttempt.lists };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
/**
* Get tweets from a list timeline
*/
async getListTimeline(listId: string, count = 20, options: TimelineFetchOptions = {}): Promise<SearchResult> {
return this.getListTimelinePaged(listId, count, options);
}
/**
* Get all tweets from a list timeline (paginated)
*/
async getAllListTimeline(listId: string, options?: TimelinePaginationOptions): Promise<SearchResult> {
return this.getListTimelinePaged(listId, Number.POSITIVE_INFINITY, options);
}
/**
* Internal paginated list timeline fetcher
*/
private async getListTimelinePaged(
listId: string,
limit: number,
options: TimelinePaginationOptions = {},
): Promise<SearchResult> {
const features = buildListsFeatures();
const pageSize = 20;
const seen = new Set<string>();
const tweets: TweetData[] = [];
let cursor: string | undefined = options.cursor;
let nextCursor: string | undefined;
let pagesFetched = 0;
const { includeRaw = false, maxPages } = options;
const fetchPage = async (pageCount: number, pageCursor?: string) => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getListTimelineQueryIds();
const variables = {
listId,
count: pageCount,
...(pageCursor ? { cursor: pageCursor } : {}),
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/ListLatestTweetsTimeline?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
list?: {
tweets_timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.list?.tweets_timeline?.timeline?.instructions;
const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const nextCursor = extractCursorFromInstructions(instructions);
return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching list timeline', had404 };
};
const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => {
const firstAttempt = await fetchPage(pageCount, pageCursor);
if (firstAttempt.success) {
return firstAttempt;
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await fetchPage(pageCount, pageCursor);
if (secondAttempt.success) {
return secondAttempt;
}
return { success: false as const, error: secondAttempt.error };
}
return { success: false as const, error: firstAttempt.error };
};
const unlimited = limit === Number.POSITIVE_INFINITY;
while (unlimited || tweets.length < limit) {
const pageCount = unlimited ? pageSize : Math.min(pageSize, limit - tweets.length);
const page = await fetchWithRefresh(pageCount, cursor);
if (!page.success) {
return { success: false, error: page.error };
}
pagesFetched += 1;
let added = 0;
for (const tweet of page.tweets) {
if (seen.has(tweet.id)) {
continue;
}
seen.add(tweet.id);
tweets.push(tweet);
added += 1;
if (!unlimited && tweets.length >= limit) {
break;
}
}
const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) {
nextCursor = undefined;
break;
}
if (maxPages && pagesFetched >= maxPages) {
nextCursor = pageCursor;
break;
}
cursor = pageCursor;
nextCursor = pageCursor;
}
return { success: true, tweets, nextCursor };
}
}
return TwitterClientLists;
}
+175
View File
@@ -0,0 +1,175 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_MEDIA_METADATA_URL, TWITTER_UPLOAD_URL } from './twitter-client-constants.js';
import type { UploadMediaResult } from './twitter-client-types.js';
export interface TwitterClientMediaMethods {
uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise<UploadMediaResult>;
}
export function withMedia<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientMediaMethods> {
abstract class TwitterClientMedia extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
private mediaCategoryForMime(mimeType: string): string | null {
if (mimeType.startsWith('image/')) {
if (mimeType === 'image/gif') {
return 'tweet_gif';
}
return 'tweet_image';
}
if (mimeType.startsWith('video/')) {
return 'tweet_video';
}
return null;
}
async uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise<UploadMediaResult> {
const category = this.mediaCategoryForMime(input.mimeType);
if (!category) {
return { success: false, error: `Unsupported media type: ${input.mimeType}` };
}
try {
const initParams = new URLSearchParams({
command: 'INIT',
total_bytes: String(input.data.byteLength),
media_type: input.mimeType,
media_category: category,
});
const initResp = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, {
method: 'POST',
headers: this.getUploadHeaders(),
body: initParams,
});
if (!initResp.ok) {
const text = await initResp.text();
return { success: false, error: `HTTP ${initResp.status}: ${text.slice(0, 200)}` };
}
const initBody = (await initResp.json()) as { media_id_string?: string; media_id?: string | number };
const mediaId =
typeof initBody.media_id_string === 'string'
? initBody.media_id_string
: initBody.media_id !== undefined
? String(initBody.media_id)
: undefined;
if (!mediaId) {
return { success: false, error: 'Media upload INIT did not return media_id' };
}
const chunkSize = 5 * 1024 * 1024;
let segmentIndex = 0;
for (let offset = 0; offset < input.data.byteLength; offset += chunkSize) {
const chunk = input.data.slice(offset, Math.min(input.data.byteLength, offset + chunkSize));
const form = new FormData();
form.set('command', 'APPEND');
form.set('media_id', mediaId);
form.set('segment_index', String(segmentIndex));
form.set('media', new Blob([chunk], { type: input.mimeType }), 'media');
const appendResp = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, {
method: 'POST',
headers: this.getUploadHeaders(),
body: form,
});
if (!appendResp.ok) {
const text = await appendResp.text();
return { success: false, error: `HTTP ${appendResp.status}: ${text.slice(0, 200)}` };
}
segmentIndex += 1;
}
const finalizeParams = new URLSearchParams({ command: 'FINALIZE', media_id: mediaId });
const finalizeResp = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, {
method: 'POST',
headers: this.getUploadHeaders(),
body: finalizeParams,
});
if (!finalizeResp.ok) {
const text = await finalizeResp.text();
return { success: false, error: `HTTP ${finalizeResp.status}: ${text.slice(0, 200)}` };
}
const finalizeBody = (await finalizeResp.json()) as {
processing_info?: {
state?: string;
check_after_secs?: number;
error?: { message?: string; name?: string };
};
};
const info = finalizeBody.processing_info;
if (info?.state && info.state !== 'succeeded') {
let attempts = 0;
while (attempts < 20) {
if (info.state === 'failed') {
const msg = info.error?.message || info.error?.name || 'Media processing failed';
return { success: false, error: msg };
}
const delaySecs = Number.isFinite(info.check_after_secs) ? Math.max(1, info.check_after_secs as number) : 2;
await this.sleep(delaySecs * 1000);
const statusUrl = `${TWITTER_UPLOAD_URL}?${new URLSearchParams({
command: 'STATUS',
media_id: mediaId,
}).toString()}`;
const statusResp = await this.fetchWithTimeout(statusUrl, {
method: 'GET',
headers: this.getUploadHeaders(),
});
if (!statusResp.ok) {
const text = await statusResp.text();
return { success: false, error: `HTTP ${statusResp.status}: ${text.slice(0, 200)}` };
}
const statusBody = (await statusResp.json()) as {
processing_info?: {
state?: string;
check_after_secs?: number;
error?: { message?: string; name?: string };
};
};
if (!statusBody.processing_info) {
break;
}
info.state = statusBody.processing_info.state;
info.check_after_secs = statusBody.processing_info.check_after_secs;
info.error = statusBody.processing_info.error;
if (info.state === 'succeeded') {
break;
}
attempts += 1;
}
}
if (input.alt && input.mimeType.startsWith('image/')) {
const metaResp = await this.fetchWithTimeout(TWITTER_MEDIA_METADATA_URL, {
method: 'POST',
headers: this.getJsonHeaders(),
body: JSON.stringify({ media_id: mediaId, alt_text: { text: input.alt } }),
});
if (!metaResp.ok) {
const text = await metaResp.text();
return { success: false, error: `HTTP ${metaResp.status}: ${text.slice(0, 200)}` };
}
}
return { success: true, mediaId };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
}
return TwitterClientMedia;
}
+430
View File
@@ -0,0 +1,430 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildExploreFeatures } from './twitter-client-features.js';
import type { SearchResult, TweetData } from './twitter-client-types.js';
const POST_COUNT_REGEX = /[\d.]+[KMB]?\s*posts?/i;
const POST_COUNT_MATCH_REGEX = /([\d.]+)([KMB]?)\s*posts?/i;
// Timeline IDs for different Explore tabs
const TIMELINE_IDS = {
forYou: 'VGltZWxpbmU6DAC2CwABAAAAB2Zvcl95b3UAAA==',
trending: 'VGltZWxpbmU6DAC2CwABAAAACHRyZW5kaW5nAAA=',
news: 'VGltZWxpbmU6DAC2CwABAAAABG5ld3MAAA==',
sports: 'VGltZWxpbmU6DAC2CwABAAAABnNwb3J0cwAA',
entertainment: 'VGltZWxpbmU6DAC2CwABAAAADWVudGVydGFpbm1lbnQAAA==',
} as const;
export type ExploreTab = keyof typeof TIMELINE_IDS;
/** Options for news fetch methods */
export interface NewsFetchOptions {
/** Include raw GraphQL response in `_raw` field */
includeRaw?: boolean;
/** Also fetch related tweets for each news item */
withTweets?: boolean;
/** Number of tweets to fetch per news item (default: 5) */
tweetsPerItem?: number;
/** Filter to show only AI-curated news items */
aiOnly?: boolean;
/** Fetch from specific tabs only (default: all tabs) */
tabs?: ExploreTab[];
}
export interface NewsItem {
id: string;
headline: string;
category?: string;
timeAgo?: string;
postCount?: number;
description?: string;
url?: string;
tweets?: TweetData[];
// biome-ignore lint/suspicious/noExplicitAny: Raw API response can have any structure
_raw?: any;
}
export type NewsResult =
| {
success: true;
items: NewsItem[];
}
| {
success: false;
error: string;
};
export interface TwitterClientNewsMethods {
getNews(count?: number, options?: NewsFetchOptions): Promise<NewsResult>;
}
export function withNews<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientNewsMethods> {
abstract class TwitterClientNews extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
/**
* Fetch news and trending topics from Twitter's Explore page tabs
*/
async getNews(count = 10, options: NewsFetchOptions = {}): Promise<NewsResult> {
const {
includeRaw = false,
withTweets = false,
tweetsPerItem = 5,
aiOnly = false,
tabs = ['forYou', 'news', 'sports', 'entertainment'],
} = options;
const debug = process.env.BIRD_DEBUG === '1';
if (debug) {
console.error(`[getNews] Fetching from tabs: ${tabs.join(', ')}`);
}
const allItems: NewsItem[] = [];
const seenHeadlines = new Set<string>();
// Fetch from each tab
for (const tab of tabs) {
const timelineId = TIMELINE_IDS[tab];
if (!timelineId) {
continue;
}
try {
const tabItems = await this.fetchTimelineTab(tab, timelineId, count, aiOnly, includeRaw);
// Deduplicate across tabs
for (const item of tabItems) {
if (!seenHeadlines.has(item.headline)) {
seenHeadlines.add(item.headline);
allItems.push(item);
}
}
if (debug) {
console.error(`[getNews] Tab ${tab}: found ${tabItems.length} items, total unique: ${allItems.length}`);
}
// Stop early if we have enough
if (allItems.length >= count) {
break;
}
} catch (error) {
if (debug) {
console.error(`[getNews] Error fetching tab ${tab}:`, error);
}
// Continue with other tabs
}
}
if (allItems.length === 0) {
return { success: false, error: 'No news items found' };
}
// Limit to requested count
const items = allItems.slice(0, count);
if (withTweets) {
await this.enrichWithTweets(items, tweetsPerItem, includeRaw);
}
return { success: true, items };
}
/**
* Fetch a specific timeline tab using GenericTimelineById
*/
private async fetchTimelineTab(
tabName: string,
timelineId: string,
maxCount: number,
aiOnly: boolean,
includeRaw: boolean,
): Promise<NewsItem[]> {
const queryId = await this.getQueryId('GenericTimelineById');
const features = buildExploreFeatures();
const variables = {
timelineId: timelineId,
count: maxCount * 2, // Fetch more to account for filtering
includePromotedContent: false,
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const url = `${TWITTER_API_BASE}/${queryId}/GenericTimelineById?${params.toString()}`;
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`HTTP ${response.status}: ${text.slice(0, 200)}`);
}
const data = (await response.json()) as {
// biome-ignore lint/suspicious/noExplicitAny: API response structure is complex
data?: any;
// biome-ignore lint/suspicious/noExplicitAny: API errors can have any structure
errors?: Array<{ message: string; code?: number; [key: string]: any }>;
};
// Debug: save response if BIRD_DEBUG_JSON is set
if (process.env.BIRD_DEBUG_JSON) {
const fs = await import('node:fs/promises');
const debugPath = process.env.BIRD_DEBUG_JSON.replace('.json', `-${tabName}.json`);
await fs.writeFile(debugPath, JSON.stringify(data, null, 2)).catch(() => {});
}
if (data.errors && data.errors.length > 0) {
throw new Error(data.errors.map((e) => e.message).join('; '));
}
// Parse timeline response
return this.parseTimelineTabItems(data, tabName, maxCount, aiOnly, includeRaw);
}
/**
* Parse items from a GenericTimelineById response
*/
private parseTimelineTabItems(
// biome-ignore lint/suspicious/noExplicitAny: API response structure is complex
data: any,
source: string,
maxCount: number,
aiOnly: boolean,
includeRaw: boolean,
): NewsItem[] {
const items: NewsItem[] = [];
const seenHeadlines = new Set<string>();
// Navigate to timeline instructions
const timeline = data?.data?.timeline?.timeline;
if (!timeline) {
return [];
}
const instructions = timeline.instructions || [];
for (const instruction of instructions) {
const entries = instruction.entries ?? (instruction.entry ? [instruction.entry] : []);
if (!entries || entries.length === 0) {
continue;
}
for (const entry of entries) {
if (items.length >= maxCount) {
break;
}
const content = entry.content;
if (!content) {
continue;
}
// Handle TimelineTimelineItem (single trend item)
if (content.itemContent) {
const newsItem = this.parseNewsItemFromContent(
content.itemContent,
entry.entryId,
source,
seenHeadlines,
aiOnly,
includeRaw,
);
if (newsItem) {
items.push(newsItem);
}
}
// Handle TimelineTimelineModule (multiple items)
const itemsArray = content?.items || [];
for (const data of itemsArray) {
if (items.length >= maxCount) {
break;
}
// Structure can be data.itemContent OR data.item.itemContent
const itemContent = data?.itemContent || data?.item?.itemContent;
if (!itemContent) {
continue;
}
const newsItem = this.parseNewsItemFromContent(
itemContent,
entry.entryId,
source,
seenHeadlines,
aiOnly,
includeRaw,
);
if (newsItem) {
items.push(newsItem);
}
}
}
}
return items;
}
private parseNewsItemFromContent(
// biome-ignore lint/suspicious/noExplicitAny: API response structure is complex
itemContent: any,
entryId: string,
source: string,
seenHeadlines: Set<string>,
aiOnly: boolean,
includeRaw: boolean,
): NewsItem | null {
const headline = itemContent.name || itemContent.title;
if (!headline) {
return null;
}
const trendMetadata = itemContent?.trend_metadata;
const trendUrl = itemContent.trend_url?.url || trendMetadata?.url?.url;
// Detect AI news by characteristics:
// 1. Full sentence headlines (contains spaces and is longer)
// 2. Has social_context with "News" category
// 3. Or explicitly marked as is_ai_trend
const socialContext = itemContent?.social_context?.text || '';
const hasNewsCategory = socialContext.includes('News') || socialContext.includes('hours ago');
const isFullSentence = headline.split(' ').length >= 5; // AI news are full sentences
const isExplicitlyAiTrend = itemContent.is_ai_trend === true;
const isAiNews = isExplicitlyAiTrend || (isFullSentence && hasNewsCategory);
// Filter AI trends if aiOnly is enabled
if (aiOnly && !isAiNews) {
return null;
}
if (seenHeadlines.has(headline)) {
return null;
}
seenHeadlines.add(headline);
let postCount: number | undefined;
let timeAgo: string | undefined;
let category = 'Trending';
// Parse social context for metadata
const socialCtx = itemContent?.social_context;
if (socialCtx?.text) {
const socialContextText = socialCtx.text;
const parts = socialContextText.split('·').map((s: string) => s.trim());
for (const part of parts) {
if (part.includes('ago')) {
timeAgo = part;
} else if (part.match(POST_COUNT_REGEX)) {
const match = part.match(POST_COUNT_MATCH_REGEX);
if (match) {
let num = Number.parseFloat(match[1]);
const suffix = match[2]?.toUpperCase();
if (suffix === 'K') {
num *= 1000;
} else if (suffix === 'M') {
num *= 1_000_000;
} else if (suffix === 'B') {
num *= 1_000_000_000;
}
postCount = Math.round(num);
}
} else {
category = part;
}
}
}
// Parse trend metadata
if (trendMetadata?.meta_description) {
const metaDesc = trendMetadata.meta_description;
const postMatch = metaDesc.match(POST_COUNT_MATCH_REGEX);
if (postMatch) {
let num = Number.parseFloat(postMatch[1]);
const suffix = postMatch[2]?.toUpperCase();
if (suffix === 'K') {
num *= 1000;
} else if (suffix === 'M') {
num *= 1_000_000;
} else if (suffix === 'B') {
num *= 1_000_000_000;
}
postCount = Math.round(num);
}
}
if (trendMetadata?.domain_context && (category === 'Trending' || category === 'News')) {
category = trendMetadata.domain_context;
}
const item: NewsItem = {
id: trendUrl ?? (entryId ? `${entryId}-${headline}` : `${source}-${headline}`),
headline,
category: isAiNews ? `AI · ${category}` : category,
timeAgo,
postCount,
description: itemContent.description,
url: trendUrl,
};
if (includeRaw) {
item._raw = itemContent;
}
return item;
}
private async enrichWithTweets(items: NewsItem[], tweetsPerItem: number, includeRaw: boolean): Promise<void> {
const debug = process.env.BIRD_DEBUG === '1';
for (const item of items) {
try {
const searchQuery = item.headline;
if (!searchQuery) {
continue;
}
// Use the search method if available (requires search mixin)
if ('search' in this && typeof (this as { search?: unknown }).search === 'function') {
const result = (await (
this as { search: (q: string, c: number, o: { includeRaw: boolean }) => Promise<SearchResult> }
).search(searchQuery, tweetsPerItem, { includeRaw })) as SearchResult;
if (result.success && result.tweets) {
item.tweets = result.tweets;
}
}
} catch {
if (debug) {
console.error('[getNews] Failed to enrich item with tweets:', item.headline);
}
}
}
}
}
return TwitterClientNews;
}
+283
View File
@@ -0,0 +1,283 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE, TWITTER_GRAPHQL_POST_URL, TWITTER_STATUS_UPDATE_URL } from './twitter-client-constants.js';
import { buildTweetCreateFeatures } from './twitter-client-features.js';
import type { CreateTweetResponse, TweetResult } from './twitter-client-types.js';
export interface TwitterClientPostingMethods {
tweet(text: string, mediaIds?: string[]): Promise<TweetResult>;
reply(text: string, replyToTweetId: string, mediaIds?: string[]): Promise<TweetResult>;
}
export function withPosting<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientPostingMethods> {
abstract class TwitterClientPosting extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
/**
* Post a new tweet
*/
async tweet(text: string, mediaIds?: string[]): Promise<TweetResult> {
const variables = {
tweet_text: text,
dark_request: false,
media: {
media_entities: (mediaIds ?? []).map((id) => ({ media_id: id, tagged_users: [] })),
possibly_sensitive: false,
},
semantic_annotation_ids: [],
};
const features = buildTweetCreateFeatures();
return this.createTweet(variables, features);
}
/**
* Reply to an existing tweet
*/
async reply(text: string, replyToTweetId: string, mediaIds?: string[]): Promise<TweetResult> {
const variables = {
tweet_text: text,
reply: {
in_reply_to_tweet_id: replyToTweetId,
exclude_reply_user_ids: [],
},
dark_request: false,
media: {
media_entities: (mediaIds ?? []).map((id) => ({ media_id: id, tagged_users: [] })),
possibly_sensitive: false,
},
semantic_annotation_ids: [],
};
const features = buildTweetCreateFeatures();
return this.createTweet(variables, features);
}
private async createTweet(
variables: Record<string, unknown>,
features: Record<string, boolean>,
): Promise<TweetResult> {
await this.ensureClientUserId();
let queryId = await this.getQueryId('CreateTweet');
let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/CreateTweet`;
const buildBody = () => JSON.stringify({ variables, features, queryId });
let body = buildBody();
try {
const headers = this.getJsonHeaders();
let response = await this.fetchWithTimeout(urlWithOperation, {
method: 'POST',
headers,
body,
});
// Twitter increasingly prefers POST to /i/api/graphql with queryId in the payload.
// If the operation URL 404s, retry the generic endpoint.
if (response.status === 404) {
await this.refreshQueryIds();
queryId = await this.getQueryId('CreateTweet');
urlWithOperation = `${TWITTER_API_BASE}/${queryId}/CreateTweet`;
body = buildBody();
response = await this.fetchWithTimeout(urlWithOperation, {
method: 'POST',
headers,
body,
});
if (response.status === 404) {
const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, {
method: 'POST',
headers,
body,
});
if (!retry.ok) {
const text = await retry.text();
return { success: false, error: `HTTP ${retry.status}: ${text.slice(0, 200)}` };
}
const data = (await retry.json()) as CreateTweetResponse;
if (data.errors && data.errors.length > 0) {
const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
if (fallback) {
return fallback;
}
return { success: false, error: this.formatErrors(data.errors) };
}
const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id;
if (tweetId) {
return { success: true, tweetId };
}
return { success: false, error: 'Tweet created but no ID returned' };
}
}
if (!response.ok) {
const text = await response.text();
return {
success: false,
error: `HTTP ${response.status}: ${text.slice(0, 200)}`,
};
}
const data = (await response.json()) as CreateTweetResponse;
if (data.errors && data.errors.length > 0) {
const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
if (fallback) {
return fallback;
}
return {
success: false,
error: this.formatErrors(data.errors),
};
}
const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id;
if (tweetId) {
return {
success: true,
tweetId,
};
}
return {
success: false,
error: 'Tweet created but no ID returned',
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
private formatErrors(errors: Array<{ message: string; code?: number }>): string {
return errors
.map((error) => (typeof error.code === 'number' ? `${error.message} (${error.code})` : error.message))
.join(', ');
}
private statusUpdateInputFromCreateTweetVariables(variables: Record<string, unknown>): {
text: string;
inReplyToTweetId?: string;
mediaIds?: string[];
} | null {
const text = typeof variables.tweet_text === 'string' ? variables.tweet_text : null;
if (!text) {
return null;
}
const reply = variables.reply;
const inReplyToTweetId =
reply &&
typeof reply === 'object' &&
typeof (reply as { in_reply_to_tweet_id?: unknown }).in_reply_to_tweet_id === 'string'
? (reply as { in_reply_to_tweet_id: string }).in_reply_to_tweet_id
: undefined;
const media = variables.media;
const mediaEntities =
media && typeof media === 'object' ? (media as { media_entities?: unknown }).media_entities : undefined;
const mediaIds = Array.isArray(mediaEntities)
? mediaEntities
.map((entity) =>
entity && typeof entity === 'object' && 'media_id' in (entity as Record<string, unknown>)
? (entity as { media_id?: unknown }).media_id
: undefined,
)
.filter((value): value is string | number => typeof value === 'string' || typeof value === 'number')
.map((value) => String(value))
: undefined;
return { text, inReplyToTweetId, mediaIds: mediaIds && mediaIds.length > 0 ? mediaIds : undefined };
}
private async postStatusUpdate(input: {
text: string;
inReplyToTweetId?: string;
mediaIds?: string[];
}): Promise<TweetResult> {
const params = new URLSearchParams();
params.set('status', input.text);
if (input.inReplyToTweetId) {
params.set('in_reply_to_status_id', input.inReplyToTweetId);
params.set('auto_populate_reply_metadata', 'true');
}
if (input.mediaIds && input.mediaIds.length > 0) {
params.set('media_ids', input.mediaIds.join(','));
}
try {
const response = await this.fetchWithTimeout(TWITTER_STATUS_UPDATE_URL, {
method: 'POST',
headers: this.getHeaders(),
body: params,
});
if (!response.ok) {
const text = await response.text();
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
}
const data = (await response.json()) as {
id_str?: string;
id?: string | number;
errors?: Array<{ message: string; code?: number }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false, error: this.formatErrors(data.errors) };
}
const tweetId =
typeof data.id_str === 'string' ? data.id_str : data.id !== undefined ? String(data.id) : undefined;
if (tweetId) {
return { success: true, tweetId };
}
return { success: false, error: 'Tweet created but no ID returned' };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
private async tryStatusUpdateFallback(
errors: Array<{ message: string; code?: number }>,
variables: Record<string, unknown>,
): Promise<TweetResult | null> {
if (!errors.some((error) => error.code === 226)) {
return null;
}
const input = this.statusUpdateInputFromCreateTweetVariables(variables);
if (!input) {
return null;
}
const fallback = await this.postStatusUpdate(input);
if (fallback.success) {
return fallback;
}
return {
success: false,
error: `${this.formatErrors(errors)} | fallback: ${fallback.error ?? 'Unknown error'}`,
};
}
}
return TwitterClientPosting;
}
+252
View File
@@ -0,0 +1,252 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildSearchFeatures } from './twitter-client-features.js';
import type { SearchResult, TweetData } from './twitter-client-types.js';
import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js';
const RAW_QUERY_MISSING_REGEX = /must be defined/i;
/** Options for search methods */
export interface SearchFetchOptions {
/** Include raw GraphQL response in `_raw` field */
includeRaw?: boolean;
}
/** Options for paged search methods */
export interface SearchPaginationOptions extends SearchFetchOptions {
maxPages?: number;
/** Starting cursor for pagination (resume from previous fetch) */
cursor?: string;
}
export interface TwitterClientSearchMethods {
search(query: string, count?: number, options?: SearchFetchOptions): Promise<SearchResult>;
getAllSearchResults(query: string, options?: SearchPaginationOptions): Promise<SearchResult>;
}
function isQueryIdMismatch(payload: string): boolean {
try {
const parsed = JSON.parse(payload) as {
errors?: Array<{ message?: string; path?: string[]; extensions?: { code?: string } }>;
};
return (
parsed.errors?.some((error) => {
if (error?.extensions?.code === 'GRAPHQL_VALIDATION_FAILED') {
return true;
}
if (error?.path?.includes('rawQuery') && RAW_QUERY_MISSING_REGEX.test(error.message ?? '')) {
return true;
}
return false;
}) ?? false
);
} catch {
return false;
}
}
export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientSearchMethods> {
abstract class TwitterClientSearch extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
/**
* Search for tweets matching a query
*/
async search(query: string, count = 20, options: SearchFetchOptions = {}): Promise<SearchResult> {
return this.searchPaged(query, count, options);
}
/**
* Get all search results (paged)
*/
async getAllSearchResults(query: string, options?: SearchPaginationOptions): Promise<SearchResult> {
return this.searchPaged(query, Number.POSITIVE_INFINITY, options);
}
private async searchPaged(
query: string,
limit: number,
options: SearchPaginationOptions = {},
): Promise<SearchResult> {
const features = buildSearchFeatures();
const pageSize = 20;
const seen = new Set<string>();
const tweets: TweetData[] = [];
let cursor: string | undefined = options.cursor;
let nextCursor: string | undefined;
let pagesFetched = 0;
const { includeRaw = false, maxPages } = options;
const fetchPage = async (pageCount: number, pageCursor?: string) => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getSearchTimelineQueryIds();
for (const queryId of queryIds) {
const variables = {
rawQuery: query,
count: pageCount,
querySource: 'typed_query',
product: 'Latest',
...(pageCursor ? { cursor: pageCursor } : {}),
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
});
const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'POST',
headers: this.getJsonHeaders(),
body: JSON.stringify({ features, queryId }),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
const shouldRefreshQueryIds =
(response.status === 400 || response.status === 422) && isQueryIdMismatch(text);
return {
success: false as const,
error: `HTTP ${response.status}: ${text.slice(0, 200)}`,
had404: had404 || shouldRefreshQueryIds,
};
}
const data = (await response.json()) as {
data?: {
search_by_raw_query?: {
search_timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: {
rest_id?: string;
legacy?: {
full_text?: string;
created_at?: string;
reply_count?: number;
retweet_count?: number;
favorite_count?: number;
in_reply_to_status_id_str?: string;
};
core?: {
user_results?: {
result?: {
legacy?: {
screen_name?: string;
name?: string;
};
};
};
};
};
};
};
};
}>;
}>;
};
};
};
};
errors?: Array<{ message: string; extensions?: { code?: string } }>;
};
if (data.errors && data.errors.length > 0) {
const shouldRefreshQueryIds = data.errors.some(
(error) => error?.extensions?.code === 'GRAPHQL_VALIDATION_FAILED',
);
return {
success: false as const,
error: data.errors.map((e) => e.message).join(', '),
had404: had404 || shouldRefreshQueryIds,
};
}
const instructions = data.data?.search_by_raw_query?.search_timeline?.timeline?.instructions;
const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const nextCursor = extractCursorFromInstructions(instructions);
return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching search results', had404 };
};
const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => {
const firstAttempt = await fetchPage(pageCount, pageCursor);
if (firstAttempt.success) {
return firstAttempt;
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await fetchPage(pageCount, pageCursor);
if (secondAttempt.success) {
return secondAttempt;
}
return { success: false as const, error: secondAttempt.error };
}
return { success: false as const, error: firstAttempt.error };
};
const unlimited = limit === Number.POSITIVE_INFINITY;
while (unlimited || tweets.length < limit) {
const pageCount = unlimited ? pageSize : Math.min(pageSize, limit - tweets.length);
const page = await fetchWithRefresh(pageCount, cursor);
if (!page.success) {
return { success: false, error: page.error };
}
pagesFetched += 1;
let added = 0;
for (const tweet of page.tweets) {
if (seen.has(tweet.id)) {
continue;
}
seen.add(tweet.id);
tweets.push(tweet);
added += 1;
if (!unlimited && tweets.length >= limit) {
break;
}
}
const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) {
nextCursor = undefined;
break;
}
if (maxPages && pagesFetched >= maxPages) {
nextCursor = pageCursor;
break;
}
cursor = pageCursor;
nextCursor = pageCursor;
}
return { success: true, tweets, nextCursor };
}
}
return TwitterClientSearch;
}
+635
View File
@@ -0,0 +1,635 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildBookmarksFeatures, buildLikesFeatures } from './twitter-client-features.js';
import type { GraphqlTweetResult, SearchResult, TweetData } from './twitter-client-types.js';
import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js';
/** Options for timeline fetch methods */
export interface TimelineFetchOptions {
/** Include raw GraphQL response in `_raw` field */
includeRaw?: boolean;
}
/** Options for paged timeline fetch methods */
export interface TimelinePaginationOptions extends TimelineFetchOptions {
maxPages?: number;
/** Starting cursor for pagination (resume from previous fetch) */
cursor?: string;
}
export interface TwitterClientTimelineMethods {
getBookmarks(count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
getAllBookmarks(options?: TimelinePaginationOptions): Promise<SearchResult>;
getLikes(count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
getAllLikes(options?: TimelinePaginationOptions): Promise<SearchResult>;
getBookmarkFolderTimeline(folderId: string, count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
getAllBookmarkFolderTimeline(folderId: string, options?: TimelinePaginationOptions): Promise<SearchResult>;
}
export function withTimelines<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientTimelineMethods> {
abstract class TwitterClientTimelines extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
private logBookmarksDebug(message: string, data?: Record<string, unknown>): void {
if (process.env.BIRD_DEBUG_BOOKMARKS !== '1') {
return;
}
if (data) {
console.error(`[bird][debug][bookmarks] ${message}`, JSON.stringify(data));
} else {
console.error(`[bird][debug][bookmarks] ${message}`);
}
}
private async getBookmarksQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Bookmarks');
return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw']));
}
private async getBookmarkFolderQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('BookmarkFolderTimeline');
return Array.from(new Set([primary, 'KJIQpsvxrTfRIlbaRIySHQ']));
}
private async getLikesQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Likes');
return Array.from(new Set([primary, 'JR2gceKucIKcVNB_9JkhsA']));
}
/**
* Get the authenticated user's bookmarks
*/
async getBookmarks(count = 20, options: TimelineFetchOptions = {}): Promise<SearchResult> {
return this.getBookmarksPaged(count, options);
}
async getAllBookmarks(options?: TimelinePaginationOptions): Promise<SearchResult> {
return this.getBookmarksPaged(Number.POSITIVE_INFINITY, options);
}
/**
* Get the authenticated user's liked tweets
*/
async getLikes(count = 20, options: TimelineFetchOptions = {}): Promise<SearchResult> {
return this.getLikesPaged(count, options);
}
async getAllLikes(options?: TimelinePaginationOptions): Promise<SearchResult> {
return this.getLikesPaged(Number.POSITIVE_INFINITY, options);
}
private async getLikesPaged(limit: number, options: TimelinePaginationOptions = {}): Promise<SearchResult> {
const userResult = await this.getCurrentUser();
if (!userResult.success || !userResult.user) {
return { success: false, error: userResult.error ?? 'Could not determine current user' };
}
const userId = userResult.user.id;
const features = buildLikesFeatures();
const pageSize = 20;
const seen = new Set<string>();
const tweets: TweetData[] = [];
let cursor: string | undefined = options.cursor;
let nextCursor: string | undefined;
let pagesFetched = 0;
const { includeRaw = false, maxPages } = options;
const fetchPage = async (pageCount: number, pageCursor?: string) => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getLikesQueryIds();
for (const queryId of queryIds) {
const variables = {
userId,
count: pageCount,
includePromotedContent: false,
withClientEventToken: false,
withBirdwatchNotes: false,
withVoice: true,
...(pageCursor ? { cursor: pageCursor } : {}),
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const url = `${TWITTER_API_BASE}/${queryId}/Likes?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
};
};
};
errors?: Array<{ message: string }>;
};
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
if (data.errors && data.errors.length > 0) {
const message = data.errors.map((e) => e.message).join(', ');
if (!instructions) {
if (message.includes('Query: Unspecified')) {
lastError = message;
continue;
}
return { success: false as const, error: message, had404 };
}
}
const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const extractedCursor = extractCursorFromInstructions(instructions);
return { success: true as const, tweets: pageTweets, cursor: extractedCursor, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching likes', had404 };
};
const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => {
const firstAttempt = await fetchPage(pageCount, pageCursor);
if (firstAttempt.success) {
return firstAttempt;
}
const shouldRefresh =
firstAttempt.had404 ||
(typeof firstAttempt.error === 'string' && firstAttempt.error.includes('Query: Unspecified'));
if (shouldRefresh) {
await this.refreshQueryIds();
const secondAttempt = await fetchPage(pageCount, pageCursor);
if (secondAttempt.success) {
return secondAttempt;
}
return { success: false as const, error: secondAttempt.error };
}
return { success: false as const, error: firstAttempt.error };
};
const unlimited = limit === Number.POSITIVE_INFINITY;
while (unlimited || tweets.length < limit) {
const pageCount = unlimited ? pageSize : Math.min(pageSize, limit - tweets.length);
const page = await fetchWithRefresh(pageCount, cursor);
if (!page.success) {
return { success: false, error: page.error };
}
pagesFetched += 1;
let added = 0;
for (const tweet of page.tweets) {
if (seen.has(tweet.id)) {
continue;
}
seen.add(tweet.id);
tweets.push(tweet);
added += 1;
if (!unlimited && tweets.length >= limit) {
break;
}
}
const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) {
nextCursor = undefined;
break;
}
if (maxPages && pagesFetched >= maxPages) {
nextCursor = pageCursor;
break;
}
cursor = pageCursor;
nextCursor = pageCursor;
}
return { success: true, tweets, nextCursor };
}
/**
* Get the authenticated user's bookmark folder timeline
*/
async getBookmarkFolderTimeline(
folderId: string,
count = 20,
options: TimelineFetchOptions = {},
): Promise<SearchResult> {
return this.getBookmarkFolderTimelinePaged(folderId, count, options);
}
async getAllBookmarkFolderTimeline(folderId: string, options?: TimelinePaginationOptions): Promise<SearchResult> {
return this.getBookmarkFolderTimelinePaged(folderId, Number.POSITIVE_INFINITY, options);
}
private async getBookmarksPaged(limit: number, options: TimelinePaginationOptions = {}): Promise<SearchResult> {
const features = buildBookmarksFeatures();
const pageSize = 20;
const seen = new Set<string>();
const tweets: TweetData[] = [];
let cursor: string | undefined = options.cursor;
let nextCursor: string | undefined;
let pagesFetched = 0;
const { includeRaw = false, maxPages } = options;
const fetchPage = async (pageCount: number, pageCursor?: string) => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getBookmarksQueryIds();
const variables = {
count: pageCount,
includePromotedContent: false,
withDownvotePerspective: false,
withReactionsMetadata: false,
withReactionsPerspective: false,
...(pageCursor ? { cursor: pageCursor } : {}),
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params.toString()}`;
try {
this.logBookmarksDebug('request bookmarks page', {
queryId,
pageCount,
hasCursor: Boolean(pageCursor),
});
const response = await this.fetchWithRetry(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
this.logBookmarksDebug('bookmarks 404', { queryId });
continue;
}
if (!response.ok) {
const text = await response.text();
this.logBookmarksDebug('bookmarks non-200', {
queryId,
status: response.status,
body: text.slice(0, 200),
});
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
bookmark_timeline_v2?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
};
errors?: Array<{ message: string }>;
};
const instructions = data.data?.bookmark_timeline_v2?.timeline?.instructions;
const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const nextCursor = extractCursorFromInstructions(instructions);
if (data.errors && data.errors.length > 0) {
this.logBookmarksDebug('bookmarks graphql errors (non-fatal)', { queryId, errors: data.errors });
if (!instructions) {
lastError = data.errors.map((e) => e.message).join(', ');
continue;
}
}
this.logBookmarksDebug('bookmarks page parsed', {
queryId,
tweets: pageTweets.length,
hasNextCursor: Boolean(nextCursor),
});
return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
this.logBookmarksDebug('bookmarks request error', { queryId, error: lastError });
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching bookmarks', had404 };
};
const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => {
const firstAttempt = await fetchPage(pageCount, pageCursor);
if (firstAttempt.success) {
return firstAttempt;
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await fetchPage(pageCount, pageCursor);
if (secondAttempt.success) {
return secondAttempt;
}
return { success: false as const, error: secondAttempt.error };
}
return { success: false as const, error: firstAttempt.error };
};
const unlimited = limit === Number.POSITIVE_INFINITY;
while (unlimited || tweets.length < limit) {
const pageCount = unlimited ? pageSize : Math.min(pageSize, limit - tweets.length);
const page = await fetchWithRefresh(pageCount, cursor);
if (!page.success) {
return { success: false, error: page.error };
}
pagesFetched += 1;
let added = 0;
for (const tweet of page.tweets) {
if (seen.has(tweet.id)) {
continue;
}
seen.add(tweet.id);
tweets.push(tweet);
added += 1;
if (!unlimited && tweets.length >= limit) {
break;
}
}
const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) {
nextCursor = undefined;
break;
}
if (maxPages && pagesFetched >= maxPages) {
nextCursor = pageCursor;
break;
}
cursor = pageCursor;
nextCursor = pageCursor;
}
return { success: true, tweets, nextCursor };
}
private async getBookmarkFolderTimelinePaged(
folderId: string,
limit: number,
options: TimelinePaginationOptions = {},
): Promise<SearchResult> {
const features = buildBookmarksFeatures();
const pageSize = 20;
const seen = new Set<string>();
const tweets: TweetData[] = [];
let cursor: string | undefined = options.cursor;
let nextCursor: string | undefined;
let pagesFetched = 0;
const { includeRaw = false, maxPages } = options;
const buildVariables = (pageCount: number, pageCursor: string | undefined, includeCount: boolean) => ({
bookmark_collection_id: folderId,
includePromotedContent: true,
...(includeCount ? { count: pageCount } : {}),
...(pageCursor ? { cursor: pageCursor } : {}),
});
const fetchPage = async (pageCount: number, pageCursor?: string) => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getBookmarkFolderQueryIds();
const tryOnce = async (variables: Record<string, unknown>) => {
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params.toString()}`;
try {
this.logBookmarksDebug('request bookmark folder page', {
queryId,
pageCount,
hasCursor: Boolean(pageCursor),
includeCount: Object.hasOwn(variables, 'count'),
});
const response = await this.fetchWithRetry(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
this.logBookmarksDebug('bookmark folder 404', { queryId });
continue;
}
if (!response.ok) {
const text = await response.text();
this.logBookmarksDebug('bookmark folder non-200', {
queryId,
status: response.status,
body: text.slice(0, 200),
});
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
bookmark_collection_timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
};
errors?: Array<{ message: string }>;
};
const instructions = data.data?.bookmark_collection_timeline?.timeline?.instructions;
const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const nextCursor = extractCursorFromInstructions(instructions);
if (data.errors && data.errors.length > 0) {
this.logBookmarksDebug('bookmark folder graphql errors (non-fatal)', { queryId, errors: data.errors });
if (!instructions) {
lastError = data.errors.map((e) => e.message).join(', ');
continue;
}
}
this.logBookmarksDebug('bookmark folder page parsed', {
queryId,
tweets: pageTweets.length,
hasNextCursor: Boolean(nextCursor),
});
return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
this.logBookmarksDebug('bookmark folder request error', { queryId, error: lastError });
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching bookmark folder', had404 };
};
let attempt = await tryOnce(buildVariables(pageCount, pageCursor, true));
if (!attempt.success && attempt.error?.includes('Variable "$count"')) {
attempt = await tryOnce(buildVariables(pageCount, pageCursor, false));
}
if (!attempt.success && attempt.error?.includes('Variable "$cursor"') && pageCursor) {
return {
success: false as const,
error: 'Bookmark folder pagination rejected the cursor parameter',
had404: attempt.had404,
};
}
return attempt;
};
const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => {
const firstAttempt = await fetchPage(pageCount, pageCursor);
if (firstAttempt.success) {
return firstAttempt;
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await fetchPage(pageCount, pageCursor);
if (secondAttempt.success) {
return secondAttempt;
}
return { success: false as const, error: secondAttempt.error };
}
return { success: false as const, error: firstAttempt.error };
};
const unlimited = limit === Number.POSITIVE_INFINITY;
while (unlimited || tweets.length < limit) {
const pageCount = unlimited ? pageSize : Math.min(pageSize, limit - tweets.length);
const page = await fetchWithRefresh(pageCount, cursor);
if (!page.success) {
return { success: false, error: page.error };
}
pagesFetched += 1;
let added = 0;
for (const tweet of page.tweets) {
if (seen.has(tweet.id)) {
continue;
}
seen.add(tweet.id);
tweets.push(tweet);
added += 1;
if (!unlimited && tweets.length >= limit) {
break;
}
}
const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) {
nextCursor = undefined;
break;
}
if (maxPages && pagesFetched >= maxPages) {
nextCursor = pageCursor;
break;
}
cursor = pageCursor;
nextCursor = pageCursor;
}
return { success: true, tweets, nextCursor };
}
private async fetchWithRetry(url: string, init: RequestInit): Promise<Response> {
const maxRetries = 2;
const baseDelayMs = 500;
const retryable = new Set([429, 500, 502, 503, 504]);
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
const response = await this.fetchWithTimeout(url, init);
if (!retryable.has(response.status) || attempt === maxRetries) {
return response;
}
this.logBookmarksDebug('retrying bookmarks request', {
status: response.status,
attempt,
});
// Retry-After supports delta-seconds only; HTTP-date falls back to backoff.
const retryAfter = response.headers?.get?.('retry-after');
const retryAfterMs = retryAfter ? Number.parseInt(retryAfter, 10) * 1000 : Number.NaN;
const backoffMs = Number.isFinite(retryAfterMs)
? retryAfterMs
: baseDelayMs * 2 ** attempt + Math.floor(Math.random() * baseDelayMs);
await new Promise((resolve) => setTimeout(resolve, backoffMs));
}
return this.fetchWithTimeout(url, init);
}
}
return TwitterClientTimelines;
}
+467
View File
@@ -0,0 +1,467 @@
import { paginateCursor } from './paginate-cursor.js';
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildArticleFeatures, buildArticleFieldToggles, buildTweetDetailFeatures } from './twitter-client-features.js';
import type { GetTweetResult, GraphqlTweetResult, SearchResult, TweetData } from './twitter-client-types.js';
import {
extractArticleText,
extractCursorFromInstructions,
findTweetInInstructions,
firstText,
mapTweetResult,
parseTweetsFromInstructions,
} from './twitter-client-utils.js';
/** Options for tweet fetching methods */
export interface TweetFetchOptions {
/** Include raw GraphQL response in `_raw` field */
includeRaw?: boolean;
}
/** Options for paginated tweet detail fetch */
export interface TweetDetailPaginationOptions extends TweetFetchOptions {
/** Maximum number of pages to fetch (default: unlimited when using pagination) */
maxPages?: number;
/** Starting cursor for pagination (resume from previous fetch) */
cursor?: string;
/** Delay in milliseconds between page fetches (default: 1000) */
pageDelayMs?: number;
}
export interface TwitterClientTweetDetailMethods {
getTweet(tweetId: string, options?: TweetFetchOptions): Promise<GetTweetResult>;
getReplies(tweetId: string, options?: TweetFetchOptions): Promise<SearchResult>;
getThread(tweetId: string, options?: TweetFetchOptions): Promise<SearchResult>;
getRepliesPaged(tweetId: string, options?: TweetDetailPaginationOptions): Promise<SearchResult>;
getThreadPaged(tweetId: string, options?: TweetDetailPaginationOptions): Promise<SearchResult>;
}
export function withTweetDetails<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientTweetDetailMethods> {
abstract class TwitterClientTweetDetails extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
private async fetchUserArticlePlainText(
userId: string,
tweetId: string,
): Promise<{ title?: string; plainText?: string }> {
const variables = {
userId,
count: 20,
includePromotedContent: true,
withVoice: true,
withQuickPromoteEligibilityTweetFields: true,
withBirdwatchNotes: true,
withCommunity: true,
withSafetyModeUserFields: true,
withSuperFollowsUserFields: true,
withDownvotePerspective: false,
withReactionsMetadata: false,
withReactionsPerspective: false,
withSuperFollowsTweetFields: true,
withSuperFollowsReplyCount: false,
withClientEventToken: false,
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(buildArticleFeatures()),
fieldToggles: JSON.stringify(buildArticleFieldToggles()),
});
const queryId = await this.getQueryId('UserArticlesTweets');
const url = `${TWITTER_API_BASE}/${queryId}/UserArticlesTweets?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, { method: 'GET', headers: this.getHeaders() });
if (!response.ok) {
return {};
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: { result?: GraphqlTweetResult };
};
};
}>;
}>;
};
};
};
};
};
};
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions ?? [];
for (const instruction of instructions) {
for (const entry of instruction.entries ?? []) {
const result = entry.content?.itemContent?.tweet_results?.result;
if (result?.rest_id !== tweetId) {
continue;
}
const articleResult = result.article?.article_results?.result;
const title = firstText(articleResult?.title, result.article?.title);
const plainText = firstText(articleResult?.plain_text, result.article?.plain_text);
return { title, plainText };
}
}
} catch {
return {};
}
return {};
}
private async fetchTweetDetail(
tweetId: string,
cursor?: string,
): Promise<
| {
success: true;
data: {
tweetResult?: { result?: GraphqlTweetResult };
threaded_conversation_with_injections_v2?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
}
| { success: false; error: string }
> {
const variables = {
focalTweetId: tweetId,
with_rux_injections: false,
rankingMode: 'Relevance',
includePromotedContent: true,
withCommunity: true,
withQuickPromoteEligibilityTweetFields: true,
withBirdwatchNotes: true,
withVoice: true,
...(cursor ? { cursor } : {}),
};
const features = {
...buildTweetDetailFeatures(),
articles_preview_enabled: true,
articles_rest_api_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
rweb_video_timestamps_enabled: true,
};
const fieldToggles = {
...buildArticleFieldToggles(),
withArticleRichContentState: true,
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
fieldToggles: JSON.stringify(fieldToggles),
});
try {
const parseResponse = async (response: Response) => {
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
}
const data = (await response.json()) as {
data?: {
tweetResult?: { result?: GraphqlTweetResult };
threaded_conversation_with_injections_v2?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
errors?: Array<{ message: string; code?: number }>;
};
if (data.errors && data.errors.length > 0) {
// Twitter API sometimes returns partial errors (e.g., is_translatable failures)
// alongside valid tweet/thread data. Only fail if nothing useful is present.
const hasUsableData = Boolean(
data.data?.tweetResult?.result ||
data.data?.threaded_conversation_with_injections_v2?.instructions?.length,
);
if (!hasUsableData) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', ') };
}
}
return { success: true as const, data: data.data ?? {} };
};
let lastError: string | undefined;
let had404 = false;
const tryOnce = async () => {
const queryIds = await this.getTweetDetailQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params.toString()}`;
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status !== 404) {
return await parseResponse(response);
}
had404 = true;
const postResponse = await this.fetchWithTimeout(`${TWITTER_API_BASE}/${queryId}/TweetDetail`, {
method: 'POST',
headers: this.getJsonHeaders(),
body: JSON.stringify({ variables, features, queryId }),
});
if (postResponse.status !== 404) {
return await parseResponse(postResponse);
}
lastError = 'HTTP 404';
}
return { success: false as const, error: lastError ?? 'Unknown error fetching tweet detail' };
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return firstAttempt;
}
if (had404) {
await this.refreshQueryIds();
return await tryOnce();
}
return firstAttempt;
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
/**
* Get tweet details by ID
*/
async getTweet(tweetId: string, options: TweetFetchOptions = {}): Promise<GetTweetResult> {
const { includeRaw = false } = options;
const response = await this.fetchTweetDetail(tweetId);
if (!response.success) {
return response;
}
const tweetResult =
(response.data.tweetResult as { result?: GraphqlTweetResult } | undefined)?.result ??
findTweetInInstructions(
response.data.threaded_conversation_with_injections_v2?.instructions as
| Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>
| undefined,
tweetId,
);
const mapped = mapTweetResult(tweetResult, { quoteDepth: this.quoteDepth, includeRaw });
if (mapped) {
if (tweetResult?.article) {
const title = firstText(tweetResult.article.article_results?.result?.title, tweetResult.article.title);
const articleText = extractArticleText(tweetResult);
if (title && (!articleText || articleText.trim() === title.trim())) {
const userId = tweetResult.core?.user_results?.result?.rest_id;
if (userId) {
const fallback = await this.fetchUserArticlePlainText(userId, tweetId);
if (fallback.plainText) {
mapped.text = fallback.title ? `${fallback.title}\n\n${fallback.plainText}` : fallback.plainText;
}
}
}
}
return { success: true, tweet: mapped };
}
return { success: false, error: 'Tweet not found in response' };
}
/**
* Get replies to a tweet by ID
*/
async getReplies(tweetId: string, options: TweetFetchOptions = {}): Promise<SearchResult> {
const { includeRaw = false } = options;
const response = await this.fetchTweetDetail(tweetId);
if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const replies = tweets.filter((tweet) => tweet.inReplyToStatusId === tweetId);
return { success: true, tweets: replies };
}
/**
* Get full conversation thread for a tweet ID
*/
async getThread(tweetId: string, options: TweetFetchOptions = {}): Promise<SearchResult> {
const { includeRaw = false } = options;
const response = await this.fetchTweetDetail(tweetId);
if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const target = tweets.find((t) => t.id === tweetId);
const rootId = target?.conversationId || tweetId;
const thread = tweets.filter((tweet) => tweet.conversationId === rootId);
thread.sort((a, b) => {
const aTime = a.createdAt ? Date.parse(a.createdAt) : 0;
const bTime = b.createdAt ? Date.parse(b.createdAt) : 0;
return aTime - bTime;
});
return { success: true, tweets: thread };
}
/**
* Get replies to a tweet with pagination support
*/
async getRepliesPaged(tweetId: string, options: TweetDetailPaginationOptions = {}): Promise<SearchResult> {
const { includeRaw = false, maxPages, pageDelayMs = 1000 } = options;
const result = await paginateCursor<TweetData>({
cursor: options.cursor,
maxPages,
pageDelayMs,
sleep: async (ms) => this.sleep(ms),
getKey: (tweet) => tweet.id,
fetchPage: async (cursor) => {
const response = await this.fetchTweetDetail(tweetId, cursor);
if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const replies = tweets.filter((tweet) => tweet.inReplyToStatusId === tweetId);
const pageCursor = extractCursorFromInstructions(instructions);
return { success: true as const, items: replies, cursor: pageCursor };
},
});
if (result.success) {
return { success: true, tweets: result.items, nextCursor: result.nextCursor };
}
if (result.items) {
return { success: false, tweets: result.items, nextCursor: result.nextCursor, error: result.error };
}
return { success: false, error: result.error };
}
/**
* Get full conversation thread with pagination support
*/
async getThreadPaged(tweetId: string, options: TweetDetailPaginationOptions = {}): Promise<SearchResult> {
const { includeRaw = false, maxPages, pageDelayMs = 1000 } = options;
let rootId: string | undefined;
const result = await paginateCursor<TweetData>({
cursor: options.cursor,
maxPages,
pageDelayMs,
sleep: async (ms) => this.sleep(ms),
getKey: (tweet) => tweet.id,
fetchPage: async (cursor) => {
const response = await this.fetchTweetDetail(tweetId, cursor);
if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
if (!rootId) {
const target = tweets.find((t) => t.id === tweetId);
rootId = target?.conversationId || tweetId;
}
const threadTweets = tweets.filter((tweet) => tweet.conversationId === rootId);
const pageCursor = extractCursorFromInstructions(instructions);
return { success: true as const, items: threadTweets, cursor: pageCursor };
},
});
const sortedTweets = (result.items ?? []).slice().sort((a, b) => {
const aTime = a.createdAt ? Date.parse(a.createdAt) : 0;
const bTime = b.createdAt ? Date.parse(b.createdAt) : 0;
return aTime - bTime;
});
if (result.success) {
return { success: true, tweets: sortedTweets, nextCursor: result.nextCursor };
}
if (result.items) {
return { success: false, tweets: sortedTweets, nextCursor: result.nextCursor, error: result.error };
}
return { success: false, error: result.error };
}
}
return TwitterClientTweetDetails;
}
+369
View File
@@ -0,0 +1,369 @@
// Raw media entity from Twitter API
export interface GraphqlMediaEntity {
id_str?: string;
media_url_https?: string;
type?: 'photo' | 'video' | 'animated_gif';
url?: string;
expanded_url?: string;
sizes?: {
thumb?: { w: number; h: number; resize: string };
small?: { w: number; h: number; resize: string };
medium?: { w: number; h: number; resize: string };
large?: { w: number; h: number; resize: string };
};
video_info?: {
duration_millis?: number;
variants?: Array<{
bitrate?: number;
content_type?: string;
url?: string;
}>;
};
}
export type GraphqlTweetResult = {
__typename?: string;
rest_id?: string;
legacy?: {
full_text?: string;
created_at?: string;
reply_count?: number;
retweet_count?: number;
favorite_count?: number;
conversation_id_str?: string;
in_reply_to_status_id_str?: string | null;
entities?: {
media?: GraphqlMediaEntity[];
};
extended_entities?: {
media?: GraphqlMediaEntity[];
};
};
core?: {
user_results?: {
result?: {
rest_id?: string;
id?: string;
legacy?: {
screen_name?: string;
name?: string;
};
core?: {
screen_name?: string;
name?: string;
};
};
};
};
note_tweet?: {
note_tweet_results?: {
result?: {
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
content?: {
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
};
};
};
};
article?: {
title?: string;
preview_text?: string;
article_results?: {
result?: {
title?: string;
preview_text?: string;
plain_text?: string;
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
body?: {
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
};
content?: {
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
items?: Array<{
text?: string;
content?: {
text?: string;
richtext?: { text?: string };
rich_text?: { text?: string };
};
}>;
};
sections?: Array<{
items?: Array<{
text?: string;
content?: {
text?: string;
richtext?: { text?: string };
rich_text?: { text?: string };
};
}>;
}>;
/** Draft.js content state for rich article content */
content_state?: {
blocks: Array<{
key: string;
type: string;
text: string;
data?: Record<string, unknown>;
entityRanges?: Array<{ key: number; offset: number; length: number }>;
inlineStyleRanges?: Array<{ offset: number; length: number; style: string }>;
}>;
entityMap?:
| Array<{
key: string;
value: {
type: string;
mutability: string;
data: Record<string, unknown>;
};
}>
| Record<
string,
{
type: string;
mutability: string;
data: Record<string, unknown>;
}
>;
};
};
};
plain_text?: string;
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
body?: {
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
};
content?: {
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
items?: Array<{
text?: string;
content?: {
text?: string;
richtext?: { text?: string };
rich_text?: { text?: string };
};
}>;
};
sections?: Array<{
items?: Array<{
text?: string;
content?: {
text?: string;
richtext?: { text?: string };
rich_text?: { text?: string };
};
}>;
}>;
};
tweet?: GraphqlTweetResult;
quoted_status_result?: {
result?: GraphqlTweetResult;
};
};
export type TweetResult =
| {
success: true;
tweetId: string;
}
| {
success: false;
error: string;
};
export type BookmarkMutationResult =
| {
success: true;
}
| {
success: false;
error: string;
};
export interface UploadMediaResult {
success: boolean;
mediaId?: string;
error?: string;
}
// Parsed media item for output
export interface TweetMedia {
type: 'photo' | 'video' | 'animated_gif';
url: string;
previewUrl?: string;
width?: number;
height?: number;
// For video/animated_gif: best quality video URL
videoUrl?: string;
durationMs?: number;
}
export interface TweetData {
id: string;
text: string;
author: {
username: string;
name: string;
};
authorId?: string;
createdAt?: string;
replyCount?: number;
retweetCount?: number;
likeCount?: number;
conversationId?: string;
inReplyToStatusId?: string;
// Optional quoted tweet; depth controlled by quoteDepth (default: 1).
quotedTweet?: TweetData;
// Media attachments (photos, videos, GIFs)
media?: TweetMedia[];
// Article metadata (for Twitter Articles / long-form posts)
article?: {
title: string;
previewText?: string;
};
// Raw GraphQL tweet result (only when includeRaw is enabled)
_raw?: GraphqlTweetResult;
}
export interface GetTweetResult {
success: boolean;
tweet?: TweetData;
error?: string;
}
export type SearchResult =
| {
success: true;
tweets: TweetData[];
/** Cursor for fetching the next page of results */
nextCursor?: string;
}
| {
success: false;
error: string;
tweets?: TweetData[];
/** Cursor for fetching the next page of results */
nextCursor?: string;
};
export interface CurrentUserResult {
success: boolean;
user?: {
id: string;
username: string;
name: string;
};
error?: string;
}
export interface TwitterUser {
id: string;
username: string;
name: string;
description?: string;
followersCount?: number;
followingCount?: number;
isBlueVerified?: boolean;
profileImageUrl?: string;
createdAt?: string;
}
export interface FollowingResult {
success: boolean;
users?: TwitterUser[];
error?: string;
/** Cursor for fetching the next page of results */
nextCursor?: string;
}
export interface TwitterClientOptions {
relayBaseUrl?: string;
timeoutMs?: number;
// Max depth for quoted tweets (0 disables). Defaults to 1.
quoteDepth?: number;
}
export interface TwitterList {
id: string;
name: string;
description?: string;
memberCount?: number;
subscriberCount?: number;
isPrivate?: boolean;
createdAt?: string;
owner?: {
id: string;
username: string;
name: string;
};
}
export interface ListsResult {
success: boolean;
lists?: TwitterList[];
error?: string;
}
export interface CreateTweetResponse {
data?: {
create_tweet?: {
tweet_results?: {
result?: {
rest_id?: string;
legacy?: {
full_text?: string;
};
};
};
};
};
errors?: Array<{ message: string; code?: number }>;
}
+211
View File
@@ -0,0 +1,211 @@
import { normalizeHandle } from './normalize-handle.js';
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
/** Result of username to userId lookup */
export interface UserLookupResult {
success: boolean;
userId?: string;
username?: string;
name?: string;
error?: string;
}
export interface TwitterClientUserLookupMethods {
getUserIdByUsername(username: string): Promise<UserLookupResult>;
}
export function withUserLookup<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientUserLookupMethods> {
abstract class TwitterClientUserLookup extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
private async getUserByScreenNameGraphQL(screenName: string): Promise<UserLookupResult> {
// UserByScreenName query IDs observed from web client
const queryIds = ['xc8f1g7BYqr6VTzTbvNlGw', 'qW5u-DAuXpMEG0zA1F7UGQ', 'sLVLhk0bGj3MVFEKTdax1w'];
const variables = {
screen_name: screenName,
withSafetyModeUserFields: true,
};
const features = {
hidden_profile_subscriptions_enabled: true,
hidden_profile_likes_enabled: true,
rweb_tipjar_consumption_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
verified_phone_label_enabled: false,
subscriptions_verification_info_is_identity_verified_enabled: true,
subscriptions_verification_info_verified_since_enabled: true,
highlights_tweets_tab_ui_enabled: true,
responsive_web_twitter_article_notes_tab_enabled: true,
subscriptions_feature_can_gift_premium: true,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
responsive_web_graphql_timeline_navigation_enabled: true,
blue_business_profile_image_shape_enabled: true,
};
const fieldToggles = {
withAuxiliaryUserLabels: false,
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
fieldToggles: JSON.stringify(fieldToggles),
});
let lastError: string | undefined;
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/UserByScreenName?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
const text = await response.text();
if (response.status === 404) {
// Try next query ID
lastError = `HTTP ${response.status}`;
continue;
}
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
continue;
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
__typename?: string;
rest_id?: string;
legacy?: {
screen_name?: string;
name?: string;
};
core?: {
screen_name?: string;
name?: string;
};
};
};
};
errors?: Array<{ message: string }>;
};
// Check for user not found
if (data.data?.user?.result?.__typename === 'UserUnavailable') {
return { success: false, error: `User @${screenName} not found or unavailable` };
}
const userResult = data.data?.user?.result;
const userId = userResult?.rest_id;
const username = userResult?.legacy?.screen_name ?? userResult?.core?.screen_name;
const name = userResult?.legacy?.name ?? userResult?.core?.name;
if (userId && username) {
return {
success: true,
userId,
username,
name,
};
}
if (data.errors && data.errors.length > 0) {
lastError = data.errors.map((e) => e.message).join(', ');
continue;
}
lastError = 'Could not parse user data from response';
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false, error: lastError ?? 'Unknown error looking up user' };
}
/**
* Look up a user's ID by their username/handle.
* Uses GraphQL UserByScreenName first, then falls back to REST on transient failures.
*/
async getUserIdByUsername(username: string): Promise<UserLookupResult> {
const cleanUsername = normalizeHandle(username);
if (!cleanUsername) {
return { success: false, error: `Invalid username: ${username}` };
}
const graphqlResult = await this.getUserByScreenNameGraphQL(cleanUsername);
if (graphqlResult.success) {
return graphqlResult;
}
// If GraphQL definitively says user is unavailable, don't retry with REST
if (graphqlResult.error?.includes('not found or unavailable')) {
return graphqlResult;
}
// Fallback to REST API for transient GraphQL errors
const urls = [
`https://x.com/i/api/1.1/users/show.json?screen_name=${encodeURIComponent(cleanUsername)}`,
`https://api.twitter.com/1.1/users/show.json?screen_name=${encodeURIComponent(cleanUsername)}`,
];
let lastError: string | undefined = graphqlResult.error;
for (const url of urls) {
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
const text = await response.text();
if (response.status === 404) {
return { success: false, error: `User @${cleanUsername} not found` };
}
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
continue;
}
const data = (await response.json()) as {
id_str?: string;
id?: number;
screen_name?: string;
name?: string;
};
const userId = data.id_str ?? (data.id ? String(data.id) : null);
if (!userId) {
lastError = 'Could not parse user ID from response';
continue;
}
return {
success: true,
userId,
username: data.screen_name ?? cleanUsername,
name: data.name,
};
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false, error: lastError ?? 'Unknown error looking up user' };
}
}
return TwitterClientUserLookup;
}
+262
View File
@@ -0,0 +1,262 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildUserTweetsFeatures } from './twitter-client-features.js';
import type { GraphqlTweetResult, SearchResult, TweetData } from './twitter-client-types.js';
import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js';
/** Options for user tweets fetch methods */
export interface UserTweetsFetchOptions {
/** Include raw GraphQL response in `_raw` field */
includeRaw?: boolean;
}
/** Options for paginated user tweets fetch */
export interface UserTweetsPaginationOptions extends UserTweetsFetchOptions {
/** Maximum number of pages to fetch (default: 1) */
maxPages?: number;
/** Starting cursor for pagination (resume from previous fetch) */
cursor?: string;
/** Delay in milliseconds between page fetches (default: 1000) */
pageDelayMs?: number;
}
export interface TwitterClientUserTweetsMethods {
getUserTweets(userId: string, count?: number, options?: UserTweetsFetchOptions): Promise<SearchResult>;
getUserTweetsPaged(userId: string, limit: number, options?: UserTweetsPaginationOptions): Promise<SearchResult>;
}
export function withUserTweets<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientUserTweetsMethods> {
abstract class TwitterClientUserTweets extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
private async getUserTweetsQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('UserTweets');
// Fallback query ID observed from web client
return Array.from(new Set([primary, 'Wms1GvIiHXAPBaCr9KblaA']));
}
/**
* Get tweets from a user's profile timeline (single page).
*/
async getUserTweets(userId: string, count = 20, options: UserTweetsFetchOptions = {}): Promise<SearchResult> {
return this.getUserTweetsPaged(userId, count, options);
}
/**
* Get tweets from a user's profile timeline with pagination support.
*/
async getUserTweetsPaged(
userId: string,
limit: number,
options: UserTweetsPaginationOptions = {},
): Promise<SearchResult> {
if (!Number.isFinite(limit) || limit <= 0) {
return { success: false, error: `Invalid limit: ${limit}` };
}
const { includeRaw = false, maxPages, pageDelayMs = 1000 } = options;
const features = buildUserTweetsFeatures();
const pageSize = 20;
const seen = new Set<string>();
const tweets: TweetData[] = [];
let cursor: string | undefined = options.cursor;
let nextCursor: string | undefined;
let pagesFetched = 0;
const hardMaxPages = 10;
const computedMaxPages = Math.max(1, Math.ceil(limit / pageSize));
const effectiveMaxPages = Math.min(hardMaxPages, maxPages ?? computedMaxPages);
const fetchPage = async (pageCount: number, pageCursor?: string) => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getUserTweetsQueryIds();
const variables = {
userId,
count: pageCount,
includePromotedContent: false, // Filter out ads
withQuickPromoteEligibilityTweetFields: true,
withVoice: true,
...(pageCursor ? { cursor: pageCursor } : {}),
};
const fieldToggles = {
withArticlePlainText: false,
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
fieldToggles: JSON.stringify(fieldToggles),
});
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/UserTweets?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{
type?: string;
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
item?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
items?: Array<{
item?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
cursorType?: string;
value?: string;
};
}>;
}>;
};
};
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
// Check for common errors
const errorMsg = data.errors.map((e) => e.message).join(', ');
if (errorMsg.includes('User has been suspended') || errorMsg.includes('User not found')) {
return { success: false as const, error: errorMsg, had404 };
}
// Some errors are non-fatal if we got data
if (!data.data?.user?.result?.timeline?.timeline?.instructions) {
return { success: false as const, error: errorMsg, had404 };
}
}
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const pageCursorValue = extractCursorFromInstructions(instructions);
return { success: true as const, tweets: pageTweets, cursor: pageCursorValue, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching user tweets', had404 };
};
const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => {
const firstAttempt = await fetchPage(pageCount, pageCursor);
if (firstAttempt.success) {
return firstAttempt;
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await fetchPage(pageCount, pageCursor);
if (secondAttempt.success) {
return secondAttempt;
}
return { success: false as const, error: secondAttempt.error };
}
return { success: false as const, error: firstAttempt.error };
};
while (tweets.length < limit) {
// Add delay between pages (but not before the first page)
if (pagesFetched > 0 && pageDelayMs > 0) {
await this.sleep(pageDelayMs);
}
const remaining = limit - tweets.length;
const pageCount = Math.min(pageSize, remaining);
const page = await fetchWithRefresh(pageCount, cursor);
if (!page.success) {
return { success: false, error: page.error };
}
pagesFetched += 1;
let added = 0;
for (const tweet of page.tweets) {
if (seen.has(tweet.id)) {
continue;
}
seen.add(tweet.id);
tweets.push(tweet);
added += 1;
if (tweets.length >= limit) {
break;
}
}
const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) {
nextCursor = undefined;
break;
}
if (pagesFetched >= effectiveMaxPages) {
nextCursor = pageCursor;
break;
}
cursor = pageCursor;
nextCursor = pageCursor;
}
return { success: true, tweets, nextCursor };
}
}
return TwitterClientUserTweets;
}
+452
View File
@@ -0,0 +1,452 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildFollowingFeatures } from './twitter-client-features.js';
import type { CurrentUserResult, FollowingResult, TwitterUser } from './twitter-client-types.js';
import { extractCursorFromInstructions, parseUsersFromInstructions } from './twitter-client-utils.js';
type RestUser = {
id_str?: string;
id?: string | number;
screen_name?: string;
name?: string;
description?: string;
followers_count?: number;
friends_count?: number;
verified?: boolean;
profile_image_url_https?: string;
created_at?: string;
};
export interface TwitterClientUserMethods {
getCurrentUser(): Promise<CurrentUserResult>;
getFollowing(userId: string, count?: number, cursor?: string): Promise<FollowingResult>;
getFollowers(userId: string, count?: number, cursor?: string): Promise<FollowingResult>;
}
export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientUserMethods> {
abstract class TwitterClientUsers extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
private async getFollowingQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Following');
return Array.from(new Set([primary, 'BEkNpEt5pNETESoqMsTEGA']));
}
private async getFollowersQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Followers');
return Array.from(new Set([primary, 'kuFUYP9eV1FPoEy4N-pi7w']));
}
private parseUsersFromRestResponse(users: RestUser[] | undefined): TwitterUser[] {
return (users ?? [])
.map((u) => {
const id = typeof u.id_str === 'string' ? u.id_str : typeof u.id === 'number' ? String(u.id) : null;
const username = typeof u.screen_name === 'string' ? u.screen_name : null;
if (!id || !username) {
return null;
}
return {
id,
username,
name: typeof u.name === 'string' && u.name.length > 0 ? u.name : username,
description: typeof u.description === 'string' ? u.description : undefined,
followersCount: typeof u.followers_count === 'number' ? u.followers_count : undefined,
followingCount: typeof u.friends_count === 'number' ? u.friends_count : undefined,
isBlueVerified: typeof u.verified === 'boolean' ? u.verified : undefined,
profileImageUrl: typeof u.profile_image_url_https === 'string' ? u.profile_image_url_https : undefined,
createdAt: typeof u.created_at === 'string' ? u.created_at : undefined,
};
})
.filter((u) => u !== null);
}
private async getFollowersViaRest(userId: string, count: number): Promise<FollowingResult> {
const params = new URLSearchParams({
user_id: userId,
count: String(count),
skip_status: 'true',
include_user_entities: 'false',
});
const urls = [
`https://x.com/i/api/1.1/followers/list.json?${params.toString()}`,
`https://api.twitter.com/1.1/followers/list.json?${params.toString()}`,
];
let lastError: string | undefined;
for (const url of urls) {
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
const text = await response.text();
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
continue;
}
const data = (await response.json()) as {
users?: RestUser[];
};
const users = this.parseUsersFromRestResponse(data.users);
return { success: true, users };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false, error: lastError ?? 'Unknown error fetching followers' };
}
private async getFollowingViaRest(userId: string, count: number): Promise<FollowingResult> {
const params = new URLSearchParams({
user_id: userId,
count: String(count),
skip_status: 'true',
include_user_entities: 'false',
});
const urls = [
`https://x.com/i/api/1.1/friends/list.json?${params.toString()}`,
`https://api.twitter.com/1.1/friends/list.json?${params.toString()}`,
];
let lastError: string | undefined;
for (const url of urls) {
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
const text = await response.text();
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
continue;
}
const data = (await response.json()) as {
users?: RestUser[];
};
const users = this.parseUsersFromRestResponse(data.users);
return { success: true, users };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false, error: lastError ?? 'Unknown error fetching following' };
}
/**
* Fetch the account associated with the configured relay.
*/
async getCurrentUser(): Promise<CurrentUserResult> {
const candidateUrls = [
'https://x.com/i/api/account/settings.json',
'https://api.twitter.com/1.1/account/settings.json',
'https://x.com/i/api/account/verify_credentials.json?skip_status=true&include_entities=false',
'https://api.twitter.com/1.1/account/verify_credentials.json?skip_status=true&include_entities=false',
];
let lastError: string | undefined;
for (const url of candidateUrls) {
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
const text = await response.text();
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
continue;
}
// biome-ignore lint/suspicious/noExplicitAny: Twitter API response is dynamic here
let data: any;
try {
data = await response.json();
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
continue;
}
const username =
typeof data?.screen_name === 'string'
? data.screen_name
: typeof data?.user?.screen_name === 'string'
? data.user.screen_name
: null;
const name =
typeof data?.name === 'string'
? data.name
: typeof data?.user?.name === 'string'
? data.user.name
: (username ?? '');
const userId =
typeof data?.user_id === 'string'
? data.user_id
: typeof data?.user_id_str === 'string'
? data.user_id_str
: typeof data?.user?.id_str === 'string'
? data.user.id_str
: typeof data?.user?.id === 'string'
? data.user.id
: null;
if (username && userId) {
this.clientUserId = userId;
return {
success: true,
user: {
id: userId,
username,
name: name || username,
},
};
}
lastError = 'Could not determine current user from response';
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return {
success: false,
error: lastError ?? 'Unknown error fetching current user',
};
}
/**
* Get users that a user is following
*/
async getFollowing(userId: string, count = 20, cursor?: string): Promise<FollowingResult> {
const variables: Record<string, unknown> = {
userId,
count,
includePromotedContent: false,
};
if (cursor) {
variables.cursor = cursor;
}
const features = buildFollowingFeatures();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const tryOnce = async () => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getFollowingQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/Following?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{ type?: string; entries?: Array<unknown> }>;
};
};
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const users = parseUsersFromInstructions(instructions);
const nextCursor = extractCursorFromInstructions(
instructions as Array<{ entries?: Array<{ content?: unknown }> }> | undefined,
);
return { success: true as const, users, nextCursor, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching following', had404 };
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, users: firstAttempt.users, nextCursor: firstAttempt.nextCursor };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, users: secondAttempt.users, nextCursor: secondAttempt.nextCursor };
}
// GraphQL Following can also return 404 (queryId churn / endpoint flakiness).
// Fallback to the internal v1.1 REST endpoint used by the web client through the relay.
// Note: REST fallback does not support cursor pagination.
const restAttempt = await this.getFollowingViaRest(userId, count);
if (restAttempt.success) {
return restAttempt;
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
/**
* Get users that follow a user
*/
async getFollowers(userId: string, count = 20, cursor?: string): Promise<FollowingResult> {
const variables: Record<string, unknown> = {
userId,
count,
includePromotedContent: false,
};
if (cursor) {
variables.cursor = cursor;
}
const features = buildFollowingFeatures();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const tryOnce = async () => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getFollowersQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/Followers?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{ type?: string; entries?: Array<unknown> }>;
};
};
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const users = parseUsersFromInstructions(instructions);
const nextCursor = extractCursorFromInstructions(
instructions as Array<{ entries?: Array<{ content?: unknown }> }> | undefined,
);
return { success: true as const, users, nextCursor, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching followers', had404 };
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, users: firstAttempt.users, nextCursor: firstAttempt.nextCursor };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, users: secondAttempt.users, nextCursor: secondAttempt.nextCursor };
}
// GraphQL Followers regularly returns 404 (queryId churn / endpoint flakiness).
// Fallback to the internal v1.1 REST endpoint used by the web client through the relay.
// Note: REST fallback does not support cursor pagination.
const restAttempt = await this.getFollowersViaRest(userId, count);
if (restAttempt.success) {
return restAttempt;
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
}
return TwitterClientUsers;
}
+839
View File
@@ -0,0 +1,839 @@
import type { GraphqlTweetResult, TweetData, TweetMedia, TwitterUser } from './twitter-client-types.js';
export function normalizeQuoteDepth(value?: number): number {
if (value === undefined || value === null) {
return 1;
}
if (!Number.isFinite(value)) {
return 1;
}
return Math.max(0, Math.floor(value));
}
export function firstText(...values: Array<string | undefined | null>): string | undefined {
for (const value of values) {
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed) {
return trimmed;
}
}
}
return undefined;
}
export function collectTextFields(value: unknown, keys: Set<string>, output: string[]): void {
if (!value) {
return;
}
if (typeof value === 'string') {
return;
}
if (Array.isArray(value)) {
for (const item of value) {
collectTextFields(item, keys, output);
}
return;
}
if (typeof value === 'object') {
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
if (keys.has(key)) {
if (typeof nested === 'string') {
const trimmed = nested.trim();
if (trimmed) {
output.push(trimmed);
}
continue;
}
}
collectTextFields(nested, keys, output);
}
}
}
export function uniqueOrdered(values: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
if (seen.has(value)) {
continue;
}
seen.add(value);
result.push(value);
}
return result;
}
// ============================================================================
// Draft.js Content State Types and Parser for Long-form Tweets (X Articles)
// ============================================================================
/** Inline style range for text formatting (Bold, Italic, etc.) */
interface InlineStyleRange {
offset: number;
length: number;
style: string;
}
/** Entity range linking a portion of text to an entity in entityMap */
interface EntityRange {
key: number;
offset: number;
length: number;
}
/** A content block in Draft.js format */
interface ContentBlock {
key: string;
type: string;
text: string;
data?: {
mentions?: Array<{ fromIndex: number; toIndex: number; text: string }>;
};
entityRanges?: EntityRange[];
inlineStyleRanges?: InlineStyleRange[];
}
/** Entity data for different entity types */
interface EntityValue {
type: string;
mutability: string;
data: {
markdown?: string;
url?: string;
tweetId?: string;
};
}
/** Entity map entry */
interface EntityMapEntry {
key: string;
value: EntityValue;
}
/** Draft.js content state structure */
interface ContentState {
blocks: ContentBlock[];
entityMap?: Array<EntityMapEntry> | Record<string, EntityValue>;
}
/**
* Renders a Draft.js content_state into readable markdown/text format.
* Handles blocks (paragraphs, headers, lists) and entities (code blocks, links, tweets, dividers).
*/
export function renderContentState(contentState: ContentState | undefined): string | undefined {
if (!contentState?.blocks || contentState.blocks.length === 0) {
return undefined;
}
// Build entity lookup map from array/object formats
const entityMap = new Map<number, EntityValue>();
const rawEntityMap = contentState.entityMap ?? [];
if (Array.isArray(rawEntityMap)) {
for (const entry of rawEntityMap) {
const key = Number.parseInt(entry.key, 10);
if (!Number.isNaN(key)) {
entityMap.set(key, entry.value);
}
}
} else {
for (const [key, value] of Object.entries(rawEntityMap)) {
const keyNumber = Number.parseInt(key, 10);
if (!Number.isNaN(keyNumber)) {
entityMap.set(keyNumber, value);
}
}
}
const outputLines: string[] = [];
let orderedListCounter = 0;
let previousBlockType: string | undefined;
for (const block of contentState.blocks) {
// Reset ordered list counter when leaving ordered list context
if (block.type !== 'ordered-list-item' && previousBlockType === 'ordered-list-item') {
orderedListCounter = 0;
}
switch (block.type) {
case 'unstyled': {
// Plain paragraph - just output text with any inline formatting
const text = renderBlockText(block, entityMap);
if (text) {
outputLines.push(text);
}
break;
}
case 'header-one': {
const text = renderBlockText(block, entityMap);
if (text) {
outputLines.push(`# ${text}`);
}
break;
}
case 'header-two': {
const text = renderBlockText(block, entityMap);
if (text) {
outputLines.push(`## ${text}`);
}
break;
}
case 'header-three': {
const text = renderBlockText(block, entityMap);
if (text) {
outputLines.push(`### ${text}`);
}
break;
}
case 'unordered-list-item': {
const text = renderBlockText(block, entityMap);
if (text) {
outputLines.push(`- ${text}`);
}
break;
}
case 'ordered-list-item': {
orderedListCounter++;
const text = renderBlockText(block, entityMap);
if (text) {
outputLines.push(`${orderedListCounter}. ${text}`);
}
break;
}
case 'blockquote': {
const text = renderBlockText(block, entityMap);
if (text) {
outputLines.push(`> ${text}`);
}
break;
}
case 'atomic': {
// Atomic blocks are placeholders for embedded entities
const entityContent = renderAtomicBlock(block, entityMap);
if (entityContent) {
outputLines.push(entityContent);
}
break;
}
default: {
// Fallback: just output the text
const text = renderBlockText(block, entityMap);
if (text) {
outputLines.push(text);
}
}
}
previousBlockType = block.type;
}
const result = outputLines.join('\n\n');
return result.trim() || undefined;
}
/**
* Renders text content of a block, applying inline link entities.
*/
function renderBlockText(block: ContentBlock, entityMap: Map<number, EntityValue>): string {
let text = block.text;
// Handle LINK entities by appending URL in markdown format
// Process in reverse order to not mess up offsets
const linkRanges = (block.entityRanges ?? [])
.filter((range) => {
const entity = entityMap.get(range.key);
return entity?.type === 'LINK' && entity.data.url;
})
.sort((a, b) => b.offset - a.offset);
for (const range of linkRanges) {
const entity = entityMap.get(range.key);
if (entity?.data.url) {
const linkText = text.slice(range.offset, range.offset + range.length);
const markdownLink = `[${linkText}](${entity.data.url})`;
text = text.slice(0, range.offset) + markdownLink + text.slice(range.offset + range.length);
}
}
return text.trim();
}
/**
* Renders an atomic block by looking up its entity and returning appropriate content.
*/
function renderAtomicBlock(block: ContentBlock, entityMap: Map<number, EntityValue>): string | undefined {
const entityRanges = block.entityRanges ?? [];
if (entityRanges.length === 0) {
return undefined;
}
const entityKey = entityRanges[0].key;
const entity = entityMap.get(entityKey);
if (!entity) {
return undefined;
}
switch (entity.type) {
case 'MARKDOWN':
// Code blocks and other markdown content - output as-is
return entity.data.markdown?.trim();
case 'DIVIDER':
return '---';
case 'TWEET':
if (entity.data.tweetId) {
return `[Embedded Tweet: https://x.com/i/status/${entity.data.tweetId}]`;
}
return undefined;
case 'LINK':
if (entity.data.url) {
return `[Link: ${entity.data.url}]`;
}
return undefined;
case 'IMAGE':
// Images in atomic blocks - could extract URL if available
return '[Image]';
default:
return undefined;
}
}
export function extractArticleText(result: GraphqlTweetResult | undefined): string | undefined {
const article = result?.article;
if (!article) {
return undefined;
}
const articleResult = article.article_results?.result ?? article;
if (process.env.BIRD_DEBUG_ARTICLE === '1') {
console.error(
'[bird][debug][article] payload:',
JSON.stringify(
{
rest_id: result?.rest_id,
article: articleResult,
note_tweet: result?.note_tweet?.note_tweet_results?.result ?? null,
},
null,
2,
),
);
}
const title = firstText(articleResult.title, article.title);
// Try to render from rich content_state first (Draft.js format with blocks + entityMap)
// This preserves code blocks, embedded tweets, markdown, etc.
const contentState = article.article_results?.result?.content_state;
const richBody = renderContentState(contentState);
if (richBody) {
// Rich content found - prepend title if not already included
if (title) {
const normalizedTitle = title.trim();
const trimmedBody = richBody.trimStart();
const headingMatches = [`# ${normalizedTitle}`, `## ${normalizedTitle}`, `### ${normalizedTitle}`];
const hasTitle =
trimmedBody === normalizedTitle ||
trimmedBody.startsWith(`${normalizedTitle}\n`) ||
headingMatches.some((heading) => trimmedBody.startsWith(heading));
if (!hasTitle) {
return `${title}\n\n${richBody}`;
}
}
return richBody;
}
// Fallback to plain text extraction for articles without rich content_state
let body = firstText(
articleResult.plain_text,
article.plain_text,
articleResult.body?.text,
articleResult.body?.richtext?.text,
articleResult.body?.rich_text?.text,
articleResult.content?.text,
articleResult.content?.richtext?.text,
articleResult.content?.rich_text?.text,
articleResult.text,
articleResult.richtext?.text,
articleResult.rich_text?.text,
article.body?.text,
article.body?.richtext?.text,
article.body?.rich_text?.text,
article.content?.text,
article.content?.richtext?.text,
article.content?.rich_text?.text,
article.text,
article.richtext?.text,
article.rich_text?.text,
);
if (body && title && body.trim() === title.trim()) {
body = undefined;
}
if (!body) {
const collected: string[] = [];
collectTextFields(articleResult, new Set(['text', 'title']), collected);
collectTextFields(article, new Set(['text', 'title']), collected);
const unique = uniqueOrdered(collected);
const filtered = title ? unique.filter((value) => value !== title) : unique;
if (filtered.length > 0) {
body = filtered.join('\n\n');
}
}
if (title && body && !body.startsWith(title)) {
return `${title}\n\n${body}`;
}
return body ?? title;
}
export function extractNoteTweetText(result: GraphqlTweetResult | undefined): string | undefined {
const note = result?.note_tweet?.note_tweet_results?.result;
if (!note) {
return undefined;
}
return firstText(
note.text,
note.richtext?.text,
note.rich_text?.text,
note.content?.text,
note.content?.richtext?.text,
note.content?.rich_text?.text,
);
}
export function extractTweetText(result: GraphqlTweetResult | undefined): string | undefined {
return extractArticleText(result) ?? extractNoteTweetText(result) ?? firstText(result?.legacy?.full_text);
}
export function extractArticleMetadata(
result: GraphqlTweetResult | undefined,
): { title: string; previewText?: string } | undefined {
const article = result?.article;
if (!article) {
return undefined;
}
const articleResult = article.article_results?.result ?? article;
const title = firstText(articleResult.title, article.title);
if (!title) {
return undefined;
}
// preview_text is available in home timeline responses
const previewText = firstText(articleResult.preview_text, article.preview_text);
return { title, previewText };
}
export function extractMedia(result: GraphqlTweetResult | undefined): TweetMedia[] | undefined {
// Prefer extended_entities (has video info), fall back to entities
const rawMedia = result?.legacy?.extended_entities?.media ?? result?.legacy?.entities?.media;
if (!rawMedia || rawMedia.length === 0) {
return undefined;
}
const media: TweetMedia[] = [];
for (const item of rawMedia) {
if (!item.type || !item.media_url_https) {
continue;
}
const mediaItem: TweetMedia = {
type: item.type,
url: item.media_url_https,
};
// Get dimensions from largest available size
const sizes = item.sizes;
if (sizes?.large) {
mediaItem.width = sizes.large.w;
mediaItem.height = sizes.large.h;
} else if (sizes?.medium) {
mediaItem.width = sizes.medium.w;
mediaItem.height = sizes.medium.h;
}
// For thumbnails/previews
if (sizes?.small) {
mediaItem.previewUrl = `${item.media_url_https}:small`;
}
// Extract video URL for video/animated_gif
if ((item.type === 'video' || item.type === 'animated_gif') && item.video_info?.variants) {
// Prefer highest bitrate MP4, fall back to first MP4 when bitrate is missing.
const mp4Variants = item.video_info.variants.filter(
(v): v is { bitrate?: number; content_type: string; url: string } =>
v.content_type === 'video/mp4' && typeof v.url === 'string',
);
const mp4WithBitrate = mp4Variants
.filter((v): v is { bitrate: number; content_type: string; url: string } => typeof v.bitrate === 'number')
.sort((a, b) => b.bitrate - a.bitrate);
const selectedVariant = mp4WithBitrate[0] ?? mp4Variants[0];
if (selectedVariant) {
mediaItem.videoUrl = selectedVariant.url;
}
if (typeof item.video_info.duration_millis === 'number') {
mediaItem.durationMs = item.video_info.duration_millis;
}
}
media.push(mediaItem);
}
return media.length > 0 ? media : undefined;
}
export function unwrapTweetResult(result: GraphqlTweetResult | undefined): GraphqlTweetResult | undefined {
if (!result) {
return undefined;
}
if (result.tweet) {
return result.tweet;
}
return result;
}
export interface MapTweetResultOptions {
quoteDepth: number;
includeRaw?: boolean;
}
export function mapTweetResult(
result: GraphqlTweetResult | undefined,
quoteDepthOrOptions: number | MapTweetResultOptions,
): TweetData | undefined {
const options: MapTweetResultOptions =
typeof quoteDepthOrOptions === 'number' ? { quoteDepth: quoteDepthOrOptions } : quoteDepthOrOptions;
const { quoteDepth, includeRaw = false } = options;
const userResult = result?.core?.user_results?.result;
const userLegacy = userResult?.legacy;
const userCore = userResult?.core;
const username = userLegacy?.screen_name ?? userCore?.screen_name;
const name = userLegacy?.name ?? userCore?.name ?? username;
const userId = userResult?.rest_id;
if (!result?.rest_id || !username) {
return undefined;
}
const text = extractTweetText(result);
if (!text) {
return undefined;
}
let quotedTweet: TweetData | undefined;
if (quoteDepth > 0) {
const quotedResult = unwrapTweetResult(result.quoted_status_result?.result);
if (quotedResult) {
quotedTweet = mapTweetResult(quotedResult, { quoteDepth: quoteDepth - 1, includeRaw });
}
}
const media = extractMedia(result);
const article = extractArticleMetadata(result);
const tweetData: TweetData = {
id: result.rest_id,
text,
createdAt: result.legacy?.created_at,
replyCount: result.legacy?.reply_count,
retweetCount: result.legacy?.retweet_count,
likeCount: result.legacy?.favorite_count,
conversationId: result.legacy?.conversation_id_str,
inReplyToStatusId: result.legacy?.in_reply_to_status_id_str ?? undefined,
author: {
username,
name: name || username,
},
authorId: userId,
quotedTweet,
media,
article,
};
if (includeRaw) {
(tweetData as TweetData & { _raw: GraphqlTweetResult })._raw = result;
}
return tweetData;
}
export function findTweetInInstructions(
instructions:
| Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>
| undefined,
tweetId: string,
): GraphqlTweetResult | undefined {
if (!instructions) {
return undefined;
}
for (const instruction of instructions) {
for (const entry of instruction.entries || []) {
const result = entry.content?.itemContent?.tweet_results?.result;
if (result?.rest_id === tweetId) {
return result;
}
}
}
return undefined;
}
export function collectTweetResultsFromEntry(entry: {
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
item?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
items?: Array<{
item?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
};
}): GraphqlTweetResult[] {
const results: GraphqlTweetResult[] = [];
const pushResult = (result?: GraphqlTweetResult) => {
if (result?.rest_id) {
results.push(result);
}
};
const content = entry.content;
pushResult(content?.itemContent?.tweet_results?.result);
pushResult(content?.item?.itemContent?.tweet_results?.result);
for (const item of content?.items ?? []) {
pushResult(item?.item?.itemContent?.tweet_results?.result);
pushResult(item?.itemContent?.tweet_results?.result);
pushResult(item?.content?.itemContent?.tweet_results?.result);
}
return results;
}
export interface ParseTweetsOptions {
quoteDepth: number;
includeRaw?: boolean;
}
export function parseTweetsFromInstructions(
instructions:
| Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
item?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
items?: Array<{
item?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
};
}>;
}>
| undefined,
quoteDepthOrOptions: number | ParseTweetsOptions,
): TweetData[] {
const options: ParseTweetsOptions =
typeof quoteDepthOrOptions === 'number' ? { quoteDepth: quoteDepthOrOptions } : quoteDepthOrOptions;
const { quoteDepth, includeRaw = false } = options;
const tweets: TweetData[] = [];
const seen = new Set<string>();
for (const instruction of instructions ?? []) {
for (const entry of instruction.entries ?? []) {
const results = collectTweetResultsFromEntry(entry);
for (const result of results) {
const mapped = mapTweetResult(result, { quoteDepth, includeRaw });
if (!mapped || seen.has(mapped.id)) {
continue;
}
seen.add(mapped.id);
tweets.push(mapped);
}
}
}
return tweets;
}
export function extractCursorFromInstructions(
instructions:
| Array<{
entries?: Array<{
content?: unknown;
}>;
}>
| undefined,
cursorType = 'Bottom',
): string | undefined {
for (const instruction of instructions ?? []) {
for (const entry of instruction.entries ?? []) {
const content = entry.content as { cursorType?: unknown; value?: unknown } | undefined;
if (content?.cursorType === cursorType && typeof content.value === 'string' && content.value.length > 0) {
return content.value;
}
}
}
return undefined;
}
export function parseUsersFromInstructions(
instructions: Array<{ type?: string; entries?: Array<unknown> }> | undefined,
): TwitterUser[] {
if (!instructions) {
return [];
}
const users: TwitterUser[] = [];
for (const instruction of instructions) {
if (!instruction.entries) {
continue;
}
for (const entry of instruction.entries) {
const content = (entry as { content?: { itemContent?: { user_results?: { result?: unknown } } } })?.content;
const rawUserResult = content?.itemContent?.user_results?.result as
| {
__typename?: string;
rest_id?: string;
is_blue_verified?: boolean;
user?: unknown;
legacy?: {
screen_name?: string;
name?: string;
description?: string;
followers_count?: number;
friends_count?: number;
profile_image_url_https?: string;
created_at?: string;
};
core?: {
screen_name?: string;
name?: string;
created_at?: string;
};
avatar?: {
image_url?: string;
};
}
| undefined;
const userResult =
rawUserResult?.__typename === 'UserWithVisibilityResults' && rawUserResult.user
? (rawUserResult.user as typeof rawUserResult)
: rawUserResult;
if (!userResult || userResult.__typename !== 'User') {
continue;
}
const legacy = userResult.legacy;
const core = userResult.core;
const username = legacy?.screen_name ?? core?.screen_name;
if (!userResult.rest_id || !username) {
continue;
}
users.push({
id: userResult.rest_id,
username,
name: legacy?.name ?? core?.name ?? username,
description: legacy?.description,
followersCount: legacy?.followers_count,
followingCount: legacy?.friends_count,
isBlueVerified: userResult.is_blue_verified,
profileImageUrl: legacy?.profile_image_url_https ?? userResult.avatar?.image_url,
createdAt: legacy?.created_at ?? core?.created_at,
});
}
}
return users;
}
+58 -1643
View File
File diff suppressed because it is too large Load Diff
+46 -17
View File
@@ -5,6 +5,9 @@ import { fileURLToPath } from 'node:url';
declare const __dirname: string | undefined;
export const FALLBACK_VERSION = 'unknown';
const REF_PREFIX_REGEX = /^ref:\s*/i;
const LINE_SPLIT_REGEX = /\r?\n/;
const GITDIR_REGEX = /gitdir:\s*(.+)\s*$/i;
function readPackageVersionFromJsonFile(candidate: string): string | null {
try {
@@ -37,7 +40,9 @@ function resolveStartDir(importMetaUrl?: string): string {
}
}
if (typeof __dirname === 'string' && __dirname.length > 0) return __dirname;
if (typeof __dirname === 'string' && __dirname.length > 0) {
return __dirname;
}
return process.cwd();
}
@@ -47,27 +52,37 @@ export function resolvePackageVersion(importMetaUrl?: string): string {
typeof process !== 'undefined' && typeof process.env.BIRD_VERSION === 'string'
? process.env.BIRD_VERSION.trim()
: '';
if (injected.length > 0) return injected;
if (injected.length > 0) {
return injected;
}
let dir = resolveStartDir(importMetaUrl);
for (let i = 0; i < 10; i += 1) {
const version =
readPackageVersionFromJsonFile(path.join(dir, 'package.json')) ??
readVersionFromTextFile(path.join(dir, 'VERSION'));
if (version) return version;
if (version) {
return version;
}
const parent = path.dirname(dir);
if (parent === dir) break;
if (parent === dir) {
break;
}
dir = parent;
}
return FALLBACK_VERSION;
}
function truncateSha(sha: string, length = 8): string {
function truncateSha(sha: string, length: number = 8): string {
const trimmed = sha.trim();
if (!trimmed) return '';
if (trimmed.length <= length) return trimmed;
if (!trimmed) {
return '';
}
if (trimmed.length <= length) {
return trimmed;
}
return trimmed.slice(0, length);
}
@@ -80,14 +95,18 @@ function resolveGitShaFromGitDir(gitDir: string): string | null {
return null;
}
if (!head) return null;
if (!head) {
return null;
}
if (!head.startsWith('ref:')) {
const sha = truncateSha(head);
return sha.length > 0 ? sha : null;
}
const ref = head.replace(/^ref:\s*/i, '').trim();
if (!ref) return null;
const ref = head.replace(REF_PREFIX_REGEX, '').trim();
if (!ref) {
return null;
}
const refPath = path.join(gitDir, ref);
try {
@@ -100,9 +119,11 @@ function resolveGitShaFromGitDir(gitDir: string): string | null {
const packedRefsPath = path.join(gitDir, 'packed-refs');
try {
const packed = fs.readFileSync(packedRefsPath, 'utf8');
const lines = packed.split(/\r?\n/);
const lines = packed.split(LINE_SPLIT_REGEX);
for (const line of lines) {
if (!line || line.startsWith('#') || line.startsWith('^')) continue;
if (!line || line.startsWith('#') || line.startsWith('^')) {
continue;
}
const [shaRaw, refName] = line.split(' ');
if (refName?.trim() === ref) {
const sha = truncateSha(shaRaw ?? '');
@@ -121,7 +142,9 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
typeof process !== 'undefined' && typeof process.env.BIRD_GIT_SHA === 'string'
? process.env.BIRD_GIT_SHA.trim()
: '';
if (injected.length > 0) return truncateSha(injected);
if (injected.length > 0) {
return truncateSha(injected);
}
let dir = resolveStartDir(importMetaUrl);
for (let i = 0; i < 10; i += 1) {
@@ -130,15 +153,19 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
const stat = fs.statSync(dotGit);
if (stat.isDirectory()) {
const sha = resolveGitShaFromGitDir(dotGit);
if (sha) return sha;
if (sha) {
return sha;
}
} else if (stat.isFile()) {
const txt = fs.readFileSync(dotGit, 'utf8');
const match = txt.match(/gitdir:\s*(.+)\s*$/i);
const match = GITDIR_REGEX.exec(txt);
const gitDir = match?.[1]?.trim();
if (gitDir) {
const resolved = path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir);
const sha = resolveGitShaFromGitDir(resolved);
if (sha) return sha;
if (sha) {
return sha;
}
}
}
} catch {
@@ -146,7 +173,9 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
}
const parent = path.dirname(dir);
if (parent === dir) break;
if (parent === dir) {
break;
}
dir = parent;
}
+6 -9
View File
@@ -11,6 +11,10 @@ describe('cli-args', () => {
'thread',
'search',
'mentions',
'bookmarks',
'following',
'followers',
'likes',
'help',
'whoami',
'check',
@@ -45,15 +49,8 @@ describe('cli-args', () => {
});
it('preserves leading options before inferred read command', () => {
const result = resolveCliInvocation(['--engine', 'sweetistics', 'https://x.com/user/status/1234567890'], known);
expect(result.argv).toEqual([
'node',
'bird',
'--engine',
'sweetistics',
'read',
'https://x.com/user/status/1234567890',
]);
const result = resolveCliInvocation(['--plain', 'https://x.com/user/status/1234567890'], known);
expect(result.argv).toEqual(['node', 'bird', '--plain', 'read', 'https://x.com/user/status/1234567890']);
});
it('does not rewrite when a known command is provided', () => {
+84
View File
@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest';
import { extractBookmarkFolderId } from '../src/lib/extract-bookmark-folder-id.js';
import { extractListId } from '../src/lib/extract-list-id.js';
import { extractTweetId } from '../src/lib/extract-tweet-id.js';
describe('CLI utilities', () => {
@@ -18,6 +20,11 @@ describe('CLI utilities', () => {
expect(extractTweetId(url)).toBe('1234567890123456789');
});
it('should extract ID from i/web/status URLs', () => {
const url = 'https://x.com/i/web/status/1234567890123456789';
expect(extractTweetId(url)).toBe('1234567890123456789');
});
it('should return ID as-is if already an ID', () => {
const id = '1234567890123456789';
expect(extractTweetId(id)).toBe('1234567890123456789');
@@ -29,4 +36,81 @@ describe('CLI utilities', () => {
expect(extractTweetId(url)).toBe('1234567890123456789');
});
});
describe('extractBookmarkFolderId', () => {
it('should extract ID from x.com bookmarks URL', () => {
const url = 'https://x.com/i/bookmarks/1976792203235119344';
expect(extractBookmarkFolderId(url)).toBe('1976792203235119344');
});
it('should extract ID from twitter.com bookmarks URL', () => {
const url = 'https://twitter.com/i/bookmarks/1976792203235119344';
expect(extractBookmarkFolderId(url)).toBe('1976792203235119344');
});
it('should accept a numeric ID as-is', () => {
const id = '1976792203235119344';
expect(extractBookmarkFolderId(id)).toBe('1976792203235119344');
});
it('should return null for invalid values', () => {
expect(extractBookmarkFolderId('not-an-id')).toBeNull();
});
it('should return null for folder_id query URLs', () => {
const url = 'https://x.com/i/bookmarks?folder_id=1976792203235119344';
expect(extractBookmarkFolderId(url)).toBeNull();
});
});
describe('extractListId', () => {
it('should extract ID from x.com list URL', () => {
const url = 'https://x.com/i/lists/1234567890123456789';
expect(extractListId(url)).toBe('1234567890123456789');
});
it('should extract ID from twitter.com list URL', () => {
const url = 'https://twitter.com/i/lists/1234567890123456789';
expect(extractListId(url)).toBe('1234567890123456789');
});
it('should extract ID from URL with query params', () => {
const url = 'https://x.com/i/lists/1234567890123456789?s=20';
expect(extractListId(url)).toBe('1234567890123456789');
});
it('should accept a numeric ID as-is', () => {
const id = '1234567890123456789';
expect(extractListId(id)).toBe('1234567890123456789');
});
it('should return null for invalid values', () => {
expect(extractListId('not-an-id')).toBeNull();
});
it('should return null for empty string', () => {
expect(extractListId('')).toBeNull();
});
it('should return null for whitespace-only string', () => {
expect(extractListId(' ')).toBeNull();
});
it('should return null for short numeric IDs', () => {
expect(extractListId('1234')).toBeNull();
});
it('should accept minimum 5-digit IDs', () => {
expect(extractListId('12345')).toBe('12345');
});
it('should trim whitespace from input', () => {
expect(extractListId(' 1234567890123456789 ')).toBe('1234567890123456789');
});
it('should extract ID from URL with fragment', () => {
const url = 'https://x.com/i/lists/1234567890123456789#section';
expect(extractListId(url)).toBe('1234567890123456789');
});
});
});
+30
View File
@@ -0,0 +1,30 @@
import { Command } from 'commander';
import { describe, expect, it, vi } from 'vitest';
import type { CliContext } from '../src/cli/shared.js';
import { registerBookmarksCommand } from '../src/commands/bookmarks.js';
describe('bookmarks command', () => {
it('requires --all or --cursor when --max-pages is provided', async () => {
const program = new Command();
const ctx = {
resolveTimeoutFromOptions: () => undefined,
resolveRelayBaseUrlFromOptions: () => 'https://relay.example',
p: () => '',
printTweets: () => undefined,
} as unknown as CliContext;
registerBookmarksCommand(program, ctx);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'bookmarks', '--max-pages', '2'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--max-pages requires --all or --cursor'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
});
+155
View File
@@ -0,0 +1,155 @@
import { Command } from 'commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CliContext } from '../src/cli/shared.js';
import { registerNewsCommand } from '../src/commands/news.js';
import { TwitterClient } from '../src/lib/twitter-client.js';
describe('news command', () => {
let program: Command;
let mockContext: Partial<CliContext>;
beforeEach(() => {
program = new Command();
mockContext = {
resolveTimeoutFromOptions: () => 30000,
resolveQuoteDepthFromOptions: () => undefined,
resolveRelayBaseUrlFromOptions: () => 'https://relay.example',
p: (type: string) => `[${type}] `,
colors: {
accent: (text: string) => text,
command: (text: string) => text,
muted: (text: string) => text,
section: (text: string) => text,
},
l: (key: string) => key,
};
});
it('requires positive count value', async () => {
registerNewsCommand(program, mockContext as CliContext);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'news', '--count', '0'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--count must be a positive number'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('rejects negative count value', async () => {
registerNewsCommand(program, mockContext as CliContext);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'news', '--count', '-5'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--count must be a positive number'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('rejects non-numeric count value', async () => {
registerNewsCommand(program, mockContext as CliContext);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'news', '--count', 'abc'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--count must be a positive number'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('requires positive tweets-per-item value', async () => {
registerNewsCommand(program, mockContext as CliContext);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'news', '--tweets-per-item', '0'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--tweets-per-item must be a positive number'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('rejects negative tweets-per-item value', async () => {
registerNewsCommand(program, mockContext as CliContext);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'news', '--tweets-per-item', '-3'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--tweets-per-item must be a positive number'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('rejects non-numeric tweets-per-item value', async () => {
registerNewsCommand(program, mockContext as CliContext);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'news', '--tweets-per-item', 'xyz'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--tweets-per-item must be a positive number'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('limits related tweets to tweets-per-item in CLI output', async () => {
registerNewsCommand(program, mockContext as CliContext);
const getNewsSpy = vi.spyOn(TwitterClient.prototype, 'getNews').mockResolvedValue({
success: true,
items: [
{
id: 'item-1',
headline: 'News headline',
category: 'News',
tweets: [
{ id: 't1', text: 'first tweet', author: { username: 'a', name: 'A' } },
{ id: 't2', text: 'second tweet', author: { username: 'b', name: 'B' } },
{ id: 't3', text: 'third tweet', author: { username: 'c', name: 'C' } },
],
},
],
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
try {
await program.parseAsync(['node', 'bird', 'news', '--with-tweets', '--tweets-per-item', '2', '--count', '1']);
expect(getNewsSpy).toHaveBeenCalledWith(1, expect.objectContaining({ tweetsPerItem: 2, withTweets: true }));
const tweetLines = logSpy.mock.calls
.map((call) => String(call[0]))
.filter((line) => line.trimStart().startsWith('@'));
expect(tweetLines).toHaveLength(2);
} finally {
getNewsSpy.mockRestore();
logSpy.mockRestore();
}
});
});
+149
View File
@@ -0,0 +1,149 @@
import { Command } from 'commander';
import { describe, expect, it, vi } from 'vitest';
import type { CliContext } from '../src/cli/shared.js';
import { registerReadCommands } from '../src/commands/read.js';
import { TwitterClient } from '../src/lib/twitter-client.js';
describe('replies command', () => {
const createMockContext = () =>
({
resolveTimeoutFromOptions: () => undefined,
resolveQuoteDepthFromOptions: () => 1,
extractTweetId: (input: string) => input,
resolveRelayBaseUrlFromOptions: () => 'https://relay.example',
p: () => '',
printTweets: () => undefined,
printTweetsResult: () => undefined,
}) as unknown as CliContext;
it('uses pagination when --max-pages is provided', async () => {
const program = new Command();
registerReadCommands(program, createMockContext());
const pagedSpy = vi
.spyOn(TwitterClient.prototype, 'getRepliesPaged')
.mockResolvedValue({ success: true, tweets: [], nextCursor: undefined });
const unpagedSpy = vi.spyOn(TwitterClient.prototype, 'getReplies').mockResolvedValue({ success: true, tweets: [] });
try {
await program.parseAsync(['node', 'bird', 'replies', '123', '--max-pages', '2', '--json']);
expect(pagedSpy).toHaveBeenCalledTimes(1);
expect(unpagedSpy).toHaveBeenCalledTimes(0);
} finally {
pagedSpy.mockRestore();
unpagedSpy.mockRestore();
}
});
it('validates --max-pages is a positive integer', async () => {
const program = new Command();
registerReadCommands(program, createMockContext());
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(
program.parseAsync(['node', 'bird', 'replies', '123', '--all', '--max-pages', '-1']),
).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid --max-pages'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('validates --delay is a non-negative integer', async () => {
const program = new Command();
registerReadCommands(program, createMockContext());
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'replies', '123', '--all', '--delay', '-100'])).rejects.toThrow(
'exit 1',
);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid --delay'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
});
describe('thread command', () => {
const createMockContext = () =>
({
resolveTimeoutFromOptions: () => undefined,
resolveQuoteDepthFromOptions: () => 1,
extractTweetId: (input: string) => input,
resolveRelayBaseUrlFromOptions: () => 'https://relay.example',
p: () => '',
printTweets: () => undefined,
printTweetsResult: () => undefined,
}) as unknown as CliContext;
it('uses pagination when --max-pages is provided', async () => {
const program = new Command();
registerReadCommands(program, createMockContext());
const pagedSpy = vi
.spyOn(TwitterClient.prototype, 'getThreadPaged')
.mockResolvedValue({ success: true, tweets: [], nextCursor: undefined });
const unpagedSpy = vi.spyOn(TwitterClient.prototype, 'getThread').mockResolvedValue({ success: true, tweets: [] });
try {
await program.parseAsync(['node', 'bird', 'thread', '123', '--max-pages', '2', '--json']);
expect(pagedSpy).toHaveBeenCalledTimes(1);
expect(unpagedSpy).toHaveBeenCalledTimes(0);
} finally {
pagedSpy.mockRestore();
unpagedSpy.mockRestore();
}
});
it('validates --max-pages is a positive integer', async () => {
const program = new Command();
registerReadCommands(program, createMockContext());
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'thread', '123', '--all', '--max-pages', '0'])).rejects.toThrow(
'exit 1',
);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid --max-pages'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('validates --delay is a non-negative integer', async () => {
const program = new Command();
registerReadCommands(program, createMockContext());
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'thread', '123', '--all', '--delay', 'abc'])).rejects.toThrow(
'exit 1',
);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid --delay'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
});
+116
View File
@@ -0,0 +1,116 @@
import { Command } from 'commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CliContext } from '../src/cli/shared.js';
import { registerSearchCommands } from '../src/commands/search.js';
import { TwitterClient } from '../src/lib/twitter-client.js';
describe('search command', () => {
let program: Command;
let mockContext: Partial<CliContext>;
beforeEach(() => {
program = new Command();
mockContext = {
resolveTimeoutFromOptions: () => undefined,
resolveQuoteDepthFromOptions: () => undefined,
resolveRelayBaseUrlFromOptions: () => 'https://relay.example',
p: () => '',
printTweetsResult: vi.fn(),
};
});
it('requires --all or --cursor when --max-pages is provided', async () => {
registerSearchCommands(program, mockContext as CliContext);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'search', 'cats', '--max-pages', '2'])).rejects.toThrow(
'exit 1',
);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--max-pages requires --all or --cursor'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('requires positive --count when not paging', async () => {
registerSearchCommands(program, mockContext as CliContext);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'search', 'cats', '--count', '0'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid --count. Expected a positive integer.'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('requires positive --max-pages when paging', async () => {
registerSearchCommands(program, mockContext as CliContext);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'search', 'cats', '--all', '--max-pages', '0'])).rejects.toThrow(
'exit 1',
);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid --max-pages. Expected a positive integer.'),
);
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('uses paged search when --all is set', async () => {
registerSearchCommands(program, mockContext as CliContext);
const getAllSpy = vi
.spyOn(TwitterClient.prototype, 'getAllSearchResults')
.mockResolvedValue({ success: true, tweets: [] });
const searchSpy = vi.spyOn(TwitterClient.prototype, 'search').mockResolvedValue({ success: true, tweets: [] });
try {
await program.parseAsync(['node', 'bird', 'search', 'cats', '--all', '--json']);
expect(getAllSpy).toHaveBeenCalledWith('cats', expect.objectContaining({ includeRaw: false }));
expect(searchSpy).not.toHaveBeenCalled();
expect(mockContext.printTweetsResult).toHaveBeenCalledWith(expect.objectContaining({ tweets: [] }), {
json: true,
usePagination: true,
emptyMessage: 'No tweets found.',
});
} finally {
getAllSpy.mockRestore();
searchSpy.mockRestore();
}
});
it('uses cursor pagination when --cursor is set', async () => {
registerSearchCommands(program, mockContext as CliContext);
const getAllSpy = vi
.spyOn(TwitterClient.prototype, 'getAllSearchResults')
.mockResolvedValue({ success: true, tweets: [] });
try {
await program.parseAsync(['node', 'bird', 'search', 'cats', '--cursor', 'cursor-1']);
expect(getAllSpy).toHaveBeenCalledWith('cats', expect.objectContaining({ cursor: 'cursor-1' }));
expect(mockContext.printTweetsResult).toHaveBeenCalledWith(expect.objectContaining({ tweets: [] }), {
json: false,
usePagination: true,
emptyMessage: 'No tweets found.',
});
} finally {
getAllSpy.mockRestore();
}
});
});
+211
View File
@@ -0,0 +1,211 @@
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { CliContext } from '../src/cli/shared.js';
import { registerUserCommands } from '../src/commands/users.js';
import { TwitterClient } from '../src/lib/twitter-client.js';
const baseCtx = {
resolveTimeoutFromOptions: () => undefined,
resolveRelayBaseUrlFromOptions: () => 'https://relay.example',
p: () => '',
printTweets: () => undefined,
} as unknown as CliContext;
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('users commands', () => {
it('requires --all when --max-pages is provided', async () => {
const program = new Command();
registerUserCommands(program, baseCtx);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'following', '--max-pages', '2'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--max-pages requires --all.'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('rejects --max-pages when only --cursor is provided', async () => {
const program = new Command();
registerUserCommands(program, baseCtx);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(
program.parseAsync(['node', 'bird', 'following', '--cursor', 'prev', '--max-pages', '2']),
).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--max-pages requires --all.'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('emits pagination JSON when --cursor is provided', async () => {
const program = new Command();
registerUserCommands(program, baseCtx);
vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({
success: true,
user: { id: '123', username: 'tester', name: 'Tester' },
});
vi.spyOn(TwitterClient.prototype, 'getFollowing').mockResolvedValue({
success: true,
users: [{ id: '1', username: 'alpha', name: 'Alpha' }],
nextCursor: 'next-1',
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
await program.parseAsync(['node', 'bird', 'following', '--cursor', 'prev', '--json']);
const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0]));
expect(payload.users).toHaveLength(1);
expect(payload.nextCursor).toBe('next-1');
});
it('prints next cursor in non-JSON single-page mode', async () => {
const program = new Command();
registerUserCommands(program, baseCtx);
vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({
success: true,
user: { id: '123', username: 'tester', name: 'Tester' },
});
vi.spyOn(TwitterClient.prototype, 'getFollowing').mockResolvedValue({
success: true,
users: [{ id: '1', username: 'alpha', name: 'Alpha' }],
nextCursor: 'next-1',
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
await program.parseAsync(['node', 'bird', 'following', '--cursor', 'prev']);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Next cursor: next-1'));
expect(logSpy).toHaveBeenCalled();
});
it('emits pagination JSON for --all results', async () => {
const program = new Command();
registerUserCommands(program, baseCtx);
vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({
success: true,
user: { id: '123', username: 'tester', name: 'Tester' },
});
vi.spyOn(TwitterClient.prototype, 'getFollowing').mockResolvedValue({
success: true,
users: [{ id: '1', username: 'alpha', name: 'Alpha' }],
nextCursor: 'next-1',
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
await program.parseAsync(['node', 'bird', 'following', '--all', '--max-pages', '1', '--json']);
const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0]));
expect(payload.users).toHaveLength(1);
expect(payload.nextCursor).toBe('next-1');
});
it('prints next cursor when stopping at max pages in non-JSON mode', async () => {
vi.useFakeTimers();
const program = new Command();
registerUserCommands(program, baseCtx);
vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({
success: true,
user: { id: '123', username: 'tester', name: 'Tester' },
});
vi.spyOn(TwitterClient.prototype, 'getFollowing').mockResolvedValue({
success: true,
users: [{ id: '1', username: 'alpha', name: 'Alpha' }],
nextCursor: 'next-1',
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const run = program.parseAsync(['node', 'bird', 'following', '--all', '--max-pages', '1']);
await vi.advanceTimersByTimeAsync(1000);
await run;
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Next cursor: next-1'));
expect(logSpy).toHaveBeenCalled();
});
it('deduplicates users across pages in --all mode', async () => {
vi.useFakeTimers();
const program = new Command();
registerUserCommands(program, baseCtx);
vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({
success: true,
user: { id: '123', username: 'tester', name: 'Tester' },
});
const followingSpy = vi.spyOn(TwitterClient.prototype, 'getFollowing');
followingSpy
.mockResolvedValueOnce({
success: true,
users: [
{ id: '1', username: 'alpha', name: 'Alpha' },
{ id: '2', username: 'beta', name: 'Beta' },
],
nextCursor: 'cursor-1',
})
.mockResolvedValueOnce({
success: true,
users: [
{ id: '2', username: 'beta', name: 'Beta' },
{ id: '3', username: 'gamma', name: 'Gamma' },
],
nextCursor: undefined,
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const run = program.parseAsync(['node', 'bird', 'following', '--all', '--json']);
await vi.advanceTimersByTimeAsync(1000);
await run;
const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0]));
expect(payload.users).toHaveLength(3);
expect(payload.users.map((user: { id: string }) => user.id)).toEqual(['1', '2', '3']);
});
it('stops pagination when cursor repeats or no new users are added', async () => {
vi.useFakeTimers();
const program = new Command();
registerUserCommands(program, baseCtx);
vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({
success: true,
user: { id: '123', username: 'tester', name: 'Tester' },
});
const followingSpy = vi.spyOn(TwitterClient.prototype, 'getFollowing');
followingSpy
.mockResolvedValueOnce({
success: true,
users: [{ id: '1', username: 'alpha', name: 'Alpha' }],
nextCursor: 'cursor-1',
})
.mockResolvedValueOnce({
success: true,
users: [{ id: '1', username: 'alpha', name: 'Alpha' }],
nextCursor: 'cursor-1',
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const run = program.parseAsync(['node', 'bird', 'following', '--all', '--json']);
await vi.advanceTimersByTimeAsync(1000);
await run;
const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0]));
expect(payload.users).toHaveLength(1);
expect(payload.nextCursor).toBeNull();
expect(followingSpy).toHaveBeenCalledTimes(2);
});
});
-240
View File
@@ -1,240 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock child_process.execSync to prevent actual shell commands
vi.mock('node:child_process', () => ({
execSync: vi.fn(() => ''),
}));
// Mock fs to prevent actual file operations
vi.mock('node:fs', () => {
const fs = vi.importActual<typeof import('node:fs')>('node:fs');
return {
...fs,
existsSync: vi.fn(() => false),
copyFileSync: vi.fn(),
mkdtempSync: vi.fn(() => '/tmp/test-dir'),
readdirSync: vi.fn(() => []),
rmSync: vi.fn(),
};
});
describe('cookies', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
// Clear Twitter-related env vars
process.env.AUTH_TOKEN = undefined;
process.env.TWITTER_AUTH_TOKEN = undefined;
process.env.CT0 = undefined;
process.env.TWITTER_CT0 = undefined;
});
afterEach(() => {
process.env = originalEnv;
vi.restoreAllMocks();
});
describe('resolveCredentials', () => {
it('uses firefox when enabled and returns cookies', async () => {
const { resolveCredentials } = await import('../src/lib/cookies.js');
const fs = await import('node:fs');
// Firefox present with cookies.sqlite
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => {
const lower = path.toLowerCase();
if (lower.endsWith('cookies.sqlite')) return true;
if (lower.includes('firefox')) return true;
return false;
});
(fs.readdirSync as unknown as vi.Mock).mockReturnValue([
{ isDirectory: () => true, name: 'abc.default-release' },
]);
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
(fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir');
// sqlite3 output for firefox
const { execSync } = await import('node:child_process');
(execSync as unknown as vi.Mock).mockReturnValue('auth_token|firefox_auth\nct0|firefox_ct0');
const result = await resolveCredentials({
allowFirefox: true,
allowChrome: false,
firefoxProfile: 'abc.default-release',
});
expect(result.cookies.authToken).toBe('firefox_auth');
expect(result.cookies.ct0).toBe('firefox_ct0');
expect(result.cookies.source).toContain('Firefox');
});
it('should prioritize CLI arguments over env vars', async () => {
process.env.AUTH_TOKEN = 'env_auth';
process.env.CT0 = 'env_ct0';
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({
authToken: 'cli_auth',
ct0: 'cli_ct0',
});
expect(result.cookies.authToken).toBe('cli_auth');
expect(result.cookies.ct0).toBe('cli_ct0');
expect(result.cookies.source).toBe('CLI argument');
});
it('should use AUTH_TOKEN env var', async () => {
process.env.AUTH_TOKEN = 'test_auth_token';
process.env.CT0 = 'test_ct0';
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ allowFirefox: false, allowChrome: false });
expect(result.cookies.authToken).toBe('test_auth_token');
expect(result.cookies.ct0).toBe('test_ct0');
expect(result.cookies.source).toBe('env AUTH_TOKEN');
});
it('should use TWITTER_AUTH_TOKEN env var as fallback', async () => {
process.env.TWITTER_AUTH_TOKEN = 'twitter_auth';
process.env.TWITTER_CT0 = 'twitter_ct0';
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ allowFirefox: false, allowChrome: false });
expect(result.cookies.authToken).toBe('twitter_auth');
expect(result.cookies.ct0).toBe('twitter_ct0');
});
it('should trim whitespace from values', async () => {
process.env.AUTH_TOKEN = ' trimmed_auth ';
process.env.CT0 = ' trimmed_ct0 ';
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({});
expect(result.cookies.authToken).toBe('trimmed_auth');
expect(result.cookies.ct0).toBe('trimmed_ct0');
});
it('should treat empty strings as null', async () => {
process.env.AUTH_TOKEN = ' ';
process.env.CT0 = '';
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ allowFirefox: false, allowChrome: false });
expect(result.cookies.authToken).toBeNull();
expect(result.cookies.ct0).toBeNull();
expect(result.warnings.length).toBeGreaterThan(0);
});
it('should warn when credentials are missing', async () => {
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ allowFirefox: false, allowChrome: false });
expect(result.warnings).toContain(
'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome/Firefox',
);
expect(result.warnings).toContain(
'Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome/Firefox',
);
});
it('falls back to Chrome when enabled and Firefox disabled', async () => {
const fs = await import('node:fs');
const { execSync } = await import('node:child_process');
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => path.includes('Cookies'));
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
(execSync as unknown as vi.Mock).mockImplementation((cmd: string) => {
if (cmd.includes('sqlite3')) {
return 'auth_token|746573745f61757468\nct0|746573745f637430';
}
return '';
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ allowFirefox: false, allowChrome: true, chromeProfile: 'Default' });
expect(result.cookies.authToken).toBe('test_auth');
expect(result.cookies.ct0).toBe('test_ct0');
expect(result.cookies.source).toContain('Chrome');
});
});
describe('extractCookiesFromChrome', () => {
it('returns cookies when sqlite yields hex values', async () => {
const fs = await import('node:fs');
const { execSync } = await import('node:child_process');
// Pretend Chrome cookie DB exists
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => path.includes('Cookies'));
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
// sqlite output with hex strings ("test_auth", "test_ct0")
(execSync as unknown as vi.Mock).mockImplementation((cmd: string) => {
if (cmd.includes('sqlite3')) {
return 'auth_token|746573745f61757468\nct0|746573745f637430';
}
return '';
});
const { extractCookiesFromChrome } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromChrome('Default');
expect(result.cookies.authToken).toBe('test_auth');
expect(result.cookies.ct0).toBe('test_ct0');
expect(result.cookies.source).toContain('Chrome');
expect(result.warnings).toHaveLength(0);
});
it('warns when Chrome DB exists but contains no cookies', async () => {
const fs = await import('node:fs');
const { execSync } = await import('node:child_process');
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => path.includes('Cookies'));
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
(execSync as unknown as vi.Mock).mockReturnValue('');
const { extractCookiesFromChrome } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromChrome('Default');
expect(result.cookies.authToken).toBeNull();
expect(result.cookies.ct0).toBeNull();
expect(result.warnings.some((w) => w.includes('No Twitter cookies found in Chrome'))).toBe(true);
});
});
describe('extractCookiesFromFirefox', () => {
it('warns when Firefox cookies database is missing', async () => {
const { extractCookiesFromFirefox } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromFirefox('missing-profile');
expect(result.cookies.authToken).toBeNull();
expect(result.cookies.ct0).toBeNull();
expect(result.warnings).toContain('Firefox cookies database not found.');
});
it('warns when Firefox DB exists but contains no cookies', async () => {
const fs = await import('node:fs');
const { execSync } = await import('node:child_process');
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => {
const lower = path.toLowerCase();
if (lower.endsWith('cookies.sqlite')) return true;
if (lower.includes('firefox')) return true;
return false;
});
(fs.readdirSync as unknown as vi.Mock).mockReturnValue([
{ isDirectory: () => true, name: 'abc.default-release' },
]);
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
(fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir');
(execSync as unknown as vi.Mock).mockReturnValue('');
const { extractCookiesFromFirefox } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromFirefox('abc.default-release');
expect(result.cookies.authToken).toBeNull();
expect(result.cookies.ct0).toBeNull();
expect(result.warnings.some((w) => w.includes('No Twitter cookies found in Firefox'))).toBe(true);
});
});
});
-29
View File
@@ -1,29 +0,0 @@
import { describe, expect, it } from 'vitest';
import { resolveEngineMode, shouldAllowSweetisticsFallback, shouldUseSweetistics } from '../src/lib/engine.js';
describe('engine', () => {
it('normalizes engine values and defaults to auto', () => {
expect(resolveEngineMode()).toBe('auto');
expect(resolveEngineMode('graphql')).toBe('graphql');
expect(resolveEngineMode('sweetistics')).toBe('sweetistics');
expect(resolveEngineMode('AUTO')).toBe('auto');
expect(resolveEngineMode('nope')).toBe('auto');
});
it('uses GraphQL as primary in auto mode', () => {
expect(shouldUseSweetistics('auto', false)).toBe(false);
expect(shouldUseSweetistics('auto', true)).toBe(false);
});
it('forces engine selection when explicitly set', () => {
expect(shouldUseSweetistics('sweetistics', false)).toBe(true);
expect(shouldUseSweetistics('graphql', true)).toBe(false);
});
it('only allows Sweetistics fallback in auto mode', () => {
expect(shouldAllowSweetisticsFallback('auto', true)).toBe(true);
expect(shouldAllowSweetisticsFallback('auto', false)).toBe(false);
expect(shouldAllowSweetisticsFallback('graphql', true)).toBe(false);
expect(shouldAllowSweetisticsFallback('sweetistics', true)).toBe(false);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import type { GraphqlTweetResult } from '../src/lib/twitter-client-types.js';
import { extractArticleMetadata } from '../src/lib/twitter-client-utils.js';
describe('extractArticleMetadata', () => {
it('returns undefined when no result', () => {
expect(extractArticleMetadata(undefined)).toBeUndefined();
});
it('returns undefined when no article field', () => {
const result: GraphqlTweetResult = {
rest_id: '123',
legacy: { full_text: 'Hello world' },
};
expect(extractArticleMetadata(result)).toBeUndefined();
});
it('returns undefined when article has no title', () => {
const result: GraphqlTweetResult = {
rest_id: '123',
article: {
article_results: {
result: {},
},
},
};
expect(extractArticleMetadata(result)).toBeUndefined();
});
it('extracts title from article_results.result', () => {
const result: GraphqlTweetResult = {
rest_id: '123',
article: {
article_results: {
result: {
title: 'Building Context Graphs for GTM',
},
},
},
};
const metadata = extractArticleMetadata(result);
expect(metadata).toEqual({
title: 'Building Context Graphs for GTM',
previewText: undefined,
});
});
it('extracts title and preview_text from article_results.result', () => {
const result: GraphqlTweetResult = {
rest_id: '123',
article: {
article_results: {
result: {
title: 'Building Context Graphs for GTM',
preview_text:
'Foundation Capital recently argued that one of the next trillion-dollar opportunities in AI will come from context graphs.',
},
},
},
} as GraphqlTweetResult;
const metadata = extractArticleMetadata(result);
expect(metadata).toEqual({
title: 'Building Context Graphs for GTM',
previewText:
'Foundation Capital recently argued that one of the next trillion-dollar opportunities in AI will come from context graphs.',
});
});
it('falls back to article.title when article_results.result.title missing', () => {
const result: GraphqlTweetResult = {
rest_id: '123',
article: {
title: 'Fallback Title',
article_results: {
result: {},
},
},
};
const metadata = extractArticleMetadata(result);
expect(metadata).toEqual({
title: 'Fallback Title',
previewText: undefined,
});
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import type { GraphqlTweetResult } from '../src/lib/twitter-client-types.js';
import { extractArticleText } from '../src/lib/twitter-client-utils.js';
describe('extractArticleText', () => {
it('does not duplicate title when rich content starts with a heading', () => {
const result = {
rest_id: '1',
article: {
title: 'Hello World',
article_results: {
result: {
title: 'Hello World',
content_state: {
blocks: [
{
key: '1',
type: 'header-one',
text: 'Hello World',
entityRanges: [],
inlineStyleRanges: [],
},
],
entityMap: [],
},
},
},
},
} as GraphqlTweetResult;
expect(extractArticleText(result)).toBe('# Hello World');
});
});
+332
View File
@@ -0,0 +1,332 @@
import { describe, expect, it } from 'vitest';
import type { GraphqlTweetResult } from '../src/lib/twitter-client-types.js';
import { extractMedia } from '../src/lib/twitter-client-utils.js';
describe('extractMedia', () => {
it('returns undefined when no legacy data', () => {
expect(extractMedia(undefined)).toBeUndefined();
expect(extractMedia({})).toBeUndefined();
expect(extractMedia({ legacy: {} })).toBeUndefined();
});
it('returns undefined when media array is empty', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: { media: [] },
},
};
expect(extractMedia(result)).toBeUndefined();
});
it('extracts photo with dimensions and preview', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: {
media: [
{
type: 'photo',
media_url_https: 'https://pbs.twimg.com/media/test.jpg',
sizes: {
large: { w: 1920, h: 1080, resize: 'fit' },
small: { w: 680, h: 383, resize: 'fit' },
},
},
],
},
},
};
const media = extractMedia(result);
expect(media).toHaveLength(1);
const item = media?.[0];
expect(item).toEqual({
type: 'photo',
url: 'https://pbs.twimg.com/media/test.jpg',
width: 1920,
height: 1080,
previewUrl: 'https://pbs.twimg.com/media/test.jpg:small',
});
});
it('falls back to medium size when large unavailable', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: {
media: [
{
type: 'photo',
media_url_https: 'https://pbs.twimg.com/media/test.jpg',
sizes: {
medium: { w: 1200, h: 675, resize: 'fit' },
},
},
],
},
},
};
const media = extractMedia(result);
const item = media?.[0];
expect(item?.width).toBe(1200);
expect(item?.height).toBe(675);
expect(item?.previewUrl).toBeUndefined();
});
it('extracts video with highest bitrate mp4', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: {
media: [
{
type: 'video',
media_url_https: 'https://pbs.twimg.com/ext_tw_video_thumb/123/img/thumb.jpg',
sizes: {
large: { w: 1280, h: 720, resize: 'fit' },
small: { w: 680, h: 383, resize: 'fit' },
},
video_info: {
duration_millis: 30000,
variants: [
{ bitrate: 256000, content_type: 'video/mp4', url: 'https://video.twimg.com/low.mp4' },
{ bitrate: 2176000, content_type: 'video/mp4', url: 'https://video.twimg.com/high.mp4' },
{ bitrate: 832000, content_type: 'video/mp4', url: 'https://video.twimg.com/medium.mp4' },
{ content_type: 'application/x-mpegURL', url: 'https://video.twimg.com/pl.m3u8' },
],
},
},
],
},
},
};
const media = extractMedia(result);
expect(media).toHaveLength(1);
const item = media?.[0];
expect(item).toEqual({
type: 'video',
url: 'https://pbs.twimg.com/ext_tw_video_thumb/123/img/thumb.jpg',
width: 1280,
height: 720,
previewUrl: 'https://pbs.twimg.com/ext_tw_video_thumb/123/img/thumb.jpg:small',
videoUrl: 'https://video.twimg.com/high.mp4',
durationMs: 30000,
});
});
it('falls back to first mp4 when bitrate is missing', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: {
media: [
{
type: 'video',
media_url_https: 'https://pbs.twimg.com/ext_tw_video_thumb/123/img/thumb.jpg',
video_info: {
variants: [
{ content_type: 'video/mp4', url: 'https://video.twimg.com/no-bitrate.mp4' },
{ content_type: 'application/x-mpegURL', url: 'https://video.twimg.com/pl.m3u8' },
],
},
},
],
},
},
};
const media = extractMedia(result);
expect(media).toHaveLength(1);
const item = media?.[0];
expect(item?.videoUrl).toBe('https://video.twimg.com/no-bitrate.mp4');
});
it('extracts animated_gif with video url', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: {
media: [
{
type: 'animated_gif',
media_url_https: 'https://pbs.twimg.com/tweet_video_thumb/test.jpg',
sizes: {
large: { w: 480, h: 270, resize: 'fit' },
small: { w: 480, h: 270, resize: 'fit' },
},
video_info: {
variants: [
{ bitrate: 0, content_type: 'video/mp4', url: 'https://video.twimg.com/tweet_video/test.mp4' },
],
},
},
],
},
},
};
const media = extractMedia(result);
expect(media).toHaveLength(1);
const item = media?.[0];
expect(item?.type).toBe('animated_gif');
expect(item?.videoUrl).toBe('https://video.twimg.com/tweet_video/test.mp4');
expect(item?.durationMs).toBeUndefined();
});
it('keeps zero duration for videos', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: {
media: [
{
type: 'video',
media_url_https: 'https://pbs.twimg.com/ext_tw_video_thumb/zero.jpg',
video_info: {
duration_millis: 0,
variants: [{ content_type: 'video/mp4', url: 'https://video.twimg.com/zero.mp4' }],
},
},
],
},
},
};
const media = extractMedia(result);
expect(media).toHaveLength(1);
const item = media?.[0];
expect(item?.durationMs).toBe(0);
});
it('handles multiple media items', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: {
media: [
{
type: 'photo',
media_url_https: 'https://pbs.twimg.com/media/photo1.jpg',
sizes: { large: { w: 800, h: 600, resize: 'fit' } },
},
{
type: 'photo',
media_url_https: 'https://pbs.twimg.com/media/photo2.jpg',
sizes: { large: { w: 1024, h: 768, resize: 'fit' } },
},
{
type: 'video',
media_url_https: 'https://pbs.twimg.com/ext_tw_video_thumb/vid.jpg',
sizes: { large: { w: 1920, h: 1080, resize: 'fit' } },
video_info: {
duration_millis: 15000,
variants: [{ bitrate: 1000000, content_type: 'video/mp4', url: 'https://video.twimg.com/vid.mp4' }],
},
},
],
},
},
};
const media = extractMedia(result) ?? [];
expect(media).toHaveLength(3);
const [first, second, third] = media;
expect(first?.type).toBe('photo');
expect(second?.type).toBe('photo');
expect(third?.type).toBe('video');
expect(third?.videoUrl).toBe('https://video.twimg.com/vid.mp4');
});
it('skips items missing type or url', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: {
media: [
{ type: 'photo' } as never, // missing media_url_https
{ media_url_https: 'https://pbs.twimg.com/media/test.jpg' } as never, // missing type
{
type: 'photo',
media_url_https: 'https://pbs.twimg.com/media/valid.jpg',
sizes: { large: { w: 100, h: 100, resize: 'fit' } },
},
],
},
},
};
const media = extractMedia(result);
expect(media).toHaveLength(1);
const item = media?.[0];
expect(item?.url).toBe('https://pbs.twimg.com/media/valid.jpg');
});
it('prefers extended_entities over entities', () => {
const result: GraphqlTweetResult = {
legacy: {
entities: {
media: [
{
type: 'photo',
media_url_https: 'https://pbs.twimg.com/media/from_entities.jpg',
sizes: { large: { w: 100, h: 100, resize: 'fit' } },
},
],
},
extended_entities: {
media: [
{
type: 'photo',
media_url_https: 'https://pbs.twimg.com/media/from_extended.jpg',
sizes: { large: { w: 200, h: 200, resize: 'fit' } },
},
],
},
},
};
const media = extractMedia(result);
const item = media?.[0];
expect(item?.url).toBe('https://pbs.twimg.com/media/from_extended.jpg');
});
it('falls back to entities when extended_entities missing', () => {
const result: GraphqlTweetResult = {
legacy: {
entities: {
media: [
{
type: 'photo',
media_url_https: 'https://pbs.twimg.com/media/from_entities.jpg',
sizes: { large: { w: 100, h: 100, resize: 'fit' } },
},
],
},
},
};
const media = extractMedia(result);
const item = media?.[0];
expect(item?.url).toBe('https://pbs.twimg.com/media/from_entities.jpg');
});
it('handles video without mp4 variants', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: {
media: [
{
type: 'video',
media_url_https: 'https://pbs.twimg.com/ext_tw_video_thumb/test.jpg',
sizes: { large: { w: 1280, h: 720, resize: 'fit' } },
video_info: {
duration_millis: 10000,
variants: [{ content_type: 'application/x-mpegURL', url: 'https://video.twimg.com/pl.m3u8' }],
},
},
],
},
},
};
const media = extractMedia(result);
const item = media?.[0];
expect(item?.type).toBe('video');
expect(item?.videoUrl).toBeUndefined();
expect(item?.durationMs).toBe(10000);
});
});
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import { createProgram } from '../src/cli/program.js';
import { createCliContext } from '../src/cli/shared.js';
describe('root help output', () => {
it('mentions shorthand + json and lists user commands', () => {
const ctx = createCliContext([]);
const program = createProgram(ctx);
let help = '';
program.configureOutput({
writeOut: (s) => {
help += s;
},
writeErr: () => {},
});
program.outputHelp();
expect(help).toContain('following');
expect(help).toContain('followers');
expect(help).toContain('likes');
expect(help).toContain('bird <tweet-id-or-url>');
expect(help).toContain('--json');
expect(help).toContain('--json-full');
expect(help).toContain('_raw');
});
it('shows global options in subcommand help', () => {
const ctx = createCliContext([]);
const program = createProgram(ctx);
const cmd = program.commands.find((c) => c.name() === 'read');
if (!cmd) {
throw new Error('Expected "read" command to be registered');
}
let help = '';
const output = {
writeOut: (s) => {
help += s;
},
writeErr: () => {},
};
program.configureOutput(output);
cmd.configureOutput(output);
cmd.outputHelp();
expect(help).toContain('--relay-base-url');
expect(help).not.toContain('--auth-token');
expect(help).not.toContain('--ct0');
expect(help).toContain('--timeout');
expect(help).toContain('--quote-depth');
});
it('shows --json-full option in read command help', () => {
const ctx = createCliContext([]);
const program = createProgram(ctx);
const cmd = program.commands.find((c) => c.name() === 'read');
if (!cmd) {
throw new Error('Expected "read" command to be registered');
}
let help = '';
const output = {
writeOut: (s) => {
help += s;
},
writeErr: () => {},
};
program.configureOutput(output);
cmd.configureOutput(output);
cmd.outputHelp();
expect(help).toContain('--json-full');
expect(help).toContain('_raw');
});
it('shows --json-full option in search command help', () => {
const ctx = createCliContext([]);
const program = createProgram(ctx);
const cmd = program.commands.find((c) => c.name() === 'search');
if (!cmd) {
throw new Error('Expected "search" command to be registered');
}
let help = '';
const output = {
writeOut: (s) => {
help += s;
},
writeErr: () => {},
};
program.configureOutput(output);
cmd.configureOutput(output);
cmd.outputHelp();
expect(help).toContain('--json-full');
});
});
+8
View File
@@ -0,0 +1,8 @@
import { describe, expect, it } from 'vitest';
import { TwitterClient } from '../src/index.js';
describe('library exports', () => {
it('exposes primary library surface', () => {
expect(typeof TwitterClient).toBe('function');
});
});
+367
View File
@@ -0,0 +1,367 @@
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { beforeAll, describe, expect, it } from 'vitest';
type RunResult = { exitCode: number; stdout: string; stderr: string; signal: NodeJS.Signals | null };
const LIVE = process.env.BIRD_LIVE === '1';
const relayBaseUrl = (process.env.TWITTER_RELAY_BASE_URL ?? '').trim();
const CLI_PATH = path.resolve(process.cwd(), 'dist', 'cli.js');
const WHOAMI_HANDLE_REGEX = /^user:\s*@([A-Za-z0-9_]+)/m;
const WHOAMI_USER_ID_REGEX = /^user_id:\s*([0-9]+)/m;
const TWEET_ID_REGEX = /^\d+$/;
const LIVE_NODE_ENV = (process.env.BIRD_LIVE_NODE_ENV ?? 'production').trim() || 'production';
function runBird(args: string[], options: { timeoutMs?: number } = {}): Promise<RunResult> {
if (!LIVE) {
throw new Error('runBird() called without BIRD_LIVE=1');
}
return new Promise((resolve) => {
const child = spawn(process.execPath, [CLI_PATH, ...args], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
NODE_ENV: LIVE_NODE_ENV,
},
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
const killTimer =
typeof options.timeoutMs === 'number' && options.timeoutMs > 0
? setTimeout(() => {
child.kill('SIGKILL');
}, options.timeoutMs)
: null;
child.on('close', (code, signal) => {
if (killTimer) {
clearTimeout(killTimer);
}
resolve({
exitCode: code ?? (signal ? 1 : 0),
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
stderr: Buffer.concat(stderrChunks).toString('utf8'),
signal: signal ?? null,
});
});
});
}
function parseJson<T>(stdout: string): T {
try {
return JSON.parse(stdout) as T;
} catch (error) {
throw new Error(`Invalid JSON output: ${error instanceof Error ? error.message : String(error)}\n${stdout}`);
}
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
const d = LIVE ? describe : describe.skip;
d('live CLI (Twitter/X) all commands', () => {
const timeoutArg = (process.env.BIRD_LIVE_TIMEOUT_MS ?? '20000').trim();
const baseArgs = ['--plain', '--timeout', timeoutArg, '--quote-depth', '0'];
let whoamiStdout = '';
let handle = '';
let userId = '';
let tweetId = '';
let searchStdout = '';
let listId = '';
let listTimelineAvailable = false;
beforeAll(async () => {
if (!existsSync(CLI_PATH)) {
throw new Error(`Missing ${CLI_PATH}. Run: pnpm run build:dist`);
}
if (!relayBaseUrl) {
throw new Error('Missing TWITTER_RELAY_BASE_URL for live tests.');
}
const check = await runBird([...baseArgs, 'check'], { timeoutMs: 45_000 });
if (check.exitCode !== 0) {
throw new Error(`bird check failed:\n${check.stdout}\n${check.stderr}`);
}
const who = await runBird([...baseArgs, 'whoami'], { timeoutMs: 45_000 });
if (who.exitCode !== 0) {
throw new Error(`whoami failed (exit ${who.exitCode}, signal ${who.signal ?? 'none'}):\n${who.stderr}`);
}
whoamiStdout = who.stdout;
const handleMatch = WHOAMI_HANDLE_REGEX.exec(who.stdout);
if (!handleMatch?.[1]) {
throw new Error(`Could not parse whoami handle:\n${who.stdout}`);
}
handle = handleMatch[1];
const userIdMatch = WHOAMI_USER_ID_REGEX.exec(who.stdout);
if (!userIdMatch?.[1]) {
throw new Error(`Could not parse whoami user_id:\n${who.stdout}`);
}
userId = userIdMatch[1];
const forcedTweetId = (process.env.BIRD_LIVE_TWEET_ID ?? '').trim();
if (forcedTweetId) {
if (!TWEET_ID_REGEX.test(forcedTweetId)) {
throw new Error(`Invalid BIRD_LIVE_TWEET_ID (expected digits): "${forcedTweetId}"`);
}
tweetId = forcedTweetId;
} else {
const searchQuery = (
process.env.BIRD_LIVE_SEARCH_QUERY ?? `from:${handle} -filter:replies -filter:retweets`
).trim();
const search = await runBird([...baseArgs, 'search', searchQuery, '-n', '25', '--json'], {
timeoutMs: 45_000,
});
if (search.exitCode !== 0) {
throw new Error(
`search failed (exit ${search.exitCode}, signal ${search.signal ?? 'none'}):\n${search.stderr}`,
);
}
searchStdout = search.stdout;
const tweets = parseJson<Array<{ id?: string }>>(search.stdout);
const first = String(tweets[0]?.id ?? '');
if (!TWEET_ID_REGEX.test(first)) {
throw new Error(
`Search returned no usable tweets. Query: "${searchQuery}". ` +
`Override with BIRD_LIVE_SEARCH_QUERY.\n${search.stdout}`,
);
}
tweetId = first;
}
listId = (process.env.BIRD_LIVE_LIST_ID ?? '').trim();
if (!listId) {
const lists = await runBird([...baseArgs, 'lists', '-n', '1', '--json'], {
timeoutMs: 45_000,
});
if (lists.exitCode === 0) {
const parsed = parseJson<Array<{ id?: string }>>(lists.stdout);
listId = String(parsed[0]?.id ?? '').trim();
}
}
if (!listId) {
const lists = await runBird([...baseArgs, 'lists', '--member-of', '-n', '1', '--json'], {
timeoutMs: 45_000,
});
if (lists.exitCode === 0) {
const parsed = parseJson<Array<{ id?: string }>>(lists.stdout);
listId = String(parsed[0]?.id ?? '').trim();
}
}
listTimelineAvailable = Boolean(listId);
});
it('requires env credentials + built dist CLI', async () => {
expect(existsSync(CLI_PATH)).toBe(true);
expect(whoamiStdout.length).toBeGreaterThan(0);
});
it('check works', async () => {
const check = await runBird([...baseArgs, 'check'], { timeoutMs: 45_000 });
expect(check.exitCode).toBe(0);
expect(check.stdout.length).toBeGreaterThan(0);
});
it('whoami works', async () => {
expect(whoamiStdout).toContain('user: @');
expect(whoamiStdout).toContain('user_id:');
});
it('search returns JSON array', async () => {
const search = searchStdout
? { exitCode: 0, stdout: searchStdout }
: await runBird([...baseArgs, 'search', `from:${handle}`, '-n', '10', '--json'], { timeoutMs: 45_000 });
expect(search.exitCode).toBe(0);
const tweets = parseJson<Array<{ id?: string }>>(search.stdout);
expect(Array.isArray(tweets)).toBe(true);
});
it('read returns tweet JSON', async () => {
const read = await runBird([...baseArgs, 'read', tweetId, '--json'], {
timeoutMs: 45_000,
});
expect(read.exitCode).toBe(0);
const readTweet = parseJson<{ id?: string; author?: { username?: string } }>(read.stdout);
expect(readTweet.id).toBe(tweetId);
expect(readTweet.author?.username?.length).toBeGreaterThan(0);
});
it('tweet-id shorthand returns tweet JSON', async () => {
const shorthand = await runBird([...baseArgs, tweetId, '--json'], {
timeoutMs: 45_000,
});
expect(shorthand.exitCode).toBe(0);
const shorthandTweet = parseJson<{ id?: string }>(shorthand.stdout);
expect(shorthandTweet.id).toBe(tweetId);
});
it('replies returns JSON array', async () => {
const replies = await runBird([...baseArgs, 'replies', tweetId, '--json'], {
timeoutMs: 45_000,
});
expect(replies.exitCode).toBe(0);
const replyTweets = parseJson<Array<{ id?: string }>>(replies.stdout);
expect(Array.isArray(replyTweets)).toBe(true);
});
it('thread returns JSON array', async () => {
const thread = await runBird([...baseArgs, 'thread', tweetId, '--json'], {
timeoutMs: 45_000,
});
expect(thread.exitCode).toBe(0);
const threadTweets = parseJson<Array<{ id?: string }>>(thread.stdout);
expect(Array.isArray(threadTweets)).toBe(true);
});
it('mentions returns JSON array', async () => {
const mentions = await runBird([...baseArgs, 'mentions', '-n', '10', '--json'], {
timeoutMs: 45_000,
});
expect(mentions.exitCode).toBe(0);
const mentionTweets = parseJson<Array<{ id?: string }>>(mentions.stdout);
expect(Array.isArray(mentionTweets)).toBe(true);
});
it('bookmarks returns JSON array', async () => {
const bookmarks = await runBird([...baseArgs, 'bookmarks', '-n', '10', '--json'], {
timeoutMs: 45_000,
});
expect(bookmarks.exitCode).toBe(0);
const bookmarkTweets = parseJson<Array<{ id?: string }>>(bookmarks.stdout);
expect(Array.isArray(bookmarkTweets)).toBe(true);
});
it('likes returns JSON array', async () => {
const likes = await runBird([...baseArgs, 'likes', '-n', '10', '--json'], {
timeoutMs: 45_000,
});
expect(likes.exitCode).toBe(0);
const likeTweets = parseJson<Array<{ id?: string }>>(likes.stdout);
expect(Array.isArray(likeTweets)).toBe(true);
});
it('following returns JSON array', async () => {
const following = await runBird([...baseArgs, 'following', '--user', userId, '-n', '10', '--json'], {
timeoutMs: 45_000,
});
expect(following.exitCode).toBe(0);
const followingUsers = parseJson<Array<{ id?: string; username?: string }>>(following.stdout);
expect(Array.isArray(followingUsers)).toBe(true);
});
it('followers returns JSON array', async () => {
const followers = await runBird([...baseArgs, 'followers', '--user', userId, '-n', '10', '--json'], {
timeoutMs: 45_000,
});
expect(followers.exitCode).toBe(0);
const followerUsers = parseJson<Array<{ id?: string; username?: string }>>(followers.stdout);
expect(Array.isArray(followerUsers)).toBe(true);
});
it('user-tweets returns JSON array', async () => {
const testHandle = process.env.BIRD_LIVE_USER_TWEETS_HANDLE || 'X';
const userTweets = await runBird([...baseArgs, 'user-tweets', testHandle, '-n', '5', '--json'], {
timeoutMs: 45_000,
});
expect(userTweets.exitCode).toBe(0);
const tweets = parseJson<Array<{ id?: string; author?: { username?: string } }>>(userTweets.stdout);
expect(Array.isArray(tweets)).toBe(true);
expect(tweets.length).toBeGreaterThan(0);
});
it('user-tweets paged JSON returns { tweets, nextCursor }', async () => {
const testHandle =
process.env.BIRD_LIVE_USER_TWEETS_PAGED_HANDLE ?? process.env.BIRD_LIVE_USER_TWEETS_HANDLE ?? 'X';
const userTweets = await runBird(
[...baseArgs, 'user-tweets', testHandle, '-n', '50', '--max-pages', '3', '--delay', '0', '--json'],
{
timeoutMs: 45_000,
},
);
expect(userTweets.exitCode).toBe(0);
const payload = parseJson<{ tweets?: Array<{ id?: string }>; nextCursor?: string | null }>(userTweets.stdout);
expect(Array.isArray(payload.tweets)).toBe(true);
});
it('lists returns JSON array', async () => {
const lists = await runBird([...baseArgs, 'lists', '-n', '10', '--json'], {
timeoutMs: 45_000,
});
expect(lists.exitCode).toBe(0);
const payload = parseJson<Array<{ id?: string }>>(lists.stdout);
expect(Array.isArray(payload)).toBe(true);
});
it('list-timeline returns JSON array', async () => {
if (!listTimelineAvailable) {
return;
}
const timeline = await runBird([...baseArgs, 'list-timeline', listId, '-n', '5', '--json'], { timeoutMs: 45_000 });
expect(timeline.exitCode).toBe(0);
const payload = parseJson<Array<{ id?: string }>>(timeline.stdout);
expect(Array.isArray(payload)).toBe(true);
});
it('home for-you returns JSON array', async () => {
const home = await runBird([...baseArgs, 'home', '-n', '5', '--json'], {
timeoutMs: 45_000,
});
expect(home.exitCode).toBe(0);
const payload = parseJson<Array<{ id?: string }>>(home.stdout);
expect(Array.isArray(payload)).toBe(true);
}, 60_000);
it('home following returns JSON array', async () => {
let home = await runBird([...baseArgs, 'home', '--following', '-n', '5', '--json'], {
timeoutMs: 45_000,
});
if (home.exitCode !== 0 && home.stderr.includes('OverCapacity')) {
await sleep(1500);
home = await runBird([...baseArgs, 'home', '--following', '-n', '5', '--json'], {
timeoutMs: 45_000,
});
}
expect(home.exitCode).toBe(0);
const payload = parseJson<Array<{ id?: string }>>(home.stdout);
expect(Array.isArray(payload)).toBe(true);
}, 60_000);
it('news returns JSON array', async () => {
const news = await runBird([...baseArgs, 'news', '-n', '5', '--ai-only', '--json'], { timeoutMs: 45_000 });
expect(news.exitCode).toBe(0);
const payload = parseJson<Array<{ id?: string }>>(news.stdout);
expect(Array.isArray(payload)).toBe(true);
});
it('trending returns JSON array', async () => {
const trending = await runBird([...baseArgs, 'trending', '-n', '5', '--json'], { timeoutMs: 45_000 });
expect(trending.exitCode).toBe(0);
const payload = parseJson<Array<{ id?: string }>>(trending.stdout);
expect(Array.isArray(payload)).toBe(true);
});
it('query-ids returns JSON', async () => {
const queryIds = await runBird([...baseArgs, 'query-ids', '--json'], {
timeoutMs: 60_000,
});
expect(queryIds.exitCode).toBe(0);
const snapshot = parseJson<{ cached?: boolean; ids?: Record<string, string> }>(queryIds.stdout);
expect(typeof snapshot.cached).toBe('boolean');
});
});
+314
View File
@@ -0,0 +1,314 @@
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { beforeAll, describe, expect, it } from 'vitest';
type RunResult = { exitCode: number; stdout: string; stderr: string; signal: NodeJS.Signals | null };
const LIVE = process.env.BIRD_LIVE === '1';
const relayBaseUrl = (process.env.TWITTER_RELAY_BASE_URL ?? '').trim();
const CLI_PATH = path.resolve(process.cwd(), 'dist', 'cli.js');
const WHOAMI_HANDLE_REGEX = /^user:\s*@([A-Za-z0-9_]+)/m;
const WHOAMI_USER_ID_REGEX = /^user_id:\s*([0-9]+)/m;
const TWEET_ID_REGEX = /^\d+$/;
const LIVE_NODE_ENV = (process.env.BIRD_LIVE_NODE_ENV ?? 'production').trim() || 'production';
function runBird(args: string[], options: { timeoutMs?: number } = {}): Promise<RunResult> {
if (!LIVE) {
throw new Error('runBird() called without BIRD_LIVE=1');
}
return new Promise((resolve) => {
const child = spawn(process.execPath, [CLI_PATH, ...args], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
NODE_ENV: LIVE_NODE_ENV,
},
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
const killTimer =
typeof options.timeoutMs === 'number' && options.timeoutMs > 0
? setTimeout(() => {
child.kill('SIGKILL');
}, options.timeoutMs)
: null;
child.on('close', (code, signal) => {
if (killTimer) {
clearTimeout(killTimer);
}
resolve({
exitCode: code ?? (signal ? 1 : 0),
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
stderr: Buffer.concat(stderrChunks).toString('utf8'),
signal: signal ?? null,
});
});
});
}
function parseJson<T>(stdout: string): T {
try {
return JSON.parse(stdout) as T;
} catch (error) {
throw new Error(`Invalid JSON output: ${error instanceof Error ? error.message : String(error)}\n${stdout}`);
}
}
const d = LIVE ? describe : describe.skip;
d('live CLI (Twitter/X)', () => {
const timeoutArg = (process.env.BIRD_LIVE_TIMEOUT_MS ?? '20000').trim();
const baseArgs = ['--plain', '--timeout', timeoutArg, '--quote-depth', '0'];
let whoamiStdout = '';
let handle = '';
let userId = '';
let tweetId = '';
beforeAll(async () => {
if (!existsSync(CLI_PATH)) {
throw new Error(`Missing ${CLI_PATH}. Run: pnpm run build:dist`);
}
if (!relayBaseUrl) {
throw new Error('Missing TWITTER_RELAY_BASE_URL for live tests.');
}
const check = await runBird([...baseArgs, 'check'], { timeoutMs: 45_000 });
if (check.exitCode !== 0) {
throw new Error(`bird check failed:\n${check.stdout}\n${check.stderr}`);
}
const who = await runBird([...baseArgs, 'whoami'], { timeoutMs: 45_000 });
if (who.exitCode !== 0) {
throw new Error(`whoami failed (exit ${who.exitCode}, signal ${who.signal ?? 'none'}):\n${who.stderr}`);
}
whoamiStdout = who.stdout;
const handleMatch = WHOAMI_HANDLE_REGEX.exec(who.stdout);
if (!handleMatch?.[1]) {
throw new Error(`Could not parse whoami handle:\n${who.stdout}`);
}
handle = handleMatch[1];
const userIdMatch = WHOAMI_USER_ID_REGEX.exec(who.stdout);
if (!userIdMatch?.[1]) {
throw new Error(`Could not parse whoami user_id:\n${who.stdout}`);
}
userId = userIdMatch[1];
const forcedTweetId = (process.env.BIRD_LIVE_TWEET_ID ?? '').trim();
if (forcedTweetId) {
if (!TWEET_ID_REGEX.test(forcedTweetId)) {
throw new Error(`Invalid BIRD_LIVE_TWEET_ID (expected digits): "${forcedTweetId}"`);
}
tweetId = forcedTweetId;
return;
}
const searchQuery = (
process.env.BIRD_LIVE_SEARCH_QUERY ?? `from:${handle} -filter:replies -filter:retweets`
).trim();
const search = await runBird([...baseArgs, 'search', searchQuery, '-n', '25', '--json'], {
timeoutMs: 45_000,
});
if (search.exitCode !== 0) {
throw new Error(`search failed (exit ${search.exitCode}, signal ${search.signal ?? 'none'}):\n${search.stderr}`);
}
const tweets = parseJson<Array<{ id?: string }>>(search.stdout);
const first = String(tweets[0]?.id ?? '');
if (!TWEET_ID_REGEX.test(first)) {
throw new Error(
`Search returned no usable tweets. Query: "${searchQuery}". ` +
`Override with BIRD_LIVE_SEARCH_QUERY.\n${search.stdout}`,
);
}
tweetId = first;
});
it('requires env credentials + built dist CLI', async () => {
expect(existsSync(CLI_PATH)).toBe(true);
expect(whoamiStdout.length).toBeGreaterThan(0);
});
it('whoami works', async () => {
expect(whoamiStdout).toContain('user: @');
expect(whoamiStdout).toContain('user_id:');
expect(whoamiStdout).toContain('engine:');
expect(whoamiStdout).toContain('credentials:');
});
it('read returns tweet JSON', async () => {
const read = await runBird([...baseArgs, 'read', tweetId, '--json'], {
timeoutMs: 45_000,
});
expect(read.exitCode).toBe(0);
const readTweet = parseJson<{ id?: string; author?: { username?: string } }>(read.stdout);
expect(readTweet.id).toBe(tweetId);
expect(readTweet.author?.username?.length).toBeGreaterThan(0);
});
it('tweet-id shorthand returns tweet JSON', async () => {
const shorthand = await runBird([...baseArgs, tweetId, '--json'], {
timeoutMs: 45_000,
});
expect(shorthand.exitCode).toBe(0);
const shorthandTweet = parseJson<{ id?: string }>(shorthand.stdout);
expect(shorthandTweet.id).toBe(tweetId);
});
it('replies returns JSON array', async () => {
const replies = await runBird([...baseArgs, 'replies', tweetId, '--json'], {
timeoutMs: 45_000,
});
expect(replies.exitCode).toBe(0);
const replyTweets = parseJson<Array<{ id?: string }>>(replies.stdout);
expect(Array.isArray(replyTweets)).toBe(true);
});
it('thread returns JSON array', async () => {
const thread = await runBird([...baseArgs, 'thread', tweetId, '--json'], {
timeoutMs: 45_000,
});
expect(thread.exitCode).toBe(0);
const threadTweets = parseJson<Array<{ id?: string }>>(thread.stdout);
expect(Array.isArray(threadTweets)).toBe(true);
expect(threadTweets.length).toBeGreaterThan(0);
});
it('mentions returns JSON array', async () => {
const mentions = await runBird([...baseArgs, 'mentions', '-n', '10', '--json'], {
timeoutMs: 45_000,
});
expect(mentions.exitCode).toBe(0);
const mentionTweets = parseJson<Array<{ id?: string }>>(mentions.stdout);
expect(Array.isArray(mentionTweets)).toBe(true);
});
it('bookmarks returns JSON array', async () => {
const bookmarks = await runBird([...baseArgs, 'bookmarks', '-n', '10', '--json'], {
timeoutMs: 45_000,
});
expect(bookmarks.exitCode).toBe(0);
const bookmarkTweets = parseJson<Array<{ id?: string }>>(bookmarks.stdout);
expect(Array.isArray(bookmarkTweets)).toBe(true);
});
it('bookmarks --folder-id works (opt-in)', async () => {
const folderId = (process.env.BIRD_LIVE_BOOKMARK_FOLDER_ID ?? '').trim();
if (!folderId) {
return;
}
const bookmarks = await runBird([...baseArgs, 'bookmarks', '--folder-id', folderId, '-n', '10', '--json'], {
timeoutMs: 45_000,
});
expect(bookmarks.exitCode).toBe(0);
const bookmarkTweets = parseJson<Array<{ id?: string }>>(bookmarks.stdout);
expect(Array.isArray(bookmarkTweets)).toBe(true);
});
it('likes returns JSON array', async () => {
const likes = await runBird([...baseArgs, 'likes', '-n', '10', '--json'], {
timeoutMs: 45_000,
});
expect(likes.exitCode).toBe(0);
const likeTweets = parseJson<Array<{ id?: string }>>(likes.stdout);
expect(Array.isArray(likeTweets)).toBe(true);
});
it('following returns JSON array', async () => {
const following = await runBird([...baseArgs, 'following', '--user', userId, '-n', '10', '--json'], {
timeoutMs: 45_000,
});
expect(following.exitCode).toBe(0);
const followingUsers = parseJson<Array<{ id?: string; username?: string }>>(following.stdout);
expect(Array.isArray(followingUsers)).toBe(true);
});
it('followers returns JSON array', async () => {
const followers = await runBird([...baseArgs, 'followers', '--user', userId, '-n', '10', '--json'], {
timeoutMs: 45_000,
});
expect(followers.exitCode).toBe(0);
const followerUsers = parseJson<Array<{ id?: string; username?: string }>>(followers.stdout);
expect(Array.isArray(followerUsers)).toBe(true);
});
it('user-tweets returns JSON array', async () => {
// Use a known active account for reliable testing (authenticated user may have no tweets)
const testHandle = process.env.BIRD_LIVE_USER_TWEETS_HANDLE || 'X';
const userTweets = await runBird([...baseArgs, 'user-tweets', testHandle, '-n', '5', '--json'], {
timeoutMs: 45_000,
});
expect(userTweets.exitCode).toBe(0);
const tweets = parseJson<Array<{ id?: string; author?: { username?: string } }>>(userTweets.stdout);
expect(Array.isArray(tweets)).toBe(true);
expect(tweets.length).toBeGreaterThan(0);
const handleLower = testHandle.toLowerCase();
expect(tweets.some((t) => t.author?.username?.toLowerCase() === handleLower)).toBe(true);
});
it('user-tweets paged JSON returns { tweets, nextCursor }', async () => {
const testHandle =
process.env.BIRD_LIVE_USER_TWEETS_PAGED_HANDLE ?? process.env.BIRD_LIVE_USER_TWEETS_HANDLE ?? 'X';
const userTweets = await runBird(
[...baseArgs, 'user-tweets', testHandle, '-n', '50', '--max-pages', '3', '--delay', '0', '--json'],
{
timeoutMs: 45_000,
},
);
expect(userTweets.exitCode).toBe(0);
const payload = parseJson<{ tweets?: Array<{ id?: string }>; nextCursor?: string | null }>(userTweets.stdout);
expect(Array.isArray(payload.tweets)).toBe(true);
expect(payload.tweets?.length).toBeGreaterThan(0);
expect(payload.tweets?.[0]?.id?.length).toBeGreaterThan(0);
});
it('query-ids returns JSON', async () => {
const queryIds = await runBird([...baseArgs, 'query-ids', '--json'], {
timeoutMs: 60_000,
});
expect(queryIds.exitCode).toBe(0);
const snapshot = parseJson<{ cached?: boolean; ids?: Record<string, string> }>(queryIds.stdout);
expect(typeof snapshot.cached).toBe('boolean');
});
it('query-ids --fresh works (opt-in)', async () => {
if (process.env.BIRD_LIVE_QUERY_IDS_FRESH !== '1') {
return;
}
const queryIds = await runBird([...baseArgs, 'query-ids', '--fresh', '--json'], { timeoutMs: 5 * 60_000 });
expect(queryIds.exitCode).toBe(0);
const snapshot = parseJson<{ cached?: boolean; ids?: Record<string, string> }>(queryIds.stdout);
expect(snapshot.cached).toBe(true);
expect(snapshot.ids && Object.keys(snapshot.ids).length).toBeGreaterThan(0);
});
it('long-form tweet (article) extracts rich content (opt-in)', async () => {
const longformTweetId = (process.env.BIRD_LIVE_LONGFORM_TWEET_ID ?? '').trim();
if (!longformTweetId) {
// Skip unless explicitly provided - long-form tweets may be deleted/unavailable
return;
}
const read = await runBird([...baseArgs, 'read', longformTweetId, '--json'], {
timeoutMs: 45_000,
});
expect(read.exitCode).toBe(0);
const tweet = parseJson<{ id?: string; text?: string }>(read.stdout);
expect(tweet.id).toBe(longformTweetId);
// Long-form tweets (articles) typically have substantial content (>500 chars)
// This verifies the article content is being extracted, not just a stub
expect(tweet.text?.length).toBeGreaterThan(500);
});
});
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import type { GraphqlTweetResult } from '../src/lib/twitter-client-types.js';
import { mapTweetResult } from '../src/lib/twitter-client-utils.js';
describe('mapTweetResult article', () => {
const makeUserResult = () => ({
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'alice', name: 'Alice' },
},
},
},
});
it('includes extracted article metadata on mapped tweet', () => {
const result: GraphqlTweetResult = {
rest_id: '1',
legacy: {
full_text: 'Check out my article',
},
article: {
article_results: {
result: {
title: 'Building Context Graphs for GTM',
preview_text: 'Foundation Capital recently argued...',
},
},
},
...makeUserResult(),
} as GraphqlTweetResult;
const mapped = mapTweetResult(result, 0);
expect(mapped).toBeDefined();
expect(mapped?.article).toEqual({
title: 'Building Context Graphs for GTM',
previewText: 'Foundation Capital recently argued...',
});
});
it('omits article when none present', () => {
const result: GraphqlTweetResult = {
rest_id: '2',
legacy: { full_text: 'no article' },
...makeUserResult(),
};
const mapped = mapTweetResult(result, 0);
expect(mapped?.article).toBeUndefined();
});
it('includes article with only title when no preview_text', () => {
const result: GraphqlTweetResult = {
rest_id: '3',
legacy: { full_text: 'article link' },
article: {
article_results: {
result: {
title: 'My Article Title',
},
},
},
...makeUserResult(),
};
const mapped = mapTweetResult(result, 0);
expect(mapped?.article).toEqual({
title: 'My Article Title',
previewText: undefined,
});
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import type { GraphqlTweetResult } from '../src/lib/twitter-client-types.js';
import { mapTweetResult } from '../src/lib/twitter-client-utils.js';
describe('mapTweetResult media', () => {
const makeUserResult = () => ({
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'alice', name: 'Alice' },
},
},
},
});
it('includes extracted media on mapped tweet', () => {
const result: GraphqlTweetResult = {
rest_id: '1',
legacy: {
full_text: 'hello',
extended_entities: {
media: [
{
type: 'photo',
media_url_https: 'https://pbs.twimg.com/media/test.jpg',
sizes: {
large: { w: 800, h: 600, resize: 'fit' },
small: { w: 320, h: 240, resize: 'fit' },
},
},
],
},
},
...makeUserResult(),
};
const mapped = mapTweetResult(result, 0);
expect(mapped).toBeDefined();
const media = mapped?.media ?? [];
expect(media).toHaveLength(1);
expect(media[0]).toEqual({
type: 'photo',
url: 'https://pbs.twimg.com/media/test.jpg',
width: 800,
height: 600,
previewUrl: 'https://pbs.twimg.com/media/test.jpg:small',
});
});
it('omits media when none present', () => {
const result: GraphqlTweetResult = {
rest_id: '2',
legacy: { full_text: 'no media' },
...makeUserResult(),
};
const mapped = mapTweetResult(result, 0);
expect(mapped?.media).toBeUndefined();
});
});
+3 -1
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest';
import { mentionsQueryFromUserOption, normalizeHandle } from '../src/lib/normalize-handle.js';
const INVALID_HANDLE_REGEX = /Invalid --user handle/;
describe('normalizeHandle', () => {
it('accepts bare handle', () => {
expect(normalizeHandle('steipete')).toBe('steipete');
@@ -36,6 +38,6 @@ describe('mentionsQueryFromUserOption', () => {
it('returns error for invalid handle', () => {
const result = mentionsQueryFromUserOption('@stei-pete');
expect(result.query).toBeNull();
expect(result.error).toMatch(/Invalid --user handle/);
expect(result.error).toMatch(INVALID_HANDLE_REGEX);
});
});
+144 -3
View File
@@ -1,10 +1,30 @@
import { describe, expect, it } from 'vitest';
import { labelPrefix, resolveOutputConfigFromArgv, statusPrefix } from '../src/lib/output.js';
import { createCliContext } from '../src/cli/shared.js';
import {
formatStatsLine,
formatTweetUrlLine,
hyperlink,
labelPrefix,
resolveOutputConfigFromArgv,
resolveOutputConfigFromCommander,
statusPrefix,
} from '../src/lib/output.js';
import type { TweetData } from '../src/lib/twitter-client-types.js';
describe('output', () => {
it('plain disables emoji + color', () => {
it('defaults to emoji + color + hyperlinks on TTY', () => {
const cfg = resolveOutputConfigFromArgv([], {}, true);
expect(cfg).toEqual({ plain: false, emoji: true, color: true, hyperlinks: true });
});
it('disables hyperlinks on non-TTY', () => {
const cfg = resolveOutputConfigFromArgv([], {}, false);
expect(cfg.hyperlinks).toBe(false);
});
it('plain disables emoji + color + hyperlinks', () => {
const cfg = resolveOutputConfigFromArgv(['--plain'], {}, true);
expect(cfg).toEqual({ plain: true, emoji: false, color: false });
expect(cfg).toEqual({ plain: true, emoji: false, color: false, hyperlinks: false });
expect(statusPrefix('ok', cfg)).toBe('[ok] ');
expect(labelPrefix('url', cfg)).toBe('url: ');
});
@@ -14,9 +34,130 @@ describe('output', () => {
expect(cfg.color).toBe(false);
});
it('TERM=dumb disables colors by default', () => {
const cfg = resolveOutputConfigFromArgv([], { TERM: 'dumb' }, true);
expect(cfg.color).toBe(false);
});
it('--no-color disables colors', () => {
const cfg = resolveOutputConfigFromArgv(['--no-color'], {}, true);
expect(cfg).toEqual({ plain: false, emoji: true, color: false, hyperlinks: true });
});
it('--no-emoji switches to text prefixes', () => {
const cfg = resolveOutputConfigFromArgv(['--no-emoji'], {}, true);
expect(cfg.emoji).toBe(false);
expect(statusPrefix('warn', cfg)).toBe('Warning: ');
});
it('commander opts override defaults', () => {
const cfg = resolveOutputConfigFromCommander({ emoji: false, color: false }, {}, true);
expect(cfg).toEqual({ plain: false, emoji: false, color: false, hyperlinks: true });
expect(statusPrefix('info', cfg)).toBe('Info: ');
expect(labelPrefix('date', cfg)).toBe('Date: ');
});
it('commander plain wins over emoji/color', () => {
const cfg = resolveOutputConfigFromCommander({ plain: true, emoji: true, color: true }, {}, true);
expect(cfg).toEqual({ plain: true, emoji: false, color: false, hyperlinks: false });
});
it('commander disables hyperlinks on non-TTY', () => {
const cfg = resolveOutputConfigFromCommander({}, {}, false);
expect(cfg.hyperlinks).toBe(false);
});
it('formats stats line for all modes', () => {
const stats = { likeCount: null, retweetCount: undefined, replyCount: 2 };
expect(formatStatsLine(stats, { plain: true, emoji: false, color: false, hyperlinks: false })).toBe(
'likes: 0 retweets: 0 replies: 2',
);
expect(formatStatsLine(stats, { plain: false, emoji: false, color: false, hyperlinks: false })).toBe(
'Likes 0 Retweets 0 Replies 2',
);
expect(formatStatsLine(stats, { plain: false, emoji: true, color: false, hyperlinks: false })).toBe(
'❤️ 0 🔁 0 💬 2',
);
});
it('always includes tweet URL in all modes', () => {
const id = '1234567890';
const url = `https://x.com/i/status/${id}`;
expect(formatTweetUrlLine(id, { plain: true, emoji: false, color: false, hyperlinks: false })).toContain(url);
expect(formatTweetUrlLine(id, { plain: false, emoji: false, color: false, hyperlinks: false })).toContain(url);
expect(formatTweetUrlLine(id, { plain: false, emoji: true, color: false, hyperlinks: true })).toContain(url);
});
it('hyperlink returns plain text when hyperlinks disabled', () => {
const cfg = { plain: true, emoji: false, color: false, hyperlinks: false };
expect(hyperlink('https://x.com/test', undefined, cfg)).toBe('https://x.com/test');
});
it('hyperlink returns plain text on non-TTY (hyperlinks: false)', () => {
const cfg = { plain: false, emoji: true, color: false, hyperlinks: false };
expect(hyperlink('https://x.com/test', undefined, cfg)).toBe('https://x.com/test');
});
it('hyperlink wraps URL with OSC 8 escapes when hyperlinks enabled', () => {
const cfg = { plain: false, emoji: true, color: true, hyperlinks: true };
const result = hyperlink('https://x.com/test', undefined, cfg);
expect(result).toContain('\x1b]8;;');
expect(result).toContain('\x07');
});
it('hyperlink uses custom display text', () => {
const cfg = { plain: false, emoji: true, color: true, hyperlinks: true };
const result = hyperlink('https://x.com/test', 'Click here', cfg);
expect(result).toContain('Click here');
expect(result).toContain('\x1b]8;;https://x.com/test\x07');
});
it('hyperlink strips OSC control characters from url and text', () => {
const cfg = { plain: false, emoji: true, color: true, hyperlinks: true };
const result = hyperlink('https://x.com/\u001btest\u0007', 'Hi\u001b\u0007', cfg);
expect(result).not.toContain('\u001btest\u0007');
expect(result).not.toContain('Hi\u001b\u0007');
});
it('printTweets respects emoji toggle for media/article/quotes', () => {
const tweet: TweetData = {
id: '1',
text: 'hello world',
author: { username: 'alice', name: 'Alice' },
media: [{ type: 'photo', url: 'https://img/test.jpg' }],
article: { title: 'Article title', previewText: 'Preview' },
quotedTweet: {
id: '2',
text: 'quoted text',
author: { username: 'bob', name: 'Bob' },
media: [{ type: 'video', url: 'https://vid/test.mp4' }],
},
};
const ctxEmoji = createCliContext([], {}, true);
const logEmoji = vi.spyOn(console, 'log').mockImplementation(() => {});
ctxEmoji.printTweets([tweet]);
const emojiOutput = logEmoji.mock.calls.flat().join('\n');
logEmoji.mockRestore();
expect(emojiOutput).toContain('📰 Article title');
expect(emojiOutput).toContain('🖼️ https://img/test.jpg');
expect(emojiOutput).toContain('┌─ QT @bob:');
expect(emojiOutput).toContain('🎬 https://vid/test.mp4');
const ctxPlain = createCliContext(['--plain'], {}, true);
const logPlain = vi.spyOn(console, 'log').mockImplementation(() => {});
ctxPlain.printTweets([tweet]);
const plainOutput = logPlain.mock.calls.flat().join('\n');
logPlain.mockRestore();
expect(plainOutput).toContain('Article: Article title');
expect(plainOutput).toContain('PHOTO: https://img/test.jpg');
expect(plainOutput).toContain('QT @bob:');
expect(plainOutput).toContain('VIDEO: https://vid/test.mp4');
expect(plainOutput).not.toContain('📰');
expect(plainOutput).not.toContain('🖼️');
});
});
+351
View File
@@ -0,0 +1,351 @@
import { describe, expect, it } from 'vitest';
import { renderContentState } from '../src/lib/twitter-client-utils.js';
describe('renderContentState', () => {
it('returns undefined for undefined input', () => {
expect(renderContentState(undefined)).toBeUndefined();
});
it('returns undefined for empty blocks', () => {
expect(renderContentState({ blocks: [], entityMap: [] })).toBeUndefined();
});
it('renders unstyled blocks as plain paragraphs', () => {
const result = renderContentState({
blocks: [
{ key: '1', type: 'unstyled', text: 'First paragraph', entityRanges: [], inlineStyleRanges: [] },
{ key: '2', type: 'unstyled', text: 'Second paragraph', entityRanges: [], inlineStyleRanges: [] },
],
entityMap: [],
});
expect(result).toBe('First paragraph\n\nSecond paragraph');
});
it('renders header-one as # heading', () => {
const result = renderContentState({
blocks: [{ key: '1', type: 'header-one', text: 'Main Title', entityRanges: [], inlineStyleRanges: [] }],
entityMap: [],
});
expect(result).toBe('# Main Title');
});
it('renders header-two as ## heading', () => {
const result = renderContentState({
blocks: [{ key: '1', type: 'header-two', text: 'Section Title', entityRanges: [], inlineStyleRanges: [] }],
entityMap: [],
});
expect(result).toBe('## Section Title');
});
it('renders header-three as ### heading', () => {
const result = renderContentState({
blocks: [{ key: '1', type: 'header-three', text: 'Subsection', entityRanges: [], inlineStyleRanges: [] }],
entityMap: [],
});
expect(result).toBe('### Subsection');
});
it('renders unordered-list-item as bullet points', () => {
const result = renderContentState({
blocks: [
{ key: '1', type: 'unordered-list-item', text: 'Item one', entityRanges: [], inlineStyleRanges: [] },
{ key: '2', type: 'unordered-list-item', text: 'Item two', entityRanges: [], inlineStyleRanges: [] },
],
entityMap: [],
});
expect(result).toBe('- Item one\n\n- Item two');
});
it('renders ordered-list-item with incrementing numbers', () => {
const result = renderContentState({
blocks: [
{ key: '1', type: 'ordered-list-item', text: 'First step', entityRanges: [], inlineStyleRanges: [] },
{ key: '2', type: 'ordered-list-item', text: 'Second step', entityRanges: [], inlineStyleRanges: [] },
{ key: '3', type: 'ordered-list-item', text: 'Third step', entityRanges: [], inlineStyleRanges: [] },
],
entityMap: [],
});
expect(result).toBe('1. First step\n\n2. Second step\n\n3. Third step');
});
it('resets ordered list counter after non-list block', () => {
const result = renderContentState({
blocks: [
{ key: '1', type: 'ordered-list-item', text: 'First', entityRanges: [], inlineStyleRanges: [] },
{ key: '2', type: 'ordered-list-item', text: 'Second', entityRanges: [], inlineStyleRanges: [] },
{ key: '3', type: 'unstyled', text: 'Paragraph break', entityRanges: [], inlineStyleRanges: [] },
{ key: '4', type: 'ordered-list-item', text: 'New first', entityRanges: [], inlineStyleRanges: [] },
],
entityMap: [],
});
expect(result).toBe('1. First\n\n2. Second\n\nParagraph break\n\n1. New first');
});
it('renders blockquote with > prefix', () => {
const result = renderContentState({
blocks: [{ key: '1', type: 'blockquote', text: 'A wise quote', entityRanges: [], inlineStyleRanges: [] }],
entityMap: [],
});
expect(result).toBe('> A wise quote');
});
it('renders MARKDOWN entity as code block', () => {
const result = renderContentState({
blocks: [
{
key: '1',
type: 'atomic',
text: ' ',
entityRanges: [{ key: 0, offset: 0, length: 1 }],
inlineStyleRanges: [],
},
],
entityMap: [
{
key: '0',
value: {
type: 'MARKDOWN',
mutability: 'Mutable',
data: { markdown: '```bash\necho "hello"\n```' },
},
},
],
});
expect(result).toBe('```bash\necho "hello"\n```');
});
it('handles entityMap object form', () => {
const result = renderContentState({
blocks: [
{
key: '1',
type: 'atomic',
text: ' ',
entityRanges: [{ key: 0, offset: 0, length: 1 }],
},
],
entityMap: {
0: {
type: 'MARKDOWN',
mutability: 'Mutable',
data: { markdown: '```js\nconsole.log("ok")\n```' },
},
},
});
expect(result).toBe('```js\nconsole.log("ok")\n```');
});
it('renders DIVIDER entity as horizontal rule', () => {
const result = renderContentState({
blocks: [
{
key: '1',
type: 'atomic',
text: ' ',
entityRanges: [{ key: 0, offset: 0, length: 1 }],
inlineStyleRanges: [],
},
],
entityMap: [
{
key: '0',
value: {
type: 'DIVIDER',
mutability: 'Immutable',
data: {},
},
},
],
});
expect(result).toBe('---');
});
it('renders TWEET entity with URL', () => {
const result = renderContentState({
blocks: [
{
key: '1',
type: 'atomic',
text: ' ',
entityRanges: [{ key: 0, offset: 0, length: 1 }],
inlineStyleRanges: [],
},
],
entityMap: [
{
key: '0',
value: {
type: 'TWEET',
mutability: 'Immutable',
data: { tweetId: '1234567890' },
},
},
],
});
expect(result).toBe('[Embedded Tweet: https://x.com/i/status/1234567890]');
});
it('renders LINK entity in atomic block', () => {
const result = renderContentState({
blocks: [
{
key: '1',
type: 'atomic',
text: ' ',
entityRanges: [{ key: 0, offset: 0, length: 1 }],
inlineStyleRanges: [],
},
],
entityMap: [
{
key: '0',
value: {
type: 'LINK',
mutability: 'Mutable',
data: { url: 'https://example.com' },
},
},
],
});
expect(result).toBe('[Link: https://example.com]');
});
it('renders inline LINK entity as markdown link', () => {
const result = renderContentState({
blocks: [
{
key: '1',
type: 'unstyled',
text: 'Check out this link for more info',
entityRanges: [{ key: 0, offset: 15, length: 4 }],
inlineStyleRanges: [],
},
],
entityMap: [
{
key: '0',
value: {
type: 'LINK',
mutability: 'Mutable',
data: { url: 'https://example.com' },
},
},
],
});
expect(result).toBe('Check out this [link](https://example.com) for more info');
});
it('renders IMAGE entity as placeholder', () => {
const result = renderContentState({
blocks: [
{
key: '1',
type: 'atomic',
text: ' ',
entityRanges: [{ key: 0, offset: 0, length: 1 }],
inlineStyleRanges: [],
},
],
entityMap: [
{
key: '0',
value: {
type: 'IMAGE',
mutability: 'Immutable',
data: {},
},
},
],
});
expect(result).toBe('[Image]');
});
it('handles complex document with mixed content', () => {
const result = renderContentState({
blocks: [
{ key: '1', type: 'unstyled', text: 'Introduction paragraph.', entityRanges: [], inlineStyleRanges: [] },
{ key: '2', type: 'header-two', text: 'Getting Started', entityRanges: [], inlineStyleRanges: [] },
{ key: '3', type: 'ordered-list-item', text: 'Install dependencies', entityRanges: [], inlineStyleRanges: [] },
{ key: '4', type: 'ordered-list-item', text: 'Run the script', entityRanges: [], inlineStyleRanges: [] },
{
key: '5',
type: 'atomic',
text: ' ',
entityRanges: [{ key: 0, offset: 0, length: 1 }],
inlineStyleRanges: [],
},
{ key: '6', type: 'unstyled', text: 'Conclusion.', entityRanges: [], inlineStyleRanges: [] },
],
entityMap: [
{
key: '0',
value: {
type: 'MARKDOWN',
mutability: 'Mutable',
data: { markdown: '```bash\nnpm install\n```' },
},
},
],
});
expect(result).toBe(
'Introduction paragraph.\n\n' +
'## Getting Started\n\n' +
'1. Install dependencies\n\n' +
'2. Run the script\n\n' +
'```bash\nnpm install\n```\n\n' +
'Conclusion.',
);
});
it('skips empty text blocks', () => {
const result = renderContentState({
blocks: [
{ key: '1', type: 'unstyled', text: 'Content', entityRanges: [], inlineStyleRanges: [] },
{ key: '2', type: 'unstyled', text: ' ', entityRanges: [], inlineStyleRanges: [] },
{ key: '3', type: 'unstyled', text: '', entityRanges: [], inlineStyleRanges: [] },
{ key: '4', type: 'unstyled', text: 'More content', entityRanges: [], inlineStyleRanges: [] },
],
entityMap: [],
});
expect(result).toBe('Content\n\nMore content');
});
it('handles missing entityRanges on text blocks', () => {
const result = renderContentState({
blocks: [{ key: '1', type: 'unstyled', text: 'Content' }],
entityMap: [],
});
expect(result).toBe('Content');
});
it('handles atomic block with missing entity gracefully', () => {
const result = renderContentState({
blocks: [
{ key: '1', type: 'unstyled', text: 'Before', entityRanges: [], inlineStyleRanges: [] },
{
key: '2',
type: 'atomic',
text: ' ',
entityRanges: [{ key: 99, offset: 0, length: 1 }],
inlineStyleRanges: [],
},
{ key: '3', type: 'unstyled', text: 'After', entityRanges: [], inlineStyleRanges: [] },
],
entityMap: [],
});
expect(result).toBe('Before\n\nAfter');
});
it('handles atomic block with no entityRanges gracefully', () => {
const result = renderContentState({
blocks: [
{ key: '1', type: 'unstyled', text: 'Before', entityRanges: [], inlineStyleRanges: [] },
{ key: '2', type: 'atomic', text: ' ', entityRanges: [], inlineStyleRanges: [] },
{ key: '3', type: 'unstyled', text: 'After', entityRanges: [], inlineStyleRanges: [] },
],
entityMap: [],
});
expect(result).toBe('Before\n\nAfter');
});
});
+129
View File
@@ -0,0 +1,129 @@
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { clearFeatureOverridesCache, refreshFeatureOverridesCache } from '../src/lib/runtime-features.js';
import { buildLikesFeatures, buildListsFeatures, buildSearchFeatures } from '../src/lib/twitter-client-features.js';
describe('runtime-features', () => {
afterEach(() => {
delete process.env.BIRD_FEATURES_JSON;
delete process.env.BIRD_FEATURES_CACHE;
delete process.env.BIRD_FEATURES_PATH;
clearFeatureOverridesCache();
});
it('applies global and set overrides from env json', () => {
process.env.BIRD_FEATURES_JSON = JSON.stringify({
global: {
global_flag: true,
ignored: 'nope',
},
sets: {
search: {
global_flag: false,
search_only: true,
bad: 1,
},
likes: {
likes_only: true,
},
lists: {
responsive_web_text_conversations_enabled: true,
},
},
});
clearFeatureOverridesCache();
const search = buildSearchFeatures();
expect(search.global_flag).toBe(false);
expect(search.search_only).toBe(true);
expect(search.bad).toBeUndefined();
const likes = buildLikesFeatures();
expect(likes.global_flag).toBe(true);
expect(likes.likes_only).toBe(true);
const lists = buildListsFeatures();
expect(lists.responsive_web_text_conversations_enabled).toBe(true);
});
it('refresh merges defaults with existing cache', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'features.json');
await writeFile(
cachePath,
JSON.stringify(
{
global: {
existing_flag: true,
},
sets: {
search: {
existing_search: false,
},
},
},
null,
2,
),
);
process.env.BIRD_FEATURES_PATH = cachePath;
await refreshFeatureOverridesCache();
const raw = await readFile(cachePath, 'utf8');
const parsed = JSON.parse(raw) as {
global?: Record<string, boolean>;
sets?: Record<string, Record<string, boolean>>;
};
expect(parsed.global?.existing_flag).toBe(true);
expect(parsed.global?.responsive_web_grok_annotations_enabled).toBe(false);
expect(parsed.sets?.search?.existing_search).toBe(false);
});
it('applies overrides from cache file', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'features.json');
await writeFile(
cachePath,
JSON.stringify(
{
global: {
file_flag: true,
},
sets: {
search: {
file_search: true,
},
},
},
null,
2,
),
);
process.env.BIRD_FEATURES_PATH = cachePath;
clearFeatureOverridesCache();
const search = buildSearchFeatures();
expect(search.file_flag).toBe(true);
expect(search.file_search).toBe(true);
});
it('includes required lists API feature flags', () => {
const features = buildListsFeatures();
expect(features.responsive_web_graphql_exclude_directive_enabled).toBe(true);
expect(features.blue_business_profile_image_shape_enabled).toBe(false);
expect(features.responsive_web_text_conversations_enabled).toBe(false);
expect(features.tweetypie_unmention_optimization_enabled).toBe(true);
expect(features.vibe_api_enabled).toBe(false);
expect(features.interactive_text_enabled).toBe(false);
});
});
+81 -1
View File
@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto';
import { mkdir, readFile } from 'node:fs/promises';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -8,6 +8,7 @@ import { createRuntimeQueryIdStore } from '../src/lib/runtime-query-ids.js';
describe('runtime-query-ids', () => {
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.BIRD_QUERY_IDS_CACHE;
});
it('refreshes IDs by scanning discovered bundles and persists cache', async () => {
@@ -60,4 +61,83 @@ describe('runtime-query-ids', () => {
const parsed = JSON.parse(raw) as { ids?: Record<string, string> };
expect(parsed.ids?.SearchTimeline).toBe('EEE');
});
it('uses env cache path override and returns fresh snapshot without refreshing', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'query-ids-cache.json');
process.env.BIRD_QUERY_IDS_CACHE = cachePath;
const snapshot = {
fetchedAt: new Date().toISOString(),
ttlMs: 60_000,
ids: { CreateTweet: 'AAA' },
discovery: { pages: ['https://x.com/'], bundles: ['main.js'] },
};
await writeFile(cachePath, JSON.stringify(snapshot), 'utf8');
const fetchMock = vi.fn();
const store = createRuntimeQueryIdStore({ fetchImpl: fetchMock as unknown as typeof fetch });
const info = await store.refresh(['CreateTweet']);
expect(store.cachePath).toBe(cachePath);
expect(info?.snapshot.ids.CreateTweet).toBe('AAA');
expect(fetchMock).not.toHaveBeenCalled();
store.clearMemory();
});
it('returns current snapshot when refresh finds no matching operations', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'query-ids.json');
const snapshot = {
fetchedAt: new Date(0).toISOString(),
ttlMs: 1,
ids: { CreateTweet: 'AAA' },
discovery: { pages: ['https://x.com/'], bundles: ['main.test.js'] },
};
await writeFile(cachePath, JSON.stringify(snapshot), 'utf8');
const html = `<script src="https://abs.twimg.com/responsive-web/client-web/main.test.js"></script>`;
const js = 'console.log("no ops here")';
const fetchMock = vi.fn(async (url: string | URL) => {
const asString = String(url);
if (asString.startsWith('https://x.com/')) {
return new Response(html, { status: 200 });
}
if (asString.startsWith('https://abs.twimg.com/')) {
return new Response(js, { status: 200 });
}
return new Response('not found', { status: 404 });
});
const store = createRuntimeQueryIdStore({
cachePath,
ttlMs: 1,
fetchImpl: fetchMock as unknown as typeof fetch,
});
const info = await store.refresh(['CreateTweet']);
expect(info?.snapshot.ids.CreateTweet).toBe('AAA');
});
it('throws when no bundles can be discovered', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'query-ids.json');
const fetchMock = vi.fn(async () => new Response('nope', { status: 500 }));
const store = createRuntimeQueryIdStore({
cachePath,
fetchImpl: fetchMock as unknown as typeof fetch,
});
await expect(store.refresh(['CreateTweet'], { force: true })).rejects.toThrow(
'No client bundles discovered; x.com layout may have changed.',
);
});
});
-277
View File
@@ -1,277 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SweetisticsClient } from '../src/lib/sweetistics-client.js';
describe('SweetisticsClient', () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('posts tweet with bearer token and reply id', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ success: true, tweetId: '123' }),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.tweet('hello world', '456');
expect(result.success).toBe(true);
expect(result.tweetId).toBe('123');
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.example.com/api/actions/tweet');
expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' });
expect(JSON.parse((init as RequestInit).body as string)).toEqual({ text: 'hello world', replyToTweetId: '456' });
});
it('returns error when API responds with failure', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ success: false, error: 'Unauthorized' }),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.tweet('test');
expect(result.success).toBe(false);
expect(result.error).toContain('Unauthorized');
});
it('uploads media and passes mediaIds into tweet', async () => {
const fetchMock = vi
.fn()
// upload
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ mediaId: 'mid-1' }),
})
// tweet
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ success: true, tweetId: '789' }),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const uploadRes = await client.uploadMedia({ data: 'Zm9v', mimeType: 'image/png', alt: 'foo' });
expect(uploadRes.success).toBe(true);
expect(uploadRes.mediaId).toBe('mid-1');
const tweetRes = await client.tweet('hi', undefined, ['mid-1']);
expect(tweetRes.success).toBe(true);
// First call: upload; second call: tweet with mediaIds
const [, tweetInit] = fetchMock.mock.calls[1];
expect(JSON.parse((tweetInit as RequestInit).body as string)).toMatchObject({ mediaIds: ['mid-1'] });
});
it('reads a tweet', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: '1',
text: 'hi',
author: { username: 'u', name: 'User' },
metrics: { likeCount: 5 },
}),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.read('1');
expect(result.success).toBe(true);
expect(result.tweet?.id).toBe('1');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.example.com/api/tweets/1');
expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' });
});
it('fetches replies', async () => {
const conversationPayload = {
tweetIds: ['1', '2'],
tweets: {
1: {
id: '1',
text: 'root',
author: { username: 'root', name: 'Root' },
conversationId: 'c1',
},
2: {
id: '2',
text: 'reply',
author: { username: 'reply', name: 'Reply' },
conversationId: 'c1',
inReplyToStatusId: '1',
},
},
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
result: { data: conversationPayload },
}),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.replies('1');
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('2');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/api/trpc/tweets.getConversation');
const params = new URL(url as string).searchParams.get('input');
expect(params).toBe(JSON.stringify({ tweetId: '1', force: 'true' }));
expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' });
});
it('fetches thread', async () => {
const conversationPayload = {
tweetIds: ['1', '2'],
tweets: {
1: { id: '1', text: 'root', author: { username: 'root', name: 'Root' }, conversationId: 'c1' },
2: { id: '2', text: 'reply', author: { username: 'reply', name: 'Reply' }, conversationId: 'c1' },
},
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
result: { data: conversationPayload },
}),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.thread('1');
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(2);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/api/trpc/tweets.getConversation');
const params = new URL(url as string).searchParams.get('input');
expect(params).toBe(JSON.stringify({ tweetId: '1', force: 'true' }));
expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' });
});
it('searches tweets', async () => {
const searchPayload = {
tweets: {
items: [
{
id: '3',
text: 'needle',
authorUsername: 'n',
authorName: 'Needle',
createdAt: '2024-01-01',
metrics: { replyCount: 1, retweetCount: 2, likeCount: 3 },
},
],
},
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => [{ result: { data: searchPayload } }],
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.search('needle', 5);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('3');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/api/trpc/search.execute');
expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' });
});
it('fetches current user', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => [{ result: { data: { json: { id: 'u1', username: 'tester', name: 'Test User' } } } }],
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.getCurrentUser();
expect(result.success).toBe(true);
expect(result.user?.id).toBe('u1');
expect(result.user?.username).toBe('tester');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.example.com/api/trpc/user.getCurrent?batch=1');
expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' });
});
it('falls back to GET when POST is not allowed', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 405, json: async () => ({}) })
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ result: { data: { json: { id: 'u2', username: 'fallback' } } } }),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.getCurrentUser();
expect(result.success).toBe(true);
expect(result.user?.id).toBe('u2');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][0]).toContain('input=null');
});
it('propagates Sweetistics user errors', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => [{ result: { error: { message: 'bad key' } } }],
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.getCurrentUser();
expect(result.success).toBe(false);
expect(result.error).toBe('bad key');
});
it('propagates Sweetistics error message', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 500,
json: async () => [{ error: { message: 'boom' } }],
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.search('needle', 1);
expect(result.success).toBe(false);
expect(result.error).toBe('boom');
});
});

Some files were not shown because too many files have changed in this diff Show More