Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f38efc9bf | ||
|
|
0bf7190c95 | ||
|
|
3be2e3ae7e | ||
|
|
dee304235b | ||
|
|
9a4465f05d | ||
|
|
978d07f60f | ||
|
|
472d666eee | ||
|
|
c35e937b2a | ||
|
|
4730267c2a | ||
|
|
8257d52532 | ||
|
|
80a57c825f | ||
|
|
88651f669d | ||
|
|
6669ba9912 | ||
|
|
3df7969b16 | ||
|
|
5613caa149 | ||
|
|
b4e1ddbf2e | ||
|
|
9222908ab7 | ||
|
|
674fee3b8a |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const root = resolve(import.meta.dirname, '..');
|
||||||
|
const files = [
|
||||||
|
{
|
||||||
|
from: resolve(root, 'src/lib/query-ids.json'),
|
||||||
|
to: resolve(root, 'dist/lib/query-ids.json'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
mkdirSync(dirname(file.to), { recursive: true });
|
||||||
|
writeFileSync(file.to, readFileSync(file.from));
|
||||||
|
}
|
||||||
|
|
||||||
@@ -28,6 +28,18 @@ const BUNDLE_URL_REGEX =
|
|||||||
/https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g;
|
/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,
|
||||||
|
|||||||
+351
-160
@@ -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 (result.success && result.tweets) {
|
if (!query) {
|
||||||
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
|
const who = await client.getCurrentUser();
|
||||||
} else if (sweetistics.apiKey) {
|
const handle = normalizeHandle(who.user?.username);
|
||||||
console.error(`⚠️ GraphQL mentions failed (${result.error}); trying Sweetistics fallback...`);
|
if (!handle) {
|
||||||
const fallback = await new SweetisticsClient({
|
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,
|
baseUrl: sweetistics.baseUrl,
|
||||||
apiKey: sweetistics.apiKey,
|
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
}).search('@clawdbot', count);
|
}).getCurrentUser();
|
||||||
if (fallback.success && fallback.tweets) {
|
const fallbackHandle = normalizeHandle(fallbackWho.user?.username);
|
||||||
printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
|
if (!fallbackHandle) {
|
||||||
|
console.error(`${p('err')}Could not determine current user. Use --user <handle>.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
query = `@${fallbackHandle}`;
|
||||||
} else {
|
} else {
|
||||||
console.error(`❌ Failed to fetch mentions: ${result.error} | Sweetistics fallback: ${fallback.error}`);
|
console.error(
|
||||||
|
`${p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user <handle>.`,
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error(`❌ Failed to fetch mentions: ${result.error}`);
|
query = `@${handle}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await client.search(query, count);
|
||||||
|
|
||||||
|
if (result.success && result.tweets) {
|
||||||
|
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
|
||||||
|
} else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
|
||||||
|
console.error(`${p('warn')}GraphQL mentions failed (${result.error}); trying Sweetistics fallback...`);
|
||||||
|
const fallback = await new SweetisticsClient({
|
||||||
|
baseUrl: sweetistics.baseUrl,
|
||||||
|
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||||
|
timeoutMs,
|
||||||
|
}).search(query, count);
|
||||||
|
if (fallback.success && fallback.tweets) {
|
||||||
|
printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
|
||||||
|
} else {
|
||||||
|
console.error(`${p('err')}Failed to fetch mentions: ${result.error} | Sweetistics fallback: ${fallback.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export function normalizeHandle(input?: string | null): string | null {
|
||||||
|
const raw = (input ?? '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw;
|
||||||
|
const handle = withoutAt.trim();
|
||||||
|
if (!handle) return null;
|
||||||
|
|
||||||
|
// X/Twitter handles are traditionally max 15 chars; keep strict to avoid surprising queries.
|
||||||
|
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null;
|
||||||
|
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mentionsQueryFromUserOption(userOption?: string | null): {
|
||||||
|
query: string | null;
|
||||||
|
error: string | null;
|
||||||
|
} {
|
||||||
|
if (typeof userOption === 'undefined') return { query: null, error: null };
|
||||||
|
|
||||||
|
const handle = normalizeHandle(userOption);
|
||||||
|
if (!handle) {
|
||||||
|
return {
|
||||||
|
query: null,
|
||||||
|
error: 'Invalid --user handle. Expected something like @steipete (letters, digits, underscore; max 15).',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { query: `@${handle}`, error: null };
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
export type OutputConfig = {
|
||||||
|
plain: boolean;
|
||||||
|
emoji: boolean;
|
||||||
|
color: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatusKind = 'ok' | 'warn' | 'err' | 'info' | 'hint';
|
||||||
|
export type LabelKind = 'url' | 'date' | 'source' | 'engine' | 'credentials' | 'user' | 'userId' | 'email';
|
||||||
|
|
||||||
|
const STATUS: Record<StatusKind, { emoji: string; text: string; plain: string }> = {
|
||||||
|
ok: { emoji: '✅', text: 'OK:', plain: '[ok]' },
|
||||||
|
warn: { emoji: '⚠️', text: 'Warning:', plain: '[warn]' },
|
||||||
|
err: { emoji: '❌', text: 'Error:', plain: '[err]' },
|
||||||
|
info: { emoji: 'ℹ️', text: 'Info:', plain: '[info]' },
|
||||||
|
hint: { emoji: 'ℹ️', text: 'Hint:', plain: '[hint]' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const LABELS: Record<LabelKind, { emoji: string; text: string; plain: string }> = {
|
||||||
|
url: { emoji: '🔗', text: 'URL:', plain: 'url:' },
|
||||||
|
date: { emoji: '📅', text: 'Date:', plain: 'date:' },
|
||||||
|
source: { emoji: '📍', text: 'Source:', plain: 'source:' },
|
||||||
|
engine: { emoji: '⚙️', text: 'Engine:', plain: 'engine:' },
|
||||||
|
credentials: { emoji: '🔑', text: 'Credentials:', plain: 'credentials:' },
|
||||||
|
user: { emoji: '🙋', text: 'User:', plain: 'user:' },
|
||||||
|
userId: { emoji: '🪪', text: 'User ID:', plain: 'user_id:' },
|
||||||
|
email: { emoji: '📧', text: 'Email:', plain: 'email:' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveOutputConfigFromArgv(argv: string[], env: NodeJS.ProcessEnv, isTty: boolean): OutputConfig {
|
||||||
|
const hasNoColorEnv = Object.hasOwn(env, 'NO_COLOR') || env.TERM === 'dumb';
|
||||||
|
const defaultColor = isTty && !hasNoColorEnv;
|
||||||
|
|
||||||
|
const plain = argv.includes('--plain');
|
||||||
|
const emoji = !plain && !argv.includes('--no-emoji');
|
||||||
|
const color = !plain && !argv.includes('--no-color') && defaultColor;
|
||||||
|
|
||||||
|
return { plain, emoji, color };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveOutputConfigFromCommander(
|
||||||
|
opts: { plain?: boolean; emoji?: boolean; color?: boolean },
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
isTty: boolean,
|
||||||
|
): OutputConfig {
|
||||||
|
const hasNoColorEnv = Object.hasOwn(env, 'NO_COLOR') || env.TERM === 'dumb';
|
||||||
|
const defaultColor = isTty && !hasNoColorEnv;
|
||||||
|
|
||||||
|
const plain = Boolean(opts.plain);
|
||||||
|
const emoji = !plain && (opts.emoji ?? true);
|
||||||
|
const color = !plain && (opts.color ?? true) && defaultColor;
|
||||||
|
|
||||||
|
return { plain, emoji, color };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusPrefix(kind: StatusKind, cfg: OutputConfig): string {
|
||||||
|
if (cfg.plain) return `${STATUS[kind].plain} `;
|
||||||
|
if (cfg.emoji) return `${STATUS[kind].emoji} `;
|
||||||
|
return `${STATUS[kind].text} `;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function labelPrefix(kind: LabelKind, cfg: OutputConfig): string {
|
||||||
|
if (cfg.plain) return `${LABELS[kind].plain} `;
|
||||||
|
if (cfg.emoji) return `${LABELS[kind].emoji} `;
|
||||||
|
return `${LABELS[kind].text} `;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatStatsLine(
|
||||||
|
stats: { likeCount?: number | null; retweetCount?: number | null; replyCount?: number | null },
|
||||||
|
cfg: OutputConfig,
|
||||||
|
): string {
|
||||||
|
const likeCount = stats.likeCount ?? 0;
|
||||||
|
const retweetCount = stats.retweetCount ?? 0;
|
||||||
|
const replyCount = stats.replyCount ?? 0;
|
||||||
|
|
||||||
|
if (cfg.plain) return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`;
|
||||||
|
if (!cfg.emoji) return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`;
|
||||||
|
return `❤️ ${likeCount} 🔁 ${retweetCount} 💬 ${replyCount}`;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"CreateTweet": "2BA2DIWTRVxHgKc4Xh2uKQ",
|
"CreateTweet": "Uf3io9zVp1DsYxrmL5FJ7g",
|
||||||
"CreateRetweet": "Uf3io9zVp1DsYxrmL5FJ7g",
|
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
|
||||||
"FavoriteTweet": "kuFUYP9eV1FPoEy4N-pi7w",
|
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
|
||||||
"TweetDetail": "97JF30KziU00483E_8elBA",
|
"TweetDetail": "97JF30KziU00483E_8elBA",
|
||||||
"SearchTimeline": "5h0kNbk3ii97rmfY6CdgAA"
|
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const DEFAULT_CACHE_FILENAME = 'query-ids-cache.json';
|
||||||
|
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const DISCOVERY_PAGES = [
|
||||||
|
'https://x.com/?lang=en',
|
||||||
|
'https://x.com/explore',
|
||||||
|
'https://x.com/notifications',
|
||||||
|
'https://x.com/settings/profile',
|
||||||
|
];
|
||||||
|
|
||||||
|
const BUNDLE_URL_REGEX = /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g;
|
||||||
|
|
||||||
|
const OPERATION_PATTERNS = [
|
||||||
|
{
|
||||||
|
regex: /e\.exports=\{queryId\s*:\s*["']([^"']+)["']\s*,\s*operationName\s*:\s*["']([^"']+)["']/gs,
|
||||||
|
operationGroup: 2,
|
||||||
|
queryIdGroup: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: /e\.exports=\{operationName\s*:\s*["']([^"']+)["']\s*,\s*queryId\s*:\s*["']([^"']+)["']/gs,
|
||||||
|
operationGroup: 1,
|
||||||
|
queryIdGroup: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: /operationName\s*[:=]\s*["']([^"']+)["'](.{0,4000}?)queryId\s*[:=]\s*["']([^"']+)["']/gs,
|
||||||
|
operationGroup: 1,
|
||||||
|
queryIdGroup: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: /queryId\s*[:=]\s*["']([^"']+)["'](.{0,4000}?)operationName\s*[:=]\s*["']([^"']+)["']/gs,
|
||||||
|
operationGroup: 3,
|
||||||
|
queryIdGroup: 1,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const HEADERS = {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36',
|
||||||
|
Accept: 'text/html,application/json;q=0.9,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeQueryIdSnapshot = {
|
||||||
|
fetchedAt: string;
|
||||||
|
ttlMs: number;
|
||||||
|
ids: Record<string, string>;
|
||||||
|
discovery: {
|
||||||
|
pages: string[];
|
||||||
|
bundles: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeQueryIdSnapshotInfo = {
|
||||||
|
snapshot: RuntimeQueryIdSnapshot;
|
||||||
|
cachePath: string;
|
||||||
|
ageMs: number;
|
||||||
|
isFresh: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeQueryIdsOptions = {
|
||||||
|
cachePath?: string;
|
||||||
|
ttlMs?: number;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchText(fetchImpl: typeof fetch, url: string): Promise<string> {
|
||||||
|
const response = await fetchImpl(url, { headers: HEADERS });
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text().catch(() => '');
|
||||||
|
throw new Error(`HTTP ${response.status} for ${url}: ${body.slice(0, 120)}`);
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDefaultCachePath(): string {
|
||||||
|
const override = process.env.BIRD_QUERY_IDS_CACHE;
|
||||||
|
if (override && override.trim().length > 0) {
|
||||||
|
return path.resolve(override.trim());
|
||||||
|
}
|
||||||
|
return path.join(homedir(), '.config', 'bird', DEFAULT_CACHE_FILENAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null;
|
||||||
|
const record = raw as Record<string, unknown>;
|
||||||
|
const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null;
|
||||||
|
const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null;
|
||||||
|
const ids = record.ids && typeof record.ids === 'object' ? (record.ids as Record<string, unknown>) : null;
|
||||||
|
const discovery =
|
||||||
|
record.discovery && typeof record.discovery === 'object' ? (record.discovery as Record<string, unknown>) : null;
|
||||||
|
|
||||||
|
if (!fetchedAt || !ttlMs || !ids || !discovery) return null;
|
||||||
|
const pages = Array.isArray(discovery.pages) ? discovery.pages : null;
|
||||||
|
const bundles = Array.isArray(discovery.bundles) ? discovery.bundles : null;
|
||||||
|
if (!pages || !bundles) return null;
|
||||||
|
|
||||||
|
const normalizedIds: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(ids)) {
|
||||||
|
if (typeof value === 'string' && value.trim().length > 0) {
|
||||||
|
normalizedIds[key] = value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetchedAt,
|
||||||
|
ttlMs,
|
||||||
|
ids: normalizedIds,
|
||||||
|
discovery: {
|
||||||
|
pages: pages.filter((p) => typeof p === 'string') as string[],
|
||||||
|
bundles: bundles.filter((b) => typeof b === 'string') as string[],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSnapshotFromDisk(cachePath: string): Promise<RuntimeQueryIdSnapshot | null> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(cachePath, 'utf8');
|
||||||
|
return parseSnapshot(JSON.parse(raw));
|
||||||
|
} catch (error) {
|
||||||
|
const code = error && typeof error === 'object' ? String((error as { code?: unknown }).code ?? '') : '';
|
||||||
|
if (code !== 'ENOENT') return null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeSnapshotToDisk(cachePath: string, snapshot: RuntimeQueryIdSnapshot): Promise<void> {
|
||||||
|
await mkdir(path.dirname(cachePath), { recursive: true });
|
||||||
|
await writeFile(cachePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverBundles(fetchImpl: typeof fetch): Promise<string[]> {
|
||||||
|
const bundles = new Set<string>();
|
||||||
|
for (const page of DISCOVERY_PAGES) {
|
||||||
|
try {
|
||||||
|
const html = await fetchText(fetchImpl, page);
|
||||||
|
for (const match of html.matchAll(BUNDLE_URL_REGEX)) {
|
||||||
|
bundles.add(match[0]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore discovery page failures; other pages often work
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const discovered = [...bundles];
|
||||||
|
if (discovered.length === 0) {
|
||||||
|
throw new Error('No client bundles discovered; x.com layout may have changed.');
|
||||||
|
}
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOperations(
|
||||||
|
bundleContents: string,
|
||||||
|
bundleLabel: string,
|
||||||
|
targets: Set<string>,
|
||||||
|
discovered: Map<string, { queryId: string; bundle: string }>,
|
||||||
|
): void {
|
||||||
|
for (const pattern of OPERATION_PATTERNS) {
|
||||||
|
pattern.regex.lastIndex = 0;
|
||||||
|
while (true) {
|
||||||
|
const match = pattern.regex.exec(bundleContents);
|
||||||
|
if (match === null) break;
|
||||||
|
const operationName = match[pattern.operationGroup];
|
||||||
|
const queryId = match[pattern.queryIdGroup];
|
||||||
|
if (!operationName || !queryId) continue;
|
||||||
|
if (!targets.has(operationName)) continue;
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(queryId)) continue;
|
||||||
|
if (discovered.has(operationName)) continue;
|
||||||
|
discovered.set(operationName, { queryId, bundle: bundleLabel });
|
||||||
|
if (discovered.size === targets.size) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAndExtract(
|
||||||
|
fetchImpl: typeof fetch,
|
||||||
|
bundleUrls: string[],
|
||||||
|
targets: Set<string>,
|
||||||
|
): Promise<Map<string, { queryId: string; bundle: string }>> {
|
||||||
|
const discovered = new Map<string, { queryId: string; bundle: string }>();
|
||||||
|
const CONCURRENCY = 6;
|
||||||
|
|
||||||
|
for (let i = 0; i < bundleUrls.length; i += CONCURRENCY) {
|
||||||
|
const chunk = bundleUrls.slice(i, i + CONCURRENCY);
|
||||||
|
await Promise.all(
|
||||||
|
chunk.map(async (url) => {
|
||||||
|
if (discovered.size === targets.size) return;
|
||||||
|
const label = url.split('/').at(-1) ?? url;
|
||||||
|
try {
|
||||||
|
const js = await fetchText(fetchImpl, url);
|
||||||
|
extractOperations(js, label, targets, discovered);
|
||||||
|
} catch {
|
||||||
|
// ignore failed bundles
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (discovered.size === targets.size) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}) {
|
||||||
|
const fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
||||||
|
const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath();
|
||||||
|
|
||||||
|
let memorySnapshot: RuntimeQueryIdSnapshot | null = null;
|
||||||
|
let loadOnce: Promise<RuntimeQueryIdSnapshot | null> | null = null;
|
||||||
|
let refreshInFlight: Promise<RuntimeQueryIdSnapshotInfo | null> | null = null;
|
||||||
|
|
||||||
|
const loadSnapshot = async (): Promise<RuntimeQueryIdSnapshot | null> => {
|
||||||
|
if (memorySnapshot) return memorySnapshot;
|
||||||
|
if (!loadOnce) {
|
||||||
|
loadOnce = (async () => {
|
||||||
|
const fromDisk = await readSnapshotFromDisk(cachePath);
|
||||||
|
memorySnapshot = fromDisk;
|
||||||
|
return fromDisk;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return loadOnce;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSnapshotInfo = async (): Promise<RuntimeQueryIdSnapshotInfo | null> => {
|
||||||
|
const snapshot = await loadSnapshot();
|
||||||
|
if (!snapshot) return null;
|
||||||
|
const fetchedAtMs = new Date(snapshot.fetchedAt).getTime();
|
||||||
|
const ageMs = Number.isFinite(fetchedAtMs) ? Math.max(0, Date.now() - fetchedAtMs) : Number.POSITIVE_INFINITY;
|
||||||
|
const effectiveTtl = Number.isFinite(snapshot.ttlMs) ? snapshot.ttlMs : ttlMs;
|
||||||
|
const isFresh = ageMs <= effectiveTtl;
|
||||||
|
return { snapshot, cachePath, ageMs, isFresh };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQueryId = async (operationName: string): Promise<string | null> => {
|
||||||
|
const info = await getSnapshotInfo();
|
||||||
|
if (!info) return null;
|
||||||
|
return info.snapshot.ids[operationName] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async (
|
||||||
|
operationNames: string[],
|
||||||
|
opts: { force?: boolean } = {},
|
||||||
|
): Promise<RuntimeQueryIdSnapshotInfo | null> => {
|
||||||
|
if (refreshInFlight) return refreshInFlight;
|
||||||
|
|
||||||
|
refreshInFlight = (async () => {
|
||||||
|
const current = await getSnapshotInfo();
|
||||||
|
if (!opts.force && current?.isFresh) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = new Set(operationNames);
|
||||||
|
const bundleUrls = await discoverBundles(fetchImpl);
|
||||||
|
const discovered = await fetchAndExtract(fetchImpl, bundleUrls, targets);
|
||||||
|
if (discovered.size === 0) {
|
||||||
|
return current ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids: Record<string, string> = {};
|
||||||
|
for (const name of operationNames) {
|
||||||
|
const entry = discovered.get(name);
|
||||||
|
if (entry?.queryId) {
|
||||||
|
ids[name] = entry.queryId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot: RuntimeQueryIdSnapshot = {
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
ttlMs,
|
||||||
|
ids,
|
||||||
|
discovery: {
|
||||||
|
pages: [...DISCOVERY_PAGES],
|
||||||
|
bundles: bundleUrls.map((url) => url.split('/').at(-1) ?? url),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeSnapshotToDisk(cachePath, snapshot);
|
||||||
|
memorySnapshot = snapshot;
|
||||||
|
|
||||||
|
return getSnapshotInfo();
|
||||||
|
})().finally(() => {
|
||||||
|
refreshInFlight = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return refreshInFlight;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cachePath,
|
||||||
|
ttlMs,
|
||||||
|
getSnapshotInfo,
|
||||||
|
getQueryId,
|
||||||
|
refresh,
|
||||||
|
clearMemory() {
|
||||||
|
memorySnapshot = null;
|
||||||
|
loadOnce = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtimeQueryIds = createRuntimeQueryIdStore();
|
||||||
@@ -498,9 +498,12 @@ export class SweetisticsClient {
|
|||||||
: undefined;
|
: 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) {
|
||||||
|
|||||||
+297
-29
@@ -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,8 +1064,12 @@ 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 queryIds = await this.getTweetDetailQueryIds();
|
||||||
|
|
||||||
|
for (const queryId of queryIds) {
|
||||||
const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params}`;
|
const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params}`;
|
||||||
const response = await this.fetchWithTimeout(url, {
|
const response = await this.fetchWithTimeout(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -886,6 +1080,8 @@ export class TwitterClient {
|
|||||||
return await parseResponse(response);
|
return await parseResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
had404 = true;
|
||||||
|
|
||||||
const postResponse = await this.fetchWithTimeout(`${TWITTER_API_BASE}/${queryId}/TweetDetail`, {
|
const postResponse = await this.fetchWithTimeout(`${TWITTER_API_BASE}/${queryId}/TweetDetail`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
@@ -899,7 +1095,20 @@ export class TwitterClient {
|
|||||||
lastError = 'HTTP 404';
|
lastError = 'HTTP 404';
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: false, error: lastError ?? 'Unknown error fetching tweet detail' };
|
return { success: false as const, error: lastError ?? 'Unknown error fetching tweet detail' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstAttempt = await tryOnce();
|
||||||
|
if (firstAttempt.success) {
|
||||||
|
return firstAttempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (had404) {
|
||||||
|
await this.refreshQueryIds();
|
||||||
|
return await tryOnce();
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstAttempt;
|
||||||
} catch (error) {
|
} 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,12 +1380,16 @@ export class TwitterClient {
|
|||||||
|
|
||||||
const features = this.buildSearchFeatures();
|
const features = this.buildSearchFeatures();
|
||||||
|
|
||||||
let lastError: string | undefined;
|
|
||||||
|
|
||||||
for (const queryId of SEARCH_TIMELINE_QUERY_IDS) {
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
variables: JSON.stringify(variables),
|
variables: JSON.stringify(variables),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tryOnce = async () => {
|
||||||
|
let lastError: string | undefined;
|
||||||
|
let had404 = false;
|
||||||
|
const queryIds = await this.getSearchTimelineQueryIds();
|
||||||
|
|
||||||
|
for (const queryId of queryIds) {
|
||||||
const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params}`;
|
const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1146,13 +1400,14 @@ export class TwitterClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
|
had404 = true;
|
||||||
lastError = `HTTP ${response.status}`;
|
lastError = `HTTP ${response.status}`;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
|
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as {
|
const data = (await response.json()) as {
|
||||||
@@ -1199,19 +1454,32 @@ export class TwitterClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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 instructions = data.data?.search_by_raw_query?.search_timeline?.timeline?.instructions;
|
||||||
const tweets = this.parseTweetsFromInstructions(instructions);
|
const tweets = this.parseTweetsFromInstructions(instructions);
|
||||||
|
|
||||||
return { success: true, tweets };
|
return { success: true as const, tweets, had404 };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error instanceof Error ? error.message : String(error);
|
lastError = error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: false, error: lastError ?? 'Unknown error fetching search results' };
|
return { success: false as const, error: lastError ?? 'Unknown error fetching search results', had404 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstAttempt = await tryOnce();
|
||||||
|
if (firstAttempt.success) return { success: true, tweets: firstAttempt.tweets };
|
||||||
|
|
||||||
|
if (firstAttempt.had404) {
|
||||||
|
await this.refreshQueryIds();
|
||||||
|
const secondAttempt = await tryOnce();
|
||||||
|
if (secondAttempt.success) return { success: true, tweets: secondAttempt.tweets };
|
||||||
|
return { success: false, error: secondAttempt.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: firstAttempt.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
declare const __dirname: string | undefined;
|
||||||
|
|
||||||
|
export const FALLBACK_VERSION = 'unknown';
|
||||||
|
|
||||||
|
function readPackageVersionFromJsonFile(candidate: string): string | null {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(candidate, 'utf8');
|
||||||
|
const json = JSON.parse(raw) as { version?: unknown } | null;
|
||||||
|
if (json && typeof json.version === 'string' && json.version.trim().length > 0) {
|
||||||
|
return json.version.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readVersionFromTextFile(candidate: string): string | null {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(candidate, 'utf8').trim();
|
||||||
|
return raw.length > 0 ? raw : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStartDir(importMetaUrl?: string): string {
|
||||||
|
if (typeof importMetaUrl === 'string' && importMetaUrl.trim().length > 0) {
|
||||||
|
try {
|
||||||
|
return path.dirname(fileURLToPath(importMetaUrl));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof __dirname === 'string' && __dirname.length > 0) return __dirname;
|
||||||
|
|
||||||
|
return process.cwd();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePackageVersion(importMetaUrl?: string): string {
|
||||||
|
const injected =
|
||||||
|
typeof process !== 'undefined' && typeof process.env.BIRD_VERSION === 'string'
|
||||||
|
? process.env.BIRD_VERSION.trim()
|
||||||
|
: '';
|
||||||
|
if (injected.length > 0) return injected;
|
||||||
|
|
||||||
|
let dir = resolveStartDir(importMetaUrl);
|
||||||
|
for (let i = 0; i < 10; i += 1) {
|
||||||
|
const version =
|
||||||
|
readPackageVersionFromJsonFile(path.join(dir, 'package.json')) ??
|
||||||
|
readVersionFromTextFile(path.join(dir, 'VERSION'));
|
||||||
|
if (version) return version;
|
||||||
|
|
||||||
|
const parent = path.dirname(dir);
|
||||||
|
if (parent === dir) break;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FALLBACK_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateSha(sha: string, length = 8): string {
|
||||||
|
const trimmed = sha.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
if (trimmed.length <= length) return trimmed;
|
||||||
|
return trimmed.slice(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGitShaFromGitDir(gitDir: string): string | null {
|
||||||
|
const headPath = path.join(gitDir, 'HEAD');
|
||||||
|
let head = '';
|
||||||
|
try {
|
||||||
|
head = fs.readFileSync(headPath, 'utf8').trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!head) return null;
|
||||||
|
if (!head.startsWith('ref:')) {
|
||||||
|
const sha = truncateSha(head);
|
||||||
|
return sha.length > 0 ? sha : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ref = head.replace(/^ref:\s*/i, '').trim();
|
||||||
|
if (!ref) return null;
|
||||||
|
|
||||||
|
const refPath = path.join(gitDir, ref);
|
||||||
|
try {
|
||||||
|
const sha = truncateSha(fs.readFileSync(refPath, 'utf8'));
|
||||||
|
return sha.length > 0 ? sha : null;
|
||||||
|
} catch {
|
||||||
|
// fall through to packed-refs
|
||||||
|
}
|
||||||
|
|
||||||
|
const packedRefsPath = path.join(gitDir, 'packed-refs');
|
||||||
|
try {
|
||||||
|
const packed = fs.readFileSync(packedRefsPath, 'utf8');
|
||||||
|
const lines = packed.split(/\r?\n/);
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line || line.startsWith('#') || line.startsWith('^')) continue;
|
||||||
|
const [shaRaw, refName] = line.split(' ');
|
||||||
|
if (refName?.trim() === ref) {
|
||||||
|
const sha = truncateSha(shaRaw ?? '');
|
||||||
|
return sha.length > 0 ? sha : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGitSha(importMetaUrl?: string): string | null {
|
||||||
|
const injected =
|
||||||
|
typeof process !== 'undefined' && typeof process.env.BIRD_GIT_SHA === 'string'
|
||||||
|
? process.env.BIRD_GIT_SHA.trim()
|
||||||
|
: '';
|
||||||
|
if (injected.length > 0) return truncateSha(injected);
|
||||||
|
|
||||||
|
let dir = resolveStartDir(importMetaUrl);
|
||||||
|
for (let i = 0; i < 10; i += 1) {
|
||||||
|
const dotGit = path.join(dir, '.git');
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(dotGit);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
const sha = resolveGitShaFromGitDir(dotGit);
|
||||||
|
if (sha) return sha;
|
||||||
|
} else if (stat.isFile()) {
|
||||||
|
const txt = fs.readFileSync(dotGit, 'utf8');
|
||||||
|
const match = txt.match(/gitdir:\s*(.+)\s*$/i);
|
||||||
|
const gitDir = match?.[1]?.trim();
|
||||||
|
if (gitDir) {
|
||||||
|
const resolved = path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir);
|
||||||
|
const sha = resolveGitShaFromGitDir(resolved);
|
||||||
|
if (sha) return sha;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = path.dirname(dir);
|
||||||
|
if (parent === dir) break;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatVersionLine(importMetaUrl?: string): string {
|
||||||
|
const version = resolvePackageVersion(importMetaUrl);
|
||||||
|
const sha = resolveGitSha(importMetaUrl);
|
||||||
|
return sha ? `${version} (${sha})` : version;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCliVersion(): string {
|
||||||
|
return formatVersionLine(import.meta.url);
|
||||||
|
}
|
||||||
+13
-1
@@ -2,7 +2,19 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { looksLikeTweetInput, resolveCliInvocation } from '../src/lib/cli-args.js';
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { mentionsQueryFromUserOption, normalizeHandle } from '../src/lib/normalize-handle.js';
|
||||||
|
|
||||||
|
describe('normalizeHandle', () => {
|
||||||
|
it('accepts bare handle', () => {
|
||||||
|
expect(normalizeHandle('steipete')).toBe('steipete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts @handle and trims whitespace', () => {
|
||||||
|
expect(normalizeHandle(' @steipete ')).toBe('steipete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty input', () => {
|
||||||
|
expect(normalizeHandle('')).toBeNull();
|
||||||
|
expect(normalizeHandle(' ')).toBeNull();
|
||||||
|
expect(normalizeHandle(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid characters and too-long handles', () => {
|
||||||
|
expect(normalizeHandle('@stei-pete')).toBeNull();
|
||||||
|
expect(normalizeHandle('@steipete!')).toBeNull();
|
||||||
|
expect(normalizeHandle('a'.repeat(16))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mentionsQueryFromUserOption', () => {
|
||||||
|
it('returns null query when option omitted', () => {
|
||||||
|
expect(mentionsQueryFromUserOption(undefined)).toEqual({ query: null, error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns normalized @query for valid handle', () => {
|
||||||
|
expect(mentionsQueryFromUserOption('@steipete')).toEqual({ query: '@steipete', error: null });
|
||||||
|
expect(mentionsQueryFromUserOption(' steipete ')).toEqual({ query: '@steipete', error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error for invalid handle', () => {
|
||||||
|
const result = mentionsQueryFromUserOption('@stei-pete');
|
||||||
|
expect(result.query).toBeNull();
|
||||||
|
expect(result.error).toMatch(/Invalid --user handle/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { labelPrefix, resolveOutputConfigFromArgv, statusPrefix } from '../src/lib/output.js';
|
||||||
|
|
||||||
|
describe('output', () => {
|
||||||
|
it('plain disables emoji + color', () => {
|
||||||
|
const cfg = resolveOutputConfigFromArgv(['--plain'], {}, true);
|
||||||
|
expect(cfg).toEqual({ plain: true, emoji: false, color: false });
|
||||||
|
expect(statusPrefix('ok', cfg)).toBe('[ok] ');
|
||||||
|
expect(labelPrefix('url', cfg)).toBe('url: ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NO_COLOR disables colors by default', () => {
|
||||||
|
const cfg = resolveOutputConfigFromArgv([], { NO_COLOR: '1' }, true);
|
||||||
|
expect(cfg.color).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('--no-emoji switches to text prefixes', () => {
|
||||||
|
const cfg = resolveOutputConfigFromArgv(['--no-emoji'], {}, true);
|
||||||
|
expect(cfg.emoji).toBe(false);
|
||||||
|
expect(statusPrefix('warn', cfg)).toBe('Warning: ');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { mkdir, readFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createRuntimeQueryIdStore } from '../src/lib/runtime-query-ids.js';
|
||||||
|
|
||||||
|
describe('runtime-query-ids', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes IDs by scanning discovered bundles and persists cache', async () => {
|
||||||
|
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
|
||||||
|
await mkdir(cacheDir, { recursive: true });
|
||||||
|
const cachePath = path.join(cacheDir, 'query-ids.json');
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<html>
|
||||||
|
<script src="https://abs.twimg.com/responsive-web/client-web/main.test.js"></script>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
const js = [
|
||||||
|
`e.exports={queryId:"AAA",operationName:"CreateTweet"}`,
|
||||||
|
`e.exports={queryId:"BBB",operationName:"CreateRetweet"}`,
|
||||||
|
`e.exports={queryId:"CCC",operationName:"FavoriteTweet"}`,
|
||||||
|
`e.exports={queryId:"DDD",operationName:"TweetDetail"}`,
|
||||||
|
`e.exports={queryId:"EEE",operationName:"SearchTimeline"}`,
|
||||||
|
`e.exports={queryId:"FFF",operationName:"UserArticlesTweets"}`,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async (url: string | URL) => {
|
||||||
|
const asString = String(url);
|
||||||
|
if (asString.startsWith('https://x.com/')) {
|
||||||
|
return new Response(html, { status: 200 });
|
||||||
|
}
|
||||||
|
if (asString.startsWith('https://abs.twimg.com/')) {
|
||||||
|
return new Response(js, { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const store = createRuntimeQueryIdStore({
|
||||||
|
cachePath,
|
||||||
|
ttlMs: 1000,
|
||||||
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
const info = await store.refresh(
|
||||||
|
['CreateTweet', 'CreateRetweet', 'FavoriteTweet', 'TweetDetail', 'SearchTimeline', 'UserArticlesTweets'],
|
||||||
|
{ force: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(info?.snapshot.ids.CreateTweet).toBe('AAA');
|
||||||
|
expect(info?.snapshot.ids.TweetDetail).toBe('DDD');
|
||||||
|
expect(info?.cachePath).toBe(cachePath);
|
||||||
|
|
||||||
|
const raw = await readFile(cachePath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as { ids?: Record<string, string> };
|
||||||
|
expect(parsed.ids?.SearchTimeline).toBe('EEE');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -79,6 +79,79 @@ describe('TwitterClient', () => {
|
|||||||
|
|
||||||
const body = JSON.parse(options.body);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { formatVersionLine, resolveGitSha, resolvePackageVersion } from '../src/lib/version.js';
|
||||||
|
|
||||||
|
describe('getCliVersion', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.BIRD_VERSION;
|
||||||
|
delete process.env.BIRD_GIT_SHA;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads package.json version when available', () => {
|
||||||
|
const raw = readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8');
|
||||||
|
const pkg = JSON.parse(raw) as { version?: unknown };
|
||||||
|
expect(resolvePackageVersion()).toBe(pkg.version);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats injected version + sha', () => {
|
||||||
|
process.env.BIRD_VERSION = '9.9.9';
|
||||||
|
process.env.BIRD_GIT_SHA = 'abcdef123456';
|
||||||
|
expect(formatVersionLine('not a url')).toBe('9.9.9 (abcdef12)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds git sha when available', () => {
|
||||||
|
const version = resolvePackageVersion();
|
||||||
|
const sha = resolveGitSha();
|
||||||
|
expect(sha).toMatch(/^[0-9a-f]{8}$/);
|
||||||
|
expect(formatVersionLine()).toBe(`${version} (${sha})`);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user