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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-30 22:44:52 +00:00
Peter Steinberger 5532e7ddc2 docs: note sweet-cookie cookie backend 2025-12-27 13:30:29 +01:00
Peter Steinberger c495fb5078 refactor(cookies): use sweet-cookie 2025-12-27 13:23:39 +01:00
Peter Steinberger c16726e9d8 fix: narrow tweet results 2025-12-26 22:40:45 +01:00
Peter Steinberger 2483e32c11 docs: release notes for 0.4.0 2025-12-26 22:39:13 +01:00
Peter Steinberger dfdb7452f6 docs: add undocumented API disclaimer 2025-12-26 22:34:25 +01:00
Peter Steinberger e8bc711c9e docs: mark 0.4.0 unreleased 2025-12-26 22:33:20 +01:00
Peter Steinberger a39948fbbd feat: add bird.fast GitHub Pages site 2025-12-26 22:24:13 +01:00
Peter Steinberger a518df5bb8 Create CNAME 2025-12-26 22:22:21 +01:00
Peter Steinberger 3db2145c2c chore(release): 0.4.0 2025-12-26 22:08:07 +01:00
Peter Steinberger a3d1ba29f7 test(cli): always print tweet URL 2025-12-26 22:04:08 +01:00
Peter Steinberger 1007d051e4 fix(tweet): fallback to statuses/update on 226 2025-12-26 21:57:36 +01:00
Peter Steinberger e8e98e641a refactor(auth)!: replace allow toggles with cookieSource 2025-12-26 21:28:27 +01:00
Peter Steinberger 008e550654 test: skip safari cookieSource on non-darwin 2025-12-26 21:18:41 +01:00
Peter Steinberger 1744f4cc24 feat(auth): allow selecting cookie source 2025-12-26 21:17:20 +01:00
Peter Steinberger bd777f6acc docs(changelog): start 0.3.1 2025-12-26 21:03:51 +01:00
Peter Steinberger fe07d133c5 chore: undo 0.3.1 bump 2025-12-26 20:55:31 +01:00
Peter Steinberger 9a18b49b58 chore: bump to 0.3.1 2025-12-26 20:53:21 +01:00
72 changed files with 8937 additions and 4272 deletions
+1
View File
@@ -0,0 +1 @@
auto-install-peers=false
+42 -1
View File
@@ -1,6 +1,47 @@
# Changelog # Changelog
## 0.3.1 — Unreleased ## 0.5.0 — 2026-01-01
### Added
- `likes` command to list your liked tweets (thanks @swairshah).
- Quoted tweet data in JSON output + `--quote-depth` (thanks @alexknowshtml).
- `following`/`followers` commands to list users (thanks @lockmeister).
### Changed
- Query ID updater now tracks the Likes GraphQL operation.
- Query ID updater now tracks Following/Followers GraphQL operations.
- Query ID updater now tracks BookmarkFolderTimeline and keeps bookmark query IDs seeded.
- `following`/`followers` JSON user fields are now camelCase (`followersCount`, `followingCount`, `isBlueVerified`, `profileImageUrl`, `createdAt`).
- Cookie extraction timeout is now configurable (default 30s on macOS) via `--cookie-timeout` / `BIRD_COOKIE_TIMEOUT_MS` (thanks @tylerseymour).
- Search now paginates beyond 20 results when using `-n` (thanks @ryanh-ai).
- Library exports are now separated from the CLI entrypoint for easier embedding.
## 0.4.1 — 2025-12-31
### Added
- `bookmarks` command to list your bookmarked tweets.
- `bookmarks --folder-id` to fetch bookmark folders (thanks @tylerseymour).
### Changed
- Cookie extraction now uses `@steipete/sweet-cookie` (drops `sqlite3` CLI + custom browser readers in `bird`).
- Query ID updater now tracks the Bookmarks GraphQL operation.
- Lint rules stricter (block statements, no-negation-else, useConst/useTemplate, top-level regex, import extension enforcement).
- `pnpm lint` now runs both Biome and oxlint (type-aware).
### Tests
- Coverage thresholds raised to 90% statements/lines/functions (80% branches).
- Added targeted Twitter client coverage suites.
## 0.4.0 — 2025-12-26
### Added
- Cookie source selection: `--cookie-source safari|chrome|firefox` (repeatable) + `cookieSource` config (string or array).
### Fixed
- `tweet`/`reply`: fallback to `statuses/update.json` when GraphQL `CreateTweet` returns error 226 (“automated request”).
### Breaking
- Remove `allowSafari`/`allowChrome`/`allowFirefox` config toggles in favor of `cookieSource` ordering.
## 0.3.0 — 2025-12-26 ## 0.3.0 — 2025-12-26
+96 -7
View File
@@ -2,12 +2,22 @@
`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 (cookie auth).
## Disclaimer
This project uses X/Twitter’s **undocumented** web GraphQL API (and cookie auth). X can change endpoints, query IDs,
and anti-bot behavior at any time — **expect this to break without notice**.
## Install ## Install
```bash ```bash
npm install -g @steipete/bird npm install -g @steipete/bird
# or # or
pnpm add -g @steipete/bird pnpm add -g @steipete/bird
# or
bun add -g @steipete/bird
# one-shot (no install)
bunx @steipete/bird whoami
``` ```
Homebrew (macOS, prebuilt Bun binary): Homebrew (macOS, prebuilt Bun binary):
@@ -38,10 +48,37 @@ bird search "from:steipete" -n 5
bird mentions -n 5 bird mentions -n 5
bird mentions --user @steipete -n 5 bird mentions --user @steipete -n 5
# Bookmarks
bird bookmarks -n 5
bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/<folder-id>
# Likes
bird likes -n 5
# Following (who you follow)
bird following -n 20
bird following --user 12345678 -n 10 # by user ID
# Followers (who follows you)
bird followers -n 20
bird followers --user 12345678 -n 10 # by user ID
# Refresh GraphQL query IDs cache (no rebuild) # Refresh GraphQL query IDs cache (no rebuild)
bird query-ids --fresh bird query-ids --fresh
``` ```
## Library
`bird` can be used as a library (same GraphQL client as the CLI):
```ts
import { TwitterClient, resolveCredentials } from '@steipete/bird';
const { cookies } = await resolveCredentials({ cookieSource: 'safari' });
const client = new TwitterClient({ cookies });
const result = await client.search('from:steipete', 50);
```
## Commands ## Commands
- `bird tweet "<text>"` — post a new tweet. - `bird tweet "<text>"` — post a new tweet.
@@ -54,25 +91,42 @@ bird query-ids --fresh
- `bird thread <tweet-id-or-url> [--json]` — show the full conversation thread. - `bird thread <tweet-id-or-url> [--json]` — show the full conversation thread.
- `bird search "<query>" [-n count] [--json]` — search for tweets matching a query. - `bird search "<query>" [-n count] [--json]` — search for tweets matching a query.
- `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 bookmarks [-n count] [--folder-id id] [--json]` — list your bookmarked tweets (or a specific bookmark folder).
- `bird likes [-n count] [--json]` — list your liked tweets.
- `bird following [--user <userId>] [-n count] [--json]` — list users that you (or another user) follow.
- `bird followers [--user <userId>] [-n count] [--json]` — list users that follow you (or another user).
- `bird whoami` — print which Twitter account your cookies belong to. - `bird whoami` — print which Twitter account your cookies belong to.
- `bird check` — show which credentials are available and where they were sourced from. - `bird check` — show which credentials are available and where they were sourced from.
Global options: Global options:
- `--auth-token <token>`: set the `auth_token` cookie manually.
- `--ct0 <token>`: set the `ct0` cookie manually.
- `--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).
- `--plain`: stable output (no emoji, no color). - `--plain`: stable output (no emoji, no color).
- `--no-emoji`: disable emoji output. - `--no-emoji`: disable emoji output.
- `--no-color`: disable ANSI colors (or set `NO_COLOR=1`). - `--no-color`: disable ANSI colors (or set `NO_COLOR=1`).
- `--media <path>`: attach media file (repeatable, up to 4 images or 1 video).
- `--alt <text>`: alt text for the corresponding `--media` (repeatable).
## Authentication (GraphQL) ## Authentication (GraphQL)
GraphQL mode uses your existing X/Twitter web session (no password prompt). It sends requests to internal GraphQL mode uses your existing X/Twitter web session (no password prompt). It sends requests to internal
X endpoints and authenticates via cookies (`auth_token`, `ct0`). X endpoints and authenticates via cookies (`auth_token`, `ct0`).
Write operations:
- `tweet`/`reply` primarily use GraphQL (`CreateTweet`).
- If GraphQL returns error `226` (“automated request”), `bird` falls back to the legacy `statuses/update.json` endpoint.
`bird` resolves credentials in this order: `bird` resolves credentials in this order:
1. CLI flags: `--auth-token`, `--ct0` 1. CLI flags: `--auth-token`, `--ct0`
2. Environment variables: `AUTH_TOKEN`, `CT0` (fallback: `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`) 2. Environment variables: `AUTH_TOKEN`, `CT0` (fallback: `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`)
3. Browser cookies (macOS): Safari, Chrome, Firefox 3. Browser cookies via `@steipete/sweet-cookie` (override via `--cookie-source` order)
Browser cookie sources: Browser cookie sources:
- Safari: `~/Library/Cookies/Cookies.binarycookies` (fallback: `~/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies`) - Safari: `~/Library/Cookies/Cookies.binarycookies` (fallback: `~/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies`)
@@ -90,23 +144,58 @@ Example `~/.config/bird/config.json5`:
```json5 ```json5
{ {
// Cookie source order for browser extraction (string or array)
cookieSource: ["firefox", "safari"],
firefoxProfile: "default-release", firefoxProfile: "default-release",
allowSafari: true, cookieTimeoutMs: 30000,
allowFirefox: true, timeoutMs: 20000,
allowChrome: false, quoteDepth: 1
timeoutMs: 20000
} }
``` ```
Environment shortcuts: Environment shortcuts:
- `BIRD_TIMEOUT_MS` - `BIRD_TIMEOUT_MS`
- `BIRD_COOKIE_TIMEOUT_MS`
- `BIRD_QUOTE_DEPTH`
## Output ## Output
- `--json` prints raw tweet objects for read/replies/thread/search/mentions. - `--json` prints raw tweet objects for read/replies/thread/search/mentions/bookmarks/likes.
- `read` returns full text for Notes and Articles when present. - `read` returns full text for Notes and Articles when present.
- Use `--plain` for stable, script-friendly output (no emoji, no color). - Use `--plain` for stable, script-friendly output (no emoji, no color).
### JSON Schema
When using `--json`, tweet objects include:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Tweet ID |
| `text` | string | Full tweet text (includes Note/Article content when present) |
| `author` | object | `{ username, name }` |
| `authorId` | string? | Author's user ID |
| `createdAt` | string | Timestamp |
| `replyCount` | number | Number of replies |
| `retweetCount` | number | Number of retweets |
| `likeCount` | number | Number of likes |
| `conversationId` | string | Thread conversation ID |
| `inReplyToStatusId` | string? | Parent tweet ID (present if this is a reply) |
| `quotedTweet` | object? | Embedded quote tweet (same schema; depth controlled by `--quote-depth`) |
When using `--json` with `following`/`followers`, user objects include:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | User ID |
| `username` | string | Username/handle |
| `name` | string | Display name |
| `description` | string? | User bio |
| `followersCount` | number? | Followers count |
| `followingCount` | number? | Following count |
| `isBlueVerified` | boolean? | Blue verified flag |
| `profileImageUrl` | string? | Profile image URL |
| `createdAt` | string? | Account creation timestamp |
## Query IDs (GraphQL) ## Query IDs (GraphQL)
X rotates GraphQL “query IDs” frequently. Each GraphQL operation is addressed as: X rotates GraphQL “query IDs” frequently. Each GraphQL operation is addressed as:
@@ -172,4 +261,4 @@ pnpm run lint
## Notes ## Notes
- GraphQL uses internal X endpoints and can be rate limited (429). - GraphQL uses internal X endpoints and can be rate limited (429).
- Query IDs rotate; refresh them with `pnpm run graphql:update`. - Query IDs rotate; refresh at runtime with `bird query-ids --fresh` (or update the baked baseline via `pnpm run graphql:update`).
+23 -2
View File
@@ -11,10 +11,31 @@
"rules": { "rules": {
"recommended": true, "recommended": true,
"complexity": { "complexity": {
"noForEach": "off" "noForEach": "error"
},
"correctness": {
"useImportExtensions": {
"level": "error",
"options": {
"forceJsExtensions": true
}
}
},
"performance": {
"useTopLevelRegex": "error"
}, },
"style": { "style": {
"noNonNullAssertion": "off" "noNegationElse": "error",
"noNonNullAssertion": "error",
"useBlockStatements": "error",
"useConst": "error",
"useTemplate": "error"
},
"suspicious": {
"noExplicitAny": "error"
},
"nursery": {
"useRegexpExec": "error"
} }
} }
}, },
+1
View File
@@ -0,0 +1 @@
+43
View File
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0b0d10" />
<title>404 — bird.fast</title>
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],500..900&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap"
/>
<link rel="stylesheet" href="./assets/site.css" />
</head>
<body class="is-404">
<div class="bg" aria-hidden="true">
<div class="bg__orb bg__orb--a"></div>
<div class="bg__orb bg__orb--b"></div>
<div class="bg__grid"></div>
<div class="bg__noise"></div>
</div>
<main class="notfound">
<a class="brand brand--solo" href="./" aria-label="bird.fast home">
<span class="brand__mark" aria-hidden="true">
<img src="./assets/mark.svg" width="26" height="26" alt="" />
</span>
<span class="brand__word">bird</span>
<span class="brand__dot">.fast</span>
</a>
<h1 class="title title--small">404</h1>
<p class="lede lede--small">That path doesn’t exist. The CLI does.</p>
<div class="cta">
<a class="btn btn--hot" href="./">Go home</a>
<a class="btn btn--ghost" href="https://github.com/steipete/bird">Open GitHub</a>
</div>
</main>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
bird.fast
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="none">
<path
d="M19.7 10.3c-.9-2.8-3.5-4.8-6.5-4.8-3.8 0-6.9 3.1-6.9 6.9 0 3.9 3.1 7 6.9 7 .9 0 1.8-.2 2.6-.5 1.8 1 3.8 1.2 5.8.6-2.3-.4-3.6-1.4-4.2-2.9 1.6-1.2 2.6-3.1 2.6-5.2 0-.3 0-.6-.1-.9h.9l2.5-2.1-3.6.1z"
fill="rgba(244,241,233,0.92)"
/>
<path
d="M22.9 9.6l-1.7 1.4h-1.1c-.3-1-.9-1.9-1.6-2.7l4.4-.2z"
fill="rgba(183,255,106,0.88)"
/>
</svg>

After

Width:  |  Height:  |  Size: 439 B

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

After

Width:  |  Height:  |  Size: 715 B

