Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f38efc9bf | ||
|
|
0bf7190c95 | ||
|
|
3be2e3ae7e | ||
|
|
dee304235b | ||
|
|
9a4465f05d | ||
|
|
978d07f60f | ||
|
|
472d666eee | ||
|
|
c35e937b2a | ||
|
|
4730267c2a | ||
|
|
8257d52532 | ||
|
|
80a57c825f | ||
|
|
88651f669d | ||
|
|
6669ba9912 | ||
|
|
3df7969b16 | ||
|
|
5613caa149 | ||
|
|
b4e1ddbf2e | ||
|
|
9222908ab7 | ||
|
|
674fee3b8a | ||
|
|
a485655f42 | ||
|
|
618d87ec72 | ||
|
|
8b9072fa7e | ||
|
|
3661f784cb | ||
|
|
c4689d2580 | ||
|
|
fdf099ff70 | ||
|
|
2cbd181d8c | ||
|
|
5397bf4e31 |
@@ -16,3 +16,7 @@ twitter-cli.bin
|
||||
bird
|
||||
.env
|
||||
.*.bun-build
|
||||
*.zip
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.tgz
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 — 2025-12-26
|
||||
|
||||
### Added
|
||||
- Output controls: `--plain`, `--no-emoji`, `--no-color` (respects `NO_COLOR`).
|
||||
- `help` command: `bird help <command>`.
|
||||
- Runtime GraphQL query ID refresh: `bird query-ids --fresh` (cached on disk; auto-retry on 404; override cache via `BIRD_QUERY_IDS_CACHE`).
|
||||
- GraphQL media uploads via `--media` (up to 4 images/GIFs, or 1 video).
|
||||
|
||||
### Fixed
|
||||
- CLI `--version`: read version from `package.json`/`VERSION` (no hardcoded string) + append git sha when available.
|
||||
|
||||
### Changed
|
||||
- `mentions`: no hardcoded user; defaults to authenticated user or accepts `--user @handle`.
|
||||
- GraphQL query ID updater: correctly pairs `operationName` ↔ `queryId` (CreateTweet/CreateRetweet/etc).
|
||||
- `build:dist`: copies `src/lib/query-ids.json` into `dist/lib/query-ids.json` (keeps `dist/` in sync).
|
||||
- `--engine graphql`: strict GraphQL-only (disables Sweetistics fallback).
|
||||
|
||||
## 0.1.1 — 2025-12-26
|
||||
|
||||
### Changed
|
||||
- Engine default now `auto` (GraphQL primary; Sweetistics only on fallback when configured).
|
||||
|
||||
### Tests
|
||||
- Add engine resolution tests for auto/default behavior.
|
||||
|
||||
### Fixed
|
||||
- GraphQL read: rotate TweetDetail query IDs with fallback to avoid 404s.
|
||||
|
||||
## 0.1.0 — 2025-12-20
|
||||
|
||||
### Added
|
||||
|
||||
@@ -16,6 +16,9 @@ pnpm add -g @steipete/bird
|
||||
# Show the logged-in account
|
||||
bird whoami
|
||||
|
||||
# Discover command help
|
||||
bird help whoami
|
||||
|
||||
# Read a tweet (URL or ID)
|
||||
bird read https://x.com/user/status/1234567890123456789
|
||||
bird 1234567890123456789 --json
|
||||
@@ -27,29 +30,40 @@ bird replies 1234567890123456789
|
||||
# Search + mentions
|
||||
bird search "from:steipete" -n 5
|
||||
bird mentions -n 5
|
||||
bird mentions --user @steipete -n 5
|
||||
|
||||
# Refresh GraphQL query IDs cache (no rebuild)
|
||||
bird query-ids --fresh
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
- `bird tweet "<text>"` — post a new tweet.
|
||||
- `bird reply <tweet-id-or-url> "<text>"` — reply to a tweet using its ID or URL.
|
||||
- `bird help [command]` — show help (or help for a subcommand).
|
||||
- `bird query-ids [--fresh] [--json]` — inspect or refresh cached GraphQL query IDs.
|
||||
- `bird read <tweet-id-or-url> [--json]` — fetch tweet content as text or JSON.
|
||||
- `bird <tweet-id-or-url> [--json]` — shorthand for `read` when only a URL or ID is provided.
|
||||
- `bird replies <tweet-id-or-url> [--json]` — list replies to a tweet.
|
||||
- `bird thread <tweet-id-or-url> [--json]` — show the full conversation thread.
|
||||
- `bird search "<query>" [-n count] [--json]` — search for tweets matching a query.
|
||||
- `bird mentions [-n count] [--json]` — find tweets mentioning @clawdbot.
|
||||
- `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
|
||||
- `bird whoami` — print which Twitter account your cookies belong to.
|
||||
- `bird check` — show which credentials are available and where they were sourced from.
|
||||
|
||||
## Engines
|
||||
|
||||
- `--engine graphql` (default) — use Twitter/X GraphQL with cookies (Chrome/Firefox/env/flags).
|
||||
- `--engine auto` (default) — use GraphQL first; if a Sweetistics API key is available, fall back on errors.
|
||||
- `--engine graphql` — use Twitter/X GraphQL with cookies (Chrome/Firefox/env/flags).
|
||||
- `--engine sweetistics` — use Sweetistics API key (no browser cookies needed).
|
||||
- `--engine auto` — Sweetistics if a key is available, otherwise GraphQL.
|
||||
|
||||
Note: fallback to Sweetistics only happens in `--engine auto`. `--engine graphql` is strict (no fallback).
|
||||
|
||||
Global options:
|
||||
- `--timeout <ms>`: abort requests after the given timeout (milliseconds).
|
||||
- `--plain`: stable output (no emoji, no color).
|
||||
- `--no-emoji`: disable emoji output.
|
||||
- `--no-color`: disable ANSI colors (or set `NO_COLOR=1`).
|
||||
|
||||
## Authentication (GraphQL)
|
||||
|
||||
@@ -74,7 +88,7 @@ Example `~/.config/bird/config.json5`:
|
||||
|
||||
```json5
|
||||
{
|
||||
engine: "graphql",
|
||||
engine: "auto",
|
||||
firefoxProfile: "default-release",
|
||||
sweetisticsApiKey: "sweet-...",
|
||||
allowFirefox: true,
|
||||
@@ -91,11 +105,33 @@ Environment shortcuts:
|
||||
|
||||
- `--json` prints raw tweet objects for read/replies/thread/search/mentions.
|
||||
- `read` returns full text for Notes and Articles when present.
|
||||
- Use `--plain` for stable, script-friendly output (no emoji, no color).
|
||||
|
||||
## Media uploads (Sweetistics only)
|
||||
## Query IDs (GraphQL)
|
||||
|
||||
X rotates GraphQL query IDs frequently. `bird` caches refreshed IDs on disk and retries automatically on 404s.
|
||||
|
||||
Refresh on demand:
|
||||
|
||||
```bash
|
||||
bird query-ids --fresh
|
||||
```
|
||||
|
||||
Exit codes:
|
||||
- `0`: success
|
||||
- `1`: runtime error (network/auth/etc)
|
||||
- `2`: invalid usage/validation (e.g. bad `--user` handle)
|
||||
|
||||
## Version
|
||||
|
||||
`bird --version` prints `package.json` version plus current git sha when available, e.g. `0.2.0 (3df7969b)`.
|
||||
|
||||
## Media uploads
|
||||
|
||||
- Attach media with `--media` (repeatable) and optional `--alt` per item.
|
||||
- Up to 4 images, or 1 video (no mixing). Supported: jpg, jpeg, png, webp, gif, mp4, mov.
|
||||
- Up to 4 images/GIFs, or 1 video (no mixing). Supported: jpg, jpeg, png, webp, gif, mp4, mov.
|
||||
- `--engine graphql`: images/GIFs + 1 video supported (uploads via Twitter legacy upload endpoint + cookies; video may take longer to process).
|
||||
- `--engine sweetistics`: images + video supported (requires `SWEETISTICS_API_KEY`).
|
||||
|
||||
Example:
|
||||
|
||||
@@ -113,6 +149,7 @@ pnpm run build:dist # dist/ only
|
||||
pnpm run build:binary
|
||||
|
||||
pnpm run dev tweet "Test"
|
||||
pnpm run dev -- --plain check
|
||||
pnpm test
|
||||
pnpm run lint
|
||||
```
|
||||
|
||||
+9
-3
@@ -1,16 +1,22 @@
|
||||
{
|
||||
"name": "@steipete/bird",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "CLI tool for tweeting and replying via Twitter/X GraphQL API",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"bird": "dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "pnpm run build:dist && pnpm run build:binary",
|
||||
"build:dist": "tsc",
|
||||
"build:binary": "bun build --compile --minify src/index.ts --outfile bird",
|
||||
"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",
|
||||
"dev": "tsx src/index.ts",
|
||||
"bird": "pnpm run build:dist && node dist/index.js",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
const files = [
|
||||
{
|
||||
from: resolve(root, 'src/lib/query-ids.json'),
|
||||
to: resolve(root, 'dist/lib/query-ids.json'),
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
mkdirSync(dirname(file.to), { recursive: true });
|
||||
writeFileSync(file.to, readFileSync(file.from));
|
||||
}
|
||||
|
||||
@@ -28,6 +28,18 @@ const BUNDLE_URL_REGEX =
|
||||
/https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g;
|
||||
|
||||
const OPERATION_PATTERNS = [
|
||||
// Modern bundles export operations like:
|
||||
// e.exports={queryId:"...",operationName:"CreateTweet",operationType:"mutation",...}
|
||||
{
|
||||
regex: /e\.exports=\{queryId\s*:\s*["']([^"']+)["']\s*,\s*operationName\s*:\s*["']([^"']+)["']/gs,
|
||||
operationGroup: 2,
|
||||
queryIdGroup: 1,
|
||||
},
|
||||
{
|
||||
regex: /e\.exports=\{operationName\s*:\s*["']([^"']+)["']\s*,\s*queryId\s*:\s*["']([^"']+)["']/gs,
|
||||
operationGroup: 1,
|
||||
queryIdGroup: 2,
|
||||
},
|
||||
{
|
||||
regex: /operationName\s*[:=]\s*["']([^"']+)["'](.{0,4000}?)queryId\s*[:=]\s*["']([^"']+)["']/gs,
|
||||
operationGroup: 1,
|
||||
|
||||
+351
-175
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
export type EngineMode = 'graphql' | 'sweetistics' | 'auto';
|
||||
|
||||
export function resolveEngineMode(value?: string): EngineMode {
|
||||
const normalized = (value || 'auto').toLowerCase();
|
||||
if (normalized === 'graphql' || normalized === 'sweetistics' || normalized === 'auto') {
|
||||
return normalized;
|
||||
}
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
export function shouldUseSweetistics(engine: EngineMode, _hasApiKey: boolean): boolean {
|
||||
if (engine === 'sweetistics') return true;
|
||||
if (engine === 'graphql') return false;
|
||||
return false; // auto: GraphQL primary, Sweetistics only as fallback
|
||||
}
|
||||
|
||||
export function shouldAllowSweetisticsFallback(engine: EngineMode, hasApiKey: boolean): boolean {
|
||||
return engine === 'auto' && hasApiKey;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export function normalizeHandle(input?: string | null): string | null {
|
||||
const raw = (input ?? '').trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw;
|
||||
const handle = withoutAt.trim();
|
||||
if (!handle) return null;
|
||||
|
||||
// X/Twitter handles are traditionally max 15 chars; keep strict to avoid surprising queries.
|
||||
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null;
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
export function mentionsQueryFromUserOption(userOption?: string | null): {
|
||||
query: string | null;
|
||||
error: string | null;
|
||||
} {
|
||||
if (typeof userOption === 'undefined') return { query: null, error: null };
|
||||
|
||||
const handle = normalizeHandle(userOption);
|
||||
if (!handle) {
|
||||
return {
|
||||
query: null,
|
||||
error: 'Invalid --user handle. Expected something like @steipete (letters, digits, underscore; max 15).',
|
||||
};
|
||||
}
|
||||
|
||||
return { query: `@${handle}`, error: null };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
export type OutputConfig = {
|
||||
plain: boolean;
|
||||
emoji: boolean;
|
||||
color: boolean;
|
||||
};
|
||||
|
||||
export type StatusKind = 'ok' | 'warn' | 'err' | 'info' | 'hint';
|
||||
export type LabelKind = 'url' | 'date' | 'source' | 'engine' | 'credentials' | 'user' | 'userId' | 'email';
|
||||
|
||||
const STATUS: Record<StatusKind, { emoji: string; text: string; plain: string }> = {
|
||||
ok: { emoji: '✅', text: 'OK:', plain: '[ok]' },
|
||||
warn: { emoji: '⚠️', text: 'Warning:', plain: '[warn]' },
|
||||
err: { emoji: '❌', text: 'Error:', plain: '[err]' },
|
||||
info: { emoji: 'ℹ️', text: 'Info:', plain: '[info]' },
|
||||
hint: { emoji: 'ℹ️', text: 'Hint:', plain: '[hint]' },
|
||||
};
|
||||
|
||||
const LABELS: Record<LabelKind, { emoji: string; text: string; plain: string }> = {
|
||||
url: { emoji: '🔗', text: 'URL:', plain: 'url:' },
|
||||
date: { emoji: '📅', text: 'Date:', plain: 'date:' },
|
||||
source: { emoji: '📍', text: 'Source:', plain: 'source:' },
|
||||
engine: { emoji: '⚙️', text: 'Engine:', plain: 'engine:' },
|
||||
credentials: { emoji: '🔑', text: 'Credentials:', plain: 'credentials:' },
|
||||
user: { emoji: '🙋', text: 'User:', plain: 'user:' },
|
||||
userId: { emoji: '🪪', text: 'User ID:', plain: 'user_id:' },
|
||||
email: { emoji: '📧', text: 'Email:', plain: 'email:' },
|
||||
};
|
||||
|
||||
export function resolveOutputConfigFromArgv(argv: string[], env: NodeJS.ProcessEnv, isTty: boolean): OutputConfig {
|
||||
const hasNoColorEnv = Object.hasOwn(env, 'NO_COLOR') || env.TERM === 'dumb';
|
||||
const defaultColor = isTty && !hasNoColorEnv;
|
||||
|
||||
const plain = argv.includes('--plain');
|
||||
const emoji = !plain && !argv.includes('--no-emoji');
|
||||
const color = !plain && !argv.includes('--no-color') && defaultColor;
|
||||
|
||||
return { plain, emoji, color };
|
||||
}
|
||||
|
||||
export function resolveOutputConfigFromCommander(
|
||||
opts: { plain?: boolean; emoji?: boolean; color?: boolean },
|
||||
env: NodeJS.ProcessEnv,
|
||||
isTty: boolean,
|
||||
): OutputConfig {
|
||||
const hasNoColorEnv = Object.hasOwn(env, 'NO_COLOR') || env.TERM === 'dumb';
|
||||
const defaultColor = isTty && !hasNoColorEnv;
|
||||
|
||||
const plain = Boolean(opts.plain);
|
||||
const emoji = !plain && (opts.emoji ?? true);
|
||||
const color = !plain && (opts.color ?? true) && defaultColor;
|
||||
|
||||
return { plain, emoji, color };
|
||||
}
|
||||
|
||||
export function statusPrefix(kind: StatusKind, cfg: OutputConfig): string {
|
||||
if (cfg.plain) return `${STATUS[kind].plain} `;
|
||||
if (cfg.emoji) return `${STATUS[kind].emoji} `;
|
||||
return `${STATUS[kind].text} `;
|
||||
}
|
||||
|
||||
export function labelPrefix(kind: LabelKind, cfg: OutputConfig): string {
|
||||
if (cfg.plain) return `${LABELS[kind].plain} `;
|
||||
if (cfg.emoji) return `${LABELS[kind].emoji} `;
|
||||
return `${LABELS[kind].text} `;
|
||||
}
|
||||
|
||||
export function formatStatsLine(
|
||||
stats: { likeCount?: number | null; retweetCount?: number | null; replyCount?: number | null },
|
||||
cfg: OutputConfig,
|
||||
): string {
|
||||
const likeCount = stats.likeCount ?? 0;
|
||||
const retweetCount = stats.retweetCount ?? 0;
|
||||
const replyCount = stats.replyCount ?? 0;
|
||||
|
||||
if (cfg.plain) return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`;
|
||||
if (!cfg.emoji) return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`;
|
||||
return `❤️ ${likeCount} 🔁 ${retweetCount} 💬 ${replyCount}`;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"CreateTweet": "2BA2DIWTRVxHgKc4Xh2uKQ",
|
||||
"CreateRetweet": "Uf3io9zVp1DsYxrmL5FJ7g",
|
||||
"FavoriteTweet": "kuFUYP9eV1FPoEy4N-pi7w",
|
||||
"TweetDetail": "aFvUsJm2c-oDkJV75blV6g",
|
||||
"CreateTweet": "Uf3io9zVp1DsYxrmL5FJ7g",
|
||||
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
|
||||
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
|
||||
"TweetDetail": "97JF30KziU00483E_8elBA",
|
||||
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const DEFAULT_CACHE_FILENAME = 'query-ids-cache.json';
|
||||
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const DISCOVERY_PAGES = [
|
||||
'https://x.com/?lang=en',
|
||||
'https://x.com/explore',
|
||||
'https://x.com/notifications',
|
||||
'https://x.com/settings/profile',
|
||||
];
|
||||
|
||||
const BUNDLE_URL_REGEX = /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g;
|
||||
|
||||
const OPERATION_PATTERNS = [
|
||||
{
|
||||
regex: /e\.exports=\{queryId\s*:\s*["']([^"']+)["']\s*,\s*operationName\s*:\s*["']([^"']+)["']/gs,
|
||||
operationGroup: 2,
|
||||
queryIdGroup: 1,
|
||||
},
|
||||
{
|
||||
regex: /e\.exports=\{operationName\s*:\s*["']([^"']+)["']\s*,\s*queryId\s*:\s*["']([^"']+)["']/gs,
|
||||
operationGroup: 1,
|
||||
queryIdGroup: 2,
|
||||
},
|
||||
{
|
||||
regex: /operationName\s*[:=]\s*["']([^"']+)["'](.{0,4000}?)queryId\s*[:=]\s*["']([^"']+)["']/gs,
|
||||
operationGroup: 1,
|
||||
queryIdGroup: 3,
|
||||
},
|
||||
{
|
||||
regex: /queryId\s*[:=]\s*["']([^"']+)["'](.{0,4000}?)operationName\s*[:=]\s*["']([^"']+)["']/gs,
|
||||
operationGroup: 3,
|
||||
queryIdGroup: 1,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const HEADERS = {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36',
|
||||
Accept: 'text/html,application/json;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
};
|
||||
|
||||
export type RuntimeQueryIdSnapshot = {
|
||||
fetchedAt: string;
|
||||
ttlMs: number;
|
||||
ids: Record<string, string>;
|
||||
discovery: {
|
||||
pages: string[];
|
||||
bundles: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type RuntimeQueryIdSnapshotInfo = {
|
||||
snapshot: RuntimeQueryIdSnapshot;
|
||||
cachePath: string;
|
||||
ageMs: number;
|
||||
isFresh: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeQueryIdsOptions = {
|
||||
cachePath?: string;
|
||||
ttlMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
};
|
||||
|
||||
async function fetchText(fetchImpl: typeof fetch, url: string): Promise<string> {
|
||||
const response = await fetchImpl(url, { headers: HEADERS });
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`HTTP ${response.status} for ${url}: ${body.slice(0, 120)}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function resolveDefaultCachePath(): string {
|
||||
const override = process.env.BIRD_QUERY_IDS_CACHE;
|
||||
if (override && override.trim().length > 0) {
|
||||
return path.resolve(override.trim());
|
||||
}
|
||||
return path.join(homedir(), '.config', 'bird', DEFAULT_CACHE_FILENAME);
|
||||
}
|
||||
|
||||
function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null;
|
||||
const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null;
|
||||
const ids = record.ids && typeof record.ids === 'object' ? (record.ids as Record<string, unknown>) : null;
|
||||
const discovery =
|
||||
record.discovery && typeof record.discovery === 'object' ? (record.discovery as Record<string, unknown>) : null;
|
||||
|
||||
if (!fetchedAt || !ttlMs || !ids || !discovery) return null;
|
||||
const pages = Array.isArray(discovery.pages) ? discovery.pages : null;
|
||||
const bundles = Array.isArray(discovery.bundles) ? discovery.bundles : null;
|
||||
if (!pages || !bundles) return null;
|
||||
|
||||
const normalizedIds: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(ids)) {
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
normalizedIds[key] = value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fetchedAt,
|
||||
ttlMs,
|
||||
ids: normalizedIds,
|
||||
discovery: {
|
||||
pages: pages.filter((p) => typeof p === 'string') as string[],
|
||||
bundles: bundles.filter((b) => typeof b === 'string') as string[],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readSnapshotFromDisk(cachePath: string): Promise<RuntimeQueryIdSnapshot | null> {
|
||||
try {
|
||||
const raw = await readFile(cachePath, 'utf8');
|
||||
return parseSnapshot(JSON.parse(raw));
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? String((error as { code?: unknown }).code ?? '') : '';
|
||||
if (code !== 'ENOENT') return null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSnapshotToDisk(cachePath: string, snapshot: RuntimeQueryIdSnapshot): Promise<void> {
|
||||
await mkdir(path.dirname(cachePath), { recursive: true });
|
||||
await writeFile(cachePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
async function discoverBundles(fetchImpl: typeof fetch): Promise<string[]> {
|
||||
const bundles = new Set<string>();
|
||||
for (const page of DISCOVERY_PAGES) {
|
||||
try {
|
||||
const html = await fetchText(fetchImpl, page);
|
||||
for (const match of html.matchAll(BUNDLE_URL_REGEX)) {
|
||||
bundles.add(match[0]);
|
||||
}
|
||||
} catch {
|
||||
// ignore discovery page failures; other pages often work
|
||||
}
|
||||
}
|
||||
const discovered = [...bundles];
|
||||
if (discovered.length === 0) {
|
||||
throw new Error('No client bundles discovered; x.com layout may have changed.');
|
||||
}
|
||||
return discovered;
|
||||
}
|
||||
|
||||
function extractOperations(
|
||||
bundleContents: string,
|
||||
bundleLabel: string,
|
||||
targets: Set<string>,
|
||||
discovered: Map<string, { queryId: string; bundle: string }>,
|
||||
): void {
|
||||
for (const pattern of OPERATION_PATTERNS) {
|
||||
pattern.regex.lastIndex = 0;
|
||||
while (true) {
|
||||
const match = pattern.regex.exec(bundleContents);
|
||||
if (match === null) break;
|
||||
const operationName = match[pattern.operationGroup];
|
||||
const queryId = match[pattern.queryIdGroup];
|
||||
if (!operationName || !queryId) continue;
|
||||
if (!targets.has(operationName)) continue;
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(queryId)) continue;
|
||||
if (discovered.has(operationName)) continue;
|
||||
discovered.set(operationName, { queryId, bundle: bundleLabel });
|
||||
if (discovered.size === targets.size) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAndExtract(
|
||||
fetchImpl: typeof fetch,
|
||||
bundleUrls: string[],
|
||||
targets: Set<string>,
|
||||
): Promise<Map<string, { queryId: string; bundle: string }>> {
|
||||
const discovered = new Map<string, { queryId: string; bundle: string }>();
|
||||
const CONCURRENCY = 6;
|
||||
|
||||
for (let i = 0; i < bundleUrls.length; i += CONCURRENCY) {
|
||||
const chunk = bundleUrls.slice(i, i + CONCURRENCY);
|
||||
await Promise.all(
|
||||
chunk.map(async (url) => {
|
||||
if (discovered.size === targets.size) return;
|
||||
const label = url.split('/').at(-1) ?? url;
|
||||
try {
|
||||
const js = await fetchText(fetchImpl, url);
|
||||
extractOperations(js, label, targets, discovered);
|
||||
} catch {
|
||||
// ignore failed bundles
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (discovered.size === targets.size) break;
|
||||
}
|
||||
|
||||
return discovered;
|
||||
}
|
||||
|
||||
export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}) {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
||||
const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath();
|
||||
|
||||
let memorySnapshot: RuntimeQueryIdSnapshot | null = null;
|
||||
let loadOnce: Promise<RuntimeQueryIdSnapshot | null> | null = null;
|
||||
let refreshInFlight: Promise<RuntimeQueryIdSnapshotInfo | null> | null = null;
|
||||
|
||||
const loadSnapshot = async (): Promise<RuntimeQueryIdSnapshot | null> => {
|
||||
if (memorySnapshot) return memorySnapshot;
|
||||
if (!loadOnce) {
|
||||
loadOnce = (async () => {
|
||||
const fromDisk = await readSnapshotFromDisk(cachePath);
|
||||
memorySnapshot = fromDisk;
|
||||
return fromDisk;
|
||||
})();
|
||||
}
|
||||
return loadOnce;
|
||||
};
|
||||
|
||||
const getSnapshotInfo = async (): Promise<RuntimeQueryIdSnapshotInfo | null> => {
|
||||
const snapshot = await loadSnapshot();
|
||||
if (!snapshot) return null;
|
||||
const fetchedAtMs = new Date(snapshot.fetchedAt).getTime();
|
||||
const ageMs = Number.isFinite(fetchedAtMs) ? Math.max(0, Date.now() - fetchedAtMs) : Number.POSITIVE_INFINITY;
|
||||
const effectiveTtl = Number.isFinite(snapshot.ttlMs) ? snapshot.ttlMs : ttlMs;
|
||||
const isFresh = ageMs <= effectiveTtl;
|
||||
return { snapshot, cachePath, ageMs, isFresh };
|
||||
};
|
||||
|
||||
const getQueryId = async (operationName: string): Promise<string | null> => {
|
||||
const info = await getSnapshotInfo();
|
||||
if (!info) return null;
|
||||
return info.snapshot.ids[operationName] ?? null;
|
||||
};
|
||||
|
||||
const refresh = async (
|
||||
operationNames: string[],
|
||||
opts: { force?: boolean } = {},
|
||||
): Promise<RuntimeQueryIdSnapshotInfo | null> => {
|
||||
if (refreshInFlight) return refreshInFlight;
|
||||
|
||||
refreshInFlight = (async () => {
|
||||
const current = await getSnapshotInfo();
|
||||
if (!opts.force && current?.isFresh) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const targets = new Set(operationNames);
|
||||
const bundleUrls = await discoverBundles(fetchImpl);
|
||||
const discovered = await fetchAndExtract(fetchImpl, bundleUrls, targets);
|
||||
if (discovered.size === 0) {
|
||||
return current ?? null;
|
||||
}
|
||||
|
||||
const ids: Record<string, string> = {};
|
||||
for (const name of operationNames) {
|
||||
const entry = discovered.get(name);
|
||||
if (entry?.queryId) {
|
||||
ids[name] = entry.queryId;
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot: RuntimeQueryIdSnapshot = {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
ttlMs,
|
||||
ids,
|
||||
discovery: {
|
||||
pages: [...DISCOVERY_PAGES],
|
||||
bundles: bundleUrls.map((url) => url.split('/').at(-1) ?? url),
|
||||
},
|
||||
};
|
||||
|
||||
await writeSnapshotToDisk(cachePath, snapshot);
|
||||
memorySnapshot = snapshot;
|
||||
|
||||
return getSnapshotInfo();
|
||||
})().finally(() => {
|
||||
refreshInFlight = null;
|
||||
});
|
||||
|
||||
return refreshInFlight;
|
||||
};
|
||||
|
||||
return {
|
||||
cachePath,
|
||||
ttlMs,
|
||||
getSnapshotInfo,
|
||||
getQueryId,
|
||||
refresh,
|
||||
clearMemory() {
|
||||
memorySnapshot = null;
|
||||
loadOnce = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const runtimeQueryIds = createRuntimeQueryIdStore();
|
||||
@@ -498,9 +498,12 @@ export class SweetisticsClient {
|
||||
: undefined;
|
||||
const errorMessage =
|
||||
typeof (body as { error?: unknown })?.error === 'string' ? (body as { error: string }).error : undefined;
|
||||
const errorCode =
|
||||
typeof (body as { code?: unknown })?.code === 'string' ? (body as { code: string }).code : undefined;
|
||||
|
||||
if (!response.ok || !mediaId) {
|
||||
return { success: false, error: errorMessage ?? `HTTP ${response.status}` };
|
||||
const details = [errorCode, errorMessage].filter(Boolean).join(': ');
|
||||
return { success: false, error: details.length > 0 ? details : `HTTP ${response.status}` };
|
||||
}
|
||||
return { success: true, mediaId };
|
||||
} catch (error) {
|
||||
|
||||
+349
-51
@@ -4,8 +4,12 @@
|
||||
|
||||
import type { TwitterCookies } from './cookies.js';
|
||||
import queryIds from './query-ids.json' with { type: 'json' };
|
||||
import { runtimeQueryIds } from './runtime-query-ids.js';
|
||||
|
||||
const TWITTER_API_BASE = 'https://x.com/i/api/graphql';
|
||||
const TWITTER_GRAPHQL_POST_URL = 'https://x.com/i/api/graphql';
|
||||
const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/i/media/upload.json';
|
||||
const TWITTER_MEDIA_METADATA_URL = 'https://x.com/i/api/1.1/media/metadata/create.json';
|
||||
|
||||
// Query IDs rotate frequently; the values in query-ids.json are refreshed by
|
||||
// scripts/update-query-ids.ts. The fallback values keep the client usable if
|
||||
@@ -14,7 +18,7 @@ const FALLBACK_QUERY_IDS = {
|
||||
CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg',
|
||||
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
|
||||
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
|
||||
TweetDetail: 'nBS-WpgA6ZG0CyNHD517JQ',
|
||||
TweetDetail: '97JF30KziU00483E_8elBA',
|
||||
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
|
||||
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
|
||||
} as const;
|
||||
@@ -26,9 +30,7 @@ const QUERY_IDS: Record<OperationName, string> = {
|
||||
...(queryIds as Partial<Record<OperationName, string>>),
|
||||
};
|
||||
|
||||
const SEARCH_TIMELINE_QUERY_IDS = Array.from(
|
||||
new Set([QUERY_IDS.SearchTimeline, 'M1jEez78PEfVfbQLvlWMvQ', '5h0kNbk3ii97rmfY6CdgAA', 'Tp1sewRU1AsZpBWhqCZicQ']),
|
||||
);
|
||||
const TARGET_QUERY_ID_OPERATIONS = Object.keys(FALLBACK_QUERY_IDS) as Array<OperationName>;
|
||||
|
||||
type GraphqlTweetResult = {
|
||||
rest_id?: string;
|
||||
@@ -183,6 +185,12 @@ export interface TweetResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UploadMediaResult {
|
||||
success: boolean;
|
||||
mediaId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TweetData {
|
||||
id: string;
|
||||
text: string;
|
||||
@@ -261,6 +269,32 @@ export class TwitterClient {
|
||||
this.timeoutMs = options.timeoutMs;
|
||||
}
|
||||
|
||||
private async getQueryId(operationName: OperationName): Promise<string> {
|
||||
const cached = await runtimeQueryIds.getQueryId(operationName);
|
||||
return cached ?? QUERY_IDS[operationName];
|
||||
}
|
||||
|
||||
private 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
|
||||
}
|
||||
}
|
||||
|
||||
private async getTweetDetailQueryIds(): Promise<string[]> {
|
||||
const primary = await this.getQueryId('TweetDetail');
|
||||
return Array.from(new Set([primary, '97JF30KziU00483E_8elBA', 'aFvUsJm2c-oDkJV75blV6g']));
|
||||
}
|
||||
|
||||
private async getSearchTimelineQueryIds(): Promise<string[]> {
|
||||
const primary = await this.getQueryId('SearchTimeline');
|
||||
return Array.from(new Set([primary, 'M1jEez78PEfVfbQLvlWMvQ', '5h0kNbk3ii97rmfY6CdgAA', 'Tp1sewRU1AsZpBWhqCZicQ']));
|
||||
}
|
||||
|
||||
private async fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
|
||||
if (!this.timeoutMs || this.timeoutMs <= 0) {
|
||||
return fetch(url, init);
|
||||
@@ -306,10 +340,13 @@ export class TwitterClient {
|
||||
}
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
return this.getJsonHeaders();
|
||||
}
|
||||
|
||||
private getBaseHeaders(): Record<string, string> {
|
||||
return {
|
||||
authorization:
|
||||
'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
|
||||
'content-type': 'application/json',
|
||||
'x-csrf-token': this.ct0,
|
||||
'x-twitter-auth-type': 'OAuth2Session',
|
||||
'x-twitter-active-user': 'yes',
|
||||
@@ -321,6 +358,162 @@ export class TwitterClient {
|
||||
};
|
||||
}
|
||||
|
||||
private getJsonHeaders(): Record<string, string> {
|
||||
return {
|
||||
...this.getBaseHeaders(),
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private getUploadHeaders(): Record<string, string> {
|
||||
// Note: do not set content-type; URLSearchParams/FormData need to set it (incl boundary) themselves.
|
||||
return this.getBaseHeaders();
|
||||
}
|
||||
|
||||
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 })}`;
|
||||
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) };
|
||||
}
|
||||
}
|
||||
|
||||
private firstText(...values: Array<string | undefined | null>): string | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string') {
|
||||
@@ -636,6 +829,23 @@ export class TwitterClient {
|
||||
};
|
||||
}
|
||||
|
||||
private buildTweetDetailFeatures(): Record<string, boolean> {
|
||||
return {
|
||||
...this.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,
|
||||
};
|
||||
}
|
||||
|
||||
private buildArticleFieldToggles(): Record<string, boolean> {
|
||||
return {
|
||||
withPayments: false,
|
||||
@@ -716,7 +926,8 @@ export class TwitterClient {
|
||||
fieldToggles: JSON.stringify(this.buildArticleFieldToggles()),
|
||||
});
|
||||
|
||||
const url = `${TWITTER_API_BASE}/${QUERY_IDS.UserArticlesTweets}/UserArticlesTweets?${params}`;
|
||||
const queryId = await this.getQueryId('UserArticlesTweets');
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/UserArticlesTweets?${params}`;
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(url, { method: 'GET', headers: this.getHeaders() });
|
||||
@@ -798,33 +1009,19 @@ export class TwitterClient {
|
||||
};
|
||||
|
||||
const features = {
|
||||
rweb_tipjar_consumption_enabled: true,
|
||||
responsive_web_graphql_exclude_directive_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,
|
||||
communities_web_enable_tweet_community_results_fetch: true,
|
||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||
...this.buildTweetDetailFeatures(),
|
||||
articles_preview_enabled: true,
|
||||
articles_rest_api_enabled: true,
|
||||
responsive_web_edit_tweet_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,
|
||||
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,
|
||||
tweet_awards_web_tipping_enabled: 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,
|
||||
rweb_video_timestamps_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: true,
|
||||
responsive_web_enhance_cards_enabled: false,
|
||||
};
|
||||
|
||||
const params = new URLSearchParams({
|
||||
@@ -832,17 +1029,11 @@ export class TwitterClient {
|
||||
features: JSON.stringify(features),
|
||||
});
|
||||
|
||||
const url = `${TWITTER_API_BASE}/${QUERY_IDS.TweetDetail}/TweetDetail?${params}`;
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
const parseResponse = async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
|
||||
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
@@ -866,10 +1057,58 @@ export class TwitterClient {
|
||||
};
|
||||
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
return { success: false, error: data.errors.map((e) => e.message).join(', ') };
|
||||
return { success: false as const, error: data.errors.map((e) => e.message).join(', ') };
|
||||
}
|
||||
|
||||
return { success: true, data: data.data ?? {} };
|
||||
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}`;
|
||||
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) };
|
||||
}
|
||||
@@ -926,18 +1165,20 @@ export class TwitterClient {
|
||||
/**
|
||||
* Post a new tweet
|
||||
*/
|
||||
async tweet(text: string): Promise<TweetResult> {
|
||||
async tweet(text: string, mediaIds?: string[]): Promise<TweetResult> {
|
||||
const variables = {
|
||||
tweet_text: text,
|
||||
dark_request: false,
|
||||
media: {
|
||||
media_entities: [],
|
||||
media_entities: (mediaIds ?? []).map((id) => ({ media_id: id, tagged_users: [] })),
|
||||
possibly_sensitive: false,
|
||||
},
|
||||
semantic_annotation_ids: [],
|
||||
};
|
||||
|
||||
const features = {
|
||||
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,
|
||||
@@ -978,7 +1219,7 @@ export class TwitterClient {
|
||||
/**
|
||||
* Reply to an existing tweet
|
||||
*/
|
||||
async reply(text: string, replyToTweetId: string): Promise<TweetResult> {
|
||||
async reply(text: string, replyToTweetId: string, mediaIds?: string[]): Promise<TweetResult> {
|
||||
const variables = {
|
||||
tweet_text: text,
|
||||
reply: {
|
||||
@@ -987,13 +1228,15 @@ export class TwitterClient {
|
||||
},
|
||||
dark_request: false,
|
||||
media: {
|
||||
media_entities: [],
|
||||
media_entities: (mediaIds ?? []).map((id) => ({ media_id: id, tagged_users: [] })),
|
||||
possibly_sensitive: false,
|
||||
},
|
||||
semantic_annotation_ids: [],
|
||||
};
|
||||
|
||||
const features = {
|
||||
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,
|
||||
@@ -1035,21 +1278,58 @@ export class TwitterClient {
|
||||
variables: Record<string, unknown>,
|
||||
features: Record<string, boolean>,
|
||||
): Promise<TweetResult> {
|
||||
const url = `${TWITTER_API_BASE}/${QUERY_IDS.CreateTweet}/CreateTweet`;
|
||||
let queryId = await this.getQueryId('CreateTweet');
|
||||
let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/CreateTweet`;
|
||||
|
||||
const body = JSON.stringify({
|
||||
variables,
|
||||
features,
|
||||
queryId: QUERY_IDS.CreateTweet,
|
||||
});
|
||||
const buildBody = () => JSON.stringify({ variables, features, queryId });
|
||||
let body = buildBody();
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
let response = await this.fetchWithTimeout(urlWithOperation, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
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(),
|
||||
body,
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
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) {
|
||||
return { success: false, error: data.errors.map((e) => e.message).join(', ') };
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -1100,12 +1380,16 @@ export class TwitterClient {
|
||||
|
||||
const features = this.buildSearchFeatures();
|
||||
|
||||
let lastError: string | undefined;
|
||||
|
||||
for (const queryId of SEARCH_TIMELINE_QUERY_IDS) {
|
||||
const params = new URLSearchParams({
|
||||
variables: JSON.stringify(variables),
|
||||
});
|
||||
|
||||
const tryOnce = async () => {
|
||||
let lastError: string | undefined;
|
||||
let had404 = false;
|
||||
const queryIds = await this.getSearchTimelineQueryIds();
|
||||
|
||||
for (const queryId of queryIds) {
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params}`;
|
||||
|
||||
try {
|
||||
@@ -1116,13 +1400,14 @@ export class TwitterClient {
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
had404 = true;
|
||||
lastError = `HTTP ${response.status}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
|
||||
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
@@ -1169,19 +1454,32 @@ export class TwitterClient {
|
||||
};
|
||||
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
return { success: false, error: data.errors.map((e) => e.message).join(', ') };
|
||||
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 tweets = this.parseTweetsFromInstructions(instructions);
|
||||
|
||||
return { success: true, tweets };
|
||||
return { success: true as const, tweets, had404 };
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: lastError ?? 'Unknown error fetching search results' };
|
||||
return { success: false as const, error: lastError ?? 'Unknown error fetching search results', 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
declare const __dirname: string | undefined;
|
||||
|
||||
export const FALLBACK_VERSION = 'unknown';
|
||||
|
||||
function readPackageVersionFromJsonFile(candidate: string): string | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(candidate, 'utf8');
|
||||
const json = JSON.parse(raw) as { version?: unknown } | null;
|
||||
if (json && typeof json.version === 'string' && json.version.trim().length > 0) {
|
||||
return json.version.trim();
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readVersionFromTextFile(candidate: string): string | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(candidate, 'utf8').trim();
|
||||
return raw.length > 0 ? raw : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStartDir(importMetaUrl?: string): string {
|
||||
if (typeof importMetaUrl === 'string' && importMetaUrl.trim().length > 0) {
|
||||
try {
|
||||
return path.dirname(fileURLToPath(importMetaUrl));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof __dirname === 'string' && __dirname.length > 0) return __dirname;
|
||||
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
export function resolvePackageVersion(importMetaUrl?: string): string {
|
||||
const injected =
|
||||
typeof process !== 'undefined' && typeof process.env.BIRD_VERSION === 'string'
|
||||
? process.env.BIRD_VERSION.trim()
|
||||
: '';
|
||||
if (injected.length > 0) return injected;
|
||||
|
||||
let dir = resolveStartDir(importMetaUrl);
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const version =
|
||||
readPackageVersionFromJsonFile(path.join(dir, 'package.json')) ??
|
||||
readVersionFromTextFile(path.join(dir, 'VERSION'));
|
||||
if (version) return version;
|
||||
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
return FALLBACK_VERSION;
|
||||
}
|
||||
|
||||
function truncateSha(sha: string, length = 8): string {
|
||||
const trimmed = sha.trim();
|
||||
if (!trimmed) return '';
|
||||
if (trimmed.length <= length) return trimmed;
|
||||
return trimmed.slice(0, length);
|
||||
}
|
||||
|
||||
function resolveGitShaFromGitDir(gitDir: string): string | null {
|
||||
const headPath = path.join(gitDir, 'HEAD');
|
||||
let head = '';
|
||||
try {
|
||||
head = fs.readFileSync(headPath, 'utf8').trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!head) return null;
|
||||
if (!head.startsWith('ref:')) {
|
||||
const sha = truncateSha(head);
|
||||
return sha.length > 0 ? sha : null;
|
||||
}
|
||||
|
||||
const ref = head.replace(/^ref:\s*/i, '').trim();
|
||||
if (!ref) return null;
|
||||
|
||||
const refPath = path.join(gitDir, ref);
|
||||
try {
|
||||
const sha = truncateSha(fs.readFileSync(refPath, 'utf8'));
|
||||
return sha.length > 0 ? sha : null;
|
||||
} catch {
|
||||
// fall through to packed-refs
|
||||
}
|
||||
|
||||
const packedRefsPath = path.join(gitDir, 'packed-refs');
|
||||
try {
|
||||
const packed = fs.readFileSync(packedRefsPath, 'utf8');
|
||||
const lines = packed.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
if (!line || line.startsWith('#') || line.startsWith('^')) continue;
|
||||
const [shaRaw, refName] = line.split(' ');
|
||||
if (refName?.trim() === ref) {
|
||||
const sha = truncateSha(shaRaw ?? '');
|
||||
return sha.length > 0 ? sha : null;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveGitSha(importMetaUrl?: string): string | null {
|
||||
const injected =
|
||||
typeof process !== 'undefined' && typeof process.env.BIRD_GIT_SHA === 'string'
|
||||
? process.env.BIRD_GIT_SHA.trim()
|
||||
: '';
|
||||
if (injected.length > 0) return truncateSha(injected);
|
||||
|
||||
let dir = resolveStartDir(importMetaUrl);
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const dotGit = path.join(dir, '.git');
|
||||
try {
|
||||
const stat = fs.statSync(dotGit);
|
||||
if (stat.isDirectory()) {
|
||||
const sha = resolveGitShaFromGitDir(dotGit);
|
||||
if (sha) return sha;
|
||||
} else if (stat.isFile()) {
|
||||
const txt = fs.readFileSync(dotGit, 'utf8');
|
||||
const match = txt.match(/gitdir:\s*(.+)\s*$/i);
|
||||
const gitDir = match?.[1]?.trim();
|
||||
if (gitDir) {
|
||||
const resolved = path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir);
|
||||
const sha = resolveGitShaFromGitDir(resolved);
|
||||
if (sha) return sha;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatVersionLine(importMetaUrl?: string): string {
|
||||
const version = resolvePackageVersion(importMetaUrl);
|
||||
const sha = resolveGitSha(importMetaUrl);
|
||||
return sha ? `${version} (${sha})` : version;
|
||||
}
|
||||
|
||||
export function getCliVersion(): string {
|
||||
return formatVersionLine(import.meta.url);
|
||||
}
|
||||
+13
-1
@@ -2,7 +2,19 @@ import { describe, expect, it } from 'vitest';
|
||||
import { looksLikeTweetInput, resolveCliInvocation } from '../src/lib/cli-args.js';
|
||||
|
||||
describe('cli-args', () => {
|
||||
const known = new Set(['tweet', 'reply', 'read', 'replies', 'thread', 'search', 'mentions', 'whoami', 'check']);
|
||||
const known = new Set([
|
||||
'tweet',
|
||||
'reply',
|
||||
'query-ids',
|
||||
'read',
|
||||
'replies',
|
||||
'thread',
|
||||
'search',
|
||||
'mentions',
|
||||
'help',
|
||||
'whoami',
|
||||
'check',
|
||||
]);
|
||||
|
||||
it('detects tweet URLs', () => {
|
||||
expect(looksLikeTweetInput('https://x.com/user/status/1234567890')).toBe(true);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveEngineMode, shouldAllowSweetisticsFallback, shouldUseSweetistics } from '../src/lib/engine.js';
|
||||
|
||||
describe('engine', () => {
|
||||
it('normalizes engine values and defaults to auto', () => {
|
||||
expect(resolveEngineMode()).toBe('auto');
|
||||
expect(resolveEngineMode('graphql')).toBe('graphql');
|
||||
expect(resolveEngineMode('sweetistics')).toBe('sweetistics');
|
||||
expect(resolveEngineMode('AUTO')).toBe('auto');
|
||||
expect(resolveEngineMode('nope')).toBe('auto');
|
||||
});
|
||||
|
||||
it('uses GraphQL as primary in auto mode', () => {
|
||||
expect(shouldUseSweetistics('auto', false)).toBe(false);
|
||||
expect(shouldUseSweetistics('auto', true)).toBe(false);
|
||||
});
|
||||
|
||||
it('forces engine selection when explicitly set', () => {
|
||||
expect(shouldUseSweetistics('sweetistics', false)).toBe(true);
|
||||
expect(shouldUseSweetistics('graphql', true)).toBe(false);
|
||||
});
|
||||
|
||||
it('only allows Sweetistics fallback in auto mode', () => {
|
||||
expect(shouldAllowSweetisticsFallback('auto', true)).toBe(true);
|
||||
expect(shouldAllowSweetisticsFallback('auto', false)).toBe(false);
|
||||
expect(shouldAllowSweetisticsFallback('graphql', true)).toBe(false);
|
||||
expect(shouldAllowSweetisticsFallback('sweetistics', true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mentionsQueryFromUserOption, normalizeHandle } from '../src/lib/normalize-handle.js';
|
||||
|
||||
describe('normalizeHandle', () => {
|
||||
it('accepts bare handle', () => {
|
||||
expect(normalizeHandle('steipete')).toBe('steipete');
|
||||
});
|
||||
|
||||
it('accepts @handle and trims whitespace', () => {
|
||||
expect(normalizeHandle(' @steipete ')).toBe('steipete');
|
||||
});
|
||||
|
||||
it('rejects empty input', () => {
|
||||
expect(normalizeHandle('')).toBeNull();
|
||||
expect(normalizeHandle(' ')).toBeNull();
|
||||
expect(normalizeHandle(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects invalid characters and too-long handles', () => {
|
||||
expect(normalizeHandle('@stei-pete')).toBeNull();
|
||||
expect(normalizeHandle('@steipete!')).toBeNull();
|
||||
expect(normalizeHandle('a'.repeat(16))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mentionsQueryFromUserOption', () => {
|
||||
it('returns null query when option omitted', () => {
|
||||
expect(mentionsQueryFromUserOption(undefined)).toEqual({ query: null, error: null });
|
||||
});
|
||||
|
||||
it('returns normalized @query for valid handle', () => {
|
||||
expect(mentionsQueryFromUserOption('@steipete')).toEqual({ query: '@steipete', error: null });
|
||||
expect(mentionsQueryFromUserOption(' steipete ')).toEqual({ query: '@steipete', error: null });
|
||||
});
|
||||
|
||||
it('returns error for invalid handle', () => {
|
||||
const result = mentionsQueryFromUserOption('@stei-pete');
|
||||
expect(result.query).toBeNull();
|
||||
expect(result.error).toMatch(/Invalid --user handle/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { labelPrefix, resolveOutputConfigFromArgv, statusPrefix } from '../src/lib/output.js';
|
||||
|
||||
describe('output', () => {
|
||||
it('plain disables emoji + color', () => {
|
||||
const cfg = resolveOutputConfigFromArgv(['--plain'], {}, true);
|
||||
expect(cfg).toEqual({ plain: true, emoji: false, color: false });
|
||||
expect(statusPrefix('ok', cfg)).toBe('[ok] ');
|
||||
expect(labelPrefix('url', cfg)).toBe('url: ');
|
||||
});
|
||||
|
||||
it('NO_COLOR disables colors by default', () => {
|
||||
const cfg = resolveOutputConfigFromArgv([], { NO_COLOR: '1' }, true);
|
||||
expect(cfg.color).toBe(false);
|
||||
});
|
||||
|
||||
it('--no-emoji switches to text prefixes', () => {
|
||||
const cfg = resolveOutputConfigFromArgv(['--no-emoji'], {}, true);
|
||||
expect(cfg.emoji).toBe(false);
|
||||
expect(statusPrefix('warn', cfg)).toBe('Warning: ');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createRuntimeQueryIdStore } from '../src/lib/runtime-query-ids.js';
|
||||
|
||||
describe('runtime-query-ids', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('refreshes IDs by scanning discovered bundles and persists cache', async () => {
|
||||
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
const cachePath = path.join(cacheDir, 'query-ids.json');
|
||||
|
||||
const html = `
|
||||
<html>
|
||||
<script src="https://abs.twimg.com/responsive-web/client-web/main.test.js"></script>
|
||||
</html>
|
||||
`;
|
||||
const js = [
|
||||
`e.exports={queryId:"AAA",operationName:"CreateTweet"}`,
|
||||
`e.exports={queryId:"BBB",operationName:"CreateRetweet"}`,
|
||||
`e.exports={queryId:"CCC",operationName:"FavoriteTweet"}`,
|
||||
`e.exports={queryId:"DDD",operationName:"TweetDetail"}`,
|
||||
`e.exports={queryId:"EEE",operationName:"SearchTimeline"}`,
|
||||
`e.exports={queryId:"FFF",operationName:"UserArticlesTweets"}`,
|
||||
].join('\n');
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const store = createRuntimeQueryIdStore({
|
||||
cachePath,
|
||||
ttlMs: 1000,
|
||||
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||
});
|
||||
const info = await store.refresh(
|
||||
['CreateTweet', 'CreateRetweet', 'FavoriteTweet', 'TweetDetail', 'SearchTimeline', 'UserArticlesTweets'],
|
||||
{ force: true },
|
||||
);
|
||||
|
||||
expect(info?.snapshot.ids.CreateTweet).toBe('AAA');
|
||||
expect(info?.snapshot.ids.TweetDetail).toBe('DDD');
|
||||
expect(info?.cachePath).toBe(cachePath);
|
||||
|
||||
const raw = await readFile(cachePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as { ids?: Record<string, string> };
|
||||
expect(parsed.ids?.SearchTimeline).toBe('EEE');
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,79 @@ describe('TwitterClient', () => {
|
||||
|
||||
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 () => {
|
||||
@@ -144,6 +217,100 @@ describe('TwitterClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reply', () => {
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -178,6 +345,8 @@ describe('TwitterClient', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -283,7 +452,7 @@ describe('TwitterClient', () => {
|
||||
});
|
||||
|
||||
it('should report HTTP errors from getTweet', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => 'Not Found',
|
||||
@@ -474,6 +643,38 @@ describe('TwitterClient', () => {
|
||||
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('getCurrentUser', () => {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { formatVersionLine, resolveGitSha, resolvePackageVersion } from '../src/lib/version.js';
|
||||
|
||||
describe('getCliVersion', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.BIRD_VERSION;
|
||||
delete process.env.BIRD_GIT_SHA;
|
||||
});
|
||||
|
||||
it('reads package.json version when available', () => {
|
||||
const raw = readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8');
|
||||
const pkg = JSON.parse(raw) as { version?: unknown };
|
||||
expect(resolvePackageVersion()).toBe(pkg.version);
|
||||
});
|
||||
|
||||
it('formats injected version + sha', () => {
|
||||
process.env.BIRD_VERSION = '9.9.9';
|
||||
process.env.BIRD_GIT_SHA = 'abcdef123456';
|
||||
expect(formatVersionLine('not a url')).toBe('9.9.9 (abcdef12)');
|
||||
});
|
||||
|
||||
it('adds git sha when available', () => {
|
||||
const version = resolvePackageVersion();
|
||||
const sha = resolveGitSha();
|
||||
expect(sha).toMatch(/^[0-9a-f]{8}$/);
|
||||
expect(formatVersionLine()).toBe(`${version} (${sha})`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user