18 Commits
Author SHA1 Message Date
Peter Steinberger 9f38efc9bf chore(release): 0.2.0 2025-12-26 16:27:00 +01:00
Peter Steinberger 0bf7190c95 feat(graphql): support video uploads 2025-12-26 15:27:35 +01:00
Peter Steinberger 3be2e3ae7e feat(graphql): upload media 2025-12-26 15:22:53 +01:00
Peter Steinberger dee304235b fix(engine): disable Sweetistics fallback when forcing graphql 2025-12-26 15:10:10 +01:00
Peter Steinberger 9a4465f05d docs(changelog): mention runtime query-id cache 2025-12-26 15:03:48 +01:00
Peter Steinberger 978d07f60f feat(graphql): runtime query-id refresh 2025-12-26 14:58:03 +01:00
Peter Steinberger 472d666eee docs: document output flags + help 2025-12-26 14:26:29 +01:00
Peter Steinberger c35e937b2a feat(cli): add plain/no-emoji/no-color + help 2025-12-26 14:14:31 +01:00
Peter Steinberger 4730267c2a fix(sweetistics): improve media upload errors 2025-12-26 13:10:54 +01:00
Peter Steinberger 8257d52532 fix(twitter): improve createTweet posting 2025-12-26 13:10:49 +01:00
Peter Steinberger 80a57c825f fix(graphql): keep query ids fresh 2025-12-26 13:10:43 +01:00
Peter Steinberger 88651f669d chore: bump version to 0.2.0 2025-12-26 12:41:48 +01:00
Peter Steinberger 6669ba9912 test(cli): validate mentions handle 2025-12-26 12:34:16 +01:00
Peter Steinberger 3df7969b16 feat(cli): mentions target user 2025-12-26 12:23:00 +01:00
Peter Steinberger 5613caa149 feat(cli): include git sha in --version 2025-12-26 12:12:16 +01:00
Peter Steinberger b4e1ddbf2e docs(changelog): note version lookup 2025-12-26 12:06:31 +01:00
Peter Steinberger 9222908ab7 fix(cli): read version from file 2025-12-26 12:05:52 +01:00
Peter Steinberger 674fee3b8a docs(changelog): add 0.1.2 unreleased 2025-12-26 10:36:39 +01:00
21 changed files with 1734 additions and 267 deletions
+17
View File
@@ -1,5 +1,22 @@
# Changelog # 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 ## 0.1.1 — 2025-12-26
### Changed ### Changed
+40 -3
View File
@@ -16,6 +16,9 @@ pnpm add -g @steipete/bird
# Show the logged-in account # Show the logged-in account
bird whoami bird whoami
# Discover command help
bird help whoami
# Read a tweet (URL or ID) # Read a tweet (URL or ID)
bird read https://x.com/user/status/1234567890123456789 bird read https://x.com/user/status/1234567890123456789
bird 1234567890123456789 --json bird 1234567890123456789 --json
@@ -27,18 +30,24 @@ bird replies 1234567890123456789
# Search + mentions # Search + mentions
bird search "from:steipete" -n 5 bird search "from:steipete" -n 5
bird mentions -n 5 bird mentions -n 5
bird mentions --user @steipete -n 5
# Refresh GraphQL query IDs cache (no rebuild)
bird query-ids --fresh
``` ```
## Commands ## Commands
- `bird tweet "<text>"` — post a new tweet. - `bird tweet "<text>"` — post a new tweet.
- `bird reply <tweet-id-or-url> "<text>"` — reply to a tweet using its ID or URL. - `bird reply <tweet-id-or-url> "<text>"` — reply to a tweet using its ID or URL.
- `bird help [command]` — show help (or help for a subcommand).
- `bird 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 read <tweet-id-or-url> [--json]` — fetch tweet content as text or JSON.
- `bird <tweet-id-or-url> [--json]` — shorthand for `read` when only a URL or ID is provided. - `bird <tweet-id-or-url> [--json]` — shorthand for `read` when only a URL or ID is provided.
- `bird replies <tweet-id-or-url> [--json]` — list replies to a tweet. - `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 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] [--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 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.
@@ -48,8 +57,13 @@ bird mentions -n 5
- `--engine graphql` — use Twitter/X GraphQL with cookies (Chrome/Firefox/env/flags). - `--engine graphql` — use Twitter/X GraphQL with cookies (Chrome/Firefox/env/flags).
- `--engine sweetistics` — use Sweetistics API key (no browser cookies needed). - `--engine sweetistics` — use Sweetistics API key (no browser cookies needed).
Note: fallback to Sweetistics only happens in `--engine auto`. `--engine graphql` is strict (no fallback).
Global options: Global options:
- `--timeout <ms>`: abort requests after the given timeout (milliseconds). - `--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) ## Authentication (GraphQL)
@@ -91,11 +105,33 @@ Environment shortcuts:
- `--json` prints raw tweet objects for read/replies/thread/search/mentions. - `--json` prints raw tweet objects for read/replies/thread/search/mentions.
- `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).
## 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. - 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: Example:
@@ -113,6 +149,7 @@ pnpm run build:dist # dist/ only
pnpm run build:binary pnpm run build:binary
pnpm run dev tweet "Test" pnpm run dev tweet "Test"
pnpm run dev -- --plain check
pnpm test pnpm test
pnpm run lint pnpm run lint
``` ```
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@steipete/bird", "name": "@steipete/bird",
"version": "0.1.1", "version": "0.2.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",
@@ -15,8 +15,8 @@
], ],
"scripts": { "scripts": {
"build": "pnpm run build:dist && pnpm run build:binary", "build": "pnpm run build:dist && pnpm run build:binary",
"build:dist": "tsc", "build:dist": "tsc && node scripts/copy-dist-assets.js",
"build:binary": "bun build --compile --minify 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/index.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",
+16
View File
@@ -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));
}
+12
View File
@@ -28,6 +28,18 @@ const BUNDLE_URL_REGEX =
/https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g; /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g;
const OPERATION_PATTERNS = [ 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, regex: /operationName\s*[:=]\s*["']([^"']+)["'](.{0,4000}?)queryId\s*[:=]\s*["']([^"']+)["']/gs,
operationGroup: 1, operationGroup: 1,
+346 -155
View File
@@ -17,20 +17,58 @@ import JSON5 from 'json5';
import kleur from 'kleur'; import kleur from 'kleur';
import { resolveCliInvocation } from './lib/cli-args.js'; import { resolveCliInvocation } from './lib/cli-args.js';
import { resolveCredentials } from './lib/cookies.js'; import { resolveCredentials } from './lib/cookies.js';
import { type EngineMode, resolveEngineMode, shouldUseSweetistics } from './lib/engine.js'; import {
type EngineMode,
resolveEngineMode,
shouldAllowSweetisticsFallback,
shouldUseSweetistics,
} from './lib/engine.js';
import { extractTweetId } from './lib/extract-tweet-id.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 { SweetisticsClient } from './lib/sweetistics-client.js'; import { SweetisticsClient } from './lib/sweetistics-client.js';
import { type TweetData, TwitterClient } from './lib/twitter-client.js'; import { type TweetData, TwitterClient } from './lib/twitter-client.js';
import { getCliVersion } from './lib/version.js';
const program = new Command(); const program = new Command();
const rawArgs = process.argv.slice(2);
const normalizedArgs = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs;
const isTty = process.stdout.isTTY; 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 wrap = (styler: (text: string) => string) => (text: string) => (isTty ? styler(text) : text);
const collect = (value: string, previous: string[] = []) => { const collect = (value: string, previous: string[] = []) => {
previous.push(value); previous.push(value);
return previous; return previous;
}; };
const p = (kind: Parameters<typeof statusPrefix>[0]) => statusPrefix(kind, output);
const l = (kind: Parameters<typeof labelPrefix>[0]) => labelPrefix(kind, output);
function requireSweetisticsApiKey(apiKey: string | null): string {
if (!apiKey) {
console.error(`${p('err')}Sweetistics API key missing. Set SWEETISTICS_API_KEY or pass --sweetistics-api-key.`);
process.exit(1);
}
return apiKey;
}
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 = { const colors = {
banner: wrap((t) => kleur.bold().blue(t)), banner: wrap((t) => kleur.bold().blue(t)),
subtitle: wrap((t) => kleur.dim(t)), subtitle: wrap((t) => kleur.dim(t)),
@@ -63,7 +101,9 @@ function readConfigFile(path: string): Partial<BirdConfig> {
return parsed ?? {}; return parsed ?? {};
} catch (error) { } catch (error) {
console.error( console.error(
colors.muted(`⚠️ Failed to parse config at ${path}: ${error instanceof Error ? error.message : String(error)}`), colors.muted(
`${p('warn')}Failed to parse config at ${path}: ${error instanceof Error ? error.message : String(error)}`,
),
); );
return {}; return {};
} }
@@ -84,11 +124,13 @@ const config = loadConfig();
const KNOWN_COMMANDS = new Set([ const KNOWN_COMMANDS = new Set([
'tweet', 'tweet',
'reply', 'reply',
'query-ids',
'read', 'read',
'replies', 'replies',
'thread', 'thread',
'search', 'search',
'mentions', 'mentions',
'help',
'whoami', 'whoami',
'check', 'check',
]); ]);
@@ -98,7 +140,7 @@ program.addHelpText(
() => `${colors.banner('bird CLI')} ${colors.subtitle('— fast X CLI for tweeting, replying, and reading')}`, () => `${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('0.1.0'); program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion());
const formatExample = (command: string, description: string) => const formatExample = (command: string, description: string) =>
`${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`; `${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`;
@@ -133,7 +175,14 @@ program
'Engine: graphql | sweetistics | auto', 'Engine: graphql | sweetistics | auto',
process.env.BIRD_ENGINE || config.engine || 'auto', process.env.BIRD_ENGINE || config.engine || 'auto',
) )
.option('--timeout <ms>', 'Request timeout in milliseconds'); .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 }; type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer };
@@ -205,15 +254,91 @@ function printTweets(
console.log(`\n@${tweet.author.username} (${tweet.author.name}):`); console.log(`\n@${tweet.author.username} (${tweet.author.name}):`);
console.log(tweet.text); console.log(tweet.text);
if (tweet.createdAt) { if (tweet.createdAt) {
console.log(`📅 ${tweet.createdAt}`); console.log(`${l('date')}${tweet.createdAt}`);
} }
console.log(`🔗 https://x.com/${tweet.author.username}/status/${tweet.id}`); console.log(`${l('url')}https://x.com/${tweet.author.username}/status/${tweet.id}`);
if (opts.showSeparator ?? true) { if (opts.showSeparator ?? true) {
console.log('─'.repeat(50)); 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 // Tweet command
program program
.command('tweet') .command('tweet')
@@ -226,7 +351,7 @@ program
try { try {
media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] }); media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
} catch (error) { } catch (error) {
console.error(`❌ ${error instanceof Error ? error.message : String(error)}`); console.error(`${p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1); process.exit(1);
} }
const sweetistics = resolveSweetisticsConfig({ const sweetistics = resolveSweetisticsConfig({
@@ -238,7 +363,7 @@ program
if (useSweetistics) { if (useSweetistics) {
if (!sweetistics.apiKey) { if (!sweetistics.apiKey) {
console.error('❌ Sweetistics engine selected but no API key provided.'); console.error(`${p('err')}Sweetistics engine selected but no API key provided.`);
process.exit(1); process.exit(1);
} }
try { try {
@@ -265,27 +390,23 @@ program
} }
const result = await client.tweet(text, undefined, mediaIds); const result = await client.tweet(text, undefined, mediaIds);
if (result.success) { if (result.success) {
console.log('✅ Tweet posted via Sweetistics!'); console.log(`${p('ok')}Tweet posted via Sweetistics!`);
if (result.tweetId) { if (result.tweetId) {
console.log(`🔗 https://x.com/i/status/${result.tweetId}`); console.log(`${l('url')}https://x.com/i/status/${result.tweetId}`);
} }
return; return;
} }
console.error(`❌ Sweetistics post failed: ${result.error ?? 'Unknown error'}`); console.error(`${p('err')}Sweetistics post failed: ${result.error ?? 'Unknown error'}`);
process.exit(1); process.exit(1);
} catch (error) { } catch (error) {
console.error(`❌ Sweetistics error: ${error instanceof Error ? error.message : String(error)}`); console.error(`${p('err')}Sweetistics error: ${error instanceof Error ? error.message : String(error)}`);
if (error instanceof Error && /(^|\\b)INTERNAL_ERROR(\\b|$)|An unexpected error occurred/.test(error.message)) {
console.error(`${p('hint')}Sweetistics media upload likely needs Twitter OAuth with \`media.write\` scope.`);
}
process.exit(1); process.exit(1);
} }
} }
if (media.length > 0) {
console.error(
'❌ Media uploads are only supported via Sweetistics. Provide SWEETISTICS_API_KEY or --engine sweetistics.',
);
process.exit(1);
}
const { cookies, warnings } = await resolveCredentials({ const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken, authToken: opts.authToken,
ct0: opts.ct0, ct0: opts.ct0,
@@ -296,42 +417,56 @@ program
}); });
for (const warning of warnings) { for (const warning of warnings) {
console.error(`⚠️ ${warning}`); console.error(`${p('warn')}${warning}`);
} }
if (!cookies.authToken || !cookies.ct0) { if (!cookies.authToken || !cookies.ct0) {
console.error('❌ Missing required credentials'); console.error(`${p('err')}Missing required credentials`);
process.exit(1); process.exit(1);
} }
if (cookies.source) { if (cookies.source) {
console.error(`📍 Using credentials from: ${cookies.source}`); console.error(`${l('source')}${cookies.source}`);
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs });
const result = await client.tweet(text); 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) { if (result.success) {
console.log('✅ Tweet posted successfully!'); console.log(`${p('ok')}Tweet posted successfully!`);
console.log(`🔗 https://x.com/i/status/${result.tweetId}`); console.log(`${l('url')}https://x.com/i/status/${result.tweetId}`);
} else if (sweetistics.apiKey) { } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
console.error(`⚠️ GraphQL tweet failed (${result.error}); trying Sweetistics fallback...`); console.error(`${p('warn')}GraphQL tweet failed (${result.error}); trying Sweetistics fallback...`);
const fallback = await new SweetisticsClient({ const fallback = await new SweetisticsClient({
baseUrl: sweetistics.baseUrl, baseUrl: sweetistics.baseUrl,
apiKey: sweetistics.apiKey, apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
timeoutMs, timeoutMs,
}).tweet(text); }).tweet(text);
if (fallback.success) { if (fallback.success) {
console.log('✅ Tweet posted via Sweetistics (fallback)!'); console.log(`${p('ok')}Tweet posted via Sweetistics (fallback)!`);
if (fallback.tweetId) { if (fallback.tweetId) {
console.log(`🔗 https://x.com/i/status/${fallback.tweetId}`); console.log(`${l('url')}https://x.com/i/status/${fallback.tweetId}`);
} }
} else { } else {
console.error(`❌ Failed to post tweet: ${result.error} | Sweetistics fallback: ${fallback.error}`); console.error(`${p('err')}Failed to post tweet: ${result.error} | Sweetistics fallback: ${fallback.error}`);
process.exit(1); process.exit(1);
} }
} else { } else {
console.error(`❌ Failed to post tweet: ${result.error}`); console.error(`${p('err')}Failed to post tweet: ${result.error}`);
process.exit(1); process.exit(1);
} }
}); });
@@ -349,7 +484,7 @@ program
try { try {
media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] }); media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
} catch (error) { } catch (error) {
console.error(`❌ ${error instanceof Error ? error.message : String(error)}`); console.error(`${p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1); process.exit(1);
} }
const sweetistics = resolveSweetisticsConfig({ const sweetistics = resolveSweetisticsConfig({
@@ -362,7 +497,7 @@ program
if (useSweetistics) { if (useSweetistics) {
if (!sweetistics.apiKey) { if (!sweetistics.apiKey) {
console.error('❌ Sweetistics engine selected but no API key provided.'); console.error(`${p('err')}Sweetistics engine selected but no API key provided.`);
process.exit(1); process.exit(1);
} }
try { try {
@@ -389,27 +524,23 @@ program
} }
const result = await client.tweet(text, tweetId, mediaIds); const result = await client.tweet(text, tweetId, mediaIds);
if (result.success) { if (result.success) {
console.log('✅ Reply posted via Sweetistics!'); console.log(`${p('ok')}Reply posted via Sweetistics!`);
if (result.tweetId) { if (result.tweetId) {
console.log(`🔗 https://x.com/i/status/${result.tweetId}`); console.log(`${l('url')}https://x.com/i/status/${result.tweetId}`);
} }
return; return;
} }
console.error(`❌ Sweetistics reply failed: ${result.error ?? 'Unknown error'}`); console.error(`${p('err')}Sweetistics reply failed: ${result.error ?? 'Unknown error'}`);
process.exit(1); process.exit(1);
} catch (error) { } catch (error) {
console.error(`❌ Sweetistics error: ${error instanceof Error ? error.message : String(error)}`); console.error(`${p('err')}Sweetistics error: ${error instanceof Error ? error.message : String(error)}`);
if (error instanceof Error && /(^|\\b)INTERNAL_ERROR(\\b|$)|An unexpected error occurred/.test(error.message)) {
console.error(`${p('hint')}Sweetistics media upload likely needs Twitter OAuth with \`media.write\` scope.`);
}
process.exit(1); process.exit(1);
} }
} }
if (media.length > 0) {
console.error(
'❌ Media uploads are only supported via Sweetistics. Provide SWEETISTICS_API_KEY or --engine sweetistics.',
);
process.exit(1);
}
const { cookies, warnings } = await resolveCredentials({ const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken, authToken: opts.authToken,
ct0: opts.ct0, ct0: opts.ct0,
@@ -420,44 +551,58 @@ program
}); });
for (const warning of warnings) { for (const warning of warnings) {
console.error(`⚠️ ${warning}`); console.error(`${p('warn')}${warning}`);
} }
if (!cookies.authToken || !cookies.ct0) { if (!cookies.authToken || !cookies.ct0) {
console.error('❌ Missing required credentials'); console.error(`${p('err')}Missing required credentials`);
process.exit(1); process.exit(1);
} }
if (cookies.source) { if (cookies.source) {
console.error(`📍 Using credentials from: ${cookies.source}`); console.error(`${l('source')}${cookies.source}`);
} }
console.error(`📝 Replying to tweet: ${tweetId}`); console.error(`${p('info')}Replying to tweet: ${tweetId}`);
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs });
const result = await client.reply(text, tweetId); 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) { if (result.success) {
console.log('✅ Reply posted successfully!'); console.log(`${p('ok')}Reply posted successfully!`);
console.log(`🔗 https://x.com/i/status/${result.tweetId}`); console.log(`${l('url')}https://x.com/i/status/${result.tweetId}`);
} else if (sweetistics.apiKey) { } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
console.error(`⚠️ GraphQL reply failed (${result.error}); trying Sweetistics fallback...`); console.error(`${p('warn')}GraphQL reply failed (${result.error}); trying Sweetistics fallback...`);
const fallback = await new SweetisticsClient({ const fallback = await new SweetisticsClient({
baseUrl: sweetistics.baseUrl, baseUrl: sweetistics.baseUrl,
apiKey: sweetistics.apiKey, apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
timeoutMs, timeoutMs,
}).tweet(text, tweetId); }).tweet(text, tweetId);
if (fallback.success) { if (fallback.success) {
console.log('✅ Reply posted via Sweetistics (fallback)!'); console.log(`${p('ok')}Reply posted via Sweetistics (fallback)!`);
if (fallback.tweetId) { if (fallback.tweetId) {
console.log(`🔗 https://x.com/i/status/${fallback.tweetId}`); console.log(`${l('url')}https://x.com/i/status/${fallback.tweetId}`);
} }
} else { } else {
console.error(`❌ Failed to post reply: ${result.error} | Sweetistics fallback: ${fallback.error}`); console.error(`${p('err')}Failed to post reply: ${result.error} | Sweetistics fallback: ${fallback.error}`);
process.exit(1); process.exit(1);
} }
} else { } else {
console.error(`❌ Failed to post reply: ${result.error}`); console.error(`${p('err')}Failed to post reply: ${result.error}`);
process.exit(1); process.exit(1);
} }
}); });
@@ -481,7 +626,7 @@ program
const tweetId = extractTweetId(tweetIdOrUrl); const tweetId = extractTweetId(tweetIdOrUrl);
if (useSweetistics) { if (useSweetistics) {
if (!sweetistics.apiKey) { if (!sweetistics.apiKey) {
console.error('❌ Sweetistics engine selected but no API key provided.'); console.error(`${p('err')}Sweetistics engine selected but no API key provided.`);
process.exit(1); process.exit(1);
} }
const client = new SweetisticsClient({ const client = new SweetisticsClient({
@@ -497,15 +642,13 @@ program
console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`); console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`);
console.log(result.tweet.text); console.log(result.tweet.text);
if (result.tweet.createdAt) { if (result.tweet.createdAt) {
console.log(`\n📅 ${result.tweet.createdAt}`); console.log(`\n${l('date')}${result.tweet.createdAt}`);
} }
console.log( console.log(formatStatsLine(result.tweet, output));
`❤️ ${result.tweet.likeCount ?? 0} 🔁 ${result.tweet.retweetCount ?? 0} 💬 ${result.tweet.replyCount ?? 0}`,
);
} }
return; return;
} }
console.error(`❌ Failed to read tweet via Sweetistics: ${result.error ?? 'Unknown error'}`); console.error(`${p('err')}Failed to read tweet via Sweetistics: ${result.error ?? 'Unknown error'}`);
process.exit(1); process.exit(1);
} }
@@ -519,11 +662,11 @@ program
}); });
for (const warning of warnings) { for (const warning of warnings) {
console.error(`⚠️ ${warning}`); console.error(`${p('warn')}${warning}`);
} }
if (!cookies.authToken || !cookies.ct0) { if (!cookies.authToken || !cookies.ct0) {
console.error('❌ Missing required credentials'); console.error(`${p('err')}Missing required credentials`);
process.exit(1); process.exit(1);
} }
@@ -537,17 +680,15 @@ program
console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`); console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`);
console.log(result.tweet.text); console.log(result.tweet.text);
if (result.tweet.createdAt) { if (result.tweet.createdAt) {
console.log(`\n📅 ${result.tweet.createdAt}`); console.log(`\n${l('date')}${result.tweet.createdAt}`);
} }
console.log( console.log(formatStatsLine(result.tweet, output));
`❤️ ${result.tweet.likeCount ?? 0} 🔁 ${result.tweet.retweetCount ?? 0} 💬 ${result.tweet.replyCount ?? 0}`,
);
} }
} else if (sweetistics.apiKey) { } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
console.error(`⚠️ GraphQL read failed (${result.error}); trying Sweetistics fallback...`); console.error(`${p('warn')}GraphQL read failed (${result.error}); trying Sweetistics fallback...`);
const fallback = await new SweetisticsClient({ const fallback = await new SweetisticsClient({
baseUrl: sweetistics.baseUrl, baseUrl: sweetistics.baseUrl,
apiKey: sweetistics.apiKey, apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
timeoutMs, timeoutMs,
}).read(tweetId); }).read(tweetId);
if (fallback.success && fallback.tweet) { if (fallback.success && fallback.tweet) {
@@ -557,18 +698,16 @@ program
console.log(`@${fallback.tweet.author.username} (${fallback.tweet.author.name}):`); console.log(`@${fallback.tweet.author.username} (${fallback.tweet.author.name}):`);
console.log(fallback.tweet.text); console.log(fallback.tweet.text);
if (fallback.tweet.createdAt) { if (fallback.tweet.createdAt) {
console.log(`\n📅 ${fallback.tweet.createdAt}`); console.log(`\n${l('date')}${fallback.tweet.createdAt}`);
} }
console.log( console.log(formatStatsLine(fallback.tweet, output));
`❤️ ${fallback.tweet.likeCount ?? 0} 🔁 ${fallback.tweet.retweetCount ?? 0} 💬 ${fallback.tweet.replyCount ?? 0}`,
);
} }
} else { } else {
console.error(`❌ Failed to read tweet: ${result.error} | Sweetistics fallback: ${fallback.error}`); console.error(`${p('err')}Failed to read tweet: ${result.error} | Sweetistics fallback: ${fallback.error}`);
process.exit(1); process.exit(1);
} }
} else { } else {
console.error(`❌ Failed to read tweet: ${result.error}`); console.error(`${p('err')}Failed to read tweet: ${result.error}`);
process.exit(1); process.exit(1);
} }
}); });
@@ -591,7 +730,7 @@ program
const tweetId = extractTweetId(tweetIdOrUrl); const tweetId = extractTweetId(tweetIdOrUrl);
if (useSweetistics) { if (useSweetistics) {
if (!sweetistics.apiKey) { if (!sweetistics.apiKey) {
console.error('❌ Sweetistics engine selected but no API key provided.'); console.error(`${p('err')}Sweetistics engine selected but no API key provided.`);
process.exit(1); process.exit(1);
} }
const client = new SweetisticsClient({ const client = new SweetisticsClient({
@@ -604,7 +743,7 @@ program
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' }); printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' });
return; return;
} }
console.error(`❌ Failed to fetch replies via Sweetistics: ${result.error}`); console.error(`${p('err')}Failed to fetch replies via Sweetistics: ${result.error}`);
process.exit(1); process.exit(1);
} }
@@ -616,11 +755,11 @@ program
}); });
for (const warning of warnings) { for (const warning of warnings) {
console.error(`⚠️ ${warning}`); console.error(`${p('warn')}${warning}`);
} }
if (!cookies.authToken || !cookies.ct0) { if (!cookies.authToken || !cookies.ct0) {
console.error('❌ Missing required credentials'); console.error(`${p('err')}Missing required credentials`);
process.exit(1); process.exit(1);
} }
@@ -629,21 +768,21 @@ program
if (result.success && result.tweets) { if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' }); printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' });
} else if (sweetistics.apiKey) { } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
console.error(`⚠️ GraphQL replies failed (${result.error}); trying Sweetistics fallback...`); console.error(`${p('warn')}GraphQL replies failed (${result.error}); trying Sweetistics fallback...`);
const fallback = await new SweetisticsClient({ const fallback = await new SweetisticsClient({
baseUrl: sweetistics.baseUrl, baseUrl: sweetistics.baseUrl,
apiKey: sweetistics.apiKey, apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
timeoutMs, timeoutMs,
}).replies(tweetId); }).replies(tweetId);
if (fallback.success && fallback.tweets) { if (fallback.success && fallback.tweets) {
printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' }); printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' });
} else { } else {
console.error(`❌ Failed to fetch replies: ${result.error} | Sweetistics fallback: ${fallback.error}`); console.error(`${p('err')}Failed to fetch replies: ${result.error} | Sweetistics fallback: ${fallback.error}`);
process.exit(1); process.exit(1);
} }
} else { } else {
console.error(`❌ Failed to fetch replies: ${result.error}`); console.error(`${p('err')}Failed to fetch replies: ${result.error}`);
process.exit(1); process.exit(1);
} }
}); });
@@ -666,7 +805,7 @@ program
const tweetId = extractTweetId(tweetIdOrUrl); const tweetId = extractTweetId(tweetIdOrUrl);
if (useSweetistics) { if (useSweetistics) {
if (!sweetistics.apiKey) { if (!sweetistics.apiKey) {
console.error('❌ Sweetistics engine selected but no API key provided.'); console.error(`${p('err')}Sweetistics engine selected but no API key provided.`);
process.exit(1); process.exit(1);
} }
const client = new SweetisticsClient({ const client = new SweetisticsClient({
@@ -679,7 +818,7 @@ program
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' }); printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' });
return; return;
} }
console.error(`❌ Failed to fetch thread via Sweetistics: ${result.error}`); console.error(`${p('err')}Failed to fetch thread via Sweetistics: ${result.error}`);
process.exit(1); process.exit(1);
} }
@@ -691,11 +830,11 @@ program
}); });
for (const warning of warnings) { for (const warning of warnings) {
console.error(`⚠️ ${warning}`); console.error(`${p('warn')}${warning}`);
} }
if (!cookies.authToken || !cookies.ct0) { if (!cookies.authToken || !cookies.ct0) {
console.error('❌ Missing required credentials'); console.error(`${p('err')}Missing required credentials`);
process.exit(1); process.exit(1);
} }
@@ -704,21 +843,21 @@ program
if (result.success && result.tweets) { if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' }); printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' });
} else if (sweetistics.apiKey) { } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
console.error(`⚠️ GraphQL thread failed (${result.error}); trying Sweetistics fallback...`); console.error(`${p('warn')}GraphQL thread failed (${result.error}); trying Sweetistics fallback...`);
const fallback = await new SweetisticsClient({ const fallback = await new SweetisticsClient({
baseUrl: sweetistics.baseUrl, baseUrl: sweetistics.baseUrl,
apiKey: sweetistics.apiKey, apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
timeoutMs, timeoutMs,
}).thread(tweetId); }).thread(tweetId);
if (fallback.success && fallback.tweets) { if (fallback.success && fallback.tweets) {
printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' }); printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' });
} else { } else {
console.error(`❌ Failed to fetch thread: ${result.error} | Sweetistics fallback: ${fallback.error}`); console.error(`${p('err')}Failed to fetch thread: ${result.error} | Sweetistics fallback: ${fallback.error}`);
process.exit(1); process.exit(1);
} }
} else { } else {
console.error(`❌ Failed to fetch thread: ${result.error}`); console.error(`${p('err')}Failed to fetch thread: ${result.error}`);
process.exit(1); process.exit(1);
} }
}); });
@@ -743,7 +882,7 @@ program
if (useSweetistics) { if (useSweetistics) {
if (!sweetistics.apiKey) { if (!sweetistics.apiKey) {
console.error('❌ Sweetistics engine selected but no API key provided.'); console.error(`${p('err')}Sweetistics engine selected but no API key provided.`);
process.exit(1); process.exit(1);
} }
const client = new SweetisticsClient({ const client = new SweetisticsClient({
@@ -756,7 +895,7 @@ program
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' }); printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' });
return; return;
} }
console.error(`❌ Search failed via Sweetistics: ${result.error}`); console.error(`${p('err')}Search failed via Sweetistics: ${result.error}`);
process.exit(1); process.exit(1);
} }
@@ -768,11 +907,11 @@ program
}); });
for (const warning of warnings) { for (const warning of warnings) {
console.error(`⚠️ ${warning}`); console.error(`${p('warn')}${warning}`);
} }
if (!cookies.authToken || !cookies.ct0) { if (!cookies.authToken || !cookies.ct0) {
console.error('❌ Missing required credentials'); console.error(`${p('err')}Missing required credentials`);
process.exit(1); process.exit(1);
} }
@@ -781,21 +920,21 @@ program
if (result.success && result.tweets) { if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' }); printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' });
} else if (sweetistics.apiKey) { } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
console.error(`⚠️ GraphQL search failed (${result.error}); trying Sweetistics fallback...`); console.error(`${p('warn')}GraphQL search failed (${result.error}); trying Sweetistics fallback...`);
const fallback = await new SweetisticsClient({ const fallback = await new SweetisticsClient({
baseUrl: sweetistics.baseUrl, baseUrl: sweetistics.baseUrl,
apiKey: sweetistics.apiKey, apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
timeoutMs, timeoutMs,
}).search(query, count); }).search(query, count);
if (fallback.success && fallback.tweets) { if (fallback.success && fallback.tweets) {
printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' }); printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' });
} else { } else {
console.error(`❌ Search failed: ${result.error} | Sweetistics fallback: ${fallback.error}`); console.error(`${p('err')}Search failed: ${result.error} | Sweetistics fallback: ${fallback.error}`);
process.exit(1); process.exit(1);
} }
} else { } else {
console.error(`❌ Search failed: ${result.error}`); console.error(`${p('err')}Search failed: ${result.error}`);
process.exit(1); process.exit(1);
} }
}); });
@@ -803,10 +942,11 @@ program
// Mentions command - shortcut to search for @username mentions // Mentions command - shortcut to search for @username mentions
program program
.command('mentions') .command('mentions')
.description('Find tweets mentioning @clawdbot') .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('-n, --count <number>', 'Number of tweets to fetch', '10')
.option('--json', 'Output as JSON') .option('--json', 'Output as JSON')
.action(async (cmdOpts: { count?: string; json?: boolean }) => { .action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10); const count = Number.parseInt(cmdOpts.count || '10', 10);
@@ -814,9 +954,17 @@ program
const engine = resolveEngineMode(opts.engine); const engine = resolveEngineMode(opts.engine);
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user);
if (fromUserOpt.error) {
console.error(`${p('err')}${fromUserOpt.error}`);
process.exit(2);
}
let query: string | null = fromUserOpt.query;
if (useSweetistics) { if (useSweetistics) {
if (!sweetistics.apiKey) { if (!sweetistics.apiKey) {
console.error('❌ Sweetistics engine selected but no API key provided.'); console.error(`${p('err')}Sweetistics engine selected but no API key provided.`);
process.exit(1); process.exit(1);
} }
const client = new SweetisticsClient({ const client = new SweetisticsClient({
@@ -824,12 +972,25 @@ program
apiKey: sweetistics.apiKey, apiKey: sweetistics.apiKey,
timeoutMs, timeoutMs,
}); });
const result = await client.search('@clawdbot', count);
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);
}
query = `@${handle}`;
}
const result = await client.search(query, count);
if (result.success && result.tweets) { if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' }); printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
return; return;
} }
console.error(`❌ Failed to fetch mentions via Sweetistics: ${result.error}`); console.error(`${p('err')}Failed to fetch mentions via Sweetistics: ${result.error}`);
process.exit(1); process.exit(1);
} }
@@ -840,34 +1001,65 @@ program
}); });
for (const warning of warnings) { for (const warning of warnings) {
console.error(`⚠️ ${warning}`); console.error(`${p('warn')}${warning}`);
} }
if (!cookies.authToken || !cookies.ct0) { if (!cookies.authToken || !cookies.ct0) {
console.error('❌ Missing required credentials'); console.error(`${p('err')}Missing required credentials`);
process.exit(1); process.exit(1);
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs });
const result = await client.search('@clawdbot', count);
if (!query) {
const who = await client.getCurrentUser();
const handle = normalizeHandle(who.user?.username);
if (!handle) {
if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
console.error(
`${p('warn')}Could not determine current user (${who.error ?? 'Unknown error'}); trying Sweetistics fallback...`,
);
const fallbackWho = await new SweetisticsClient({
baseUrl: sweetistics.baseUrl,
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
timeoutMs,
}).getCurrentUser();
const fallbackHandle = normalizeHandle(fallbackWho.user?.username);
if (!fallbackHandle) {
console.error(`${p('err')}Could not determine current user. Use --user <handle>.`);
process.exit(1);
}
query = `@${fallbackHandle}`;
} else {
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) { if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' }); printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
} else if (sweetistics.apiKey) { } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
console.error(`⚠️ GraphQL mentions failed (${result.error}); trying Sweetistics fallback...`); console.error(`${p('warn')}GraphQL mentions failed (${result.error}); trying Sweetistics fallback...`);
const fallback = await new SweetisticsClient({ const fallback = await new SweetisticsClient({
baseUrl: sweetistics.baseUrl, baseUrl: sweetistics.baseUrl,
apiKey: sweetistics.apiKey, apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
timeoutMs, timeoutMs,
}).search('@clawdbot', count); }).search(query, count);
if (fallback.success && fallback.tweets) { if (fallback.success && fallback.tweets) {
printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' }); printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
} else { } else {
console.error(`❌ Failed to fetch mentions: ${result.error} | Sweetistics fallback: ${fallback.error}`); console.error(`${p('err')}Failed to fetch mentions: ${result.error} | Sweetistics fallback: ${fallback.error}`);
process.exit(1); process.exit(1);
} }
} else { } else {
console.error(`❌ Failed to fetch mentions: ${result.error}`); console.error(`${p('err')}Failed to fetch mentions: ${result.error}`);
process.exit(1); process.exit(1);
} }
}); });
@@ -888,7 +1080,7 @@ program
const resolvedEngine = useSweetistics ? 'sweetistics' : 'graphql'; const resolvedEngine = useSweetistics ? 'sweetistics' : 'graphql';
if (useSweetistics) { if (useSweetistics) {
if (!sweetistics.apiKey) { if (!sweetistics.apiKey) {
console.error('❌ Sweetistics engine selected but no API key provided.'); console.error(`${p('err')}Sweetistics engine selected but no API key provided.`);
process.exit(1); process.exit(1);
} }
@@ -902,17 +1094,17 @@ program
if (result.success && result.user) { if (result.success && result.user) {
const handle = result.user.username ? `@${result.user.username}` : '(no handle)'; const handle = result.user.username ? `@${result.user.username}` : '(no handle)';
const name = result.user.name || handle; const name = result.user.name || handle;
console.log(`🙋 Logged in via Sweetistics as ${handle} (${name})`); console.log(`${l('user')}${handle} (${name})`);
console.log(`🪪 User ID: ${result.user.id}`); console.log(`${l('userId')}${result.user.id}`);
if (result.user.email) { if (result.user.email) {
console.log(`📧 ${result.user.email}`); console.log(`${l('email')}${result.user.email}`);
} }
console.log(`⚙️ Engine: ${resolvedEngine}`); console.log(`${l('engine')}${resolvedEngine}`);
console.log('🔑 Credentials: Sweetistics API key'); console.log(`${l('credentials')}Sweetistics API key`);
return; return;
} }
console.error(`❌ Failed to determine Sweetistics user: ${result.error ?? 'Unknown error'}`); console.error(`${p('err')}Failed to determine Sweetistics user: ${result.error ?? 'Unknown error'}`);
process.exit(1); process.exit(1);
} }
@@ -926,16 +1118,16 @@ program
}); });
for (const warning of warnings) { for (const warning of warnings) {
console.error(`⚠️ ${warning}`); console.error(`${p('warn')}${warning}`);
} }
if (!cookies.authToken || !cookies.ct0) { if (!cookies.authToken || !cookies.ct0) {
console.error('❌ Missing required credentials'); console.error(`${p('err')}Missing required credentials`);
process.exit(1); process.exit(1);
} }
if (cookies.source) { if (cookies.source) {
console.error(`📍 Using credentials from: ${cookies.source}`); console.error(`${l('source')}${cookies.source}`);
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs });
@@ -944,32 +1136,32 @@ program
const credentialSource = cookies.source ?? 'env/auto-detected cookies'; const credentialSource = cookies.source ?? 'env/auto-detected cookies';
if (result.success && result.user) { if (result.success && result.user) {
console.log(`🙋 Logged in as @${result.user.username} (${result.user.name})`); console.log(`${l('user')}@${result.user.username} (${result.user.name})`);
console.log(`🪪 User ID: ${result.user.id}`); console.log(`${l('userId')}${result.user.id}`);
console.log(`⚙️ Engine: ${resolvedEngine}`); console.log(`${l('engine')}${resolvedEngine}`);
console.log(`🔑 Credentials: ${credentialSource}`); console.log(`${l('credentials')}${credentialSource}`);
} else { } else {
// Fallback: try Sweetistics if available // Fallback: try Sweetistics if available
if (sweetistics.apiKey) { if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
const fallback = await new SweetisticsClient({ const fallback = await new SweetisticsClient({
baseUrl: sweetistics.baseUrl, baseUrl: sweetistics.baseUrl,
apiKey: sweetistics.apiKey, apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
timeoutMs, timeoutMs,
}).getCurrentUser(); }).getCurrentUser();
if (fallback.success && fallback.user) { if (fallback.success && fallback.user) {
const handle = fallback.user.username ? `@${fallback.user.username}` : '(no handle)'; const handle = fallback.user.username ? `@${fallback.user.username}` : '(no handle)';
const name = fallback.user.name || handle; const name = fallback.user.name || handle;
console.log(`🙋 Logged in via Sweetistics as ${handle} (${name})`); console.log(`${l('user')}${handle} (${name})`);
console.log(`🪪 User ID: ${fallback.user.id}`); console.log(`${l('userId')}${fallback.user.id}`);
if (fallback.user.email) { if (fallback.user.email) {
console.log(`📧 ${fallback.user.email}`); console.log(`${l('email')}${fallback.user.email}`);
} }
console.log('⚙️ Engine: sweetistics (fallback)'); console.log(`${l('engine')}sweetistics (fallback)`);
console.log('🔑 Credentials: Sweetistics API key'); console.log(`${l('credentials')}Sweetistics API key`);
return; return;
} }
} }
console.error(`❌ Failed to determine current user: ${result.error ?? 'Unknown error'}`); console.error(`${p('err')}Failed to determine current user: ${result.error ?? 'Unknown error'}`);
process.exit(1); process.exit(1);
} }
}); });
@@ -986,36 +1178,36 @@ program
chromeProfile: opts.chromeProfile, chromeProfile: opts.chromeProfile,
}); });
console.log('🔍 Credential Check'); console.log(`${p('info')}Credential check`);
console.log('─'.repeat(40)); console.log('─'.repeat(40));
if (cookies.authToken) { if (cookies.authToken) {
console.log(`✅ auth_token: ${cookies.authToken.slice(0, 10)}...`); console.log(`${p('ok')}auth_token: ${cookies.authToken.slice(0, 10)}...`);
} else { } else {
console.log('❌ auth_token: not found'); console.log(`${p('err')}auth_token: not found`);
} }
if (cookies.ct0) { if (cookies.ct0) {
console.log(`✅ ct0: ${cookies.ct0.slice(0, 10)}...`); console.log(`${p('ok')}ct0: ${cookies.ct0.slice(0, 10)}...`);
} else { } else {
console.log('❌ ct0: not found'); console.log(`${p('err')}ct0: not found`);
} }
if (cookies.source) { if (cookies.source) {
console.log(`📍 Source: ${cookies.source}`); console.log(`${l('source')}${cookies.source}`);
} }
if (warnings.length > 0) { if (warnings.length > 0) {
console.log('\n⚠️ Warnings:'); console.log(`\n${p('warn')}Warnings:`);
for (const warning of warnings) { for (const warning of warnings) {
console.log(` - ${warning}`); console.log(` - ${warning}`);
} }
} }
if (cookies.authToken && cookies.ct0) { if (cookies.authToken && cookies.ct0) {
console.log('\n✅ Ready to tweet!'); console.log(`\n${p('ok')}Ready to tweet!`);
} else { } else {
console.log('\n❌ Missing credentials. Options:'); console.log(`\n${p('err')}Missing credentials. Options:`);
console.log(' 1. Login to x.com in Chrome'); console.log(' 1. Login to x.com in Chrome');
console.log(' 2. Set AUTH_TOKEN and CT0 environment variables'); console.log(' 2. Set AUTH_TOKEN and CT0 environment variables');
console.log(' 3. Use --auth-token and --ct0 flags'); console.log(' 3. Use --auth-token and --ct0 flags');
@@ -1023,8 +1215,7 @@ program
} }
}); });
const rawArgs = process.argv.slice(2); const { argv, showHelp } = resolveCliInvocation(normalizedArgs, KNOWN_COMMANDS);
const { argv, showHelp } = resolveCliInvocation(rawArgs, KNOWN_COMMANDS);
if (showHelp) { if (showHelp) {
program.outputHelp(); program.outputHelp();
@@ -1034,5 +1225,5 @@ if (showHelp) {
if (argv) { if (argv) {
program.parse(argv); program.parse(argv);
} else { } else {
program.parse(); program.parse(['node', 'bird', ...normalizedArgs]);
} }
+4
View File
@@ -13,3 +13,7 @@ export function shouldUseSweetistics(engine: EngineMode, _hasApiKey: boolean): b
if (engine === 'graphql') return false; if (engine === 'graphql') return false;
return false; // auto: GraphQL primary, Sweetistics only as fallback return false; // auto: GraphQL primary, Sweetistics only as fallback
} }
export function shouldAllowSweetisticsFallback(engine: EngineMode, hasApiKey: boolean): boolean {
return engine === 'auto' && hasApiKey;
}
+30
View File
@@ -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 };
}
+78
View File
@@ -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}`;
}
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"CreateTweet": "2BA2DIWTRVxHgKc4Xh2uKQ", "CreateTweet": "Uf3io9zVp1DsYxrmL5FJ7g",
"CreateRetweet": "Uf3io9zVp1DsYxrmL5FJ7g", "CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
"FavoriteTweet": "kuFUYP9eV1FPoEy4N-pi7w", "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
"TweetDetail": "97JF30KziU00483E_8elBA", "TweetDetail": "97JF30KziU00483E_8elBA",
"SearchTimeline": "5h0kNbk3ii97rmfY6CdgAA" "SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ"
} }
+303
View File
@@ -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();
+4 -1
View File
@@ -498,9 +498,12 @@ export class SweetisticsClient {
: undefined; : undefined;
const errorMessage = const errorMessage =
typeof (body as { error?: unknown })?.error === 'string' ? (body as { error: string }).error : undefined; 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) { 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 }; return { success: true, mediaId };
} catch (error) { } catch (error) {
+367 -99
View File
@@ -4,8 +4,12 @@
import type { TwitterCookies } from './cookies.js'; import type { TwitterCookies } from './cookies.js';
import queryIds from './query-ids.json' with { type: 'json' }; 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_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 // Query IDs rotate frequently; the values in query-ids.json are refreshed by
// scripts/update-query-ids.ts. The fallback values keep the client usable if // scripts/update-query-ids.ts. The fallback values keep the client usable if
@@ -26,13 +30,7 @@ const QUERY_IDS: Record<OperationName, string> = {
...(queryIds as Partial<Record<OperationName, string>>), ...(queryIds as Partial<Record<OperationName, string>>),
}; };
const TWEET_DETAIL_QUERY_IDS = Array.from( const TARGET_QUERY_ID_OPERATIONS = Object.keys(FALLBACK_QUERY_IDS) as Array<OperationName>;
new Set([QUERY_IDS.TweetDetail, '97JF30KziU00483E_8elBA', 'aFvUsJm2c-oDkJV75blV6g']),
);
const SEARCH_TIMELINE_QUERY_IDS = Array.from(
new Set([QUERY_IDS.SearchTimeline, 'M1jEez78PEfVfbQLvlWMvQ', '5h0kNbk3ii97rmfY6CdgAA', 'Tp1sewRU1AsZpBWhqCZicQ']),
);
type GraphqlTweetResult = { type GraphqlTweetResult = {
rest_id?: string; rest_id?: string;
@@ -187,6 +185,12 @@ export interface TweetResult {
error?: string; error?: string;
} }
export interface UploadMediaResult {
success: boolean;
mediaId?: string;
error?: string;
}
export interface TweetData { export interface TweetData {
id: string; id: string;
text: string; text: string;
@@ -265,6 +269,32 @@ export class TwitterClient {
this.timeoutMs = options.timeoutMs; 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> { private async fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
if (!this.timeoutMs || this.timeoutMs <= 0) { if (!this.timeoutMs || this.timeoutMs <= 0) {
return fetch(url, init); return fetch(url, init);
@@ -310,10 +340,13 @@ export class TwitterClient {
} }
private getHeaders(): Record<string, string> { private getHeaders(): Record<string, string> {
return this.getJsonHeaders();
}
private getBaseHeaders(): Record<string, string> {
return { return {
authorization: authorization:
'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA', 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
'content-type': 'application/json',
'x-csrf-token': this.ct0, 'x-csrf-token': this.ct0,
'x-twitter-auth-type': 'OAuth2Session', 'x-twitter-auth-type': 'OAuth2Session',
'x-twitter-active-user': 'yes', 'x-twitter-active-user': 'yes',
@@ -325,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 { private firstText(...values: Array<string | undefined | null>): string | undefined {
for (const value of values) { for (const value of values) {
if (typeof value === 'string') { if (typeof value === 'string') {
@@ -737,7 +926,8 @@ export class TwitterClient {
fieldToggles: JSON.stringify(this.buildArticleFieldToggles()), 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 { try {
const response = await this.fetchWithTimeout(url, { method: 'GET', headers: this.getHeaders() }); const response = await this.fetchWithTimeout(url, { method: 'GET', headers: this.getHeaders() });
@@ -874,32 +1064,51 @@ export class TwitterClient {
}; };
let lastError: string | undefined; let lastError: string | undefined;
let had404 = false;
for (const queryId of TWEET_DETAIL_QUERY_IDS) { const tryOnce = async () => {
const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params}`; const queryIds = await this.getTweetDetailQueryIds();
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status !== 404) { for (const queryId of queryIds) {
return await parseResponse(response); 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';
} }
const postResponse = await this.fetchWithTimeout(`${TWITTER_API_BASE}/${queryId}/TweetDetail`, { return { success: false as const, error: lastError ?? 'Unknown error fetching tweet detail' };
method: 'POST', };
headers: this.getHeaders(),
body: JSON.stringify({ variables, features, queryId }),
});
if (postResponse.status !== 404) { const firstAttempt = await tryOnce();
return await parseResponse(postResponse); if (firstAttempt.success) {
} return firstAttempt;
lastError = 'HTTP 404';
} }
return { success: false, error: lastError ?? 'Unknown error fetching tweet detail' }; if (had404) {
await this.refreshQueryIds();
return await tryOnce();
}
return firstAttempt;
} catch (error) { } catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }; return { success: false, error: error instanceof Error ? error.message : String(error) };
} }
@@ -956,18 +1165,20 @@ export class TwitterClient {
/** /**
* Post a new tweet * Post a new tweet
*/ */
async tweet(text: string): Promise<TweetResult> { async tweet(text: string, mediaIds?: string[]): Promise<TweetResult> {
const variables = { const variables = {
tweet_text: text, tweet_text: text,
dark_request: false, dark_request: false,
media: { media: {
media_entities: [], media_entities: (mediaIds ?? []).map((id) => ({ media_id: id, tagged_users: [] })),
possibly_sensitive: false, possibly_sensitive: false,
}, },
semantic_annotation_ids: [], semantic_annotation_ids: [],
}; };
const features = { const features = {
rweb_video_screen_enabled: true,
creator_subscriptions_tweet_preview_api_enabled: true,
premium_content_api_read_enabled: false, premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true, communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true, c9s_tweet_anatomy_moderator_badge_enabled: true,
@@ -1008,7 +1219,7 @@ export class TwitterClient {
/** /**
* Reply to an existing tweet * 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 = { const variables = {
tweet_text: text, tweet_text: text,
reply: { reply: {
@@ -1017,13 +1228,15 @@ export class TwitterClient {
}, },
dark_request: false, dark_request: false,
media: { media: {
media_entities: [], media_entities: (mediaIds ?? []).map((id) => ({ media_id: id, tagged_users: [] })),
possibly_sensitive: false, possibly_sensitive: false,
}, },
semantic_annotation_ids: [], semantic_annotation_ids: [],
}; };
const features = { const features = {
rweb_video_screen_enabled: true,
creator_subscriptions_tweet_preview_api_enabled: true,
premium_content_api_read_enabled: false, premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true, communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true, c9s_tweet_anatomy_moderator_badge_enabled: true,
@@ -1065,21 +1278,58 @@ export class TwitterClient {
variables: Record<string, unknown>, variables: Record<string, unknown>,
features: Record<string, boolean>, features: Record<string, boolean>,
): Promise<TweetResult> { ): 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({ const buildBody = () => JSON.stringify({ variables, features, queryId });
variables, let body = buildBody();
features,
queryId: QUERY_IDS.CreateTweet,
});
try { try {
const response = await this.fetchWithTimeout(url, { let response = await this.fetchWithTimeout(urlWithOperation, {
method: 'POST', method: 'POST',
headers: this.getHeaders(), headers: this.getHeaders(),
body, 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) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
return { return {
@@ -1130,57 +1380,63 @@ export class TwitterClient {
const features = this.buildSearchFeatures(); const features = this.buildSearchFeatures();
let lastError: string | undefined; const params = new URLSearchParams({
variables: JSON.stringify(variables),
});
for (const queryId of SEARCH_TIMELINE_QUERY_IDS) { const tryOnce = async () => {
const params = new URLSearchParams({ let lastError: string | undefined;
variables: JSON.stringify(variables), let had404 = false;
}); const queryIds = await this.getSearchTimelineQueryIds();
const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params}`;
try { for (const queryId of queryIds) {
const response = await this.fetchWithTimeout(url, { const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params}`;
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({ features, queryId }),
});
if (response.status === 404) { try {
lastError = `HTTP ${response.status}`; const response = await this.fetchWithTimeout(url, {
continue; method: 'POST',
} headers: this.getHeaders(),
body: JSON.stringify({ features, queryId }),
});
if (!response.ok) { if (response.status === 404) {
const text = await response.text(); had404 = true;
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` }; lastError = `HTTP ${response.status}`;
} continue;
}
const data = (await response.json()) as { if (!response.ok) {
data?: { const text = await response.text();
search_by_raw_query?: { return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
search_timeline?: { }
timeline?: {
instructions?: Array<{ const data = (await response.json()) as {
entries?: Array<{ data?: {
content?: { search_by_raw_query?: {
itemContent?: { search_timeline?: {
tweet_results?: { timeline?: {
result?: { instructions?: Array<{
rest_id?: string; entries?: Array<{
legacy?: { content?: {
full_text?: string; itemContent?: {
created_at?: string; tweet_results?: {
reply_count?: number; result?: {
retweet_count?: number; rest_id?: string;
favorite_count?: number; legacy?: {
in_reply_to_status_id_str?: string; full_text?: string;
}; created_at?: string;
core?: { reply_count?: number;
user_results?: { retweet_count?: number;
result?: { favorite_count?: number;
legacy?: { in_reply_to_status_id_str?: string;
screen_name?: string; };
name?: string; core?: {
user_results?: {
result?: {
legacy?: {
screen_name?: string;
name?: string;
};
}; };
}; };
}; };
@@ -1188,30 +1444,42 @@ export class TwitterClient {
}; };
}; };
}; };
}; }>;
}>; }>;
}>; };
}; };
}; };
}; };
errors?: Array<{ message: string }>;
}; };
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) { 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 as const, tweets, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
} }
const instructions = data.data?.search_by_raw_query?.search_timeline?.timeline?.instructions;
const tweets = this.parseTweetsFromInstructions(instructions);
return { success: true, tweets };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
} }
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: lastError ?? 'Unknown error fetching search results' }; return { success: false, error: firstAttempt.error };
} }
/** /**
+164
View File
@@ -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
View File
@@ -2,7 +2,19 @@ import { describe, expect, it } from 'vitest';
import { looksLikeTweetInput, resolveCliInvocation } from '../src/lib/cli-args.js'; import { looksLikeTweetInput, resolveCliInvocation } from '../src/lib/cli-args.js';
describe('cli-args', () => { 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', () => { it('detects tweet URLs', () => {
expect(looksLikeTweetInput('https://x.com/user/status/1234567890')).toBe(true); expect(looksLikeTweetInput('https://x.com/user/status/1234567890')).toBe(true);
+8 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { resolveEngineMode, shouldUseSweetistics } from '../src/lib/engine.js'; import { resolveEngineMode, shouldAllowSweetisticsFallback, shouldUseSweetistics } from '../src/lib/engine.js';
describe('engine', () => { describe('engine', () => {
it('normalizes engine values and defaults to auto', () => { it('normalizes engine values and defaults to auto', () => {
@@ -19,4 +19,11 @@ describe('engine', () => {
expect(shouldUseSweetistics('sweetistics', false)).toBe(true); expect(shouldUseSweetistics('sweetistics', false)).toBe(true);
expect(shouldUseSweetistics('graphql', true)).toBe(false); 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);
});
}); });
+41
View File
@@ -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/);
});
});
+22
View File
@@ -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: ');
});
});
+63
View File
@@ -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');
});
});
+169
View File
@@ -79,6 +79,79 @@ describe('TwitterClient', () => {
const body = JSON.parse(options.body); const body = JSON.parse(options.body);
expect(body.variables.tweet_text).toBe('Hello world!'); expect(body.variables.tweet_text).toBe('Hello world!');
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 () => { 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', () => { describe('reply', () => {
let mockFetch: ReturnType<typeof vi.fn>; let mockFetch: ReturnType<typeof vi.fn>;
@@ -178,6 +345,8 @@ describe('TwitterClient', () => {
const body = JSON.parse(options.body); const body = JSON.parse(options.body);
expect(body.variables.reply.in_reply_to_tweet_id).toBe('1234567890'); expect(body.variables.reply.in_reply_to_tweet_id).toBe('1234567890');
expect(body.variables.tweet_text).toBe('This is a reply'); 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);
}); });
}); });
+30
View File
@@ -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})`);
});
});