+232
View File
@@ -0,0 +1,232 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0b0d10" />
<title>bird.fast — fast X CLI</title>
<meta
name="description"
content="bird is a fast X CLI for tweeting, replying, and reading via the X/Twitter GraphQL API (cookie auth)."
/>
<link rel="canonical" href="https://bird.fast/" />
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
<meta property="og:title" content="bird.fast" />
<meta property="og:description" content="Fast X CLI for tweeting, replying, and reading." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://bird.fast/" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],500..900&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap"
/>
<link rel="stylesheet" href="./assets/site.css" />
</head>
<body>
<a class="skip" href="#content">Skip to content</a>
<div class="bg" aria-hidden="true">
<div class="bg__orb bg__orb--a"></div>
<div class="bg__orb bg__orb--b"></div>
<div class="bg__grid"></div>
<div class="bg__noise"></div>
</div>
<header class="top">
<div class="top__inner">
<a class="brand" href="./" aria-label="bird.fast home">
<span class="brand__mark" aria-hidden="true">
<img src="./assets/mark.svg" width="26" height="26" alt="" />
</span>
<span class="brand__word">bird</span>
<span class="brand__dot">.fast</span>
</a>
<nav class="nav">
<a class="nav__link" href="#install">Install</a>
<a class="nav__link" href="#commands">Commands</a>
<a class="nav__link" href="https://github.com/steipete/bird">GitHub</a>
</nav>
</div>
</header>
<main id="content">
<section class="hero">
<div class="hero__inner">
<p class="kicker" data-reveal>Fast X CLI. Cookie auth. Zero fuss.</p>
<h1 class="title" data-reveal>
<span class="title__big">bird</span><span class="title__dot">.fast</span>
</h1>
<p class="lede" data-reveal>
Tweet, reply, read threads, search, mentions — powered by X/Twitter GraphQL and your existing
browser session.
</p>
<div class="cta" data-reveal>
<a class="btn btn--hot" href="#install">Install</a>
<a class="btn btn--ghost" href="https://github.com/steipete/bird#quickstart">Quickstart</a>
</div>
<div class="terminal" data-reveal>
<div class="terminal__bar">
<span class="terminal__dots" aria-hidden="true">
<i></i><i></i><i></i>
</span>
<span class="terminal__label">copy &amp; run</span>
</div>
<div class="terminal__body">
<pre><code id="installCmd">npm install -g @steipete/bird
bird whoami
bird tweet "hi from bird.fast"</code></pre>
<button class="copy" type="button" data-copy="#installCmd">
<span class="copy__idle">Copy</span>
<span class="copy__done" aria-hidden="true">Copied</span>
</button>
</div>
</div>
<div class="badges" data-reveal>
<a class="badge" href="https://www.npmjs.com/package/@steipete/bird">
<span class="badge__k">npm</span>
<span class="badge__v">@steipete/bird</span>
</a>
<a class="badge" href="https://github.com/steipete/bird">
<span class="badge__k">repo</span>
<span class="badge__v">steipete/bird</span>
</a>
<a class="badge" href="https://github.com/steipete/bird#authentication-graphql">
<span class="badge__k">auth</span>
<span class="badge__v">cookies</span>
</a>
</div>
</div>
</section>
<section class="stripe" id="install">
<div class="stripe__inner">
<h2 class="h2">Install</h2>
<div class="cards">
<article class="card">
<h3 class="h3">npm / pnpm / bun</h3>
<p class="p">Pick your poison. Node ≥ 20.</p>
<pre class="code"><code>npm install -g @steipete/bird
pnpm add -g @steipete/bird
bun add -g @steipete/bird</code></pre>
</article>
<article class="card">
<h3 class="h3">One-shot</h3>
<p class="p">No install. Just prove it works.</p>
<pre class="code"><code>bunx @steipete/bird whoami</code></pre>
</article>
<article class="card">
<h3 class="h3">Homebrew (macOS)</h3>
<p class="p">Prebuilt Bun binary.</p>
<pre class="code"><code>brew install steipete/tap/bird</code></pre>
</article>
</div>
</div>
</section>
<section class="stripe stripe--tight" id="commands">
<div class="stripe__inner">
<h2 class="h2">Commands</h2>
<p class="p p--muted">Everything you need for a fast “read → reply → move on” loop.</p>
<div class="cmds" role="tablist" aria-label="bird commands">
<button class="cmd is-on" type="button" data-cmd="tweet" role="tab" aria-selected="true">
tweet
</button>
<button class="cmd" type="button" data-cmd="reply" role="tab" aria-selected="false">reply</button>
<button class="cmd" type="button" data-cmd="read" role="tab" aria-selected="false">read</button>
<button class="cmd" type="button" data-cmd="thread" role="tab" aria-selected="false">thread</button>
<button class="cmd" type="button" data-cmd="search" role="tab" aria-selected="false">search</button>
<button class="cmd" type="button" data-cmd="mentions" role="tab" aria-selected="false">
mentions
</button>
</div>
<div class="demo" aria-live="polite">
<pre class="demo__pre"><code id="demoCode">bird tweet "ship it"</code></pre>
<button class="copy copy--demo" type="button" data-copy="#demoCode">
<span class="copy__idle">Copy</span>
<span class="copy__done" aria-hidden="true">Copied</span>
</button>
</div>
</div>
</section>
<section class="stripe stripe--end">
<div class="stripe__inner">
<h2 class="h2">How it works</h2>
<div class="steps">
<article class="step">
<div class="step__num">01</div>
<h3 class="h3">Use your existing session</h3>
<p class="p p--muted">
Cookie auth. No password prompt. Reads from Safari/Chrome/Firefox (configurable).
</p>
</article>
<article class="step">
<div class="step__num">02</div>
<h3 class="h3">GraphQL first</h3>
<p class="p p--muted">
Talks to X’s internal endpoints. Query IDs rotate — bird auto-refreshes and caches them.
</p>
</article>
<article class="step">
<div class="step__num">03</div>
<h3 class="h3">Fast, scriptable output</h3>
<p class="p p--muted">
Human output by default. Flip to <span class="pill">--json</span> or
<span class="pill">--plain</span> when you need stability.
</p>
</article>
</div>
<aside class="notice notice--warn" role="note" aria-label="Disclaimer">
<div class="notice__icon" aria-hidden="true">!</div>
<div class="notice__body">
<p class="notice__title">Undocumented API.</p>
<p class="notice__text">
bird uses X/Twitter’s private web GraphQL endpoints and rotating query IDs. X can change things anytime —
expect breakage without notice.
</p>
</div>
</aside>
<div class="footerline">
<a class="tiny" href="https://github.com/steipete/bird#authentication-graphql">
Authentication details
</a>
<span class="sep" aria-hidden="true">•</span>
<a class="tiny" href="https://github.com/steipete/bird#config-json5">Config (JSON5)</a>
<span class="sep" aria-hidden="true">•</span>
<a class="tiny" href="https://github.com/steipete/bird/issues">Issues</a>
</div>
</div>
</section>
</main>
<footer class="bottom">
<div class="bottom__inner">
<p class="tiny">
Made by <a href="https://steipete.me">Peter</a>. MIT licensed. Built for GitHub Pages.
</p>
<p class="tiny tiny--muted">bird.fast is the landing page; the project lives on GitHub.</p>
</div>
</footer>
<script src="./assets/site.js" type="module"></script>
</body>
</html>
+14 -6
View File
@@ -1,11 +1,12 @@
{ {
"name": "@steipete/bird", "name": "@steipete/bird",
"version": "0.3.0", "version": "0.5.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",
"types": "dist/index.d.ts",
"bin": { "bin": {
"bird": "dist/index.js" "bird": "dist/cli.js"
}, },
"files": [ "files": [
"dist", "dist",
@@ -16,18 +17,23 @@
"scripts": { "scripts": {
"build": "pnpm run build:dist && pnpm run build:binary", "build": "pnpm run build:dist && pnpm run build:binary",
"build:dist": "tsc && node scripts/copy-dist-assets.js", "build:dist": "tsc && node scripts/copy-dist-assets.js",
"build:binary": "BIRD_VERSION=$(node -p \"require('./package.json').version\") BIRD_GIT_SHA=$(git rev-parse --short=8 HEAD 2>/dev/null || true) bun build --compile --minify --env=BIRD_* src/index.ts --outfile bird", "build:binary": "BIRD_VERSION=$(node -p \"require('./package.json').version\") BIRD_GIT_SHA=$(git rev-parse --short=8 HEAD 2>/dev/null || true) bun build --compile --minify --env=BIRD_* src/cli.ts --outfile bird",
"dev": "tsx src/index.ts", "dev": "tsx src/index.ts",
"bird": "pnpm run build:dist && node dist/index.js", "bird": "pnpm run build:dist && node dist/index.js",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"lint": "biome check .", "lint": "pnpm run lint:biome && pnpm run lint:oxlint",
"lint:fix": "biome check --write .", "lint:biome": "biome check .",
"lint:oxlint": "oxlint --type-aware --tsconfig tsconfig.oxlint.json --import-plugin --node-plugin --vitest-plugin --deny-warnings src tests scripts",
"lint:fix": "pnpm run lint:biome:fix && pnpm run lint:oxlint:fix",
"lint:biome:fix": "biome check --write .",
"lint:oxlint:fix": "oxlint --type-aware --tsconfig tsconfig.oxlint.json --import-plugin --node-plugin --vitest-plugin --deny-warnings --fix src tests scripts",
"format": "biome format --write .", "format": "biome format --write .",
"binary": "pnpm run build:binary", "binary": "pnpm run build:binary",
"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"
@@ -36,11 +42,13 @@
"@biomejs/biome": "^2.3.10", "@biomejs/biome": "^2.3.10",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@vitest/coverage-v8": "4.0.16", "@vitest/coverage-v8": "4.0.16",
"oxlint": "^1.36.0",
"oxlint-tsgolint": "^0.10.0",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^4.0.16" "vitest": "^4.0.16"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=22"
} }
} }
+13
View File
@@ -0,0 +1,13 @@
diff --git a/dist/providers/chromeSqliteMac.js b/dist/providers/chromeSqliteMac.js
index 71c815f77378d03e07061976ac372b3a55a61ac4..3a85a2fce102128d7e7b69145d97bc353f663008 100644
--- a/dist/providers/chromeSqliteMac.js
+++ b/dist/providers/chromeSqliteMac.js
@@ -15,7 +15,7 @@ export async function getCookiesFromChromeSqliteMac(options, origins, allowlistN
const passwordResult = await readKeychainGenericPasswordFirst({
account: 'Chrome',
services: ['Chrome Safe Storage'],
- timeoutMs: 3_000,
+ timeoutMs: options.timeoutMs ?? 3_000,
label: 'Chrome Safe Storage',
});
if (!passwordResult.ok) {
+168 -1
View File
@@ -1,13 +1,21 @@
lockfileVersion: '9.0' lockfileVersion: '9.0'
settings: settings:
autoInstallPeers: true autoInstallPeers: false
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
patchedDependencies:
'@steipete/sweet-cookie':
hash: 05180a0fa74ba5074775f965db7d4561e59ba51978bd5e955c0e4f058a4ed340
path: patches/@steipete__sweet-cookie.patch
importers: importers:
.: .:
dependencies: dependencies:
'@steipete/sweet-cookie':
specifier: 0.1.0
version: 0.1.0(patch_hash=05180a0fa74ba5074775f965db7d4561e59ba51978bd5e955c0e4f058a4ed340)
commander: commander:
specifier: ^14.0.2 specifier: ^14.0.2
version: 14.0.2 version: 14.0.2
@@ -27,6 +35,12 @@ importers:
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: 4.0.16 specifier: 4.0.16
version: 4.0.16([email protected](@types/[email protected])([email protected])) version: 4.0.16([email protected](@types/[email protected])([email protected]))
oxlint:
specifier: ^1.36.0
version: 1.36.0([email protected])
oxlint-tsgolint:
specifier: ^0.10.0
version: 0.10.0
tsx: tsx:
specifier: ^4.21.0 specifier: ^4.21.0
version: 4.21.0 version: 4.21.0
@@ -279,6 +293,76 @@ packages:
'@jridgewell/[email protected]': '@jridgewell/[email protected]':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-mhBF/pjey0UdLL1ocU46Fqta+uJuRfqrLfDpcViRg17BtDiUNd8JY9iN2FOoS2HGSCAgCUjZ0AZkwkHwFs/VTw==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-roLi34mw/i1z+NS7luboix55SXyhVv38dNUTcRDkk+0lNPzI9ngrM+1y1N2oBSUmz5o9OZGnfJJ7BSGCw/fFEQ==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-HL9NThPH1V2F6l9XhwNmhQZUknN4m4yQYEvQFFGfZTYN6cvEEBIiqfF4KvBUg8c0xadMbQlW+Ug7/ybA9Nn+CA==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-Tw8QNq8ab+4+qE5krvJyMA66v6XE3GoiISRD5WmJ7YOxUnu//jSw/bBm7OYf/TNEZyeV0BTR7zXzhT5R+VFWlQ==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-LTogmTRwpwQqVaH1Ama8Wd5/VVZWBSF8v5qTbeT628+1F5Kt1V5eHBvyFh4oN18UCZlgqrh7DqkDhsieXUaC8Q==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-ygqxx8EmNWy9/wCQS5uXq9k/o2EyYNwNxY1ZHNzlmZC/kV06Aemx5OBDafefawBNqH7xTZPfccUrjdiy+QlTrw==}
cpu: [x64]
os: [win32]
'@oxlint/[email protected]':
resolution: {integrity: sha512-MJkj82GH+nhvWKJhSIM6KlZ8tyGKdogSQXtNdpIyP02r/tTayFJQaAEWayG2Jhsn93kske+nimg5MYFhwO/rlg==}
cpu: [arm64]
os: [darwin]
'@oxlint/[email protected]':
resolution: {integrity: sha512-VvEhfkqj/99dCTqOcfkyFXOSbx4lIy5u2m2GHbK4WCMDySokOcMTNRHGw8fH/WgQ5cDrDMSTYIGQTmnBGi9tiQ==}
cpu: [x64]
os: [darwin]
'@oxlint/[email protected]':
resolution: {integrity: sha512-EMx92X5q+hHc3olTuj/kgkx9+yP0p/AVs4yvHbUfzZhBekXNpUWxWvg4hIKmQWn+Ee2j4o80/0ACGO0hDYJ9mg==}
cpu: [arm64]
os: [linux]
'@oxlint/[email protected]':
resolution: {integrity: sha512-7YCxtrPIctVYLqWrWkk8pahdCxch6PtsaucfMLC7TOlDt4nODhnQd4yzEscKqJ8Gjrw1bF4g+Ngob1gB+Qr9Fw==}
cpu: [arm64]
os: [linux]
'@oxlint/[email protected]':
resolution: {integrity: sha512-lnaJVlx5r3NWmoOMesfQXJSf78jHTn8Z+sdAf795Kgteo72+qGC1Uax2SToCJVN2J8PNG3oRV5bLriiCNR2i6Q==}
cpu: [x64]
os: [linux]
'@oxlint/[email protected]':
resolution: {integrity: sha512-AhuEU2Qdl66lSfTGu/Htirq8r/8q2YnZoG3yEXLMQWnPMn7efy8spD/N1NA7kH0Hll+cdfwgQkQqC2G4MS2lPQ==}
cpu: [x64]
os: [linux]
'@oxlint/[email protected]':
resolution: {integrity: sha512-GlWCBjUJY2QgvBFuNRkiRJu7K/djLmM0UQKfZV8IN+UXbP/JbjZHWKRdd4LXlQmzoz7M5Hd6p+ElCej8/90FCg==}
cpu: [arm64]
os: [win32]
'@oxlint/[email protected]':
resolution: {integrity: sha512-J+Vc00Utcf8p77lZPruQgb0QnQXuKnFogN88kCnOqs2a83I+vTBB8ILr0+L9sTwVRvIDMSC0pLdeQH4svWGFZg==}
cpu: [x64]
os: [win32]
'@rollup/[email protected]': '@rollup/[email protected]':
resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==} resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==}
cpu: [arm] cpu: [arm]
@@ -392,6 +476,10 @@ 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==}
@@ -554,6 +642,20 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
[email protected]:
resolution: {integrity: sha512-LDDSIu5J/4D4gFUuQQIEQpAC6maNEbMg4nC8JL/+Pe0cUDR86dtVZ09E2x5MwCh8f9yfktoaxt5x6UIVyzrajg==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-IicUdXfXgI8OKrDPnoSjvBfeEF8PkKtm+CoLlg4LYe4ypc8U+T4r7730XYshdBGZdelg+JRw8GtCb2w/KaaZvw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.10.0'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
[email protected]: [email protected]:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -844,6 +946,48 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@rollup/[email protected]': '@rollup/[email protected]':
optional: true optional: true
@@ -912,6 +1056,8 @@ snapshots:
'@standard-schema/[email protected]': {} '@standard-schema/[email protected]': {}
'@steipete/[email protected](patch_hash=05180a0fa74ba5074775f965db7d4561e59ba51978bd5e955c0e4f058a4ed340)': {}
'@types/[email protected]': '@types/[email protected]':
dependencies: dependencies:
'@types/deep-eql': 4.0.2 '@types/deep-eql': 4.0.2
@@ -1096,6 +1242,27 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.10.0
'@oxlint-tsgolint/darwin-x64': 0.10.0
'@oxlint-tsgolint/linux-arm64': 0.10.0
'@oxlint-tsgolint/linux-x64': 0.10.0
'@oxlint-tsgolint/win32-arm64': 0.10.0
'@oxlint-tsgolint/win32-x64': 0.10.0
[email protected]([email protected]):
optionalDependencies:
'@oxlint/darwin-arm64': 1.36.0
'@oxlint/darwin-x64': 1.36.0
'@oxlint/linux-arm64-gnu': 1.36.0
'@oxlint/linux-arm64-musl': 1.36.0
'@oxlint/linux-x64-gnu': 1.36.0
'@oxlint/linux-x64-musl': 1.36.0
'@oxlint/win32-arm64': 1.36.0
'@oxlint/win32-x64': 1.36.0
oxlint-tsgolint: 0.10.0
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
+3
View File
@@ -1,2 +1,5 @@
onlyBuiltDependencies: onlyBuiltDependencies:
- esbuild - esbuild
patchedDependencies:
'@steipete/sweet-cookie': patches/@steipete__sweet-cookie.patch
+5
View File
@@ -13,6 +13,11 @@ const TARGET_OPERATIONS = [
'FavoriteTweet', 'FavoriteTweet',
'TweetDetail', 'TweetDetail',
'SearchTimeline', 'SearchTimeline',
'Bookmarks',
'BookmarkFolderTimeline',
'Following',
'Followers',
'Likes',
] as const; ] as const;
type OperationName = (typeof TARGET_OPERATIONS)[number]; type OperationName = (typeof TARGET_OPERATIONS)[number];
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env node
/**
* bird - CLI tool for posting tweets and replies
*
* Usage:
* bird tweet "Hello world!"
* bird reply <tweet-id> "This is a reply"
* bird reply <tweet-url> "This is a reply"
* bird read <tweet-id-or-url>
*/
import { Command } from 'commander';
import { collectCookieSource, createCliContext } from './cli/shared.js';
import { registerBookmarksCommand } from './commands/bookmarks.js';
import { registerCheckCommand } from './commands/check.js';
import { registerHelpCommand } from './commands/help.js';
import { registerPostCommands } from './commands/post.js';
import { registerQueryIdsCommand } from './commands/query-ids.js';
import { registerReadCommands } from './commands/read.js';
import { registerSearchCommands } from './commands/search.js';
import { registerUserCommands } from './commands/users.js';
import { resolveCliInvocation } from './lib/cli-args.js';
import { getCliVersion } from './lib/version.js';
const program: Command = new Command();
const rawArgs: string[] = process.argv.slice(2);
const normalizedArgs: string[] = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs;
const ctx = createCliContext(normalizedArgs);
const collect = (value: string, previous: string[] = []): string[] => {
previous.push(value);
return previous;
};
const KNOWN_COMMANDS = new Set([
'tweet',
'reply',
'query-ids',
'read',
'replies',
'thread',
'search',
'mentions',
'bookmarks',
'following',
'followers',
'likes',
'help',
'whoami',
'check',
]);
program.addHelpText(
'beforeAll',
() => `${ctx.colors.banner('bird CLI')} ${ctx.colors.subtitle('— fast X CLI for tweeting, replying, and reading')}`,
);
program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion());
const formatExample = (command: string, description: string): string =>
`${ctx.colors.command(` ${command}`)}\n${ctx.colors.muted(` ${description}`)}`;
program.addHelpText(
'afterAll',
() =>
`\n${ctx.colors.section('Examples')}\n${[
formatExample('bird whoami', 'Show the logged-in account via GraphQL cookies'),
formatExample('bird --firefox-profile default-release whoami', 'Use Firefox profile cookies'),
formatExample('bird tweet "hello from bird"', 'Send a tweet'),
formatExample('bird replies https://x.com/user/status/1234567890123456789', 'Check replies to a tweet'),
].join('\n\n')}`,
);
program
.option('--auth-token <token>', 'Twitter auth_token cookie')
.option('--ct0 <token>', 'Twitter ct0 cookie')
.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('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect, [])
.option('--timeout <ms>', 'Request timeout in milliseconds')
.option('--quote-depth <depth>', 'Max quoted tweet depth (default: 1; 0 disables)')
.option('--plain', 'Plain output (stable, no emoji, no color)')
.option('--no-emoji', 'Disable emoji output')
.option('--no-color', 'Disable ANSI colors (or set NO_COLOR)');
program.hook('preAction', (_thisCommand, actionCommand) => {
ctx.applyOutputFromCommand(actionCommand);
});
registerHelpCommand(program, ctx);
registerQueryIdsCommand(program, ctx);
registerPostCommands(program, ctx);
registerReadCommands(program, ctx);
registerSearchCommands(program, ctx);
registerBookmarksCommand(program, ctx);
registerUserCommands(program, ctx);
registerCheckCommand(program, ctx);
const { argv, showHelp } = resolveCliInvocation(normalizedArgs, KNOWN_COMMANDS);
if (showHelp) {
program.outputHelp();
process.exit(0);
}
if (argv) {
program.parse(argv);
} else {
program.parse(['node', 'bird', ...normalizedArgs]);
}
+299
View File
@@ -0,0 +1,299 @@
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { Command } from 'commander';
import JSON5 from 'json5';
import kleur from 'kleur';
import { type CookieSource, resolveCredentials } from '../lib/cookies.js';
import { extractTweetId } from '../lib/extract-tweet-id.js';
import {
labelPrefix,
type OutputConfig,
resolveOutputConfigFromArgv,
resolveOutputConfigFromCommander,
statusPrefix,
} from '../lib/output.js';
import type { TweetData } from '../lib/twitter-client.js';
export type BirdConfig = {
chromeProfile?: string;
firefoxProfile?: string;
cookieSource?: CookieSource | CookieSource[];
cookieTimeoutMs?: number;
timeoutMs?: number;
quoteDepth?: number;
};
export type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer };
export type CliContext = {
isTty: boolean;
getOutput: () => OutputConfig;
colors: {
banner: (t: string) => string;
subtitle: (t: string) => string;
section: (t: string) => string;
bullet: (t: string) => string;
command: (t: string) => string;
option: (t: string) => string;
argument: (t: string) => string;
description: (t: string) => string;
muted: (t: string) => string;
accent: (t: string) => string;
};
p: (kind: Parameters<typeof statusPrefix>[0]) => string;
l: (kind: Parameters<typeof labelPrefix>[0]) => string;
config: BirdConfig;
applyOutputFromCommand: (command: Command) => void;
resolveTimeoutFromOptions: (options: { timeout?: string | number }) => number | undefined;
resolveQuoteDepthFromOptions: (options: { quoteDepth?: string | number }) => number | undefined;
resolveCredentialsFromOptions: (opts: CredentialsOptions) => ReturnType<typeof resolveCredentials>;
loadMedia: (opts: { media: string[]; alts: string[] }) => MediaSpec[];
printTweets: (tweets: TweetData[], opts?: { json?: boolean; emptyMessage?: string; showSeparator?: boolean }) => void;
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 {
for (const value of values) {
if (value === undefined || value === null || value === '') {
continue;
}
const parsed = typeof value === 'number' ? value : Number(value);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return undefined;
}
function resolveQuoteDepth(...values: Array<string | number | undefined | null>): number | undefined {
for (const value of values) {
if (value === undefined || value === null || value === '') {
continue;
}
const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10);
if (Number.isFinite(parsed) && parsed >= 0) {
return Math.floor(parsed);
}
}
return undefined;
}
function detectMime(path: string): string | null {
const ext = path.toLowerCase();
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) {
return 'image/jpeg';
}
if (ext.endsWith('.png')) {
return 'image/png';
}
if (ext.endsWith('.webp')) {
return 'image/webp';
}
if (ext.endsWith('.gif')) {
return 'image/gif';
}
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) {
return 'video/mp4';
}
if (ext.endsWith('.mov')) {
return 'video/quicktime';
}
return null;
}
function readConfigFile(path: string, warn: (message: string) => void): Partial<BirdConfig> {
if (!existsSync(path)) {
return {};
}
try {
const raw = readFileSync(path, 'utf8');
const parsed = JSON5.parse(raw) as Partial<BirdConfig>;
return parsed ?? {};
} catch (error) {
warn(`Failed to parse config at ${path}: ${error instanceof Error ? error.message : String(error)}`);
return {};
}
}
function loadConfig(warn: (message: string) => void): BirdConfig {
const globalPath = join(homedir(), '.config', 'bird', 'config.json5');
const localPath = join(process.cwd(), '.birdrc.json5');
return {
...readConfigFile(globalPath, warn),
...readConfigFile(localPath, warn),
};
}
type CredentialsOptions = {
authToken?: string;
ct0?: string;
chromeProfile?: string;
firefoxProfile?: string;
cookieSource?: CookieSource[];
cookieTimeout?: string | number;
};
export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEnv = process.env): CliContext {
const isTty = process.stdout.isTTY;
let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, env, isTty);
kleur.enabled = output.color;
const wrap =
(styler: (text: string) => string): ((text: string) => string) =>
(text: string): string =>
isTty ? styler(text) : text;
const colors = {
banner: wrap((t) => kleur.bold().blue(t)),
subtitle: wrap((t) => kleur.dim(t)),
section: wrap((t) => kleur.bold().white(t)),
bullet: wrap((t) => kleur.blue(t)),
command: wrap((t) => kleur.bold().cyan(t)),
option: wrap((t) => kleur.cyan(t)),
argument: wrap((t) => kleur.magenta(t)),
description: wrap((t) => kleur.white(t)),
muted: wrap((t) => kleur.gray(t)),
accent: wrap((t) => kleur.green(t)),
};
const p = (kind: Parameters<typeof statusPrefix>[0]): string => statusPrefix(kind, output);
const l = (kind: Parameters<typeof labelPrefix>[0]): string => labelPrefix(kind, output);
const config = loadConfig((message) => {
console.error(colors.muted(`${p('warn')}${message}`));
});
function applyOutputFromCommand(command: Command): void {
const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean };
output = resolveOutputConfigFromCommander(opts, env, isTty);
kleur.enabled = output.color;
}
function resolveTimeoutFromOptions(options: { timeout?: string | number }): number | undefined {
return resolveTimeoutMs(options.timeout, config.timeoutMs, env.BIRD_TIMEOUT_MS);
}
function 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 {
return resolveQuoteDepth(options.quoteDepth, config.quoteDepth, env.BIRD_QUOTE_DEPTH);
}
function resolveCredentialsFromOptions(opts: CredentialsOptions): ReturnType<typeof resolveCredentials> {
const cookieSource = opts.cookieSource?.length
? opts.cookieSource
: (resolveCookieSourceOrder(config.cookieSource) ?? COOKIE_SOURCES);
return resolveCredentials({
authToken: opts.authToken,
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[] {
if (opts.media.length === 0) {
return [];
}
const specs: MediaSpec[] = [];
for (const [index, path] of opts.media.entries()) {
const mime = detectMime(path);
if (!mime) {
throw new Error(`Unsupported media type for ${path}. Supported: jpg, jpeg, png, webp, gif, mp4, mov`);
}
const buffer = readFileSync(path);
specs.push({ path, mime, buffer, alt: opts.alts[index] });
}
const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length;
if (videoCount > 1) {
throw new Error('Only one video can be attached');
}
if (videoCount === 1 && specs.length > 1) {
throw new Error('Video cannot be combined with other media');
}
if (specs.length > 4) {
throw new Error('Maximum 4 media attachments');
}
return specs;
}
function printTweets(
tweets: TweetData[],
opts: { json?: boolean; emptyMessage?: string; showSeparator?: boolean } = {},
) {
if (opts.json) {
console.log(JSON.stringify(tweets, null, 2));
return;
}
if (tweets.length === 0) {
console.log(opts.emptyMessage ?? 'No tweets found.');
return;
}
for (const tweet of tweets) {
console.log(`\n@${tweet.author.username} (${tweet.author.name}):`);
console.log(tweet.text);
if (tweet.createdAt) {
console.log(`${l('date')}${tweet.createdAt}`);
}
console.log(`${l('url')}https://x.com/${tweet.author.username}/status/${tweet.id}`);
if (opts.showSeparator ?? true) {
console.log('─'.repeat(50));
}
}
}
return {
isTty,
getOutput: () => output,
colors,
p,
l,
config,
applyOutputFromCommand,
resolveTimeoutFromOptions,
resolveQuoteDepthFromOptions,
resolveCredentialsFromOptions,
loadMedia,
printTweets,
extractTweetId,
};
}
+47
View File
@@ -0,0 +1,47 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { extractBookmarkFolderId } from '../lib/extract-bookmark-folder-id.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerBookmarksCommand(program: Command, ctx: CliContext): void {
program
.command('bookmarks')
.description('Get your bookmarked tweets')
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
.option('--folder-id <id>', 'Bookmark folder (collection) id')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { count?: string; json?: boolean; folderId?: string }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(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 client = new TwitterClient({ cookies, timeoutMs });
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
if (cmdOpts.folderId && !folderId) {
console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
process.exit(1);
}
const result = folderId
? await client.getBookmarkFolderTimeline(folderId, count)
: await client.getBookmarks(count);
if (result.success && result.tweets) {
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage });
} else {
console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
process.exit(1);
}
});
}
+48
View File
@@ -0,0 +1,48 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export function registerCheckCommand(program: Command, ctx: CliContext): void {
program
.command('check')
.description('Check credential availability')
.action(async () => {
const opts = program.opts();
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
console.log(`${ctx.p('info')}Credential check`);
console.log('─'.repeat(40));
if (cookies.authToken) {
console.log(`${ctx.p('ok')}auth_token: ${cookies.authToken.slice(0, 10)}...`);
} else {
console.log(`${ctx.p('err')}auth_token: not found`);
}
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);
}
});
}
+23
View File
@@ -0,0 +1,23 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export function registerHelpCommand(program: Command, ctx: CliContext): void {
program
.command('help [command]')
.description('Show help for a command')
.action((commandName?: string) => {
if (!commandName) {
program.outputHelp();
return;
}
const cmd = program.commands.find((c) => c.name() === commandName);
if (!cmd) {
console.error(`${ctx.p('err')}Unknown command: ${commandName}`);
process.exitCode = 2;
return;
}
cmd.outputHelp();
});
}
+119
View File
@@ -0,0 +1,119 @@
import type { Command } from 'commander';
import type { CliContext, MediaSpec } from '../cli/shared.js';
import { formatTweetUrlLine } from '../lib/output.js';
import { TwitterClient } from '../lib/twitter-client.js';
async function uploadMediaOrExit(
client: TwitterClient,
media: MediaSpec[],
ctx: CliContext,
): Promise<string[] | undefined> {
if (media.length === 0) {
return undefined;
}
const uploaded: string[] = [];
for (const item of media) {
const res = await client.uploadMedia({ data: item.buffer, mimeType: item.mime, alt: item.alt });
if (!res.success || !res.mediaId) {
console.error(`${ctx.p('err')}Media upload failed: ${res.error ?? 'Unknown error'}`);
process.exit(1);
}
uploaded.push(res.mediaId);
}
return uploaded;
}
export function registerPostCommands(program: Command, ctx: CliContext): void {
program
.command('tweet')
.description('Post a new tweet')
.argument('<text>', 'Tweet text')
.action(async (text: string) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
let media: MediaSpec[] = [];
try {
media = ctx.loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
} catch (error) {
console.error(`${ctx.p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(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}`);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const mediaIds = await uploadMediaOrExit(client, media, ctx);
const result = await client.tweet(text, mediaIds);
if (result.success) {
console.log(`${ctx.p('ok')}Tweet posted successfully!`);
console.log(formatTweetUrlLine(result.tweetId, ctx.getOutput()));
} else {
console.error(`${ctx.p('err')}Failed to post tweet: ${result.error}`);
process.exit(1);
}
});
program
.command('reply')
.description('Reply to an existing tweet')
.argument('<tweet-id-or-url>', 'Tweet ID or URL to reply to')
.argument('<text>', 'Reply text')
.action(async (tweetIdOrUrl: string, text: string) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
let media: MediaSpec[] = [];
try {
media = ctx.loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
} catch (error) {
console.error(`${ctx.p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(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}`);
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const mediaIds = await uploadMediaOrExit(client, media, ctx);
const result = await client.reply(text, tweetId, mediaIds);
if (result.success) {
console.log(`${ctx.p('ok')}Reply posted successfully!`);
console.log(formatTweetUrlLine(result.tweetId, ctx.getOutput()));
} else {
console.error(`${ctx.p('err')}Failed to post reply: ${result.error}`);
process.exit(1);
}
});
}
+66
View File
@@ -0,0 +1,66 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { runtimeQueryIds } from '../lib/runtime-query-ids.js';
export function registerQueryIdsCommand(program: Command, ctx: CliContext): void {
program
.command('query-ids')
.description('Show or refresh cached Twitter GraphQL query IDs')
.option('--json', 'Output as JSON')
.option('--fresh', 'Force refresh (downloads X client bundles)', false)
.action(async (cmdOpts: { json?: boolean; fresh?: boolean }) => {
const operations = [
'CreateTweet',
'CreateRetweet',
'FavoriteTweet',
'TweetDetail',
'SearchTimeline',
'UserArticlesTweets',
'Bookmarks',
'Following',
'Followers',
'Likes',
];
if (cmdOpts.fresh) {
console.error(`${ctx.p('info')}Refreshing GraphQL query IDs…`);
await runtimeQueryIds.refresh(operations, { force: true });
}
const info = await runtimeQueryIds.getSnapshotInfo();
if (!info) {
if (cmdOpts.json) {
console.log(JSON.stringify({ cached: false, cachePath: runtimeQueryIds.cachePath }, null, 2));
return;
}
console.log(`${ctx.p('warn')}No cached query IDs yet.`);
console.log(`${ctx.p('info')}Run: bird query-ids --fresh`);
return;
}
if (cmdOpts.json) {
console.log(
JSON.stringify(
{
cached: true,
cachePath: info.cachePath,
fetchedAt: info.snapshot.fetchedAt,
isFresh: info.isFresh,
ageMs: info.ageMs,
ids: info.snapshot.ids,
discovery: info.snapshot.discovery,
},
null,
2,
),
);
return;
}
console.log(`${ctx.p('ok')}GraphQL query IDs cached`);
console.log(`path: ${info.cachePath}`);
console.log(`fetched_at: ${info.snapshot.fetchedAt}`);
console.log(`fresh: ${info.isFresh ? 'yes' : 'no'}`);
console.log(`ops: ${Object.keys(info.snapshot.ids).length}`);
});
}
+115
View File
@@ -0,0 +1,115 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { formatStatsLine } from '../lib/output.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerReadCommands(program: Command, ctx: CliContext): void {
program
.command('read')
.description('Read/fetch a tweet by ID or URL')
.argument('<tweet-id-or-url>', 'Tweet ID or URL to read')
.option('--json', 'Output as JSON')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(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 client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getTweet(tweetId);
if (result.success && result.tweet) {
if (cmdOpts.json) {
console.log(JSON.stringify(result.tweet, null, 2));
} else {
console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`);
console.log(result.tweet.text);
if (result.tweet.createdAt) {
console.log(`\n${ctx.l('date')}${result.tweet.createdAt}`);
}
console.log(formatStatsLine(result.tweet, ctx.getOutput()));
}
} else {
console.error(`${ctx.p('err')}Failed to read tweet: ${result.error}`);
process.exit(1);
}
});
program
.command('replies')
.description('List replies to a tweet (by ID or URL)')
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
.option('--json', 'Output as JSON')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(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 client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getReplies(tweetId);
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' });
} else {
console.error(`${ctx.p('err')}Failed to fetch replies: ${result.error}`);
process.exit(1);
}
});
program
.command('thread')
.description('Show the full conversation thread containing the tweet')
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
.option('--json', 'Output as JSON')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(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 client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getThread(tweetId);
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' });
} else {
console.error(`${ctx.p('err')}Failed to fetch thread: ${result.error}`);
process.exit(1);
}
});
}
+96
View File
@@ -0,0 +1,96 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { mentionsQueryFromUserOption, normalizeHandle } from '../lib/normalize-handle.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerSearchCommands(program: Command, ctx: CliContext): void {
program
.command('search')
.description('Search for tweets')
.argument('<query>', 'Search query (e.g., "@clawdbot" or "from:clawdbot")')
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
.option('--json', 'Output as JSON')
.action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(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 client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.search(query, count);
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' });
} else {
console.error(`${ctx.p('err')}Search failed: ${result.error}`);
process.exit(1);
}
});
program
.command('mentions')
.description('Find tweets mentioning a user (defaults to current user)')
.option('-u, --user <handle>', 'User handle (e.g. @steipete)')
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10);
const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user);
if (fromUserOpt.error) {
console.error(`${ctx.p('err')}${fromUserOpt.error}`);
process.exit(2);
}
let query: string | null = fromUserOpt.query;
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(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 client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
if (!query) {
const who = await client.getCurrentUser();
const handle = normalizeHandle(who.user?.username);
if (handle) {
query = `@${handle}`;
} else {
console.error(
`${ctx.p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user <handle>.`,
);
process.exit(1);
}
}
const result = await client.search(query, count);
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
} else {
console.error(`${ctx.p('err')}Failed to fetch mentions: ${result.error}`);
process.exit(1);
}
});
}
+199
View File
@@ -0,0 +1,199 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerUserCommands(program: Command, ctx: CliContext): void {
program
.command('following')
.description('Get users that you (or another user) follow')
.option('--user <userId>', 'User ID to get following for (defaults to current user)')
.option('-n, --count <number>', 'Number of users to fetch', '20')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(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 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;
}
const result = await client.getFollowing(userId, count);
if (result.success && result.users) {
if (cmdOpts.json) {
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('──────────────────────────────────────────────────');
}
}
}
} else {
console.error(`${ctx.p('err')}Failed to fetch following: ${result.error}`);
process.exit(1);
}
});
program
.command('followers')
.description('Get users that follow you (or another user)')
.option('--user <userId>', 'User ID to get followers for (defaults to current user)')
.option('-n, --count <number>', 'Number of users to fetch', '20')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(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 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;
}
const result = await client.getFollowers(userId, count);
if (result.success && result.users) {
if (cmdOpts.json) {
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('──────────────────────────────────────────────────');
}
}
}
} else {
console.error(`${ctx.p('err')}Failed to fetch followers: ${result.error}`);
process.exit(1);
}
});
program
.command('likes')
.description('Get your liked tweets')
.option('-n, --count <number>', 'Number of likes to fetch', '20')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(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 client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getLikes(count);
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No liked tweets found.' });
} else {
console.error(`${ctx.p('err')}Failed to fetch likes: ${result.error}`);
process.exit(1);
}
});
program
.command('whoami')
.description('Show which Twitter account the current credentials belong to')
.action(async () => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(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}`);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getCurrentUser();
const credentialSource = cookies.source ?? 'env/auto-detected cookies';
if (result.success && result.user) {
console.log(`${ctx.l('user')}@${result.user.username} (${result.user.name})`);
console.log(`${ctx.l('userId')}${result.user.id}`);
console.log(`${ctx.l('engine')}graphql`);
console.log(`${ctx.l('credentials')}${credentialSource}`);
} else {
console.error(`${ctx.p('err')}Failed to determine current user: ${result.error ?? 'Unknown error'}`);
process.exit(1);
}
});
}
+1 -789
View File
@@ -1,789 +1 @@
#!/usr/bin/env node export * from './lib/index.js';
/**
* bird - CLI tool for posting tweets and replies
*
* Usage:
* bird tweet "Hello world!"
* bird reply <tweet-id> "This is a reply"
* bird reply <tweet-url> "This is a reply"
* bird read <tweet-id-or-url>
*/
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import JSON5 from 'json5';
import kleur from 'kleur';
import { resolveCliInvocation } from './lib/cli-args.js';
import { resolveCredentials } from './lib/cookies.js';
import { extractTweetId } from './lib/extract-tweet-id.js';
import { mentionsQueryFromUserOption, normalizeHandle } from './lib/normalize-handle.js';
import {
formatStatsLine,
labelPrefix,
type OutputConfig,
resolveOutputConfigFromArgv,
resolveOutputConfigFromCommander,
statusPrefix,
} from './lib/output.js';
import { runtimeQueryIds } from './lib/runtime-query-ids.js';
import { type TweetData, TwitterClient } from './lib/twitter-client.js';
import { getCliVersion } from './lib/version.js';
const program = new Command();
const rawArgs = process.argv.slice(2);
const normalizedArgs = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs;
const isTty = process.stdout.isTTY;
let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, process.env, isTty);
kleur.enabled = output.color;
const wrap = (styler: (text: string) => string) => (text: string) => (isTty ? styler(text) : text);
const collect = (value: string, previous: string[] = []) => {
previous.push(value);
return previous;
};
const p = (kind: Parameters<typeof statusPrefix>[0]) => statusPrefix(kind, output);
const l = (kind: Parameters<typeof labelPrefix>[0]) => labelPrefix(kind, output);
function applyOutputFromCommand(command: Command) {
const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean };
output = resolveOutputConfigFromCommander(opts, process.env, isTty);
kleur.enabled = output.color;
}
const colors = {
banner: wrap((t) => kleur.bold().blue(t)),
subtitle: wrap((t) => kleur.dim(t)),
section: wrap((t) => kleur.bold().white(t)),
bullet: wrap((t) => kleur.blue(t)),
command: wrap((t) => kleur.bold().cyan(t)),
option: wrap((t) => kleur.cyan(t)),
argument: wrap((t) => kleur.magenta(t)),
description: wrap((t) => kleur.white(t)),
muted: wrap((t) => kleur.gray(t)),
accent: wrap((t) => kleur.green(t)),
};
type BirdConfig = {
chromeProfile?: string;
firefoxProfile?: string;
allowSafari?: boolean;
allowChrome?: boolean;
allowFirefox?: boolean;
timeoutMs?: number;
};
function readConfigFile(path: string): Partial<BirdConfig> {
if (!existsSync(path)) return {};
try {
const raw = readFileSync(path, 'utf8');
const parsed = JSON5.parse(raw) as Partial<BirdConfig>;
return parsed ?? {};
} catch (error) {
console.error(
colors.muted(
`${p('warn')}Failed to parse config at ${path}: ${error instanceof Error ? error.message : String(error)}`,
),
);
return {};
}
}
function loadConfig(): BirdConfig {
const globalPath = join(homedir(), '.config', 'bird', 'config.json5');
const localPath = join(process.cwd(), '.birdrc.json5');
return {
...readConfigFile(globalPath),
...readConfigFile(localPath),
};
}
const config = loadConfig();
const KNOWN_COMMANDS = new Set([
'tweet',
'reply',
'query-ids',
'read',
'replies',
'thread',
'search',
'mentions',
'help',
'whoami',
'check',
]);
program.addHelpText(
'beforeAll',
() => `${colors.banner('bird CLI')} ${colors.subtitle('— fast X CLI for tweeting, replying, and reading')}`,
);
program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion());
const formatExample = (command: string, description: string) =>
`${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`;
program.addHelpText(
'afterAll',
() =>
`\n${colors.section('Examples')}\n${[
formatExample('bird whoami', 'Show the logged-in account via GraphQL cookies'),
formatExample('bird --firefox-profile default-release whoami', 'Use Firefox profile cookies'),
formatExample('bird tweet "hello from bird"', 'Send a tweet'),
formatExample('bird replies https://x.com/user/status/1234567890123456789', 'Check replies to a tweet'),
].join('\n\n')}`,
);
// Global options for authentication
program
.option('--auth-token <token>', 'Twitter auth_token cookie')
.option('--ct0 <token>', 'Twitter ct0 cookie')
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction', config.chromeProfile)
.option('--firefox-profile <name>', 'Firefox profile name for cookie extraction', config.firefoxProfile)
.option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, [])
.option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect, [])
.option('--timeout <ms>', 'Request timeout in milliseconds')
.option('--plain', 'Plain output (stable, no emoji, no color)')
.option('--no-emoji', 'Disable emoji output')
.option('--no-color', 'Disable ANSI colors (or set NO_COLOR)');
program.hook('preAction', (_thisCommand, actionCommand) => {
applyOutputFromCommand(actionCommand);
});
type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer };
function resolveTimeoutMs(...values: Array<string | number | undefined | null>): number | undefined {
for (const value of values) {
if (value === undefined || value === null || value === '') continue;
const parsed = typeof value === 'number' ? value : Number(value);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return undefined;
}
function resolveTimeoutFromOptions(options: { timeout?: string | number }): number | undefined {
return resolveTimeoutMs(options.timeout, config.timeoutMs, process.env.BIRD_TIMEOUT_MS);
}
function detectMime(path: string): string | null {
const ext = path.toLowerCase();
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) return 'image/jpeg';
if (ext.endsWith('.png')) return 'image/png';
if (ext.endsWith('.webp')) return 'image/webp';
if (ext.endsWith('.gif')) return 'image/gif';
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) return 'video/mp4';
if (ext.endsWith('.mov')) return 'video/quicktime';
return null;
}
function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] {
if (opts.media.length === 0) return [];
const specs: MediaSpec[] = [];
for (const [index, path] of opts.media.entries()) {
const mime = detectMime(path);
if (!mime) {
throw new Error(`Unsupported media type for ${path}. Supported: jpg, jpeg, png, webp, gif, mp4, mov`);
}
const buffer = readFileSync(path);
specs.push({ path, mime, buffer, alt: opts.alts[index] });
}
const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length;
if (videoCount > 1) throw new Error('Only one video can be attached');
if (videoCount === 1 && specs.length > 1) throw new Error('Video cannot be combined with other media');
if (specs.length > 4) throw new Error('Maximum 4 media attachments');
return specs;
}
function printTweets(
tweets: TweetData[],
opts: { json?: boolean; emptyMessage?: string; showSeparator?: boolean } = {},
) {
if (opts.json) {
console.log(JSON.stringify(tweets, null, 2));
return;
}
if (tweets.length === 0) {
console.log(opts.emptyMessage ?? 'No tweets found.');
return;
}
for (const tweet of tweets) {
console.log(`\n@${tweet.author.username} (${tweet.author.name}):`);
console.log(tweet.text);
if (tweet.createdAt) {
console.log(`${l('date')}${tweet.createdAt}`);
}
console.log(`${l('url')}https://x.com/${tweet.author.username}/status/${tweet.id}`);
if (opts.showSeparator ?? true) {
console.log('─'.repeat(50));
}
}
}
program
.command('help [command]')
.description('Show help for a command')
.action((commandName?: string) => {
if (!commandName) {
program.outputHelp();
return;
}
const cmd = program.commands.find((c) => c.name() === commandName);
if (!cmd) {
console.error(`${p('err')}Unknown command: ${commandName}`);
process.exitCode = 2;
return;
}
cmd.outputHelp();
});
program
.command('query-ids')
.description('Show or refresh cached Twitter GraphQL query IDs')
.option('--json', 'Output as JSON')
.option('--fresh', 'Force refresh (downloads X client bundles)', false)
.action(async (cmdOpts: { json?: boolean; fresh?: boolean }) => {
const operations = [
'CreateTweet',
'CreateRetweet',
'FavoriteTweet',
'TweetDetail',
'SearchTimeline',
'UserArticlesTweets',
];
if (cmdOpts.fresh) {
console.error(`${p('info')}Refreshing GraphQL query IDs…`);
await runtimeQueryIds.refresh(operations, { force: true });
}
const info = await runtimeQueryIds.getSnapshotInfo();
if (!info) {
if (cmdOpts.json) {
console.log(JSON.stringify({ cached: false, cachePath: runtimeQueryIds.cachePath }, null, 2));
return;
}
console.log(`${p('warn')}No cached query IDs yet.`);
console.log(`${p('info')}Run: bird query-ids --fresh`);
return;
}
if (cmdOpts.json) {
console.log(
JSON.stringify(
{
cached: true,
cachePath: info.cachePath,
fetchedAt: info.snapshot.fetchedAt,
isFresh: info.isFresh,
ageMs: info.ageMs,
ids: info.snapshot.ids,
discovery: info.snapshot.discovery,
},
null,
2,
),
);
return;
}
console.log(`${p('ok')}GraphQL query IDs cached`);
console.log(`path: ${info.cachePath}`);
console.log(`fetched_at: ${info.snapshot.fetchedAt}`);
console.log(`fresh: ${info.isFresh ? 'yes' : 'no'}`);
console.log(`ops: ${Object.keys(info.snapshot.ids).length}`);
});
// Tweet command
program
.command('tweet')
.description('Post a new tweet')
.argument('<text>', 'Tweet text')
.action(async (text: string) => {
const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts);
let media: MediaSpec[] = [];
try {
media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
} catch (error) {
console.error(`${p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowSafari: config.allowSafari ?? true,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${p('err')}Missing required credentials`);
process.exit(1);
}
if (cookies.source) {
console.error(`${l('source')}${cookies.source}`);
}
const client = new TwitterClient({ cookies, timeoutMs });
let mediaIds: string[] | undefined;
if (media.length > 0) {
const uploaded: string[] = [];
for (const item of media) {
const res = await client.uploadMedia({ data: item.buffer, mimeType: item.mime, alt: item.alt });
if (!res.success || !res.mediaId) {
console.error(`${p('err')}Media upload failed: ${res.error ?? 'Unknown error'}`);
process.exit(1);
}
uploaded.push(res.mediaId);
}
mediaIds = uploaded;
}
const result = await client.tweet(text, mediaIds);
if (result.success) {
console.log(`${p('ok')}Tweet posted successfully!`);
console.log(`${l('url')}https://x.com/i/status/${result.tweetId}`);
} else {
console.error(`${p('err')}Failed to post tweet: ${result.error}`);
process.exit(1);
}
});
// Reply command
program
.command('reply')
.description('Reply to an existing tweet')
.argument('<tweet-id-or-url>', 'Tweet ID or URL to reply to')
.argument('<text>', 'Reply text')
.action(async (tweetIdOrUrl: string, text: string) => {
const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts);
let media: MediaSpec[] = [];
try {
media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
} catch (error) {
console.error(`${p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
const tweetId = extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowSafari: config.allowSafari ?? true,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${p('err')}Missing required credentials`);
process.exit(1);
}
if (cookies.source) {
console.error(`${l('source')}${cookies.source}`);
}
console.error(`${p('info')}Replying to tweet: ${tweetId}`);
const client = new TwitterClient({ cookies, timeoutMs });
let mediaIds: string[] | undefined;
if (media.length > 0) {
const uploaded: string[] = [];
for (const item of media) {
const res = await client.uploadMedia({ data: item.buffer, mimeType: item.mime, alt: item.alt });
if (!res.success || !res.mediaId) {
console.error(`${p('err')}Media upload failed: ${res.error ?? 'Unknown error'}`);
process.exit(1);
}
uploaded.push(res.mediaId);
}
mediaIds = uploaded;
}
const result = await client.reply(text, tweetId, mediaIds);
if (result.success) {
console.log(`${p('ok')}Reply posted successfully!`);
console.log(`${l('url')}https://x.com/i/status/${result.tweetId}`);
} else {
console.error(`${p('err')}Failed to post reply: ${result.error}`);
process.exit(1);
}
});
// Read command - fetch tweet content
program
.command('read')
.description('Read/fetch a tweet by ID or URL')
.argument('<tweet-id-or-url>', 'Tweet ID or URL to read')
.option('--json', 'Output as JSON')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts);
const tweetId = extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowSafari: config.allowSafari ?? true,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const result = await client.getTweet(tweetId);
if (result.success && result.tweet) {
if (cmdOpts.json) {
console.log(JSON.stringify(result.tweet, null, 2));
} else {
console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`);
console.log(result.tweet.text);
if (result.tweet.createdAt) {
console.log(`\n${l('date')}${result.tweet.createdAt}`);
}
console.log(formatStatsLine(result.tweet, output));
}
} else {
console.error(`${p('err')}Failed to read tweet: ${result.error}`);
process.exit(1);
}
});
// Replies command - list replies to a tweet
program
.command('replies')
.description('List replies to a tweet (by ID or URL)')
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
.option('--json', 'Output as JSON')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts);
const tweetId = extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowSafari: config.allowSafari ?? true,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const result = await client.getReplies(tweetId);
if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' });
} else {
console.error(`${p('err')}Failed to fetch replies: ${result.error}`);
process.exit(1);
}
});
// Thread command - show full conversation thread
program
.command('thread')
.description('Show the full conversation thread containing the tweet')
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
.option('--json', 'Output as JSON')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts);
const tweetId = extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowSafari: config.allowSafari ?? true,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const result = await client.getThread(tweetId);
if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' });
} else {
console.error(`${p('err')}Failed to fetch thread: ${result.error}`);
process.exit(1);
}
});
// Search command - find tweets
program
.command('search')
.description('Search for tweets')
.argument('<query>', 'Search query (e.g., "@clawdbot" or "from:clawdbot")')
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
.option('--json', 'Output as JSON')
.action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10);
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowSafari: config.allowSafari ?? true,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const result = await client.search(query, count);
if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' });
} else {
console.error(`${p('err')}Search failed: ${result.error}`);
process.exit(1);
}
});
// Mentions command - shortcut to search for @username mentions
program
.command('mentions')
.description('Find tweets mentioning a user (defaults to current user)')
.option('-u, --user <handle>', 'User handle (e.g. @steipete)')
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10);
const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user);
if (fromUserOpt.error) {
console.error(`${p('err')}${fromUserOpt.error}`);
process.exit(2);
}
let query: string | null = fromUserOpt.query;
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowSafari: config.allowSafari ?? true,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
if (!query) {
const who = await client.getCurrentUser();
const handle = normalizeHandle(who.user?.username);
if (!handle) {
console.error(
`${p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user <handle>.`,
);
process.exit(1);
} else {
query = `@${handle}`;
}
}
const result = await client.search(query, count);
if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
} else {
console.error(`${p('err')}Failed to fetch mentions: ${result.error}`);
process.exit(1);
}
});
// Whoami command - show the logged-in account
program
.command('whoami')
.description('Show which Twitter account the current credentials belong to')
.action(async () => {
const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts);
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowSafari: config.allowSafari ?? true,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${p('err')}Missing required credentials`);
process.exit(1);
}
if (cookies.source) {
console.error(`${l('source')}${cookies.source}`);
}
const client = new TwitterClient({ cookies, timeoutMs });
const result = await client.getCurrentUser();
const credentialSource = cookies.source ?? 'env/auto-detected cookies';
if (result.success && result.user) {
console.log(`${l('user')}@${result.user.username} (${result.user.name})`);
console.log(`${l('userId')}${result.user.id}`);
console.log(`${l('engine')}graphql`);
console.log(`${l('credentials')}${credentialSource}`);
} else {
console.error(`${p('err')}Failed to determine current user: ${result.error ?? 'Unknown error'}`);
process.exit(1);
}
});
// Check command - verify credentials
program
.command('check')
.description('Check credential availability')
.action(async () => {
const opts = program.opts();
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowSafari: config.allowSafari ?? true,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
console.log(`${p('info')}Credential check`);
console.log('─'.repeat(40));
if (cookies.authToken) {
console.log(`${p('ok')}auth_token: ${cookies.authToken.slice(0, 10)}...`);
} else {
console.log(`${p('err')}auth_token: not found`);
}
if (cookies.ct0) {
console.log(`${p('ok')}ct0: ${cookies.ct0.slice(0, 10)}...`);
} else {
console.log(`${p('err')}ct0: not found`);
}
if (cookies.source) {
console.log(`${l('source')}${cookies.source}`);
}
if (warnings.length > 0) {
console.log(`\n${p('warn')}Warnings:`);
for (const warning of warnings) {
console.log(` - ${warning}`);
}
}
if (cookies.authToken && cookies.ct0) {
console.log(`\n${p('ok')}Ready to tweet!`);
} else {
console.log(`\n${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);
}
});
const { argv, showHelp } = resolveCliInvocation(normalizedArgs, KNOWN_COMMANDS);
if (showHelp) {
program.outputHelp();
process.exit(0);
}
if (argv) {
program.parse(argv);
} else {
program.parse(['node', 'bird', ...normalizedArgs]);
}
+3 -1
View File
@@ -8,7 +8,9 @@ const TWEET_ID_REGEX = /^\d{8,}$/;
export function looksLikeTweetInput(value: string): boolean { export function looksLikeTweetInput(value: string): boolean {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return false; if (!trimmed) {
return false;
}
return TWEET_URL_REGEX.test(trimmed) || TWEET_ID_REGEX.test(trimmed); return TWEET_URL_REGEX.test(trimmed) || TWEET_ID_REGEX.test(trimmed);
} }
+157 -478
View File
@@ -1,17 +1,14 @@
/** /**
* Browser cookie extraction for Twitter authentication * Browser cookie extraction for Twitter authentication.
* Uses sqlite3 CLI where possible and binarycookies parsing for Safari. * Delegates to @steipete/sweet-cookie for Safari/Chrome/Firefox reads.
*/ */
import { execSync } from 'node:child_process'; import { getCookies } from '@steipete/sweet-cookie';
import { createDecipheriv, pbkdf2Sync } from 'node:crypto';
import { copyFileSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export interface TwitterCookies { export interface TwitterCookies {
authToken: string | null; authToken: string | null;
ct0: string | null; ct0: string | null;
cookieHeader: string | null;
source: string | null; source: string | null;
} }
@@ -20,529 +17,208 @@ export interface CookieExtractionResult {
warnings: string[]; 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 { function normalizeValue(value: unknown): string | null {
if (typeof value === 'string') { if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim(); const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null; 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; return null;
} }
function getChromeCookiesPath(profile?: string): string { const preferred = matches.find((c) => (c.domain ?? '').endsWith('x.com'));
const home = process.env.HOME || ''; if (preferred?.value) {
const profileDir = profile || 'Default'; return preferred.value;
return join(home, 'Library', 'Application Support', 'Google', 'Chrome', profileDir, 'Cookies');
} }
function getFirefoxProfilesRoot(): string | null { const twitter = matches.find((c) => (c.domain ?? '').endsWith('twitter.com'));
const home = process.env.HOME || ''; if (twitter?.value) {
if (process.platform === 'darwin') { return twitter.value;
return join(home, 'Library', 'Application Support', 'Firefox', 'Profiles');
}
if (process.platform === 'linux') {
return join(home, '.mozilla', 'firefox');
}
if (process.platform === 'win32') {
const appData = process.env.APPDATA;
if (!appData) return null;
return join(appData, 'Mozilla', 'Firefox', 'Profiles');
}
return null;
} }
function pickFirefoxProfile(profilesRoot: string, profile?: string): string | null { return matches[0]?.value ?? null;
if (profile) {
const candidate = join(profilesRoot, profile, 'cookies.sqlite');
return existsSync(candidate) ? candidate : null;
} }
const entries = readdirSync(profilesRoot, { withFileTypes: true }); async function readTwitterCookiesFromBrowser(options: {
const defaultRelease = entries.find((entry) => entry.isDirectory() && entry.name.includes('default-release')); source: CookieSource;
const targetDir = defaultRelease?.name ?? entries.find((e) => e.isDirectory())?.name; chromeProfile?: string;
if (!targetDir) return null; firefoxProfile?: string;
cookieTimeoutMs?: number;
const candidate = join(profilesRoot, targetDir, 'cookies.sqlite'); }): Promise<CookieExtractionResult> {
return existsSync(candidate) ? candidate : null;
}
function getFirefoxCookiesPath(profile?: string): string | null {
const profilesRoot = getFirefoxProfilesRoot();
if (!profilesRoot || !existsSync(profilesRoot)) return null;
return pickFirefoxProfile(profilesRoot, profile);
}
function getSafariCookiesPath(): string | null {
if (process.platform !== 'darwin') return null;
const home = process.env.HOME || '';
const candidates = [
join(home, 'Library', 'Cookies', 'Cookies.binarycookies'),
join(home, 'Library', 'Containers', 'com.apple.Safari', 'Data', 'Library', 'Cookies', 'Cookies.binarycookies'),
];
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
return null;
}
const SAFARI_COOKIE_NAMES = new Set(['auth_token', 'ct0']);
const SAFARI_COOKIE_DOMAINS = ['x.com', 'twitter.com'];
const SAFARI_PAGE_SIGNATURE = Buffer.from([0x00, 0x00, 0x01, 0x00]);
function matchesSafariDomain(domain: string | null): boolean {
if (!domain) return false;
const normalized = (domain.startsWith('.') ? domain.slice(1) : domain).toLowerCase();
return SAFARI_COOKIE_DOMAINS.some((target) => normalized === target || normalized.endsWith(`.${target}`));
}
function readSafariCString(buffer: Buffer, start: number, end: number): string | null {
if (start < 0 || start >= end) return null;
let cursor = start;
while (cursor < end && buffer[cursor] !== 0) cursor += 1;
if (cursor >= end) return null;
return buffer.toString('utf8', start, cursor);
}
function parseSafariCookieRecord(page: Buffer, offset: number, cookies: TwitterCookies): void {
if (offset < 0 || offset + 4 > page.length) return;
const recordSize = page.readUInt32LE(offset);
const recordEnd = offset + recordSize;
if (recordSize <= 0 || recordEnd > page.length) return;
const headerStart = offset + 4;
const headerSize = 4 + 4 + 4 + 4 + 4 + 4 + 4; // unknown1 + flags + unknown2 + domain + name + path + value
if (headerStart + headerSize > recordEnd) return;
const domainOffset = page.readUInt32LE(headerStart + 12);
const nameOffset = page.readUInt32LE(headerStart + 16);
const valueOffset = page.readUInt32LE(headerStart + 24);
const domain = readSafariCString(page, offset + domainOffset, recordEnd);
const name = readSafariCString(page, offset + nameOffset, recordEnd);
const value = readSafariCString(page, offset + valueOffset, recordEnd);
if (!name || !value || !matchesSafariDomain(domain)) return;
if (!SAFARI_COOKIE_NAMES.has(name)) return;
const normalizedValue = normalizeValue(value);
if (!normalizedValue) return;
if (name === 'auth_token' && !cookies.authToken) {
cookies.authToken = normalizedValue;
} else if (name === 'ct0' && !cookies.ct0) {
cookies.ct0 = normalizedValue;
}
}
function parseSafariCookiePage(page: Buffer, cookies: TwitterCookies): void {
if (page.length < 12) return;
if (!page.subarray(0, 4).equals(SAFARI_PAGE_SIGNATURE)) return;
const cookieCount = page.readUInt32LE(4);
if (!cookieCount) return;
const offsets: number[] = [];
let cursor = 8;
for (let i = 0; i < cookieCount; i += 1) {
if (cursor + 4 > page.length) return;
offsets.push(page.readUInt32LE(cursor));
cursor += 4;
}
for (const offset of offsets) {
parseSafariCookieRecord(page, offset, cookies);
if (cookies.authToken && cookies.ct0) return;
}
}
function parseSafariCookies(data: Buffer, cookies: TwitterCookies): void {
if (data.length < 8) return;
if (data.subarray(0, 4).toString('utf8') !== 'cook') return;
const pageCount = data.readUInt32BE(4);
let cursor = 8;
const pageSizes: number[] = [];
for (let i = 0; i < pageCount; i += 1) {
if (cursor + 4 > data.length) return;
pageSizes.push(data.readUInt32BE(cursor));
cursor += 4;
}
for (const pageSize of pageSizes) {
if (cursor + pageSize > data.length) return;
const page = data.subarray(cursor, cursor + pageSize);
parseSafariCookiePage(page, cookies);
if (cookies.authToken && cookies.ct0) return;
cursor += pageSize;
}
}
/**
* Extract Twitter cookies from Safari browser using Cookies.binarycookies
*/
export async function extractCookiesFromSafari(): Promise<CookieExtractionResult> {
const warnings: string[] = []; const warnings: string[] = [];
const cookies: TwitterCookies = { const out = buildEmpty();
authToken: null,
ct0: null,
source: null,
};
const cookiesPath = getSafariCookiesPath(); const { cookies, warnings: providerWarnings } = await getCookies({
if (!cookiesPath) { url: TWITTER_URL,
warnings.push('Safari cookies database not found.'); origins: TWITTER_ORIGINS,
return { cookies, warnings }; 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;
} }
let tempDir: string | null = null; if (out.authToken && out.ct0) {
out.cookieHeader = cookieHeader(out.authToken, out.ct0);
try { out.source = labelForSource(
tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-')); options.source,
const tempCookiesPath = join(tempDir, 'Cookies.binarycookies'); options.source === 'chrome' ? options.chromeProfile : options.firefoxProfile,
copyFileSync(cookiesPath, tempCookiesPath); );
const data = readFileSync(tempCookiesPath); return { cookies: out, warnings };
parseSafariCookies(data, cookies);
if (cookies.authToken || cookies.ct0) {
cookies.source = 'Safari';
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warnings.push(`Failed to read Safari cookies: ${message}`);
} finally {
if (tempDir) {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
}
} }
if (!cookies.authToken && !cookies.ct0) { if (options.source === 'safari') {
warnings.push('No Twitter cookies found in Safari. Make sure you are logged into x.com in Safari.'); warnings.push('No Twitter cookies found in Safari. Make sure you are logged into x.com in Safari.');
} } else if (options.source === 'chrome') {
return { cookies, warnings };
}
/**
* Decrypt Chrome cookie value using macOS keychain
* Chrome encrypts cookies with a key stored in the keychain
*/
function decryptCookieValue(encryptedHex: string): string | null {
try {
// Convert hex to buffer
const encryptedValue = Buffer.from(encryptedHex, 'hex');
if (encryptedValue.length < 4) {
return null;
}
const version = encryptedValue.subarray(0, 3).toString('utf8');
if (version !== 'v10' && version !== 'v11') {
// Not encrypted, just return as string
return encryptedValue.toString('utf8');
}
// Get encryption key from keychain
const keyOutput = execSync('security find-generic-password -s "Chrome Safe Storage" -w 2>/dev/null || echo ""', {
encoding: 'utf8',
}).trim();
if (!keyOutput) {
return null;
}
// Derive the key using PBKDF2
const salt = 'saltysalt';
const iterations = 1003;
const keyLength = 16;
const derivedKey = pbkdf2Sync(keyOutput, salt, iterations, keyLength, 'sha1');
// Decrypt using AES-128-CBC with empty IV (16 bytes of 0x20/space)
const iv = Buffer.alloc(16, 0x20);
const encryptedData = encryptedValue.subarray(3); // Skip "v10" or "v11" prefix
const decipher = createDecipheriv('aes-128-cbc', derivedKey, iv);
decipher.setAutoPadding(true);
let decrypted = decipher.update(encryptedData);
decrypted = Buffer.concat([decrypted, decipher.final()]);
// Chrome v10 cookies have key material prepended before the actual value
// Twitter cookies are hex strings, so extract the longest hex sequence
const decryptedStr = decrypted.toString('utf8');
const hexMatch = decryptedStr.match(/[a-f0-9]{32,}/i);
if (hexMatch) {
return hexMatch[0];
}
// Fallback: keep only printable ASCII characters
return decryptedStr.replace(/[^\x20-\x7E]/g, '');
} catch {
return null;
}
}
/**
* Extract Twitter cookies from Chrome browser using sqlite3 CLI
* @param profile - Chrome profile name (optional, uses Default if not specified)
*/
export async function extractCookiesFromChrome(profile?: string): Promise<CookieExtractionResult> {
const warnings: string[] = [];
const cookies: TwitterCookies = {
authToken: null,
ct0: null,
source: null,
};
const cookiesPath = getChromeCookiesPath(profile);
if (!existsSync(cookiesPath)) {
warnings.push(`Chrome cookies database not found at: ${cookiesPath}`);
return { cookies, warnings };
}
// Chrome locks the database, so we need to copy it
let tempDir: string | null = null;
try {
tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-'));
const tempDbPath = join(tempDir, 'Cookies');
copyFileSync(cookiesPath, tempDbPath);
// Also copy the WAL and SHM files if they exist
const walPath = `${cookiesPath}-wal`;
const shmPath = `${cookiesPath}-shm`;
if (existsSync(walPath)) {
copyFileSync(walPath, `${tempDbPath}-wal`);
}
if (existsSync(shmPath)) {
copyFileSync(shmPath, `${tempDbPath}-shm`);
}
// Use sqlite3 CLI to query cookies (no native deps required!)
const query = `SELECT name, hex(encrypted_value) as encrypted_hex FROM cookies WHERE host_key IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com') AND name IN ('auth_token', 'ct0');`;
const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, {
encoding: 'utf8',
maxBuffer: 1024 * 1024,
}).trim();
if (result) {
for (const line of result.split('\n')) {
const [name, encryptedHex] = line.split('|');
if (!name || !encryptedHex) continue;
const decryptedValue = decryptCookieValue(encryptedHex);
if (decryptedValue) {
if (name === 'auth_token' && !cookies.authToken) {
cookies.authToken = decryptedValue;
} else if (name === 'ct0' && !cookies.ct0) {
cookies.ct0 = decryptedValue;
}
}
}
}
if (cookies.authToken || cookies.ct0) {
cookies.source = profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warnings.push(`Failed to read Chrome cookies: ${message}`);
} finally {
// Cleanup temp files
if (tempDir) {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
}
}
if (!cookies.authToken && !cookies.ct0) {
warnings.push('No Twitter cookies found in Chrome. Make sure you are logged into x.com in Chrome.'); warnings.push('No Twitter cookies found in Chrome. Make sure you are logged into x.com in Chrome.');
} } else {
return { cookies, warnings };
}
/**
* Extract Twitter cookies from Firefox browser using sqlite3 CLI
* @param profile - Firefox profile directory name (optional, auto-detected)
*/
export async function extractCookiesFromFirefox(profile?: string): Promise<CookieExtractionResult> {
const warnings: string[] = [];
const cookies: TwitterCookies = {
authToken: null,
ct0: null,
source: null,
};
const cookiesPath = getFirefoxCookiesPath(profile);
if (!cookiesPath) {
warnings.push('Firefox cookies database not found.');
return { cookies, warnings };
}
let tempDir: string | null = null;
try {
tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-'));
const tempDbPath = join(tempDir, 'cookies.sqlite');
copyFileSync(cookiesPath, tempDbPath);
const query = `SELECT name, value FROM moz_cookies WHERE host IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com') AND name IN ('auth_token', 'ct0');`;
const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, {
encoding: 'utf8',
maxBuffer: 1024 * 1024,
}).trim();
if (result) {
for (const line of result.split('\n')) {
const [name, value] = line.split('|');
if (!name || !value) continue;
if (name === 'auth_token' && !cookies.authToken) {
cookies.authToken = value;
} else if (name === 'ct0' && !cookies.ct0) {
cookies.ct0 = value;
}
}
}
if (cookies.authToken || cookies.ct0) {
cookies.source = profile ? `Firefox profile "${profile}"` : 'Firefox default profile';
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warnings.push(`Failed to read Firefox cookies: ${message}`);
} finally {
if (tempDir) {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
}
}
if (!cookies.authToken && !cookies.ct0) {
warnings.push( warnings.push(
'No Twitter cookies found in Firefox. Make sure you are logged into x.com in Firefox and the profile exists.', 'No Twitter cookies found in Firefox. Make sure you are logged into x.com in Firefox and the profile exists.',
); );
} }
return { cookies, warnings }; 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 * Resolve Twitter credentials from multiple sources.
* Priority: CLI args > environment variables > Safari > Chrome > Firefox * Priority: CLI args > environment variables > browsers (ordered).
*/ */
export async function resolveCredentials(options: { export async function resolveCredentials(options: {
authToken?: string; authToken?: string;
ct0?: string; ct0?: string;
allowSafari?: boolean; cookieSource?: CookieSource | CookieSource[];
chromeProfile?: string; chromeProfile?: string;
firefoxProfile?: string; firefoxProfile?: string;
allowChrome?: boolean; cookieTimeoutMs?: number;
allowFirefox?: boolean;
}): Promise<CookieExtractionResult> { }): Promise<CookieExtractionResult> {
const warnings: string[] = []; const warnings: string[] = [];
const cookies: TwitterCookies = { const cookies = buildEmpty();
authToken: null, const cookieTimeoutMs =
ct0: null, typeof options.cookieTimeoutMs === 'number' &&
source: null, Number.isFinite(options.cookieTimeoutMs) &&
}; options.cookieTimeoutMs > 0
? options.cookieTimeoutMs
: process.platform === 'darwin'
? DEFAULT_COOKIE_TIMEOUT_MS
: undefined;
// 1. CLI arguments (highest priority)
if (options.authToken) { if (options.authToken) {
cookies.authToken = options.authToken; cookies.authToken = options.authToken;
cookies.source = 'CLI argument'; cookies.source = 'CLI argument';
} }
if (options.ct0) { if (options.ct0) {
cookies.ct0 = options.ct0; cookies.ct0 = options.ct0;
if (!cookies.source) cookies.source = 'CLI argument'; if (!cookies.source) {
} cookies.source = 'CLI argument';
// 2. Environment variables
const envAuthKeys = ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'];
const envCt0Keys = ['CT0', 'TWITTER_CT0'];
if (!cookies.authToken) {
for (const key of envAuthKeys) {
const value = normalizeValue(process.env[key]);
if (value) {
cookies.authToken = value;
cookies.source = `env ${key}`;
break;
}
}
}
if (!cookies.ct0) {
for (const key of envCt0Keys) {
const value = normalizeValue(process.env[key]);
if (value) {
cookies.ct0 = value;
if (!cookies.source) cookies.source = `env ${key}`;
break;
}
}
}
const allowSafari = options.allowSafari ?? true;
const allowChrome = options.allowChrome ?? true;
const allowFirefox = options.allowFirefox ?? true;
// 3. Safari cookies (preferred browser fallback)
if (allowSafari && (!cookies.authToken || !cookies.ct0)) {
const safariResult = await extractCookiesFromSafari();
warnings.push(...safariResult.warnings);
if (!cookies.authToken && safariResult.cookies.authToken) {
cookies.authToken = safariResult.cookies.authToken;
cookies.source = safariResult.cookies.source;
}
if (!cookies.ct0 && safariResult.cookies.ct0) {
cookies.ct0 = safariResult.cookies.ct0;
if (!cookies.source) cookies.source = safariResult.cookies.source;
} }
} }
// 4. Chrome cookies (secondary browser fallback) readEnvCookie(cookies, ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'], 'authToken');
if (allowChrome && (!cookies.authToken || !cookies.ct0)) { readEnvCookie(cookies, ['CT0', 'TWITTER_CT0'], 'ct0');
const chromeResult = await extractCookiesFromChrome(options.chromeProfile);
warnings.push(...chromeResult.warnings);
if (!cookies.authToken && chromeResult.cookies.authToken) { if (cookies.authToken && cookies.ct0) {
cookies.authToken = chromeResult.cookies.authToken; cookies.cookieHeader = cookieHeader(cookies.authToken, cookies.ct0);
cookies.source = chromeResult.cookies.source; return { cookies, warnings };
} }
if (!cookies.ct0 && chromeResult.cookies.ct0) {
cookies.ct0 = chromeResult.cookies.ct0; const sourcesToTry = resolveSources(options.cookieSource);
if (!cookies.source) cookies.source = chromeResult.cookies.source; 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 };
} }
} }
// 5. Firefox cookies (tertiary browser fallback)
if (allowFirefox && (!cookies.authToken || !cookies.ct0)) {
const firefoxResult = await extractCookiesFromFirefox(options.firefoxProfile);
warnings.push(...firefoxResult.warnings);
if (!cookies.authToken && firefoxResult.cookies.authToken) {
cookies.authToken = firefoxResult.cookies.authToken;
cookies.source = firefoxResult.cookies.source;
}
if (!cookies.ct0 && firefoxResult.cookies.ct0) {
cookies.ct0 = firefoxResult.cookies.ct0;
if (!cookies.source) cookies.source = firefoxResult.cookies.source;
}
}
// Validation
if (!cookies.authToken) { if (!cookies.authToken) {
warnings.push( warnings.push(
'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Safari/Chrome/Firefox', 'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Safari/Chrome/Firefox',
@@ -551,6 +227,9 @@ export async function resolveCredentials(options: {
if (!cookies.ct0) { if (!cookies.ct0) {
warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Safari/Chrome/Firefox'); 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 }; return { cookies, warnings };
} }
+20
View File
@@ -0,0 +1,20 @@
/**
* Extract bookmark folder (collection) ID from an X/Twitter bookmarks URL.
*/
const BOOKMARK_FOLDER_URL_REGEX = /(?:twitter\.com|x\.com)\/i\/bookmarks\/(\d+)/i;
const BOOKMARK_FOLDER_ID_REGEX = /^\d{5,}$/;
export function extractBookmarkFolderId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) {
return null;
}
const urlMatch = BOOKMARK_FOLDER_URL_REGEX.exec(trimmed);
if (urlMatch) {
return urlMatch[1];
}
if (BOOKMARK_FOLDER_ID_REGEX.test(trimmed)) {
return trimmed;
}
return null;
}
+3 -1
View File
@@ -1,9 +1,11 @@
/** /**
* Extract tweet ID from a Twitter/X URL or return the input unchanged if it's already an ID. * Extract tweet ID from a Twitter/X URL or return the input unchanged if it's already an ID.
*/ */
const TWEET_URL_REGEX = /(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/;
export function extractTweetId(input: string): string { export function extractTweetId(input: string): string {
// If it's a URL, extract the tweet ID // If it's a URL, extract the tweet ID
const urlMatch = input.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/); const urlMatch = TWEET_URL_REGEX.exec(input);
if (urlMatch) { if (urlMatch) {
return urlMatch[1]; return urlMatch[1];
} }
+21
View File
@@ -0,0 +1,21 @@
export {
type CookieExtractionResult,
type CookieSource,
extractCookiesFromChrome,
extractCookiesFromFirefox,
extractCookiesFromSafari,
resolveCredentials,
type TwitterCookies,
} from './cookies.js';
export { runtimeQueryIds } from './runtime-query-ids.js';
export {
type CurrentUserResult,
type FollowingResult,
type GetTweetResult,
type SearchResult,
type TweetData,
TwitterClient,
type TwitterClientOptions,
type TwitterUser,
} from './twitter-client.js';
export type { TweetResult, UploadMediaResult } from './twitter-client-types.js';
+14 -4
View File
@@ -1,13 +1,21 @@
const HANDLE_REGEX = /^[A-Za-z0-9_]{1,15}$/;
export function normalizeHandle(input?: string | null): string | null { export function normalizeHandle(input?: string | null): string | null {
const raw = (input ?? '').trim(); const raw = (input ?? '').trim();
if (!raw) return null; if (!raw) {
return null;
}
const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw; const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw;
const handle = withoutAt.trim(); const handle = withoutAt.trim();
if (!handle) return null; if (!handle) {
return null;
}
// X/Twitter handles are traditionally max 15 chars; keep strict to avoid surprising queries. // X/Twitter handles are traditionally max 15 chars; keep strict to avoid surprising queries.
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null; if (!HANDLE_REGEX.test(handle)) {
return null;
}
return handle; return handle;
} }
@@ -16,7 +24,9 @@ export function mentionsQueryFromUserOption(userOption?: string | null): {
query: string | null; query: string | null;
error: string | null; error: string | null;
} { } {
if (typeof userOption === 'undefined') return { query: null, error: null }; if (typeof userOption === 'undefined') {
return { query: null, error: null };
}
const handle = normalizeHandle(userOption); const handle = normalizeHandle(userOption);
if (!handle) { if (!handle) {
+26 -6
View File
@@ -53,14 +53,22 @@ export function resolveOutputConfigFromCommander(
} }
export function statusPrefix(kind: StatusKind, cfg: OutputConfig): string { export function statusPrefix(kind: StatusKind, cfg: OutputConfig): string {
if (cfg.plain) return `${STATUS[kind].plain} `; if (cfg.plain) {
if (cfg.emoji) return `${STATUS[kind].emoji} `; return `${STATUS[kind].plain} `;
}
if (cfg.emoji) {
return `${STATUS[kind].emoji} `;
}
return `${STATUS[kind].text} `; return `${STATUS[kind].text} `;
} }
export function labelPrefix(kind: LabelKind, cfg: OutputConfig): string { export function labelPrefix(kind: LabelKind, cfg: OutputConfig): string {
if (cfg.plain) return `${LABELS[kind].plain} `; if (cfg.plain) {
if (cfg.emoji) return `${LABELS[kind].emoji} `; return `${LABELS[kind].plain} `;
}
if (cfg.emoji) {
return `${LABELS[kind].emoji} `;
}
return `${LABELS[kind].text} `; return `${LABELS[kind].text} `;
} }
@@ -72,7 +80,19 @@ export function formatStatsLine(
const retweetCount = stats.retweetCount ?? 0; const retweetCount = stats.retweetCount ?? 0;
const replyCount = stats.replyCount ?? 0; const replyCount = stats.replyCount ?? 0;
if (cfg.plain) return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`; if (cfg.plain) {
if (!cfg.emoji) return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`; return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`;
}
if (!cfg.emoji) {
return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`;
}
return `❤️ ${likeCount} 🔁 ${retweetCount} 💬 ${replyCount}`; return `❤️ ${likeCount} 🔁 ${retweetCount} 💬 ${replyCount}`;
} }
export function formatTweetUrl(tweetId: string): string {
return `https://x.com/i/status/${tweetId}`;
}
export function formatTweetUrlLine(tweetId: string, cfg: OutputConfig): string {
return `${labelPrefix('url', cfg)}${formatTweetUrl(tweetId)}`;
}
+6 -1
View File
@@ -3,5 +3,10 @@
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A", "CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A", "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
"TweetDetail": "97JF30KziU00483E_8elBA", "TweetDetail": "97JF30KziU00483E_8elBA",
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ" "SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ",
"Bookmarks": "RV1g3b8n_SGOHwkqKYSCFw",
"BookmarkFolderTimeline": "KJIQpsvxrTfRIlbaRIySHQ",
"Following": "BEkNpEt5pNETESoqMsTEGA",
"Followers": "kuFUYP9eV1FPoEy4N-pi7w",
"Likes": "JR2gceKucIKcVNB_9JkhsA"
} }
+59 -21
View File
@@ -3,9 +3,9 @@ import { homedir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
const DEFAULT_CACHE_FILENAME = 'query-ids-cache.json'; const DEFAULT_CACHE_FILENAME = 'query-ids-cache.json';
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_TTL_MS: number = 24 * 60 * 60 * 1000;
const DISCOVERY_PAGES = [ const DISCOVERY_PAGES: string[] = [
'https://x.com/?lang=en', 'https://x.com/?lang=en',
'https://x.com/explore', 'https://x.com/explore',
'https://x.com/notifications', 'https://x.com/notifications',
@@ -13,6 +13,7 @@ const DISCOVERY_PAGES = [
]; ];
const BUNDLE_URL_REGEX = /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g; const BUNDLE_URL_REGEX = /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g;
const QUERY_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
const OPERATION_PATTERNS = [ const OPERATION_PATTERNS = [
{ {
@@ -67,6 +68,15 @@ export type RuntimeQueryIdsOptions = {
fetchImpl?: typeof fetch; fetchImpl?: typeof fetch;
}; };
export type RuntimeQueryIdStore = {
cachePath: string;
ttlMs: number;
getSnapshotInfo: () => Promise<RuntimeQueryIdSnapshotInfo | null>;
getQueryId: (operationName: string) => Promise<string | null>;
refresh: (operationNames: string[], opts?: { force?: boolean }) => Promise<RuntimeQueryIdSnapshotInfo | null>;
clearMemory: () => void;
};
async function fetchText(fetchImpl: typeof fetch, url: string): Promise<string> { async function fetchText(fetchImpl: typeof fetch, url: string): Promise<string> {
const response = await fetchImpl(url, { headers: HEADERS }); const response = await fetchImpl(url, { headers: HEADERS });
if (!response.ok) { if (!response.ok) {
@@ -85,7 +95,9 @@ function resolveDefaultCachePath(): string {
} }
function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null { function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
if (!raw || typeof raw !== 'object') return null; if (!raw || typeof raw !== 'object') {
return null;
}
const record = raw as Record<string, unknown>; const record = raw as Record<string, unknown>;
const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null; const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null;
const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null; const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null;
@@ -93,10 +105,14 @@ function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
const discovery = const discovery =
record.discovery && typeof record.discovery === 'object' ? (record.discovery as Record<string, unknown>) : null; record.discovery && typeof record.discovery === 'object' ? (record.discovery as Record<string, unknown>) : null;
if (!fetchedAt || !ttlMs || !ids || !discovery) return null; if (!fetchedAt || !ttlMs || !ids || !discovery) {
return null;
}
const pages = Array.isArray(discovery.pages) ? discovery.pages : null; const pages = Array.isArray(discovery.pages) ? discovery.pages : null;
const bundles = Array.isArray(discovery.bundles) ? discovery.bundles : null; const bundles = Array.isArray(discovery.bundles) ? discovery.bundles : null;
if (!pages || !bundles) return null; if (!pages || !bundles) {
return null;
}
const normalizedIds: Record<string, string> = {}; const normalizedIds: Record<string, string> = {};
for (const [key, value] of Object.entries(ids)) { for (const [key, value] of Object.entries(ids)) {
@@ -120,9 +136,7 @@ async function readSnapshotFromDisk(cachePath: string): Promise<RuntimeQueryIdSn
try { try {
const raw = await readFile(cachePath, 'utf8'); const raw = await readFile(cachePath, 'utf8');
return parseSnapshot(JSON.parse(raw)); return parseSnapshot(JSON.parse(raw));
} catch (error) { } catch {
const code = error && typeof error === 'object' ? String((error as { code?: unknown }).code ?? '') : '';
if (code !== 'ENOENT') return null;
return null; return null;
} }
} }
@@ -161,15 +175,27 @@ function extractOperations(
pattern.regex.lastIndex = 0; pattern.regex.lastIndex = 0;
while (true) { while (true) {
const match = pattern.regex.exec(bundleContents); const match = pattern.regex.exec(bundleContents);
if (match === null) break; if (match === null) {
break;
}
const operationName = match[pattern.operationGroup]; const operationName = match[pattern.operationGroup];
const queryId = match[pattern.queryIdGroup]; const queryId = match[pattern.queryIdGroup];
if (!operationName || !queryId) continue; if (!operationName || !queryId) {
if (!targets.has(operationName)) continue; continue;
if (!/^[a-zA-Z0-9_-]+$/.test(queryId)) continue; }
if (discovered.has(operationName)) continue; if (!targets.has(operationName)) {
continue;
}
if (!QUERY_ID_REGEX.test(queryId)) {
continue;
}
if (discovered.has(operationName)) {
continue;
}
discovered.set(operationName, { queryId, bundle: bundleLabel }); discovered.set(operationName, { queryId, bundle: bundleLabel });
if (discovered.size === targets.size) return; if (discovered.size === targets.size) {
return;
}
} }
} }
} }
@@ -186,7 +212,9 @@ async function fetchAndExtract(
const chunk = bundleUrls.slice(i, i + CONCURRENCY); const chunk = bundleUrls.slice(i, i + CONCURRENCY);
await Promise.all( await Promise.all(
chunk.map(async (url) => { chunk.map(async (url) => {
if (discovered.size === targets.size) return; if (discovered.size === targets.size) {
return;
}
const label = url.split('/').at(-1) ?? url; const label = url.split('/').at(-1) ?? url;
try { try {
const js = await fetchText(fetchImpl, url); const js = await fetchText(fetchImpl, url);
@@ -196,13 +224,15 @@ async function fetchAndExtract(
} }
}), }),
); );
if (discovered.size === targets.size) break; if (discovered.size === targets.size) {
break;
}
} }
return discovered; return discovered;
} }
export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}) { export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}): RuntimeQueryIdStore {
const fetchImpl = options.fetchImpl ?? fetch; const fetchImpl = options.fetchImpl ?? fetch;
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath(); const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath();
@@ -212,7 +242,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
let refreshInFlight: Promise<RuntimeQueryIdSnapshotInfo | null> | null = null; let refreshInFlight: Promise<RuntimeQueryIdSnapshotInfo | null> | null = null;
const loadSnapshot = async (): Promise<RuntimeQueryIdSnapshot | null> => { const loadSnapshot = async (): Promise<RuntimeQueryIdSnapshot | null> => {
if (memorySnapshot) return memorySnapshot; if (memorySnapshot) {
return memorySnapshot;
}
if (!loadOnce) { if (!loadOnce) {
loadOnce = (async () => { loadOnce = (async () => {
const fromDisk = await readSnapshotFromDisk(cachePath); const fromDisk = await readSnapshotFromDisk(cachePath);
@@ -225,7 +257,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
const getSnapshotInfo = async (): Promise<RuntimeQueryIdSnapshotInfo | null> => { const getSnapshotInfo = async (): Promise<RuntimeQueryIdSnapshotInfo | null> => {
const snapshot = await loadSnapshot(); const snapshot = await loadSnapshot();
if (!snapshot) return null; if (!snapshot) {
return null;
}
const fetchedAtMs = new Date(snapshot.fetchedAt).getTime(); const fetchedAtMs = new Date(snapshot.fetchedAt).getTime();
const ageMs = Number.isFinite(fetchedAtMs) ? Math.max(0, Date.now() - fetchedAtMs) : Number.POSITIVE_INFINITY; const ageMs = Number.isFinite(fetchedAtMs) ? Math.max(0, Date.now() - fetchedAtMs) : Number.POSITIVE_INFINITY;
const effectiveTtl = Number.isFinite(snapshot.ttlMs) ? snapshot.ttlMs : ttlMs; const effectiveTtl = Number.isFinite(snapshot.ttlMs) ? snapshot.ttlMs : ttlMs;
@@ -235,7 +269,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
const getQueryId = async (operationName: string): Promise<string | null> => { const getQueryId = async (operationName: string): Promise<string | null> => {
const info = await getSnapshotInfo(); const info = await getSnapshotInfo();
if (!info) return null; if (!info) {
return null;
}
return info.snapshot.ids[operationName] ?? null; return info.snapshot.ids[operationName] ?? null;
}; };
@@ -243,7 +279,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
operationNames: string[], operationNames: string[],
opts: { force?: boolean } = {}, opts: { force?: boolean } = {},
): Promise<RuntimeQueryIdSnapshotInfo | null> => { ): Promise<RuntimeQueryIdSnapshotInfo | null> => {
if (refreshInFlight) return refreshInFlight; if (refreshInFlight) {
return refreshInFlight;
}
refreshInFlight = (async () => { refreshInFlight = (async () => {
const current = await getSnapshotInfo(); const current = await getSnapshotInfo();
+142
View File
@@ -0,0 +1,142 @@
import { randomBytes, randomUUID } from 'node:crypto';
import { runtimeQueryIds } from './runtime-query-ids.js';
import { type OperationName, QUERY_IDS, TARGET_QUERY_ID_OPERATIONS } from './twitter-client-constants.js';
import type { CurrentUserResult, TwitterClientOptions } from './twitter-client-types.js';
import { normalizeQuoteDepth } from './twitter-client-utils.js';
// biome-ignore lint/suspicious/noExplicitAny: TS mixin base constructor requirement.
export type Constructor<T = object> = new (...args: any[]) => T;
// biome-ignore lint/suspicious/noExplicitAny: TS mixin base constructor requirement.
export type AbstractConstructor<T = object> = abstract new (...args: any[]) => T;
export type Mixin<TBase extends AbstractConstructor<TwitterClientBase>, TAdded> = abstract new (
...args: ConstructorParameters<TBase>
) => TwitterClientBase & TAdded;
export abstract class TwitterClientBase {
protected authToken: string;
protected ct0: string;
protected cookieHeader: string;
protected userAgent: string;
protected timeoutMs?: number;
protected quoteDepth: number;
protected clientUuid: string;
protected clientDeviceId: string;
protected clientUserId?: string;
constructor(options: TwitterClientOptions) {
if (!options.cookies.authToken || !options.cookies.ct0) {
throw new Error('Both authToken and ct0 cookies are required');
}
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.quoteDepth = normalizeQuoteDepth(options.quoteDepth);
this.clientUuid = randomUUID();
this.clientDeviceId = randomUUID();
}
protected abstract getCurrentUser(): Promise<CurrentUserResult>;
protected async getQueryId(operationName: OperationName): Promise<string> {
const cached = await runtimeQueryIds.getQueryId(operationName);
return cached ?? QUERY_IDS[operationName];
}
protected async refreshQueryIds(): Promise<void> {
if (process.env.NODE_ENV === 'test') {
return;
}
try {
await runtimeQueryIds.refresh(TARGET_QUERY_ID_OPERATIONS, { force: true });
} catch {
// ignore refresh failures; callers will fall back to baked-in IDs
}
}
protected async getTweetDetailQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('TweetDetail');
return Array.from(new Set([primary, '97JF30KziU00483E_8elBA', 'aFvUsJm2c-oDkJV75blV6g']));
}
protected async getSearchTimelineQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('SearchTimeline');
return Array.from(new Set([primary, 'M1jEez78PEfVfbQLvlWMvQ', '5h0kNbk3ii97rmfY6CdgAA', 'Tp1sewRU1AsZpBWhqCZicQ']));
}
protected async fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
if (!this.timeoutMs || this.timeoutMs <= 0) {
return fetch(url, init);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
protected getHeaders(): Record<string, string> {
return this.getJsonHeaders();
}
protected createTransactionId(): string {
return randomBytes(16).toString('hex');
}
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 headers;
}
protected getJsonHeaders(): Record<string, string> {
return {
...this.getBaseHeaders(),
'content-type': 'application/json',
};
}
protected getUploadHeaders(): Record<string, string> {
// Note: do not set content-type; URLSearchParams/FormData need to set it (incl boundary) themselves.
return this.getBaseHeaders();
}
protected async ensureClientUserId(): Promise<void> {
if (process.env.NODE_ENV === 'test') {
return;
}
if (this.clientUserId) {
return;
}
const result = await this.getCurrentUser();
if (result.success && result.user?.id) {
this.clientUserId = result.user.id;
}
}
}
+37
View File
@@ -0,0 +1,37 @@
// biome-ignore lint/correctness/useImportExtensions: JSON module import doesn't use .js extension.
import queryIds from './query-ids.json' with { type: 'json' };
export const TWITTER_API_BASE = 'https://x.com/i/api/graphql';
export const TWITTER_GRAPHQL_POST_URL = 'https://x.com/i/api/graphql';
export const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/i/media/upload.json';
export const TWITTER_MEDIA_METADATA_URL = 'https://x.com/i/api/1.1/media/metadata/create.json';
export const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/update.json';
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
// scripts/update-query-ids.ts. The fallback values keep the client usable if
// the file is missing or incomplete.
export const FALLBACK_QUERY_IDS = {
CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg',
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
TweetDetail: '97JF30KziU00483E_8elBA',
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
Following: 'BEkNpEt5pNETESoqMsTEGA',
Followers: 'kuFUYP9eV1FPoEy4N-pi7w',
Likes: 'JR2gceKucIKcVNB_9JkhsA',
BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ',
} as const;
export type OperationName = keyof typeof FALLBACK_QUERY_IDS;
export const QUERY_IDS: Record<OperationName, string> = {
...FALLBACK_QUERY_IDS,
...(queryIds as Partial<Record<OperationName, string>>),
};
export const TARGET_QUERY_ID_OPERATIONS = Object.keys(FALLBACK_QUERY_IDS) as Array<OperationName>;
+210
View File
@@ -0,0 +1,210 @@
export function buildArticleFeatures(): Record<string, boolean> {
return {
rweb_video_screen_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: true,
rweb_tipjar_consumption_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_jetfuel_frame: true,
responsive_web_grok_share_attachment_enabled: true,
articles_preview_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
responsive_web_grok_show_grok_translated_post: false,
responsive_web_grok_analysis_button_from_backend: true,
creator_subscriptions_quote_tweet_preview_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_grok_image_annotation_enabled: true,
responsive_web_grok_imagine_annotation_enabled: true,
responsive_web_grok_community_note_auto_translation_is_enabled: false,
responsive_web_enhance_cards_enabled: false,
};
}
export function buildTweetDetailFeatures(): Record<string, boolean> {
return {
...buildArticleFeatures(),
responsive_web_graphql_exclude_directive_enabled: true,
communities_web_enable_tweet_community_results_fetch: true,
responsive_web_twitter_article_plain_text_enabled: true,
responsive_web_twitter_article_seed_tweet_detail_enabled: true,
responsive_web_twitter_article_seed_tweet_summary_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
tweet_awards_web_tipping_enabled: false,
creator_subscriptions_quote_tweet_preview_enabled: false,
verified_phone_label_enabled: false,
};
}
export function buildArticleFieldToggles(): Record<string, boolean> {
return {
withPayments: false,
withAuxiliaryUserLabels: false,
withArticleRichContentState: true,
withArticlePlainText: true,
withGrokAnalyze: false,
withDisallowedReplyControls: false,
};
}
export function buildSearchFeatures(): Record<string, boolean> {
return {
rweb_video_screen_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: true,
rweb_tipjar_consumption_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_jetfuel_frame: true,
responsive_web_grok_share_attachment_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
responsive_web_grok_show_grok_translated_post: false,
responsive_web_grok_analysis_button_from_backend: true,
creator_subscriptions_quote_tweet_preview_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
rweb_video_timestamps_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_grok_image_annotation_enabled: true,
responsive_web_grok_imagine_annotation_enabled: true,
responsive_web_grok_community_note_auto_translation_is_enabled: false,
articles_preview_enabled: true,
responsive_web_enhance_cards_enabled: false,
};
}
export function buildTweetCreateFeatures(): Record<string, boolean> {
return {
rweb_video_screen_enabled: true,
creator_subscriptions_tweet_preview_api_enabled: true,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_jetfuel_frame: true,
responsive_web_grok_share_attachment_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
responsive_web_grok_show_grok_translated_post: false,
responsive_web_grok_analysis_button_from_backend: true,
creator_subscriptions_quote_tweet_preview_enabled: false,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: false,
rweb_tipjar_consumption_enabled: true,
verified_phone_label_enabled: false,
articles_preview_enabled: true,
responsive_web_grok_community_note_auto_translation_is_enabled: false,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
responsive_web_grok_image_annotation_enabled: true,
responsive_web_grok_imagine_annotation_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_enhance_cards_enabled: false,
};
}
export function buildTimelineFeatures(): Record<string, boolean> {
return {
...buildSearchFeatures(),
blue_business_profile_image_shape_enabled: true,
responsive_web_text_conversations_enabled: false,
tweetypie_unmention_optimization_enabled: true,
vibe_api_enabled: true,
responsive_web_twitter_blue_verified_badge_is_enabled: true,
interactive_text_enabled: true,
longform_notetweets_richtext_consumption_enabled: true,
responsive_web_media_download_video_enabled: false,
};
}
export function buildBookmarksFeatures(): Record<string, boolean> {
return {
...buildTimelineFeatures(),
graphql_timeline_v2_bookmark_timeline: true,
};
}
export function buildLikesFeatures(): Record<string, boolean> {
return buildTimelineFeatures();
}
export function buildFollowingFeatures(): Record<string, boolean> {
return {
rweb_video_screen_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: false,
responsive_web_profile_redirect_enabled: true,
rweb_tipjar_consumption_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: true,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_jetfuel_frame: false,
responsive_web_grok_share_attachment_enabled: false,
articles_preview_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: true,
responsive_web_grok_show_grok_translated_post: false,
responsive_web_grok_analysis_button_from_backend: false,
creator_subscriptions_quote_tweet_preview_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_grok_image_annotation_enabled: false,
responsive_web_grok_imagine_annotation_enabled: false,
responsive_web_grok_community_note_auto_translation_is_enabled: false,
responsive_web_enhance_cards_enabled: false,
};
}
+179
View File
@@ -0,0 +1,179 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_MEDIA_METADATA_URL, TWITTER_UPLOAD_URL } from './twitter-client-constants.js';
import type { UploadMediaResult } from './twitter-client-types.js';
export interface TwitterClientMediaMethods {
uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise<UploadMediaResult>;
}
export function withMedia<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientMediaMethods> {
abstract class TwitterClientMedia extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
private mediaCategoryForMime(mimeType: string): string | null {
if (mimeType.startsWith('image/')) {
if (mimeType === 'image/gif') {
return 'tweet_gif';
}
return 'tweet_image';
}
if (mimeType.startsWith('video/')) {
return 'tweet_video';
}
return null;
}
private async sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise<UploadMediaResult> {
const category = this.mediaCategoryForMime(input.mimeType);
if (!category) {
return { success: false, error: `Unsupported media type: ${input.mimeType}` };
}
try {
const initParams = new URLSearchParams({
command: 'INIT',
total_bytes: String(input.data.byteLength),
media_type: input.mimeType,
media_category: category,
});
const initResp = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, {
method: 'POST',
headers: this.getUploadHeaders(),
body: initParams,
});
if (!initResp.ok) {
const text = await initResp.text();
return { success: false, error: `HTTP ${initResp.status}: ${text.slice(0, 200)}` };
}
const initBody = (await initResp.json()) as { media_id_string?: string; media_id?: string | number };
const mediaId =
typeof initBody.media_id_string === 'string'
? initBody.media_id_string
: initBody.media_id !== undefined
? String(initBody.media_id)
: undefined;
if (!mediaId) {
return { success: false, error: 'Media upload INIT did not return media_id' };
}
const chunkSize = 5 * 1024 * 1024;
let segmentIndex = 0;
for (let offset = 0; offset < input.data.byteLength; offset += chunkSize) {
const chunk = input.data.slice(offset, Math.min(input.data.byteLength, offset + chunkSize));
const form = new FormData();
form.set('command', 'APPEND');
form.set('media_id', mediaId);
form.set('segment_index', String(segmentIndex));
form.set('media', new Blob([chunk], { type: input.mimeType }), 'media');
const appendResp = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, {
method: 'POST',
headers: this.getUploadHeaders(),
body: form,
});
if (!appendResp.ok) {
const text = await appendResp.text();
return { success: false, error: `HTTP ${appendResp.status}: ${text.slice(0, 200)}` };
}
segmentIndex += 1;
}
const finalizeParams = new URLSearchParams({ command: 'FINALIZE', media_id: mediaId });
const finalizeResp = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, {
method: 'POST',
headers: this.getUploadHeaders(),
body: finalizeParams,
});
if (!finalizeResp.ok) {
const text = await finalizeResp.text();
return { success: false, error: `HTTP ${finalizeResp.status}: ${text.slice(0, 200)}` };
}
const finalizeBody = (await finalizeResp.json()) as {
processing_info?: {
state?: string;
check_after_secs?: number;
error?: { message?: string; name?: string };
};
};
const info = finalizeBody.processing_info;
if (info?.state && info.state !== 'succeeded') {
let attempts = 0;
while (attempts < 20) {
if (info.state === 'failed') {
const msg = info.error?.message || info.error?.name || 'Media processing failed';
return { success: false, error: msg };
}
const delaySecs = Number.isFinite(info.check_after_secs) ? Math.max(1, info.check_after_secs as number) : 2;
await this.sleep(delaySecs * 1000);
const statusUrl = `${TWITTER_UPLOAD_URL}?${new URLSearchParams({
command: 'STATUS',
media_id: mediaId,
}).toString()}`;
const statusResp = await this.fetchWithTimeout(statusUrl, {
method: 'GET',
headers: this.getUploadHeaders(),
});
if (!statusResp.ok) {
const text = await statusResp.text();
return { success: false, error: `HTTP ${statusResp.status}: ${text.slice(0, 200)}` };
}
const statusBody = (await statusResp.json()) as {
processing_info?: {
state?: string;
check_after_secs?: number;
error?: { message?: string; name?: string };
};
};
if (!statusBody.processing_info) {
break;
}
info.state = statusBody.processing_info.state;
info.check_after_secs = statusBody.processing_info.check_after_secs;
info.error = statusBody.processing_info.error;
if (info.state === 'succeeded') {
break;
}
attempts += 1;
}
}
if (input.alt && input.mimeType.startsWith('image/')) {
const metaResp = await this.fetchWithTimeout(TWITTER_MEDIA_METADATA_URL, {
method: 'POST',
headers: this.getJsonHeaders(),
body: JSON.stringify({ media_id: mediaId, alt_text: { text: input.alt } }),
});
if (!metaResp.ok) {
const text = await metaResp.text();
return { success: false, error: `HTTP ${metaResp.status}: ${text.slice(0, 200)}` };
}
}
return { success: true, mediaId };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
}
return TwitterClientMedia;
}
+287
View File
@@ -0,0 +1,287 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE, TWITTER_GRAPHQL_POST_URL, TWITTER_STATUS_UPDATE_URL } from './twitter-client-constants.js';
import { buildTweetCreateFeatures } from './twitter-client-features.js';
import type { CreateTweetResponse, TweetResult } from './twitter-client-types.js';
export interface TwitterClientPostingMethods {
tweet(text: string, mediaIds?: string[]): Promise<TweetResult>;
reply(text: string, replyToTweetId: string, mediaIds?: string[]): Promise<TweetResult>;
}
export function withPosting<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientPostingMethods> {
abstract class TwitterClientPosting extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
/**
* Post a new tweet
*/
async tweet(text: string, mediaIds?: string[]): Promise<TweetResult> {
const variables = {
tweet_text: text,
dark_request: false,
media: {
media_entities: (mediaIds ?? []).map((id) => ({ media_id: id, tagged_users: [] })),
possibly_sensitive: false,
},
semantic_annotation_ids: [],
};
const features = buildTweetCreateFeatures();
return this.createTweet(variables, features);
}
/**
* Reply to an existing tweet
*/
async reply(text: string, replyToTweetId: string, mediaIds?: string[]): Promise<TweetResult> {
const variables = {
tweet_text: text,
reply: {
in_reply_to_tweet_id: replyToTweetId,
exclude_reply_user_ids: [],
},
dark_request: false,
media: {
media_entities: (mediaIds ?? []).map((id) => ({ media_id: id, tagged_users: [] })),
possibly_sensitive: false,
},
semantic_annotation_ids: [],
};
const features = buildTweetCreateFeatures();
return this.createTweet(variables, features);
}
private async createTweet(
variables: Record<string, unknown>,
features: Record<string, boolean>,
): Promise<TweetResult> {
await this.ensureClientUserId();
let queryId = await this.getQueryId('CreateTweet');
let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/CreateTweet`;
const buildBody = () => JSON.stringify({ variables, features, queryId });
let body = buildBody();
try {
const headers = { ...this.getHeaders(), referer: 'https://x.com/compose/post' };
let response = await this.fetchWithTimeout(urlWithOperation, {
method: 'POST',
headers,
body,
});
// Twitter increasingly prefers POST to /i/api/graphql with queryId in the payload.
// If the operation URL 404s, retry the generic endpoint.
if (response.status === 404) {
await this.refreshQueryIds();
queryId = await this.getQueryId('CreateTweet');
urlWithOperation = `${TWITTER_API_BASE}/${queryId}/CreateTweet`;
body = buildBody();
response = await this.fetchWithTimeout(urlWithOperation, {
method: 'POST',
headers: { ...this.getHeaders(), referer: 'https://x.com/compose/post' },
body,
});
if (response.status === 404) {
const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, {
method: 'POST',
headers: { ...this.getHeaders(), referer: 'https://x.com/compose/post' },
body,
});
if (!retry.ok) {
const text = await retry.text();
return { success: false, error: `HTTP ${retry.status}: ${text.slice(0, 200)}` };
}
const data = (await retry.json()) as CreateTweetResponse;
if (data.errors && data.errors.length > 0) {
const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
if (fallback) {
return fallback;
}
return { success: false, error: this.formatErrors(data.errors) };
}
const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id;
if (tweetId) {
return { success: true, tweetId };
}
return { success: false, error: 'Tweet created but no ID returned' };
}
}
if (!response.ok) {
const text = await response.text();
return {
success: false,
error: `HTTP ${response.status}: ${text.slice(0, 200)}`,
};
}
const data = (await response.json()) as CreateTweetResponse;
if (data.errors && data.errors.length > 0) {
const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
if (fallback) {
return fallback;
}
return {
success: false,
error: this.formatErrors(data.errors),
};
}
const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id;
if (tweetId) {
return {
success: true,
tweetId,
};
}
return {
success: false,
error: 'Tweet created but no ID returned',
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
private formatErrors(errors: Array<{ message: string; code?: number }>): string {
return errors
.map((error) => (typeof error.code === 'number' ? `${error.message} (${error.code})` : error.message))
.join(', ');
}
private statusUpdateInputFromCreateTweetVariables(variables: Record<string, unknown>): {
text: string;
inReplyToTweetId?: string;
mediaIds?: string[];
} | null {
const text = typeof variables.tweet_text === 'string' ? variables.tweet_text : null;
if (!text) {
return null;
}
const reply = variables.reply;
const inReplyToTweetId =
reply &&
typeof reply === 'object' &&
typeof (reply as { in_reply_to_tweet_id?: unknown }).in_reply_to_tweet_id === 'string'
? (reply as { in_reply_to_tweet_id: string }).in_reply_to_tweet_id
: undefined;
const media = variables.media;
const mediaEntities =
media && typeof media === 'object' ? (media as { media_entities?: unknown }).media_entities : undefined;
const mediaIds = Array.isArray(mediaEntities)
? mediaEntities
.map((entity) =>
entity && typeof entity === 'object' && 'media_id' in (entity as Record<string, unknown>)
? (entity as { media_id?: unknown }).media_id
: undefined,
)
.filter((value): value is string | number => typeof value === 'string' || typeof value === 'number')
.map((value) => String(value))
: undefined;
return { text, inReplyToTweetId, mediaIds: mediaIds && mediaIds.length > 0 ? mediaIds : undefined };
}
private async postStatusUpdate(input: {
text: string;
inReplyToTweetId?: string;
mediaIds?: string[];
}): Promise<TweetResult> {
const params = new URLSearchParams();
params.set('status', input.text);
if (input.inReplyToTweetId) {
params.set('in_reply_to_status_id', input.inReplyToTweetId);
params.set('auto_populate_reply_metadata', 'true');
}
if (input.mediaIds && input.mediaIds.length > 0) {
params.set('media_ids', input.mediaIds.join(','));
}
try {
const response = await this.fetchWithTimeout(TWITTER_STATUS_UPDATE_URL, {
method: 'POST',
headers: {
...this.getBaseHeaders(),
'content-type': 'application/x-www-form-urlencoded',
referer: 'https://x.com/compose/post',
},
body: params.toString(),
});
if (!response.ok) {
const text = await response.text();
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
}
const data = (await response.json()) as {
id_str?: string;
id?: string | number;
errors?: Array<{ message: string; code?: number }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false, error: this.formatErrors(data.errors) };
}
const tweetId =
typeof data.id_str === 'string' ? data.id_str : data.id !== undefined ? String(data.id) : undefined;
if (tweetId) {
return { success: true, tweetId };
}
return { success: false, error: 'Tweet created but no ID returned' };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
private async tryStatusUpdateFallback(
errors: Array<{ message: string; code?: number }>,
variables: Record<string, unknown>,
): Promise<TweetResult | null> {
if (!errors.some((error) => error.code === 226)) {
return null;
}
const input = this.statusUpdateInputFromCreateTweetVariables(variables);
if (!input) {
return null;
}
const fallback = await this.postStatusUpdate(input);
if (fallback.success) {
return fallback;
}
return {
success: false,
error: `${this.formatErrors(errors)} | fallback: ${fallback.error ?? 'Unknown error'}`,
};
}
}
return TwitterClientPosting;
}
+173
View File
@@ -0,0 +1,173 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildSearchFeatures } from './twitter-client-features.js';
import type { SearchResult, TweetData } from './twitter-client-types.js';
import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js';
export interface TwitterClientSearchMethods {
search(query: string, count?: number): Promise<SearchResult>;
}
export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientSearchMethods> {
abstract class TwitterClientSearch extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
/**
* Search for tweets matching a query
*/
async search(query: string, count = 20): Promise<SearchResult> {
const features = buildSearchFeatures();
const pageSize = 20;
const seen = new Set<string>();
const tweets: TweetData[] = [];
let cursor: string | undefined;
const fetchPage = async (pageCount: number, pageCursor?: string) => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getSearchTimelineQueryIds();
for (const queryId of queryIds) {
const variables = {
rawQuery: query,
count: pageCount,
querySource: 'typed_query',
product: 'Latest',
...(pageCursor ? { cursor: pageCursor } : {}),
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
});
const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({ features, queryId }),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
search_by_raw_query?: {
search_timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: {
rest_id?: string;
legacy?: {
full_text?: string;
created_at?: string;
reply_count?: number;
retweet_count?: number;
favorite_count?: number;
in_reply_to_status_id_str?: string;
};
core?: {
user_results?: {
result?: {
legacy?: {
screen_name?: string;
name?: string;
};
};
};
};
};
};
};
};
}>;
}>;
};
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.search_by_raw_query?.search_timeline?.timeline?.instructions;
const pageTweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
const nextCursor = extractCursorFromInstructions(instructions);
return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching search results', had404 };
};
const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => {
const firstAttempt = await fetchPage(pageCount, pageCursor);
if (firstAttempt.success) {
return firstAttempt;
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await fetchPage(pageCount, pageCursor);
if (secondAttempt.success) {
return secondAttempt;
}
return { success: false as const, error: secondAttempt.error };
}
return { success: false as const, error: firstAttempt.error };
};
while (tweets.length < count) {
const pageCount = Math.min(pageSize, count - tweets.length);
const page = await fetchWithRefresh(pageCount, cursor);
if (!page.success) {
return { success: false, error: page.error };
}
for (const tweet of page.tweets) {
if (seen.has(tweet.id)) {
continue;
}
seen.add(tweet.id);
tweets.push(tweet);
if (tweets.length >= count) {
break;
}
}
if (!page.cursor || page.cursor === cursor || page.tweets.length === 0) {
break;
}
cursor = page.cursor;
}
return { success: true, tweets };
}
}
return TwitterClientSearch;
}
+352
View File
@@ -0,0 +1,352 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildBookmarksFeatures, buildLikesFeatures } from './twitter-client-features.js';
import type { GraphqlTweetResult, SearchResult } from './twitter-client-types.js';
import { parseTweetsFromInstructions } from './twitter-client-utils.js';
export interface TwitterClientTimelineMethods {
getBookmarks(count?: number): Promise<SearchResult>;
getLikes(count?: number): Promise<SearchResult>;
getBookmarkFolderTimeline(folderId: string, count?: number): Promise<SearchResult>;
}
export function withTimelines<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientTimelineMethods> {
abstract class TwitterClientTimelines extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
private async getBookmarksQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Bookmarks');
return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw']));
}
private async getBookmarkFolderQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('BookmarkFolderTimeline');
return Array.from(new Set([primary, 'KJIQpsvxrTfRIlbaRIySHQ']));
}
private async getLikesQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Likes');
return Array.from(new Set([primary, 'JR2gceKucIKcVNB_9JkhsA']));
}
/**
* Get the authenticated user's bookmarks
*/
async getBookmarks(count = 20): Promise<SearchResult> {
const variables = {
count,
includePromotedContent: false,
withDownvotePerspective: false,
withReactionsMetadata: false,
withReactionsPerspective: false,
};
const features = buildBookmarksFeatures();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const tryOnce = async () => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getBookmarksQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
bookmark_timeline_v2?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.bookmark_timeline_v2?.timeline?.instructions;
const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
return { success: true as const, tweets, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching bookmarks', had404 };
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, tweets: firstAttempt.tweets };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
/**
* Get the authenticated user's liked tweets
*/
async getLikes(count = 20): Promise<SearchResult> {
const userResult = await this.getCurrentUser();
if (!userResult.success || !userResult.user) {
return { success: false, error: userResult.error ?? 'Could not determine current user' };
}
const variables = {
userId: userResult.user.id,
count,
includePromotedContent: false,
withClientEventToken: false,
withBirdwatchNotes: false,
withVoice: true,
};
const features = buildLikesFeatures();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const tryOnce = async () => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getLikesQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/Likes?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
return { success: true as const, tweets, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching likes', had404 };
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, tweets: firstAttempt.tweets };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
/**
* Get the authenticated user's bookmark folder timeline
*/
async getBookmarkFolderTimeline(folderId: string, count = 20): Promise<SearchResult> {
const variablesWithCount = {
bookmark_collection_id: folderId,
includePromotedContent: true,
count,
};
const variablesWithoutCount = {
bookmark_collection_id: folderId,
includePromotedContent: true,
};
const features = buildBookmarksFeatures();
const tryOnce = async (variables: Record<string, unknown>) => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getBookmarkFolderQueryIds();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
bookmark_collection_timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.bookmark_collection_timeline?.timeline?.instructions;
const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
return { success: true as const, tweets, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching bookmark folder', had404 };
};
let firstAttempt = await tryOnce(variablesWithCount);
if (!firstAttempt.success && firstAttempt.error?.includes('Variable "$count"')) {
firstAttempt = await tryOnce(variablesWithoutCount);
}
if (firstAttempt.success) {
return { success: true, tweets: firstAttempt.tweets };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
let secondAttempt = await tryOnce(variablesWithCount);
if (!secondAttempt.success && secondAttempt.error?.includes('Variable "$count"')) {
secondAttempt = await tryOnce(variablesWithoutCount);
}
if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
}
return TwitterClientTimelines;
}
+337
View File
@@ -0,0 +1,337 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildArticleFeatures, buildArticleFieldToggles, buildTweetDetailFeatures } from './twitter-client-features.js';
import type { GetTweetResult, GraphqlTweetResult, SearchResult } from './twitter-client-types.js';
import {
extractArticleText,
findTweetInInstructions,
firstText,
mapTweetResult,
parseTweetsFromInstructions,
} from './twitter-client-utils.js';
export interface TwitterClientTweetDetailMethods {
getTweet(tweetId: string): Promise<GetTweetResult>;
getReplies(tweetId: string): Promise<SearchResult>;
getThread(tweetId: string): Promise<SearchResult>;
}
export function withTweetDetails<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientTweetDetailMethods> {
abstract class TwitterClientTweetDetails extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
private async fetchUserArticlePlainText(
userId: string,
tweetId: string,
): Promise<{ title?: string; plainText?: string }> {
const variables = {
userId,
count: 20,
includePromotedContent: true,
withVoice: true,
withQuickPromoteEligibilityTweetFields: true,
withBirdwatchNotes: true,
withCommunity: true,
withSafetyModeUserFields: true,
withSuperFollowsUserFields: true,
withDownvotePerspective: false,
withReactionsMetadata: false,
withReactionsPerspective: false,
withSuperFollowsTweetFields: true,
withSuperFollowsReplyCount: false,
withClientEventToken: false,
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(buildArticleFeatures()),
fieldToggles: JSON.stringify(buildArticleFieldToggles()),
});
const queryId = await this.getQueryId('UserArticlesTweets');
const url = `${TWITTER_API_BASE}/${queryId}/UserArticlesTweets?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, { method: 'GET', headers: this.getHeaders() });
if (!response.ok) {
return {};
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: { result?: GraphqlTweetResult };
};
};
}>;
}>;
};
};
};
};
};
};
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions ?? [];
for (const instruction of instructions) {
for (const entry of instruction.entries ?? []) {
const result = entry.content?.itemContent?.tweet_results?.result;
if (result?.rest_id !== tweetId) {
continue;
}
const articleResult = result.article?.article_results?.result;
const title = firstText(articleResult?.title, result.article?.title);
const plainText = firstText(articleResult?.plain_text, result.article?.plain_text);
return { title, plainText };
}
}
} catch {
return {};
}
return {};
}
private async fetchTweetDetail(tweetId: string): Promise<
| {
success: true;
data: {
tweetResult?: { result?: GraphqlTweetResult };
threaded_conversation_with_injections_v2?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
}
| { success: false; error: string }
> {
const variables = {
focalTweetId: tweetId,
with_rux_injections: false,
rankingMode: 'Relevance',
includePromotedContent: true,
withCommunity: true,
withQuickPromoteEligibilityTweetFields: true,
withBirdwatchNotes: true,
withVoice: true,
};
const features = {
...buildTweetDetailFeatures(),
articles_preview_enabled: true,
articles_rest_api_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
rweb_video_timestamps_enabled: true,
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
try {
const parseResponse = async (response: Response) => {
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
}
const data = (await response.json()) as {
data?: {
tweetResult?: { result?: GraphqlTweetResult };
threaded_conversation_with_injections_v2?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
errors?: Array<{ message: string; code?: number }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', ') };
}
return { success: true as const, data: data.data ?? {} };
};
let lastError: string | undefined;
let had404 = false;
const tryOnce = async () => {
const queryIds = await this.getTweetDetailQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params.toString()}`;
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status !== 404) {
return await parseResponse(response);
}
had404 = true;
const postResponse = await this.fetchWithTimeout(`${TWITTER_API_BASE}/${queryId}/TweetDetail`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({ variables, features, queryId }),
});
if (postResponse.status !== 404) {
return await parseResponse(postResponse);
}
lastError = 'HTTP 404';
}
return { success: false as const, error: lastError ?? 'Unknown error fetching tweet detail' };
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return firstAttempt;
}
if (had404) {
await this.refreshQueryIds();
return await tryOnce();
}
return firstAttempt;
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
/**
* Get tweet details by ID
*/
async getTweet(tweetId: string): Promise<GetTweetResult> {
const response = await this.fetchTweetDetail(tweetId);
if (!response.success) {
return response;
}
const tweetResult =
(response.data.tweetResult as { result?: GraphqlTweetResult } | undefined)?.result ??
findTweetInInstructions(
response.data.threaded_conversation_with_injections_v2?.instructions as
| Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>
| undefined,
tweetId,
);
const mapped = mapTweetResult(tweetResult, this.quoteDepth);
if (mapped) {
if (tweetResult?.article) {
const title = firstText(tweetResult.article.article_results?.result?.title, tweetResult.article.title);
const articleText = extractArticleText(tweetResult);
if (title && (!articleText || articleText.trim() === title.trim())) {
const userId = tweetResult.core?.user_results?.result?.rest_id;
if (userId) {
const fallback = await this.fetchUserArticlePlainText(userId, tweetId);
if (fallback.plainText) {
mapped.text = fallback.title ? `${fallback.title}\n\n${fallback.plainText}` : fallback.plainText;
}
}
}
}
return { success: true, tweet: mapped };
}
return { success: false, error: 'Tweet not found in response' };
}
/**
* Get replies to a tweet by ID
*/
async getReplies(tweetId: string): Promise<SearchResult> {
const response = await this.fetchTweetDetail(tweetId);
if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
const replies = tweets.filter((tweet) => tweet.inReplyToStatusId === tweetId);
return { success: true, tweets: replies };
}
/**
* Get full conversation thread for a tweet ID
*/
async getThread(tweetId: string): Promise<SearchResult> {
const response = await this.fetchTweetDetail(tweetId);
if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
const target = tweets.find((t) => t.id === tweetId);
const rootId = target?.conversationId || tweetId;
const thread = tweets.filter((tweet) => tweet.conversationId === rootId);
thread.sort((a, b) => {
const aTime = a.createdAt ? Date.parse(a.createdAt) : 0;
const bTime = b.createdAt ? Date.parse(b.createdAt) : 0;
return aTime - bTime;
});
return { success: true, tweets: thread };
}
}
return TwitterClientTweetDetails;
}
+251
View File
@@ -0,0 +1,251 @@
import type { TwitterCookies } from './cookies.js';
export type GraphqlTweetResult = {
__typename?: string;
rest_id?: string;
legacy?: {
full_text?: string;
created_at?: string;
reply_count?: number;
retweet_count?: number;
favorite_count?: number;
conversation_id_str?: string;
in_reply_to_status_id_str?: string | null;
};
core?: {
user_results?: {
result?: {
rest_id?: string;
id?: string;
legacy?: {
screen_name?: string;
name?: string;
};
core?: {
screen_name?: string;
name?: string;
};
};
};
};
note_tweet?: {
note_tweet_results?: {
result?: {
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
content?: {
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
};
};
};
};
article?: {
article_results?: {
result?: {
title?: string;
plain_text?: string;
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
body?: {
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
};
content?: {
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
items?: Array<{
text?: string;
content?: {
text?: string;
richtext?: { text?: string };
rich_text?: { text?: string };
};
}>;
};
sections?: Array<{
items?: Array<{
text?: string;
content?: {
text?: string;
richtext?: { text?: string };
rich_text?: { text?: string };
};
}>;
}>;
};
};
title?: string;
plain_text?: string;
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
body?: {
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
};
content?: {
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
items?: Array<{
text?: string;
content?: {
text?: string;
richtext?: { text?: string };
rich_text?: { text?: string };
};
}>;
};
sections?: Array<{
items?: Array<{
text?: string;
content?: {
text?: string;
richtext?: { text?: string };
rich_text?: { text?: string };
};
}>;
}>;
};
tweet?: GraphqlTweetResult;
quoted_status_result?: {
result?: GraphqlTweetResult;
};
};
export type TweetResult =
| {
success: true;
tweetId: string;
}
| {
success: false;
error: string;
};
export interface UploadMediaResult {
success: boolean;
mediaId?: string;
error?: string;
}
export interface TweetData {
id: string;
text: string;
author: {
username: string;
name: string;
};
authorId?: string;
createdAt?: string;
replyCount?: number;
retweetCount?: number;
likeCount?: number;
conversationId?: string;
inReplyToStatusId?: string;
// Optional quoted tweet; depth controlled by quoteDepth (default: 1).
quotedTweet?: TweetData;
}
export interface GetTweetResult {
success: boolean;
tweet?: TweetData;
error?: string;
}
export interface SearchResult {
success: boolean;
tweets?: TweetData[];
error?: string;
}
export interface CurrentUserResult {
success: boolean;
user?: {
id: string;
username: string;
name: string;
};
error?: string;
}
export interface TwitterUser {
id: string;
username: string;
name: string;
description?: string;
followersCount?: number;
followingCount?: number;
isBlueVerified?: boolean;
profileImageUrl?: string;
createdAt?: string;
}
export interface FollowingResult {
success: boolean;
users?: TwitterUser[];
error?: string;
}
export interface TwitterClientOptions {
cookies: TwitterCookies;
userAgent?: string;
timeoutMs?: number;
// Max depth for quoted tweets (0 disables). Defaults to 1.
quoteDepth?: number;
}
export interface CreateTweetResponse {
data?: {
create_tweet?: {
tweet_results?: {
result?: {
rest_id?: string;
legacy?: {
full_text?: string;
};
};
};
};
};
errors?: Array<{ message: string; code?: number }>;
}
+345
View File
@@ -0,0 +1,345 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import {
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 type { CurrentUserResult, FollowingResult } from './twitter-client-types.js';
import { parseUsersFromInstructions } from './twitter-client-utils.js';
export interface TwitterClientUserMethods {
getCurrentUser(): Promise<CurrentUserResult>;
getFollowing(userId: string, count?: number): Promise<FollowingResult>;
getFollowers(userId: string, count?: number): Promise<FollowingResult>;
}
export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientUserMethods> {
abstract class TwitterClientUsers extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
private async getFollowingQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Following');
return Array.from(new Set([primary, 'BEkNpEt5pNETESoqMsTEGA']));
}
private async getFollowersQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Followers');
return Array.from(new Set([primary, 'kuFUYP9eV1FPoEy4N-pi7w']));
}
/**
* Fetch the account associated with the current cookies
*/
async getCurrentUser(): Promise<CurrentUserResult> {
const candidateUrls = [
'https://x.com/i/api/account/settings.json',
'https://api.twitter.com/1.1/account/settings.json',
'https://x.com/i/api/account/verify_credentials.json?skip_status=true&include_entities=false',
'https://api.twitter.com/1.1/account/verify_credentials.json?skip_status=true&include_entities=false',
];
let lastError: string | undefined;
for (const url of candidateUrls) {
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
const text = await response.text();
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
continue;
}
// biome-ignore lint/suspicious/noExplicitAny: Twitter API response is dynamic here
let data: any;
try {
data = await response.json();
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
continue;
}
const username =
typeof data?.screen_name === 'string'
? data.screen_name
: typeof data?.user?.screen_name === 'string'
? data.user.screen_name
: null;
const name =
typeof data?.name === 'string'
? data.name
: typeof data?.user?.name === 'string'
? data.user.name
: (username ?? '');
const userId =
typeof data?.user_id === 'string'
? data.user_id
: typeof data?.user_id_str === 'string'
? data.user_id_str
: typeof data?.user?.id_str === 'string'
? data.user.id_str
: typeof data?.user?.id === 'string'
? data.user.id
: null;
if (username && userId) {
this.clientUserId = userId;
return {
success: true,
user: {
id: userId,
username,
name: name || username,
},
};
}
lastError = 'Could not determine current user from response';
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
// 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 {
success: false,
error: lastError ?? 'Unknown error fetching current user',
};
}
/**
* Get users that a user is following
*/
async getFollowing(userId: string, count = 20): Promise<FollowingResult> {
const variables = {
userId,
count,
includePromotedContent: false,
};
const features = buildFollowingFeatures();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const tryOnce = async () => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getFollowingQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/Following?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{ type?: string; entries?: Array<unknown> }>;
};
};
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const users = parseUsersFromInstructions(instructions);
return { success: true as const, users, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching following', had404 };
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, users: firstAttempt.users };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, users: secondAttempt.users };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
/**
* Get users that follow a user
*/
async getFollowers(userId: string, count = 20): Promise<FollowingResult> {
const variables = {
userId,
count,
includePromotedContent: false,
};
const features = buildFollowingFeatures();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const tryOnce = async () => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getFollowersQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/Followers?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{ type?: string; entries?: Array<unknown> }>;
};
};
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const users = parseUsersFromInstructions(instructions);
return { success: true as const, users, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching followers', had404 };
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, users: firstAttempt.users };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, users: secondAttempt.users };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
}
return TwitterClientUsers;
}
+455
View File
@@ -0,0 +1,455 @@
import type { GraphqlTweetResult, TweetData, TwitterUser } from './twitter-client-types.js';
export function normalizeQuoteDepth(value?: number): number {
if (value === undefined || value === null) {
return 1;
}
if (!Number.isFinite(value)) {
return 1;
}
return Math.max(0, Math.floor(value));
}
export function firstText(...values: Array<string | undefined | null>): string | undefined {
for (const value of values) {
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed) {
return trimmed;
}
}
}
return undefined;
}
export function collectTextFields(value: unknown, keys: Set<string>, output: string[]): void {
if (!value) {
return;
}
if (typeof value === 'string') {
return;
}
if (Array.isArray(value)) {
for (const item of value) {
collectTextFields(item, keys, output);
}
return;
}
if (typeof value === 'object') {
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
if (keys.has(key)) {
if (typeof nested === 'string') {
const trimmed = nested.trim();
if (trimmed) {
output.push(trimmed);
}
continue;
}
}
collectTextFields(nested, keys, output);
}
}
}
export function uniqueOrdered(values: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
if (seen.has(value)) {
continue;
}
seen.add(value);
result.push(value);
}
return result;
}
export function extractArticleText(result: GraphqlTweetResult | undefined): string | undefined {
const article = result?.article;
if (!article) {
return undefined;
}
const articleResult = article.article_results?.result ?? article;
if (process.env.BIRD_DEBUG_ARTICLE === '1') {
console.error(
'[bird][debug][article] payload:',
JSON.stringify(
{
rest_id: result?.rest_id,
article: articleResult,
note_tweet: result?.note_tweet?.note_tweet_results?.result ?? null,
},
null,
2,
),
);
}
const title = firstText(articleResult.title, article.title);
let body = firstText(
articleResult.plain_text,
article.plain_text,
articleResult.body?.text,
articleResult.body?.richtext?.text,
articleResult.body?.rich_text?.text,
articleResult.content?.text,
articleResult.content?.richtext?.text,
articleResult.content?.rich_text?.text,
articleResult.text,
articleResult.richtext?.text,
articleResult.rich_text?.text,
article.body?.text,
article.body?.richtext?.text,
article.body?.rich_text?.text,
article.content?.text,
article.content?.richtext?.text,
article.content?.rich_text?.text,
article.text,
article.richtext?.text,
article.rich_text?.text,
);
if (body && title && body.trim() === title.trim()) {
body = undefined;
}
if (!body) {
const collected: string[] = [];
collectTextFields(articleResult, new Set(['text', 'title']), collected);
collectTextFields(article, new Set(['text', 'title']), collected);
const unique = uniqueOrdered(collected);
const filtered = title ? unique.filter((value) => value !== title) : unique;
if (filtered.length > 0) {
body = filtered.join('\n\n');
}
}
if (title && body && !body.startsWith(title)) {
return `${title}\n\n${body}`;
}
return body ?? title;
}
export function extractNoteTweetText(result: GraphqlTweetResult | undefined): string | undefined {
const note = result?.note_tweet?.note_tweet_results?.result;
if (!note) {
return undefined;
}
return firstText(
note.text,
note.richtext?.text,
note.rich_text?.text,
note.content?.text,
note.content?.richtext?.text,
note.content?.rich_text?.text,
);
}
export function extractTweetText(result: GraphqlTweetResult | undefined): string | undefined {
return extractArticleText(result) ?? extractNoteTweetText(result) ?? firstText(result?.legacy?.full_text);
}
export function unwrapTweetResult(result: GraphqlTweetResult | undefined): GraphqlTweetResult | undefined {
if (!result) {
return undefined;
}
if (result.tweet) {
return result.tweet;
}
return result;
}
export function mapTweetResult(result: GraphqlTweetResult | undefined, quoteDepth: number): TweetData | undefined {
const userResult = result?.core?.user_results?.result;
const userLegacy = userResult?.legacy;
const userCore = userResult?.core;
const username = userLegacy?.screen_name ?? userCore?.screen_name;
const name = userLegacy?.name ?? userCore?.name ?? username;
const userId = userResult?.rest_id;
if (!result?.rest_id || !username) {
return undefined;
}
const text = extractTweetText(result);
if (!text) {
return undefined;
}
let quotedTweet: TweetData | undefined;
if (quoteDepth > 0) {
const quotedResult = unwrapTweetResult(result.quoted_status_result?.result);
if (quotedResult) {
quotedTweet = mapTweetResult(quotedResult, quoteDepth - 1);
}
}
return {
id: result.rest_id,
text,
createdAt: result.legacy?.created_at,
replyCount: result.legacy?.reply_count,
retweetCount: result.legacy?.retweet_count,
likeCount: result.legacy?.favorite_count,
conversationId: result.legacy?.conversation_id_str,
inReplyToStatusId: result.legacy?.in_reply_to_status_id_str ?? undefined,
author: {
username,
name: name || username,
},
authorId: userId,
quotedTweet,
};
}
export function findTweetInInstructions(
instructions:
| Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>
| undefined,
tweetId: string,
) {
if (!instructions) {
return undefined;
}
for (const instruction of instructions) {
for (const entry of instruction.entries || []) {
const result = entry.content?.itemContent?.tweet_results?.result;
if (result?.rest_id === tweetId) {
return result;
}
}
}
return undefined;
}
export function collectTweetResultsFromEntry(entry: {
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
item?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
items?: Array<{
item?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
};
}): GraphqlTweetResult[] {
const results: GraphqlTweetResult[] = [];
const pushResult = (result?: GraphqlTweetResult) => {
if (result?.rest_id) {
results.push(result);
}
};
const content = entry.content;
pushResult(content?.itemContent?.tweet_results?.result);
pushResult(content?.item?.itemContent?.tweet_results?.result);
for (const item of content?.items ?? []) {
pushResult(item?.item?.itemContent?.tweet_results?.result);
pushResult(item?.itemContent?.tweet_results?.result);
pushResult(item?.content?.itemContent?.tweet_results?.result);
}
return results;
}
export function parseTweetsFromInstructions(
instructions:
| Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
item?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
items?: Array<{
item?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
};
}>;
}>
| undefined,
quoteDepth: number,
): TweetData[] {
const tweets: TweetData[] = [];
const seen = new Set<string>();
for (const instruction of instructions ?? []) {
for (const entry of instruction.entries ?? []) {
const results = collectTweetResultsFromEntry(entry);
for (const result of results) {
const mapped = mapTweetResult(result, quoteDepth);
if (!mapped || seen.has(mapped.id)) {
continue;
}
seen.add(mapped.id);
tweets.push(mapped);
}
}
}
return tweets;
}
export function extractCursorFromInstructions(
instructions:
| Array<{
entries?: Array<{
content?: unknown;
}>;
}>
| undefined,
cursorType = 'Bottom',
): string | undefined {
for (const instruction of instructions ?? []) {
for (const entry of instruction.entries ?? []) {
const content = entry.content as { cursorType?: unknown; value?: unknown } | undefined;
if (content?.cursorType === cursorType && typeof content.value === 'string' && content.value.length > 0) {
return content.value;
}
}
}
return undefined;
}
export function parseUsersFromInstructions(
instructions: Array<{ type?: string; entries?: Array<unknown> }> | undefined,
): TwitterUser[] {
if (!instructions) {
return [];
}
const users: TwitterUser[] = [];
for (const instruction of instructions) {
if (!instruction.entries) {
continue;
}
for (const entry of instruction.entries) {
const content = (entry as { content?: { itemContent?: { user_results?: { result?: unknown } } } })?.content;
const rawUserResult = content?.itemContent?.user_results?.result as
| {
__typename?: string;
rest_id?: string;
is_blue_verified?: boolean;
user?: unknown;
legacy?: {
screen_name?: string;
name?: string;
description?: string;
followers_count?: number;
friends_count?: number;
profile_image_url_https?: string;
created_at?: string;
};
core?: {
screen_name?: string;
name?: string;
created_at?: string;
};
avatar?: {
image_url?: string;
};
}
| undefined;
const userResult =
rawUserResult?.__typename === 'UserWithVisibilityResults' && rawUserResult.user
? (rawUserResult.user as typeof rawUserResult)
: rawUserResult;
if (!userResult || userResult.__typename !== 'User') {
continue;
}
const legacy = userResult.legacy;
const core = userResult.core;
const username = legacy?.screen_name ?? core?.screen_name;
if (!userResult.rest_id || !username) {
continue;
}
users.push({
id: userResult.rest_id,
username,
name: legacy?.name ?? core?.name ?? username,
description: legacy?.description,
followersCount: legacy?.followers_count,
followingCount: legacy?.friends_count,
isBlueVerified: userResult.is_blue_verified,
profileImageUrl: legacy?.profile_image_url_https ?? userResult.avatar?.image_url,
createdAt: legacy?.created_at ?? core?.created_at,
});
}
}
return users;
}
+34 -1646
View File
File diff suppressed because it is too large Load Diff
+46 -17
View File
@@ -5,6 +5,9 @@ import { fileURLToPath } from 'node:url';
declare const __dirname: string | undefined; declare const __dirname: string | undefined;
export const FALLBACK_VERSION = 'unknown'; export const FALLBACK_VERSION = 'unknown';
const REF_PREFIX_REGEX = /^ref:\s*/i;
const LINE_SPLIT_REGEX = /\r?\n/;
const GITDIR_REGEX = /gitdir:\s*(.+)\s*$/i;
function readPackageVersionFromJsonFile(candidate: string): string | null { function readPackageVersionFromJsonFile(candidate: string): string | null {
try { try {
@@ -37,7 +40,9 @@ function resolveStartDir(importMetaUrl?: string): string {
} }
} }
if (typeof __dirname === 'string' && __dirname.length > 0) return __dirname; if (typeof __dirname === 'string' && __dirname.length > 0) {
return __dirname;
}
return process.cwd(); return process.cwd();
} }
@@ -47,27 +52,37 @@ export function resolvePackageVersion(importMetaUrl?: string): string {
typeof process !== 'undefined' && typeof process.env.BIRD_VERSION === 'string' typeof process !== 'undefined' && typeof process.env.BIRD_VERSION === 'string'
? process.env.BIRD_VERSION.trim() ? process.env.BIRD_VERSION.trim()
: ''; : '';
if (injected.length > 0) return injected; if (injected.length > 0) {
return injected;
}
let dir = resolveStartDir(importMetaUrl); let dir = resolveStartDir(importMetaUrl);
for (let i = 0; i < 10; i += 1) { for (let i = 0; i < 10; i += 1) {
const version = const version =
readPackageVersionFromJsonFile(path.join(dir, 'package.json')) ?? readPackageVersionFromJsonFile(path.join(dir, 'package.json')) ??
readVersionFromTextFile(path.join(dir, 'VERSION')); readVersionFromTextFile(path.join(dir, 'VERSION'));
if (version) return version; if (version) {
return version;
}
const parent = path.dirname(dir); const parent = path.dirname(dir);
if (parent === dir) break; if (parent === dir) {
break;
}
dir = parent; dir = parent;
} }
return FALLBACK_VERSION; return FALLBACK_VERSION;
} }
function truncateSha(sha: string, length = 8): string { function truncateSha(sha: string, length: number = 8): string {
const trimmed = sha.trim(); const trimmed = sha.trim();
if (!trimmed) return ''; if (!trimmed) {
if (trimmed.length <= length) return trimmed; return '';
}
if (trimmed.length <= length) {
return trimmed;
}
return trimmed.slice(0, length); return trimmed.slice(0, length);
} }
@@ -80,14 +95,18 @@ function resolveGitShaFromGitDir(gitDir: string): string | null {
return null; return null;
} }
if (!head) return null; if (!head) {
return null;
}
if (!head.startsWith('ref:')) { if (!head.startsWith('ref:')) {
const sha = truncateSha(head); const sha = truncateSha(head);
return sha.length > 0 ? sha : null; return sha.length > 0 ? sha : null;
} }
const ref = head.replace(/^ref:\s*/i, '').trim(); const ref = head.replace(REF_PREFIX_REGEX, '').trim();
if (!ref) return null; if (!ref) {
return null;
}
const refPath = path.join(gitDir, ref); const refPath = path.join(gitDir, ref);
try { try {
@@ -100,9 +119,11 @@ function resolveGitShaFromGitDir(gitDir: string): string | null {
const packedRefsPath = path.join(gitDir, 'packed-refs'); const packedRefsPath = path.join(gitDir, 'packed-refs');
try { try {
const packed = fs.readFileSync(packedRefsPath, 'utf8'); const packed = fs.readFileSync(packedRefsPath, 'utf8');
const lines = packed.split(/\r?\n/); const lines = packed.split(LINE_SPLIT_REGEX);
for (const line of lines) { for (const line of lines) {
if (!line || line.startsWith('#') || line.startsWith('^')) continue; if (!line || line.startsWith('#') || line.startsWith('^')) {
continue;
}
const [shaRaw, refName] = line.split(' '); const [shaRaw, refName] = line.split(' ');
if (refName?.trim() === ref) { if (refName?.trim() === ref) {
const sha = truncateSha(shaRaw ?? ''); const sha = truncateSha(shaRaw ?? '');
@@ -121,7 +142,9 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
typeof process !== 'undefined' && typeof process.env.BIRD_GIT_SHA === 'string' typeof process !== 'undefined' && typeof process.env.BIRD_GIT_SHA === 'string'
? process.env.BIRD_GIT_SHA.trim() ? process.env.BIRD_GIT_SHA.trim()
: ''; : '';
if (injected.length > 0) return truncateSha(injected); if (injected.length > 0) {
return truncateSha(injected);
}
let dir = resolveStartDir(importMetaUrl); let dir = resolveStartDir(importMetaUrl);
for (let i = 0; i < 10; i += 1) { for (let i = 0; i < 10; i += 1) {
@@ -130,15 +153,19 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
const stat = fs.statSync(dotGit); const stat = fs.statSync(dotGit);
if (stat.isDirectory()) { if (stat.isDirectory()) {
const sha = resolveGitShaFromGitDir(dotGit); const sha = resolveGitShaFromGitDir(dotGit);
if (sha) return sha; if (sha) {
return sha;
}
} else if (stat.isFile()) { } else if (stat.isFile()) {
const txt = fs.readFileSync(dotGit, 'utf8'); const txt = fs.readFileSync(dotGit, 'utf8');
const match = txt.match(/gitdir:\s*(.+)\s*$/i); const match = GITDIR_REGEX.exec(txt);
const gitDir = match?.[1]?.trim(); const gitDir = match?.[1]?.trim();
if (gitDir) { if (gitDir) {
const resolved = path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir); const resolved = path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir);
const sha = resolveGitShaFromGitDir(resolved); const sha = resolveGitShaFromGitDir(resolved);
if (sha) return sha; if (sha) {
return sha;
}
} }
} }
} catch { } catch {
@@ -146,7 +173,9 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
} }
const parent = path.dirname(dir); const parent = path.dirname(dir);
if (parent === dir) break; if (parent === dir) {
break;
}
dir = parent; dir = parent;
} }
+4
View File
@@ -11,6 +11,10 @@ describe('cli-args', () => {
'thread', 'thread',
'search', 'search',
'mentions', 'mentions',
'bookmarks',
'following',
'followers',
'likes',
'help', 'help',
'whoami', 'whoami',
'check', 'check',
+27
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { extractBookmarkFolderId } from '../src/lib/extract-bookmark-folder-id.js';
import { extractTweetId } from '../src/lib/extract-tweet-id.js'; import { extractTweetId } from '../src/lib/extract-tweet-id.js';
describe('CLI utilities', () => { describe('CLI utilities', () => {
@@ -29,4 +30,30 @@ describe('CLI utilities', () => {
expect(extractTweetId(url)).toBe('1234567890123456789'); expect(extractTweetId(url)).toBe('1234567890123456789');
}); });
}); });
describe('extractBookmarkFolderId', () => {
it('should extract ID from x.com bookmarks URL', () => {
const url = 'https://x.com/i/bookmarks/1976792203235119344';
expect(extractBookmarkFolderId(url)).toBe('1976792203235119344');
});
it('should extract ID from twitter.com bookmarks URL', () => {
const url = 'https://twitter.com/i/bookmarks/1976792203235119344';
expect(extractBookmarkFolderId(url)).toBe('1976792203235119344');
});
it('should accept a numeric ID as-is', () => {
const id = '1976792203235119344';
expect(extractBookmarkFolderId(id)).toBe('1976792203235119344');
});
it('should return null for invalid values', () => {
expect(extractBookmarkFolderId('not-an-id')).toBeNull();
});
it('should return null for folder_id query URLs', () => {
const url = 'https://x.com/i/bookmarks?folder_id=1976792203235119344';
expect(extractBookmarkFolderId(url)).toBeNull();
});
});
}); });
+206 -187
View File
@@ -1,98 +1,33 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock child_process.execSync to prevent actual shell commands type SweetCookieResult = { cookies: Array<{ name: string; value: string; domain?: string }>; warnings: string[] };
vi.mock('node:child_process', () => ({
execSync: vi.fn(() => ''), const sweet = vi.hoisted(() => ({
results: new Map<string, SweetCookieResult>(),
options: new Map<string, { timeoutMs?: number }>(),
})); }));
// Mock fs to prevent actual file operations vi.mock('@steipete/sweet-cookie', () => ({
vi.mock('node:fs', () => { getCookies: vi.fn(async (options: { browsers?: string[]; timeoutMs?: number }) => {
const fs = vi.importActual<typeof import('node:fs')>('node:fs'); const browser = options.browsers?.[0] ?? 'unknown';
return { sweet.options.set(browser, options);
...fs, return (
existsSync: vi.fn(() => false), sweet.results.get(browser) ?? {
copyFileSync: vi.fn(), cookies: [],
mkdtempSync: vi.fn(() => '/tmp/test-dir'), warnings: [],
readdirSync: vi.fn(() => []),
readFileSync: vi.fn(() => Buffer.alloc(0)),
rmSync: vi.fn(),
};
});
const itIfDarwin = process.platform === 'darwin' ? it : it.skip;
function buildSafariCookieRecord(input: { domain: string; name: string; value: string; path?: string }): Buffer {
const domain = Buffer.from(input.domain, 'utf8');
const name = Buffer.from(input.name, 'utf8');
const path = Buffer.from(input.path ?? '/', 'utf8');
const value = Buffer.from(input.value, 'utf8');
const headerSize = 56;
const domainOffset = headerSize;
const nameOffset = domainOffset + domain.length + 1;
const pathOffset = nameOffset + name.length + 1;
const valueOffset = pathOffset + path.length + 1;
const recordSize = valueOffset + value.length + 1;
const record = Buffer.alloc(recordSize);
record.writeUInt32LE(recordSize, 0);
record.writeUInt32LE(0, 4);
record.writeUInt32LE(0, 8);
record.writeUInt32LE(0, 12);
record.writeUInt32LE(domainOffset, 16);
record.writeUInt32LE(nameOffset, 20);
record.writeUInt32LE(pathOffset, 24);
record.writeUInt32LE(valueOffset, 28);
domain.copy(record, domainOffset);
record[domainOffset + domain.length] = 0;
name.copy(record, nameOffset);
record[nameOffset + name.length] = 0;
path.copy(record, pathOffset);
record[pathOffset + path.length] = 0;
value.copy(record, valueOffset);
record[valueOffset + value.length] = 0;
return record;
}
function buildSafariCookiesFile(records: Buffer[]): Buffer {
const cookieCount = records.length;
const headerSize = 4 + 4 + 4 * cookieCount + 4;
const offsets: number[] = [];
let cursor = headerSize;
for (const record of records) {
offsets.push(cursor);
cursor += record.length;
}
const pageSize = cursor;
const page = Buffer.alloc(pageSize);
page.writeUInt32BE(0x00000100, 0);
page.writeUInt32LE(cookieCount, 4);
offsets.forEach((offset, index) => {
page.writeUInt32LE(offset, 8 + index * 4);
});
page.writeUInt32LE(0, 8 + cookieCount * 4);
offsets.forEach((offset, index) => {
records[index].copy(page, offset);
});
const header = Buffer.alloc(12);
header.write('cook', 0, 'ascii');
header.writeUInt32BE(1, 4);
header.writeUInt32BE(pageSize, 8);
return Buffer.concat([header, page]);
} }
);
}),
}));
describe('cookies', () => { describe('cookies', () => {
const originalEnv = process.env; const originalEnv = process.env;
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
sweet.results.clear();
sweet.options.clear();
process.env = { ...originalEnv }; process.env = { ...originalEnv };
// Clear Twitter-related env vars
process.env.AUTH_TOKEN = undefined; process.env.AUTH_TOKEN = undefined;
process.env.TWITTER_AUTH_TOKEN = undefined; process.env.TWITTER_AUTH_TOKEN = undefined;
process.env.CT0 = undefined; process.env.CT0 = undefined;
@@ -105,38 +40,71 @@ describe('cookies', () => {
}); });
describe('resolveCredentials', () => { describe('resolveCredentials', () => {
it('uses firefox when enabled and returns cookies', async () => { 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 { resolveCredentials } = await import('../src/lib/cookies.js');
const fs = await import('node:fs'); const result = await resolveCredentials({ cookieSource: 'firefox' });
// Firefox present with cookies.sqlite
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => {
const lower = path.toLowerCase();
if (lower.endsWith('cookies.sqlite')) return true;
if (lower.includes('firefox')) return true;
return false;
});
(fs.readdirSync as unknown as vi.Mock).mockReturnValue([
{ isDirectory: () => true, name: 'abc.default-release' },
]);
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
(fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir');
// sqlite3 output for firefox
const { execSync } = await import('node:child_process');
(execSync as unknown as vi.Mock).mockReturnValue('auth_token|firefox_auth\nct0|firefox_ct0');
const result = await resolveCredentials({
allowFirefox: true,
allowChrome: false,
firefoxProfile: 'abc.default-release',
});
expect(result.cookies.authToken).toBe('firefox_auth'); expect(result.cookies.authToken).toBe('firefox_auth');
expect(result.cookies.ct0).toBe('firefox_ct0'); expect(result.cookies.ct0).toBe('firefox_ct0');
expect(result.cookies.source).toContain('Firefox'); 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 () => { it('should prioritize CLI arguments over env vars', async () => {
process.env.AUTH_TOKEN = 'env_auth'; process.env.AUTH_TOKEN = 'env_auth';
process.env.CT0 = 'env_ct0'; process.env.CT0 = 'env_ct0';
@@ -149,6 +117,7 @@ describe('cookies', () => {
expect(result.cookies.authToken).toBe('cli_auth'); expect(result.cookies.authToken).toBe('cli_auth');
expect(result.cookies.ct0).toBe('cli_ct0'); 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'); expect(result.cookies.source).toBe('CLI argument');
}); });
@@ -157,7 +126,7 @@ describe('cookies', () => {
process.env.CT0 = 'test_ct0'; process.env.CT0 = 'test_ct0';
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ allowFirefox: false, allowChrome: false }); const result = await resolveCredentials({ cookieSource: 'safari' });
expect(result.cookies.authToken).toBe('test_auth_token'); expect(result.cookies.authToken).toBe('test_auth_token');
expect(result.cookies.ct0).toBe('test_ct0'); expect(result.cookies.ct0).toBe('test_ct0');
@@ -169,7 +138,7 @@ describe('cookies', () => {
process.env.TWITTER_CT0 = 'twitter_ct0'; process.env.TWITTER_CT0 = 'twitter_ct0';
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ allowFirefox: false, allowChrome: false }); const result = await resolveCredentials({ cookieSource: 'safari' });
expect(result.cookies.authToken).toBe('twitter_auth'); expect(result.cookies.authToken).toBe('twitter_auth');
expect(result.cookies.ct0).toBe('twitter_ct0'); expect(result.cookies.ct0).toBe('twitter_ct0');
@@ -191,7 +160,7 @@ describe('cookies', () => {
process.env.CT0 = ''; process.env.CT0 = '';
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ allowFirefox: false, allowChrome: false }); const result = await resolveCredentials({ cookieSource: 'safari' });
expect(result.cookies.authToken).toBeNull(); expect(result.cookies.authToken).toBeNull();
expect(result.cookies.ct0).toBeNull(); expect(result.cookies.ct0).toBeNull();
@@ -200,7 +169,7 @@ describe('cookies', () => {
it('should warn when credentials are missing', async () => { it('should warn when credentials are missing', async () => {
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ allowFirefox: false, allowChrome: false }); const result = await resolveCredentials({ cookieSource: 'safari' });
expect(result.warnings).toContain( expect(result.warnings).toContain(
'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Safari/Chrome/Firefox', 'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Safari/Chrome/Firefox',
@@ -211,45 +180,111 @@ describe('cookies', () => {
}); });
it('falls back to Chrome when enabled and Firefox disabled', async () => { it('falls back to Chrome when enabled and Firefox disabled', async () => {
const fs = await import('node:fs'); sweet.results.set('chrome', {
const { execSync } = await import('node:child_process'); cookies: [
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => path.includes('Cookies')); { name: 'auth_token', value: 'test_auth', domain: 'x.com' },
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); { name: 'ct0', value: 'test_ct0', domain: 'x.com' },
(execSync as unknown as vi.Mock).mockImplementation((cmd: string) => { ],
if (cmd.includes('sqlite3')) { warnings: [],
return 'auth_token|746573745f61757468\nct0|746573745f637430';
}
return '';
}); });
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ const result = await resolveCredentials({
allowSafari: false, cookieSource: 'chrome',
allowFirefox: false,
allowChrome: true,
chromeProfile: 'Default', chromeProfile: 'Default',
cookieTimeoutMs: 15000,
}); });
expect(result.cookies.authToken).toBe('test_auth'); expect(result.cookies.authToken).toBe('test_auth');
expect(result.cookies.ct0).toBe('test_ct0'); expect(result.cookies.ct0).toBe('test_ct0');
expect(result.cookies.source).toContain('Chrome'); 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', () => { describe('extractCookiesFromSafari', () => {
itIfDarwin('returns cookies from Safari binarycookies', async () => { it('returns cookies from Safari', async () => {
const fs = await import('node:fs'); if (process.platform !== 'darwin') {
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => return;
path.toLowerCase().endsWith('cookies.binarycookies'), }
); sweet.results.set('safari', {
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); cookies: [
(fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir'); { name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
(fs.readFileSync as unknown as vi.Mock).mockReturnValue( { name: 'ct0', value: 'safari_ct0', domain: 'x.com' },
buildSafariCookiesFile([ ],
buildSafariCookieRecord({ domain: '.x.com', name: 'auth_token', value: 'safari_auth' }), warnings: [],
buildSafariCookieRecord({ domain: '.x.com', name: 'ct0', value: 'safari_ct0' }), });
]),
);
const { extractCookiesFromSafari } = await import('../src/lib/cookies.js'); const { extractCookiesFromSafari } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromSafari(); const result = await extractCookiesFromSafari();
@@ -259,26 +294,27 @@ describe('cookies', () => {
expect(result.cookies.source).toBe('Safari'); expect(result.cookies.source).toBe('Safari');
}); });
itIfDarwin('prefers Safari over Chrome when both are available', async () => { it('prefers Safari over Chrome when both are available', async () => {
const fs = await import('node:fs'); if (process.platform !== 'darwin') {
return;
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => { }
const lower = path.toLowerCase(); sweet.results.set('safari', {
if (lower.endsWith('cookies.binarycookies')) return true; cookies: [
if (lower.includes('chrome') && lower.endsWith('cookies')) return true; { name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
return false; { 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: [],
}); });
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
(fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir');
(fs.readFileSync as unknown as vi.Mock).mockReturnValue(
buildSafariCookiesFile([
buildSafariCookieRecord({ domain: '.x.com', name: 'auth_token', value: 'safari_auth' }),
buildSafariCookieRecord({ domain: '.x.com', name: 'ct0', value: 'safari_ct0' }),
]),
);
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ allowSafari: true, allowChrome: true, allowFirefox: false }); const result = await resolveCredentials({ cookieSource: ['safari', 'chrome'] });
expect(result.cookies.authToken).toBe('safari_auth'); expect(result.cookies.authToken).toBe('safari_auth');
expect(result.cookies.ct0).toBe('safari_ct0'); expect(result.cookies.ct0).toBe('safari_ct0');
@@ -286,18 +322,13 @@ describe('cookies', () => {
}); });
describe('extractCookiesFromChrome', () => { describe('extractCookiesFromChrome', () => {
it('returns cookies when sqlite yields hex values', async () => { it('returns cookies when Chrome yields values', async () => {
const fs = await import('node:fs'); sweet.results.set('chrome', {
const { execSync } = await import('node:child_process'); cookies: [
// Pretend Chrome cookie DB exists { name: 'auth_token', value: 'test_auth', domain: 'x.com' },
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => path.includes('Cookies')); { name: 'ct0', value: 'test_ct0', domain: 'x.com' },
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); ],
// sqlite output with hex strings ("test_auth", "test_ct0") warnings: [],
(execSync as unknown as vi.Mock).mockImplementation((cmd: string) => {
if (cmd.includes('sqlite3')) {
return 'auth_token|746573745f61757468\nct0|746573745f637430';
}
return '';
}); });
const { extractCookiesFromChrome } = await import('../src/lib/cookies.js'); const { extractCookiesFromChrome } = await import('../src/lib/cookies.js');
@@ -309,12 +340,8 @@ describe('cookies', () => {
expect(result.warnings).toHaveLength(0); expect(result.warnings).toHaveLength(0);
}); });
it('warns when Chrome DB exists but contains no cookies', async () => { it('warns when Chrome returns no cookies', async () => {
const fs = await import('node:fs'); sweet.results.set('chrome', { cookies: [], warnings: [] });
const { execSync } = await import('node:child_process');
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => path.includes('Cookies'));
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
(execSync as unknown as vi.Mock).mockReturnValue('');
const { extractCookiesFromChrome } = await import('../src/lib/cookies.js'); const { extractCookiesFromChrome } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromChrome('Default'); const result = await extractCookiesFromChrome('Default');
@@ -327,6 +354,11 @@ describe('cookies', () => {
describe('extractCookiesFromFirefox', () => { describe('extractCookiesFromFirefox', () => {
it('warns when Firefox cookies database is missing', async () => { 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 { extractCookiesFromFirefox } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromFirefox('missing-profile'); const result = await extractCookiesFromFirefox('missing-profile');
@@ -335,21 +367,8 @@ describe('cookies', () => {
expect(result.warnings).toContain('Firefox cookies database not found.'); expect(result.warnings).toContain('Firefox cookies database not found.');
}); });
it('warns when Firefox DB exists but contains no cookies', async () => { it('warns when Firefox returns no cookies', async () => {
const fs = await import('node:fs'); sweet.results.set('firefox', { cookies: [], warnings: [] });
const { execSync } = await import('node:child_process');
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => {
const lower = path.toLowerCase();
if (lower.endsWith('cookies.sqlite')) return true;
if (lower.includes('firefox')) return true;
return false;
});
(fs.readdirSync as unknown as vi.Mock).mockReturnValue([
{ isDirectory: () => true, name: 'abc.default-release' },
]);
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
(fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir');
(execSync as unknown as vi.Mock).mockReturnValue('');
const { extractCookiesFromFirefox } = await import('../src/lib/cookies.js'); const { extractCookiesFromFirefox } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromFirefox('abc.default-release'); const result = await extractCookiesFromFirefox('abc.default-release');
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest';
import { resolveCredentials, TwitterClient } from '../src/index.js';
describe('library exports', () => {
it('exposes primary library surface', () => {
expect(typeof TwitterClient).toBe('function');
expect(typeof resolveCredentials).toBe('function');
});
});
+3 -1
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { mentionsQueryFromUserOption, normalizeHandle } from '../src/lib/normalize-handle.js'; import { mentionsQueryFromUserOption, normalizeHandle } from '../src/lib/normalize-handle.js';
const INVALID_HANDLE_REGEX = /Invalid --user handle/;
describe('normalizeHandle', () => { describe('normalizeHandle', () => {
it('accepts bare handle', () => { it('accepts bare handle', () => {
expect(normalizeHandle('steipete')).toBe('steipete'); expect(normalizeHandle('steipete')).toBe('steipete');
@@ -36,6 +38,6 @@ describe('mentionsQueryFromUserOption', () => {
it('returns error for invalid handle', () => { it('returns error for invalid handle', () => {
const result = mentionsQueryFromUserOption('@stei-pete'); const result = mentionsQueryFromUserOption('@stei-pete');
expect(result.query).toBeNull(); expect(result.query).toBeNull();
expect(result.error).toMatch(/Invalid --user handle/); expect(result.error).toMatch(INVALID_HANDLE_REGEX);
}); });
}); });
+10
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
formatStatsLine, formatStatsLine,
formatTweetUrlLine,
labelPrefix, labelPrefix,
resolveOutputConfigFromArgv, resolveOutputConfigFromArgv,
resolveOutputConfigFromCommander, resolveOutputConfigFromCommander,
@@ -62,4 +63,13 @@ describe('output', () => {
expect(formatStatsLine(stats, { plain: false, emoji: false, color: false })).toBe('Likes 0 Retweets 0 Replies 2'); expect(formatStatsLine(stats, { plain: false, emoji: false, color: false })).toBe('Likes 0 Retweets 0 Replies 2');
expect(formatStatsLine(stats, { plain: false, emoji: true, color: false })).toBe('❤️ 0 🔁 0 💬 2'); expect(formatStatsLine(stats, { plain: false, emoji: true, color: false })).toBe('❤️ 0 🔁 0 💬 2');
}); });
it('always includes tweet URL in all modes', () => {
const id = '1234567890';
const url = `https://x.com/i/status/${id}`;
expect(formatTweetUrlLine(id, { plain: true, emoji: false, color: false })).toContain(url);
expect(formatTweetUrlLine(id, { plain: false, emoji: false, color: false })).toContain(url);
expect(formatTweetUrlLine(id, { plain: false, emoji: true, color: false })).toContain(url);
});
}); });
+81 -1
View File
@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { mkdir, readFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -8,6 +8,7 @@ import { createRuntimeQueryIdStore } from '../src/lib/runtime-query-ids.js';
describe('runtime-query-ids', () => { describe('runtime-query-ids', () => {
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
delete process.env.BIRD_QUERY_IDS_CACHE;
}); });
it('refreshes IDs by scanning discovered bundles and persists cache', async () => { it('refreshes IDs by scanning discovered bundles and persists cache', async () => {
@@ -60,4 +61,83 @@ describe('runtime-query-ids', () => {
const parsed = JSON.parse(raw) as { ids?: Record<string, string> }; const parsed = JSON.parse(raw) as { ids?: Record<string, string> };
expect(parsed.ids?.SearchTimeline).toBe('EEE'); expect(parsed.ids?.SearchTimeline).toBe('EEE');
}); });
it('uses env cache path override and returns fresh snapshot without refreshing', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'query-ids-cache.json');
process.env.BIRD_QUERY_IDS_CACHE = cachePath;
const snapshot = {
fetchedAt: new Date().toISOString(),
ttlMs: 60_000,
ids: { CreateTweet: 'AAA' },
discovery: { pages: ['https://x.com/'], bundles: ['main.js'] },
};
await writeFile(cachePath, JSON.stringify(snapshot), 'utf8');
const fetchMock = vi.fn();
const store = createRuntimeQueryIdStore({ fetchImpl: fetchMock as unknown as typeof fetch });
const info = await store.refresh(['CreateTweet']);
expect(store.cachePath).toBe(cachePath);
expect(info?.snapshot.ids.CreateTweet).toBe('AAA');
expect(fetchMock).not.toHaveBeenCalled();
store.clearMemory();
});
it('returns current snapshot when refresh finds no matching operations', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'query-ids.json');
const snapshot = {
fetchedAt: new Date(0).toISOString(),
ttlMs: 1,
ids: { CreateTweet: 'AAA' },
discovery: { pages: ['https://x.com/'], bundles: ['main.test.js'] },
};
await writeFile(cachePath, JSON.stringify(snapshot), 'utf8');
const html = `<script src="https://abs.twimg.com/responsive-web/client-web/main.test.js"></script>`;
const js = 'console.log("no ops here")';
const fetchMock = vi.fn(async (url: string | URL) => {
const asString = String(url);
if (asString.startsWith('https://x.com/')) {
return new Response(html, { status: 200 });
}
if (asString.startsWith('https://abs.twimg.com/')) {
return new Response(js, { status: 200 });
}
return new Response('not found', { status: 404 });
});
const store = createRuntimeQueryIdStore({
cachePath,
ttlMs: 1,
fetchImpl: fetchMock as unknown as typeof fetch,
});
const info = await store.refresh(['CreateTweet']);
expect(info?.snapshot.ids.CreateTweet).toBe('AAA');
});
it('throws when no bundles can be discovered', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'query-ids.json');
const fetchMock = vi.fn(async () => new Response('nope', { status: 500 }));
const store = createRuntimeQueryIdStore({
cachePath,
fetchImpl: fetchMock as unknown as typeof fetch,
});
await expect(store.refresh(['CreateTweet'], { force: true })).rejects.toThrow(
'No client bundles discovered; x.com layout may have changed.',
);
});
}); });
+423
View File
@@ -0,0 +1,423 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
const validCookies = {
authToken: 'test_auth_token',
ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
type ResponseLike = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
};
type TwitterClientApiPrivate = TwitterClient & {
getBookmarksQueryIds: () => Promise<string[]>;
getFollowingQueryIds: () => Promise<string[]>;
getFollowersQueryIds: () => Promise<string[]>;
getLikesQueryIds: () => Promise<string[]>;
getCurrentUser: () => Promise<{
success: boolean;
user?: { id: string; username: string; name: string };
error?: string;
}>;
getBookmarkFolderQueryIds: () => Promise<string[]>;
};
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
ok: true,
status: 200,
json: async (): Promise<unknown> => ({}),
text: async (): Promise<string> => '',
...overrides,
});
describe('TwitterClient API coverage', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('createTweet fallback paths', () => {
it('returns an error when retry response is not ok', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'boom' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('hi');
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns API errors when retry response contains errors', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'rate limited', code: 1 }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('hi');
expect(result.success).toBe(false);
expect(result.error).toContain('rate limited');
});
it('returns an error when retry response has no tweet id', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ data: { create_tweet: { tweet_results: { result: {} } } } }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('hi');
expect(result.success).toBe(false);
expect(result.error).toContain('no ID');
});
});
describe('search error paths', () => {
it('returns an error for non-ok responses', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('test');
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns errors from payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('test');
expect(result.success).toBe(false);
expect(result.error).toBe('bad');
});
it('returns an error when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('test');
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
it('returns the second attempt error after 404s', async () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('test');
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404');
});
});
describe('bookmarks error paths', () => {
it('returns an error for non-ok responses', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns API errors from payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toBe('bad');
});
it('returns an error when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
it('returns unknown error when no query ids are available', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarksQueryIds = async () => [];
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toBe('Unknown error fetching bookmarks');
});
it('returns the second attempt error after 404s', async () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404');
});
});
describe('following/followers error paths', () => {
it('returns an error for non-ok responses', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getFollowingQueryIds = async () => ['test'];
const result = await client.getFollowing('123', 1);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns API errors from payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getFollowersQueryIds = async () => ['test'];
const result = await client.getFollowers('123', 1);
expect(result.success).toBe(false);
expect(result.error).toBe('bad');
});
it('returns unknown error when no query ids are available', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getFollowingQueryIds = async () => [];
const result = await client.getFollowing('123', 1);
expect(result.success).toBe(false);
expect(result.error).toBe('Unknown error fetching following');
});
it('returns the second attempt error after 404s', async () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getFollowersQueryIds = async () => ['test'];
const result = await client.getFollowers('123', 1);
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404');
});
});
describe('likes error paths', () => {
const stubCurrentUser = async () => ({
success: true,
user: { id: '123', username: 'tester', name: 'Tester' },
});
it('returns an error for non-ok responses', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns API errors from payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toBe('bad');
});
it('returns an error when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
it('returns unknown error when no query ids are available', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => [];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toBe('Unknown error fetching likes');
});
it('returns the second attempt error after 404s', async () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404');
});
});
describe('bookmark folder error paths', () => {
it('returns an error for non-ok responses', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns API errors from payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
expect(result.error).toBe('bad');
});
it('returns an error when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
it('returns unknown error when no query ids are available', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarkFolderQueryIds = async () => [];
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
expect(result.error).toBe('Unknown error fetching bookmark folder');
});
it('returns the second attempt error after 404s', async () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404');
});
});
});
+248
View File
@@ -0,0 +1,248 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { runtimeQueryIds } from '../src/lib/runtime-query-ids.js';
import { TwitterClient } from '../src/lib/twitter-client.js';
const validCookies = {
authToken: 'test_auth_token',
ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
type ResponseLike = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
};
type TwitterClientPrivate = TwitterClient & {
refreshQueryIds: () => Promise<void>;
fetchWithTimeout: (url: string, init: RequestInit) => Promise<ResponseLike>;
ensureClientUserId: () => Promise<void>;
getBaseHeaders: () => Record<string, string>;
fetchUserArticlePlainText: (userId: string, tweetId: string) => Promise<{ title?: string; plainText?: string }>;
fetchTweetDetail: (tweetId: string) => Promise<{ success: true } | { success: false; error: string }>;
postStatusUpdate: (input: { text: string }) => Promise<{ success: boolean; error?: string }>;
};
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
ok: true,
status: 200,
json: async (): Promise<unknown> => ({}),
text: async (): Promise<string> => '',
...overrides,
});
describe('TwitterClient core coverage', () => {
const originalFetch = global.fetch;
const originalNodeEnv = process.env.NODE_ENV;
afterEach(() => {
global.fetch = originalFetch;
process.env.NODE_ENV = originalNodeEnv;
delete process.env.BIRD_DEBUG_ARTICLE;
vi.restoreAllMocks();
});
it('refreshQueryIds calls runtime refresh outside test env', async () => {
const client = new TwitterClient({ cookies: validCookies });
const refreshSpy = vi.spyOn(runtimeQueryIds, 'refresh').mockResolvedValue(null);
process.env.NODE_ENV = 'production';
const clientPrivate = client as unknown as TwitterClientPrivate;
await clientPrivate.refreshQueryIds();
expect(refreshSpy).toHaveBeenCalled();
});
it('fetchWithTimeout uses an abort signal when timeout is set', async () => {
const mockFetch = vi.fn(async (_url: string, init?: RequestInit) => {
expect(init?.signal).toBeInstanceOf(AbortSignal);
return makeResponse();
});
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies, timeoutMs: 5 });
const clientPrivate = client as unknown as TwitterClientPrivate;
await clientPrivate.fetchWithTimeout('https://example.com', { method: 'GET' });
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('returns a not found error when tweet data is missing', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({
data: {
threaded_conversation_with_injections_v2: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'other',
legacy: { full_text: 'nope', created_at: '2024-01-01T00:00:00Z' },
core: { user_results: { result: { legacy: { screen_name: 'user', name: 'User' } } } },
},
},
},
},
},
],
},
],
},
},
}),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getTweet('123');
expect(result.success).toBe(false);
expect(result.error).toBe('Tweet not found in response');
});
it('adds client user id header after ensureClientUserId', async () => {
const client = new TwitterClient({ cookies: validCookies });
process.env.NODE_ENV = 'production';
vi.spyOn(client, 'getCurrentUser').mockResolvedValue({
success: true,
user: { id: '42', username: 'tester', name: 'Tester' },
});
const clientPrivate = client as unknown as TwitterClientPrivate;
await clientPrivate.ensureClientUserId();
const headers = clientPrivate.getBaseHeaders();
expect(headers['x-twitter-client-user-id']).toBe('42');
});
describe('fetchUserArticlePlainText failures', () => {
it('returns empty object when response is not ok', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(makeResponse({ ok: false, status: 500 }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet');
expect(result).toEqual({});
});
it('returns empty object when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet');
expect(result).toEqual({});
});
it('returns empty object when no matching tweet is found', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({
data: {
user: { result: { timeline: { timeline: { instructions: [] } } } },
},
}),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet');
expect(result).toEqual({});
});
});
describe('fetchTweetDetail error handling', () => {
it('returns API errors from response payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad news' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchTweetDetail('1');
expect(result.success).toBe(false);
expect(result.error).toBe('bad news');
});
it('parses POST responses when GET returns 404', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(
makeResponse({
json: async () => ({
data: {
tweetResult: {
result: {
rest_id: '1',
legacy: {
full_text: 'hi',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
},
core: { user_results: { result: { legacy: { screen_name: 'user', name: 'User' } } } },
},
},
},
}),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchTweetDetail('1');
expect(result.success).toBe(true);
});
it('returns an error when fetch throws', async () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchTweetDetail('1');
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
});
describe('postStatusUpdate error handling', () => {
it('returns an error when postStatusUpdate throws', async () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.postStatusUpdate({ text: 'hello' });
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
});
});
@@ -0,0 +1,220 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { extractArticleText } from '../src/lib/twitter-client-utils.js';
const validCookies = {
authToken: 'test_auth_token',
ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
type ResponseLike = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
};
type TwitterClientUploadPrivate = TwitterClient & {
sleep: (ms: number) => Promise<void>;
};
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
ok: true,
status: 200,
json: async (): Promise<unknown> => ({}),
text: async (): Promise<string> => '',
...overrides,
});
describe('TwitterClient upload coverage', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
delete process.env.BIRD_DEBUG_ARTICLE;
vi.restoreAllMocks();
});
describe('uploadMedia error paths', () => {
it('rejects unsupported media types', async () => {
global.fetch = vi.fn() as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1, 2, 3]), mimeType: 'application/pdf' });
expect(result.success).toBe(false);
expect(global.fetch).not.toHaveBeenCalled();
});
it('returns an error when INIT fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns an error when APPEND fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns an error when FINALIZE fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns an error when media processing fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ processing_info: { state: 'failed', error: { message: 'processing failed' } } }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' });
expect(result.success).toBe(false);
expect(result.error).toBe('processing failed');
});
it('returns an error when STATUS fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }),
}),
)
.mockResolvedValueOnce(makeResponse({ ok: false, status: 503, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientUploadPrivate;
clientPrivate.sleep = vi.fn().mockResolvedValue(undefined);
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 503');
});
it('retries STATUS and continues when processing is pending', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }),
}),
)
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }),
}),
)
.mockResolvedValueOnce(makeResponse({ json: async () => ({ processing_info: { state: 'succeeded' } }) }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientUploadPrivate;
clientPrivate.sleep = vi.fn().mockResolvedValue(undefined);
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' });
expect(result.success).toBe(true);
});
it('returns an error when metadata upload fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(makeResponse({ json: async () => ({}) }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 400, text: async () => 'bad' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png', alt: 'alt' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 400');
});
it('returns an error when upload throws', async () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
});
describe('article extraction edge cases', () => {
it('logs article payloads when debug flag is set', () => {
process.env.BIRD_DEBUG_ARTICLE = '1';
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const result = extractArticleText({
rest_id: '1',
article: {
title: 'Title',
plain_text: 'Body',
article_results: { result: { title: 'Title', plain_text: 'Body' } },
},
});
expect(result).toBe('Title\n\nBody');
expect(errorSpy).toHaveBeenCalled();
});
it('drops duplicate body when it matches the title', () => {
const result = extractArticleText({
rest_id: '1',
article: {
title: 'Same',
plain_text: 'Same',
article_results: { result: { title: 'Same', plain_text: 'Same' } },
},
});
expect(result).toBe('Same');
});
});
});
+17
View File
@@ -0,0 +1,17 @@
import type { TwitterClient } from '../src/lib/twitter-client.js';
export const validCookies = {
authToken: 'test_auth_token',
ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
export type TwitterClientPrivate = TwitterClient & {
getCurrentUser: () => Promise<{
success: boolean;
user?: { id: string; username: string; name: string };
error?: string;
}>;
getLikesQueryIds: () => Promise<string[]>;
};
@@ -0,0 +1,400 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient constructor', () => {
it('should throw if authToken is missing', () => {
expect(
() =>
new TwitterClient({
cookies: { authToken: null, ct0: 'test', cookieHeader: null, source: null },
}),
).toThrow('Both authToken and ct0 cookies are required');
});
it('should throw if ct0 is missing', () => {
expect(
() =>
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);
});
});
describe('TwitterClient tweet', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch;
});
it('should post a tweet successfully', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
create_tweet: {
tweet_results: {
result: {
rest_id: '1234567890',
legacy: {
full_text: 'Hello world!',
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Hello world!');
expect(result.success).toBe(true);
expect(result.tweetId).toBe('1234567890');
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(url).toContain('CreateTweet');
expect(options.method).toBe('POST');
const body = JSON.parse(options.body);
expect(body.variables.tweet_text).toBe('Hello world!');
expect(body.features.rweb_video_screen_enabled).toBe(true);
expect(body.features.creator_subscriptions_tweet_preview_api_enabled).toBe(true);
});
it('supports attaching media IDs', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
create_tweet: {
tweet_results: {
result: {
rest_id: '1234567890',
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Hello world!', ['111', '222']);
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [, options] = mockFetch.mock.calls[0];
const body = JSON.parse(options.body);
expect(body.variables.media.media_entities).toEqual([
{ media_id: '111', tagged_users: [] },
{ media_id: '222', tagged_users: [] },
]);
});
it('retries CreateTweet via /i/api/graphql when operation URL 404s', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => '',
})
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => '',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
create_tweet: {
tweet_results: {
result: {
rest_id: '1234567890',
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Hello world!');
expect(result.success).toBe(true);
expect(result.tweetId).toBe('1234567890');
expect(mockFetch).toHaveBeenCalledTimes(3);
const [firstUrl] = mockFetch.mock.calls[0];
const [thirdUrl] = mockFetch.mock.calls[2];
expect(String(firstUrl)).toContain('/CreateTweet');
expect(String(thirdUrl)).toBe('https://x.com/i/api/graphql');
});
it('should handle API errors', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
errors: [{ message: 'Rate limit exceeded', code: 88 }],
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Test');
expect(result.success).toBe(false);
expect(result.error).toContain('Rate limit exceeded');
});
it('falls back to statuses/update.json when CreateTweet returns code 226', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({
errors: [
{
message: 'Authorization: This request looks like it might be automated.',
code: 226,
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
id_str: '1234567890',
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Hello world!');
expect(result.success).toBe(true);
expect(result.tweetId).toBe('1234567890');
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(String(mockFetch.mock.calls[1][0])).toContain('statuses/update.json');
});
it('surfaces statuses/update.json failure when CreateTweet returns code 226', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({
errors: [
{
message: 'Authorization: This request looks like it might be automated.',
code: 226,
},
],
}),
})
.mockResolvedValueOnce({
ok: false,
status: 403,
text: async () => 'Forbidden',
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Hello world!');
expect(result.success).toBe(false);
expect(result.error).toContain('(226)');
expect(result.error).toContain('fallback: HTTP 403');
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(String(mockFetch.mock.calls[1][0])).toContain('statuses/update.json');
});
it('surfaces statuses/update.json API errors when CreateTweet returns code 226', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({
errors: [
{
message: 'Authorization: This request looks like it might be automated.',
code: 226,
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
errors: [{ message: 'Nope', code: 999 }],
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Hello world!');
expect(result.success).toBe(false);
expect(result.error).toContain('(226)');
expect(result.error).toContain('fallback: Nope (999)');
});
it('surfaces statuses/update.json missing id when CreateTweet returns code 226', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({
errors: [
{
message: 'Authorization: This request looks like it might be automated.',
code: 226,
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Hello world!');
expect(result.success).toBe(false);
expect(result.error).toContain('(226)');
expect(result.error).toContain('fallback: Tweet created but no ID returned');
});
it('should handle HTTP errors', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 403,
text: async () => 'Forbidden',
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Test');
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 403');
});
it('should handle network errors', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Test');
expect(result.success).toBe(false);
expect(result.error).toBe('Network error');
});
it('should surface missing tweet ID when API responds without rest_id', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
create_tweet: {
tweet_results: {
result: {
legacy: { full_text: 'No id' },
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Hello world!');
expect(result.success).toBe(false);
expect(result.error).toBe('Tweet created but no ID returned');
});
});
describe('TwitterClient reply', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch;
});
it('should post a reply with correct reply_to_tweet_id', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
create_tweet: {
tweet_results: {
result: {
rest_id: '9876543210',
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.reply('This is a reply', '1234567890');
expect(result.success).toBe(true);
expect(result.tweetId).toBe('9876543210');
const [, options] = mockFetch.mock.calls[0];
const body = JSON.parse(options.body);
expect(body.variables.reply.in_reply_to_tweet_id).toBe('1234567890');
expect(body.variables.tweet_text).toBe('This is a reply');
expect(body.features.rweb_video_screen_enabled).toBe(true);
expect(body.features.creator_subscriptions_tweet_preview_api_enabled).toBe(true);
});
it('falls back to statuses/update.json for replies when CreateTweet returns code 226', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({
errors: [
{
message: 'Authorization: This request looks like it might be automated.',
code: 226,
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
id_str: '999',
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.reply('This is a reply', '1234567890', ['111', '222']);
expect(result.success).toBe(true);
expect(result.tweetId).toBe('999');
expect(mockFetch).toHaveBeenCalledTimes(2);
const [, options] = mockFetch.mock.calls[1];
expect(String(mockFetch.mock.calls[1][0])).toContain('statuses/update.json');
expect(options.method).toBe('POST');
expect(options.body).toContain('status=This+is+a+reply');
expect(options.body).toContain('in_reply_to_status_id=1234567890');
expect(options.body).toContain('auto_populate_reply_metadata=true');
expect(options.body).toContain('media_ids=111%2C222');
});
});
+170
View File
@@ -0,0 +1,170 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient conversation helpers', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
const makeConversationPayload = () => ({
data: {
threaded_conversation_with_injections_v2: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '1',
legacy: {
full_text: 'root',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '1',
},
core: { user_results: { result: { legacy: { screen_name: 'root', name: 'Root' } } } },
},
},
},
},
},
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '2',
legacy: {
full_text: 'child reply',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '1',
in_reply_to_status_id_str: '1',
},
core: { user_results: { result: { legacy: { screen_name: 'child', name: 'Child' } } } },
},
},
},
},
},
],
},
],
},
},
});
it('getReplies returns only replies to tweet', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => makeConversationPayload(),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getReplies('1');
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(1);
expect(result.tweets?.[0].id).toBe('2');
});
it('getThread returns sorted thread by createdAt', async () => {
const payload = makeConversationPayload();
// swap dates to verify sorting
const legacy =
payload.data.threaded_conversation_with_injections_v2.instructions[0]?.entries?.[0]?.content?.itemContent
?.tweet_results?.result?.legacy;
if (legacy) {
legacy.created_at = '2024-01-03T00:00:00Z';
}
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => payload,
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getThread('2');
expect(result.success).toBe(true);
expect(result.tweets?.map((t) => t.id)).toEqual(['2', '1']); // sorted by createdAt asc
});
it('getThread includes tweets from timeline module items', async () => {
const payload = makeConversationPayload();
payload.data.threaded_conversation_with_injections_v2.instructions[0]?.entries?.push({
content: {
items: [
{
item: {
itemContent: {
tweet_results: {
result: {
rest_id: '3',
legacy: {
full_text: 'nested reply',
created_at: '2024-01-04T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '1',
in_reply_to_status_id_str: '1',
},
core: {
user_results: { result: { legacy: { screen_name: 'nested', name: 'Nested' } } },
},
},
},
},
},
},
],
},
});
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => payload,
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getThread('1');
expect(result.success).toBe(true);
expect(result.tweets?.map((t) => t.id)).toEqual(['1', '2', '3']);
});
it('propagates fetchTweetDetail errors', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 500,
text: async () => 'oops',
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getThread('1');
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
});
+136
View File
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient getCurrentUser', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('returns mapped user details when present', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
user_id: '12345',
screen_name: 'tester',
name: 'Test User',
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getCurrentUser();
expect(result.success).toBe(true);
expect(result.user).toEqual({ id: '12345', username: 'tester', name: 'Test User' });
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('account/settings'), expect.any(Object));
});
it('returns error when response lacks identifiers', async () => {
mockFetch.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({ language: 'en' }),
text: async () => '{"language":"en"}',
}));
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getCurrentUser();
expect(result.success).toBe(false);
expect(result.error).toContain('Could not');
});
it('surfaces HTTP errors', async () => {
mockFetch.mockImplementation(async () => ({
ok: false,
status: 401,
text: async () => 'Unauthorized',
}));
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getCurrentUser();
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 401');
});
it('uses HTML fallback when API endpoints 404', async () => {
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: true,
status: 200,
text: async () => '<html>"screen_name":"fallback","user_id":"999"</html>',
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getCurrentUser();
expect(result.success).toBe(true);
expect(result.user?.username).toBe('fallback');
expect(result.user?.id).toBe('999');
expect(mockFetch).toHaveBeenCalledTimes(5);
});
it('skips an endpoint when JSON parsing fails', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => {
throw new Error('bad json');
},
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
user_id: '12345',
screen_name: 'tester',
name: 'Test User',
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getCurrentUser();
expect(result.success).toBe(true);
expect(result.user).toEqual({ id: '12345', username: 'tester', name: 'Test User' });
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('continues on fetch errors and still succeeds via HTML fallback', async () => {
mockFetch
.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' })
.mockRejectedValueOnce(new Error('settings boom'))
.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () => '<html>"screen_name":"fallback","user_id":"999"</html>',
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getCurrentUser();
expect(result.success).toBe(true);
expect(result.user?.username).toBe('fallback');
expect(result.user?.id).toBe('999');
});
});
@@ -0,0 +1,299 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient following/followers', () => {
let mockFetch: ReturnType<typeof vi.fn>;
const makeUserResult = (id: string, username: string, name = username) => ({
__typename: 'User',
rest_id: id,
is_blue_verified: true,
legacy: {
screen_name: username,
name,
description: `bio-${id}`,
followers_count: 10,
friends_count: 5,
profile_image_url_https: `https://example.com/${id}.jpg`,
created_at: '2024-01-01T00:00:00Z',
},
});
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('fetches following users and filters invalid entries', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
type: 'TimelineAddEntries',
entries: [
{
content: {
itemContent: {
user_results: {
result: makeUserResult('1', 'alpha', 'Alpha'),
},
},
},
},
{
content: {
itemContent: {
user_results: {
result: { __typename: 'User', rest_id: '2' },
},
},
},
},
{
content: {
itemContent: {
user_results: {
result: { __typename: 'TimelineUser' },
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClient & { getFollowingQueryIds: () => Promise<string[]> };
clientPrivate.getFollowingQueryIds = async () => ['test'];
const result = await client.getFollowing('123', 2);
expect(result.success).toBe(true);
expect(result.users?.length).toBe(1);
expect(result.users?.[0].username).toBe('alpha');
expect(result.users?.[0].followersCount).toBe(10);
expect(result.users?.[0].followingCount).toBe(5);
expect(result.users?.[0].isBlueVerified).toBe(true);
expect(result.users?.[0].profileImageUrl).toBe('https://example.com/1.jpg');
expect(result.users?.[0].createdAt).toBe('2024-01-01T00:00:00Z');
const [url] = mockFetch.mock.calls[0];
expect(String(url)).toContain('/Following?');
});
it('fetches followers and unwraps visibility results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
user_results: {
result: {
__typename: 'UserWithVisibilityResults',
user: makeUserResult('9', 'vis', 'Visible'),
},
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise<string[]> };
clientPrivate.getFollowersQueryIds = async () => ['test'];
const result = await client.getFollowers('123', 1);
expect(result.success).toBe(true);
expect(result.users?.[0].username).toBe('vis');
const [url] = mockFetch.mock.calls[0];
expect(String(url)).toContain('/Followers?');
});
it('refreshes query IDs after 404s', async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'nope' }).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
user_results: {
result: makeUserResult('1', 'alpha', 'Alpha'),
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClient & {
getFollowingQueryIds: () => Promise<string[]>;
refreshQueryIds: () => Promise<void>;
};
clientPrivate.getFollowingQueryIds = async () => ['test'];
let refreshed = false;
clientPrivate.refreshQueryIds = async () => {
refreshed = true;
};
const result = await client.getFollowing('123', 1);
expect(refreshed).toBe(true);
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
describe('TwitterClient likes', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('fetches likes and parses tweet results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '2',
legacy: {
full_text: 'liked',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '2',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'root', name: 'Root' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '42', username: 'tester', name: 'Tester' },
});
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(2);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('2');
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('GET');
expect(String(url)).toContain('/Likes?');
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
expect(parsedVars.userId).toBe('42');
expect(parsedVars.count).toBe(2);
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBeUndefined();
});
it('returns an error when current user is unavailable', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ success: false, error: 'no user' });
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toBe('no user');
expect(mockFetch).not.toHaveBeenCalled();
});
});
+417
View File
@@ -0,0 +1,417 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { mapTweetResult } from '../src/lib/twitter-client-utils.js';
import { validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient getTweet', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch;
});
it('should return tweet data from root tweetResult', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
tweetResult: {
result: {
rest_id: '12345',
legacy: {
full_text: 'Root tweet text',
created_at: 'Mon Jan 01 00:00:00 +0000 2024',
reply_count: 1,
retweet_count: 2,
favorite_count: 3,
},
core: {
user_results: {
result: {
legacy: {
screen_name: 'user',
name: 'User Name',
},
},
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getTweet('12345');
expect(result.success).toBe(true);
expect(result.tweet?.id).toBe('12345');
expect(result.tweet?.text).toBe('Root tweet text');
expect(result.tweet?.author.username).toBe('user');
});
it('should return tweet data found inside conversation instructions', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
threaded_conversation_with_injections_v2: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '6789',
legacy: {
full_text: 'Nested text',
created_at: 'Mon Jan 01 00:00:00 +0000 2024',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
},
core: {
user_results: {
result: {
legacy: {
screen_name: 'nestuser',
name: 'Nested User',
},
},
},
},
},
},
},
},
},
],
},
],
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getTweet('6789');
expect(result.success).toBe(true);
expect(result.tweet?.text).toBe('Nested text');
expect(result.tweet?.author.username).toBe('nestuser');
});
it('should report HTTP errors from getTweet', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
text: async () => 'Not Found',
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getTweet('missing');
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 404');
});
it('should return article text when present', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
tweetResult: {
result: {
rest_id: 'article123',
legacy: {
full_text: '',
created_at: 'Mon Jan 01 00:00:00 +0000 2024',
},
article: {
article_results: {
result: {
title: '2025 LLM Year in Review',
sections: [
{
items: [
{ text: 'Intro paragraph of the article.' },
{ content: { text: 'Second paragraph.' } },
],
},
],
},
},
},
core: {
user_results: {
result: {
legacy: {
screen_name: 'author',
name: 'Article Author',
},
},
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getTweet('article123');
expect(result.success).toBe(true);
expect(result.tweet?.text).toBe('2025 LLM Year in Review\n\nIntro paragraph of the article.\n\nSecond paragraph.');
});
it('should fall back to user article timeline for plain text', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
tweetResult: {
result: {
rest_id: 'article123',
legacy: {
full_text: '',
created_at: 'Mon Jan 01 00:00:00 +0000 2024',
},
article: {
article_results: {
result: {
title: '2025 LLM Year in Review',
},
},
},
core: {
user_results: {
result: {
rest_id: '33836629',
legacy: {
screen_name: 'author',
name: 'Article Author',
},
},
},
},
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'article123',
article: {
article_results: {
result: {
title: '2025 LLM Year in Review',
plain_text: 'Full article body.',
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getTweet('article123');
expect(result.success).toBe(true);
expect(result.tweet?.text).toBe('2025 LLM Year in Review\n\nFull article body.');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('should return note tweet text when present', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
tweetResult: {
result: {
rest_id: 'note123',
legacy: {
full_text: '',
created_at: 'Mon Jan 01 00:00:00 +0000 2024',
},
note_tweet: {
note_tweet_results: {
result: {
text: 'Long form note content.',
},
},
},
core: {
user_results: {
result: {
legacy: {
screen_name: 'noter',
name: 'Note Author',
},
},
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getTweet('note123');
expect(result.success).toBe(true);
expect(result.tweet?.text).toBe('Long form note content.');
});
it('retries TweetDetail query id on 404', async () => {
const payload = {
data: {
tweetResult: {
result: {
rest_id: '1',
legacy: {
full_text: 'hello',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
},
core: { user_results: { result: { legacy: { screen_name: 'root', name: 'Root' } } } },
},
},
},
};
mockFetch
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => '' })
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => '' })
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => payload });
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getTweet('1');
expect(result.success).toBe(true);
expect(result.tweet?.id).toBe('1');
expect(mockFetch).toHaveBeenCalledTimes(3);
});
});
describe('TwitterClient quoted tweets', () => {
const makeTweetResult = (
id: string,
text: string,
username = `user${id}`,
name = `User ${id}`,
): Record<string, unknown> => ({
rest_id: id,
legacy: {
full_text: text,
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: id,
},
core: {
user_results: {
result: {
rest_id: `u${id}`,
legacy: { screen_name: username, name },
},
},
},
});
it('includes one level of quoted tweet by default', () => {
const quoted = makeTweetResult('2', 'quoted');
const root = makeTweetResult('1', 'root');
root.quoted_status_result = { result: quoted };
const client = new TwitterClient({ cookies: validCookies });
const mapped = mapTweetResult(root, (client as { quoteDepth: number }).quoteDepth);
expect(mapped?.quotedTweet?.id).toBe('2');
expect(mapped?.quotedTweet?.quotedTweet).toBeUndefined();
});
it('honors quoteDepth = 0', () => {
const quoted = makeTweetResult('2', 'quoted');
const root = makeTweetResult('1', 'root');
root.quoted_status_result = { result: quoted };
const client = new TwitterClient({ cookies: validCookies, quoteDepth: 0 });
const mapped = mapTweetResult(root, (client as { quoteDepth: number }).quoteDepth);
expect(mapped?.quotedTweet).toBeUndefined();
});
it('recurses when quoteDepth > 1', () => {
const quoted2 = makeTweetResult('3', 'quoted2');
const quoted1 = makeTweetResult('2', 'quoted1');
quoted1.quoted_status_result = { result: quoted2 };
const root = makeTweetResult('1', 'root');
root.quoted_status_result = { result: quoted1 };
const client = new TwitterClient({ cookies: validCookies, quoteDepth: 2 });
const mapped = mapTweetResult(root, (client as { quoteDepth: number }).quoteDepth);
expect(mapped?.quotedTweet?.id).toBe('2');
expect(mapped?.quotedTweet?.quotedTweet?.id).toBe('3');
expect(mapped?.quotedTweet?.quotedTweet?.quotedTweet).toBeUndefined();
});
it('unwraps quoted tweet visibility wrappers', () => {
const quoted = makeTweetResult('2', 'quoted');
const root = makeTweetResult('1', 'root');
root.quoted_status_result = {
result: {
__typename: 'TweetWithVisibilityResults',
tweet: quoted,
},
};
const client = new TwitterClient({ cookies: validCookies });
const mapped = mapTweetResult(root, (client as { quoteDepth: number }).quoteDepth);
expect(mapped?.quotedTweet?.id).toBe('2');
});
});
+104
View File
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient uploadMedia', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch;
});
it('uploads an image and sets alt text', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({ media_id_string: '999' }),
})
.mockResolvedValueOnce({
ok: true,
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({}),
})
.mockResolvedValueOnce({
ok: true,
});
const client = new TwitterClient({ cookies: validCookies });
const data = new Uint8Array([1, 2, 3, 4]);
const result = await client.uploadMedia({ data, mimeType: 'image/png', alt: 'alt text' });
expect(result.success).toBe(true);
expect(result.mediaId).toBe('999');
expect(mockFetch).toHaveBeenCalledTimes(4);
const [initUrl, initOptions] = mockFetch.mock.calls[0];
expect(String(initUrl)).toContain('upload.twitter.com');
expect(initOptions.method).toBe('POST');
expect(initOptions.body).toBeInstanceOf(URLSearchParams);
expect((initOptions.body as URLSearchParams).get('command')).toBe('INIT');
expect((initOptions.body as URLSearchParams).get('media_type')).toBe('image/png');
const [, appendOptions] = mockFetch.mock.calls[1];
expect(appendOptions.method).toBe('POST');
expect(appendOptions.body).toBeInstanceOf(FormData);
const appendBody = appendOptions.body as FormData;
expect(appendBody.get('command')).toBe('APPEND');
expect(appendBody.get('media_id')).toBe('999');
expect(appendBody.get('segment_index')).toBe('0');
expect(appendBody.get('media')).toBeInstanceOf(Blob);
const [, finalizeOptions] = mockFetch.mock.calls[2];
expect(finalizeOptions.body).toBeInstanceOf(URLSearchParams);
expect((finalizeOptions.body as URLSearchParams).get('command')).toBe('FINALIZE');
expect((finalizeOptions.body as URLSearchParams).get('media_id')).toBe('999');
const [metaUrl, metaOptions] = mockFetch.mock.calls[3];
expect(String(metaUrl)).toContain('/media/metadata/create.json');
expect(metaOptions.method).toBe('POST');
expect(JSON.parse(metaOptions.body)).toEqual({ media_id: '999', alt_text: { text: 'alt text' } });
});
it('uploads a video and polls processing status', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({ media_id_string: '777' }),
})
.mockResolvedValueOnce({
ok: true,
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ processing_info: { state: 'succeeded' } }),
});
const client = new TwitterClient({ cookies: validCookies });
const data = new Uint8Array([1, 2, 3, 4, 5]);
const result = await client.uploadMedia({ data, mimeType: 'video/mp4', alt: 'ignored' });
expect(result.success).toBe(true);
expect(result.mediaId).toBe('777');
expect(mockFetch).toHaveBeenCalledTimes(4);
const [, finalizeOptions] = mockFetch.mock.calls[2];
expect((finalizeOptions.body as URLSearchParams).get('command')).toBe('FINALIZE');
const [statusUrl] = mockFetch.mock.calls[3];
expect(String(statusUrl)).toContain('command=STATUS');
});
});
@@ -0,0 +1,562 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient search', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('retries on 404 and posts search payload', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => '',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
search_by_raw_query: {
search_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '1',
legacy: {
full_text: 'found',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '1',
},
core: {
user_results: {
result: { legacy: { screen_name: 'root', name: 'Root' } },
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('needle', 1);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('1');
expect(mockFetch).toHaveBeenCalledTimes(2);
const [url, options] = mockFetch.mock.calls[1];
expect(options.method).toBe('POST');
const body = JSON.parse(options.body);
expect(body.features).toBeDefined();
expect(body.queryId).toBeDefined();
const urlVars = new URL(url as string).searchParams.get('variables');
expect(urlVars).toBeTruthy();
const parsed = JSON.parse(urlVars as string) as { rawQuery?: string };
expect(parsed.rawQuery).toBe('needle');
});
it('refreshes query IDs when all search endpoints 404', async () => {
mockFetch
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => '' })
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => '' })
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => '' })
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => '' })
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
search_by_raw_query: {
search_timeline: {
timeline: {
instructions: [],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('hello', 5);
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(5);
});
it('returns an unknown error when no query IDs are available', async () => {
const client = new TwitterClient({ cookies: validCookies });
(client as unknown as { getSearchTimelineQueryIds: () => Promise<string[]> }).getSearchTimelineQueryIds =
async () => [];
const result = await client.search('hello', 5);
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown error fetching search results');
expect(mockFetch).not.toHaveBeenCalled();
});
it('paginates search results using the bottom cursor', async () => {
const makeSearchEntry = (id: string, text: string) => ({
content: {
itemContent: {
tweet_results: {
result: {
rest_id: id,
legacy: {
full_text: text,
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: id,
},
core: {
user_results: {
result: { legacy: { screen_name: 'root', name: 'Root' } },
},
},
},
},
},
},
});
const makeResponse = (ids: string[], cursor?: string) => ({
data: {
search_by_raw_query: {
search_timeline: {
timeline: {
instructions: [
{
entries: [
...ids.map((id) => makeSearchEntry(id, `tweet-${id}`)),
...(cursor ? [{ content: { cursorType: 'Bottom', value: cursor } }] : []),
],
},
],
},
},
},
},
});
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeResponse(['1', '2'], 'cursor-1'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeResponse(['2', '3']),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('needle', 3);
expect(result.success).toBe(true);
expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1', '2', '3']);
expect(mockFetch).toHaveBeenCalledTimes(2);
const firstVars = JSON.parse(
new URL(mockFetch.mock.calls[0][0] as string).searchParams.get('variables') as string,
) as { cursor?: string };
const secondVars = JSON.parse(
new URL(mockFetch.mock.calls[1][0] as string).searchParams.get('variables') as string,
) as { cursor?: string };
expect(firstVars.cursor).toBeUndefined();
expect(secondVars.cursor).toBe('cursor-1');
});
it('stops paginating when the cursor repeats', async () => {
const makeSearchEntry = (id: string) => ({
content: {
itemContent: {
tweet_results: {
result: {
rest_id: id,
legacy: {
full_text: `tweet-${id}`,
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: id,
},
core: {
user_results: {
result: { legacy: { screen_name: 'root', name: 'Root' } },
},
},
},
},
},
},
});
const makeResponse = (ids: string[], cursor: string) => ({
data: {
search_by_raw_query: {
search_timeline: {
timeline: {
instructions: [
{
entries: [
...ids.map((id) => makeSearchEntry(id)),
{ content: { cursorType: 'Bottom', value: cursor } },
],
},
],
},
},
},
},
});
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeResponse(['1', '2'], 'same-cursor'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeResponse(['3'], 'same-cursor'),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('needle', 4);
expect(result.success).toBe(true);
expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1', '2', '3']);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('stops paginating when the next page is empty', async () => {
const makeSearchEntry = (id: string) => ({
content: {
itemContent: {
tweet_results: {
result: {
rest_id: id,
legacy: {
full_text: `tweet-${id}`,
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: id,
},
core: {
user_results: {
result: { legacy: { screen_name: 'root', name: 'Root' } },
},
},
},
},
},
},
});
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
search_by_raw_query: {
search_timeline: {
timeline: {
instructions: [
{
entries: [makeSearchEntry('1'), { content: { cursorType: 'Bottom', value: 'cursor-1' } }],
},
],
},
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
search_by_raw_query: {
search_timeline: {
timeline: {
instructions: [
{
entries: [{ content: { cursorType: 'Bottom', value: 'cursor-2' } }],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('needle', 3);
expect(result.success).toBe(true);
expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1']);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
describe('TwitterClient bookmarks', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('fetches bookmarks and parses tweet results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
bookmark_timeline_v2: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '1',
legacy: {
full_text: 'saved',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'root', name: 'Root' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(2);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('1');
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('GET');
expect(String(url)).toContain('/Bookmarks?');
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
expect(parsedVars.count).toBe(2);
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBe(true);
});
});
describe('TwitterClient bookmark folders', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('fetches bookmark folder timeline and parses tweet results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
bookmark_collection_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '9',
legacy: {
full_text: 'saved in folder',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '9',
},
core: {
user_results: {
result: {
rest_id: 'u9',
legacy: { screen_name: 'folder', name: 'Folder' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 2);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('9');
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('GET');
expect(String(url)).toContain('/BookmarkFolderTimeline?');
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
expect(parsedVars.bookmark_collection_id).toBe('123');
expect(parsedVars.count).toBe(2);
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBe(true);
});
it('retries without count when API rejects the count variable', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
errors: [{ message: 'Variable "$count" is not defined by operation' }],
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
bookmark_collection_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '9',
legacy: {
full_text: 'saved in folder',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '9',
},
core: {
user_results: {
result: {
rest_id: 'u9',
legacy: { screen_name: 'folder', name: 'Folder' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as TwitterClient & { getBookmarkFolderQueryIds: () => Promise<string[]> };
clientPrivate.getBookmarkFolderQueryIds = async () => ['test'];
const result = await client.getBookmarkFolderTimeline('123', 2);
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
const firstVars = JSON.parse(new URL(mockFetch.mock.calls[0][0] as string).searchParams.get('variables') as string);
const secondVars = JSON.parse(
new URL(mockFetch.mock.calls[1][0] as string).searchParams.get('variables') as string,
);
expect(firstVars.count).toBe(2);
expect(secondVars.count).toBeUndefined();
});
});
File diff suppressed because it is too large Load Diff
+59 -1
View File
@@ -3,7 +3,13 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { FALLBACK_VERSION, formatVersionLine, resolveGitSha, resolvePackageVersion } from '../src/lib/version.js'; import {
FALLBACK_VERSION,
formatVersionLine,
getCliVersion,
resolveGitSha,
resolvePackageVersion,
} from '../src/lib/version.js';
function withTempDir<T>(fn: (dir: string) => T): T { function withTempDir<T>(fn: (dir: string) => T): T {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bird-version-test-')); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bird-version-test-'));
@@ -53,6 +59,17 @@ describe('getCliVersion', () => {
}); });
}); });
it('falls back to VERSION file when package.json has no version', () => {
withTempDir((dir) => {
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({}));
fs.writeFileSync(path.join(dir, 'VERSION'), '5.5.5\n');
const entry = path.join(dir, 'entry.ts');
fs.writeFileSync(entry, '// noop');
expect(resolvePackageVersion(pathToFileURL(entry).href)).toBe('5.5.5');
});
});
it('falls back to unknown when no version files exist', () => { it('falls back to unknown when no version files exist', () => {
withTempDir((dir) => { withTempDir((dir) => {
const entry = path.join(dir, 'a', 'b', 'c', 'entry.ts'); const entry = path.join(dir, 'a', 'b', 'c', 'entry.ts');
@@ -63,6 +80,31 @@ describe('getCliVersion', () => {
}); });
}); });
it('returns null when git HEAD is missing', () => {
withTempDir((dir) => {
const gitDir = path.join(dir, '.git');
fs.mkdirSync(gitDir, { recursive: true });
const entry = path.join(dir, 'entry.ts');
fs.writeFileSync(entry, '// noop');
expect(resolveGitSha(pathToFileURL(entry).href)).toBeNull();
});
});
it('returns null when git ref cannot be resolved', () => {
withTempDir((dir) => {
const gitDir = path.join(dir, '.git');
fs.mkdirSync(gitDir, { recursive: true });
fs.writeFileSync(path.join(gitDir, 'HEAD'), 'ref: refs/heads/main\n');
const entry = path.join(dir, 'entry.ts');
fs.writeFileSync(entry, '// noop');
expect(resolveGitSha(pathToFileURL(entry).href)).toBeNull();
});
});
it('resolves git sha from .git directory HEAD', () => { it('resolves git sha from .git directory HEAD', () => {
withTempDir((dir) => { withTempDir((dir) => {
const gitDir = path.join(dir, '.git'); const gitDir = path.join(dir, '.git');
@@ -113,6 +155,16 @@ describe('getCliVersion', () => {
}); });
}); });
it('returns null when no git directory exists', () => {
withTempDir((dir) => {
const entry = path.join(dir, 'nested', 'entry.ts');
fs.mkdirSync(path.dirname(entry), { recursive: true });
fs.writeFileSync(entry, '// noop');
expect(resolveGitSha(pathToFileURL(entry).href)).toBeNull();
});
});
it('formats version line with version + sha when both available', () => { it('formats version line with version + sha when both available', () => {
withTempDir((dir) => { withTempDir((dir) => {
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '3.3.3' })); fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '3.3.3' }));
@@ -126,4 +178,10 @@ describe('getCliVersion', () => {
expect(formatVersionLine(pathToFileURL(entry).href)).toBe('3.3.3 (dddddddd)'); expect(formatVersionLine(pathToFileURL(entry).href)).toBe('3.3.3 (dddddddd)');
}); });
}); });
it('returns a CLI version string', () => {
const value = getCliVersion();
expect(typeof value).toBe('string');
expect(value.length).toBeGreaterThan(0);
});
}); });
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"],
"exclude": ["node_modules", "dist", "coverage"]
}
+5 -5
View File
@@ -9,12 +9,12 @@ export default defineConfig({
provider: 'v8', provider: 'v8',
reporter: ['text', 'html'], reporter: ['text', 'html'],
include: ['src/**/*.ts'], include: ['src/**/*.ts'],
exclude: ['src/index.ts'], exclude: ['src/cli.ts'],
thresholds: { thresholds: {
statements: 70, statements: 90,
branches: 70, branches: 80,
functions: 70, functions: 90,
lines: 70, lines: 90,
}, },
}, },
}, },