Author SHA1 Message Date
yuta b8400469ca Release v0.10.0
CI / test (push) Successful in 9m59s
Publish npm package / publish (push) Successful in 9m53s
2026-07-14 02:35:26 +09:00
yuta 81921226f0 feat: support ranked tweet search
CI / test (push) Successful in 10m17s
2026-07-13 09:18:42 +09:00
yuta 0caf2677e4 Release v0.9.0
CI / test (push) Successful in 10m2s
Publish npm package / publish (push) Successful in 9m53s
2026-06-26 13:24:01 +09:00
yuta 9340ed3f47 Add profileImageUrl to TweetData.author
CI / test (push) Successful in 11m4s
2026-06-26 01:56:14 +09:00
yuta 2e293ad65b Add user profile resolution
CI / test (push) Successful in 10m0s
2026-06-24 20:46:34 +09:00
yuta 826de66d78 Add home timeline pagination flags
CI / test (push) Successful in 9m58s
2026-06-24 20:17:02 +09:00
yuta 01f4b58b99 test: require explicit relay fixture
CI / test (push) Successful in 9m59s
2026-06-24 19:20:39 +09:00
yuta eedf9b1644 fix: adapt upstream commands to relay client
CI / test (push) Failing after 5m19s
Publish npm package / publish (push) Successful in 9m52s
2026-06-24 18:51:33 +09:00
Peter Steinberger 55fd5c3d81 docs: bump changelog to 0.8.0 2026-06-24 18:45:21 +09:00
Peter Steinberger b7320a19c8 docs: update changelog for 0.7.1 (#51 #53 #54) 2026-06-24 18:45:21 +09:00
Peter Steinberger 9b40a2d43c chore: format files for lint (#53) (thanks @the-vampiire) 2026-06-24 18:44:38 +09:00
Peter Steinberger a167ac4380 fix: harden engagement mutations (#53) (thanks @the-vampiire) 2026-06-24 18:44:38 +09:00
the-vampiireandClaude Opus 4.5 81f1229660 style: format engagement test file
Fix biome formatting for mock chain.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-06-24 18:44:38 +09:00
the-vampiire da013664c8 feat: add engagement mixin for like/unlike/retweet/unretweet/bookmark
Adds a new mixin with programmatic engagement methods:
- like(tweetId) - FavoriteTweet
- unlike(tweetId) - UnfavoriteTweet
- retweet(tweetId) - CreateRetweet
- unretweet(tweetId) - DeleteRetweet
- bookmark(tweetId) - CreateBookmark

Implementation follows existing mixin patterns with:
- Shared performEngagementMutation() helper
- 404 retry with query ID refresh
- Returns BookmarkMutationResult for consistency

Also adds query IDs for the new operations to constants and
the update-query-ids script.

Includes 9 tests covering success cases, error handling, and retry logic.
2026-06-24 18:44:38 +09:00
clickingThingsAndMovingMountains 471cd9b26f feat(bookmarks): add thread expansion flags
Adds thread expansion + filtering for bookmarks, with optional ancestor branches and metadata.\n\nCo-authored-by: Klaus <[email protected]>
2026-06-24 18:44:38 +09:00
Peter Steinberger 966599522a fix: follow/unfollow hardening (#54) (thanks @citizenlee) 2026-06-24 18:43:54 +09:00
Pablo Tovar 38d5dfe96a test: move about command coverage 2026-06-24 18:42:39 +09:00
Pablo Tovar 98a7145dea test: cover about command 2026-06-24 18:42:39 +09:00
Pablo Tovar 75848960cb feat: add bird about command 2026-06-24 18:42:33 +09:00
Peter Steinberger 1b2afd5acb refactor: normalize about output + retry helper 2026-06-24 18:42:22 +09:00
Peter Steinberger 77c7d676a7 fix: about account live coverage (#51) (thanks @pjtf93) 2026-06-24 18:42:16 +09:00
Henry Lee 058aa0e3c3 fix: add cursor pagination support to REST fallback for followers/following
When GraphQL endpoints return 404 and fall back to REST API v1.1,
pagination now works correctly by:
- Accepting cursor parameter in getFollowersViaRest and getFollowingViaRest
- Passing cursor to REST API requests
- Extracting next_cursor_str from REST responses
- Returning nextCursor in the result for proper --all pagination
2026-06-24 18:42:16 +09:00
Henry Lee df449ea736 feat: add follow/unfollow commands
- bird follow <username-or-id>: follow a user by handle or ID
- bird unfollow <username-or-id>: unfollow a user by handle or ID

Uses REST API /1.1/friendships/create.json and /destroy.json with GraphQL fallback.
Accepts @handles, bare usernames, or numeric user IDs.
2026-06-24 18:41:35 +09:00
Pablo Tovar 3c2f6f480e docs: document about account access
Add README examples for getUserAboutAccount and about command.
2026-06-24 18:41:01 +09:00
Pablo Tovar 253791cc8f test: move about command coverage 2026-06-24 18:40:30 +09:00
Pablo Tovar 71aa56517e test: cover about command 2026-06-24 18:40:30 +09:00
Pablo Tovar afc06864c6 test: add getUserAboutAccount coverage 2026-06-24 18:40:30 +09:00
Pablo Tovar a2021193ee feat: add bird about command 2026-06-24 18:40:30 +09:00
Pablo Tovar 769245503c feat: add AboutAccountQuery client support 2026-06-24 18:40:30 +09:00
Peter Steinberger 083c8cd007 refactor(cli): dedupe commands 2026-06-24 18:40:30 +09:00
Peter Steinberger 6cf0c00d1b docs: update likes command flags 2026-06-24 18:37:35 +09:00
Peter Steinberger 17599dfb75 docs: release 0.7.0 2026-06-24 18:37:05 +09:00
Peter Steinberger 63e6d71959 fix: scope list feature overrides (#50) (thanks @ryanh-ai) 2026-06-24 18:35:53 +09:00
Ryan H 5509d915ba test: update feature flag expectations to match new values 2026-06-24 18:35:53 +09:00
loonbot 3dc78d8fff fix: add missing GraphQL feature flags (post_ctas_fetch_enabled et al)
Twitter's GraphQL API now requires additional feature flags that were missing.
This fixes issue #37 and merges PR #31 (home timeline command).

Added to global features.json:
- post_ctas_fetch_enabled
- responsive_web_graphql_exclude_directive_enabled
- blue_business_profile_image_shape_enabled
- tweetypie_unmention_optimization_enabled
- responsive_web_text_conversations_enabled
- interactive_text_enabled
- vibe_api_enabled
- responsive_web_twitter_blue_verified_badge_is_enabled

Fixes #37
2026-06-24 18:35:53 +09:00
yuta 3f370cc4b6 feat: support relay profile selection
CI / test (push) Successful in 10m2s
2026-06-24 18:30:24 +09:00
yuta 7a036e244d fix: parse verify credentials user id
CI / test (push) Successful in 9m59s
Publish npm package / publish (push) Failing after 5m12s
2026-06-24 16:33:43 +09:00
yuta ea7e5191b2 docs: update install and relay links
CI / test (push) Successful in 10m0s
2026-06-24 15:59:57 +09:00
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
95 changed files with 4019 additions and 2238 deletions
+1
View File
@@ -0,0 +1 @@
use flake
@@ -8,7 +8,7 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-24.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -29,10 +29,10 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: 'pnpm' cache: pnpm
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile --config.auto-install-peers=true
- name: Build dist - name: Build dist
run: pnpm run build:dist run: pnpm run build:dist
+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 }}
+42 -10
View File
@@ -1,30 +1,62 @@
# Changelog # Changelog
## 0.7.0 — Unreleased ## 0.10.0 — 2026-07-14
### Added
- Add Top and Latest product selection to library and CLI search.
## 0.9.0 — 2026-06-26
### Added
- `users` command for user profile lookup with avatars and descriptions.
- `home` pagination: `--all`, `--max-pages`, `--cursor` flags.
### Changed
- Tweet output now includes `profileImageUrl` in author data.
## 0.8.0 — 2026-01-19
### Added
- `bookmarks` thread expansion controls (`--expand-root-only`, `--author-chain`, `--author-only`, `--full-chain-only`, `--include-ancestor-branches`, `--include-parent`, `--thread-meta`, `--sort-chronological`) for richer context exports (#55) — thanks @kkretschmer2.
- `about` command to report account origin/location metadata (#51) — thanks @pjtf93.
- `follow`/`unfollow` commands to manage follows (#54) — thanks @citizenlee.
- Twitter client now supports like/unlike/retweet/unretweet/bookmark via the engagement mixin (#53) — thanks @the-vampiire.
### Fixed
- `bookmarks` expanded JSON now preserves pagination `nextCursor`, and full-chain filtering only includes ancestor branches when requested.
- Follow/unfollow REST fallback now supports cursor pagination for followers/following (#54).
- About account live coverage now verifies data extraction paths (#51) — thanks @pjtf93.
### Tests
- Live tests now exercise engagement mutations (opt-in) (#53) — thanks @the-vampiire.
## 0.7.0 — 2026-01-12
### Added ### 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. - `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. - `news`/`trending` command for Explore tabs with AI-curated headlines (#39) — thanks @aavetis.
- `user-tweets` command to fetch a user's profile timeline (#34) — thanks @crcatala. - `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. - `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. - `search` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#42) — thanks @pjtf93.
- `likes` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#44) — thanks @jsholmes. - `likes` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#44) — thanks @jsholmes.
- `list-timeline` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#30) — thanks @zheli.
- Rich text output now shows article previews, quoted tweets, and media links (#32) — thanks @odysseus0.
- Long-form article tweets now render rich Draft.js content blocks/entities (#36) — thanks @crcatala.
### Changed ### Changed
- Library typing: `SearchResult` is now a discriminated union (so `error` only exists when `success: false`). - Library typing: `SearchResult` is now a discriminated union (so `error` only exists when `success: false`).
### Fixed ### Fixed
- Lists GraphQL feature flags updated to prevent 400s (#27) — thanks @zheli.
- Lists feature overrides now scope new GraphQL flags correctly (#50) — thanks @ryanh-ai.
- Tweet detail parsing now tolerates partial GraphQL errors when usable data exists (#48) — thanks @jsholmes.
- News output now respects `--tweets-per-item`, keeps unique IDs, and parses non-add entry instructions (#39) — thanks @aavetis.
- Following/followers pagination now guards repeat cursors and standardizes JSON output (#28) — thanks @malpern.
- Likes pagination now follows cursors and avoids stalling on duplicate pages (#12) — thanks @titouv.
- macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst. - 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. - 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. - `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 ### Tests
- Following: split following/likes tests + cover cursor handling (#33) — thanks @VACInc. - Following: split following/likes tests + cover cursor handling (#33) — thanks @VACInc.
+94 -53
View File
@@ -1,34 +1,38 @@
# bird 🐦 — fast X CLI for tweeting, replying, and reading # bird 🐦 — fast X CLI for tweeting, replying, and reading
`bird` is a fast X CLI for tweeting, replying, and reading via X/Twitter GraphQL (cookie auth). `bird` is a fast X CLI for tweeting, replying, and reading via X/Twitter GraphQL through
[twitter safe relay](https://github.com/fa0311/twitter_api_safe_relay).
## Disclaimer ## Disclaimer
This project uses X/Twitter’s **undocumented** web GraphQL API (and cookie auth). X can change endpoints, query IDs, This project uses X/Twitter’s [**documented**](https://github.com/fa0311/TwitterInternalAPIDocument) internal web
and anti-bot behavior at any time — **expect this to break without notice**. GraphQL API through [twitter safe relay](https://github.com/fa0311/twitter_api_safe_relay). X can change endpoints,
query IDs, and anti-bot behavior at any time — **expect this to break without notice**.
## Install ## Install
Gitea Packages:
```bash ```bash
npm install -g @steipete/bird npm config set @yuta:registry=https://git.yutakobayashi.com/api/packages/yuta/npm/
# or npm install -g @yuta/bird
pnpm add -g @steipete/bird
# or
bun add -g @steipete/bird
# one-shot (no install) # one-shot (no install)
bunx @steipete/bird whoami npx -y --@yuta:registry=https://git.yutakobayashi.com/api/packages/yuta/npm/ @yuta/bird whoami
``` ```
Homebrew (macOS, prebuilt Bun binary): Nix flakes:
```bash ```bash
brew install steipete/tap/bird nix run git+https://git.yutakobayashi.com/yuta/bird -- --help
``` ```
## Quickstart ## Quickstart
```bash ```bash
# Point bird at your running twitter safe relay
export TWITTER_RELAY_BASE_URL=http://localhost:6900
# Show the logged-in account # Show the logged-in account
bird whoami bird whoami
@@ -47,6 +51,8 @@ bird thread 1234567890123456789 --max-pages 3 --json
# Search + mentions # Search + mentions
bird search "from:steipete" -n 5 bird search "from:steipete" -n 5
bird search "AI lang:ja" --product Top -n 20
bird search "from:steipete" --product Latest -n 20
bird mentions -n 5 bird mentions -n 5
bird mentions --user @steipete -n 5 bird mentions --user @steipete -n 5
@@ -59,6 +65,7 @@ bird bookmarks -n 5
bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/<folder-id> bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/<folder-id>
bird bookmarks --all --json bird bookmarks --all --json
bird bookmarks --all --max-pages 2 --json bird bookmarks --all --max-pages 2 --json
bird bookmarks --include-parent --json
bird unbookmark 1234567890123456789 bird unbookmark 1234567890123456789
bird unbookmark https://x.com/user/status/1234567890123456789 bird unbookmark https://x.com/user/status/1234567890123456789
@@ -82,6 +89,10 @@ bird following --user 12345678 -n 10 # by user ID
bird followers -n 20 bird followers -n 20
bird followers --user 12345678 -n 10 # by user ID bird followers --user 12345678 -n 10 # by user ID
# Rich profile resolution
bird user @steipete --json
bird profiles @steipete 12345678 --json
# Refresh GraphQL query IDs cache (no rebuild) # Refresh GraphQL query IDs cache (no rebuild)
bird query-ids --fresh bird query-ids --fresh
``` ```
@@ -127,13 +138,18 @@ By default, the command fetches from For You, News, Sports, and Entertainment ta
`bird` can be used as a library (same GraphQL client as the CLI): `bird` can be used as a library (same GraphQL client as the CLI):
```ts ```ts
import { TwitterClient, resolveCredentials } from '@steipete/bird'; import { TwitterClient } from '@yuta/bird';
const { cookies } = await resolveCredentials({ cookieSource: 'safari' }); const client = new TwitterClient({
const client = new TwitterClient({ cookies }); relayBaseUrl: process.env.TWITTER_RELAY_BASE_URL,
profileName: process.env.BIRD_PROFILE_NAME
});
// Search for tweets // Search for tweets
const searchResult = await client.search('from:steipete', 50); const searchResult = await client.search('from:steipete', 50);
const popular = await client.search('AI lang:ja', 20, {
product: 'Top',
});
// Fetch news and trending topics from all tabs (default: For You, News, Sports, Entertainment) // Fetch news and trending topics from all tabs (default: For You, News, Sports, Entertainment)
const newsResult = await client.getNews(10, { aiOnly: true }); const newsResult = await client.getNews(10, { aiOnly: true });
@@ -146,45 +162,64 @@ const sportsNews = await client.getNews(10, {
}); });
``` ```
Account details (About profile):
```ts
const aboutResult = await client.getUserAboutAccount('steipete');
if (aboutResult.success && aboutResult.aboutProfile) {
console.log(aboutResult.aboutProfile.accountBasedIn);
}
```
Fields:
- `accountBasedIn`
- `source`
- `createdCountryAccurate`
- `locationAccurate`
- `learnMoreUrl`
## Commands ## Commands
- `bird tweet "<text>"` — post a new tweet. - `bird tweet "<text>"` — post a new tweet.
- `bird reply <tweet-id-or-url> "<text>"` — reply to a tweet using its ID or URL. - `bird reply <tweet-id-or-url> "<text>"` — reply to a tweet using its ID or URL.
- `bird help [command]` — show help (or help for a subcommand). - `bird help [command]` — show help (or help for a subcommand).
- `bird query-ids [--fresh] [--json]` — inspect or refresh cached GraphQL query IDs. - `bird query-ids [--fresh] [--json]` — inspect or refresh cached GraphQL query IDs.
- `bird home [-n count] [--following] [--all] [--max-pages n] [--cursor string] [--delay ms] [--json] [--json-full]` — fetch your home timeline (For You) or Following feed; `--max-pages` implies pagination.
- `bird read <tweet-id-or-url> [--json]` — fetch tweet content as text or JSON. - `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 <tweet-id-or-url> [--json]` — shorthand for `read` when only a URL or ID is provided.
- `bird replies <tweet-id-or-url> [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — list replies to a tweet. - `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 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 search "<query>" [-n count] [--all] [--max-pages n] [--cursor string] [--product Top|Latest] [--json]` — search for tweets matching a query; `Latest` is the default product, and `--max-pages` requires `--all` or `--cursor`.
- `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user). - `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
- `bird user-tweets <@handle> [-n count] [--cursor string] [--max-pages n] [--delay ms] [--json]` — get tweets from a user's profile timeline. - `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 bookmarks [-n count] [--folder-id id] [--all] [--max-pages n] [--cursor string] [--expand-root-only] [--author-chain] [--author-only] [--full-chain-only] [--include-ancestor-branches] [--include-parent] [--thread-meta] [--sort-chronological] [--json]` — list your bookmarked tweets (or a specific bookmark folder); expansion flags control thread context; `--max-pages` requires `--all` or `--cursor`.
- `bird unbookmark <tweet-id-or-url...>` — remove one or more bookmarks by tweet ID or URL. - `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 likes [-n count] [--all] [--max-pages n] [--cursor string] [--json] [--json-full]` — list your liked tweets; `--max-pages` requires `--all` or `--cursor`.
- `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 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 trending` — alias for `news` command.
- `bird lists [--member-of] [-n count] [--json]` — list your lists (owned or memberships).
- `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 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 following [--user <userId>] [-n count] [--cursor string] [--all] [--max-pages n] [--json]` — list users that you (or another user) follow; `--max-pages` requires `--all`.
- `bird followers [--user <userId>] [-n count] [--json]` — list users that follow you (or another user). - `bird followers [--user <userId>] [-n count] [--cursor string] [--all] [--max-pages n] [--json]` — list users that follow you (or another user); `--max-pages` requires `--all`.
- `bird whoami` — print which Twitter account your cookies belong to. - `bird user <@handle-or-id> [--json]` — resolve a rich user profile.
- `bird check` — show which credentials are available and where they were sourced from. - `bird profiles <@handle-or-id...> [--json]` — resolve rich user profiles with per-target errors.
- `bird likes [-n count] [--json]` — list your liked tweets. - `bird about <@handle> [--json]` — get account origin and location information for a user.
- `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 whoami` — print which Twitter account your relay belongs to.
- `bird trending` — alias for `news` command. - `bird check` — verify relay configuration and account access.
- `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. Bookmarks flags:
- `bird followers [--user <userId>] [-n count] [--json]` — list users that follow you (or another user). - `--expand-root-only`: expand threads only when the bookmark is a root tweet.
- `bird whoami` — print which Twitter account your cookies belong to. - `--author-chain`: keep only the bookmarked author's connected self-reply chain.
- `bird check` — show which credentials are available and where they were sourced from. - `--author-only`: include all tweets from the bookmarked author within the thread.
- `--full-chain-only`: keep the entire reply chain connected to the bookmarked tweet (all authors).
- `--include-ancestor-branches`: include sibling branches for ancestors when using `--full-chain-only`.
- `--include-parent`: include the direct parent tweet for non-root bookmarks.
- `--thread-meta`: add thread metadata fields to each tweet.
- `--sort-chronological`: sort output globally oldest to newest (default preserves bookmark order).
Global options: Global options:
- `--auth-token <token>`: set the `auth_token` cookie manually. - `--relay-base-url <url>`: twitter safe relay base URL (defaults to `TWITTER_RELAY_BASE_URL`).
- `--ct0 <token>`: set the `ct0` cookie manually. - `--profile-name <name>`: twitter safe relay profile name (sent as `x-profile-name`).
- `--cookie-source <safari|chrome|firefox>`: choose browser cookie source (repeatable; order matters).
- `--chrome-profile <name>`: Chrome profile for cookie extraction.
- `--firefox-profile <name>`: Firefox profile for cookie extraction.
- `--cookie-timeout <ms>`: cookie extraction timeout for keychain/OS helpers (milliseconds).
- `--timeout <ms>`: abort requests after the given timeout (milliseconds). - `--timeout <ms>`: abort requests after the given timeout (milliseconds).
- `--quote-depth <n>`: max quoted tweet depth in JSON output (default: 1; 0 disables). - `--quote-depth <n>`: max quoted tweet depth in JSON output (default: 1; 0 disables).
- `--plain`: stable output (no emoji, no color). - `--plain`: stable output (no emoji, no color).
@@ -195,23 +230,16 @@ Global options:
## Authentication (GraphQL) ## Authentication (GraphQL)
GraphQL mode uses your existing X/Twitter web session (no password prompt). It sends requests to internal `bird` does not handle Twitter authentication locally. It sends Twitter API-compatible requests to
X endpoints and authenticates via cookies (`auth_token`, `ct0`). [twitter safe relay](https://github.com/fa0311/twitter_api_safe_relay), and the relay handles authentication.
Write operations: Write operations:
- `tweet`/`reply` primarily use GraphQL (`CreateTweet`). - `tweet`/`reply` primarily use GraphQL (`CreateTweet`).
- If GraphQL returns error `226` (“automated request”), `bird` falls back to the legacy `statuses/update.json` endpoint. - If GraphQL returns error `226` (“automated request”), `bird` falls back to the legacy `statuses/update.json` endpoint.
`bird` resolves credentials in this order: Configure the relay URL with `TWITTER_RELAY_BASE_URL` or `--relay-base-url`.
When your relay has multiple profiles, select one with `--profile-name`, `BIRD_PROFILE_NAME`, or `profileName`
1. CLI flags: `--auth-token`, `--ct0` in config; bird forwards it as the `x-profile-name` header.
2. Environment variables: `AUTH_TOKEN`, `CT0` (fallback: `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`)
3. Browser cookies via `@steipete/sweet-cookie` (override via `--cookie-source` order)
Browser cookie sources:
- Safari: `~/Library/Cookies/Cookies.binarycookies` (fallback: `~/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies`)
- Chrome: `~/Library/Application Support/Google/Chrome/<Profile>/Cookies`
- Firefox: `~/Library/Application Support/Firefox/Profiles/<profile>/cookies.sqlite`
## Config (JSON5) ## Config (JSON5)
@@ -224,18 +252,17 @@ Example `~/.config/bird/config.json5`:
```json5 ```json5
{ {
// Cookie source order for browser extraction (string or array) relayBaseUrl: "http://localhost:6900",
cookieSource: ["firefox", "safari"], profileName: "main",
firefoxProfile: "default-release",
cookieTimeoutMs: 30000,
timeoutMs: 20000, timeoutMs: 20000,
quoteDepth: 1 quoteDepth: 1
} }
``` ```
Environment shortcuts: Environment shortcuts:
- `TWITTER_RELAY_BASE_URL`
- `BIRD_PROFILE_NAME`
- `BIRD_TIMEOUT_MS` - `BIRD_TIMEOUT_MS`
- `BIRD_COOKIE_TIMEOUT_MS`
- `BIRD_QUOTE_DEPTH` - `BIRD_QUOTE_DEPTH`
## Output ## Output
@@ -277,6 +304,18 @@ When using `--json` with `following`/`followers`, user objects include:
| `profileImageUrl` | string? | Profile image URL | | `profileImageUrl` | string? | Profile image URL |
| `createdAt` | string? | Account creation timestamp | | `createdAt` | string? | Account creation timestamp |
When using `--json` with `user`/`profiles`, profile objects include the user fields above plus richer metadata when available:
| Field | Type | Description |
|-------|------|-------------|
| `location` | string? | Profile location |
| `url` | string? | Expanded profile URL |
| `verified` | boolean? | Verified status |
| `verifiedType` | string? | Verification type |
| `entities` | object? | Raw profile URL/bio entities |
| `affiliation` | object? | Highlighted-label organization metadata |
| `raw` | object? | Raw GraphQL user payload |
When using `--json` with `news`/`trending`, news objects include: When using `--json` with `news`/`trending`, news objects include:
| Field | Type | Description | | Field | Type | Description |
@@ -331,7 +370,7 @@ Exit codes:
- Attach media with `--media` (repeatable) and optional `--alt` per item. - 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. - Up to 4 images/GIFs, or 1 video (no mixing). Supported: jpg, jpeg, png, webp, gif, mp4, mov.
- Images/GIFs + 1 video supported (uploads via Twitter legacy upload endpoint + cookies; video may take longer to process). - Images/GIFs + 1 video supported (uploads via Twitter legacy upload endpoint through the relay; video may take longer to process).
Example: Example:
@@ -347,6 +386,8 @@ pnpm install
pnpm run build # dist/ + bun binary pnpm run build # dist/ + bun binary
pnpm run build:dist # dist/ only pnpm run build:dist # dist/ only
pnpm run build:binary pnpm run build:binary
nix build .#bird
nix run .# -- --help
pnpm run dev tweet "Test" pnpm run dev tweet "Test"
pnpm run dev -- --plain check pnpm run dev -- --plain check
+1 -2
View File
@@ -35,9 +35,8 @@
<p class="lede lede--small">That path doesn’t exist. The CLI does.</p> <p class="lede lede--small">That path doesn’t exist. The CLI does.</p>
<div class="cta"> <div class="cta">
<a class="btn btn--hot" href="./">Go home</a> <a class="btn btn--hot" href="./">Go home</a>
<a class="btn btn--ghost" href="https://github.com/steipete/bird">Open GitHub</a> <a class="btn btn--ghost" href="https://git.yutakobayashi.com/yuta/bird">Open Gitea</a>
</div> </div>
</main> </main>
</body> </body>
</html> </html>
+23 -22
View File
@@ -9,7 +9,7 @@
<title>bird.fast — fast X CLI</title> <title>bird.fast — fast X CLI</title>
<meta <meta
name="description" name="description"
content="bird is a fast X CLI for tweeting, replying, and reading via the X/Twitter GraphQL API (cookie auth)." 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="canonical" href="https://bird.fast/" />
@@ -53,7 +53,7 @@
<nav class="nav"> <nav class="nav">
<a class="nav__link" href="#install">Install</a> <a class="nav__link" href="#install">Install</a>
<a class="nav__link" href="#commands">Commands</a> <a class="nav__link" href="#commands">Commands</a>
<a class="nav__link" href="https://github.com/steipete/bird">GitHub</a> <a class="nav__link" href="https://git.yutakobayashi.com/yuta/bird">Gitea</a>
</nav> </nav>
</div> </div>
</header> </header>
@@ -74,7 +74,7 @@
<div class="cta" data-reveal> <div class="cta" data-reveal>
<a class="btn btn--hot" href="#install">Install</a> <a class="btn btn--hot" href="#install">Install</a>
<a class="btn btn--ghost" href="https://github.com/steipete/bird#quickstart">Quickstart</a> <a class="btn btn--ghost" href="https://git.yutakobayashi.com/yuta/bird#quickstart">Quickstart</a>
</div> </div>
<div class="terminal" data-reveal> <div class="terminal" data-reveal>
@@ -85,7 +85,7 @@
<span class="terminal__label">copy &amp; run</span> <span class="terminal__label">copy &amp; run</span>
</div> </div>
<div class="terminal__body"> <div class="terminal__body">
<pre><code id="installCmd">npm install -g @steipete/bird <pre><code id="installCmd">npm install -g @yuta/bird
bird whoami bird whoami
bird tweet "hi from bird.fast"</code></pre> bird tweet "hi from bird.fast"</code></pre>
@@ -97,17 +97,17 @@ bird tweet "hi from bird.fast"</code></pre>
</div> </div>
<div class="badges" data-reveal> <div class="badges" data-reveal>
<a class="badge" href="https://www.npmjs.com/package/@steipete/bird"> <a class="badge" href="https://www.npmjs.com/package/@yuta/bird">
<span class="badge__k">npm</span> <span class="badge__k">npm</span>
<span class="badge__v">@steipete/bird</span> <span class="badge__v">@yuta/bird</span>
</a> </a>
<a class="badge" href="https://github.com/steipete/bird"> <a class="badge" href="https://git.yutakobayashi.com/yuta/bird">
<span class="badge__k">repo</span> <span class="badge__k">repo</span>
<span class="badge__v">steipete/bird</span> <span class="badge__v">yuta/bird</span>
</a> </a>
<a class="badge" href="https://github.com/steipete/bird#authentication-graphql"> <a class="badge" href="https://git.yutakobayashi.com/yuta/bird#authentication-graphql">
<span class="badge__k">auth</span> <span class="badge__k">auth</span>
<span class="badge__v">cookies</span> <span class="badge__v">relay</span>
</a> </a>
</div> </div>
</div> </div>
@@ -120,14 +120,14 @@ bird tweet "hi from bird.fast"</code></pre>
<article class="card"> <article class="card">
<h3 class="h3">npm / pnpm / bun</h3> <h3 class="h3">npm / pnpm / bun</h3>
<p class="p">Pick your poison. Node ≥ 20.</p> <p class="p">Pick your poison. Node ≥ 20.</p>
<pre class="code"><code>npm install -g @steipete/bird <pre class="code"><code>npm install -g @yuta/bird
pnpm add -g @steipete/bird pnpm add -g @yuta/bird
bun add -g @steipete/bird</code></pre> bun add -g @yuta/bird</code></pre>
</article> </article>
<article class="card"> <article class="card">
<h3 class="h3">One-shot</h3> <h3 class="h3">One-shot</h3>
<p class="p">No install. Just prove it works.</p> <p class="p">No install. Just prove it works.</p>
<pre class="code"><code>bunx @steipete/bird whoami</code></pre> <pre class="code"><code>bunx @yuta/bird whoami</code></pre>
</article> </article>
<article class="card"> <article class="card">
<h3 class="h3">Homebrew (macOS)</h3> <h3 class="h3">Homebrew (macOS)</h3>
@@ -197,22 +197,23 @@ bun add -g @steipete/bird</code></pre>
<aside class="notice notice--warn" role="note" aria-label="Disclaimer"> <aside class="notice notice--warn" role="note" aria-label="Disclaimer">
<div class="notice__icon" aria-hidden="true">!</div> <div class="notice__icon" aria-hidden="true">!</div>
<div class="notice__body"> <div class="notice__body">
<p class="notice__title">Undocumented API.</p> <p class="notice__title">Documented internal API.</p>
<p class="notice__text"> <p class="notice__text">
bird uses X/Twitter’s private web GraphQL endpoints and rotating query IDs. X can change things anytime — bird uses X/Twitter’s
expect breakage without notice. <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> </p>
</div> </div>
</aside> </aside>
<div class="footerline"> <div class="footerline">
<a class="tiny" href="https://github.com/steipete/bird#authentication-graphql"> <a class="tiny" href="https://git.yutakobayashi.com/yuta/bird#authentication-graphql">
Authentication details Authentication details
</a> </a>
<span class="sep" aria-hidden="true">•</span> <span class="sep" aria-hidden="true">•</span>
<a class="tiny" href="https://github.com/steipete/bird#config-json5">Config (JSON5)</a> <a class="tiny" href="https://git.yutakobayashi.com/yuta/bird#config-json5">Config (JSON5)</a>
<span class="sep" aria-hidden="true">•</span> <span class="sep" aria-hidden="true">•</span>
<a class="tiny" href="https://github.com/steipete/bird/issues">Issues</a> <a class="tiny" href="https://git.yutakobayashi.com/yuta/bird/issues">Issues</a>
</div> </div>
</div> </div>
</section> </section>
@@ -221,9 +222,9 @@ bun add -g @steipete/bird</code></pre>
<footer class="bottom"> <footer class="bottom">
<div class="bottom__inner"> <div class="bottom__inner">
<p class="tiny"> <p class="tiny">
Made by <a href="https://steipete.me">Peter</a>. MIT licensed. Built for GitHub Pages. MIT licensed.
</p> </p>
<p class="tiny tiny--muted">bird.fast is the landing page; the project lives on GitHub.</p> <p class="tiny tiny--muted">bird.fast is the landing page; the project lives on Gitea.</p>
</div> </div>
</footer> </footer>
+31 -16
View File
@@ -1,10 +1,12 @@
# Releasing bird # Releasing bird
Target destinations: 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`) - Homebrew: tap formula in `steipete/homebrew-tap` (e.g., `bird.rb`)
## Checklist (npm + GitHub) ## Checklist (npm + Gitea)
1) Version bump 1) Version bump
- Update `package.json` `version` (semver, e.g., `0.5.0`). - Update `package.json` `version` (semver, e.g., `0.5.0`).
- Update `CHANGELOG.md` and tag the release section. - Update `CHANGELOG.md` and tag the release section.
@@ -13,22 +15,32 @@ Target destinations:
- `pnpm install` - `pnpm install`
- `pnpm test` - `pnpm test`
- `pnpm run build` - `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`). - 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: - Verify:
- `npm view @steipete/bird version` - `npm view @yuta/bird version`
- `npx -y @steipete/bird@<version> --help` - `npx -y @yuta/bird@<version> --help`
4) Git tag & GitHub release 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>` - `git tag v<version> && git push origin v<version>`
- Create GitHub release from the tag. Include changelog notes and attach optional binary (see below). - Create a Gitea release from the tag. Include changelog notes and attach optional binary (see below).
## Optional: attach compiled binary ## 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`). - 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) ## Homebrew tap update (steipete/homebrew-tap)
1) Package the binary 1) Package the binary
@@ -38,7 +50,7 @@ If you want a single-file binary for Homebrew/GitHub assets:
2) Update formula in tap repo 2) Update formula in tap repo
- File: `homebrew-tap/bird.rb` (create if absent). Model it after `poltergeist.rb`. - File: `homebrew-tap/bird.rb` (create if absent). Model it after `poltergeist.rb`.
- Fields to update: - Fields to update:
- `url "https://github.com/steipete/bird/releases/download/v<version>/bird-macos-universal-v<version>.tar.gz"` - `url "https://git.yutakobayashi.com/yuta/bird/releases/download/v<version>/bird-macos-universal-v<version>.tar.gz"`
- `sha256 "<calculated_sha>"` - `sha256 "<calculated_sha>"`
- `version "<version>"` - `version "<version>"`
- Install block: `bin.install "bird"` - Install block: `bin.install "bird"`
@@ -49,11 +61,14 @@ If you want a single-file binary for Homebrew/GitHub assets:
## Release order suggestion ## Release order suggestion
1) Merge to `main` and tag. 1) Merge to `main` and tag.
2) Publish npm. 2) Publish npmjs.
3) Build binary, upload to GitHub release. 3) Let Gitea Actions publish the package to Gitea Packages.
4) Update Homebrew tap with new URL/SHA. 4) Build binary, upload to Gitea release.
5) Update Homebrew tap with new URL/SHA.
## Notes ## 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. - Homebrew formula assumes macOS universal binary; adjust URL/name if you ship per-arch.
- Config defaults (JSON5) and Safari/Chrome/Firefox 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.
+8 -10
View File
@@ -8,27 +8,25 @@
Runs the CLI against real Twitter/X GraphQL endpoints to verify read-only commands still work. Runs the CLI against real Twitter/X GraphQL endpoints to verify read-only commands still work.
Requirements: Requirements:
- Auth cookies in env: - `TWITTER_RELAY_BASE_URL` pointing at a running twitter safe relay
- `AUTH_TOKEN` (or `TWITTER_AUTH_TOKEN`)
- `CT0` (or `TWITTER_CT0`)
- Network access - Network access
Run: Run:
- `pnpm test:live` - `pnpm test:live`
- `pnpm bird following --all --max-pages 2 --json --cookie-source chrome --chrome-profile Default` - `pnpm bird following --all --max-pages 2 --json`
- `pnpm bird list-timeline <list-id> --all --max-pages 2 --json --cookie-source chrome --chrome-profile Default` - `pnpm bird list-timeline <list-id> --all --max-pages 2 --json`
- `pnpm bird search "from:steipete" --all --max-pages 2 --json --cookie-source chrome --chrome-profile Default` - `pnpm bird search "from:steipete" --all --max-pages 2 --json`
- `pnpm bird home --count 5 --json --cookie-source chrome --chrome-profile Default` - `pnpm bird home --count 5 --json`
- `pnpm bird home --count 5 --following --json --cookie-source chrome --chrome-profile Default` - `pnpm bird home --count 5 --following --json`
Notes: Notes:
- Live tests are skipped unless `BIRD_LIVE=1` (set by `pnpm test:live`). - Live tests are skipped unless `BIRD_LIVE=1` (set by `pnpm test:live`).
- Search query is configurable via `BIRD_LIVE_SEARCH_QUERY`. - Search query is configurable via `BIRD_LIVE_SEARCH_QUERY`.
- Follow/unfollow handle is configurable via `BIRD_LIVE_FOLLOW_HANDLE` (opt-in).
- Command timeout is configurable via `BIRD_LIVE_TIMEOUT_MS` (ms). - Command timeout is configurable via `BIRD_LIVE_TIMEOUT_MS` (ms).
- Cookie extraction timeout is configurable via `BIRD_LIVE_COOKIE_TIMEOUT_MS` (ms).
- Spawned CLI `NODE_ENV` defaults to `production` (override with `BIRD_LIVE_NODE_ENV`). - 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`. - 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`). - 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`. - 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`. - `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; it still uses cookie auth (no developer API key). - 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);
};
}
+6 -8
View File
@@ -1,6 +1,6 @@
{ {
"name": "@steipete/bird", "name": "@yuta/bird",
"version": "0.7.0", "version": "0.10.0",
"description": "CLI tool for tweeting and replying via Twitter/X GraphQL API", "description": "CLI tool for tweeting and replying via Twitter/X GraphQL API",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -8,6 +8,10 @@
"bin": { "bin": {
"bird": "dist/cli.js" "bird": "dist/cli.js"
}, },
"repository": {
"type": "git",
"url": "https://git.yutakobayashi.com/yuta/bird.git"
},
"files": [ "files": [
"dist", "dist",
"README.md", "README.md",
@@ -35,16 +39,10 @@
"graphql:update": "tsx scripts/update-query-ids.ts" "graphql:update": "tsx scripts/update-query-ids.ts"
}, },
"dependencies": { "dependencies": {
"@steipete/sweet-cookie": "0.1.0",
"commander": "^14.0.2", "commander": "^14.0.2",
"json5": "^2.2.3", "json5": "^2.2.3",
"kleur": "^4.1.5" "kleur": "^4.1.5"
}, },
"pnpm": {
"patchedDependencies": {
"@steipete/sweet-cookie": "patches/@steipete__sweet-cookie.patch"
}
},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.10", "@biomejs/biome": "^2.3.10",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
-76
View File
@@ -1,76 +0,0 @@
diff --git a/dist/providers/chromeSqliteMac.js b/dist/providers/chromeSqliteMac.js
index 71c815f77378d03e07061976ac372b3a55a61ac4..b21c15640955a8e7faa8f1d6ea6fed7ca8f04ddb 100644
--- a/dist/providers/chromeSqliteMac.js
+++ b/dist/providers/chromeSqliteMac.js
@@ -4,19 +4,37 @@ import { decryptChromiumAes128CbcCookieValue, deriveAes128CbcKeyFromPassword, }
import { getCookiesFromChromeSqliteDb } from './chromeSqlite/shared.js';
import { readKeychainGenericPasswordFirst } from './chromium/macosKeychain.js';
import { resolveCookiesDbFromProfileOrRoots } from './chromium/paths.js';
+
+function resolveKeychainForDb(dbPath) {
+ const lower = dbPath.toLowerCase();
+ if (lower.includes('bravesoftware') || lower.includes('brave-browser') || lower.includes('brave browser')) {
+ return {
+ account: 'Brave',
+ services: ['Brave Safe Storage'],
+ label: 'Brave Safe Storage',
+ };
+ }
+ return {
+ account: 'Chrome',
+ services: ['Chrome Safe Storage'],
+ label: 'Chrome Safe Storage',
+ };
+}
+
export async function getCookiesFromChromeSqliteMac(options, origins, allowlistNames) {
const dbPath = resolveChromeCookiesDb(options.profile);
if (!dbPath) {
return { cookies: [], warnings: ['Chrome cookies database not found.'] };
}
const warnings = [];
- // On macOS, Chrome stores its "Safe Storage" secret in Keychain.
+ // On macOS, Chromium stores its "Safe Storage" secret in Keychain.
// `security find-generic-password` is stable and avoids any native Node keychain modules.
+ const keychain = resolveKeychainForDb(dbPath);
const passwordResult = await readKeychainGenericPasswordFirst({
- account: 'Chrome',
- services: ['Chrome Safe Storage'],
- timeoutMs: 3_000,
- label: 'Chrome Safe Storage',
+ account: keychain.account,
+ services: keychain.services,
+ timeoutMs: options.timeoutMs ?? 3_000,
+ label: keychain.label,
});
if (!passwordResult.ok) {
warnings.push(passwordResult.error);
@@ -24,7 +42,7 @@ export async function getCookiesFromChromeSqliteMac(options, origins, allowlistN
}
const chromePassword = passwordResult.password.trim();
if (!chromePassword) {
- warnings.push('macOS Keychain returned an empty Chrome Safe Storage password.');
+ warnings.push(`macOS Keychain returned an empty ${keychain.label} password.`);
return { cookies: [], warnings };
}
// Chromium uses PBKDF2(password, "saltysalt", 1003, 16, sha1) for AES-128-CBC cookie values on macOS.
@@ -46,14 +64,18 @@ export async function getCookiesFromChromeSqliteMac(options, origins, allowlistN
result.warnings.unshift(...warnings);
return result;
}
+
function resolveChromeCookiesDb(profile) {
const home = homedir();
/* c8 ignore next */
const roots = process.platform === 'darwin'
- ? [path.join(home, 'Library', 'Application Support', 'Google', 'Chrome')]
+ ? [
+ path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'),
+ path.join(home, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'),
+ ]
: [];
const args = { roots };
if (profile !== undefined)
args.profile = profile;
return resolveCookiesDbFromProfileOrRoots(args);
}
+20 -15
View File
@@ -1,21 +1,13 @@
lockfileVersion: '9.0' lockfileVersion: '9.0'
settings: settings:
autoInstallPeers: false autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
patchedDependencies:
'@steipete/sweet-cookie':
hash: 4ff44e9d3b5770a7b751c7a3470572bdf485763c28ead8055468dd0ae9a6e986
path: patches/@steipete__sweet-cookie.patch
importers: importers:
.: .:
dependencies: dependencies:
'@steipete/sweet-cookie':
specifier: 0.1.0
version: 0.1.0(patch_hash=4ff44e9d3b5770a7b751c7a3470572bdf485763c28ead8055468dd0ae9a6e986)
commander: commander:
specifier: ^14.0.2 specifier: ^14.0.2
version: 14.0.2 version: 14.0.2
@@ -96,24 +88,28 @@ packages:
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@biomejs/[email protected]': '@biomejs/[email protected]':
resolution: {integrity: sha512-hhPw2V3/EpHKsileVOFynuWiKRgFEV48cLe0eA+G2wO4SzlwEhLEB9LhlSrVeu2mtSn205W283LkX7Fh48CaxA==} resolution: {integrity: sha512-hhPw2V3/EpHKsileVOFynuWiKRgFEV48cLe0eA+G2wO4SzlwEhLEB9LhlSrVeu2mtSn205W283LkX7Fh48CaxA==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@biomejs/[email protected]': '@biomejs/[email protected]':
resolution: {integrity: sha512-QTfHZQh62SDFdYc2nfmZFuTm5yYb4eO1zwfB+90YxUumRCR171tS1GoTX5OD0wrv4UsziMPmrePMtkTnNyYG3g==} resolution: {integrity: sha512-QTfHZQh62SDFdYc2nfmZFuTm5yYb4eO1zwfB+90YxUumRCR171tS1GoTX5OD0wrv4UsziMPmrePMtkTnNyYG3g==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@biomejs/[email protected]': '@biomejs/[email protected]':
resolution: {integrity: sha512-wwAkWD1MR95u+J4LkWP74/vGz+tRrIQvr8kfMMJY8KOQ8+HMVleREOcPYsQX82S7uueco60L58Wc6M1I9WA9Dw==} resolution: {integrity: sha512-wwAkWD1MR95u+J4LkWP74/vGz+tRrIQvr8kfMMJY8KOQ8+HMVleREOcPYsQX82S7uueco60L58Wc6M1I9WA9Dw==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@biomejs/[email protected]': '@biomejs/[email protected]':
resolution: {integrity: sha512-o7lYc9n+CfRbHvkjPhm8s9FgbKdYZu5HCcGVMItLjz93EhgJ8AM44W+QckDqLA9MKDNFrR8nPbO4b73VC5kGGQ==} resolution: {integrity: sha512-o7lYc9n+CfRbHvkjPhm8s9FgbKdYZu5HCcGVMItLjz93EhgJ8AM44W+QckDqLA9MKDNFrR8nPbO4b73VC5kGGQ==}
@@ -337,21 +333,25 @@ packages:
resolution: {integrity: sha512-EMx92X5q+hHc3olTuj/kgkx9+yP0p/AVs4yvHbUfzZhBekXNpUWxWvg4hIKmQWn+Ee2j4o80/0ACGO0hDYJ9mg==} resolution: {integrity: sha512-EMx92X5q+hHc3olTuj/kgkx9+yP0p/AVs4yvHbUfzZhBekXNpUWxWvg4hIKmQWn+Ee2j4o80/0ACGO0hDYJ9mg==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@oxlint/[email protected]': '@oxlint/[email protected]':
resolution: {integrity: sha512-7YCxtrPIctVYLqWrWkk8pahdCxch6PtsaucfMLC7TOlDt4nODhnQd4yzEscKqJ8Gjrw1bF4g+Ngob1gB+Qr9Fw==} resolution: {integrity: sha512-7YCxtrPIctVYLqWrWkk8pahdCxch6PtsaucfMLC7TOlDt4nODhnQd4yzEscKqJ8Gjrw1bF4g+Ngob1gB+Qr9Fw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@oxlint/[email protected]': '@oxlint/[email protected]':
resolution: {integrity: sha512-lnaJVlx5r3NWmoOMesfQXJSf78jHTn8Z+sdAf795Kgteo72+qGC1Uax2SToCJVN2J8PNG3oRV5bLriiCNR2i6Q==} resolution: {integrity: sha512-lnaJVlx5r3NWmoOMesfQXJSf78jHTn8Z+sdAf795Kgteo72+qGC1Uax2SToCJVN2J8PNG3oRV5bLriiCNR2i6Q==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@oxlint/[email protected]': '@oxlint/[email protected]':
resolution: {integrity: sha512-AhuEU2Qdl66lSfTGu/Htirq8r/8q2YnZoG3yEXLMQWnPMn7efy8spD/N1NA7kH0Hll+cdfwgQkQqC2G4MS2lPQ==} resolution: {integrity: sha512-AhuEU2Qdl66lSfTGu/Htirq8r/8q2YnZoG3yEXLMQWnPMn7efy8spD/N1NA7kH0Hll+cdfwgQkQqC2G4MS2lPQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@oxlint/[email protected]': '@oxlint/[email protected]':
resolution: {integrity: sha512-GlWCBjUJY2QgvBFuNRkiRJu7K/djLmM0UQKfZV8IN+UXbP/JbjZHWKRdd4LXlQmzoz7M5Hd6p+ElCej8/90FCg==} resolution: {integrity: sha512-GlWCBjUJY2QgvBFuNRkiRJu7K/djLmM0UQKfZV8IN+UXbP/JbjZHWKRdd4LXlQmzoz7M5Hd6p+ElCej8/90FCg==}
@@ -397,56 +397,67 @@ packages:
resolution: {integrity: sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==} resolution: {integrity: sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/[email protected]': '@rollup/[email protected]':
resolution: {integrity: sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==} resolution: {integrity: sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@rollup/[email protected]': '@rollup/[email protected]':
resolution: {integrity: sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==} resolution: {integrity: sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/[email protected]': '@rollup/[email protected]':
resolution: {integrity: sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==} resolution: {integrity: sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/[email protected]': '@rollup/[email protected]':
resolution: {integrity: sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==} resolution: {integrity: sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/[email protected]': '@rollup/[email protected]':
resolution: {integrity: sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==} resolution: {integrity: sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/[email protected]': '@rollup/[email protected]':
resolution: {integrity: sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==} resolution: {integrity: sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/[email protected]': '@rollup/[email protected]':
resolution: {integrity: sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==} resolution: {integrity: sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/[email protected]': '@rollup/[email protected]':
resolution: {integrity: sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==} resolution: {integrity: sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/[email protected]': '@rollup/[email protected]':
resolution: {integrity: sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==} resolution: {integrity: sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/[email protected]': '@rollup/[email protected]':
resolution: {integrity: sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==} resolution: {integrity: sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/[email protected]': '@rollup/[email protected]':
resolution: {integrity: sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==} resolution: {integrity: sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==}
@@ -476,10 +487,6 @@ packages:
'@standard-schema/[email protected]': '@standard-schema/[email protected]':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@steipete/[email protected]':
resolution: {integrity: sha512-cF8hS6rTohAFI1NAm2/HM9omBlw6V1DpwF6q5e9FB+l274HjtyirCgCc6QK2Rtuh7uDoqWHshFQZPsceyJuVgw==}
engines: {node: '>=22'}
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -1056,8 +1063,6 @@ snapshots:
'@standard-schema/[email protected]': {} '@standard-schema/[email protected]': {}
'@steipete/[email protected](patch_hash=4ff44e9d3b5770a7b751c7a3470572bdf485763c28ead8055468dd0ae9a6e986)': {}
'@types/[email protected]': '@types/[email protected]':
dependencies: dependencies:
'@types/deep-eql': 4.0.2 '@types/deep-eql': 4.0.2
+2
View File
@@ -1,2 +1,4 @@
allowBuilds:
esbuild: true
onlyBuiltDependencies: onlyBuiltDependencies:
- esbuild - esbuild
+6
View File
@@ -10,7 +10,12 @@ import path from 'node:path';
const TARGET_OPERATIONS = [ const TARGET_OPERATIONS = [
'CreateTweet', 'CreateTweet',
'CreateRetweet', 'CreateRetweet',
'DeleteRetweet',
'CreateFriendship',
'DestroyFriendship',
'FavoriteTweet', 'FavoriteTweet',
'UnfavoriteTweet',
'CreateBookmark',
'DeleteBookmark', 'DeleteBookmark',
'TweetDetail', 'TweetDetail',
'SearchTimeline', 'SearchTimeline',
@@ -23,6 +28,7 @@ const TARGET_OPERATIONS = [
'ExplorePage', 'ExplorePage',
'GenericTimelineById', 'GenericTimelineById',
'TrendHistory', 'TrendHistory',
'AboutAccountQuery',
] as const; ] as const;
type OperationName = (typeof TARGET_OPERATIONS)[number]; type OperationName = (typeof TARGET_OPERATIONS)[number];
+14 -12
View File
@@ -1,6 +1,7 @@
import { Command } from 'commander'; import { Command } from 'commander';
import { registerBookmarksCommand } from '../commands/bookmarks.js'; import { registerBookmarksCommand } from '../commands/bookmarks.js';
import { registerCheckCommand } from '../commands/check.js'; import { registerCheckCommand } from '../commands/check.js';
import { registerFollowCommands } from '../commands/follow.js';
import { registerHelpCommand } from '../commands/help.js'; import { registerHelpCommand } from '../commands/help.js';
import { registerHomeCommand } from '../commands/home.js'; import { registerHomeCommand } from '../commands/home.js';
import { registerListsCommand } from '../commands/lists.js'; import { registerListsCommand } from '../commands/lists.js';
@@ -13,7 +14,7 @@ import { registerUnbookmarkCommand } from '../commands/unbookmark.js';
import { registerUserTweetsCommand } from '../commands/user-tweets.js'; import { registerUserTweetsCommand } from '../commands/user-tweets.js';
import { registerUserCommands } from '../commands/users.js'; import { registerUserCommands } from '../commands/users.js';
import { getCliVersion } from '../lib/version.js'; import { getCliVersion } from '../lib/version.js';
import { type CliContext, collectCookieSource } from './shared.js'; import type { CliContext } from './shared.js';
export const KNOWN_COMMANDS = new Set([ export const KNOWN_COMMANDS = new Set([
'tweet', 'tweet',
@@ -26,8 +27,12 @@ export const KNOWN_COMMANDS = new Set([
'mentions', 'mentions',
'bookmarks', 'bookmarks',
'unbookmark', 'unbookmark',
'follow',
'unfollow',
'following', 'following',
'followers', 'followers',
'user',
'profiles',
'likes', 'likes',
'lists', 'lists',
'list-timeline', 'list-timeline',
@@ -83,8 +88,8 @@ export function createProgram(ctx: CliContext): Command {
'afterAll', 'afterAll',
() => () =>
`\n${ctx.colors.section('Examples')}\n${[ `\n${ctx.colors.section('Examples')}\n${[
formatExample('bird whoami', 'Show the logged-in account via GraphQL cookies'), formatExample('bird whoami', 'Show the relay account'),
formatExample('bird --firefox-profile default-release whoami', 'Use Firefox profile cookies'), 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 tweet "hello from bird"', 'Send a tweet'),
formatExample( formatExample(
'bird 1234567890123456789 --json', 'bird 1234567890123456789 --json',
@@ -93,7 +98,7 @@ export function createProgram(ctx: CliContext): Command {
].join('\n\n')}\n\n${ctx.colors.section('Shortcuts')}\n${[ ].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>`'), 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( ].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`, ` Add ${ctx.colors.option('--json')} to: read, replies, thread, search, mentions, bookmarks, likes, following, followers, user, profiles, about, lists, list-timeline, user-tweets, query-ids`,
)}\n${ctx.colors.muted( )}\n${ctx.colors.muted(
` Add ${ctx.colors.option('--json-full')} to include raw API response in ${ctx.colors.argument('_raw')} field (tweet commands only)`, ` 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.)`)}`, )}\n${ctx.colors.muted(` (Run ${ctx.colors.command('bird <command> --help')} to see per-command flags.)`)}`,
@@ -105,19 +110,15 @@ export function createProgram(ctx: CliContext): Command {
`\n\n${ctx.colors.section('Config')}\n${ctx.colors.muted( `\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)`, ` Reads ${ctx.colors.argument('~/.config/bird/config.json5')} and ${ctx.colors.argument('./.birdrc.json5')} (JSON5)`,
)}\n${ctx.colors.muted( )}\n${ctx.colors.muted(
` Supports: chromeProfile, firefoxProfile, cookieSource, cookieTimeoutMs, timeoutMs, quoteDepth`, ` Supports: relayBaseUrl, profileName, timeoutMs, quoteDepth`,
)}\n\n${ctx.colors.section('Env')}\n${ctx.colors.muted( )}\n\n${ctx.colors.section('Env')}\n${ctx.colors.muted(
` ${ctx.colors.option('NO_COLOR')}, ${ctx.colors.option('BIRD_TIMEOUT_MS')}, ${ctx.colors.option('BIRD_COOKIE_TIMEOUT_MS')}, ${ctx.colors.option('BIRD_QUOTE_DEPTH')}`, ` ${ctx.colors.option('TWITTER_RELAY_BASE_URL')}, ${ctx.colors.option('BIRD_PROFILE_NAME')}, ${ctx.colors.option('NO_COLOR')}, ${ctx.colors.option('BIRD_TIMEOUT_MS')}, ${ctx.colors.option('BIRD_QUOTE_DEPTH')}`,
)}`, )}`,
); );
program program
.option('--auth-token <token>', 'Twitter auth_token cookie') .option('--relay-base-url <url>', 'twitter safe relay base URL', ctx.config.relayBaseUrl)
.option('--ct0 <token>', 'Twitter ct0 cookie') .option('--profile-name <name>', 'twitter safe relay profile name')
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction', ctx.config.chromeProfile)
.option('--firefox-profile <name>', 'Firefox profile name for cookie extraction', ctx.config.firefoxProfile)
.option('--cookie-timeout <ms>', 'Cookie extraction timeout in milliseconds (keychain/OS helpers)')
.option('--cookie-source <source>', 'Cookie source for browser cookie extraction (repeatable)', collectCookieSource)
.option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect) .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('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect)
.option('--timeout <ms>', 'Request timeout in milliseconds') .option('--timeout <ms>', 'Request timeout in milliseconds')
@@ -137,6 +138,7 @@ export function createProgram(ctx: CliContext): Command {
registerSearchCommands(program, ctx); registerSearchCommands(program, ctx);
registerBookmarksCommand(program, ctx); registerBookmarksCommand(program, ctx);
registerUnbookmarkCommand(program, ctx); registerUnbookmarkCommand(program, ctx);
registerFollowCommands(program, ctx);
registerListsCommand(program, ctx); registerListsCommand(program, ctx);
registerHomeCommand(program, ctx); registerHomeCommand(program, ctx);
registerUserCommands(program, ctx); registerUserCommands(program, ctx);
+18 -62
View File
@@ -4,7 +4,6 @@ import { join } from 'node:path';
import type { Command } from 'commander'; import type { Command } from 'commander';
import JSON5 from 'json5'; import JSON5 from 'json5';
import kleur from 'kleur'; import kleur from 'kleur';
import { type CookieSource, resolveCredentials } from '../lib/cookies.js';
import { extractTweetId } from '../lib/extract-tweet-id.js'; import { extractTweetId } from '../lib/extract-tweet-id.js';
import { import {
hyperlink, hyperlink,
@@ -17,10 +16,8 @@ import {
import type { TweetData } from '../lib/twitter-client.js'; import type { TweetData } from '../lib/twitter-client.js';
export type BirdConfig = { export type BirdConfig = {
chromeProfile?: string; relayBaseUrl?: string;
firefoxProfile?: string; profileName?: string;
cookieSource?: CookieSource | CookieSource[];
cookieTimeoutMs?: number;
timeoutMs?: number; timeoutMs?: number;
quoteDepth?: number; quoteDepth?: number;
}; };
@@ -48,7 +45,8 @@ export type CliContext = {
applyOutputFromCommand: (command: Command) => void; applyOutputFromCommand: (command: Command) => void;
resolveTimeoutFromOptions: (options: { timeout?: string | number }) => number | undefined; resolveTimeoutFromOptions: (options: { timeout?: string | number }) => number | undefined;
resolveQuoteDepthFromOptions: (options: { quoteDepth?: string | number }) => number | undefined; resolveQuoteDepthFromOptions: (options: { quoteDepth?: string | number }) => number | undefined;
resolveCredentialsFromOptions: (opts: CredentialsOptions) => ReturnType<typeof resolveCredentials>; resolveProfileNameFromOptions: (opts: ProfileOptions) => string | undefined;
resolveRelayBaseUrlFromOptions: (opts: RelayOptions) => string | undefined;
loadMedia: (opts: { media: string[]; alts: string[] }) => MediaSpec[]; loadMedia: (opts: { media: string[]; alts: string[] }) => MediaSpec[];
printTweets: (tweets: TweetData[], opts?: { json?: boolean; emptyMessage?: string; showSeparator?: boolean }) => void; printTweets: (tweets: TweetData[], opts?: { json?: boolean; emptyMessage?: string; showSeparator?: boolean }) => void;
printTweetsResult: ( printTweetsResult: (
@@ -65,38 +63,6 @@ export type CliContext = {
extractTweetId: (tweetIdOrUrl: string) => string; extractTweetId: (tweetIdOrUrl: string) => string;
}; };
const COOKIE_SOURCES: CookieSource[] = ['safari', 'chrome', 'firefox'];
function parseCookieSource(value: string): CookieSource {
const normalized = value.trim().toLowerCase();
if (normalized === 'safari' || normalized === 'chrome' || normalized === 'firefox') {
return normalized;
}
throw new Error(`Invalid --cookie-source "${value}". Allowed: safari, chrome, firefox.`);
}
export const collectCookieSource = (value: string, previous: CookieSource[] = []): CookieSource[] => {
previous.push(parseCookieSource(value));
return previous;
};
function resolveCookieSourceOrder(input: unknown): CookieSource[] | undefined {
if (typeof input === 'string') {
return [parseCookieSource(input)];
}
if (Array.isArray(input)) {
const result: CookieSource[] = [];
for (const entry of input) {
if (typeof entry !== 'string') {
continue;
}
result.push(parseCookieSource(entry));
}
return result.length > 0 ? result : undefined;
}
return undefined;
}
function resolveTimeoutMs(...values: Array<string | number | undefined | null>): number | undefined { function resolveTimeoutMs(...values: Array<string | number | undefined | null>): number | undefined {
for (const value of values) { for (const value of values) {
if (value === undefined || value === null || value === '') { if (value === undefined || value === null || value === '') {
@@ -170,13 +136,12 @@ function loadConfig(warn: (message: string) => void): BirdConfig {
}; };
} }
type CredentialsOptions = { type RelayOptions = {
authToken?: string; relayBaseUrl?: string;
ct0?: string; };
chromeProfile?: string;
firefoxProfile?: string; type ProfileOptions = {
cookieSource?: CookieSource[]; profileName?: string;
cookieTimeout?: string | number;
}; };
export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEnv = process.env): CliContext { export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEnv = process.env): CliContext {
@@ -268,26 +233,16 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn
return resolveTimeoutMs(options.timeout, config.timeoutMs, env.BIRD_TIMEOUT_MS); return resolveTimeoutMs(options.timeout, config.timeoutMs, env.BIRD_TIMEOUT_MS);
} }
function resolveCookieTimeoutFromOptions(options: { cookieTimeout?: string | number }): number | undefined {
return resolveTimeoutMs(options.cookieTimeout, config.cookieTimeoutMs, env.BIRD_COOKIE_TIMEOUT_MS);
}
function resolveQuoteDepthFromOptions(options: { quoteDepth?: string | number }): number | undefined { function resolveQuoteDepthFromOptions(options: { quoteDepth?: string | number }): number | undefined {
return resolveQuoteDepth(options.quoteDepth, config.quoteDepth, env.BIRD_QUOTE_DEPTH); return resolveQuoteDepth(options.quoteDepth, config.quoteDepth, env.BIRD_QUOTE_DEPTH);
} }
function resolveCredentialsFromOptions(opts: CredentialsOptions): ReturnType<typeof resolveCredentials> { function resolveRelayBaseUrlFromOptions(opts: RelayOptions): string | undefined {
const cookieSource = opts.cookieSource?.length return opts.relayBaseUrl || config.relayBaseUrl || env.TWITTER_RELAY_BASE_URL;
? opts.cookieSource }
: (resolveCookieSourceOrder(config.cookieSource) ?? COOKIE_SOURCES);
return resolveCredentials({ function resolveProfileNameFromOptions(opts: ProfileOptions): string | undefined {
authToken: opts.authToken, return opts.profileName || env.BIRD_PROFILE_NAME || config.profileName;
ct0: opts.ct0,
cookieSource,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
cookieTimeoutMs: resolveCookieTimeoutFromOptions(opts),
});
} }
function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] { function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] {
@@ -422,7 +377,8 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn
applyOutputFromCommand, applyOutputFromCommand,
resolveTimeoutFromOptions, resolveTimeoutFromOptions,
resolveQuoteDepthFromOptions, resolveQuoteDepthFromOptions,
resolveCredentialsFromOptions, resolveProfileNameFromOptions,
resolveRelayBaseUrlFromOptions,
loadMedia, loadMedia,
printTweets, printTweets,
printTweetsResult, printTweetsResult,
+168 -24
View File
@@ -1,8 +1,10 @@
import type { Command } from 'commander'; import type { Command } from 'commander';
import { parsePositiveIntFlag } from '../cli/pagination.js'; import { parsePaginationFlags } from '../cli/pagination.js';
import type { CliContext } from '../cli/shared.js'; import type { CliContext } from '../cli/shared.js';
import { extractBookmarkFolderId } from '../lib/extract-bookmark-folder-id.js'; import { extractBookmarkFolderId } from '../lib/extract-bookmark-folder-id.js';
import { addThreadMetadata, filterAuthorChain, filterAuthorOnly, filterFullChain } from '../lib/thread-filters.js';
import { TwitterClient } from '../lib/twitter-client.js'; import { TwitterClient } from '../lib/twitter-client.js';
import type { TweetData, TweetWithMeta } from '../lib/twitter-client-types.js';
export function registerBookmarksCommand(program: Command, ctx: CliContext): void { export function registerBookmarksCommand(program: Command, ctx: CliContext): void {
program program
@@ -13,6 +15,14 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
.option('--all', 'Fetch all bookmarks (paged)') .option('--all', 'Fetch all bookmarks (paged)')
.option('--max-pages <number>', 'Stop after N pages when using --all') .option('--max-pages <number>', 'Stop after N pages when using --all')
.option('--cursor <string>', 'Resume pagination from a cursor') .option('--cursor <string>', 'Resume pagination from a cursor')
.option('--expand-root-only', 'Only expand threads when bookmarked tweet is root')
.option('--author-chain', 'Only include author self-reply chains connected to the bookmark')
.option('--author-only', 'Include all tweets from bookmarked tweet author in thread')
.option('--full-chain-only', 'Save entire reply chain connected to the bookmarked tweet')
.option('--include-ancestor-branches', 'Include sibling branches for ancestors when using --full-chain-only')
.option('--include-parent', 'Include direct parent tweet for non-root bookmarks')
.option('--thread-meta', 'Add metadata fields (isThread, threadPosition, etc.)')
.option('--sort-chronological', 'Sort output globally oldest -> newest')
.option('--json', 'Output as JSON') .option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field') .option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action( .action(
@@ -24,29 +34,29 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
all?: boolean; all?: boolean;
maxPages?: string; maxPages?: string;
cursor?: string; cursor?: string;
expandRootOnly?: boolean;
authorChain?: boolean;
authorOnly?: boolean;
fullChainOnly?: boolean;
includeAncestorBranches?: boolean;
includeParent?: boolean;
threadMeta?: boolean;
sortChronological?: boolean;
}) => { }) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts); const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10); const count = Number.parseInt(cmdOpts.count || '20', 10);
const maxPagesParsed = parsePositiveIntFlag(cmdOpts.maxPages, '--max-pages');
if (!maxPagesParsed.ok) { const pagination = parsePaginationFlags(cmdOpts);
console.error(`${ctx.p('err')}${maxPagesParsed.error}`); if (!pagination.ok) {
console.error(`${ctx.p('err')}${pagination.error}`);
process.exit(1); process.exit(1);
} }
const maxPages = maxPagesParsed.value; const maxPages = pagination.maxPages;
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); const usePagination = pagination.usePagination;
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const usePagination = Boolean(cmdOpts.all || cmdOpts.cursor);
if (maxPages !== undefined && !usePagination) { if (maxPages !== undefined && !usePagination) {
console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`); console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`);
process.exit(1); process.exit(1);
@@ -56,7 +66,7 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
process.exit(1); process.exit(1);
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs });
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null; const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
if (cmdOpts.folderId && !folderId) { if (cmdOpts.folderId && !folderId) {
console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`); console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
@@ -64,7 +74,7 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
} }
const includeRaw = cmdOpts.jsonFull ?? false; const includeRaw = cmdOpts.jsonFull ?? false;
const timelineOptions = { includeRaw }; const timelineOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages, cursor: cmdOpts.cursor }; const paginationOptions = { includeRaw, maxPages, cursor: pagination.cursor };
const result = folderId const result = folderId
? usePagination ? usePagination
? await client.getAllBookmarkFolderTimeline(folderId, paginationOptions) ? await client.getAllBookmarkFolderTimeline(folderId, paginationOptions)
@@ -73,14 +83,148 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
? await client.getAllBookmarks(paginationOptions) ? await client.getAllBookmarks(paginationOptions)
: await client.getBookmarks(count, timelineOptions); : await client.getBookmarks(count, timelineOptions);
if (result.success) { 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}`); console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
process.exit(1); process.exit(1);
} }
if (cmdOpts.authorChain && (cmdOpts.authorOnly || cmdOpts.fullChainOnly)) {
console.error(
`${ctx.p('warn')}--author-chain already limits to the connected self-reply chain; ` +
'other chain filters are redundant.',
);
}
if (cmdOpts.includeAncestorBranches && !cmdOpts.fullChainOnly) {
console.error(`${ctx.p('warn')}--include-ancestor-branches only applies with --full-chain-only.`);
}
const bookmarks = result.tweets;
if (!bookmarks || bookmarks.length === 0) {
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 });
return;
}
const expandedResults: TweetData[] = [];
const threadCache = new Map<string, TweetData[]>();
const includeMeta = Boolean(cmdOpts.threadMeta);
const includeParent = Boolean(cmdOpts.includeParent);
const expandRootOnly = Boolean(cmdOpts.expandRootOnly);
const filterAuthorChainFlag = Boolean(cmdOpts.authorChain);
const filterAuthorOnlyFlag = Boolean(cmdOpts.authorOnly);
const filterFullChainFlag = Boolean(cmdOpts.fullChainOnly);
const includeAncestorBranches = Boolean(cmdOpts.includeAncestorBranches) && filterFullChainFlag;
const useChronologicalSort = Boolean(cmdOpts.sortChronological);
const shouldAttemptExpand =
expandRootOnly || filterAuthorChainFlag || filterAuthorOnlyFlag || filterFullChainFlag;
const shouldFetchThread = shouldAttemptExpand || includeMeta;
const fetchThread = async (tweet: TweetData): Promise<TweetData[] | null> => {
const cachedKey = tweet.conversationId ?? tweet.id;
const cached = threadCache.get(cachedKey);
if (cached) {
return cached;
}
const threadResult = await client.getThread(tweet.id, { includeRaw });
if (!threadResult.success) {
console.error(
`${ctx.p('warn')}Failed to expand thread for ${tweet.id}: ${threadResult.error ?? 'Unknown error'}`,
);
return null;
}
if (!threadResult.tweets) {
console.error(`${ctx.p('warn')}No thread tweets returned for ${tweet.id}.`);
return null;
}
const rootKey = threadResult.tweets[0]?.conversationId ?? cachedKey;
threadCache.set(rootKey, threadResult.tweets);
return threadResult.tweets;
};
const delayBetweenExpansionsMs = 1000;
for (let index = 0; index < bookmarks.length; index += 1) {
const bookmark = bookmarks[index];
const isRoot = !bookmark.inReplyToStatusId;
let threadTweets: TweetData[] | null = null;
if (shouldFetchThread) {
if (!expandRootOnly || isRoot || includeMeta) {
if (index > 0) {
await new Promise((resolve) => setTimeout(resolve, delayBetweenExpansionsMs));
}
threadTweets = await fetchThread(bookmark);
}
}
let outputTweets: TweetData[] = [bookmark];
if (shouldAttemptExpand) {
if (expandRootOnly && !isRoot) {
outputTweets = [bookmark];
} else if (threadTweets) {
if (filterAuthorChainFlag) {
outputTweets = filterAuthorChain(threadTweets, bookmark);
} else {
outputTweets = filterFullChainFlag
? filterFullChain(threadTweets, bookmark, { includeAncestorBranches })
: threadTweets;
if (filterAuthorOnlyFlag) {
outputTweets = filterAuthorOnly(outputTweets, bookmark);
}
}
}
}
if (includeParent && bookmark.inReplyToStatusId) {
const alreadyIncluded = outputTweets.some((tweet) => tweet.id === bookmark.inReplyToStatusId);
if (!alreadyIncluded) {
const parentFromThread = threadTweets?.find((tweet) => tweet.id === bookmark.inReplyToStatusId);
if (parentFromThread) {
expandedResults.push(parentFromThread);
} else {
const parentResult = await client.getTweet(bookmark.inReplyToStatusId, { includeRaw });
if (parentResult.success && parentResult.tweet) {
expandedResults.push(parentResult.tweet);
}
}
}
}
expandedResults.push(...outputTweets);
}
let finalResults: Array<TweetData | TweetWithMeta> = expandedResults;
if (includeMeta) {
finalResults = expandedResults.map((tweet) => {
const cacheKey = tweet.conversationId ?? tweet.id;
let conversationTweets = threadCache.get(cacheKey);
if (!conversationTweets) {
conversationTweets = [tweet];
}
return addThreadMetadata(tweet, conversationTweets);
});
}
const uniqueTweets = Array.from(new Map(finalResults.map((tweet) => [tweet.id, tweet])).values());
if (useChronologicalSort) {
uniqueTweets.sort((a, b) => {
const aTime = a.createdAt ? Date.parse(a.createdAt) : 0;
const bTime = b.createdAt ? Date.parse(b.createdAt) : 0;
return aTime - bTime;
});
}
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweetsResult(
{ tweets: uniqueTweets, nextCursor: result.nextCursor },
{ json: isJson, usePagination, emptyMessage },
);
}, },
); );
} }
+22 -33
View File
@@ -1,47 +1,36 @@
import type { Command } from 'commander'; import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js'; import type { CliContext } from '../cli/shared.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerCheckCommand(program: Command, ctx: CliContext): void { export function registerCheckCommand(program: Command, ctx: CliContext): void {
program program
.command('check') .command('check')
.description('Check credential availability') .description('Check twitter safe relay availability')
.action(async () => { .action(async () => {
const opts = program.opts(); const opts = program.opts();
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
console.log(`${ctx.p('info')}Credential check`); console.log(`${ctx.p('info')}Relay check`);
console.log('─'.repeat(40)); console.log('-'.repeat(40));
if (cookies.authToken) { if (!relayBaseUrl) {
console.log(`${ctx.p('ok')}auth_token: ${cookies.authToken.slice(0, 10)}...`); 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, profileName, 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 { } else {
console.log(`${ctx.p('err')}auth_token: not found`); console.log(`${ctx.p('err')}Relay check failed: ${result.error ?? 'Unknown error'}`);
}
if (cookies.ct0) {
console.log(`${ctx.p('ok')}ct0: ${cookies.ct0.slice(0, 10)}...`);
} else {
console.log(`${ctx.p('err')}ct0: not found`);
}
if (cookies.source) {
console.log(`${ctx.l('source')}${cookies.source}`);
}
if (warnings.length > 0) {
console.log(`\n${ctx.p('warn')}Warnings:`);
for (const warning of warnings) {
console.log(` - ${warning}`);
}
}
if (cookies.authToken && cookies.ct0) {
console.log(`\n${ctx.p('ok')}Ready to tweet!`);
} else {
console.log(`\n${ctx.p('err')}Missing credentials. Options:`);
console.log(' 1. Login to x.com in Safari/Chrome/Firefox');
console.log(' 2. Set AUTH_TOKEN and CT0 environment variables');
console.log(' 3. Use --auth-token and --ct0 flags');
process.exit(1); process.exit(1);
} }
}); });
+95
View File
@@ -0,0 +1,95 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { normalizeHandle } from '../lib/normalize-handle.js';
import { TwitterClient } from '../lib/twitter-client.js';
const ONLY_DIGITS_REGEX = /^\d+$/;
async function resolveUserId(
client: TwitterClient,
usernameOrId: string,
ctx: CliContext,
): Promise<{ userId: string; username?: string } | null> {
const raw = usernameOrId.trim();
const isNumeric = ONLY_DIGITS_REGEX.test(raw);
// Otherwise, treat as username and look up
const handle = normalizeHandle(raw);
if (handle) {
const lookup = await client.getUserIdByUsername(handle);
if (lookup.success && lookup.userId) {
return { userId: lookup.userId, username: lookup.username };
}
if (!isNumeric) {
console.error(`${ctx.p('err')}Failed to find user @${handle}: ${lookup.error ?? 'Unknown error'}`);
return null;
}
}
if (isNumeric) {
return { userId: raw };
}
console.error(`${ctx.p('err')}Invalid username: ${usernameOrId}`);
return null;
}
export function registerFollowCommands(program: Command, ctx: CliContext): void {
program
.command('follow')
.description('Follow a user')
.argument('<username-or-id>', 'Username (with or without @) or user ID to follow')
.action(async (usernameOrId: string) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs });
const resolved = await resolveUserId(client, usernameOrId, ctx);
if (!resolved) {
process.exit(1);
}
const { userId, username } = resolved;
const displayName = username ? `@${username}` : userId;
const result = await client.follow(userId);
if (result.success) {
const finalName = result.username ? `@${result.username}` : displayName;
console.log(`${ctx.p('ok')}Now following ${finalName}`);
} else {
console.error(`${ctx.p('err')}Failed to follow ${displayName}: ${result.error}`);
process.exit(1);
}
});
program
.command('unfollow')
.description('Unfollow a user')
.argument('<username-or-id>', 'Username (with or without @) or user ID to unfollow')
.action(async (usernameOrId: string) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs });
const resolved = await resolveUserId(client, usernameOrId, ctx);
if (!resolved) {
process.exit(1);
}
const { userId, username } = resolved;
const displayName = username ? `@${username}` : userId;
const result = await client.unfollow(userId);
if (result.success) {
const finalName = result.username ? `@${result.username}` : displayName;
console.log(`${ctx.p('ok')}Unfollowed ${finalName}`);
} else {
console.error(`${ctx.p('err')}Failed to unfollow ${displayName}: ${result.error}`);
process.exit(1);
}
});
}
+63 -34
View File
@@ -1,4 +1,5 @@
import type { Command } from 'commander'; import type { Command } from 'commander';
import { parsePaginationFlags } from '../cli/pagination.js';
import type { CliContext } from '../cli/shared.js'; import type { CliContext } from '../cli/shared.js';
import { TwitterClient } from '../lib/twitter-client.js'; import { TwitterClient } from '../lib/twitter-client.js';
@@ -7,45 +8,73 @@ export function registerHomeCommand(program: Command, ctx: CliContext): void {
.command('home') .command('home')
.description('Get your home timeline ("For You" feed)') .description('Get your home timeline ("For You" feed)')
.option('-n, --count <number>', 'Number of tweets to fetch', '20') .option('-n, --count <number>', 'Number of tweets to fetch', '20')
.option('--all', 'Fetch all available pages up to --count')
.option('--max-pages <number>', 'Stop after N pages')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--delay <ms>', 'Delay in ms between page fetches', '1000')
.option('--following', 'Get "Following" feed (chronological) instead of "For You"') .option('--following', 'Get "Following" feed (chronological) instead of "For You"')
.option('--json', 'Output as JSON') .option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field') .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 }) => { .action(
const opts = program.opts(); async (cmdOpts: {
const timeoutMs = ctx.resolveTimeoutFromOptions(opts); count?: string;
const count = Number.parseInt(cmdOpts.count || '20', 10); all?: boolean;
maxPages?: string;
cursor?: string;
delay?: 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 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 profileName = ctx.resolveProfileNameFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); if (!Number.isFinite(count) || count <= 0) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
for (const warning of warnings) { const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs });
console.error(`${ctx.p('warn')}${warning}`); const includeRaw = cmdOpts.jsonFull ?? false;
} const usePagination = pagination.usePagination;
const fetchOptions = {
includeRaw,
maxPages: pagination.maxPages,
cursor: pagination.cursor,
pageDelayMs: pagination.pageDelayMs,
};
if (!cookies.authToken || !cookies.ct0) { const result = cmdOpts.following
console.error(`${ctx.p('err')}Missing required credentials`); ? await client.getHomeLatestTimeline(count, fetchOptions)
process.exit(1); : await client.getHomeTimeline(count, fetchOptions);
}
if (!Number.isFinite(count) || count <= 0) { if (result.success) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`); const feedType = cmdOpts.following ? 'Following' : 'For You';
process.exit(1); const emptyMessage = `No tweets found in ${feedType} timeline.`;
} const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweetsResult(result, {
const client = new TwitterClient({ cookies, timeoutMs }); json: isJson,
const includeRaw = cmdOpts.jsonFull ?? false; usePagination,
emptyMessage,
const result = cmdOpts.following });
? await client.getHomeLatestTimeline(count, { includeRaw }) if (result.nextCursor && !cmdOpts.json && !cmdOpts.jsonFull) {
: await client.getHomeTimeline(count, { includeRaw }); console.error(`${ctx.p('info')}More tweets available. Use --cursor "${result.nextCursor}" to continue.`);
}
if (result.success) { } else {
const feedType = cmdOpts.following ? 'Following' : 'For You'; console.error(`${ctx.p('err')}Failed to fetch home timeline: ${result.error}`);
const emptyMessage = `No tweets found in ${feedType} timeline.`; process.exit(1);
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);
}
});
} }
+6 -24
View File
@@ -42,19 +42,10 @@ export function registerListsCommand(program: Command, ctx: CliContext): void {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts); const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '100', 10); const count = Number.parseInt(cmdOpts.count || '100', 10);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs });
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const result = cmdOpts.memberOf ? await client.getListMemberships(count) : await client.getOwnedLists(count); const result = cmdOpts.memberOf ? await client.getListMemberships(count) : await client.getOwnedLists(count);
@@ -118,19 +109,10 @@ export function registerListsCommand(program: Command, ctx: CliContext): void {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`); console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1); process.exit(1);
} }
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false; const includeRaw = cmdOpts.jsonFull ?? false;
const timelineOptions = { includeRaw }; const timelineOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages: pagination.maxPages, cursor: pagination.cursor }; const paginationOptions = { includeRaw, maxPages: pagination.maxPages, cursor: pagination.cursor };
+3 -12
View File
@@ -101,12 +101,8 @@ export function registerNewsCommand(program: Command, ctx: CliContext): void {
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts); const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10); const count = Number.parseInt(cmdOpts.count || '10', 10);
const tweetsPerItem = Number.parseInt(cmdOpts.tweetsPerItem || '5', 10); const tweetsPerItem = Number.parseInt(cmdOpts.tweetsPerItem || '5', 10);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); const profileName = ctx.resolveProfileNameFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (Number.isNaN(count) || count < 1) { if (Number.isNaN(count) || count < 1) {
console.error(`${ctx.p('err')}--count must be a positive number`); console.error(`${ctx.p('err')}--count must be a positive number`);
@@ -118,11 +114,6 @@ export function registerNewsCommand(program: Command, ctx: CliContext): void {
process.exit(1); process.exit(1);
} }
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
// Determine which tabs to fetch from // Determine which tabs to fetch from
const tabs: ExploreTab[] = []; const tabs: ExploreTab[] = [];
if (cmdOpts.forYou) { if (cmdOpts.forYou) {
@@ -144,7 +135,7 @@ export function registerNewsCommand(program: Command, ctx: CliContext): void {
// If no specific tabs selected, use defaults (all tabs except trending) // If no specific tabs selected, use defaults (all tabs except trending)
const tabsToFetch = tabs.length > 0 ? tabs : undefined; const tabsToFetch = tabs.length > 0 ? tabs : undefined;
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false; const includeRaw = cmdOpts.jsonFull ?? false;
const withTweets = cmdOpts.withTweets ?? false; const withTweets = cmdOpts.withTweets ?? false;
const aiOnly = cmdOpts.aiOnly ?? false; const aiOnly = cmdOpts.aiOnly ?? false;
+6 -34
View File
@@ -40,23 +40,9 @@ export function registerPostCommands(program: Command, ctx: CliContext): void {
console.error(`${ctx.p('err')}${error instanceof Error ? error.message : String(error)}`); console.error(`${ctx.p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1); process.exit(1);
} }
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); const profileName = ctx.resolveProfileNameFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
if (cookies.source) {
console.error(`${ctx.l('source')}${cookies.source}`);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const mediaIds = await uploadMediaOrExit(client, media, ctx); const mediaIds = await uploadMediaOrExit(client, media, ctx);
const result = await client.tweet(text, mediaIds); const result = await client.tweet(text, mediaIds);
@@ -86,25 +72,11 @@ export function registerPostCommands(program: Command, ctx: CliContext): void {
process.exit(1); process.exit(1);
} }
const tweetId = ctx.extractTweetId(tweetIdOrUrl); const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); const profileName = ctx.resolveProfileNameFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
if (cookies.source) {
console.error(`${ctx.l('source')}${cookies.source}`);
}
console.error(`${ctx.p('info')}Replying to tweet: ${tweetId}`); console.error(`${ctx.p('info')}Replying to tweet: ${tweetId}`);
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
const mediaIds = await uploadMediaOrExit(client, media, ctx); const mediaIds = await uploadMediaOrExit(client, media, ctx);
const result = await client.reply(text, tweetId, mediaIds); const result = await client.reply(text, tweetId, mediaIds);
+9 -36
View File
@@ -17,19 +17,10 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts); const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const tweetId = ctx.extractTweetId(tweetIdOrUrl); const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false; const includeRaw = cmdOpts.jsonFull ?? false;
const result = await client.getTweet(tweetId, { includeRaw }); const result = await client.getTweet(tweetId, { includeRaw });
@@ -78,19 +69,10 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
console.error(`${ctx.p('err')}${pagination.error}`); console.error(`${ctx.p('err')}${pagination.error}`);
process.exit(1); process.exit(1);
} }
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false; const includeRaw = cmdOpts.jsonFull ?? false;
const result = pagination.usePagination const result = pagination.usePagination
@@ -155,19 +137,10 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
console.error(`${ctx.p('err')}${pagination.error}`); console.error(`${ctx.p('err')}${pagination.error}`);
process.exit(1); process.exit(1);
} }
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false; const includeRaw = cmdOpts.jsonFull ?? false;
const result = pagination.usePagination const result = pagination.usePagination
+23 -30
View File
@@ -1,7 +1,9 @@
import type { Command } from 'commander'; import { type Command, Option } from 'commander';
import { parsePaginationFlags } from '../cli/pagination.js';
import type { CliContext } from '../cli/shared.js'; import type { CliContext } from '../cli/shared.js';
import { mentionsQueryFromUserOption, normalizeHandle } from '../lib/normalize-handle.js'; import { mentionsQueryFromUserOption, normalizeHandle } from '../lib/normalize-handle.js';
import { TwitterClient } from '../lib/twitter-client.js'; import { TwitterClient } from '../lib/twitter-client.js';
import type { SearchProduct } from '../lib/twitter-client-search.js';
export function registerSearchCommands(program: Command, ctx: CliContext): void { export function registerSearchCommands(program: Command, ctx: CliContext): void {
program program
@@ -12,6 +14,11 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
.option('--all', 'Fetch all search results (paged)') .option('--all', 'Fetch all search results (paged)')
.option('--max-pages <number>', 'Stop after N pages when using --all') .option('--max-pages <number>', 'Stop after N pages when using --all')
.option('--cursor <string>', 'Resume pagination from a cursor') .option('--cursor <string>', 'Resume pagination from a cursor')
.addOption(
new Option('--product <product>', 'Search product')
.choices(['Top', 'Latest'] satisfies SearchProduct[])
.default('Latest'),
)
.option('--json', 'Output as JSON') .option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field') .option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action( .action(
@@ -22,6 +29,7 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
all?: boolean; all?: boolean;
maxPages?: string; maxPages?: string;
cursor?: string; cursor?: string;
product?: SearchProduct;
json?: boolean; json?: boolean;
jsonFull?: boolean; jsonFull?: boolean;
}, },
@@ -30,20 +38,17 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
const timeoutMs = ctx.resolveTimeoutFromOptions(opts); const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts); const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10); const count = Number.parseInt(cmdOpts.count || '10', 10);
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); const pagination = parsePaginationFlags(cmdOpts);
if (!pagination.ok) {
for (const warning of warnings) { console.error(`${ctx.p('err')}${pagination.error}`);
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1); process.exit(1);
} }
const maxPages = pagination.maxPages;
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const usePagination = cmdOpts.all || cmdOpts.cursor; const usePagination = pagination.usePagination;
if (maxPages !== undefined && !usePagination) { if (maxPages !== undefined && !usePagination) {
console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`); console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`);
process.exit(1); process.exit(1);
@@ -52,15 +57,12 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`); console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1); 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({ cookies, timeoutMs, quoteDepth }); const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false; const includeRaw = cmdOpts.jsonFull ?? false;
const searchOptions = { includeRaw }; const product = cmdOpts.product ?? 'Latest';
const paginationOptions = { includeRaw, maxPages, cursor: cmdOpts.cursor }; const searchOptions = { includeRaw, product };
const paginationOptions = { includeRaw, maxPages, cursor: pagination.cursor, product };
const result = usePagination const result = usePagination
? await client.getAllSearchResults(query, paginationOptions) ? await client.getAllSearchResults(query, paginationOptions)
: await client.search(query, count, searchOptions); : await client.search(query, count, searchOptions);
@@ -99,19 +101,10 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
} }
let query: string | null = fromUserOpt.query; let query: string | null = fromUserOpt.query;
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
if (!query) { if (!query) {
const who = await client.getCurrentUser(); const who = await client.getCurrentUser();
+3 -12
View File
@@ -10,19 +10,10 @@ export function registerUnbookmarkCommand(program: Command, ctx: CliContext): vo
.action(async (tweetIdOrUrls: string[]) => { .action(async (tweetIdOrUrls: string[]) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts); const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs });
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
let failures = 0; let failures = 0;
for (const input of tweetIdOrUrls) { for (const input of tweetIdOrUrls) {
+3 -12
View File
@@ -86,19 +86,10 @@ export function registerUserTweetsCommand(program: Command, ctx: CliContext): vo
console.error(`${ctx.p('err')}Invalid handle: ${handle}`); console.error(`${ctx.p('err')}Invalid handle: ${handle}`);
process.exit(2); process.exit(2);
} }
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
// Look up user ID from username // Look up user ID from username
console.error(`${ctx.p('info')}Looking up @${username}...`); console.error(`${ctx.p('info')}Looking up @${username}...`);
+330 -345
View File
@@ -1,328 +1,303 @@
import type { Command } from 'commander'; import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js'; import type { CliContext } from '../cli/shared.js';
import { normalizeHandle } from '../lib/normalize-handle.js';
import { TwitterClient } from '../lib/twitter-client.js'; import { TwitterClient } from '../lib/twitter-client.js';
import type { TwitterUser } from '../lib/twitter-client-types.js'; import type { AboutAccountProfile, TwitterUser, TwitterUserProfile } from '../lib/twitter-client-types.js';
function formatAboutProfile(profile: AboutAccountProfile, ctx: CliContext, handle: string): string[] {
const lines: string[] = [`${ctx.p('info')}Account information for @${handle}:`];
if (profile.accountBasedIn) {
lines.push(` Account based in: ${profile.accountBasedIn}`);
}
if (profile.createdCountryAccurate !== undefined) {
lines.push(` Creation country accurate: ${profile.createdCountryAccurate ? 'Yes' : 'No'}`);
}
if (profile.locationAccurate !== undefined) {
lines.push(` Location accurate: ${profile.locationAccurate ? 'Yes' : 'No'}`);
}
if (profile.source) {
lines.push(`${ctx.l('source')}${profile.source}`);
}
if (profile.learnMoreUrl) {
lines.push(` Learn more: ${profile.learnMoreUrl}`);
}
return lines;
}
type PagedUsersResult = {
success: boolean;
users?: TwitterUser[];
nextCursor?: string;
error?: string;
};
type UserListCommandOpts = {
user?: string;
count?: string;
cursor?: string;
all?: boolean;
maxPages?: string;
json?: boolean;
};
type UserListCommandSpec = {
name: 'following' | 'followers';
description: string;
fetch: (
client: TwitterClient,
userId: string,
count: number,
cursor: string | undefined,
) => Promise<PagedUsersResult>;
};
function printUsers(users: TwitterUser[], ctx: CliContext): void {
for (const user of 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('──────────────────────────────────────────────────');
}
}
function printProfile(profile: TwitterUserProfile, ctx: CliContext): void {
console.log(`@${profile.username} (${profile.name})`);
console.log(`${ctx.l('userId')}${profile.id}`);
if (profile.description) {
console.log(` ${profile.description}`);
}
if (profile.location) {
console.log(` Location: ${profile.location}`);
}
if (profile.url) {
console.log(` URL: ${profile.url}`);
}
if (profile.followersCount !== undefined) {
console.log(` ${ctx.p('info')}${profile.followersCount.toLocaleString()} followers`);
}
if (profile.followingCount !== undefined) {
console.log(` ${ctx.p('info')}${profile.followingCount.toLocaleString()} following`);
}
if (profile.verifiedType || profile.verified) {
console.log(` Verified: ${profile.verifiedType ?? 'yes'}`);
}
if (profile.affiliation?.description) {
console.log(` Affiliation: ${profile.affiliation.description}`);
}
}
async function resolveUserIdOrExit(
client: TwitterClient,
requestedUserId: string | undefined,
ctx: CliContext,
): Promise<string> {
if (requestedUserId) {
return requestedUserId;
}
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);
}
return currentUser.user.id;
}
async function runUserListCommand(
program: Command,
ctx: CliContext,
spec: UserListCommandSpec,
cmdOpts: UserListCommandOpts,
) {
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 relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
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 client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs });
const userId = await resolveUserIdOrExit(client, cmdOpts.user, ctx);
if (cmdOpts.all) {
const allUsers: TwitterUser[] = [];
const seen = new Set<string>();
let cursor: string | undefined = cmdOpts.cursor;
let pageNum = 0;
let nextCursor: string | undefined;
while (true) {
pageNum += 1;
if (!cmdOpts.json) {
console.error(`${ctx.p('info')}Fetching page ${pageNum}...`);
}
const result = await spec.fetch(client, userId, count, cursor);
if (!result.success || !result.users) {
console.error(`${ctx.p('err')}Failed to fetch ${spec.name}: ${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;
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}`);
}
printUsers(allUsers, ctx);
}
return;
}
const result = await spec.fetch(client, 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 {
printUsers(result.users, ctx);
if (result.nextCursor) {
console.error(`${ctx.p('info')}Next cursor: ${result.nextCursor}`);
}
}
}
} else {
console.error(`${ctx.p('err')}Failed to fetch ${spec.name}: ${result.error}`);
process.exit(1);
}
}
export function registerUserCommands(program: Command, ctx: CliContext): void { export function registerUserCommands(program: Command, ctx: CliContext): void {
program const registerUserListCommand = (spec: UserListCommandSpec): void => {
.command('following') program
.description('Get users that you (or another user) follow') .command(spec.name)
.option('--user <userId>', 'User ID to get following for (defaults to current user)') .description(spec.description)
.option('-n, --count <number>', 'Number of users to fetch per page', '20') .option('--user <userId>', `User ID to get ${spec.name} for (defaults to current user)`)
.option('--cursor <cursor>', 'Cursor for pagination (from previous response)') .option('-n, --count <number>', 'Number of users to fetch per page', '20')
.option('--all', 'Fetch all users (paginate automatically)') .option('--cursor <cursor>', 'Cursor for pagination (from previous response)')
.option('--max-pages <number>', 'Stop after N pages when using --all') .option('--all', 'Fetch all users (paginate automatically)')
.option('--json', 'Output as JSON') .option('--max-pages <number>', 'Stop after N pages when using --all')
.action( .option('--json', 'Output as JSON')
async (cmdOpts: { .action(async (cmdOpts: UserListCommandOpts) => runUserListCommand(program, ctx, spec, 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; registerUserListCommand({
if (maxPages !== undefined && !cmdOpts.all) { name: 'following',
console.error(`${ctx.p('err')}--max-pages requires --all.`); description: 'Get users that you (or another user) follow',
process.exit(1); fetch: (client, userId, count, cursor) => client.getFollowing(userId, count, cursor),
} });
if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) {
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`);
process.exit(1);
}
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); registerUserListCommand({
name: 'followers',
for (const warning of warnings) { description: 'Get users that follow you (or another user)',
console.error(`${ctx.p('warn')}${warning}`); fetch: (client, userId, count, cursor) => client.getFollowers(userId, count, cursor),
} });
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, 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 program
.command('followers') .command('user')
.description('Get users that follow you (or another user)') .description('Resolve a rich user profile by handle or user ID')
.option('--user <userId>', 'User ID to get followers for (defaults to current user)') .argument('<target>', 'Username (with or without @) or numeric user ID')
.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') .option('--json', 'Output as JSON')
.action( .action(async (target: string, cmdOpts: { json?: boolean }) => {
async (cmdOpts: { const opts = program.opts();
user?: string; const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
count?: string; const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
cursor?: string; const profileName = ctx.resolveProfileNameFromOptions(opts);
all?: boolean; const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs });
maxPages?: string; const result = await client.getUserProfile(target);
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 (!result.success) {
if (maxPages !== undefined && !cmdOpts.all) { console.error(`${ctx.p('err')}Failed to resolve user: ${result.error}`);
console.error(`${ctx.p('err')}--max-pages requires --all.`); process.exit(1);
process.exit(1); }
} if (cmdOpts.json) {
if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) { console.log(JSON.stringify(result.profile, null, 2));
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`); return;
process.exit(1); }
} printProfile(result.profile, ctx);
});
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); program
.command('profiles')
.description('Resolve rich user profiles by handle or user ID')
.argument('<targets...>', 'Usernames or numeric user IDs')
.option('--json', 'Output as JSON')
.action(async (targets: string[], cmdOpts: { json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs });
const result = await client.getUserProfiles(targets);
const users = result.results.flatMap((item) => ('profile' in item ? [item.profile] : []));
const errors = result.results.flatMap((item) =>
'error' in item ? [{ target: item.target, error: item.error }] : [],
);
for (const warning of warnings) { if (cmdOpts.json) {
console.error(`${ctx.p('warn')}${warning}`); console.log(JSON.stringify({ users, errors }, null, 2));
} return;
}
if (!cookies.authToken || !cookies.ct0) { for (const profile of users) {
console.error(`${ctx.p('err')}Missing required credentials`); printProfile(profile, ctx);
process.exit(1); console.log('──────────────────────────────────────────────────');
} }
for (const error of errors) {
const client = new TwitterClient({ cookies, timeoutMs }); console.error(`${ctx.p('err')}${error.target}: ${error.error}`);
}
let userId = cmdOpts.user; if (users.length === 0 && errors.length > 0) {
if (!userId) { process.exit(1);
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 program
.command('likes') .command('likes')
@@ -347,17 +322,8 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts); const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10); const count = Number.parseInt(cmdOpts.count || '20', 10);
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined; const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); const profileName = ctx.resolveProfileNameFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const usePagination = cmdOpts.all || cmdOpts.cursor; const usePagination = cmdOpts.all || cmdOpts.cursor;
if (maxPages !== undefined && !usePagination) { if (maxPages !== undefined && !usePagination) {
@@ -373,7 +339,7 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
process.exit(1); process.exit(1);
} }
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false; const includeRaw = cmdOpts.jsonFull ?? false;
const timelineOptions = { includeRaw }; const timelineOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages, cursor: cmdOpts.cursor }; const paginationOptions = { includeRaw, maxPages, cursor: cmdOpts.cursor };
@@ -397,40 +363,59 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
program program
.command('whoami') .command('whoami')
.description('Show which Twitter account the current credentials belong to') .description('Show which Twitter account the relay belongs to')
.action(async () => { .action(async () => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts); const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts); const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
if (cookies.source) {
console.error(`${ctx.l('source')}${cookies.source}`);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getCurrentUser(); const result = await client.getCurrentUser();
const credentialSource = cookies.source ?? 'env/auto-detected cookies';
if (result.success && result.user) { if (result.success && result.user) {
console.log(`${ctx.l('user')}@${result.user.username} (${result.user.name})`); console.log(`${ctx.l('user')}@${result.user.username} (${result.user.name})`);
console.log(`${ctx.l('userId')}${result.user.id}`); console.log(`${ctx.l('userId')}${result.user.id}`);
console.log(`${ctx.l('engine')}graphql`); console.log(`${ctx.l('engine')}twitter-safe-relay`);
console.log(`${ctx.l('credentials')}${credentialSource}`); console.log(`${ctx.l('credentials')}${relayBaseUrl ?? 'TWITTER_RELAY_BASE_URL'}`);
} else { } else {
console.error(`${ctx.p('err')}Failed to determine current user: ${result.error ?? 'Unknown error'}`); console.error(`${ctx.p('err')}Failed to determine current user: ${result.error ?? 'Unknown error'}`);
process.exit(1); process.exit(1);
} }
}); });
program
.command('about')
.description('Get account origin and location information for a user')
.argument('<username>', 'Twitter username (with or without @)')
.option('--json', 'Output as JSON')
.action(async (username: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const normalizedHandle = normalizeHandle(username);
if (!normalizedHandle) {
console.error(`${ctx.p('err')}Invalid username: ${username}`);
process.exit(1);
}
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs });
const result = await client.getUserAboutAccount(normalizedHandle);
if (result.success && result.aboutProfile) {
if (cmdOpts.json) {
console.log(JSON.stringify(result.aboutProfile, null, 2));
} else {
for (const line of formatAboutProfile(result.aboutProfile, ctx, normalizedHandle)) {
console.log(line);
}
}
} else {
console.error(`${ctx.p('err')}Failed to fetch account information: ${result.error ?? 'Unknown error'}`);
process.exit(1);
}
});
} }
-235
View File
@@ -1,235 +0,0 @@
/**
* Browser cookie extraction for Twitter authentication.
* Delegates to @steipete/sweet-cookie for Safari/Chrome/Firefox reads.
*/
import { getCookies } from '@steipete/sweet-cookie';
export interface TwitterCookies {
authToken: string | null;
ct0: string | null;
cookieHeader: string | null;
source: string | null;
}
export interface CookieExtractionResult {
cookies: TwitterCookies;
warnings: string[];
}
export type CookieSource = 'safari' | 'chrome' | 'firefox';
const TWITTER_COOKIE_NAMES = ['auth_token', 'ct0'] as const;
const TWITTER_URL = 'https://x.com/';
const TWITTER_ORIGINS: string[] = ['https://x.com/', 'https://twitter.com/'];
const DEFAULT_COOKIE_TIMEOUT_MS = 30_000;
function normalizeValue(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function cookieHeader(authToken: string, ct0: string): string {
return `auth_token=${authToken}; ct0=${ct0}`;
}
function buildEmpty(): TwitterCookies {
return { authToken: null, ct0: null, cookieHeader: null, source: null };
}
function readEnvCookie(cookies: TwitterCookies, keys: readonly string[], field: 'authToken' | 'ct0'): void {
if (cookies[field]) {
return;
}
for (const key of keys) {
const value = normalizeValue(process.env[key]);
if (!value) {
continue;
}
cookies[field] = value;
if (!cookies.source) {
cookies.source = `env ${key}`;
}
break;
}
}
function resolveSources(cookieSource?: CookieSource | CookieSource[]): CookieSource[] {
if (Array.isArray(cookieSource)) {
return cookieSource;
}
if (cookieSource) {
return [cookieSource];
}
return ['safari', 'chrome', 'firefox'];
}
function labelForSource(source: CookieSource, profile?: string): string {
if (source === 'safari') {
return 'Safari';
}
if (source === 'chrome') {
return profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
}
return profile ? `Firefox profile "${profile}"` : 'Firefox default profile';
}
function pickCookieValue(
cookies: Array<{ name?: string; value?: string; domain?: string }>,
name: (typeof TWITTER_COOKIE_NAMES)[number],
): string | null {
const matches = cookies.filter((c) => c?.name === name && typeof c.value === 'string');
if (matches.length === 0) {
return null;
}
const preferred = matches.find((c) => (c.domain ?? '').endsWith('x.com'));
if (preferred?.value) {
return preferred.value;
}
const twitter = matches.find((c) => (c.domain ?? '').endsWith('twitter.com'));
if (twitter?.value) {
return twitter.value;
}
return matches[0]?.value ?? null;
}
async function readTwitterCookiesFromBrowser(options: {
source: CookieSource;
chromeProfile?: string;
firefoxProfile?: string;
cookieTimeoutMs?: number;
}): Promise<CookieExtractionResult> {
const warnings: string[] = [];
const out = buildEmpty();
const { cookies, warnings: providerWarnings } = await getCookies({
url: TWITTER_URL,
origins: TWITTER_ORIGINS,
names: [...TWITTER_COOKIE_NAMES],
browsers: [options.source],
mode: 'merge',
chromeProfile: options.chromeProfile,
firefoxProfile: options.firefoxProfile,
timeoutMs: options.cookieTimeoutMs,
});
warnings.push(...providerWarnings);
const authToken = pickCookieValue(cookies, 'auth_token');
const ct0 = pickCookieValue(cookies, 'ct0');
if (authToken) {
out.authToken = authToken;
}
if (ct0) {
out.ct0 = ct0;
}
if (out.authToken && out.ct0) {
out.cookieHeader = cookieHeader(out.authToken, out.ct0);
out.source = labelForSource(
options.source,
options.source === 'chrome' ? options.chromeProfile : options.firefoxProfile,
);
return { cookies: out, warnings };
}
if (options.source === 'safari') {
warnings.push('No Twitter cookies found in Safari. Make sure you are logged into x.com in Safari.');
} else if (options.source === 'chrome') {
warnings.push('No Twitter cookies found in Chrome. Make sure you are logged into x.com in Chrome.');
} else {
warnings.push(
'No Twitter cookies found in Firefox. Make sure you are logged into x.com in Firefox and the profile exists.',
);
}
return { cookies: out, warnings };
}
export async function extractCookiesFromSafari(): Promise<CookieExtractionResult> {
return readTwitterCookiesFromBrowser({ source: 'safari' });
}
export async function extractCookiesFromChrome(profile?: string): Promise<CookieExtractionResult> {
return readTwitterCookiesFromBrowser({ source: 'chrome', chromeProfile: profile });
}
export async function extractCookiesFromFirefox(profile?: string): Promise<CookieExtractionResult> {
return readTwitterCookiesFromBrowser({ source: 'firefox', firefoxProfile: profile });
}
/**
* Resolve Twitter credentials from multiple sources.
* Priority: CLI args > environment variables > browsers (ordered).
*/
export async function resolveCredentials(options: {
authToken?: string;
ct0?: string;
cookieSource?: CookieSource | CookieSource[];
chromeProfile?: string;
firefoxProfile?: string;
cookieTimeoutMs?: number;
}): Promise<CookieExtractionResult> {
const warnings: string[] = [];
const cookies = buildEmpty();
const cookieTimeoutMs =
typeof options.cookieTimeoutMs === 'number' &&
Number.isFinite(options.cookieTimeoutMs) &&
options.cookieTimeoutMs > 0
? options.cookieTimeoutMs
: process.platform === 'darwin'
? DEFAULT_COOKIE_TIMEOUT_MS
: undefined;
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';
}
}
readEnvCookie(cookies, ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'], 'authToken');
readEnvCookie(cookies, ['CT0', 'TWITTER_CT0'], 'ct0');
if (cookies.authToken && cookies.ct0) {
cookies.cookieHeader = cookieHeader(cookies.authToken, cookies.ct0);
return { cookies, warnings };
}
const sourcesToTry = resolveSources(options.cookieSource);
for (const source of sourcesToTry) {
const res = await readTwitterCookiesFromBrowser({
source,
chromeProfile: options.chromeProfile,
firefoxProfile: options.firefoxProfile,
cookieTimeoutMs,
});
warnings.push(...res.warnings);
if (res.cookies.authToken && res.cookies.ct0) {
return { cookies: res.cookies, warnings };
}
}
if (!cookies.authToken) {
warnings.push(
'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Safari/Chrome/Firefox',
);
}
if (!cookies.ct0) {
warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Safari/Chrome/Firefox');
}
if (cookies.authToken && cookies.ct0) {
cookies.cookieHeader = cookieHeader(cookies.authToken, cookies.ct0);
}
return { cookies, warnings };
}
+13 -1
View File
@@ -1,5 +1,17 @@
{ {
"global": { "global": {
"responsive_web_grok_annotations_enabled": false "responsive_web_grok_annotations_enabled": false,
"post_ctas_fetch_enabled": true,
"responsive_web_graphql_exclude_directive_enabled": true
},
"sets": {
"lists": {
"blue_business_profile_image_shape_enabled": true,
"tweetypie_unmention_optimization_enabled": true,
"responsive_web_text_conversations_enabled": false,
"interactive_text_enabled": true,
"vibe_api_enabled": true,
"responsive_web_twitter_blue_verified_badge_is_enabled": true
}
} }
} }
+11 -11
View File
@@ -1,12 +1,3 @@
export {
type CookieExtractionResult,
type CookieSource,
extractCookiesFromChrome,
extractCookiesFromFirefox,
extractCookiesFromSafari,
resolveCredentials,
type TwitterCookies,
} from './cookies.js';
export { runtimeQueryIds } from './runtime-query-ids.js'; export { runtimeQueryIds } from './runtime-query-ids.js';
export { export {
type CurrentUserResult, type CurrentUserResult,
@@ -20,7 +11,16 @@ export {
} from './twitter-client.js'; } from './twitter-client.js';
export type { HomeTimelineFetchOptions } from './twitter-client-home.js'; export type { HomeTimelineFetchOptions } from './twitter-client-home.js';
export type { ExploreTab, NewsFetchOptions, NewsItem, NewsResult } from './twitter-client-news.js'; export type { ExploreTab, NewsFetchOptions, NewsItem, NewsResult } from './twitter-client-news.js';
export type { SearchFetchOptions } from './twitter-client-search.js'; export type {
SearchFetchOptions,
SearchPaginationOptions,
SearchProduct,
} from './twitter-client-search.js';
export type { TimelineFetchOptions } from './twitter-client-timelines.js'; export type { TimelineFetchOptions } from './twitter-client-timelines.js';
export type { TweetFetchOptions } from './twitter-client-tweet-detail.js'; export type { TweetFetchOptions } from './twitter-client-tweet-detail.js';
export type { TweetResult, UploadMediaResult } from './twitter-client-types.js'; export type {
AboutAccountProfile,
AboutAccountResult,
TweetResult,
UploadMediaResult,
} from './twitter-client-types.js';
+4 -1
View File
@@ -1,6 +1,8 @@
{ {
"CreateTweet": "nmdAQXJDxw6-0KKF2on7eA", "CreateTweet": "nmdAQXJDxw6-0KKF2on7eA",
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A", "CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
"CreateFriendship": "8h9JVdV8dlSyqyRDJEPCsA",
"DestroyFriendship": "ppXWuagMNXgvzx6WoXBW0Q",
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A", "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
"DeleteBookmark": "Wlmlj2-xzyS1GN3a6cj-mQ", "DeleteBookmark": "Wlmlj2-xzyS1GN3a6cj-mQ",
"TweetDetail": "_NvJCnIjOW__EP5-RF197A", "TweetDetail": "_NvJCnIjOW__EP5-RF197A",
@@ -13,5 +15,6 @@
"ExploreSidebar": "lpSN4M6qpimkF4nRFPE3nQ", "ExploreSidebar": "lpSN4M6qpimkF4nRFPE3nQ",
"ExplorePage": "kheAINB_4pzRDqkzG3K-ng", "ExplorePage": "kheAINB_4pzRDqkzG3K-ng",
"GenericTimelineById": "uGSr7alSjR9v6QJAIaqSKQ", "GenericTimelineById": "uGSr7alSjR9v6QJAIaqSKQ",
"TrendHistory": "Sj4T-jSB9pr0Mxtsc1UKZQ" "TrendHistory": "Sj4T-jSB9pr0Mxtsc1UKZQ",
"AboutAccountQuery": "zs_jFPFT78rBpXv9Z3U2YQ"
} }
+146
View File
@@ -0,0 +1,146 @@
import type { TweetData, TweetWithMeta } from './twitter-client-types.js';
const sortByCreatedAt = (tweets: TweetData[]): TweetData[] =>
tweets.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;
});
export function filterAuthorChain(tweets: TweetData[], bookmarkedTweet: TweetData): TweetData[] {
const author = bookmarkedTweet.author.username;
const byId = new Map(tweets.map((tweet) => [tweet.id, tweet]));
const chainIds = new Set<string>();
let current: TweetData | undefined = bookmarkedTweet;
while (current && current.author.username === author) {
chainIds.add(current.id);
const parentId = current.inReplyToStatusId;
if (!parentId) {
break;
}
const parent = byId.get(parentId);
if (!parent || parent.author.username !== author) {
break;
}
current = parent;
}
let changed = true;
while (changed) {
changed = false;
for (const tweet of tweets) {
if (tweet.author.username !== author) {
continue;
}
if (chainIds.has(tweet.id)) {
continue;
}
if (tweet.inReplyToStatusId && chainIds.has(tweet.inReplyToStatusId)) {
chainIds.add(tweet.id);
changed = true;
}
}
}
return sortByCreatedAt(tweets.filter((tweet) => chainIds.has(tweet.id)));
}
export function filterAuthorOnly(tweets: TweetData[], bookmarkedTweet: TweetData): TweetData[] {
const author = bookmarkedTweet.author.username;
return tweets.filter((tweet) => tweet.author.username === author);
}
export function filterFullChain(
tweets: TweetData[],
bookmarkedTweet: TweetData,
options: { includeAncestorBranches?: boolean } = {},
): TweetData[] {
const byId = new Map(tweets.map((tweet) => [tweet.id, tweet]));
const repliesByParent = new Map<string, TweetData[]>();
for (const tweet of tweets) {
if (!tweet.inReplyToStatusId) {
continue;
}
const list = repliesByParent.get(tweet.inReplyToStatusId) ?? [];
list.push(tweet);
repliesByParent.set(tweet.inReplyToStatusId, list);
}
const chainIds = new Set<string>();
const ancestorIds: string[] = [];
chainIds.add(bookmarkedTweet.id);
let current: TweetData | undefined = bookmarkedTweet;
while (current?.inReplyToStatusId) {
const parent = byId.get(current.inReplyToStatusId);
if (!parent) {
break;
}
if (!chainIds.has(parent.id)) {
chainIds.add(parent.id);
ancestorIds.push(parent.id);
}
current = parent;
}
const addDescendants = (startIds: string[]) => {
const queue = [...startIds];
while (queue.length > 0) {
const currentId = queue.shift();
if (!currentId) {
continue;
}
if (!chainIds.has(currentId)) {
chainIds.add(currentId);
}
const replies = repliesByParent.get(currentId) ?? [];
for (const reply of replies) {
if (chainIds.has(reply.id)) {
continue;
}
chainIds.add(reply.id);
queue.push(reply.id);
}
}
};
addDescendants([bookmarkedTweet.id]);
if (options.includeAncestorBranches) {
for (const ancestorId of ancestorIds) {
addDescendants([ancestorId]);
}
}
return sortByCreatedAt(tweets.filter((tweet) => chainIds.has(tweet.id)));
}
export function addThreadMetadata(tweet: TweetData, allConversationTweets: TweetData[]): TweetWithMeta {
const author = tweet.author.username;
const hasSelfReplies = allConversationTweets.some(
(candidate) => candidate.inReplyToStatusId === tweet.id && candidate.author.username === author,
);
const isRoot = !tweet.inReplyToStatusId;
let threadPosition: 'root' | 'middle' | 'end' | 'standalone';
if (isRoot && !hasSelfReplies) {
threadPosition = 'standalone';
} else if (isRoot && hasSelfReplies) {
threadPosition = 'root';
} else if (!isRoot && hasSelfReplies) {
threadPosition = 'middle';
} else {
threadPosition = 'end';
}
return {
...tweet,
isThread: hasSelfReplies || !isRoot,
threadPosition,
hasSelfReplies,
threadRootId: tweet.conversationId ?? null,
};
}
+41 -53
View File
@@ -1,9 +1,10 @@
import { randomBytes, randomUUID } from 'node:crypto';
import { runtimeQueryIds } from './runtime-query-ids.js'; import { runtimeQueryIds } from './runtime-query-ids.js';
import { type OperationName, QUERY_IDS, TARGET_QUERY_ID_OPERATIONS } from './twitter-client-constants.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 type { CurrentUserResult, TwitterClientOptions } from './twitter-client-types.js';
import { normalizeQuoteDepth } from './twitter-client-utils.js'; import { normalizeQuoteDepth } from './twitter-client-utils.js';
const TRAILING_SLASHES = /\/+$/;
// biome-ignore lint/suspicious/noExplicitAny: TS mixin base constructor requirement. // biome-ignore lint/suspicious/noExplicitAny: TS mixin base constructor requirement.
export type Constructor<T = object> = new (...args: any[]) => T; export type Constructor<T = object> = new (...args: any[]) => T;
// biome-ignore lint/suspicious/noExplicitAny: TS mixin base constructor requirement. // biome-ignore lint/suspicious/noExplicitAny: TS mixin base constructor requirement.
@@ -13,30 +14,17 @@ export type Mixin<TBase extends AbstractConstructor<TwitterClientBase>, TAdded>
) => TwitterClientBase & TAdded; ) => TwitterClientBase & TAdded;
export abstract class TwitterClientBase { export abstract class TwitterClientBase {
protected authToken: string; protected relayBaseUrl: string;
protected ct0: string; protected profileName?: string;
protected cookieHeader: string;
protected userAgent: string;
protected timeoutMs?: number; protected timeoutMs?: number;
protected quoteDepth: number; protected quoteDepth: number;
protected clientUuid: string;
protected clientDeviceId: string;
protected clientUserId?: string; protected clientUserId?: string;
constructor(options: TwitterClientOptions) { constructor(options: TwitterClientOptions) {
if (!options.cookies.authToken || !options.cookies.ct0) { this.relayBaseUrl = this.normalizeRelayBaseUrl(options.relayBaseUrl ?? process.env.TWITTER_RELAY_BASE_URL);
throw new Error('Both authToken and ct0 cookies are required'); this.profileName = this.normalizeProfileName(options.profileName);
}
this.authToken = options.cookies.authToken;
this.ct0 = options.cookies.ct0;
this.cookieHeader = options.cookies.cookieHeader || `auth_token=${this.authToken}; ct0=${this.ct0}`;
this.userAgent =
options.userAgent ||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
this.timeoutMs = options.timeoutMs; this.timeoutMs = options.timeoutMs;
this.quoteDepth = normalizeQuoteDepth(options.quoteDepth); this.quoteDepth = normalizeQuoteDepth(options.quoteDepth);
this.clientUuid = randomUUID();
this.clientDeviceId = randomUUID();
} }
protected abstract getCurrentUser(): Promise<CurrentUserResult>; protected abstract getCurrentUser(): Promise<CurrentUserResult>;
@@ -61,6 +49,18 @@ export abstract class TwitterClientBase {
} }
} }
protected async withRefreshedQueryIdsOn404<T extends { success: boolean; had404?: boolean }>(
attempt: () => Promise<T>,
): Promise<{ result: T; refreshed: boolean }> {
const firstAttempt = await attempt();
if (firstAttempt.success || !firstAttempt.had404) {
return { result: firstAttempt, refreshed: false };
}
await this.refreshQueryIds();
const secondAttempt = await attempt();
return { result: secondAttempt, refreshed: true };
}
protected async getTweetDetailQueryIds(): Promise<string[]> { protected async getTweetDetailQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('TweetDetail'); const primary = await this.getQueryId('TweetDetail');
return Array.from(new Set([primary, '97JF30KziU00483E_8elBA', 'aFvUsJm2c-oDkJV75blV6g'])); return Array.from(new Set([primary, '97JF30KziU00483E_8elBA', 'aFvUsJm2c-oDkJV75blV6g']));
@@ -72,63 +72,51 @@ export abstract class TwitterClientBase {
} }
protected async fetchWithTimeout(url: string, init: RequestInit): Promise<Response> { protected async fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
const relayUrl = this.toRelayUrl(url);
if (!this.timeoutMs || this.timeoutMs <= 0) { if (!this.timeoutMs || this.timeoutMs <= 0) {
return fetch(url, init); return fetch(relayUrl, init);
} }
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
try { try {
return await fetch(url, { ...init, signal: controller.signal }); return await fetch(relayUrl, { ...init, signal: controller.signal });
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
} }
protected getHeaders(): Record<string, string> { protected getHeaders(): Record<string, string> {
return this.getJsonHeaders(); return this.profileName ? { 'x-profile-name': this.profileName } : {};
} }
protected createTransactionId(): string { private normalizeRelayBaseUrl(value: string | undefined): string {
return randomBytes(16).toString('hex'); const trimmed = value?.trim();
} if (!trimmed) {
throw new Error('TWITTER_RELAY_BASE_URL is required');
protected getBaseHeaders(): Record<string, string> {
const headers: Record<string, string> = {
accept: '*/*',
'accept-language': 'en-US,en;q=0.9',
authorization:
'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
'x-csrf-token': this.ct0,
'x-twitter-auth-type': 'OAuth2Session',
'x-twitter-active-user': 'yes',
'x-twitter-client-language': 'en',
'x-client-uuid': this.clientUuid,
'x-twitter-client-deviceid': this.clientDeviceId,
'x-client-transaction-id': this.createTransactionId(),
cookie: this.cookieHeader,
'user-agent': this.userAgent,
origin: 'https://x.com',
referer: 'https://x.com/',
};
if (this.clientUserId) {
headers['x-twitter-client-user-id'] = this.clientUserId;
} }
return trimmed.replace(TRAILING_SLASHES, '');
}
return headers; private normalizeProfileName(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed || undefined;
}
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> { protected getJsonHeaders(): Record<string, string> {
return { return { 'content-type': 'application/json', ...this.getHeaders() };
...this.getBaseHeaders(),
'content-type': 'application/json',
};
} }
protected getUploadHeaders(): Record<string, string> { protected getUploadHeaders(): Record<string, string> {
// Note: do not set content-type; URLSearchParams/FormData need to set it (incl boundary) themselves. return this.getHeaders();
return this.getBaseHeaders();
} }
protected async ensureClientUserId(): Promise<void> { protected async ensureClientUserId(): Promise<void> {
+3 -4
View File
@@ -22,7 +22,6 @@ export function withBookmarks<TBase extends AbstractConstructor<TwitterClientBas
let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/DeleteBookmark`; let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/DeleteBookmark`;
const buildBody = () => JSON.stringify({ variables, queryId }); const buildBody = () => JSON.stringify({ variables, queryId });
const buildHeaders = () => ({ ...this.getHeaders(), referer: `https://x.com/i/status/${tweetId}` });
let body = buildBody(); let body = buildBody();
const parseResponse = async (response: Response): Promise<BookmarkMutationResult> => { const parseResponse = async (response: Response): Promise<BookmarkMutationResult> => {
@@ -42,7 +41,7 @@ export function withBookmarks<TBase extends AbstractConstructor<TwitterClientBas
try { try {
let response = await this.fetchWithTimeout(urlWithOperation, { let response = await this.fetchWithTimeout(urlWithOperation, {
method: 'POST', method: 'POST',
headers: buildHeaders(), headers: this.getJsonHeaders(),
body, body,
}); });
@@ -54,14 +53,14 @@ export function withBookmarks<TBase extends AbstractConstructor<TwitterClientBas
response = await this.fetchWithTimeout(urlWithOperation, { response = await this.fetchWithTimeout(urlWithOperation, {
method: 'POST', method: 'POST',
headers: buildHeaders(), headers: this.getJsonHeaders(),
body, body,
}); });
if (response.status === 404) { if (response.status === 404) {
const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, { const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, {
method: 'POST', method: 'POST',
headers: buildHeaders(), headers: this.getJsonHeaders(),
body, body,
}); });
+8 -4
View File
@@ -6,17 +6,18 @@ 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_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_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'; export const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/update.json';
export const SETTINGS_SCREEN_NAME_REGEX = /"screen_name":"([^"]+)"/;
export const SETTINGS_USER_ID_REGEX = /"user_id"\s*:\s*"(\d+)"/;
export const SETTINGS_NAME_REGEX = /"name":"([^"\\]*(?:\\.[^"\\]*)*)"/;
// Query IDs rotate frequently; the values in query-ids.json are refreshed by // 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 // scripts/update-query-ids.ts. The fallback values keep the client usable if
// the file is missing or incomplete. // the file is missing or incomplete.
export const FALLBACK_QUERY_IDS = { export const FALLBACK_QUERY_IDS = {
CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg', CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg',
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA', CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
DeleteRetweet: 'iQtK4dl5hBmXewYZuEOKVw',
CreateFriendship: '8h9JVdV8dlSyqyRDJEPCsA',
DestroyFriendship: 'ppXWuagMNXgvzx6WoXBW0Q',
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A', FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
UnfavoriteTweet: 'ZYKSe-w7KEslx3JhSIk5LA',
CreateBookmark: 'aoDbu3RHznuiSkQ9aNM67Q',
DeleteBookmark: 'Wlmlj2-xzyS1GN3a6cj-mQ', DeleteBookmark: 'Wlmlj2-xzyS1GN3a6cj-mQ',
TweetDetail: '97JF30KziU00483E_8elBA', TweetDetail: '97JF30KziU00483E_8elBA',
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ', SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
@@ -37,6 +38,9 @@ export const FALLBACK_QUERY_IDS = {
ExplorePage: 'kheAINB_4pzRDqkzG3K-ng', ExplorePage: 'kheAINB_4pzRDqkzG3K-ng',
GenericTimelineById: 'uGSr7alSjR9v6QJAIaqSKQ', GenericTimelineById: 'uGSr7alSjR9v6QJAIaqSKQ',
TrendHistory: 'Sj4T-jSB9pr0Mxtsc1UKZQ', TrendHistory: 'Sj4T-jSB9pr0Mxtsc1UKZQ',
AboutAccountQuery: 'zs_jFPFT78rBpXv9Z3U2YQ',
UserByScreenName: 'IGgvgiOx4QZndDHuD3x9TQ',
UserByRestId: 'VQfQ9wwYdk6j_u2O4vt64Q',
} as const; } as const;
export type OperationName = keyof typeof FALLBACK_QUERY_IDS; export type OperationName = keyof typeof FALLBACK_QUERY_IDS;
+118
View File
@@ -0,0 +1,118 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { type OperationName, TWITTER_API_BASE, TWITTER_GRAPHQL_POST_URL } from './twitter-client-constants.js';
import type { BookmarkMutationResult } from './twitter-client-types.js';
export interface TwitterClientEngagementMethods {
/** Like a tweet. */
like(tweetId: string): Promise<BookmarkMutationResult>;
/** Remove a like from a tweet. */
unlike(tweetId: string): Promise<BookmarkMutationResult>;
/** Retweet a tweet. */
retweet(tweetId: string): Promise<BookmarkMutationResult>;
/** Remove a retweet. */
unretweet(tweetId: string): Promise<BookmarkMutationResult>;
/** Bookmark a tweet. */
bookmark(tweetId: string): Promise<BookmarkMutationResult>;
}
export function withEngagement<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientEngagementMethods> {
abstract class TwitterClientEngagement extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
private async performEngagementMutation(
operationName: OperationName,
tweetId: string,
): Promise<BookmarkMutationResult> {
await this.ensureClientUserId();
const variables =
operationName === 'DeleteRetweet' ? { tweet_id: tweetId, source_tweet_id: tweetId } : { tweet_id: tweetId };
let queryId = await this.getQueryId(operationName);
let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/${operationName}`;
const buildBody = () => JSON.stringify({ variables, queryId });
const buildHeaders = () => ({ ...this.getHeaders(), referer: `https://x.com/i/status/${tweetId}` });
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: buildHeaders(),
body,
});
if (response.status === 404) {
await this.refreshQueryIds();
queryId = await this.getQueryId(operationName);
urlWithOperation = `${TWITTER_API_BASE}/${queryId}/${operationName}`;
body = buildBody();
response = await this.fetchWithTimeout(urlWithOperation, {
method: 'POST',
headers: buildHeaders(),
body,
});
if (response.status === 404) {
const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, {
method: 'POST',
headers: buildHeaders(),
body,
});
return parseResponse(retry);
}
}
return parseResponse(response);
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
/** Like a tweet. */
async like(tweetId: string): Promise<BookmarkMutationResult> {
return this.performEngagementMutation('FavoriteTweet', tweetId);
}
/** Remove a like from a tweet. */
async unlike(tweetId: string): Promise<BookmarkMutationResult> {
return this.performEngagementMutation('UnfavoriteTweet', tweetId);
}
/** Retweet a tweet. */
async retweet(tweetId: string): Promise<BookmarkMutationResult> {
return this.performEngagementMutation('CreateRetweet', tweetId);
}
/** Remove a retweet. */
async unretweet(tweetId: string): Promise<BookmarkMutationResult> {
return this.performEngagementMutation('DeleteRetweet', tweetId);
}
/** Bookmark a tweet. */
async bookmark(tweetId: string): Promise<BookmarkMutationResult> {
return this.performEngagementMutation('CreateBookmark', tweetId);
}
}
return TwitterClientEngagement;
}
+234
View File
@@ -0,0 +1,234 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import type { FollowMutationResult } from './twitter-client-types.js';
export interface TwitterClientFollowMethods {
follow(userId: string): Promise<FollowMutationResult>;
unfollow(userId: string): Promise<FollowMutationResult>;
}
export function withFollow<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientFollowMethods> {
abstract class TwitterClientFollow extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
/**
* Follow a user by their user ID
*/
async follow(userId: string): Promise<FollowMutationResult> {
await this.ensureClientUserId();
// Try REST API first (more reliable for follow/unfollow)
const restResult = await this.followViaRest(userId, 'create');
if (restResult.success) {
return restResult;
}
// Fallback to GraphQL
return this.followViaGraphQL(userId, true);
}
/**
* Unfollow a user by their user ID
*/
async unfollow(userId: string): Promise<FollowMutationResult> {
await this.ensureClientUserId();
// Try REST API first (more reliable for follow/unfollow)
const restResult = await this.followViaRest(userId, 'destroy');
if (restResult.success) {
return restResult;
}
// Fallback to GraphQL
return this.followViaGraphQL(userId, false);
}
private async followViaRest(userId: string, action: 'create' | 'destroy'): Promise<FollowMutationResult> {
const urls = [
`https://x.com/i/api/1.1/friendships/${action}.json`,
`https://api.twitter.com/1.1/friendships/${action}.json`,
];
const params = new URLSearchParams({
user_id: userId,
skip_status: 'true',
});
let lastError: string | undefined;
for (const url of urls) {
try {
const response = await this.fetchWithTimeout(url, {
method: 'POST',
headers: {
...this.getHeaders(),
'content-type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});
if (!response.ok) {
const text = await response.text();
// Parse error response
try {
const errorData = JSON.parse(text) as { errors?: Array<{ code: number; message: string }> };
if (errorData.errors && errorData.errors.length > 0) {
const error = errorData.errors[0];
// Code 160: Already following / already not following
if (error.code === 160) {
return { success: true };
}
// Code 162: Blocked from following
if (error.code === 162) {
return { success: false, error: 'You have been blocked from following this account' };
}
// Code 108: Cannot find specified user
if (error.code === 108) {
return { success: false, error: 'User not found' };
}
lastError = `${error.message} (code ${error.code})`;
continue;
}
} catch {
// Not JSON, continue with generic error
}
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
continue;
}
const data = (await response.json()) as {
id_str?: string;
screen_name?: string;
following?: boolean;
errors?: Array<{ message: string; code?: number }>;
};
if (data.errors && data.errors.length > 0) {
lastError = data.errors.map((e) => e.message).join(', ');
continue;
}
// Success - user data returned
if (data.id_str || data.screen_name) {
return {
success: true,
userId: data.id_str,
username: data.screen_name,
};
}
return { success: true };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false, error: lastError ?? `Unknown error during ${action}` };
}
private async followViaGraphQL(userId: string, follow: boolean): Promise<FollowMutationResult> {
const operationName = follow ? 'CreateFriendship' : 'DestroyFriendship';
const variables = {
user_id: userId,
};
const tryOnce = async () => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getFollowQueryIds(follow);
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/${operationName}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({ variables, queryId }),
});
if (response.status === 404) {
had404 = true;
lastError = 'HTTP 404';
continue;
}
if (!response.ok) {
const text = await response.text();
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
continue;
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
rest_id?: string;
legacy?: {
screen_name?: string;
};
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
lastError = data.errors.map((e) => e.message).join(', ');
continue;
}
const result = data.data?.user?.result;
return {
success: true as const,
userId: result?.rest_id,
username: result?.legacy?.screen_name,
had404,
};
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return {
success: false as const,
error: lastError ?? `Unknown error during ${operationName}`,
had404,
};
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, userId: firstAttempt.userId, username: firstAttempt.username };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, userId: secondAttempt.userId, username: secondAttempt.username };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
private async getFollowQueryIds(follow: boolean): Promise<string[]> {
const primary = await this.getQueryId(follow ? 'CreateFriendship' : 'DestroyFriendship');
const fallbacks = follow
? ['8h9JVdV8dlSyqyRDJEPCsA', 'OPwKc1HXnBT_bWXfAlo-9g']
: ['ppXWuagMNXgvzx6WoXBW0Q', '8h9JVdV8dlSyqyRDJEPCsA'];
return Array.from(new Set([primary, ...fallbacks]));
}
}
return TwitterClientFollow;
}
+32 -4
View File
@@ -14,6 +14,12 @@ function isQueryIdMismatch(errors: Array<{ message?: string }>): boolean {
export interface HomeTimelineFetchOptions { export interface HomeTimelineFetchOptions {
/** Include raw GraphQL response in `_raw` field */ /** Include raw GraphQL response in `_raw` field */
includeRaw?: boolean; includeRaw?: boolean;
/** Starting cursor for pagination */
cursor?: string;
/** Maximum number of pages to fetch */
maxPages?: number;
/** Delay in milliseconds between page fetches */
pageDelayMs?: number;
} }
export interface TwitterClientHomeMethods { export interface TwitterClientHomeMethods {
@@ -64,7 +70,17 @@ export function withHome<TBase extends AbstractConstructor<TwitterClientBase>>(
const pageSize = 20; const pageSize = 20;
const seen = new Set<string>(); const seen = new Set<string>();
const tweets: TweetData[] = []; const tweets: TweetData[] = [];
let cursor: string | undefined; let cursor: string | undefined = options.cursor;
let nextCursor: string | undefined;
let pagesFetched = 0;
const effectiveMaxPages =
options.maxPages !== undefined && Number.isFinite(options.maxPages) && options.maxPages > 0
? Math.floor(options.maxPages)
: Number.POSITIVE_INFINITY;
const pageDelayMs =
options.pageDelayMs !== undefined && Number.isFinite(options.pageDelayMs) && options.pageDelayMs > 0
? options.pageDelayMs
: 0;
const fetchPage = async (pageCount: number, pageCursor?: string) => { const fetchPage = async (pageCount: number, pageCursor?: string) => {
let lastError: string | undefined; let lastError: string | undefined;
@@ -168,11 +184,16 @@ export function withHome<TBase extends AbstractConstructor<TwitterClientBase>>(
}; };
while (tweets.length < count) { while (tweets.length < count) {
if (pagesFetched > 0 && pageDelayMs > 0) {
await this.sleep(pageDelayMs);
}
const pageCount = Math.min(pageSize, count - tweets.length); const pageCount = Math.min(pageSize, count - tweets.length);
const page = await fetchWithRefresh(pageCount, cursor); const page = await fetchWithRefresh(pageCount, cursor);
if (!page.success) { if (!page.success) {
return { success: false, error: page.error }; return { success: false, error: page.error };
} }
pagesFetched += 1;
let added = 0; let added = 0;
for (const tweet of page.tweets) { for (const tweet of page.tweets) {
@@ -187,13 +208,20 @@ export function withHome<TBase extends AbstractConstructor<TwitterClientBase>>(
} }
} }
if (!page.cursor || page.cursor === cursor || page.tweets.length === 0 || added === 0) { const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) {
nextCursor = undefined;
break; break;
} }
cursor = page.cursor; if (pagesFetched >= effectiveMaxPages) {
nextCursor = pageCursor;
break;
}
cursor = pageCursor;
nextCursor = pageCursor;
} }
return { success: true, tweets }; return { success: true, tweets, nextCursor };
} }
} }
+5 -9
View File
@@ -71,7 +71,7 @@ export function withPosting<TBase extends AbstractConstructor<TwitterClientBase>
let body = buildBody(); let body = buildBody();
try { try {
const headers = { ...this.getHeaders(), referer: 'https://x.com/compose/post' }; const headers = this.getJsonHeaders();
let response = await this.fetchWithTimeout(urlWithOperation, { let response = await this.fetchWithTimeout(urlWithOperation, {
method: 'POST', method: 'POST',
headers, headers,
@@ -88,14 +88,14 @@ export function withPosting<TBase extends AbstractConstructor<TwitterClientBase>
response = await this.fetchWithTimeout(urlWithOperation, { response = await this.fetchWithTimeout(urlWithOperation, {
method: 'POST', method: 'POST',
headers: { ...this.getHeaders(), referer: 'https://x.com/compose/post' }, headers,
body, body,
}); });
if (response.status === 404) { if (response.status === 404) {
const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, { const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, {
method: 'POST', method: 'POST',
headers: { ...this.getHeaders(), referer: 'https://x.com/compose/post' }, headers,
body, body,
}); });
@@ -224,12 +224,8 @@ export function withPosting<TBase extends AbstractConstructor<TwitterClientBase>
try { try {
const response = await this.fetchWithTimeout(TWITTER_STATUS_UPDATE_URL, { const response = await this.fetchWithTimeout(TWITTER_STATUS_UPDATE_URL, {
method: 'POST', method: 'POST',
headers: { headers: this.getHeaders(),
...this.getBaseHeaders(), body: params,
'content-type': 'application/x-www-form-urlencoded',
referer: 'https://x.com/compose/post',
},
body: params.toString(),
}); });
if (!response.ok) { if (!response.ok) {
+7 -3
View File
@@ -6,10 +6,14 @@ import { extractCursorFromInstructions, parseTweetsFromInstructions } from './tw
const RAW_QUERY_MISSING_REGEX = /must be defined/i; const RAW_QUERY_MISSING_REGEX = /must be defined/i;
export type SearchProduct = 'Top' | 'Latest';
/** Options for search methods */ /** Options for search methods */
export interface SearchFetchOptions { export interface SearchFetchOptions {
/** Include raw GraphQL response in `_raw` field */ /** Include raw GraphQL response in `_raw` field */
includeRaw?: boolean; includeRaw?: boolean;
/** Search result ranking (default: Latest) */
product?: SearchProduct;
} }
/** Options for paged search methods */ /** Options for paged search methods */
@@ -80,7 +84,7 @@ export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>
let cursor: string | undefined = options.cursor; let cursor: string | undefined = options.cursor;
let nextCursor: string | undefined; let nextCursor: string | undefined;
let pagesFetched = 0; let pagesFetched = 0;
const { includeRaw = false, maxPages } = options; const { includeRaw = false, maxPages, product = 'Latest' } = options;
const fetchPage = async (pageCount: number, pageCursor?: string) => { const fetchPage = async (pageCount: number, pageCursor?: string) => {
let lastError: string | undefined; let lastError: string | undefined;
@@ -92,7 +96,7 @@ export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>
rawQuery: query, rawQuery: query,
count: pageCount, count: pageCount,
querySource: 'typed_query', querySource: 'typed_query',
product: 'Latest', product,
...(pageCursor ? { cursor: pageCursor } : {}), ...(pageCursor ? { cursor: pageCursor } : {}),
}; };
@@ -105,7 +109,7 @@ export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>
try { try {
const response = await this.fetchWithTimeout(url, { const response = await this.fetchWithTimeout(url, {
method: 'POST', method: 'POST',
headers: this.getHeaders(), headers: this.getJsonHeaders(),
body: JSON.stringify({ features, queryId }), body: JSON.stringify({ features, queryId }),
}); });
+1 -1
View File
@@ -251,7 +251,7 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
const postResponse = await this.fetchWithTimeout(`${TWITTER_API_BASE}/${queryId}/TweetDetail`, { const postResponse = await this.fetchWithTimeout(`${TWITTER_API_BASE}/${queryId}/TweetDetail`, {
method: 'POST', method: 'POST',
headers: this.getHeaders(), headers: this.getJsonHeaders(),
body: JSON.stringify({ variables, features, queryId }), body: JSON.stringify({ variables, features, queryId }),
}); });
+91 -4
View File
@@ -1,5 +1,3 @@
import type { TwitterCookies } from './cookies.js';
// Raw media entity from Twitter API // Raw media entity from Twitter API
export interface GraphqlMediaEntity { export interface GraphqlMediaEntity {
id_str?: string; id_str?: string;
@@ -49,11 +47,15 @@ export type GraphqlTweetResult = {
legacy?: { legacy?: {
screen_name?: string; screen_name?: string;
name?: string; name?: string;
profile_image_url_https?: string;
}; };
core?: { core?: {
screen_name?: string; screen_name?: string;
name?: string; name?: string;
}; };
avatar?: {
image_url?: string;
};
}; };
}; };
}; };
@@ -230,6 +232,17 @@ export type BookmarkMutationResult =
error: string; error: string;
}; };
export type FollowMutationResult =
| {
success: true;
userId?: string;
username?: string;
}
| {
success: false;
error: string;
};
export interface UploadMediaResult { export interface UploadMediaResult {
success: boolean; success: boolean;
mediaId?: string; mediaId?: string;
@@ -254,6 +267,7 @@ export interface TweetData {
author: { author: {
username: string; username: string;
name: string; name: string;
profileImageUrl?: string;
}; };
authorId?: string; authorId?: string;
createdAt?: string; createdAt?: string;
@@ -275,6 +289,13 @@ export interface TweetData {
_raw?: GraphqlTweetResult; _raw?: GraphqlTweetResult;
} }
export interface TweetWithMeta extends TweetData {
isThread: boolean;
threadPosition: 'root' | 'middle' | 'end' | 'standalone';
hasSelfReplies: boolean;
threadRootId: string | null;
}
export interface GetTweetResult { export interface GetTweetResult {
success: boolean; success: boolean;
tweet?: TweetData; tweet?: TweetData;
@@ -318,6 +339,58 @@ export interface TwitterUser {
createdAt?: string; createdAt?: string;
} }
export interface TwitterUserUrlEntity {
url?: string;
expanded_url?: string;
display_url?: string;
}
export interface TwitterUserProfile {
id: string;
username: string;
name: string;
description?: string;
location?: string;
url?: string;
verified?: boolean;
verifiedType?: string;
followersCount?: number;
followingCount?: number;
profileImageUrl?: string;
createdAt?: string;
entities?: Record<string, unknown>;
affiliation?: {
description?: string;
url?: string;
badgeUrl?: string;
};
raw?: unknown;
}
export type UserProfileResult =
| {
success: true;
profile: TwitterUserProfile;
}
| {
success: false;
error: string;
};
export type UserProfilesResult = {
success: true;
results: Array<
| {
target: string;
profile: TwitterUserProfile;
}
| {
target: string;
error: string;
}
>;
};
export interface FollowingResult { export interface FollowingResult {
success: boolean; success: boolean;
users?: TwitterUser[]; users?: TwitterUser[];
@@ -326,9 +399,23 @@ export interface FollowingResult {
nextCursor?: string; nextCursor?: string;
} }
export interface AboutAccountProfile {
accountBasedIn?: string;
source?: string;
createdCountryAccurate?: boolean;
locationAccurate?: boolean;
learnMoreUrl?: string;
}
export interface AboutAccountResult {
success: boolean;
aboutProfile?: AboutAccountProfile;
error?: string;
}
export interface TwitterClientOptions { export interface TwitterClientOptions {
cookies: TwitterCookies; relayBaseUrl?: string;
userAgent?: string; profileName?: string;
timeoutMs?: number; timeoutMs?: number;
// Max depth for quoted tweets (0 disables). Defaults to 1. // Max depth for quoted tweets (0 disables). Defaults to 1.
quoteDepth?: number; quoteDepth?: number;
+372
View File
@@ -1,6 +1,14 @@
import { normalizeHandle } from './normalize-handle.js'; import { normalizeHandle } from './normalize-handle.js';
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js'; import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js'; import { TWITTER_API_BASE } from './twitter-client-constants.js';
import type {
AboutAccountResult,
TwitterUserProfile,
UserProfileResult,
UserProfilesResult,
} from './twitter-client-types.js';
const NUMERIC_USER_ID_PATTERN = /^[0-9]+$/;
/** Result of username to userId lookup */ /** Result of username to userId lookup */
export interface UserLookupResult { export interface UserLookupResult {
@@ -13,6 +21,117 @@ export interface UserLookupResult {
export interface TwitterClientUserLookupMethods { export interface TwitterClientUserLookupMethods {
getUserIdByUsername(username: string): Promise<UserLookupResult>; getUserIdByUsername(username: string): Promise<UserLookupResult>;
getUserProfile(target: string): Promise<UserProfileResult>;
getUserProfiles(targets: string[]): Promise<UserProfilesResult>;
getUserAboutAccount(username: string): Promise<AboutAccountResult>;
}
type UserProfileGraphqlResult = {
__typename?: string;
rest_id?: string;
is_blue_verified?: boolean;
avatar?: {
image_url?: string;
};
verification_info?: unknown;
legacy?: {
screen_name?: string;
name?: string;
description?: string;
location?: string;
url?: string;
created_at?: string;
verified?: boolean;
verified_type?: string;
followers_count?: number;
friends_count?: number;
profile_image_url_https?: string;
entities?: Record<string, unknown>;
};
core?: {
screen_name?: string;
name?: string;
created_at?: string;
};
location?: {
location?: string;
};
affiliates_highlighted_label?: {
label?: {
description?: string;
url?: { url?: string };
badge?: { url?: string };
};
};
user?: UserProfileGraphqlResult;
};
function isNumericUserId(value: string): boolean {
return NUMERIC_USER_ID_PATTERN.test(value.trim());
}
function firstExpandedUrl(entities: Record<string, unknown> | undefined): string | undefined {
const urlEntity = entities?.url;
if (!urlEntity || typeof urlEntity !== 'object') {
return undefined;
}
const urls = (urlEntity as { urls?: unknown }).urls;
if (!Array.isArray(urls)) {
return undefined;
}
const first = urls[0];
if (!first || typeof first !== 'object') {
return undefined;
}
const expandedUrl = (first as { expanded_url?: unknown }).expanded_url;
const url = (first as { url?: unknown }).url;
return typeof expandedUrl === 'string' ? expandedUrl : typeof url === 'string' ? url : undefined;
}
function normalizeGraphqlUserResult(result: UserProfileGraphqlResult): TwitterUserProfile | null {
const user = result.__typename === 'UserWithVisibilityResults' && result.user ? result.user : result;
if (user.__typename === 'UserUnavailable') {
return null;
}
const id = user.rest_id;
const username = user.legacy?.screen_name ?? user.core?.screen_name;
if (!id || !username) {
return null;
}
const entities = user.legacy?.entities;
const location = user.legacy?.location ?? user.location?.location;
const createdAt = user.legacy?.created_at ?? user.core?.created_at;
const label = user.affiliates_highlighted_label?.label;
const affiliation =
label?.description || label?.url?.url || label?.badge?.url
? {
...(label.description ? { description: label.description } : {}),
...(label.url?.url ? { url: label.url.url } : {}),
...(label.badge?.url ? { badgeUrl: label.badge.url } : {}),
}
: undefined;
return {
id,
username,
name: user.legacy?.name ?? user.core?.name ?? username,
...(user.legacy?.description ? { description: user.legacy.description } : {}),
...(location ? { location } : {}),
...(firstExpandedUrl(entities) ? { url: firstExpandedUrl(entities) } : {}),
...(typeof user.is_blue_verified === 'boolean' || typeof user.legacy?.verified === 'boolean'
? { verified: Boolean(user.is_blue_verified || user.legacy?.verified) }
: {}),
...(user.legacy?.verified_type ? { verifiedType: user.legacy.verified_type } : {}),
...(typeof user.legacy?.followers_count === 'number' ? { followersCount: user.legacy.followers_count } : {}),
...(typeof user.legacy?.friends_count === 'number' ? { followingCount: user.legacy.friends_count } : {}),
...(user.avatar?.image_url || user.legacy?.profile_image_url_https
? { profileImageUrl: user.avatar?.image_url ?? user.legacy?.profile_image_url_https }
: {}),
...(createdAt ? { createdAt } : {}),
...(entities ? { entities } : {}),
...(affiliation ? { affiliation } : {}),
raw: user,
};
} }
export function withUserLookup<TBase extends AbstractConstructor<TwitterClientBase>>( export function withUserLookup<TBase extends AbstractConstructor<TwitterClientBase>>(
@@ -205,6 +324,259 @@ export function withUserLookup<TBase extends AbstractConstructor<TwitterClientBa
return { success: false, error: lastError ?? 'Unknown error looking up user' }; return { success: false, error: lastError ?? 'Unknown error looking up user' };
} }
private buildUserByScreenNameFeatures(): Record<string, boolean> {
return {
hidden_profile_subscriptions_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: false,
rweb_tipjar_consumption_enabled: false,
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,
};
}
private buildUserByRestIdFeatures(): Record<string, boolean> {
return {
hidden_profile_subscriptions_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: false,
rweb_tipjar_consumption_enabled: false,
verified_phone_label_enabled: false,
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,
};
}
private async getUserProfileByScreenName(screenName: string): Promise<UserProfileResult> {
const queryId = await this.getQueryId('UserByScreenName');
const variables = {
screen_name: screenName,
withGrokTranslatedBio: true,
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(this.buildUserByScreenNameFeatures()),
fieldToggles: JSON.stringify({
withPayments: false,
withAuxiliaryUserLabels: true,
}),
});
const response = await this.fetchWithTimeout(
`${TWITTER_API_BASE}/${queryId}/UserByScreenName?${params.toString()}`,
{
method: 'GET',
headers: this.getHeaders(),
},
);
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 {
data?: { user?: { result?: UserProfileGraphqlResult } };
errors?: Array<{ message?: string }>;
};
if (data.errors?.length) {
return { success: false, error: data.errors.map((error) => error.message ?? 'GraphQL error').join(', ') };
}
const result = data.data?.user?.result;
if (result?.__typename === 'UserUnavailable') {
return { success: false, error: `User @${screenName} not found or unavailable` };
}
const profile = result ? normalizeGraphqlUserResult(result) : null;
if (!profile) {
return { success: false, error: `Could not parse profile for @${screenName}` };
}
return { success: true, profile };
}
private async getUserProfileByRestId(userId: string): Promise<UserProfileResult> {
const queryId = await this.getQueryId('UserByRestId');
const params = new URLSearchParams({
variables: JSON.stringify({ userId }),
features: JSON.stringify(this.buildUserByRestIdFeatures()),
});
const response = await this.fetchWithTimeout(`${TWITTER_API_BASE}/${queryId}/UserByRestId?${params.toString()}`, {
method: 'GET',
headers: this.getHeaders(),
});
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 {
data?: { user?: { result?: UserProfileGraphqlResult } };
errors?: Array<{ message?: string }>;
};
if (data.errors?.length) {
return { success: false, error: data.errors.map((error) => error.message ?? 'GraphQL error').join(', ') };
}
const result = data.data?.user?.result;
if (result?.__typename === 'UserUnavailable') {
return { success: false, error: `User ${userId} not found or unavailable` };
}
const profile = result ? normalizeGraphqlUserResult(result) : null;
if (!profile) {
return { success: false, error: `Could not parse profile for ${userId}` };
}
return { success: true, profile };
}
async getUserProfile(target: string): Promise<UserProfileResult> {
const trimmed = target.trim();
if (!trimmed) {
return { success: false, error: 'User target is required' };
}
if (isNumericUserId(trimmed)) {
return this.getUserProfileByRestId(trimmed);
}
const cleanUsername = normalizeHandle(trimmed);
if (!cleanUsername) {
return { success: false, error: `Invalid username: ${target}` };
}
return this.getUserProfileByScreenName(cleanUsername);
}
async getUserProfiles(targets: string[]): Promise<UserProfilesResult> {
const results: UserProfilesResult['results'] = [];
const seen = new Set<string>();
for (const target of targets) {
const trimmed = target.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
const result = await this.getUserProfile(trimmed);
if (result.success) {
results.push({ target: trimmed, profile: result.profile });
} else {
results.push({ target: trimmed, error: result.error });
}
}
return { success: true, results };
}
private async getAboutAccountQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('AboutAccountQuery');
return Array.from(new Set([primary, 'zs_jFPFT78rBpXv9Z3U2YQ']));
}
/**
* Get account origin and location information for a user.
* Returns data from Twitter's "About this account" feature.
*/
async getUserAboutAccount(username: string): Promise<AboutAccountResult> {
const cleanUsername = normalizeHandle(username);
if (!cleanUsername) {
return { success: false, error: `Invalid username: ${username}` };
}
const variables = {
screenName: cleanUsername,
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
});
const tryOnce = async () => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getAboutAccountQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/AboutAccountQuery?${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) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
continue;
}
const data = (await response.json()) as {
data?: {
user_result_by_screen_name?: {
result?: {
about_profile?: {
account_based_in?: string;
source?: string;
created_country_accurate?: boolean;
location_accurate?: boolean;
learn_more_url?: string;
};
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
lastError = data.errors.map((e) => e.message).join(', ');
continue;
}
const aboutProfile = data.data?.user_result_by_screen_name?.result?.about_profile;
if (!aboutProfile) {
lastError = 'Missing about_profile in response';
continue;
}
return {
success: true as const,
aboutProfile: {
accountBasedIn: aboutProfile.account_based_in,
source: aboutProfile.source,
createdCountryAccurate: aboutProfile.created_country_accurate,
locationAccurate: aboutProfile.location_accurate,
learnMoreUrl: aboutProfile.learn_more_url,
},
had404,
};
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return {
success: false as const,
error: lastError ?? 'Unknown error fetching account details',
had404,
};
};
const { result } = await this.withRefreshedQueryIdsOn404(tryOnce);
if (result.success) {
return { success: true, aboutProfile: result.aboutProfile };
}
return { success: false, error: result.error };
}
} }
return TwitterClientUserLookup; return TwitterClientUserLookup;
+41 -90
View File
@@ -1,10 +1,5 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js'; import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { import { TWITTER_API_BASE } from './twitter-client-constants.js';
SETTINGS_NAME_REGEX,
SETTINGS_SCREEN_NAME_REGEX,
SETTINGS_USER_ID_REGEX,
TWITTER_API_BASE,
} from './twitter-client-constants.js';
import { buildFollowingFeatures } from './twitter-client-features.js'; import { buildFollowingFeatures } from './twitter-client-features.js';
import type { CurrentUserResult, FollowingResult, TwitterUser } from './twitter-client-types.js'; import type { CurrentUserResult, FollowingResult, TwitterUser } from './twitter-client-types.js';
import { extractCursorFromInstructions, parseUsersFromInstructions } from './twitter-client-utils.js'; import { extractCursorFromInstructions, parseUsersFromInstructions } from './twitter-client-utils.js';
@@ -70,7 +65,7 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
.filter((u) => u !== null); .filter((u) => u !== null);
} }
private async getFollowersViaRest(userId: string, count: number): Promise<FollowingResult> { private async getFollowersViaRest(userId: string, count: number, cursor?: string): Promise<FollowingResult> {
const params = new URLSearchParams({ const params = new URLSearchParams({
user_id: userId, user_id: userId,
count: String(count), count: String(count),
@@ -78,6 +73,10 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
include_user_entities: 'false', include_user_entities: 'false',
}); });
if (cursor) {
params.set('cursor', cursor);
}
const urls = [ const urls = [
`https://x.com/i/api/1.1/followers/list.json?${params.toString()}`, `https://x.com/i/api/1.1/followers/list.json?${params.toString()}`,
`https://api.twitter.com/1.1/followers/list.json?${params.toString()}`, `https://api.twitter.com/1.1/followers/list.json?${params.toString()}`,
@@ -100,11 +99,13 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
const data = (await response.json()) as { const data = (await response.json()) as {
users?: RestUser[]; users?: RestUser[];
next_cursor_str?: string;
}; };
const users = this.parseUsersFromRestResponse(data.users); const users = this.parseUsersFromRestResponse(data.users);
const nextCursor = data.next_cursor_str && data.next_cursor_str !== '0' ? data.next_cursor_str : undefined;
return { success: true, users }; return { success: true, users, nextCursor };
} catch (error) { } catch (error) {
lastError = error instanceof Error ? error.message : String(error); lastError = error instanceof Error ? error.message : String(error);
} }
@@ -113,7 +114,7 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
return { success: false, error: lastError ?? 'Unknown error fetching followers' }; return { success: false, error: lastError ?? 'Unknown error fetching followers' };
} }
private async getFollowingViaRest(userId: string, count: number): Promise<FollowingResult> { private async getFollowingViaRest(userId: string, count: number, cursor?: string): Promise<FollowingResult> {
const params = new URLSearchParams({ const params = new URLSearchParams({
user_id: userId, user_id: userId,
count: String(count), count: String(count),
@@ -121,6 +122,10 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
include_user_entities: 'false', include_user_entities: 'false',
}); });
if (cursor) {
params.set('cursor', cursor);
}
const urls = [ const urls = [
`https://x.com/i/api/1.1/friends/list.json?${params.toString()}`, `https://x.com/i/api/1.1/friends/list.json?${params.toString()}`,
`https://api.twitter.com/1.1/friends/list.json?${params.toString()}`, `https://api.twitter.com/1.1/friends/list.json?${params.toString()}`,
@@ -143,11 +148,13 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
const data = (await response.json()) as { const data = (await response.json()) as {
users?: RestUser[]; users?: RestUser[];
next_cursor_str?: string;
}; };
const users = this.parseUsersFromRestResponse(data.users); const users = this.parseUsersFromRestResponse(data.users);
const nextCursor = data.next_cursor_str && data.next_cursor_str !== '0' ? data.next_cursor_str : undefined;
return { success: true, users }; return { success: true, users, nextCursor };
} catch (error) { } catch (error) {
lastError = error instanceof Error ? error.message : String(error); lastError = error instanceof Error ? error.message : String(error);
} }
@@ -157,7 +164,7 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
} }
/** /**
* Fetch the account associated with the current cookies * Fetch the account associated with the configured relay.
*/ */
async getCurrentUser(): Promise<CurrentUserResult> { async getCurrentUser(): Promise<CurrentUserResult> {
const candidateUrls = [ const candidateUrls = [
@@ -210,11 +217,15 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
? data.user_id ? data.user_id
: typeof data?.user_id_str === 'string' : typeof data?.user_id_str === 'string'
? data.user_id_str ? data.user_id_str
: typeof data?.user?.id_str === 'string' : typeof data?.id_str === 'string'
? data.user.id_str ? data.id_str
: typeof data?.user?.id === 'string' : typeof data?.id === 'string'
? data.user.id ? data.id
: null; : typeof data?.user?.id_str === 'string'
? data.user.id_str
: typeof data?.user?.id === 'string'
? data.user.id
: null;
if (username && userId) { if (username && userId) {
this.clientUserId = userId; this.clientUserId = userId;
@@ -234,48 +245,6 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
} }
} }
// Fallback: scrape the authenticated settings page (HTML) for screen_name/user_id
const profilePages = ['https://x.com/settings/account', 'https://twitter.com/settings/account'];
for (const page of profilePages) {
try {
const response = await this.fetchWithTimeout(page, {
headers: {
cookie: this.cookieHeader,
'user-agent': this.userAgent,
},
});
if (!response.ok) {
lastError = `HTTP ${response.status} (settings page)`;
continue;
}
const html = await response.text();
const usernameMatch = SETTINGS_SCREEN_NAME_REGEX.exec(html);
const idMatch = SETTINGS_USER_ID_REGEX.exec(html);
const nameMatch = SETTINGS_NAME_REGEX.exec(html);
const username = usernameMatch?.[1];
const userId = idMatch?.[1];
const name = nameMatch?.[1]?.replace(/\\"/g, '"');
if (username && userId) {
return {
success: true,
user: {
id: userId,
username,
name: name || username,
},
};
}
lastError = 'Could not parse settings page for user info';
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { return {
success: false, success: false,
error: lastError ?? 'Unknown error fetching current user', error: lastError ?? 'Unknown error fetching current user',
@@ -362,30 +331,21 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
return { success: false as const, error: lastError ?? 'Unknown error fetching following', had404 }; return { success: false as const, error: lastError ?? 'Unknown error fetching following', had404 };
}; };
const firstAttempt = await tryOnce(); const { result, refreshed } = await this.withRefreshedQueryIdsOn404(tryOnce);
if (firstAttempt.success) { if (result.success) {
return { success: true, users: firstAttempt.users, nextCursor: firstAttempt.nextCursor }; return { success: true, users: result.users, nextCursor: result.nextCursor };
} }
if (firstAttempt.had404) { if (refreshed) {
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). // GraphQL Following can also return 404 (queryId churn / endpoint flakiness).
// Fallback to the internal v1.1 REST endpoint used by the web client (cookie-auth; no dev API key). // 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, cursor);
const restAttempt = await this.getFollowingViaRest(userId, count);
if (restAttempt.success) { if (restAttempt.success) {
return restAttempt; return restAttempt;
} }
return { success: false, error: secondAttempt.error };
} }
return { success: false, error: firstAttempt.error }; return { success: false, error: result.error };
} }
/** /**
@@ -468,30 +428,21 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
return { success: false as const, error: lastError ?? 'Unknown error fetching followers', had404 }; return { success: false as const, error: lastError ?? 'Unknown error fetching followers', had404 };
}; };
const firstAttempt = await tryOnce(); const { result, refreshed } = await this.withRefreshedQueryIdsOn404(tryOnce);
if (firstAttempt.success) { if (result.success) {
return { success: true, users: firstAttempt.users, nextCursor: firstAttempt.nextCursor }; return { success: true, users: result.users, nextCursor: result.nextCursor };
} }
if (firstAttempt.had404) { if (refreshed) {
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). // GraphQL Followers regularly returns 404 (queryId churn / endpoint flakiness).
// Fallback to the internal v1.1 REST endpoint used by the web client (cookie-auth; no dev API key). // 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, cursor);
const restAttempt = await this.getFollowersViaRest(userId, count);
if (restAttempt.success) { if (restAttempt.success) {
return restAttempt; return restAttempt;
} }
return { success: false, error: secondAttempt.error };
} }
return { success: false, error: firstAttempt.error }; return { success: false, error: result.error };
} }
} }
+1
View File
@@ -566,6 +566,7 @@ export function mapTweetResult(
author: { author: {
username, username,
name: name || username, name: name || username,
profileImageUrl: userLegacy?.profile_image_url_https ?? userResult?.avatar?.image_url,
}, },
authorId: userId, authorId: userId,
quotedTweet, quotedTweet,
+14 -1
View File
@@ -1,6 +1,8 @@
import type { AbstractConstructor } from './twitter-client-base.js'; import type { AbstractConstructor } from './twitter-client-base.js';
import { TwitterClientBase } from './twitter-client-base.js'; import { TwitterClientBase } from './twitter-client-base.js';
import { type TwitterClientBookmarkMethods, withBookmarks } from './twitter-client-bookmarks.js'; import { type TwitterClientBookmarkMethods, withBookmarks } from './twitter-client-bookmarks.js';
import { type TwitterClientEngagementMethods, withEngagement } from './twitter-client-engagement.js';
import { type TwitterClientFollowMethods, withFollow } from './twitter-client-follow.js';
import { type TwitterClientHomeMethods, withHome } from './twitter-client-home.js'; import { type TwitterClientHomeMethods, withHome } from './twitter-client-home.js';
import { type TwitterClientListMethods, withLists } from './twitter-client-lists.js'; import { type TwitterClientListMethods, withLists } from './twitter-client-lists.js';
import { type TwitterClientMediaMethods, withMedia } from './twitter-client-media.js'; import { type TwitterClientMediaMethods, withMedia } from './twitter-client-media.js';
@@ -15,6 +17,8 @@ import { type TwitterClientUserMethods, withUsers } from './twitter-client-users
type TwitterClientInstance = TwitterClientBase & type TwitterClientInstance = TwitterClientBase &
TwitterClientBookmarkMethods & TwitterClientBookmarkMethods &
TwitterClientEngagementMethods &
TwitterClientFollowMethods &
TwitterClientHomeMethods & TwitterClientHomeMethods &
TwitterClientListMethods & TwitterClientListMethods &
TwitterClientMediaMethods & TwitterClientMediaMethods &
@@ -28,13 +32,18 @@ type TwitterClientInstance = TwitterClientBase &
TwitterClientUserTweetsMethods; TwitterClientUserTweetsMethods;
// News mixin wraps search because it depends on the search() method // News mixin wraps search because it depends on the search() method
// Engagement mixin adds like/unlike/retweet/unretweet/bookmark methods
const MixedTwitterClient = withNews( const MixedTwitterClient = withNews(
withUserTweets( withUserTweets(
withUserLookup( withUserLookup(
withUsers( withUsers(
withLists( withLists(
withHome( withHome(
withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase)))))), withTimelines(
withSearch(
withTweetDetails(withPosting(withEngagement(withFollow(withBookmarks(withMedia(TwitterClientBase)))))),
),
),
), ),
), ),
), ),
@@ -49,6 +58,7 @@ export type {
BookmarkMutationResult, BookmarkMutationResult,
CurrentUserResult, CurrentUserResult,
FollowingResult, FollowingResult,
FollowMutationResult,
GetTweetResult, GetTweetResult,
ListsResult, ListsResult,
SearchResult, SearchResult,
@@ -57,5 +67,8 @@ export type {
TwitterClientOptions, TwitterClientOptions,
TwitterList, TwitterList,
TwitterUser, TwitterUser,
TwitterUserProfile,
UploadMediaResult, UploadMediaResult,
UserProfileResult,
UserProfilesResult,
} from './twitter-client-types.js'; } from './twitter-client-types.js';
+7
View File
@@ -14,6 +14,8 @@ describe('cli-args', () => {
'bookmarks', 'bookmarks',
'following', 'following',
'followers', 'followers',
'user',
'profiles',
'likes', 'likes',
'help', 'help',
'whoami', 'whoami',
@@ -58,6 +60,11 @@ describe('cli-args', () => {
expect(result.argv).toBeNull(); expect(result.argv).toBeNull();
}); });
it('does not rewrite numeric profile targets when profiles command is provided', () => {
const result = resolveCliInvocation(['profiles', '@steipete', '25401953', '--json'], known);
expect(result.argv).toBeNull();
});
it('does not rewrite unknown commands', () => { it('does not rewrite unknown commands', () => {
const result = resolveCliInvocation(['https://example.com'], known); const result = resolveCliInvocation(['https://example.com'], known);
expect(result.argv).toBeNull(); expect(result.argv).toBeNull();
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { createCliContext } from '../src/cli/shared.js';
describe('createCliContext', () => {
it('resolves profileName from CLI options before env', () => {
const ctx = createCliContext([], { BIRD_PROFILE_NAME: 'env-profile' } as NodeJS.ProcessEnv);
expect(ctx.resolveProfileNameFromOptions({ profileName: 'cli-profile' })).toBe('cli-profile');
});
it('resolves profileName from env when CLI option is absent', () => {
const ctx = createCliContext([], { BIRD_PROFILE_NAME: 'env-profile' } as NodeJS.ProcessEnv);
expect(ctx.resolveProfileNameFromOptions({})).toBe('env-profile');
});
});
+2 -4
View File
@@ -8,10 +8,8 @@ describe('bookmarks command', () => {
const program = new Command(); const program = new Command();
const ctx = { const ctx = {
resolveTimeoutFromOptions: () => undefined, resolveTimeoutFromOptions: () => undefined,
resolveCredentialsFromOptions: async () => ({ resolveRelayBaseUrlFromOptions: () => 'https://relay.example',
cookies: { authToken: 'auth', ct0: 'ct0', cookieHeader: 'auth=auth; ct0=ct0' }, resolveProfileNameFromOptions: () => undefined,
warnings: [],
}),
p: () => '', p: () => '',
printTweets: () => undefined, printTweets: () => undefined,
} as unknown as CliContext; } as unknown as CliContext;
+52
View File
@@ -0,0 +1,52 @@
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { CliContext } from '../src/cli/shared.js';
import { registerFollowCommands } from '../src/commands/follow.js';
import { TwitterClient } from '../src/lib/twitter-client.js';
const baseCtx = {
resolveTimeoutFromOptions: () => undefined,
resolveRelayBaseUrlFromOptions: () => 'http://relay.test',
resolveProfileNameFromOptions: () => undefined,
p: () => '',
} as unknown as CliContext;
afterEach(() => {
vi.restoreAllMocks();
});
describe('follow commands', () => {
it('prefers username lookup for numeric handles', async () => {
const program = new Command();
registerFollowCommands(program, baseCtx);
const lookupSpy = vi.spyOn(TwitterClient.prototype, 'getUserIdByUsername').mockResolvedValue({
success: true,
userId: '999',
username: '12345',
});
const followSpy = vi.spyOn(TwitterClient.prototype, 'follow').mockResolvedValue({ success: true });
vi.spyOn(console, 'log').mockImplementation(() => undefined);
await program.parseAsync(['node', 'bird', 'follow', '12345']);
expect(lookupSpy).toHaveBeenCalledWith('12345');
expect(followSpy).toHaveBeenCalledWith('999');
});
it('falls back to numeric user IDs when lookup fails', async () => {
const program = new Command();
registerFollowCommands(program, baseCtx);
vi.spyOn(TwitterClient.prototype, 'getUserIdByUsername').mockResolvedValue({
success: false,
error: 'User not found',
});
const followSpy = vi.spyOn(TwitterClient.prototype, 'follow').mockResolvedValue({ success: true });
vi.spyOn(console, 'log').mockImplementation(() => undefined);
await program.parseAsync(['node', 'bird', 'follow', '12345']);
expect(followSpy).toHaveBeenCalledWith('12345');
});
});
+61
View File
@@ -0,0 +1,61 @@
import { Command } from 'commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CliContext } from '../src/cli/shared.js';
import { registerHomeCommand } from '../src/commands/home.js';
import { TwitterClient } from '../src/lib/twitter-client.js';
describe('home command', () => {
let program: Command;
let mockContext: Partial<CliContext>;
beforeEach(() => {
program = new Command();
mockContext = {
resolveTimeoutFromOptions: () => undefined,
resolveRelayBaseUrlFromOptions: () => 'https://relay.example',
resolveProfileNameFromOptions: () => undefined,
p: () => '',
printTweetsResult: vi.fn(),
};
});
it('uses cursor pagination when --cursor is set', async () => {
registerHomeCommand(program, mockContext as CliContext);
const timelineSpy = vi
.spyOn(TwitterClient.prototype, 'getHomeLatestTimeline')
.mockResolvedValue({ success: true, tweets: [], nextCursor: 'next-1' });
try {
await program.parseAsync(['node', 'bird', 'home', '--following', '--cursor', 'cursor-1']);
expect(timelineSpy).toHaveBeenCalledWith(20, expect.objectContaining({ cursor: 'cursor-1' }));
expect(mockContext.printTweetsResult).toHaveBeenCalledWith(expect.objectContaining({ nextCursor: 'next-1' }), {
json: false,
usePagination: true,
emptyMessage: 'No tweets found in Following timeline.',
});
} finally {
timelineSpy.mockRestore();
}
});
it('uses paged home timeline when --max-pages is set', async () => {
registerHomeCommand(program, mockContext as CliContext);
const timelineSpy = vi.spyOn(TwitterClient.prototype, 'getHomeTimeline').mockResolvedValue({
success: true,
tweets: [],
nextCursor: 'next-2',
});
try {
await program.parseAsync(['node', 'bird', 'home', '--max-pages', '3', '--json']);
expect(timelineSpy).toHaveBeenCalledWith(20, expect.objectContaining({ maxPages: 3 }));
expect(mockContext.printTweetsResult).toHaveBeenCalledWith(expect.objectContaining({ nextCursor: 'next-2' }), {
json: true,
usePagination: true,
emptyMessage: 'No tweets found in For You timeline.',
});
} finally {
timelineSpy.mockRestore();
}
});
});
+2 -33
View File
@@ -13,14 +13,8 @@ describe('news command', () => {
mockContext = { mockContext = {
resolveTimeoutFromOptions: () => 30000, resolveTimeoutFromOptions: () => 30000,
resolveQuoteDepthFromOptions: () => undefined, resolveQuoteDepthFromOptions: () => undefined,
resolveCredentialsFromOptions: async () => ({ resolveRelayBaseUrlFromOptions: () => 'https://relay.example',
cookies: { resolveProfileNameFromOptions: () => undefined,
authToken: 'auth',
ct0: 'ct0',
cookieHeader: 'auth=auth; ct0=ct0',
},
warnings: [],
}),
p: (type: string) => `[${type}] `, p: (type: string) => `[${type}] `,
colors: { colors: {
accent: (text: string) => text, accent: (text: string) => text,
@@ -128,31 +122,6 @@ describe('news command', () => {
} }
}); });
it('requires both authToken and ct0 credentials', async () => {
mockContext.resolveCredentialsFromOptions = async () => ({
cookies: {
authToken: '',
ct0: '',
cookieHeader: '',
},
warnings: [],
});
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'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Missing required credentials'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it('limits related tweets to tweets-per-item in CLI output', async () => { it('limits related tweets to tweets-per-item in CLI output', async () => {
registerNewsCommand(program, mockContext as CliContext); registerNewsCommand(program, mockContext as CliContext);
const getNewsSpy = vi.spyOn(TwitterClient.prototype, 'getNews').mockResolvedValue({ const getNewsSpy = vi.spyOn(TwitterClient.prototype, 'getNews').mockResolvedValue({
+36 -8
View File
@@ -9,11 +9,9 @@ describe('replies command', () => {
({ ({
resolveTimeoutFromOptions: () => undefined, resolveTimeoutFromOptions: () => undefined,
resolveQuoteDepthFromOptions: () => 1, resolveQuoteDepthFromOptions: () => 1,
resolveProfileNameFromOptions: () => undefined,
extractTweetId: (input: string) => input, extractTweetId: (input: string) => input,
resolveCredentialsFromOptions: async () => ({ resolveRelayBaseUrlFromOptions: () => 'https://relay.example',
cookies: { authToken: 'auth', ct0: 'ct0', cookieHeader: 'auth=auth; ct0=ct0' },
warnings: [],
}),
p: () => '', p: () => '',
printTweets: () => undefined, printTweets: () => undefined,
printTweetsResult: () => undefined, printTweetsResult: () => undefined,
@@ -77,6 +75,38 @@ describe('replies command', () => {
errorSpy.mockRestore(); errorSpy.mockRestore();
} }
}); });
it('passes profileName to TwitterClient', async () => {
const program = new Command();
registerReadCommands(program, {
...createMockContext(),
resolveProfileNameFromOptions: () => 'work',
});
const getTweetSpy = vi.spyOn(TwitterClient.prototype, 'getTweet').mockImplementation(async function () {
const headers = (this as unknown as { getHeaders: () => Record<string, string> }).getHeaders();
expect(headers).toEqual({ 'x-profile-name': 'work' });
return {
success: true,
tweet: {
id: '123',
text: 'hello',
author: { username: 'tester', name: 'Tester' },
createdAt: '2024-01-01T00:00:00Z',
url: 'https://x.com/tester/status/123',
},
};
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
try {
await program.parseAsync(['node', 'bird', 'read', '123', '--json']);
expect(getTweetSpy).toHaveBeenCalledTimes(1);
} finally {
getTweetSpy.mockRestore();
logSpy.mockRestore();
}
});
}); });
describe('thread command', () => { describe('thread command', () => {
@@ -84,11 +114,9 @@ describe('thread command', () => {
({ ({
resolveTimeoutFromOptions: () => undefined, resolveTimeoutFromOptions: () => undefined,
resolveQuoteDepthFromOptions: () => 1, resolveQuoteDepthFromOptions: () => 1,
resolveProfileNameFromOptions: () => undefined,
extractTweetId: (input: string) => input, extractTweetId: (input: string) => input,
resolveCredentialsFromOptions: async () => ({ resolveRelayBaseUrlFromOptions: () => 'https://relay.example',
cookies: { authToken: 'auth', ct0: 'ct0', cookieHeader: 'auth=auth; ct0=ct0' },
warnings: [],
}),
p: () => '', p: () => '',
printTweets: () => undefined, printTweets: () => undefined,
printTweetsResult: () => undefined, printTweetsResult: () => undefined,
+28 -6
View File
@@ -13,10 +13,8 @@ describe('search command', () => {
mockContext = { mockContext = {
resolveTimeoutFromOptions: () => undefined, resolveTimeoutFromOptions: () => undefined,
resolveQuoteDepthFromOptions: () => undefined, resolveQuoteDepthFromOptions: () => undefined,
resolveCredentialsFromOptions: async () => ({ resolveRelayBaseUrlFromOptions: () => 'https://relay.example',
cookies: { authToken: 'auth', ct0: 'ct0', cookieHeader: 'auth=auth; ct0=ct0' }, resolveProfileNameFromOptions: () => undefined,
warnings: [],
}),
p: () => '', p: () => '',
printTweetsResult: vi.fn(), printTweetsResult: vi.fn(),
}; };
@@ -84,8 +82,8 @@ describe('search command', () => {
const searchSpy = vi.spyOn(TwitterClient.prototype, 'search').mockResolvedValue({ success: true, tweets: [] }); const searchSpy = vi.spyOn(TwitterClient.prototype, 'search').mockResolvedValue({ success: true, tweets: [] });
try { try {
await program.parseAsync(['node', 'bird', 'search', 'cats', '--all', '--json']); await program.parseAsync(['node', 'bird', 'search', 'cats', '--all', '--json', '--product', 'Top']);
expect(getAllSpy).toHaveBeenCalledWith('cats', expect.objectContaining({ includeRaw: false })); expect(getAllSpy).toHaveBeenCalledWith('cats', expect.objectContaining({ includeRaw: false, product: 'Top' }));
expect(searchSpy).not.toHaveBeenCalled(); expect(searchSpy).not.toHaveBeenCalled();
expect(mockContext.printTweetsResult).toHaveBeenCalledWith(expect.objectContaining({ tweets: [] }), { expect(mockContext.printTweetsResult).toHaveBeenCalledWith(expect.objectContaining({ tweets: [] }), {
json: true, json: true,
@@ -98,6 +96,30 @@ describe('search command', () => {
} }
}); });
it('passes --product to non-paged search', async () => {
registerSearchCommands(program, mockContext as CliContext);
const searchSpy = vi.spyOn(TwitterClient.prototype, 'search').mockResolvedValue({ success: true, tweets: [] });
try {
await program.parseAsync(['node', 'bird', 'search', 'cats', '--product', 'Top']);
expect(searchSpy).toHaveBeenCalledWith('cats', 10, {
includeRaw: false,
product: 'Top',
});
} finally {
searchSpy.mockRestore();
}
});
it('rejects unsupported search products', async () => {
program.exitOverride();
registerSearchCommands(program, mockContext as CliContext);
await expect(program.parseAsync(['node', 'bird', 'search', 'cats', '--product', 'Media'])).rejects.toMatchObject({
code: 'commander.invalidArgument',
});
});
it('uses cursor pagination when --cursor is set', async () => { it('uses cursor pagination when --cursor is set', async () => {
registerSearchCommands(program, mockContext as CliContext); registerSearchCommands(program, mockContext as CliContext);
const getAllSpy = vi const getAllSpy = vi
+70
View File
@@ -0,0 +1,70 @@
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: () => 'http://relay.test',
resolveProfileNameFromOptions: () => undefined,
p: () => '',
l: () => '',
printTweets: () => undefined,
} as unknown as CliContext;
afterEach(() => {
vi.restoreAllMocks();
});
describe('users about command', () => {
it('prints account info JSON for about command', async () => {
const program = new Command();
registerUserCommands(program, baseCtx);
vi.spyOn(TwitterClient.prototype, 'getUserAboutAccount').mockResolvedValue({
success: true,
aboutProfile: {
accountBasedIn: 'Spain',
source: 'Spain App Store',
createdCountryAccurate: true,
locationAccurate: false,
learnMoreUrl: 'https://help.twitter.com/profile',
},
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
await program.parseAsync(['node', 'bird', 'about', 'pablotovar_', '--json']);
const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0]));
expect(payload).toEqual({
accountBasedIn: 'Spain',
source: 'Spain App Store',
createdCountryAccurate: true,
locationAccurate: false,
learnMoreUrl: 'https://help.twitter.com/profile',
});
});
it('exits on about command failure', 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);
vi.spyOn(TwitterClient.prototype, 'getUserAboutAccount').mockResolvedValue({
success: false,
error: 'Missing about_profile in response',
});
try {
await expect(program.parseAsync(['node', 'bird', 'about', 'pablotovar__'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to fetch account information: Missing about_profile in response'),
);
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
});
+42 -4
View File
@@ -6,10 +6,8 @@ import { TwitterClient } from '../src/lib/twitter-client.js';
const baseCtx = { const baseCtx = {
resolveTimeoutFromOptions: () => undefined, resolveTimeoutFromOptions: () => undefined,
resolveCredentialsFromOptions: async () => ({ resolveRelayBaseUrlFromOptions: () => 'https://relay.example',
cookies: { authToken: 'auth', ct0: 'ct0', cookieHeader: 'auth=auth; ct0=ct0' }, resolveProfileNameFromOptions: () => undefined,
warnings: [],
}),
p: () => '', p: () => '',
printTweets: () => undefined, printTweets: () => undefined,
} as unknown as CliContext; } as unknown as CliContext;
@@ -211,4 +209,44 @@ describe('users commands', () => {
expect(payload.nextCursor).toBeNull(); expect(payload.nextCursor).toBeNull();
expect(followingSpy).toHaveBeenCalledTimes(2); expect(followingSpy).toHaveBeenCalledTimes(2);
}); });
it('prints a single profile as JSON', async () => {
const program = new Command();
registerUserCommands(program, baseCtx);
const profileSpy = vi.spyOn(TwitterClient.prototype, 'getUserProfile').mockResolvedValue({
success: true,
profile: { id: '1', username: 'alpha', name: 'Alpha' },
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
await program.parseAsync(['node', 'bird', 'user', '@alpha', '--json']);
expect(profileSpy).toHaveBeenCalledWith('@alpha');
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({
id: '1',
username: 'alpha',
name: 'Alpha',
});
});
it('prints batch profiles with per-target errors as JSON', async () => {
const program = new Command();
registerUserCommands(program, baseCtx);
const profilesSpy = vi.spyOn(TwitterClient.prototype, 'getUserProfiles').mockResolvedValue({
success: true,
results: [
{ target: 'alpha', profile: { id: '1', username: 'alpha', name: 'Alpha' } },
{ target: 'missing', error: 'not found' },
],
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
await program.parseAsync(['node', 'bird', 'profiles', 'alpha', 'missing', '--json']);
expect(profilesSpy).toHaveBeenCalledWith(['alpha', 'missing']);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual({
users: [{ id: '1', username: 'alpha', name: 'Alpha' }],
errors: [{ target: 'missing', error: 'not found' }],
});
});
}); });
-381
View File
@@ -1,381 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
type SweetCookieResult = { cookies: Array<{ name: string; value: string; domain?: string }>; warnings: string[] };
const sweet = vi.hoisted(() => ({
results: new Map<string, SweetCookieResult>(),
options: new Map<string, { timeoutMs?: number }>(),
}));
vi.mock('@steipete/sweet-cookie', () => ({
getCookies: vi.fn(async (options: { browsers?: string[]; timeoutMs?: number }) => {
const browser = options.browsers?.[0] ?? 'unknown';
sweet.options.set(browser, options);
return (
sweet.results.get(browser) ?? {
cookies: [],
warnings: [],
}
);
}),
}));
describe('cookies', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
sweet.results.clear();
sweet.options.clear();
process.env = { ...originalEnv };
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('honors cookieSource=firefox even when Safari has cookies', async () => {
sweet.results.set('safari', {
cookies: [
{ name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
{ name: 'ct0', value: 'safari_ct0', domain: 'x.com' },
],
warnings: [],
});
sweet.results.set('firefox', {
cookies: [
{ name: 'auth_token', value: 'firefox_auth', domain: 'x.com' },
{ name: 'ct0', value: 'firefox_ct0', domain: 'x.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ cookieSource: 'firefox' });
expect(result.cookies.authToken).toBe('firefox_auth');
expect(result.cookies.ct0).toBe('firefox_ct0');
expect(result.cookies.source).toContain('Firefox');
});
it('honors cookieSource=safari', async () => {
if (process.platform !== 'darwin') {
return;
}
sweet.results.set('safari', {
cookies: [
{ name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
{ name: 'ct0', value: 'safari_ct0', domain: 'x.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ cookieSource: 'safari' });
expect(result.cookies.authToken).toBe('safari_auth');
expect(result.cookies.ct0).toBe('safari_ct0');
expect(result.cookies.cookieHeader).toContain('auth_token=safari_auth');
expect(result.cookies.cookieHeader).toContain('ct0=safari_ct0');
expect(result.cookies.source).toBe('Safari');
});
it('uses firefox when enabled and returns cookies', async () => {
sweet.results.set('firefox', {
cookies: [
{ name: 'auth_token', value: 'firefox_auth', domain: 'x.com' },
{ name: 'ct0', value: 'firefox_ct0', domain: 'x.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ cookieSource: 'firefox', firefoxProfile: 'abc.default-release' });
expect(result.cookies.authToken).toBe('firefox_auth');
expect(result.cookies.ct0).toBe('firefox_ct0');
expect(result.cookies.cookieHeader).toContain('auth_token=firefox_auth');
expect(result.cookies.cookieHeader).toContain('ct0=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.cookieHeader).toBe('auth_token=cli_auth; ct0=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({ cookieSource: 'safari' });
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({ cookieSource: 'safari' });
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({ cookieSource: 'safari' });
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({ cookieSource: 'safari' });
expect(result.warnings).toContain(
'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Safari/Chrome/Firefox',
);
expect(result.warnings).toContain(
'Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Safari/Chrome/Firefox',
);
});
it('falls back to Chrome when enabled and Firefox disabled', async () => {
sweet.results.set('chrome', {
cookies: [
{ name: 'auth_token', value: 'test_auth', domain: 'x.com' },
{ name: 'ct0', value: 'test_ct0', domain: 'x.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({
cookieSource: 'chrome',
chromeProfile: 'Default',
cookieTimeoutMs: 15000,
});
expect(result.cookies.authToken).toBe('test_auth');
expect(result.cookies.ct0).toBe('test_ct0');
expect(result.cookies.source).toContain('Chrome');
expect(sweet.options.get('chrome')?.timeoutMs).toBe(15000);
});
it('defaults to a 30s cookie timeout on macOS', async () => {
if (process.platform !== 'darwin') {
return;
}
sweet.results.set('chrome', {
cookies: [
{ name: 'auth_token', value: 'test_auth', domain: 'x.com' },
{ name: 'ct0', value: 'test_ct0', domain: 'x.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ cookieSource: 'chrome' });
expect(result.cookies.authToken).toBe('test_auth');
expect(result.cookies.ct0).toBe('test_ct0');
expect(sweet.options.get('chrome')?.timeoutMs).toBe(30000);
});
it('uses default browser order when cookieSource is omitted', async () => {
sweet.results.set('safari', { cookies: [], warnings: [] });
sweet.results.set('chrome', { cookies: [], warnings: [] });
sweet.results.set('firefox', {
cookies: [
{ name: 'auth_token', value: 'firefox_auth', domain: 'x.com' },
{ name: 'ct0', value: 'firefox_ct0', domain: 'x.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({});
expect(result.cookies.authToken).toBe('firefox_auth');
expect(result.cookies.ct0).toBe('firefox_ct0');
expect(result.cookies.source).toContain('Firefox');
});
it('prefers twitter.com cookies when x.com is missing', async () => {
sweet.results.set('chrome', {
cookies: [
{ name: 'auth_token', value: 'twitter_auth', domain: 'twitter.com' },
{ name: 'ct0', value: 'twitter_ct0', domain: 'twitter.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ cookieSource: 'chrome' });
expect(result.cookies.authToken).toBe('twitter_auth');
expect(result.cookies.ct0).toBe('twitter_ct0');
});
it('falls back to the first cookie when no domain matches', async () => {
sweet.results.set('firefox', {
cookies: [
{ name: 'auth_token', value: 'first_auth', domain: 'example.com' },
{ name: 'ct0', value: 'first_ct0', domain: 'example.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ cookieSource: 'firefox' });
expect(result.cookies.authToken).toBe('first_auth');
expect(result.cookies.ct0).toBe('first_ct0');
});
});
describe('extractCookiesFromSafari', () => {
it('returns cookies from Safari', async () => {
if (process.platform !== 'darwin') {
return;
}
sweet.results.set('safari', {
cookies: [
{ name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
{ name: 'ct0', value: 'safari_ct0', domain: 'x.com' },
],
warnings: [],
});
const { extractCookiesFromSafari } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromSafari();
expect(result.cookies.authToken).toBe('safari_auth');
expect(result.cookies.ct0).toBe('safari_ct0');
expect(result.cookies.source).toBe('Safari');
});
it('prefers Safari over Chrome when both are available', async () => {
if (process.platform !== 'darwin') {
return;
}
sweet.results.set('safari', {
cookies: [
{ name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
{ name: 'ct0', value: 'safari_ct0', domain: 'x.com' },
],
warnings: [],
});
sweet.results.set('chrome', {
cookies: [
{ name: 'auth_token', value: 'chrome_auth', domain: 'x.com' },
{ name: 'ct0', value: 'chrome_ct0', domain: 'x.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ cookieSource: ['safari', 'chrome'] });
expect(result.cookies.authToken).toBe('safari_auth');
expect(result.cookies.ct0).toBe('safari_ct0');
});
});
describe('extractCookiesFromChrome', () => {
it('returns cookies when Chrome yields values', async () => {
sweet.results.set('chrome', {
cookies: [
{ name: 'auth_token', value: 'test_auth', domain: 'x.com' },
{ name: 'ct0', value: 'test_ct0', domain: 'x.com' },
],
warnings: [],
});
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 returns no cookies', async () => {
sweet.results.set('chrome', { cookies: [], warnings: [] });
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 () => {
sweet.results.set('firefox', {
cookies: [],
warnings: ['Firefox cookies database not found.'],
});
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 returns no cookies', async () => {
sweet.results.set('firefox', { cookies: [], warnings: [] });
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);
});
});
});
+6 -2
View File
@@ -17,6 +17,8 @@ describe('root help output', () => {
expect(help).toContain('following'); expect(help).toContain('following');
expect(help).toContain('followers'); expect(help).toContain('followers');
expect(help).toContain('user');
expect(help).toContain('profiles');
expect(help).toContain('likes'); expect(help).toContain('likes');
expect(help).toContain('bird <tweet-id-or-url>'); expect(help).toContain('bird <tweet-id-or-url>');
@@ -45,8 +47,10 @@ describe('root help output', () => {
cmd.outputHelp(); cmd.outputHelp();
expect(help).toContain('--auth-token'); expect(help).toContain('--relay-base-url');
expect(help).toContain('--ct0'); expect(help).toContain('--profile-name');
expect(help).not.toContain('--auth-token');
expect(help).not.toContain('--ct0');
expect(help).toContain('--timeout'); expect(help).toContain('--timeout');
expect(help).toContain('--quote-depth'); expect(help).toContain('--quote-depth');
}); });
+8 -2
View File
@@ -1,9 +1,15 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { resolveCredentials, TwitterClient } from '../src/index.js'; import { type SearchFetchOptions, type SearchProduct, TwitterClient } from '../src/index.js';
describe('library exports', () => { describe('library exports', () => {
it('exposes primary library surface', () => { it('exposes primary library surface', () => {
expect(typeof TwitterClient).toBe('function'); expect(typeof TwitterClient).toBe('function');
expect(typeof resolveCredentials).toBe('function'); });
it('exposes search product types', () => {
const product: SearchProduct = 'Top';
const options: SearchFetchOptions = { product };
expect(options.product).toBe('Top');
}); });
}); });
+126 -105
View File
@@ -7,8 +7,7 @@ type RunResult = { exitCode: number; stdout: string; stderr: string; signal: Nod
const LIVE = process.env.BIRD_LIVE === '1'; const LIVE = process.env.BIRD_LIVE === '1';
const authToken = (process.env.AUTH_TOKEN ?? process.env.TWITTER_AUTH_TOKEN ?? '').trim(); const relayBaseUrl = (process.env.TWITTER_RELAY_BASE_URL ?? '').trim();
const ct0 = (process.env.CT0 ?? process.env.TWITTER_CT0 ?? '').trim();
const CLI_PATH = path.resolve(process.cwd(), 'dist', 'cli.js'); const CLI_PATH = path.resolve(process.cwd(), 'dist', 'cli.js');
@@ -27,8 +26,6 @@ function runBird(args: string[], options: { timeoutMs?: number } = {}): Promise<
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
env: { env: {
...process.env, ...process.env,
AUTH_TOKEN: authToken,
CT0: ct0,
NODE_ENV: LIVE_NODE_ENV, NODE_ENV: LIVE_NODE_ENV,
}, },
}); });
@@ -76,7 +73,6 @@ const d = LIVE ? describe : describe.skip;
d('live CLI (Twitter/X) all commands', () => { d('live CLI (Twitter/X) all commands', () => {
const timeoutArg = (process.env.BIRD_LIVE_TIMEOUT_MS ?? '20000').trim(); const timeoutArg = (process.env.BIRD_LIVE_TIMEOUT_MS ?? '20000').trim();
const cookieTimeoutArg = (process.env.BIRD_LIVE_COOKIE_TIMEOUT_MS ?? '30000').trim();
const baseArgs = ['--plain', '--timeout', timeoutArg, '--quote-depth', '0']; const baseArgs = ['--plain', '--timeout', timeoutArg, '--quote-depth', '0'];
let whoamiStdout = ''; let whoamiStdout = '';
@@ -92,19 +88,16 @@ d('live CLI (Twitter/X) all commands', () => {
throw new Error(`Missing ${CLI_PATH}. Run: pnpm run build:dist`); throw new Error(`Missing ${CLI_PATH}. Run: pnpm run build:dist`);
} }
if (!authToken || !ct0) { if (!relayBaseUrl) {
const check = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'check'], { timeoutMs: 45_000 }); throw new Error('Missing TWITTER_RELAY_BASE_URL for live tests.');
if (check.exitCode !== 0) {
throw new Error(
'Missing live credentials.\n' +
'- Option A: set AUTH_TOKEN + CT0 (or TWITTER_AUTH_TOKEN/TWITTER_CT0)\n' +
'- Option B: login to x.com in Safari/Chrome/Firefox for cookie extraction\n\n' +
`bird check output:\n${check.stdout}\n${check.stderr}`,
);
}
} }
const who = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'whoami'], { timeoutMs: 45_000 }); 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) { if (who.exitCode !== 0) {
throw new Error(`whoami failed (exit ${who.exitCode}, signal ${who.signal ?? 'none'}):\n${who.stderr}`); throw new Error(`whoami failed (exit ${who.exitCode}, signal ${who.signal ?? 'none'}):\n${who.stderr}`);
} }
@@ -132,12 +125,9 @@ d('live CLI (Twitter/X) all commands', () => {
const searchQuery = ( const searchQuery = (
process.env.BIRD_LIVE_SEARCH_QUERY ?? `from:${handle} -filter:replies -filter:retweets` process.env.BIRD_LIVE_SEARCH_QUERY ?? `from:${handle} -filter:replies -filter:retweets`
).trim(); ).trim();
const search = await runBird( const search = await runBird([...baseArgs, 'search', searchQuery, '-n', '25', '--json'], {
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'search', searchQuery, '-n', '25', '--json'], timeoutMs: 45_000,
{ });
timeoutMs: 45_000,
},
);
if (search.exitCode !== 0) { if (search.exitCode !== 0) {
throw new Error( throw new Error(
`search failed (exit ${search.exitCode}, signal ${search.signal ?? 'none'}):\n${search.stderr}`, `search failed (exit ${search.exitCode}, signal ${search.signal ?? 'none'}):\n${search.stderr}`,
@@ -157,7 +147,7 @@ d('live CLI (Twitter/X) all commands', () => {
listId = (process.env.BIRD_LIVE_LIST_ID ?? '').trim(); listId = (process.env.BIRD_LIVE_LIST_ID ?? '').trim();
if (!listId) { if (!listId) {
const lists = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'lists', '-n', '1', '--json'], { const lists = await runBird([...baseArgs, 'lists', '-n', '1', '--json'], {
timeoutMs: 45_000, timeoutMs: 45_000,
}); });
if (lists.exitCode === 0) { if (lists.exitCode === 0) {
@@ -166,12 +156,9 @@ d('live CLI (Twitter/X) all commands', () => {
} }
} }
if (!listId) { if (!listId) {
const lists = await runBird( const lists = await runBird([...baseArgs, 'lists', '--member-of', '-n', '1', '--json'], {
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'lists', '--member-of', '-n', '1', '--json'], timeoutMs: 45_000,
{ });
timeoutMs: 45_000,
},
);
if (lists.exitCode === 0) { if (lists.exitCode === 0) {
const parsed = parseJson<Array<{ id?: string }>>(lists.stdout); const parsed = parseJson<Array<{ id?: string }>>(lists.stdout);
listId = String(parsed[0]?.id ?? '').trim(); listId = String(parsed[0]?.id ?? '').trim();
@@ -186,7 +173,7 @@ d('live CLI (Twitter/X) all commands', () => {
}); });
it('check works', async () => { it('check works', async () => {
const check = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'check'], { timeoutMs: 45_000 }); const check = await runBird([...baseArgs, 'check'], { timeoutMs: 45_000 });
expect(check.exitCode).toBe(0); expect(check.exitCode).toBe(0);
expect(check.stdout.length).toBeGreaterThan(0); expect(check.stdout.length).toBeGreaterThan(0);
}); });
@@ -199,17 +186,14 @@ d('live CLI (Twitter/X) all commands', () => {
it('search returns JSON array', async () => { it('search returns JSON array', async () => {
const search = searchStdout const search = searchStdout
? { exitCode: 0, stdout: searchStdout } ? { exitCode: 0, stdout: searchStdout }
: await runBird( : await runBird([...baseArgs, 'search', `from:${handle}`, '-n', '10', '--json'], { timeoutMs: 45_000 });
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'search', `from:${handle}`, '-n', '10', '--json'],
{ timeoutMs: 45_000 },
);
expect(search.exitCode).toBe(0); expect(search.exitCode).toBe(0);
const tweets = parseJson<Array<{ id?: string }>>(search.stdout); const tweets = parseJson<Array<{ id?: string }>>(search.stdout);
expect(Array.isArray(tweets)).toBe(true); expect(Array.isArray(tweets)).toBe(true);
}); });
it('read returns tweet JSON', async () => { it('read returns tweet JSON', async () => {
const read = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'read', tweetId, '--json'], { const read = await runBird([...baseArgs, 'read', tweetId, '--json'], {
timeoutMs: 45_000, timeoutMs: 45_000,
}); });
expect(read.exitCode).toBe(0); expect(read.exitCode).toBe(0);
@@ -219,7 +203,7 @@ d('live CLI (Twitter/X) all commands', () => {
}); });
it('tweet-id shorthand returns tweet JSON', async () => { it('tweet-id shorthand returns tweet JSON', async () => {
const shorthand = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, tweetId, '--json'], { const shorthand = await runBird([...baseArgs, tweetId, '--json'], {
timeoutMs: 45_000, timeoutMs: 45_000,
}); });
expect(shorthand.exitCode).toBe(0); expect(shorthand.exitCode).toBe(0);
@@ -228,7 +212,7 @@ d('live CLI (Twitter/X) all commands', () => {
}); });
it('replies returns JSON array', async () => { it('replies returns JSON array', async () => {
const replies = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'replies', tweetId, '--json'], { const replies = await runBird([...baseArgs, 'replies', tweetId, '--json'], {
timeoutMs: 45_000, timeoutMs: 45_000,
}); });
expect(replies.exitCode).toBe(0); expect(replies.exitCode).toBe(0);
@@ -237,7 +221,7 @@ d('live CLI (Twitter/X) all commands', () => {
}); });
it('thread returns JSON array', async () => { it('thread returns JSON array', async () => {
const thread = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'thread', tweetId, '--json'], { const thread = await runBird([...baseArgs, 'thread', tweetId, '--json'], {
timeoutMs: 45_000, timeoutMs: 45_000,
}); });
expect(thread.exitCode).toBe(0); expect(thread.exitCode).toBe(0);
@@ -246,20 +230,36 @@ d('live CLI (Twitter/X) all commands', () => {
}); });
it('mentions returns JSON array', async () => { it('mentions returns JSON array', async () => {
const mentions = await runBird( const mentions = await runBird([...baseArgs, 'mentions', '-n', '10', '--json'], {
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'mentions', '-n', '10', '--json'], timeoutMs: 45_000,
{ });
timeoutMs: 45_000,
},
);
expect(mentions.exitCode).toBe(0); expect(mentions.exitCode).toBe(0);
const mentionTweets = parseJson<Array<{ id?: string }>>(mentions.stdout); const mentionTweets = parseJson<Array<{ id?: string }>>(mentions.stdout);
expect(Array.isArray(mentionTweets)).toBe(true); expect(Array.isArray(mentionTweets)).toBe(true);
}); });
it('bookmarks returns JSON array', async () => { 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 full-chain-only with ancestor branches returns JSON array', async () => {
const bookmarks = await runBird( const bookmarks = await runBird(
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'bookmarks', '-n', '10', '--json'], [
...baseArgs,
'--cookie-timeout',
cookieTimeoutArg,
'bookmarks',
'-n',
'1',
'--full-chain-only',
'--include-ancestor-branches',
'--json',
],
{ {
timeoutMs: 45_000, timeoutMs: 45_000,
}, },
@@ -270,7 +270,7 @@ d('live CLI (Twitter/X) all commands', () => {
}); });
it('likes returns JSON array', async () => { it('likes returns JSON array', async () => {
const likes = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'likes', '-n', '10', '--json'], { const likes = await runBird([...baseArgs, 'likes', '-n', '10', '--json'], {
timeoutMs: 45_000, timeoutMs: 45_000,
}); });
expect(likes.exitCode).toBe(0); expect(likes.exitCode).toBe(0);
@@ -279,24 +279,18 @@ d('live CLI (Twitter/X) all commands', () => {
}); });
it('following returns JSON array', async () => { it('following returns JSON array', async () => {
const following = await runBird( const following = await runBird([...baseArgs, 'following', '--user', userId, '-n', '10', '--json'], {
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'following', '--user', userId, '-n', '10', '--json'], timeoutMs: 45_000,
{ });
timeoutMs: 45_000,
},
);
expect(following.exitCode).toBe(0); expect(following.exitCode).toBe(0);
const followingUsers = parseJson<Array<{ id?: string; username?: string }>>(following.stdout); const followingUsers = parseJson<Array<{ id?: string; username?: string }>>(following.stdout);
expect(Array.isArray(followingUsers)).toBe(true); expect(Array.isArray(followingUsers)).toBe(true);
}); });
it('followers returns JSON array', async () => { it('followers returns JSON array', async () => {
const followers = await runBird( const followers = await runBird([...baseArgs, 'followers', '--user', userId, '-n', '10', '--json'], {
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'followers', '--user', userId, '-n', '10', '--json'], timeoutMs: 45_000,
{ });
timeoutMs: 45_000,
},
);
expect(followers.exitCode).toBe(0); expect(followers.exitCode).toBe(0);
const followerUsers = parseJson<Array<{ id?: string; username?: string }>>(followers.stdout); const followerUsers = parseJson<Array<{ id?: string; username?: string }>>(followers.stdout);
expect(Array.isArray(followerUsers)).toBe(true); expect(Array.isArray(followerUsers)).toBe(true);
@@ -304,12 +298,9 @@ d('live CLI (Twitter/X) all commands', () => {
it('user-tweets returns JSON array', async () => { it('user-tweets returns JSON array', async () => {
const testHandle = process.env.BIRD_LIVE_USER_TWEETS_HANDLE || 'X'; const testHandle = process.env.BIRD_LIVE_USER_TWEETS_HANDLE || 'X';
const userTweets = await runBird( const userTweets = await runBird([...baseArgs, 'user-tweets', testHandle, '-n', '5', '--json'], {
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'user-tweets', testHandle, '-n', '5', '--json'], timeoutMs: 45_000,
{ });
timeoutMs: 45_000,
},
);
expect(userTweets.exitCode).toBe(0); expect(userTweets.exitCode).toBe(0);
const tweets = parseJson<Array<{ id?: string; author?: { username?: string } }>>(userTweets.stdout); const tweets = parseJson<Array<{ id?: string; author?: { username?: string } }>>(userTweets.stdout);
expect(Array.isArray(tweets)).toBe(true); expect(Array.isArray(tweets)).toBe(true);
@@ -320,20 +311,7 @@ d('live CLI (Twitter/X) all commands', () => {
const testHandle = const testHandle =
process.env.BIRD_LIVE_USER_TWEETS_PAGED_HANDLE ?? process.env.BIRD_LIVE_USER_TWEETS_HANDLE ?? 'X'; process.env.BIRD_LIVE_USER_TWEETS_PAGED_HANDLE ?? process.env.BIRD_LIVE_USER_TWEETS_HANDLE ?? 'X';
const userTweets = await runBird( const userTweets = await runBird(
[ [...baseArgs, 'user-tweets', testHandle, '-n', '50', '--max-pages', '3', '--delay', '0', '--json'],
...baseArgs,
'--cookie-timeout',
cookieTimeoutArg,
'user-tweets',
testHandle,
'-n',
'50',
'--max-pages',
'3',
'--delay',
'0',
'--json',
],
{ {
timeoutMs: 45_000, timeoutMs: 45_000,
}, },
@@ -344,7 +322,7 @@ d('live CLI (Twitter/X) all commands', () => {
}); });
it('lists returns JSON array', async () => { it('lists returns JSON array', async () => {
const lists = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'lists', '-n', '10', '--json'], { const lists = await runBird([...baseArgs, 'lists', '-n', '10', '--json'], {
timeoutMs: 45_000, timeoutMs: 45_000,
}); });
expect(lists.exitCode).toBe(0); expect(lists.exitCode).toBe(0);
@@ -356,17 +334,14 @@ d('live CLI (Twitter/X) all commands', () => {
if (!listTimelineAvailable) { if (!listTimelineAvailable) {
return; return;
} }
const timeline = await runBird( const timeline = await runBird([...baseArgs, 'list-timeline', listId, '-n', '5', '--json'], { timeoutMs: 45_000 });
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'list-timeline', listId, '-n', '5', '--json'],
{ timeoutMs: 45_000 },
);
expect(timeline.exitCode).toBe(0); expect(timeline.exitCode).toBe(0);
const payload = parseJson<Array<{ id?: string }>>(timeline.stdout); const payload = parseJson<Array<{ id?: string }>>(timeline.stdout);
expect(Array.isArray(payload)).toBe(true); expect(Array.isArray(payload)).toBe(true);
}); });
it('home for-you returns JSON array', async () => { it('home for-you returns JSON array', async () => {
const home = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'home', '-n', '5', '--json'], { const home = await runBird([...baseArgs, 'home', '-n', '5', '--json'], {
timeoutMs: 45_000, timeoutMs: 45_000,
}); });
expect(home.exitCode).toBe(0); expect(home.exitCode).toBe(0);
@@ -375,20 +350,14 @@ d('live CLI (Twitter/X) all commands', () => {
}, 60_000); }, 60_000);
it('home following returns JSON array', async () => { it('home following returns JSON array', async () => {
let home = await runBird( let home = await runBird([...baseArgs, 'home', '--following', '-n', '5', '--json'], {
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'home', '--following', '-n', '5', '--json'], timeoutMs: 45_000,
{ });
timeoutMs: 45_000,
},
);
if (home.exitCode !== 0 && home.stderr.includes('OverCapacity')) { if (home.exitCode !== 0 && home.stderr.includes('OverCapacity')) {
await sleep(1500); await sleep(1500);
home = await runBird( home = await runBird([...baseArgs, 'home', '--following', '-n', '5', '--json'], {
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'home', '--following', '-n', '5', '--json'], timeoutMs: 45_000,
{ });
timeoutMs: 45_000,
},
);
} }
expect(home.exitCode).toBe(0); expect(home.exitCode).toBe(0);
const payload = parseJson<Array<{ id?: string }>>(home.stdout); const payload = parseJson<Array<{ id?: string }>>(home.stdout);
@@ -396,31 +365,83 @@ d('live CLI (Twitter/X) all commands', () => {
}, 60_000); }, 60_000);
it('news returns JSON array', async () => { it('news returns JSON array', async () => {
const news = await runBird( const news = await runBird([...baseArgs, 'news', '-n', '5', '--ai-only', '--json'], { timeoutMs: 45_000 });
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'news', '-n', '5', '--ai-only', '--json'],
{ timeoutMs: 45_000 },
);
expect(news.exitCode).toBe(0); expect(news.exitCode).toBe(0);
const payload = parseJson<Array<{ id?: string }>>(news.stdout); const payload = parseJson<Array<{ id?: string }>>(news.stdout);
expect(Array.isArray(payload)).toBe(true); expect(Array.isArray(payload)).toBe(true);
}); });
it('trending returns JSON array', async () => { it('trending returns JSON array', async () => {
const trending = await runBird( const trending = await runBird([...baseArgs, 'trending', '-n', '5', '--json'], { timeoutMs: 45_000 });
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'trending', '-n', '5', '--json'],
{ timeoutMs: 45_000 },
);
expect(trending.exitCode).toBe(0); expect(trending.exitCode).toBe(0);
const payload = parseJson<Array<{ id?: string }>>(trending.stdout); const payload = parseJson<Array<{ id?: string }>>(trending.stdout);
expect(Array.isArray(payload)).toBe(true); expect(Array.isArray(payload)).toBe(true);
}); });
it('query-ids returns JSON', async () => { it('query-ids returns JSON', async () => {
const queryIds = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'query-ids', '--json'], { const queryIds = await runBird([...baseArgs, 'query-ids', '--json'], {
timeoutMs: 60_000, timeoutMs: 60_000,
}); });
expect(queryIds.exitCode).toBe(0); expect(queryIds.exitCode).toBe(0);
const snapshot = parseJson<{ cached?: boolean; ids?: Record<string, string> }>(queryIds.stdout); const snapshot = parseJson<{ cached?: boolean; ids?: Record<string, string> }>(queryIds.stdout);
expect(typeof snapshot.cached).toBe('boolean'); expect(typeof snapshot.cached).toBe('boolean');
}); });
it('engagement mutations work (opt-in)', async () => {
const engagementTweetId = (process.env.BIRD_LIVE_ENGAGEMENT_TWEET_ID ?? '').trim();
if (!engagementTweetId) {
return;
}
if (!TWEET_ID_REGEX.test(engagementTweetId)) {
throw new Error(`Invalid BIRD_LIVE_ENGAGEMENT_TWEET_ID (expected digits): "${engagementTweetId}"`);
}
const cookieTimeoutMs = Number.parseInt(cookieTimeoutArg, 10);
const { cookies, warnings } = await resolveCredentials({
authToken,
ct0,
cookieTimeoutMs: Number.isFinite(cookieTimeoutMs) ? cookieTimeoutMs : undefined,
});
for (const warning of warnings) {
console.error(`${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
throw new Error('Missing live credentials for engagement test.');
}
const originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = LIVE_NODE_ENV;
try {
const timeoutMs = Number.parseInt(timeoutArg, 10);
const client = new TwitterClient({
cookies,
timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : undefined,
});
// Cleanup (best-effort) to make the mutation sequence deterministic.
await client.unbookmark(engagementTweetId);
await client.unlike(engagementTweetId);
await client.unretweet(engagementTweetId);
const like = await client.like(engagementTweetId);
expect(like.success).toBe(true);
const retweet = await client.retweet(engagementTweetId);
expect(retweet.success).toBe(true);
const bookmark = await client.bookmark(engagementTweetId);
expect(bookmark.success).toBe(true);
const unbookmark = await client.unbookmark(engagementTweetId);
expect(unbookmark.success).toBe(true);
const unlike = await client.unlike(engagementTweetId);
expect(unlike.success).toBe(true);
const unretweet = await client.unretweet(engagementTweetId);
expect(unretweet.success).toBe(true);
} finally {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = originalNodeEnv;
}
}
});
}); });
+113 -77
View File
@@ -7,8 +7,7 @@ type RunResult = { exitCode: number; stdout: string; stderr: string; signal: Nod
const LIVE = process.env.BIRD_LIVE === '1'; const LIVE = process.env.BIRD_LIVE === '1';
const authToken = (process.env.AUTH_TOKEN ?? process.env.TWITTER_AUTH_TOKEN ?? '').trim(); const relayBaseUrl = (process.env.TWITTER_RELAY_BASE_URL ?? '').trim();
const ct0 = (process.env.CT0 ?? process.env.TWITTER_CT0 ?? '').trim();
const CLI_PATH = path.resolve(process.cwd(), 'dist', 'cli.js'); const CLI_PATH = path.resolve(process.cwd(), 'dist', 'cli.js');
@@ -27,8 +26,6 @@ function runBird(args: string[], options: { timeoutMs?: number } = {}): Promise<
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
env: { env: {
...process.env, ...process.env,
AUTH_TOKEN: authToken,
CT0: ct0,
NODE_ENV: LIVE_NODE_ENV, NODE_ENV: LIVE_NODE_ENV,
}, },
}); });
@@ -72,7 +69,6 @@ const d = LIVE ? describe : describe.skip;
d('live CLI (Twitter/X)', () => { d('live CLI (Twitter/X)', () => {
const timeoutArg = (process.env.BIRD_LIVE_TIMEOUT_MS ?? '20000').trim(); const timeoutArg = (process.env.BIRD_LIVE_TIMEOUT_MS ?? '20000').trim();
const cookieTimeoutArg = (process.env.BIRD_LIVE_COOKIE_TIMEOUT_MS ?? '30000').trim();
const baseArgs = ['--plain', '--timeout', timeoutArg, '--quote-depth', '0']; const baseArgs = ['--plain', '--timeout', timeoutArg, '--quote-depth', '0'];
let whoamiStdout = ''; let whoamiStdout = '';
@@ -85,19 +81,16 @@ d('live CLI (Twitter/X)', () => {
throw new Error(`Missing ${CLI_PATH}. Run: pnpm run build:dist`); throw new Error(`Missing ${CLI_PATH}. Run: pnpm run build:dist`);
} }
if (!authToken || !ct0) { if (!relayBaseUrl) {
const check = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'check'], { timeoutMs: 45_000 }); throw new Error('Missing TWITTER_RELAY_BASE_URL for live tests.');
if (check.exitCode !== 0) {
throw new Error(
'Missing live credentials.\n' +
'- Option A: set AUTH_TOKEN + CT0 (or TWITTER_AUTH_TOKEN/TWITTER_CT0)\n' +
'- Option B: login to x.com in Safari/Chrome/Firefox for cookie extraction\n\n' +
`bird check output:\n${check.stdout}\n${check.stderr}`,
);
}
} }
const who = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'whoami'], { timeoutMs: 45_000 }); 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) { if (who.exitCode !== 0) {
throw new Error(`whoami failed (exit ${who.exitCode}, signal ${who.signal ?? 'none'}):\n${who.stderr}`); throw new Error(`whoami failed (exit ${who.exitCode}, signal ${who.signal ?? 'none'}):\n${who.stderr}`);
} }
@@ -127,12 +120,9 @@ d('live CLI (Twitter/X)', () => {
const searchQuery = ( const searchQuery = (
process.env.BIRD_LIVE_SEARCH_QUERY ?? `from:${handle} -filter:replies -filter:retweets` process.env.BIRD_LIVE_SEARCH_QUERY ?? `from:${handle} -filter:replies -filter:retweets`
).trim(); ).trim();
const search = await runBird( const search = await runBird([...baseArgs, 'search', searchQuery, '-n', '25', '--json'], {
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'search', searchQuery, '-n', '25', '--json'], timeoutMs: 45_000,
{ });
timeoutMs: 45_000,
},
);
if (search.exitCode !== 0) { if (search.exitCode !== 0) {
throw new Error(`search failed (exit ${search.exitCode}, signal ${search.signal ?? 'none'}):\n${search.stderr}`); throw new Error(`search failed (exit ${search.exitCode}, signal ${search.signal ?? 'none'}):\n${search.stderr}`);
} }
@@ -159,8 +149,25 @@ d('live CLI (Twitter/X)', () => {
expect(whoamiStdout).toContain('credentials:'); expect(whoamiStdout).toContain('credentials:');
}); });
it('follow/unfollow works (opt-in)', async () => {
const followHandle = (process.env.BIRD_LIVE_FOLLOW_HANDLE ?? '').trim();
if (!followHandle) {
return;
}
const follow = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'follow', followHandle], {
timeoutMs: 45_000,
});
expect(follow.exitCode).toBe(0);
const unfollow = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'unfollow', followHandle], {
timeoutMs: 45_000,
});
expect(unfollow.exitCode).toBe(0);
});
it('read returns tweet JSON', async () => { it('read returns tweet JSON', async () => {
const read = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'read', tweetId, '--json'], { const read = await runBird([...baseArgs, 'read', tweetId, '--json'], {
timeoutMs: 45_000, timeoutMs: 45_000,
}); });
expect(read.exitCode).toBe(0); expect(read.exitCode).toBe(0);
@@ -170,7 +177,7 @@ d('live CLI (Twitter/X)', () => {
}); });
it('tweet-id shorthand returns tweet JSON', async () => { it('tweet-id shorthand returns tweet JSON', async () => {
const shorthand = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, tweetId, '--json'], { const shorthand = await runBird([...baseArgs, tweetId, '--json'], {
timeoutMs: 45_000, timeoutMs: 45_000,
}); });
expect(shorthand.exitCode).toBe(0); expect(shorthand.exitCode).toBe(0);
@@ -179,7 +186,7 @@ d('live CLI (Twitter/X)', () => {
}); });
it('replies returns JSON array', async () => { it('replies returns JSON array', async () => {
const replies = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'replies', tweetId, '--json'], { const replies = await runBird([...baseArgs, 'replies', tweetId, '--json'], {
timeoutMs: 45_000, timeoutMs: 45_000,
}); });
expect(replies.exitCode).toBe(0); expect(replies.exitCode).toBe(0);
@@ -188,7 +195,7 @@ d('live CLI (Twitter/X)', () => {
}); });
it('thread returns JSON array', async () => { it('thread returns JSON array', async () => {
const thread = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'thread', tweetId, '--json'], { const thread = await runBird([...baseArgs, 'thread', tweetId, '--json'], {
timeoutMs: 45_000, timeoutMs: 45_000,
}); });
expect(thread.exitCode).toBe(0); expect(thread.exitCode).toBe(0);
@@ -198,24 +205,18 @@ d('live CLI (Twitter/X)', () => {
}); });
it('mentions returns JSON array', async () => { it('mentions returns JSON array', async () => {
const mentions = await runBird( const mentions = await runBird([...baseArgs, 'mentions', '-n', '10', '--json'], {
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'mentions', '-n', '10', '--json'], timeoutMs: 45_000,
{ });
timeoutMs: 45_000,
},
);
expect(mentions.exitCode).toBe(0); expect(mentions.exitCode).toBe(0);
const mentionTweets = parseJson<Array<{ id?: string }>>(mentions.stdout); const mentionTweets = parseJson<Array<{ id?: string }>>(mentions.stdout);
expect(Array.isArray(mentionTweets)).toBe(true); expect(Array.isArray(mentionTweets)).toBe(true);
}); });
it('bookmarks returns JSON array', async () => { it('bookmarks returns JSON array', async () => {
const bookmarks = await runBird( const bookmarks = await runBird([...baseArgs, 'bookmarks', '-n', '10', '--json'], {
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'bookmarks', '-n', '10', '--json'], timeoutMs: 45_000,
{ });
timeoutMs: 45_000,
},
);
expect(bookmarks.exitCode).toBe(0); expect(bookmarks.exitCode).toBe(0);
const bookmarkTweets = parseJson<Array<{ id?: string }>>(bookmarks.stdout); const bookmarkTweets = parseJson<Array<{ id?: string }>>(bookmarks.stdout);
expect(Array.isArray(bookmarkTweets)).toBe(true); expect(Array.isArray(bookmarkTweets)).toBe(true);
@@ -226,17 +227,16 @@ d('live CLI (Twitter/X)', () => {
if (!folderId) { if (!folderId) {
return; return;
} }
const bookmarks = await runBird( const bookmarks = await runBird([...baseArgs, 'bookmarks', '--folder-id', folderId, '-n', '10', '--json'], {
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'bookmarks', '--folder-id', folderId, '-n', '10', '--json'], timeoutMs: 45_000,
{ timeoutMs: 45_000 }, });
);
expect(bookmarks.exitCode).toBe(0); expect(bookmarks.exitCode).toBe(0);
const bookmarkTweets = parseJson<Array<{ id?: string }>>(bookmarks.stdout); const bookmarkTweets = parseJson<Array<{ id?: string }>>(bookmarks.stdout);
expect(Array.isArray(bookmarkTweets)).toBe(true); expect(Array.isArray(bookmarkTweets)).toBe(true);
}); });
it('likes returns JSON array', async () => { it('likes returns JSON array', async () => {
const likes = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'likes', '-n', '10', '--json'], { const likes = await runBird([...baseArgs, 'likes', '-n', '10', '--json'], {
timeoutMs: 45_000, timeoutMs: 45_000,
}); });
expect(likes.exitCode).toBe(0); expect(likes.exitCode).toBe(0);
@@ -245,24 +245,18 @@ d('live CLI (Twitter/X)', () => {
}); });
it('following returns JSON array', async () => { it('following returns JSON array', async () => {
const following = await runBird( const following = await runBird([...baseArgs, 'following', '--user', userId, '-n', '10', '--json'], {
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'following', '--user', userId, '-n', '10', '--json'], timeoutMs: 45_000,
{ });
timeoutMs: 45_000,
},
);
expect(following.exitCode).toBe(0); expect(following.exitCode).toBe(0);
const followingUsers = parseJson<Array<{ id?: string; username?: string }>>(following.stdout); const followingUsers = parseJson<Array<{ id?: string; username?: string }>>(following.stdout);
expect(Array.isArray(followingUsers)).toBe(true); expect(Array.isArray(followingUsers)).toBe(true);
}); });
it('followers returns JSON array', async () => { it('followers returns JSON array', async () => {
const followers = await runBird( const followers = await runBird([...baseArgs, 'followers', '--user', userId, '-n', '10', '--json'], {
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'followers', '--user', userId, '-n', '10', '--json'], timeoutMs: 45_000,
{ });
timeoutMs: 45_000,
},
);
expect(followers.exitCode).toBe(0); expect(followers.exitCode).toBe(0);
const followerUsers = parseJson<Array<{ id?: string; username?: string }>>(followers.stdout); const followerUsers = parseJson<Array<{ id?: string; username?: string }>>(followers.stdout);
expect(Array.isArray(followerUsers)).toBe(true); expect(Array.isArray(followerUsers)).toBe(true);
@@ -271,12 +265,9 @@ d('live CLI (Twitter/X)', () => {
it('user-tweets returns JSON array', async () => { it('user-tweets returns JSON array', async () => {
// Use a known active account for reliable testing (authenticated user may have no tweets) // 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 testHandle = process.env.BIRD_LIVE_USER_TWEETS_HANDLE || 'X';
const userTweets = await runBird( const userTweets = await runBird([...baseArgs, 'user-tweets', testHandle, '-n', '5', '--json'], {
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'user-tweets', testHandle, '-n', '5', '--json'], timeoutMs: 45_000,
{ });
timeoutMs: 45_000,
},
);
expect(userTweets.exitCode).toBe(0); expect(userTweets.exitCode).toBe(0);
const tweets = parseJson<Array<{ id?: string; author?: { username?: string } }>>(userTweets.stdout); const tweets = parseJson<Array<{ id?: string; author?: { username?: string } }>>(userTweets.stdout);
expect(Array.isArray(tweets)).toBe(true); expect(Array.isArray(tweets)).toBe(true);
@@ -289,20 +280,7 @@ d('live CLI (Twitter/X)', () => {
const testHandle = const testHandle =
process.env.BIRD_LIVE_USER_TWEETS_PAGED_HANDLE ?? process.env.BIRD_LIVE_USER_TWEETS_HANDLE ?? 'X'; process.env.BIRD_LIVE_USER_TWEETS_PAGED_HANDLE ?? process.env.BIRD_LIVE_USER_TWEETS_HANDLE ?? 'X';
const userTweets = await runBird( const userTweets = await runBird(
[ [...baseArgs, 'user-tweets', testHandle, '-n', '50', '--max-pages', '3', '--delay', '0', '--json'],
...baseArgs,
'--cookie-timeout',
cookieTimeoutArg,
'user-tweets',
testHandle,
'-n',
'50',
'--max-pages',
'3',
'--delay',
'0',
'--json',
],
{ {
timeoutMs: 45_000, timeoutMs: 45_000,
}, },
@@ -315,7 +293,7 @@ d('live CLI (Twitter/X)', () => {
}); });
it('query-ids returns JSON', async () => { it('query-ids returns JSON', async () => {
const queryIds = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'query-ids', '--json'], { const queryIds = await runBird([...baseArgs, 'query-ids', '--json'], {
timeoutMs: 60_000, timeoutMs: 60_000,
}); });
expect(queryIds.exitCode).toBe(0); expect(queryIds.exitCode).toBe(0);
@@ -340,7 +318,7 @@ d('live CLI (Twitter/X)', () => {
// Skip unless explicitly provided - long-form tweets may be deleted/unavailable // Skip unless explicitly provided - long-form tweets may be deleted/unavailable
return; return;
} }
const read = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'read', longformTweetId, '--json'], { const read = await runBird([...baseArgs, 'read', longformTweetId, '--json'], {
timeoutMs: 45_000, timeoutMs: 45_000,
}); });
expect(read.exitCode).toBe(0); expect(read.exitCode).toBe(0);
@@ -350,4 +328,62 @@ d('live CLI (Twitter/X)', () => {
// This verifies the article content is being extracted, not just a stub // This verifies the article content is being extracted, not just a stub
expect(tweet.text?.length).toBeGreaterThan(500); expect(tweet.text?.length).toBeGreaterThan(500);
}); });
it('engagement mutations work (opt-in)', async () => {
const engagementTweetId = (process.env.BIRD_LIVE_ENGAGEMENT_TWEET_ID ?? '').trim();
if (!engagementTweetId) {
return;
}
if (!TWEET_ID_REGEX.test(engagementTweetId)) {
throw new Error(`Invalid BIRD_LIVE_ENGAGEMENT_TWEET_ID (expected digits): "${engagementTweetId}"`);
}
const cookieTimeoutMs = Number.parseInt(cookieTimeoutArg, 10);
const { cookies, warnings } = await resolveCredentials({
authToken,
ct0,
cookieTimeoutMs: Number.isFinite(cookieTimeoutMs) ? cookieTimeoutMs : undefined,
});
for (const warning of warnings) {
console.error(`${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
throw new Error('Missing live credentials for engagement test.');
}
const originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = LIVE_NODE_ENV;
try {
const timeoutMs = Number.parseInt(timeoutArg, 10);
const client = new TwitterClient({
cookies,
timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : undefined,
});
// Cleanup (best-effort) to make the mutation sequence deterministic.
await client.unbookmark(engagementTweetId);
await client.unlike(engagementTweetId);
await client.unretweet(engagementTweetId);
const like = await client.like(engagementTweetId);
expect(like.success).toBe(true);
const retweet = await client.retweet(engagementTweetId);
expect(retweet.success).toBe(true);
const bookmark = await client.bookmark(engagementTweetId);
expect(bookmark.success).toBe(true);
const unbookmark = await client.unbookmark(engagementTweetId);
expect(unbookmark.success).toBe(true);
const unlike = await client.unlike(engagementTweetId);
expect(unlike.success).toBe(true);
const unretweet = await client.unretweet(engagementTweetId);
expect(unretweet.success).toBe(true);
} finally {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = originalNodeEnv;
}
}
});
}); });
+3 -3
View File
@@ -120,10 +120,10 @@ describe('runtime-features', () => {
it('includes required lists API feature flags', () => { it('includes required lists API feature flags', () => {
const features = buildListsFeatures(); const features = buildListsFeatures();
expect(features.responsive_web_graphql_exclude_directive_enabled).toBe(true); expect(features.responsive_web_graphql_exclude_directive_enabled).toBe(true);
expect(features.blue_business_profile_image_shape_enabled).toBe(false); expect(features.blue_business_profile_image_shape_enabled).toBe(true);
expect(features.responsive_web_text_conversations_enabled).toBe(false); expect(features.responsive_web_text_conversations_enabled).toBe(false);
expect(features.tweetypie_unmention_optimization_enabled).toBe(true); expect(features.tweetypie_unmention_optimization_enabled).toBe(true);
expect(features.vibe_api_enabled).toBe(false); expect(features.vibe_api_enabled).toBe(true);
expect(features.interactive_text_enabled).toBe(false); expect(features.interactive_text_enabled).toBe(true);
}); });
}); });
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { filterFullChain } from '../src/lib/thread-filters.js';
import type { TweetData } from '../src/lib/twitter-client-types.js';
const makeTweet = (id: string, createdAt: string, inReplyToStatusId?: string, conversationId = '1'): TweetData => ({
id,
text: id,
author: { username: 'alice', name: 'Alice' },
createdAt,
inReplyToStatusId,
conversationId,
});
describe('filterFullChain', () => {
it('returns ancestors + descendants from the bookmark only', () => {
const root = makeTweet('1', '2020-01-01T00:00:00Z');
const replyA = makeTweet('2', '2020-01-02T00:00:00Z', '1');
const bookmark = makeTweet('3', '2020-01-03T00:00:00Z', '2');
const childA = makeTweet('4', '2020-01-04T00:00:00Z', '3');
const childB = makeTweet('7', '2020-01-05T00:00:00Z', '4');
const siblingOfAncestor = makeTweet('5', '2020-01-06T00:00:00Z', '2');
const siblingOfRoot = makeTweet('6', '2020-01-07T00:00:00Z', '1');
const tweets = [siblingOfAncestor, childB, root, siblingOfRoot, bookmark, childA, replyA];
const result = filterFullChain(tweets, bookmark);
expect(result.map((tweet) => tweet.id)).toEqual(['1', '2', '3', '4', '7']);
});
it('includes ancestor branches when requested', () => {
const root = makeTweet('1', '2020-01-01T00:00:00Z');
const replyA = makeTweet('2', '2020-01-02T00:00:00Z', '1');
const bookmark = makeTweet('3', '2020-01-03T00:00:00Z', '2');
const childA = makeTweet('4', '2020-01-04T00:00:00Z', '3');
const childB = makeTweet('7', '2020-01-05T00:00:00Z', '4');
const siblingOfAncestor = makeTweet('5', '2020-01-06T00:00:00Z', '2');
const siblingOfRoot = makeTweet('6', '2020-01-07T00:00:00Z', '1');
const tweets = [siblingOfRoot, childA, replyA, childB, bookmark, root, siblingOfAncestor];
const result = filterFullChain(tweets, bookmark, { includeAncestorBranches: true });
expect(result.map((tweet) => tweet.id)).toEqual(['1', '2', '3', '4', '7', '5', '6']);
});
});
+27 -32
View File
@@ -1,12 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
const validCookies = { const testRelayBaseUrl = 'https://relay.example';
authToken: 'test_auth_token',
ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
type ResponseLike = { type ResponseLike = {
ok: boolean; ok: boolean;
@@ -53,7 +48,7 @@ describe('TwitterClient API coverage', () => {
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'boom' })); .mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'boom' }));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.tweet('hi'); const result = await client.tweet('hi');
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -72,7 +67,7 @@ describe('TwitterClient API coverage', () => {
); );
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.tweet('hi'); const result = await client.tweet('hi');
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -91,7 +86,7 @@ describe('TwitterClient API coverage', () => {
); );
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.tweet('hi'); const result = await client.tweet('hi');
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -106,7 +101,7 @@ describe('TwitterClient API coverage', () => {
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' })); .mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.search('test'); const result = await client.search('test');
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -121,7 +116,7 @@ describe('TwitterClient API coverage', () => {
); );
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.search('test'); const result = await client.search('test');
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -132,7 +127,7 @@ describe('TwitterClient API coverage', () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom')); const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.search('test'); const result = await client.search('test');
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -143,7 +138,7 @@ describe('TwitterClient API coverage', () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' })); const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.search('test'); const result = await client.search('test');
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -160,7 +155,7 @@ describe('TwitterClient API coverage', () => {
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' })); .mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientApiPrivate; const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarksQueryIds = async () => ['test']; clientPrivate.getBookmarksQueryIds = async () => ['test'];
const result = await client.getBookmarks(1); const result = await client.getBookmarks(1);
@@ -177,7 +172,7 @@ describe('TwitterClient API coverage', () => {
); );
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientApiPrivate; const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarksQueryIds = async () => ['test']; clientPrivate.getBookmarksQueryIds = async () => ['test'];
const result = await client.getBookmarks(1); const result = await client.getBookmarks(1);
@@ -190,7 +185,7 @@ describe('TwitterClient API coverage', () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom')); const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientApiPrivate; const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarksQueryIds = async () => ['test']; clientPrivate.getBookmarksQueryIds = async () => ['test'];
const result = await client.getBookmarks(1); const result = await client.getBookmarks(1);
@@ -200,7 +195,7 @@ describe('TwitterClient API coverage', () => {
}); });
it('returns unknown error when no query ids are available', async () => { it('returns unknown error when no query ids are available', async () => {
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientApiPrivate; const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarksQueryIds = async () => []; clientPrivate.getBookmarksQueryIds = async () => [];
@@ -214,7 +209,7 @@ describe('TwitterClient API coverage', () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' })); const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getBookmarks(1); const result = await client.getBookmarks(1);
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -229,7 +224,7 @@ describe('TwitterClient API coverage', () => {
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' })); .mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientApiPrivate; const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getFollowingQueryIds = async () => ['test']; clientPrivate.getFollowingQueryIds = async () => ['test'];
@@ -247,7 +242,7 @@ describe('TwitterClient API coverage', () => {
); );
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientApiPrivate; const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getFollowersQueryIds = async () => ['test']; clientPrivate.getFollowersQueryIds = async () => ['test'];
@@ -258,7 +253,7 @@ describe('TwitterClient API coverage', () => {
}); });
it('returns unknown error when no query ids are available', async () => { it('returns unknown error when no query ids are available', async () => {
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientApiPrivate; const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getFollowingQueryIds = async () => []; clientPrivate.getFollowingQueryIds = async () => [];
@@ -272,7 +267,7 @@ describe('TwitterClient API coverage', () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' })); const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientApiPrivate; const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getFollowersQueryIds = async () => ['test']; clientPrivate.getFollowersQueryIds = async () => ['test'];
@@ -294,7 +289,7 @@ describe('TwitterClient API coverage', () => {
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' })); .mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientApiPrivate; const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser; clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test']; clientPrivate.getLikesQueryIds = async () => ['test'];
@@ -313,7 +308,7 @@ describe('TwitterClient API coverage', () => {
); );
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientApiPrivate; const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser; clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test']; clientPrivate.getLikesQueryIds = async () => ['test'];
@@ -328,7 +323,7 @@ describe('TwitterClient API coverage', () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom')); const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientApiPrivate; const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser; clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test']; clientPrivate.getLikesQueryIds = async () => ['test'];
@@ -340,7 +335,7 @@ describe('TwitterClient API coverage', () => {
}); });
it('returns unknown error when no query ids are available', async () => { it('returns unknown error when no query ids are available', async () => {
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientApiPrivate; const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser; clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => []; clientPrivate.getLikesQueryIds = async () => [];
@@ -355,7 +350,7 @@ describe('TwitterClient API coverage', () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' })); const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientApiPrivate; const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser; clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test']; clientPrivate.getLikesQueryIds = async () => ['test'];
@@ -375,7 +370,7 @@ describe('TwitterClient API coverage', () => {
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' })); .mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientApiPrivate; const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarkFolderQueryIds = async () => ['test']; clientPrivate.getBookmarkFolderQueryIds = async () => ['test'];
const result = await client.getBookmarkFolderTimeline('123', 1); const result = await client.getBookmarkFolderTimeline('123', 1);
@@ -392,7 +387,7 @@ describe('TwitterClient API coverage', () => {
); );
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientApiPrivate; const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarkFolderQueryIds = async () => ['test']; clientPrivate.getBookmarkFolderQueryIds = async () => ['test'];
const result = await client.getBookmarkFolderTimeline('123', 1); const result = await client.getBookmarkFolderTimeline('123', 1);
@@ -405,7 +400,7 @@ describe('TwitterClient API coverage', () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom')); const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientApiPrivate; const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarkFolderQueryIds = async () => ['test']; clientPrivate.getBookmarkFolderQueryIds = async () => ['test'];
const result = await client.getBookmarkFolderTimeline('123', 1); const result = await client.getBookmarkFolderTimeline('123', 1);
@@ -415,7 +410,7 @@ describe('TwitterClient API coverage', () => {
}); });
it('returns unknown error when no query ids are available', async () => { it('returns unknown error when no query ids are available', async () => {
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientApiPrivate; const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarkFolderQueryIds = async () => []; clientPrivate.getBookmarkFolderQueryIds = async () => [];
@@ -429,7 +424,7 @@ describe('TwitterClient API coverage', () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' })); const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getBookmarkFolderTimeline('123', 1); const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false); expect(result.success).toBe(false);
+58 -24
View File
@@ -2,12 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { runtimeQueryIds } from '../src/lib/runtime-query-ids.js'; import { runtimeQueryIds } from '../src/lib/runtime-query-ids.js';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
const validCookies = { const testRelayBaseUrl = 'https://relay.example';
authToken: 'test_auth_token',
ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
type ResponseLike = { type ResponseLike = {
ok: boolean; ok: boolean;
@@ -20,7 +15,7 @@ type TwitterClientPrivate = TwitterClient & {
refreshQueryIds: () => Promise<void>; refreshQueryIds: () => Promise<void>;
fetchWithTimeout: (url: string, init: RequestInit) => Promise<ResponseLike>; fetchWithTimeout: (url: string, init: RequestInit) => Promise<ResponseLike>;
ensureClientUserId: () => Promise<void>; ensureClientUserId: () => Promise<void>;
getBaseHeaders: () => Record<string, string>; getHeaders: () => Record<string, string>;
fetchUserArticlePlainText: (userId: string, tweetId: string) => Promise<{ title?: string; plainText?: string }>; fetchUserArticlePlainText: (userId: string, tweetId: string) => Promise<{ title?: string; plainText?: string }>;
fetchTweetDetail: (tweetId: string) => Promise<{ success: true } | { success: false; error: string }>; fetchTweetDetail: (tweetId: string) => Promise<{ success: true } | { success: false; error: string }>;
postStatusUpdate: (input: { text: string }) => Promise<{ success: boolean; error?: string }>; postStatusUpdate: (input: { text: string }) => Promise<{ success: boolean; error?: string }>;
@@ -46,7 +41,7 @@ describe('TwitterClient core coverage', () => {
}); });
it('refreshQueryIds calls runtime refresh outside test env', async () => { it('refreshQueryIds calls runtime refresh outside test env', async () => {
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const refreshSpy = vi.spyOn(runtimeQueryIds, 'refresh').mockResolvedValue(null); const refreshSpy = vi.spyOn(runtimeQueryIds, 'refresh').mockResolvedValue(null);
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = 'production';
@@ -63,11 +58,15 @@ describe('TwitterClient core coverage', () => {
}); });
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies, timeoutMs: 5 }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl, timeoutMs: 5 });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
await clientPrivate.fetchWithTimeout('https://example.com', { method: 'GET' }); await clientPrivate.fetchWithTimeout('https://x.com/i/api/test', { method: 'GET' });
expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(
'https://relay.example/i/api/test',
expect.objectContaining({ method: 'GET' }),
);
}); });
it('returns a not found error when tweet data is missing', async () => { it('returns a not found error when tweet data is missing', async () => {
@@ -102,15 +101,15 @@ describe('TwitterClient core coverage', () => {
); );
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getTweet('123'); const result = await client.getTweet('123');
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBe('Tweet not found in response'); expect(result.error).toBe('Tweet not found in response');
}); });
it('adds client user id header after ensureClientUserId', async () => { it('does not add Twitter headers after ensureClientUserId', async () => {
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = 'production';
vi.spyOn(client, 'getCurrentUser').mockResolvedValue({ vi.spyOn(client, 'getCurrentUser').mockResolvedValue({
@@ -120,9 +119,44 @@ describe('TwitterClient core coverage', () => {
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
await clientPrivate.ensureClientUserId(); await clientPrivate.ensureClientUserId();
const headers = clientPrivate.getBaseHeaders(); const headers = clientPrivate.getHeaders();
expect(headers['x-twitter-client-user-id']).toBe('42'); expect(headers).toEqual({});
});
it('adds x-profile-name header when profileName is set', () => {
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl, profileName: 'work' });
const clientPrivate = client as unknown as TwitterClientPrivate;
expect(clientPrivate.getHeaders()).toEqual({ 'x-profile-name': 'work' });
});
it('ignores blank profileName values', () => {
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl, profileName: ' ' });
const clientPrivate = client as unknown as TwitterClientPrivate;
expect(clientPrivate.getHeaders()).toEqual({});
});
it('adds x-profile-name to JSON headers when profileName is set', () => {
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl, profileName: 'work' });
const clientPrivate = client as unknown as TwitterClientPrivate & {
getJsonHeaders: () => Record<string, string>;
};
expect(clientPrivate.getJsonHeaders()).toEqual({
'content-type': 'application/json',
'x-profile-name': 'work',
});
});
it('adds x-profile-name to upload headers when profileName is set', () => {
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl, profileName: 'work' });
const clientPrivate = client as unknown as TwitterClientPrivate & {
getUploadHeaders: () => Record<string, string>;
};
expect(clientPrivate.getUploadHeaders()).toEqual({ 'x-profile-name': 'work' });
}); });
describe('fetchUserArticlePlainText failures', () => { describe('fetchUserArticlePlainText failures', () => {
@@ -130,7 +164,7 @@ describe('TwitterClient core coverage', () => {
const mockFetch = vi.fn().mockResolvedValueOnce(makeResponse({ ok: false, status: 500 })); const mockFetch = vi.fn().mockResolvedValueOnce(makeResponse({ ok: false, status: 500 }));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet'); const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet');
@@ -141,7 +175,7 @@ describe('TwitterClient core coverage', () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom')); const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet'); const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet');
@@ -160,7 +194,7 @@ describe('TwitterClient core coverage', () => {
); );
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet'); const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet');
@@ -177,7 +211,7 @@ describe('TwitterClient core coverage', () => {
); );
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchTweetDetail('1'); const result = await clientPrivate.fetchTweetDetail('1');
@@ -210,7 +244,7 @@ describe('TwitterClient core coverage', () => {
); );
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchTweetDetail('1'); const result = await clientPrivate.fetchTweetDetail('1');
@@ -260,7 +294,7 @@ describe('TwitterClient core coverage', () => {
); );
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchTweetDetail('2'); const result = await clientPrivate.fetchTweetDetail('2');
@@ -295,7 +329,7 @@ describe('TwitterClient core coverage', () => {
); );
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchTweetDetail('1'); const result = await clientPrivate.fetchTweetDetail('1');
@@ -306,7 +340,7 @@ describe('TwitterClient core coverage', () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom')); const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchTweetDetail('1'); const result = await clientPrivate.fetchTweetDetail('1');
@@ -320,7 +354,7 @@ describe('TwitterClient core coverage', () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom')); const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.postStatusUpdate({ text: 'hello' }); const result = await clientPrivate.postStatusUpdate({ text: 'hello' });
+13 -18
View File
@@ -1,12 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
const validCookies = { const testRelayBaseUrl = 'https://relay.example';
authToken: 'test_auth_token',
ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
type ResponseLike = { type ResponseLike = {
ok: boolean; ok: boolean;
@@ -99,7 +94,7 @@ describe('TwitterClient news API coverage', () => {
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getNews(1); const result = await client.getNews(1);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -125,7 +120,7 @@ describe('TwitterClient news API coverage', () => {
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getNews(1, { includeRaw: true }); const result = await client.getNews(1, { includeRaw: true });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -154,7 +149,7 @@ describe('TwitterClient news API coverage', () => {
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getNews(10, { aiOnly: true }); const result = await client.getNews(10, { aiOnly: true });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -180,7 +175,7 @@ describe('TwitterClient news API coverage', () => {
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getNews(1, { tabs: ['news'] }); const result = await client.getNews(1, { tabs: ['news'] });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -195,7 +190,7 @@ describe('TwitterClient news API coverage', () => {
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getNews(10); const result = await client.getNews(10);
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -214,7 +209,7 @@ describe('TwitterClient news API coverage', () => {
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getNews(10); const result = await client.getNews(10);
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -226,7 +221,7 @@ describe('TwitterClient news API coverage', () => {
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getNews(10); const result = await client.getNews(10);
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -262,7 +257,7 @@ describe('TwitterClient news API coverage', () => {
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getNews(10); const result = await client.getNews(10);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -296,7 +291,7 @@ describe('TwitterClient news API coverage', () => {
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getNews(2); const result = await client.getNews(2);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -326,7 +321,7 @@ describe('TwitterClient news API coverage', () => {
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getNews(1); const result = await client.getNews(1);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -360,7 +355,7 @@ describe('TwitterClient news API coverage', () => {
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getNews(2); const result = await client.getNews(2);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -387,7 +382,7 @@ describe('TwitterClient news API coverage', () => {
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getNews(1); const result = await client.getNews(1);
expect(result.success).toBe(true); expect(result.success).toBe(true);
+10 -15
View File
@@ -2,12 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { extractArticleText } from '../src/lib/twitter-client-utils.js'; import { extractArticleText } from '../src/lib/twitter-client-utils.js';
const validCookies = { const testRelayBaseUrl = 'https://relay.example';
authToken: 'test_auth_token',
ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
type ResponseLike = { type ResponseLike = {
ok: boolean; ok: boolean;
@@ -40,7 +35,7 @@ describe('TwitterClient upload coverage', () => {
describe('uploadMedia error paths', () => { describe('uploadMedia error paths', () => {
it('rejects unsupported media types', async () => { it('rejects unsupported media types', async () => {
global.fetch = vi.fn() as unknown as typeof fetch; global.fetch = vi.fn() as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.uploadMedia({ data: new Uint8Array([1, 2, 3]), mimeType: 'application/pdf' }); const result = await client.uploadMedia({ data: new Uint8Array([1, 2, 3]), mimeType: 'application/pdf' });
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -53,7 +48,7 @@ describe('TwitterClient upload coverage', () => {
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' })); .mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' }); const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -67,7 +62,7 @@ describe('TwitterClient upload coverage', () => {
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' })); .mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' }); const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -82,7 +77,7 @@ describe('TwitterClient upload coverage', () => {
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' })); .mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' }); const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -101,7 +96,7 @@ describe('TwitterClient upload coverage', () => {
); );
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' }); const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' });
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -121,7 +116,7 @@ describe('TwitterClient upload coverage', () => {
.mockResolvedValueOnce(makeResponse({ ok: false, status: 503, text: async () => 'down' })); .mockResolvedValueOnce(makeResponse({ ok: false, status: 503, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientUploadPrivate; const clientPrivate = client as unknown as TwitterClientUploadPrivate;
clientPrivate.sleep = vi.fn().mockResolvedValue(undefined); clientPrivate.sleep = vi.fn().mockResolvedValue(undefined);
@@ -149,7 +144,7 @@ describe('TwitterClient upload coverage', () => {
.mockResolvedValueOnce(makeResponse({ json: async () => ({ processing_info: { state: 'succeeded' } }) })); .mockResolvedValueOnce(makeResponse({ json: async () => ({ processing_info: { state: 'succeeded' } }) }));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientUploadPrivate; const clientPrivate = client as unknown as TwitterClientUploadPrivate;
clientPrivate.sleep = vi.fn().mockResolvedValue(undefined); clientPrivate.sleep = vi.fn().mockResolvedValue(undefined);
@@ -167,7 +162,7 @@ describe('TwitterClient upload coverage', () => {
.mockResolvedValueOnce(makeResponse({ ok: false, status: 400, text: async () => 'bad' })); .mockResolvedValueOnce(makeResponse({ ok: false, status: 400, text: async () => 'bad' }));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png', alt: 'alt' }); const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png', alt: 'alt' });
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -178,7 +173,7 @@ describe('TwitterClient upload coverage', () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom')); const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch; global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' }); const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false); expect(result.success).toBe(false);
+1 -6
View File
@@ -1,11 +1,6 @@
import type { TwitterClient } from '../src/lib/twitter-client.js'; import type { TwitterClient } from '../src/lib/twitter-client.js';
export const validCookies = { export const testRelayBaseUrl = 'https://relay.example';
authToken: 'test_auth_token',
ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
export type TwitterClientPrivate = TwitterClient & { export type TwitterClientPrivate = TwitterClient & {
getCurrentUser: () => Promise<{ getCurrentUser: () => Promise<{
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js'; import { testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -10,26 +10,22 @@ afterEach(() => {
}); });
describe('TwitterClient constructor', () => { describe('TwitterClient constructor', () => {
it('should throw if authToken is missing', () => { it('throws if relay base URL is missing', () => {
expect( const originalRelayBaseUrl = process.env.TWITTER_RELAY_BASE_URL;
() => delete process.env.TWITTER_RELAY_BASE_URL;
new TwitterClient({ try {
cookies: { authToken: null, ct0: 'test', cookieHeader: null, source: null }, expect(() => new TwitterClient({})).toThrow('TWITTER_RELAY_BASE_URL is required');
}), } finally {
).toThrow('Both authToken and ct0 cookies are required'); if (originalRelayBaseUrl === undefined) {
delete process.env.TWITTER_RELAY_BASE_URL;
} else {
process.env.TWITTER_RELAY_BASE_URL = originalRelayBaseUrl;
}
}
}); });
it('should throw if ct0 is missing', () => { it('creates client with relay base URL', () => {
expect( const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
() =>
new TwitterClient({
cookies: { authToken: 'test', ct0: null, cookieHeader: null, source: null },
}),
).toThrow('Both authToken and ct0 cookies are required');
});
it('should create client with valid cookies', () => {
const client = new TwitterClient({ cookies: validCookies });
expect(client).toBeInstanceOf(TwitterClient); expect(client).toBeInstanceOf(TwitterClient);
}); });
}); });
@@ -61,7 +57,7 @@ describe('TwitterClient tweet', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.tweet('Hello world!'); const result = await client.tweet('Hello world!');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -69,8 +65,10 @@ describe('TwitterClient tweet', () => {
expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0]; const [url, options] = mockFetch.mock.calls[0];
expect(url).toContain('CreateTweet'); expect(String(url)).toContain(`${testRelayBaseUrl}/i/api/graphql/`);
expect(String(url)).toContain('CreateTweet');
expect(options.method).toBe('POST'); expect(options.method).toBe('POST');
expect(options.headers).toEqual({ 'content-type': 'application/json' });
const body = JSON.parse(options.body); const body = JSON.parse(options.body);
expect(body.variables.tweet_text).toBe('Hello world!'); expect(body.variables.tweet_text).toBe('Hello world!');
@@ -94,7 +92,7 @@ describe('TwitterClient tweet', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.tweet('Hello world!', ['111', '222']); const result = await client.tweet('Hello world!', ['111', '222']);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -136,7 +134,7 @@ describe('TwitterClient tweet', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.tweet('Hello world!'); const result = await client.tweet('Hello world!');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -146,7 +144,7 @@ describe('TwitterClient tweet', () => {
const [firstUrl] = mockFetch.mock.calls[0]; const [firstUrl] = mockFetch.mock.calls[0];
const [thirdUrl] = mockFetch.mock.calls[2]; const [thirdUrl] = mockFetch.mock.calls[2];
expect(String(firstUrl)).toContain('/CreateTweet'); expect(String(firstUrl)).toContain('/CreateTweet');
expect(String(thirdUrl)).toBe('https://x.com/i/api/graphql'); expect(String(thirdUrl)).toBe(`${testRelayBaseUrl}/i/api/graphql`);
}); });
it('should handle API errors', async () => { it('should handle API errors', async () => {
@@ -157,7 +155,7 @@ describe('TwitterClient tweet', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.tweet('Test'); const result = await client.tweet('Test');
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -184,7 +182,7 @@ describe('TwitterClient tweet', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.tweet('Hello world!'); const result = await client.tweet('Hello world!');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -212,7 +210,7 @@ describe('TwitterClient tweet', () => {
text: async () => 'Forbidden', text: async () => 'Forbidden',
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.tweet('Hello world!'); const result = await client.tweet('Hello world!');
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -242,7 +240,7 @@ describe('TwitterClient tweet', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.tweet('Hello world!'); const result = await client.tweet('Hello world!');
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -268,7 +266,7 @@ describe('TwitterClient tweet', () => {
json: async () => ({}), json: async () => ({}),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.tweet('Hello world!'); const result = await client.tweet('Hello world!');
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -283,7 +281,7 @@ describe('TwitterClient tweet', () => {
text: async () => 'Forbidden', text: async () => 'Forbidden',
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.tweet('Test'); const result = await client.tweet('Test');
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -293,7 +291,7 @@ describe('TwitterClient tweet', () => {
it('should handle network errors', async () => { it('should handle network errors', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error')); mockFetch.mockRejectedValueOnce(new Error('Network error'));
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.tweet('Test'); const result = await client.tweet('Test');
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -316,7 +314,7 @@ describe('TwitterClient tweet', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.tweet('Hello world!'); const result = await client.tweet('Hello world!');
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -348,7 +346,7 @@ describe('TwitterClient reply', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.reply('This is a reply', '1234567890'); const result = await client.reply('This is a reply', '1234567890');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -382,7 +380,7 @@ describe('TwitterClient reply', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.reply('This is a reply', '1234567890', ['111', '222']); const result = await client.reply('This is a reply', '1234567890', ['111', '222']);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -392,9 +390,10 @@ describe('TwitterClient reply', () => {
const [, options] = mockFetch.mock.calls[1]; const [, options] = mockFetch.mock.calls[1];
expect(String(mockFetch.mock.calls[1][0])).toContain('statuses/update.json'); expect(String(mockFetch.mock.calls[1][0])).toContain('statuses/update.json');
expect(options.method).toBe('POST'); expect(options.method).toBe('POST');
expect(options.body).toContain('status=This+is+a+reply'); const body = String(options.body);
expect(options.body).toContain('in_reply_to_status_id=1234567890'); expect(body).toContain('status=This+is+a+reply');
expect(options.body).toContain('auto_populate_reply_metadata=true'); expect(body).toContain('in_reply_to_status_id=1234567890');
expect(options.body).toContain('media_ids=111%2C222'); expect(body).toContain('auto_populate_reply_metadata=true');
expect(body).toContain('media_ids=111%2C222');
}); });
}); });
+5 -5
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js'; import { testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -78,7 +78,7 @@ describe('TwitterClient conversation helpers', () => {
json: async () => makeConversationPayload(), json: async () => makeConversationPayload(),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getReplies('1'); const result = await client.getReplies('1');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -102,7 +102,7 @@ describe('TwitterClient conversation helpers', () => {
json: async () => payload, json: async () => payload,
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getThread('2'); const result = await client.getThread('2');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -147,7 +147,7 @@ describe('TwitterClient conversation helpers', () => {
json: async () => payload, json: async () => payload,
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getThread('1'); const result = await client.getThread('1');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -161,7 +161,7 @@ describe('TwitterClient conversation helpers', () => {
text: async () => 'oops', text: async () => 'oops',
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getThread('1'); const result = await client.getThread('1');
expect(result.success).toBe(false); expect(result.success).toBe(false);
+37 -23
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js'; import { testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -28,7 +28,7 @@ describe('TwitterClient getCurrentUser', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getCurrentUser(); const result = await client.getCurrentUser();
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -44,7 +44,7 @@ describe('TwitterClient getCurrentUser', () => {
text: async () => '{"language":"en"}', text: async () => '{"language":"en"}',
})); }));
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getCurrentUser(); const result = await client.getCurrentUser();
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -58,32 +58,26 @@ describe('TwitterClient getCurrentUser', () => {
text: async () => 'Unauthorized', text: async () => 'Unauthorized',
})); }));
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getCurrentUser(); const result = await client.getCurrentUser();
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 401'); expect(result.error).toContain('HTTP 401');
}); });
it('uses HTML fallback when API endpoints 404', async () => { it('returns error when API endpoints 404', async () => {
mockFetch mockFetch
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' }) .mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' })
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' }) .mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' })
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' }) .mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' })
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' }) .mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' });
.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () => '<html>"screen_name":"fallback","user_id":"999"</html>',
});
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getCurrentUser(); const result = await client.getCurrentUser();
expect(result.success).toBe(true); expect(result.success).toBe(false);
expect(result.user?.username).toBe('fallback'); expect(result.error).toContain('HTTP 404');
expect(result.user?.id).toBe('999'); expect(mockFetch).toHaveBeenCalledTimes(4);
expect(mockFetch).toHaveBeenCalledTimes(5);
}); });
it('skips an endpoint when JSON parsing fails', async () => { it('skips an endpoint when JSON parsing fails', async () => {
@@ -105,7 +99,7 @@ describe('TwitterClient getCurrentUser', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getCurrentUser(); const result = await client.getCurrentUser();
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -113,24 +107,44 @@ describe('TwitterClient getCurrentUser', () => {
expect(mockFetch).toHaveBeenCalledTimes(2); expect(mockFetch).toHaveBeenCalledTimes(2);
}); });
it('continues on fetch errors and still succeeds via HTML fallback', async () => { it('reads top-level id_str from verify_credentials responses', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id_str: '12345',
screen_name: 'tester',
name: 'Test User',
}),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getCurrentUser();
expect(result.success).toBe(true);
expect(result.user).toEqual({ id: '12345', username: 'tester', name: 'Test User' });
});
it('continues on fetch errors and still succeeds via later API endpoint', async () => {
mockFetch mockFetch
.mockRejectedValueOnce(new Error('boom')) .mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' }) .mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' })
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' })
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' })
.mockRejectedValueOnce(new Error('settings boom'))
.mockResolvedValueOnce({ .mockResolvedValueOnce({
ok: true, ok: true,
status: 200, status: 200,
text: async () => '<html>"screen_name":"fallback","user_id":"999"</html>', json: async () => ({
user_id: '999',
screen_name: 'fallback',
name: 'Fallback User',
}),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getCurrentUser(); const result = await client.getCurrentUser();
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.user?.username).toBe('fallback'); expect(result.user?.username).toBe('fallback');
expect(result.user?.id).toBe('999'); expect(result.user?.id).toBe('999');
expect(mockFetch).toHaveBeenCalledTimes(3);
}); });
}); });
+180
View File
@@ -0,0 +1,180 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient engagement', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
describe('like', () => {
it('successfully likes a tweet', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ data: { favorite_tweet: 'Done' } }),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.like('1234567890');
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('POST');
expect(String(url)).toContain('/FavoriteTweet');
const body = JSON.parse(options.body);
expect(body.variables.tweet_id).toBe('1234567890');
});
it('returns error on HTTP failure', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 403,
text: async () => 'Forbidden',
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.like('1234567890');
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 403');
});
it('returns error on GraphQL errors', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
errors: [{ message: 'You have already favorited this Tweet.' }],
}),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.like('1234567890');
expect(result.success).toBe(false);
expect(result.error).toContain('already favorited');
});
it('retries on 404 with refreshed query IDs', async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'Not found' }).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ data: { favorite_tweet: 'Done' } }),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.like('1234567890');
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
describe('unlike', () => {
it('successfully unlikes a tweet', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ data: { unfavorite_tweet: 'Done' } }),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.unlike('1234567890');
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('POST');
expect(String(url)).toContain('/UnfavoriteTweet');
});
});
describe('retweet', () => {
it('successfully retweets a tweet', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ data: { create_retweet: { retweet_results: { result: {} } } } }),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.retweet('1234567890');
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('POST');
expect(String(url)).toContain('/CreateRetweet');
});
});
describe('unretweet', () => {
it('successfully unretweets a tweet', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ data: { unretweet: { source_tweet_results: {} } } }),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.unretweet('1234567890');
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('POST');
expect(String(url)).toContain('/DeleteRetweet');
const body = JSON.parse(options.body);
expect(body.variables.tweet_id).toBe('1234567890');
expect(body.variables.source_tweet_id).toBe('1234567890');
});
});
describe('bookmark', () => {
it('successfully bookmarks a tweet', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ data: { tweet_bookmark_put: 'Done' } }),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.bookmark('1234567890');
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('POST');
expect(String(url)).toContain('/CreateBookmark');
});
});
describe('error handling', () => {
it('catches network errors', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.like('1234567890');
expect(result.success).toBe(false);
expect(result.error).toBe('Network error');
});
});
});
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import type { TwitterClientPrivate } from './twitter-client-fixtures.js'; import type { TwitterClientPrivate } from './twitter-client-fixtures.js';
import { validCookies } from './twitter-client-fixtures.js'; import { testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -69,7 +69,7 @@ describe('TwitterClient following pagination', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getFollowingQueryIds = async () => ['q1']; clientPrivate.getFollowingQueryIds = async () => ['q1'];
clientPrivate.getFollowingViaRest = vi.fn(); clientPrivate.getFollowingViaRest = vi.fn();
@@ -109,7 +109,7 @@ describe('TwitterClient following pagination', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getFollowingQueryIds = async () => ['q1']; clientPrivate.getFollowingQueryIds = async () => ['q1'];
clientPrivate.getFollowingViaRest = vi.fn(); clientPrivate.getFollowingViaRest = vi.fn();
@@ -147,7 +147,7 @@ describe('TwitterClient following pagination', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getFollowingQueryIds = async () => ['q1']; clientPrivate.getFollowingQueryIds = async () => ['q1'];
clientPrivate.getFollowingViaRest = vi.fn(); clientPrivate.getFollowingViaRest = vi.fn();
@@ -165,7 +165,7 @@ describe('TwitterClient following pagination', () => {
text: async () => 'boom', text: async () => 'boom',
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getFollowingQueryIds = async () => ['q1']; clientPrivate.getFollowingQueryIds = async () => ['q1'];
clientPrivate.getFollowingViaRest = vi.fn().mockResolvedValue({ success: false, error: 'rest fail' }); clientPrivate.getFollowingViaRest = vi.fn().mockResolvedValue({ success: false, error: 'rest fail' });
+8 -8
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js'; import { testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -84,7 +84,7 @@ describe('TwitterClient following/followers', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClient & { getFollowingQueryIds: () => Promise<string[]> }; const clientPrivate = client as unknown as TwitterClient & { getFollowingQueryIds: () => Promise<string[]> };
clientPrivate.getFollowingQueryIds = async () => ['test']; clientPrivate.getFollowingQueryIds = async () => ['test'];
@@ -138,7 +138,7 @@ describe('TwitterClient following/followers', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise<string[]> }; const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise<string[]> };
clientPrivate.getFollowersQueryIds = async () => ['test']; clientPrivate.getFollowersQueryIds = async () => ['test'];
@@ -183,7 +183,7 @@ describe('TwitterClient following/followers', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClient & { const clientPrivate = client as unknown as TwitterClient & {
getFollowingQueryIds: () => Promise<string[]>; getFollowingQueryIds: () => Promise<string[]>;
refreshQueryIds: () => Promise<void>; refreshQueryIds: () => Promise<void>;
@@ -225,7 +225,7 @@ describe('TwitterClient following/followers', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClient & { getFollowingQueryIds: () => Promise<string[]> }; const clientPrivate = client as unknown as TwitterClient & { getFollowingQueryIds: () => Promise<string[]> };
clientPrivate.getFollowingQueryIds = async () => ['test']; clientPrivate.getFollowingQueryIds = async () => ['test'];
@@ -265,7 +265,7 @@ describe('TwitterClient following/followers', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise<string[]> }; const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise<string[]> };
clientPrivate.getFollowersQueryIds = async () => ['test']; clientPrivate.getFollowersQueryIds = async () => ['test'];
@@ -320,7 +320,7 @@ describe('TwitterClient following/followers', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise<string[]> }; const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise<string[]> };
clientPrivate.getFollowersQueryIds = async () => ['test']; clientPrivate.getFollowersQueryIds = async () => ['test'];
@@ -368,7 +368,7 @@ describe('TwitterClient following/followers', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise<string[]> }; const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise<string[]> };
clientPrivate.getFollowersQueryIds = async () => ['test']; clientPrivate.getFollowersQueryIds = async () => ['test'];
+12 -12
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { mapTweetResult } from '../src/lib/twitter-client-utils.js'; import { mapTweetResult } from '../src/lib/twitter-client-utils.js';
import { validCookies } from './twitter-client-fixtures.js'; import { testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -49,7 +49,7 @@ describe('TwitterClient getTweet', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getTweet('12345'); const result = await client.getTweet('12345');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -103,7 +103,7 @@ describe('TwitterClient getTweet', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getTweet('6789'); const result = await client.getTweet('6789');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -118,7 +118,7 @@ describe('TwitterClient getTweet', () => {
text: async () => 'Not Found', text: async () => 'Not Found',
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getTweet('missing'); const result = await client.getTweet('missing');
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -168,7 +168,7 @@ describe('TwitterClient getTweet', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getTweet('article123'); const result = await client.getTweet('article123');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -252,7 +252,7 @@ describe('TwitterClient getTweet', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getTweet('article123'); const result = await client.getTweet('article123');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -295,7 +295,7 @@ describe('TwitterClient getTweet', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getTweet('note123'); const result = await client.getTweet('note123');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -326,7 +326,7 @@ describe('TwitterClient getTweet', () => {
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => '' }) .mockResolvedValueOnce({ ok: false, status: 404, text: async () => '' })
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => payload }); .mockResolvedValueOnce({ ok: true, status: 200, json: async () => payload });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getTweet('1'); const result = await client.getTweet('1');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -366,7 +366,7 @@ describe('TwitterClient quoted tweets', () => {
const root = makeTweetResult('1', 'root'); const root = makeTweetResult('1', 'root');
root.quoted_status_result = { result: quoted }; root.quoted_status_result = { result: quoted };
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const mapped = mapTweetResult(root, (client as { quoteDepth: number }).quoteDepth); const mapped = mapTweetResult(root, (client as { quoteDepth: number }).quoteDepth);
expect(mapped?.quotedTweet?.id).toBe('2'); expect(mapped?.quotedTweet?.id).toBe('2');
@@ -378,7 +378,7 @@ describe('TwitterClient quoted tweets', () => {
const root = makeTweetResult('1', 'root'); const root = makeTweetResult('1', 'root');
root.quoted_status_result = { result: quoted }; root.quoted_status_result = { result: quoted };
const client = new TwitterClient({ cookies: validCookies, quoteDepth: 0 }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl, quoteDepth: 0 });
const mapped = mapTweetResult(root, (client as { quoteDepth: number }).quoteDepth); const mapped = mapTweetResult(root, (client as { quoteDepth: number }).quoteDepth);
expect(mapped?.quotedTweet).toBeUndefined(); expect(mapped?.quotedTweet).toBeUndefined();
@@ -391,7 +391,7 @@ describe('TwitterClient quoted tweets', () => {
const root = makeTweetResult('1', 'root'); const root = makeTweetResult('1', 'root');
root.quoted_status_result = { result: quoted1 }; root.quoted_status_result = { result: quoted1 };
const client = new TwitterClient({ cookies: validCookies, quoteDepth: 2 }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl, quoteDepth: 2 });
const mapped = mapTweetResult(root, (client as { quoteDepth: number }).quoteDepth); const mapped = mapTweetResult(root, (client as { quoteDepth: number }).quoteDepth);
expect(mapped?.quotedTweet?.id).toBe('2'); expect(mapped?.quotedTweet?.id).toBe('2');
@@ -409,7 +409,7 @@ describe('TwitterClient quoted tweets', () => {
}, },
}; };
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const mapped = mapTweetResult(root, (client as { quoteDepth: number }).quoteDepth); const mapped = mapTweetResult(root, (client as { quoteDepth: number }).quoteDepth);
expect(mapped?.quotedTweet?.id).toBe('2'); expect(mapped?.quotedTweet?.id).toBe('2');
+123 -8
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js'; import { testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -72,7 +72,7 @@ describe('TwitterClient home timeline', () => {
json: async () => makeHomeTimelineResponse('123456', 'Hello from For You feed!', 'testuser'), json: async () => makeHomeTimelineResponse('123456', 'Hello from For You feed!', 'testuser'),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeTimeline(1); const result = await client.getHomeTimeline(1);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -89,7 +89,7 @@ describe('TwitterClient home timeline', () => {
text: async () => 'Unauthorized', text: async () => 'Unauthorized',
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeTimeline(1); const result = await client.getHomeTimeline(1);
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -104,7 +104,7 @@ describe('TwitterClient home timeline', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeTimeline(1); const result = await client.getHomeTimeline(1);
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -119,7 +119,7 @@ describe('TwitterClient home timeline', () => {
json: async () => makeHomeTimelineResponse('789012', 'Hello from Following feed!', 'chronouser'), json: async () => makeHomeTimelineResponse('789012', 'Hello from Following feed!', 'chronouser'),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeLatestTimeline(1); const result = await client.getHomeLatestTimeline(1);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -136,7 +136,7 @@ describe('TwitterClient home timeline', () => {
text: async () => 'Forbidden', text: async () => 'Forbidden',
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeLatestTimeline(1); const result = await client.getHomeLatestTimeline(1);
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -145,6 +145,121 @@ describe('TwitterClient home timeline', () => {
}); });
describe('pagination', () => { describe('pagination', () => {
it('returns the next cursor when stopping before the end', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
home: {
home_timeline_urt: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: { full_text: 'First tweet', created_at: 'Mon Jan 06 00:00:00 +0000 2025' },
core: {
user_results: {
result: { rest_id: 'u1', legacy: { screen_name: 'user1', name: 'User 1' } },
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor123',
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeTimeline(1);
expect(result.success).toBe(true);
expect(result.nextCursor).toBe('cursor123');
});
it('resumes from a provided cursor', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => makeHomeTimelineResponse('tweet2', 'Second page tweet', 'user2'),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeLatestTimeline(1, { cursor: 'resume-cursor' });
expect(result.success).toBe(true);
const callUrl = String(mockFetch.mock.calls[0]?.[0]);
const variables = JSON.parse(new URL(callUrl).searchParams.get('variables') ?? '{}') as { cursor?: string };
expect(variables.cursor).toBe('resume-cursor');
});
it('stops at maxPages and returns the cursor to resume', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
home: {
home_timeline_urt: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: { full_text: 'First tweet', created_at: 'Mon Jan 06 00:00:00 +0000 2025' },
core: {
user_results: {
result: { rest_id: 'u1', legacy: { screen_name: 'user1', name: 'User 1' } },
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-2',
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeTimeline(5, { maxPages: 1 });
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(result.nextCursor).toBe('cursor-page-2');
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should deduplicate tweets across pages', async () => { it('should deduplicate tweets across pages', async () => {
// First page // First page
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
@@ -241,7 +356,7 @@ describe('TwitterClient home timeline', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeTimeline(2); const result = await client.getHomeTimeline(2);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -333,7 +448,7 @@ describe('TwitterClient home timeline', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeTimeline(5); const result = await client.getHomeTimeline(5);
expect(result.success).toBe(true); expect(result.success).toBe(true);
+8 -8
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { mapTweetResult, parseTweetsFromInstructions } from '../src/lib/twitter-client-utils.js'; import { mapTweetResult, parseTweetsFromInstructions } from '../src/lib/twitter-client-utils.js';
import { validCookies } from './twitter-client-fixtures.js'; import { testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -207,7 +207,7 @@ describe('TwitterClient getTweet with includeRaw option', () => {
json: async () => mockTweetResponse, json: async () => mockTweetResponse,
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getTweet('12345'); const result = await client.getTweet('12345');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -220,7 +220,7 @@ describe('TwitterClient getTweet with includeRaw option', () => {
json: async () => mockTweetResponse, json: async () => mockTweetResponse,
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getTweet('12345', { includeRaw: true }); const result = await client.getTweet('12345', { includeRaw: true });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -280,7 +280,7 @@ describe('TwitterClient getReplies with includeRaw option', () => {
json: async () => mockRepliesResponse, json: async () => mockRepliesResponse,
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getReplies('12345', { includeRaw: true }); const result = await client.getReplies('12345', { includeRaw: true });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -340,7 +340,7 @@ describe('TwitterClient getThread with includeRaw option', () => {
json: async () => mockThreadResponse, json: async () => mockThreadResponse,
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getThread('12345', { includeRaw: true }); const result = await client.getThread('12345', { includeRaw: true });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -402,7 +402,7 @@ describe('TwitterClient search with includeRaw option', () => {
json: async () => mockSearchResponse, json: async () => mockSearchResponse,
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.search('test query', 10, { includeRaw: true }); const result = await client.search('test query', 10, { includeRaw: true });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -417,7 +417,7 @@ describe('TwitterClient search with includeRaw option', () => {
json: async () => mockSearchResponse, json: async () => mockSearchResponse,
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.search('test query', 10); const result = await client.search('test query', 10);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -462,7 +462,7 @@ describe('--json-full flag behavior (without --json)', () => {
json: async () => mockTweetResponse, json: async () => mockTweetResponse,
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
// Only passing includeRaw: true, simulating --json-full without --json // Only passing includeRaw: true, simulating --json-full without --json
const result = await client.getTweet('12345', { includeRaw: true }); const result = await client.getTweet('12345', { includeRaw: true });
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js'; import { type TwitterClientPrivate, testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -60,7 +60,7 @@ describe('TwitterClient timelines includeRaw', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getBookmarks(1, { includeRaw: true }); const result = await client.getBookmarks(1, { includeRaw: true });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -112,7 +112,7 @@ describe('TwitterClient timelines includeRaw', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getBookmarkFolderTimeline('123', 1, { includeRaw: true }); const result = await client.getBookmarkFolderTimeline('123', 1, { includeRaw: true });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -168,7 +168,7 @@ describe('TwitterClient timelines includeRaw', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ clientPrivate.getCurrentUser = async () => ({
success: true, success: true,
@@ -229,7 +229,7 @@ describe('TwitterClient timelines includeRaw', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test']; clientPrivate.getListTimelineQueryIds = async () => ['test'];
+5 -5
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js'; import { type TwitterClientPrivate, testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -67,7 +67,7 @@ describe('TwitterClient likes', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ clientPrivate.getCurrentUser = async () => ({
success: true, success: true,
@@ -198,7 +198,7 @@ describe('TwitterClient likes', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ clientPrivate.getCurrentUser = async () => ({
success: true, success: true,
@@ -275,7 +275,7 @@ describe('TwitterClient likes', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ clientPrivate.getCurrentUser = async () => ({
success: true, success: true,
@@ -292,7 +292,7 @@ describe('TwitterClient likes', () => {
}); });
it('returns an error when current user is unavailable', async () => { it('returns an error when current user is unavailable', async () => {
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ success: false, error: 'no user' }); clientPrivate.getCurrentUser = async () => ({ success: false, error: 'no user' });
@@ -2,7 +2,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js'; import { type TwitterClientPrivate, testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -61,7 +61,7 @@ describe('TwitterClient lists memberships', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ clientPrivate.getCurrentUser = async () => ({
success: true, success: true,
@@ -118,7 +118,7 @@ describe('TwitterClient lists memberships', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ clientPrivate.getCurrentUser = async () => ({
success: true, success: true,
@@ -2,7 +2,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js'; import { type TwitterClientPrivate, testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -27,7 +27,7 @@ describe('TwitterClient lists owned errors', () => {
text: async () => 'Internal Server Error', text: async () => 'Internal Server Error',
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ clientPrivate.getCurrentUser = async () => ({
success: true, success: true,
@@ -42,7 +42,7 @@ describe('TwitterClient lists owned errors', () => {
}); });
it('returns error when getCurrentUser fails', async () => { it('returns error when getCurrentUser fails', async () => {
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ clientPrivate.getCurrentUser = async () => ({
success: false, success: false,
@@ -65,7 +65,7 @@ describe('TwitterClient lists owned errors', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ clientPrivate.getCurrentUser = async () => ({
success: true, success: true,
@@ -88,7 +88,7 @@ describe('TwitterClient lists owned errors', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ clientPrivate.getCurrentUser = async () => ({
success: true, success: true,
@@ -143,7 +143,7 @@ describe('TwitterClient lists owned errors', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ clientPrivate.getCurrentUser = async () => ({
success: true, success: true,
@@ -223,7 +223,7 @@ describe('TwitterClient lists owned errors', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ clientPrivate.getCurrentUser = async () => ({
success: true, success: true,
@@ -274,7 +274,7 @@ describe('TwitterClient lists owned errors', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ clientPrivate.getCurrentUser = async () => ({
success: true, success: true,
+10 -10
View File
@@ -2,7 +2,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js'; import { type TwitterClientPrivate, testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -65,7 +65,7 @@ describe('TwitterClient lists owned', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ clientPrivate.getCurrentUser = async () => ({
success: true, success: true,
@@ -105,7 +105,7 @@ describe('TwitterClient lists owned', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ clientPrivate.getCurrentUser = async () => ({
success: true, success: true,
@@ -118,11 +118,11 @@ describe('TwitterClient lists owned', () => {
const [url] = mockFetch.mock.calls[0]; const [url] = mockFetch.mock.calls[0];
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string); const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
expect(parsedFeatures.responsive_web_graphql_exclude_directive_enabled).toBe(true); expect(parsedFeatures.responsive_web_graphql_exclude_directive_enabled).toBe(true);
expect(parsedFeatures.blue_business_profile_image_shape_enabled).toBe(false); expect(parsedFeatures.blue_business_profile_image_shape_enabled).toBe(true);
expect(parsedFeatures.responsive_web_text_conversations_enabled).toBe(false); expect(parsedFeatures.responsive_web_text_conversations_enabled).toBe(false);
expect(parsedFeatures.tweetypie_unmention_optimization_enabled).toBe(true); expect(parsedFeatures.tweetypie_unmention_optimization_enabled).toBe(true);
expect(parsedFeatures.vibe_api_enabled).toBe(false); expect(parsedFeatures.vibe_api_enabled).toBe(true);
expect(parsedFeatures.interactive_text_enabled).toBe(false); expect(parsedFeatures.interactive_text_enabled).toBe(true);
}); });
it('handles private lists correctly', async () => { it('handles private lists correctly', async () => {
@@ -160,7 +160,7 @@ describe('TwitterClient lists owned', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ clientPrivate.getCurrentUser = async () => ({
success: true, success: true,
@@ -209,7 +209,7 @@ describe('TwitterClient lists owned', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ clientPrivate.getCurrentUser = async () => ({
success: true, success: true,
@@ -258,7 +258,7 @@ describe('TwitterClient lists owned', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ clientPrivate.getCurrentUser = async () => ({
success: true, success: true,
@@ -291,7 +291,7 @@ describe('TwitterClient lists owned', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ clientPrivate.getCurrentUser = async () => ({
success: true, success: true,
@@ -2,7 +2,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js'; import { type TwitterClientPrivate, testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -68,7 +68,7 @@ describe('TwitterClient lists pagination (continued)', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test']; clientPrivate.getListTimelineQueryIds = async () => ['test'];
@@ -213,7 +213,7 @@ describe('TwitterClient lists pagination (continued)', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test']; clientPrivate.getListTimelineQueryIds = async () => ['test'];
@@ -334,7 +334,7 @@ describe('TwitterClient lists pagination (continued)', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test']; clientPrivate.getListTimelineQueryIds = async () => ['test'];
@@ -449,7 +449,7 @@ describe('TwitterClient lists pagination (continued)', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test']; clientPrivate.getListTimelineQueryIds = async () => ['test'];
@@ -2,7 +2,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js'; import { type TwitterClientPrivate, testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -123,7 +123,7 @@ describe('TwitterClient lists pagination', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test']; clientPrivate.getListTimelineQueryIds = async () => ['test'];
@@ -250,7 +250,7 @@ describe('TwitterClient lists pagination', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test']; clientPrivate.getListTimelineQueryIds = async () => ['test'];
+6 -6
View File
@@ -2,7 +2,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js'; import { type TwitterClientPrivate, testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -69,7 +69,7 @@ describe('TwitterClient lists timeline', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test']; clientPrivate.getListTimelineQueryIds = async () => ['test'];
@@ -98,7 +98,7 @@ describe('TwitterClient lists timeline', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test']; clientPrivate.getListTimelineQueryIds = async () => ['test'];
@@ -115,7 +115,7 @@ describe('TwitterClient lists timeline', () => {
text: async () => 'Forbidden', text: async () => 'Forbidden',
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test']; clientPrivate.getListTimelineQueryIds = async () => ['test'];
@@ -134,7 +134,7 @@ describe('TwitterClient lists timeline', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test']; clientPrivate.getListTimelineQueryIds = async () => ['test'];
@@ -199,7 +199,7 @@ describe('TwitterClient lists timeline', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test']; clientPrivate.getListTimelineQueryIds = async () => ['test'];
clientPrivate.refreshQueryIds = async () => {}; clientPrivate.refreshQueryIds = async () => {};
+4 -4
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js'; import { testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -34,7 +34,7 @@ describe('TwitterClient uploadMedia', () => {
ok: true, ok: true,
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const data = new Uint8Array([1, 2, 3, 4]); const data = new Uint8Array([1, 2, 3, 4]);
const result = await client.uploadMedia({ data, mimeType: 'image/png', alt: 'alt text' }); const result = await client.uploadMedia({ data, mimeType: 'image/png', alt: 'alt text' });
@@ -43,7 +43,7 @@ describe('TwitterClient uploadMedia', () => {
expect(mockFetch).toHaveBeenCalledTimes(4); expect(mockFetch).toHaveBeenCalledTimes(4);
const [initUrl, initOptions] = mockFetch.mock.calls[0]; const [initUrl, initOptions] = mockFetch.mock.calls[0];
expect(String(initUrl)).toContain('upload.twitter.com'); expect(String(initUrl)).toBe(`${testRelayBaseUrl}/i/media/upload.json`);
expect(initOptions.method).toBe('POST'); expect(initOptions.method).toBe('POST');
expect(initOptions.body).toBeInstanceOf(URLSearchParams); expect(initOptions.body).toBeInstanceOf(URLSearchParams);
expect((initOptions.body as URLSearchParams).get('command')).toBe('INIT'); expect((initOptions.body as URLSearchParams).get('command')).toBe('INIT');
@@ -87,7 +87,7 @@ describe('TwitterClient uploadMedia', () => {
json: async () => ({ processing_info: { state: 'succeeded' } }), json: async () => ({ processing_info: { state: 'succeeded' } }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const data = new Uint8Array([1, 2, 3, 4, 5]); const data = new Uint8Array([1, 2, 3, 4, 5]);
const result = await client.uploadMedia({ data, mimeType: 'video/mp4', alt: 'ignored' }); const result = await client.uploadMedia({ data, mimeType: 'video/mp4', alt: 'ignored' });
+13 -13
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js'; import { testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -75,7 +75,7 @@ describe('TwitterClient pagination for thread and replies', () => {
json: async () => makeConversationPayload(['2', '3'], undefined, '1'), json: async () => makeConversationPayload(['2', '3'], undefined, '1'),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getRepliesPaged('1'); const result = await client.getRepliesPaged('1');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -98,7 +98,7 @@ describe('TwitterClient pagination for thread and replies', () => {
json: async () => makeConversationPayload(['4', '5'], undefined, '1'), json: async () => makeConversationPayload(['4', '5'], undefined, '1'),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getRepliesPaged('1', { pageDelayMs: 0 }); const result = await client.getRepliesPaged('1', { pageDelayMs: 0 });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -121,7 +121,7 @@ describe('TwitterClient pagination for thread and replies', () => {
json: async () => makeConversationPayload(['4', '5'], 'cursor-page-3', '1'), json: async () => makeConversationPayload(['4', '5'], 'cursor-page-3', '1'),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getRepliesPaged('1', { maxPages: 2, pageDelayMs: 0 }); const result = await client.getRepliesPaged('1', { maxPages: 2, pageDelayMs: 0 });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -137,7 +137,7 @@ describe('TwitterClient pagination for thread and replies', () => {
json: async () => makeConversationPayload(['4', '5'], undefined, '1'), json: async () => makeConversationPayload(['4', '5'], undefined, '1'),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getRepliesPaged('1', { cursor: 'resume-cursor', pageDelayMs: 0 }); const result = await client.getRepliesPaged('1', { cursor: 'resume-cursor', pageDelayMs: 0 });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -161,7 +161,7 @@ describe('TwitterClient pagination for thread and replies', () => {
json: async () => makeConversationPayload(['3', '4'], undefined, '1'), // '3' is duplicate json: async () => makeConversationPayload(['3', '4'], undefined, '1'), // '3' is duplicate
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getRepliesPaged('1', { pageDelayMs: 0 }); const result = await client.getRepliesPaged('1', { pageDelayMs: 0 });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -182,7 +182,7 @@ describe('TwitterClient pagination for thread and replies', () => {
text: async () => 'Internal Server Error', text: async () => 'Internal Server Error',
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getRepliesPaged('1', { pageDelayMs: 0 }); const result = await client.getRepliesPaged('1', { pageDelayMs: 0 });
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -209,7 +209,7 @@ describe('TwitterClient pagination for thread and replies', () => {
json: async () => makeConversationPayload(['3'], undefined, '1'), json: async () => makeConversationPayload(['3'], undefined, '1'),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getRepliesPaged('1', { pageDelayMs: 0 }); const result = await client.getRepliesPaged('1', { pageDelayMs: 0 });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -227,7 +227,7 @@ describe('TwitterClient pagination for thread and replies', () => {
json: async () => makeConversationPayload(['1', '2', '3'], undefined, undefined, '1'), json: async () => makeConversationPayload(['1', '2', '3'], undefined, undefined, '1'),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getThreadPaged('1'); const result = await client.getThreadPaged('1');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -248,7 +248,7 @@ describe('TwitterClient pagination for thread and replies', () => {
json: async () => makeConversationPayload(['3', '4'], undefined, undefined, '1'), json: async () => makeConversationPayload(['3', '4'], undefined, undefined, '1'),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getThreadPaged('1', { pageDelayMs: 0 }); const result = await client.getThreadPaged('1', { pageDelayMs: 0 });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -320,7 +320,7 @@ describe('TwitterClient pagination for thread and replies', () => {
json: async () => payload, json: async () => payload,
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getThreadPaged('1'); const result = await client.getThreadPaged('1');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -391,7 +391,7 @@ describe('TwitterClient pagination for thread and replies', () => {
json: async () => payload, json: async () => payload,
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getThreadPaged('1'); const result = await client.getThreadPaged('1');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -412,7 +412,7 @@ describe('TwitterClient pagination for thread and replies', () => {
json: async () => makeConversationPayload(['3', '4'], 'cursor-page-3', undefined, '1'), json: async () => makeConversationPayload(['3', '4'], 'cursor-page-3', undefined, '1'),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getThreadPaged('1', { maxPages: 2, pageDelayMs: 0 }); const result = await client.getThreadPaged('1', { maxPages: 2, pageDelayMs: 0 });
expect(result.success).toBe(true); expect(result.success).toBe(true);
+27 -21
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js'; import { testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -69,7 +69,7 @@ describe('TwitterClient search', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.search('needle', 1); const result = await client.search('needle', 1);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -83,8 +83,12 @@ describe('TwitterClient search', () => {
expect(body.queryId).toBeDefined(); expect(body.queryId).toBeDefined();
const urlVars = new URL(url as string).searchParams.get('variables'); const urlVars = new URL(url as string).searchParams.get('variables');
expect(urlVars).toBeTruthy(); expect(urlVars).toBeTruthy();
const parsed = JSON.parse(urlVars as string) as { rawQuery?: string }; const parsed = JSON.parse(urlVars as string) as {
rawQuery?: string;
product?: string;
};
expect(parsed.rawQuery).toBe('needle'); expect(parsed.rawQuery).toBe('needle');
expect(parsed.product).toBe('Latest');
}); });
it('refreshes query IDs when all search endpoints 404', async () => { it('refreshes query IDs when all search endpoints 404', async () => {
@@ -109,7 +113,7 @@ describe('TwitterClient search', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.search('hello', 5); const result = await client.search('hello', 5);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -117,7 +121,7 @@ describe('TwitterClient search', () => {
}); });
it('returns an unknown error when no query IDs are available', async () => { it('returns an unknown error when no query IDs are available', async () => {
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
(client as unknown as { getSearchTimelineQueryIds: () => Promise<string[]> }).getSearchTimelineQueryIds = (client as unknown as { getSearchTimelineQueryIds: () => Promise<string[]> }).getSearchTimelineQueryIds =
async () => []; async () => [];
@@ -185,8 +189,8 @@ describe('TwitterClient search', () => {
json: async () => makeResponse(['2', '3']), json: async () => makeResponse(['2', '3']),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.search('needle', 3); const result = await client.search('needle', 3, { product: 'Top' });
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1', '2', '3']); expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1', '2', '3']);
@@ -194,13 +198,15 @@ describe('TwitterClient search', () => {
const firstVars = JSON.parse( const firstVars = JSON.parse(
new URL(mockFetch.mock.calls[0][0] as string).searchParams.get('variables') as string, new URL(mockFetch.mock.calls[0][0] as string).searchParams.get('variables') as string,
) as { cursor?: string }; ) as { cursor?: string; product?: string };
const secondVars = JSON.parse( const secondVars = JSON.parse(
new URL(mockFetch.mock.calls[1][0] as string).searchParams.get('variables') as string, new URL(mockFetch.mock.calls[1][0] as string).searchParams.get('variables') as string,
) as { cursor?: string }; ) as { cursor?: string; product?: string };
expect(firstVars.cursor).toBeUndefined(); expect(firstVars.cursor).toBeUndefined();
expect(secondVars.cursor).toBe('cursor-1'); expect(secondVars.cursor).toBe('cursor-1');
expect(firstVars.product).toBe('Top');
expect(secondVars.product).toBe('Top');
}); });
it('stops paginating when the cursor repeats', async () => { it('stops paginating when the cursor repeats', async () => {
@@ -260,7 +266,7 @@ describe('TwitterClient search', () => {
json: async () => makeResponse(['3'], 'same-cursor'), json: async () => makeResponse(['3'], 'same-cursor'),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.search('needle', 4); const result = await client.search('needle', 4);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -334,7 +340,7 @@ describe('TwitterClient search', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.search('needle', 3); const result = await client.search('needle', 3);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -411,7 +417,7 @@ describe('TwitterClient search', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getAllSearchResults('query', { maxPages: 1 }); const result = await client.getAllSearchResults('query', { maxPages: 1 });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -466,7 +472,7 @@ describe('TwitterClient search', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getAllSearchResults('query', { cursor: 'old-cursor' }); const result = await client.getAllSearchResults('query', { cursor: 'old-cursor' });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -533,7 +539,7 @@ describe('TwitterClient bookmarks', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getBookmarks(2); const result = await client.getBookmarks(2);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -648,7 +654,7 @@ describe('TwitterClient bookmarks', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getAllBookmarks(); const result = await client.getAllBookmarks();
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -760,7 +766,7 @@ describe('TwitterClient bookmarks', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getAllBookmarks({ maxPages: 1 }); const result = await client.getAllBookmarks({ maxPages: 1 });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -816,7 +822,7 @@ describe('TwitterClient bookmarks', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getAllBookmarks({ cursor: 'cursor-1' }); const result = await client.getAllBookmarks({ cursor: 'cursor-1' });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -931,7 +937,7 @@ describe('TwitterClient bookmarks', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getAllBookmarks(); const result = await client.getAllBookmarks();
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -988,7 +994,7 @@ describe('TwitterClient bookmarks', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getAllBookmarks({ maxPages: 1 }); const result = await client.getAllBookmarks({ maxPages: 1 });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -1051,7 +1057,7 @@ describe('TwitterClient bookmark folders', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getBookmarkFolderTimeline('123', 2); const result = await client.getBookmarkFolderTimeline('123', 2);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -1123,7 +1129,7 @@ describe('TwitterClient bookmark folders', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const clientPrivate = client as TwitterClient & { getBookmarkFolderQueryIds: () => Promise<string[]> }; const clientPrivate = client as TwitterClient & { getBookmarkFolderQueryIds: () => Promise<string[]> };
clientPrivate.getBookmarkFolderQueryIds = async () => ['test']; clientPrivate.getBookmarkFolderQueryIds = async () => ['test'];
+220
View File
@@ -0,0 +1,220 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient getUserAboutAccount', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('returns account information for a valid user', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user_result_by_screen_name: {
result: {
about_profile: {
account_based_in: 'Germany',
source: 'Twitter',
created_country_accurate: true,
location_accurate: true,
learn_more_url: 'https://help.twitter.com/about-account',
},
},
},
},
}),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserAboutAccount('testuser');
expect(result.success).toBe(true);
expect(result.aboutProfile).toBeDefined();
expect(result.aboutProfile?.accountBasedIn).toBe('Germany');
expect(result.aboutProfile?.source).toBe('Twitter');
expect(result.aboutProfile?.createdCountryAccurate).toBe(true);
expect(result.aboutProfile?.locationAccurate).toBe(true);
expect(result.aboutProfile?.learnMoreUrl).toBe('https://help.twitter.com/about-account');
});
it('handles username with @ prefix', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user_result_by_screen_name: {
result: {
about_profile: {
account_based_in: 'United States',
},
},
},
},
}),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserAboutAccount('@testuser');
expect(result.success).toBe(true);
expect(result.aboutProfile?.accountBasedIn).toBe('United States');
});
it('returns error for invalid username', async () => {
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserAboutAccount('');
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid username');
});
it('handles missing about_profile in response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user_result_by_screen_name: {
result: {},
},
},
}),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserAboutAccount('testuser');
expect(result.success).toBe(false);
expect(result.error).toContain('Missing about_profile');
});
it('handles GraphQL errors in response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
errors: [{ message: 'User not found' }],
}),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserAboutAccount('testuser');
expect(result.success).toBe(false);
expect(result.error).toContain('User not found');
});
it('handles HTTP 404 error', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
text: async () => 'Not Found',
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserAboutAccount('testuser');
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 404');
});
it('handles non-404 HTTP errors', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => 'Internal Server Error',
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserAboutAccount('testuser');
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('handles network errors', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserAboutAccount('testuser');
expect(result.success).toBe(false);
expect(result.error).toContain('Network error');
});
it('handles partial about_profile data', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user_result_by_screen_name: {
result: {
about_profile: {
account_based_in: 'Japan',
// Other fields missing
},
},
},
},
}),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserAboutAccount('testuser');
expect(result.success).toBe(true);
expect(result.aboutProfile?.accountBasedIn).toBe('Japan');
expect(result.aboutProfile?.source).toBeUndefined();
expect(result.aboutProfile?.createdCountryAccurate).toBeUndefined();
expect(result.aboutProfile?.locationAccurate).toBeUndefined();
expect(result.aboutProfile?.learnMoreUrl).toBeUndefined();
});
it('makes proper GraphQL request with correct parameters', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user_result_by_screen_name: {
result: {
about_profile: {
account_based_in: 'Canada',
},
},
},
},
}),
});
const client = new TwitterClient({ relayBaseUrl: 'http://relay.test', profileName: 'test-profile' });
await client.getUserAboutAccount('testuser');
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain('http://relay.test/i/api/graphql/');
expect(url).toContain('/AboutAccountQuery?');
expect(url).toContain('variables=');
expect(url).toContain('testuser');
expect(options.method).toBe('GET');
expect(options.headers).toMatchObject({
'x-profile-name': 'test-profile',
});
});
});
+188 -8
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js'; import { testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -37,7 +37,7 @@ describe('TwitterClient getUserIdByUsername', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserIdByUsername('@testuser'); const result = await client.getUserIdByUsername('@testuser');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -66,7 +66,7 @@ describe('TwitterClient getUserIdByUsername', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserIdByUsername('anotheruser'); const result = await client.getUserIdByUsername('anotheruser');
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -74,7 +74,7 @@ describe('TwitterClient getUserIdByUsername', () => {
}); });
it('returns error for invalid username format', async () => { it('returns error for invalid username format', async () => {
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserIdByUsername('invalid handle with spaces'); const result = await client.getUserIdByUsername('invalid handle with spaces');
@@ -84,7 +84,7 @@ describe('TwitterClient getUserIdByUsername', () => {
}); });
it('returns error for empty username', async () => { it('returns error for empty username', async () => {
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserIdByUsername(''); const result = await client.getUserIdByUsername('');
@@ -94,7 +94,7 @@ describe('TwitterClient getUserIdByUsername', () => {
}); });
it('returns error for username exceeding 15 characters', async () => { it('returns error for username exceeding 15 characters', async () => {
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserIdByUsername('thisusernameiswaytoolong'); const result = await client.getUserIdByUsername('thisusernameiswaytoolong');
@@ -118,7 +118,7 @@ describe('TwitterClient getUserIdByUsername', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserIdByUsername('suspended'); const result = await client.getUserIdByUsername('suspended');
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -156,11 +156,191 @@ describe('TwitterClient getUserIdByUsername', () => {
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserIdByUsername('founduser'); const result = await client.getUserIdByUsername('founduser');
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.userId).toBe('99999'); expect(result.userId).toBe('99999');
expect(mockFetch).toHaveBeenCalledTimes(3); expect(mockFetch).toHaveBeenCalledTimes(3);
}); });
it('resolves a rich profile by screen name', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
__typename: 'User',
rest_id: '12345',
is_blue_verified: true,
verification_info: { reason: { verified_since_msec: '1700000000000' } },
avatar: { image_url: 'https://pbs.twimg.com/profile_images/123/avatar_normal.jpg' },
legacy: {
screen_name: 'testuser',
name: 'Test User',
description: 'Building tools',
location: 'Internet',
url: 'https://t.co/site',
created_at: 'Mon Jan 06 00:00:00 +0000 2025',
verified: false,
verified_type: 'Business',
followers_count: 123,
friends_count: 45,
profile_image_url_https: 'https://pbs.twimg.com/profile_images/123/legacy_normal.jpg',
entities: {
url: {
urls: [
{
url: 'https://t.co/site',
expanded_url: 'https://example.com',
display_url: 'example.com',
},
],
},
description: {
urls: [
{
url: 'https://t.co/bio',
expanded_url: 'https://bio.example',
display_url: 'bio.example',
},
],
},
},
},
affiliates_highlighted_label: {
label: {
description: 'Example Org',
url: { url: 'https://x.com/example' },
badge: { url: 'https://pbs.twimg.com/profile_images/org/badge.png' },
},
},
},
},
},
}),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserProfile('@testuser');
expect(result).toEqual({
success: true,
profile: expect.objectContaining({
id: '12345',
username: 'testuser',
name: 'Test User',
description: 'Building tools',
location: 'Internet',
url: 'https://example.com',
verified: true,
verifiedType: 'Business',
followersCount: 123,
followingCount: 45,
profileImageUrl: 'https://pbs.twimg.com/profile_images/123/avatar_normal.jpg',
entities: expect.objectContaining({
url: expect.objectContaining({
urls: [expect.objectContaining({ expanded_url: 'https://example.com' })],
}),
}),
affiliation: {
description: 'Example Org',
url: 'https://x.com/example',
badgeUrl: 'https://pbs.twimg.com/profile_images/org/badge.png',
},
raw: expect.any(Object),
}),
});
});
it('resolves a rich profile by user id', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
__typename: 'User',
rest_id: '42',
core: {
screen_name: 'byid',
name: 'By Id',
created_at: 'Tue Feb 01 00:00:00 +0000 2022',
},
location: {
location: 'Vienna & London',
},
legacy: {
description: 'Lookup by id',
followers_count: 10,
friends_count: 2,
},
},
},
},
}),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserProfile('42');
expect(result.success).toBe(true);
expect(result.profile).toMatchObject({
id: '42',
username: 'byid',
name: 'By Id',
description: 'Lookup by id',
location: 'Vienna & London',
createdAt: 'Tue Feb 01 00:00:00 +0000 2022',
followersCount: 10,
followingCount: 2,
});
expect(String(mockFetch.mock.calls[0]?.[0])).toContain('/UserByRestId?');
});
it('resolves profile batches with per-target errors', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
__typename: 'User',
rest_id: '1',
legacy: { screen_name: 'alpha', name: 'Alpha' },
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
__typename: 'UserUnavailable',
},
},
},
}),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserProfiles(['alpha', 'missing']);
expect(result).toEqual({
success: true,
results: [
{ target: 'alpha', profile: expect.objectContaining({ id: '1', username: 'alpha' }) },
{ target: 'missing', error: 'User @missing not found or unavailable' },
],
});
});
}); });
+14 -14
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js'; import { testRelayBaseUrl } from './twitter-client-fixtures.js';
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -91,7 +91,7 @@ describe('TwitterClient getUserTweets', () => {
makeTimelineResponse([makeTweetResult('1', 'First tweet'), makeTweetResult('2', 'Second tweet')]), makeTimelineResponse([makeTweetResult('1', 'First tweet'), makeTweetResult('2', 'Second tweet')]),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweets('12345', 10); const result = await client.getUserTweets('12345', 10);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -114,7 +114,7 @@ describe('TwitterClient getUserTweets', () => {
]), ]),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweets('12345', 2); const result = await client.getUserTweets('12345', 2);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -128,7 +128,7 @@ describe('TwitterClient getUserTweets', () => {
json: async () => makeTimelineResponse([]), json: async () => makeTimelineResponse([]),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweets('12345', 10); const result = await client.getUserTweets('12345', 10);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -142,7 +142,7 @@ describe('TwitterClient getUserTweets', () => {
json: async () => makeTimelineResponse([makeTweetResult('1', 'Tweet')], 'next-cursor-abc'), json: async () => makeTimelineResponse([makeTweetResult('1', 'Tweet')], 'next-cursor-abc'),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweetsPaged('12345', 20, { maxPages: 1 }); const result = await client.getUserTweetsPaged('12345', 20, { maxPages: 1 });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -156,7 +156,7 @@ describe('TwitterClient getUserTweets', () => {
text: async () => 'Internal Server Error', text: async () => 'Internal Server Error',
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweets('12345', 10); const result = await client.getUserTweets('12345', 10);
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -249,7 +249,7 @@ describe('TwitterClient getUserTweetsPaged', () => {
), ),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweetsPaged('12345', 5, { pageDelayMs: 0 }); const result = await client.getUserTweetsPaged('12345', 5, { pageDelayMs: 0 });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -275,7 +275,7 @@ describe('TwitterClient getUserTweetsPaged', () => {
}); });
} }
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweetsPaged('12345', 9999, { pageDelayMs: 0 }); const result = await client.getUserTweetsPaged('12345', 9999, { pageDelayMs: 0 });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -285,7 +285,7 @@ describe('TwitterClient getUserTweetsPaged', () => {
}); });
it('returns error for invalid limit', async () => { it('returns error for invalid limit', async () => {
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweetsPaged('12345', 0); const result = await client.getUserTweetsPaged('12345', 0);
expect(result.success).toBe(false); expect(result.success).toBe(false);
@@ -306,7 +306,7 @@ describe('TwitterClient getUserTweetsPaged', () => {
json: async () => makeTimelineResponse([makeTweetResult('2', 'Page 2 Tweet')], 'cursor-page-3'), json: async () => makeTimelineResponse([makeTweetResult('2', 'Page 2 Tweet')], 'cursor-page-3'),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweetsPaged('12345', 40, { const result = await client.getUserTweetsPaged('12345', 40, {
maxPages: 2, maxPages: 2,
pageDelayMs: 0, // No delay for tests pageDelayMs: 0, // No delay for tests
@@ -333,7 +333,7 @@ describe('TwitterClient getUserTweetsPaged', () => {
json: async () => makeTimelineResponse([makeTweetResult('2', 'Tweet 2')], 'cursor-3'), json: async () => makeTimelineResponse([makeTweetResult('2', 'Tweet 2')], 'cursor-3'),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweetsPaged('12345', 40, { const result = await client.getUserTweetsPaged('12345', 40, {
maxPages: 2, maxPages: 2,
pageDelayMs: 0, pageDelayMs: 0,
@@ -357,7 +357,7 @@ describe('TwitterClient getUserTweetsPaged', () => {
json: async () => makeTimelineResponse([makeTweetResult('2', 'Tweet 2')]), // No cursor = end json: async () => makeTimelineResponse([makeTweetResult('2', 'Tweet 2')]), // No cursor = end
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweetsPaged('12345', 100, { const result = await client.getUserTweetsPaged('12345', 100, {
maxPages: 5, maxPages: 5,
pageDelayMs: 0, pageDelayMs: 0,
@@ -386,7 +386,7 @@ describe('TwitterClient getUserTweetsPaged', () => {
]), ]),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweetsPaged('12345', 40, { const result = await client.getUserTweetsPaged('12345', 40, {
maxPages: 2, maxPages: 2,
pageDelayMs: 0, pageDelayMs: 0,
@@ -404,7 +404,7 @@ describe('TwitterClient getUserTweetsPaged', () => {
json: async () => makeTimelineResponse([makeTweetResult('5', 'Resumed Tweet')]), json: async () => makeTimelineResponse([makeTweetResult('5', 'Resumed Tweet')]),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getUserTweetsPaged('12345', 20, { const result = await client.getUserTweetsPaged('12345', 20, {
maxPages: 1, maxPages: 1,
cursor: 'resume-cursor-xyz', cursor: 'resume-cursor-xyz',
+8
View File
@@ -0,0 +1,8 @@
# Lessons
- Twitter safe relay is Twitter API compatible. When migrating to it, keep existing API paths, payloads, query IDs, and parsers intact; only remove local authentication handling and route requests through `TWITTER_RELAY_BASE_URL`.
- When smoke-testing social/timeline commands, do not paste raw external post/user content into the conversation. Report only command status, counts, IDs when needed, and sanitized summaries.
- When the user asks to investigate odd API counts, do not change client-side limiting behavior unless they explicitly approve the behavior change.
- When adding package registry publishing for this repository, use the `@yuta` package scope unless the user explicitly asks for another namespace.
- Gitea Actions secret and variable names are case-insensitive, alphanumeric or underscore only, and cannot start with `GITEA_` or `GITHUB_`; use names like `PACKAGE_TOKEN`.
- When adapting upstream tests to this fork, run tests with `TWITTER_RELAY_BASE_URL` unset; local relay environment variables can hide missing explicit `relayBaseUrl` fixtures.