diff --git a/CHANGELOG.md b/CHANGELOG.md index 6423d77..77082b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog -## 0.2.1 — Unreleased +## 0.3.0 — Unreleased + +### Changed +- Removed the Sweetistics engine + fallback. `bird` is GraphQL-only. ## 0.2.0 — 2025-12-26 diff --git a/README.md b/README.md index 255e20f..4ac3136 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # bird 🐦 — fast X CLI for tweeting, replying, and reading -`bird` is a fast X CLI for tweeting, replying, and reading. It uses either GraphQL cookies or the Sweetistics API. +`bird` is a fast X CLI for tweeting, replying, and reading via X/Twitter GraphQL (cookie auth). ## Install @@ -51,14 +51,6 @@ bird query-ids --fresh - `bird whoami` — print which Twitter account your cookies belong to. - `bird check` — show which credentials are available and where they were sourced from. -## Engines - -- `--engine auto` (default) — use GraphQL first; if a Sweetistics API key is available, fall back on errors. -- `--engine graphql` — use Twitter/X GraphQL with cookies (Chrome/Firefox/env/flags). -- `--engine sweetistics` — use Sweetistics API key (no browser cookies needed). - -Note: fallback to Sweetistics only happens in `--engine auto`. `--engine graphql` is strict (no fallback). - Global options: - `--timeout `: abort requests after the given timeout (milliseconds). - `--plain`: stable output (no emoji, no color). @@ -91,9 +83,7 @@ Example `~/.config/bird/config.json5`: ```json5 { - engine: "auto", firefoxProfile: "default-release", - sweetisticsApiKey: "sweet-...", allowFirefox: true, allowChrome: false, timeoutMs: 20000 @@ -101,8 +91,7 @@ Example `~/.config/bird/config.json5`: ``` Environment shortcuts: -- `SWEETISTICS_API_KEY`, `SWEETISTICS_BASE_URL` -- `BIRD_ENGINE`, `BIRD_TIMEOUT_MS` +- `BIRD_TIMEOUT_MS` ## Output @@ -143,19 +132,18 @@ Exit codes: ## Version -`bird --version` prints `package.json` version plus current git sha when available, e.g. `0.2.0 (3df7969b)`. +`bird --version` prints `package.json` version plus current git sha when available, e.g. `0.3.0 (3df7969b)`. ## Media uploads - Attach media with `--media` (repeatable) and optional `--alt` per item. - Up to 4 images/GIFs, or 1 video (no mixing). Supported: jpg, jpeg, png, webp, gif, mp4, mov. -- `--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`). +- Images/GIFs + 1 video supported (uploads via Twitter legacy upload endpoint + cookies; video may take longer to process). Example: ```bash -bird --engine sweetistics tweet "hi" --media img.png --alt "desc" +bird tweet "hi" --media img.png --alt "desc" ``` ## Development diff --git a/package.json b/package.json index 607fbdf..2d5d3aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@steipete/bird", - "version": "0.2.1", + "version": "0.3.0", "description": "CLI tool for tweeting and replying via Twitter/X GraphQL API", "type": "module", "main": "dist/index.js", @@ -29,7 +29,6 @@ }, "dependencies": { "commander": "^14.0.2", - "devalue": "^5.6.1", "json5": "^2.2.3", "kleur": "^4.1.5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bdcd0a..114e339 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: commander: specifier: ^14.0.2 version: 14.0.2 - devalue: - specifier: ^5.6.1 - version: 5.6.1 json5: specifier: ^2.2.3 version: 2.2.3 @@ -469,9 +466,6 @@ packages: supports-color: optional: true - devalue@5.6.1: - resolution: {integrity: sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==} - es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -1003,8 +997,6 @@ snapshots: dependencies: ms: 2.1.3 - devalue@5.6.1: {} - es-module-lexer@1.7.0: {} esbuild@0.27.2: diff --git a/scripts/update-query-ids.ts b/scripts/update-query-ids.ts index 4a27b5c..91b062f 100644 --- a/scripts/update-query-ids.ts +++ b/scripts/update-query-ids.ts @@ -1,7 +1,7 @@ #!/usr/bin/env tsx /** * Fetches current Twitter/X GraphQL query IDs from public client bundles and - * updates src/lib/query-ids.json. Inspired by Sweetistics' extractor. + * updates src/lib/query-ids.json. */ import fs from 'node:fs/promises'; diff --git a/src/index.ts b/src/index.ts index 84a67bd..4a603d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,12 +17,6 @@ import JSON5 from 'json5'; import kleur from 'kleur'; import { resolveCliInvocation } from './lib/cli-args.js'; import { resolveCredentials } from './lib/cookies.js'; -import { - type EngineMode, - resolveEngineMode, - shouldAllowSweetisticsFallback, - shouldUseSweetistics, -} from './lib/engine.js'; import { extractTweetId } from './lib/extract-tweet-id.js'; import { mentionsQueryFromUserOption, normalizeHandle } from './lib/normalize-handle.js'; import { @@ -34,7 +28,6 @@ import { statusPrefix, } from './lib/output.js'; import { runtimeQueryIds } from './lib/runtime-query-ids.js'; -import { SweetisticsClient } from './lib/sweetistics-client.js'; import { type TweetData, TwitterClient } from './lib/twitter-client.js'; import { getCliVersion } from './lib/version.js'; @@ -55,14 +48,6 @@ const collect = (value: string, previous: string[] = []) => { const p = (kind: Parameters[0]) => statusPrefix(kind, output); const l = (kind: Parameters[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); @@ -83,11 +68,8 @@ const colors = { }; type BirdConfig = { - engine?: EngineMode; chromeProfile?: string; firefoxProfile?: string; - sweetisticsApiKey?: string; - sweetisticsBaseUrl?: string; allowChrome?: boolean; allowFirefox?: boolean; timeoutMs?: number; @@ -164,17 +146,6 @@ program .option('--firefox-profile ', 'Firefox profile name for cookie extraction', config.firefoxProfile) .option('--media ', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, []) .option('--alt ', 'Alt text for the corresponding --media (repeatable)', collect, []) - .option('--sweetistics-api-key ', 'Sweetistics API key (or set SWEETISTICS_API_KEY)') - .option( - '--sweetistics-base-url ', - 'Sweetistics base URL', - config.sweetisticsBaseUrl || process.env.SWEETISTICS_BASE_URL || 'https://sweetistics.com', - ) - .option( - '--engine ', - 'Engine: graphql | sweetistics | auto', - process.env.BIRD_ENGINE || config.engine || 'auto', - ) .option('--timeout ', 'Request timeout in milliseconds') .option('--plain', 'Plain output (stable, no emoji, no color)') .option('--no-emoji', 'Disable emoji output') @@ -186,15 +157,6 @@ program.hook('preAction', (_thisCommand, actionCommand) => { type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer }; -function resolveSweetisticsConfig(options: { sweetisticsApiKey?: string; sweetisticsBaseUrl?: string }) { - const apiKey = - options.sweetisticsApiKey || process.env.SWEETISTICS_API_KEY || process.env.SWEETISTICS_LOCALHOST_API_KEY || null; - - const baseUrl = options.sweetisticsBaseUrl || process.env.SWEETISTICS_BASE_URL || 'https://sweetistics.com'; - - return { apiKey, baseUrl }; -} - function resolveTimeoutMs(...values: Array): number | undefined { for (const value of values) { if (value === undefined || value === null || value === '') continue; @@ -354,58 +316,6 @@ program console.error(`${p('err')}${error instanceof Error ? error.message : String(error)}`); process.exit(1); } - const sweetistics = resolveSweetisticsConfig({ - sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey, - sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl, - }); - const engine = resolveEngineMode(opts.engine); - const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); - - if (useSweetistics) { - if (!sweetistics.apiKey) { - console.error(`${p('err')}Sweetistics engine selected but no API key provided.`); - process.exit(1); - } - try { - const client = new SweetisticsClient({ - baseUrl: sweetistics.baseUrl, - apiKey: sweetistics.apiKey, - timeoutMs, - }); - let mediaIds: string[] | undefined; - if (media.length > 0) { - const uploaded: string[] = []; - for (const item of media) { - const res = await client.uploadMedia({ - data: item.buffer.toString('base64'), - mimeType: item.mime, - alt: item.alt, - }); - if (!res.success || !res.mediaId) { - throw new Error(res.error ?? 'Media upload failed'); - } - uploaded.push(res.mediaId); - } - mediaIds = uploaded; - } - const result = await client.tweet(text, undefined, mediaIds); - if (result.success) { - console.log(`${p('ok')}Tweet posted via Sweetistics!`); - if (result.tweetId) { - console.log(`${l('url')}https://x.com/i/status/${result.tweetId}`); - } - return; - } - console.error(`${p('err')}Sweetistics post failed: ${result.error ?? 'Unknown error'}`); - process.exit(1); - } catch (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); - } - } const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, @@ -449,22 +359,6 @@ program if (result.success) { console.log(`${p('ok')}Tweet posted successfully!`); console.log(`${l('url')}https://x.com/i/status/${result.tweetId}`); - } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) { - console.error(`${p('warn')}GraphQL tweet failed (${result.error}); trying Sweetistics fallback...`); - const fallback = await new SweetisticsClient({ - baseUrl: sweetistics.baseUrl, - apiKey: requireSweetisticsApiKey(sweetistics.apiKey), - timeoutMs, - }).tweet(text); - if (fallback.success) { - console.log(`${p('ok')}Tweet posted via Sweetistics (fallback)!`); - if (fallback.tweetId) { - console.log(`${l('url')}https://x.com/i/status/${fallback.tweetId}`); - } - } else { - console.error(`${p('err')}Failed to post tweet: ${result.error} | Sweetistics fallback: ${fallback.error}`); - process.exit(1); - } } else { console.error(`${p('err')}Failed to post tweet: ${result.error}`); process.exit(1); @@ -487,60 +381,8 @@ program console.error(`${p('err')}${error instanceof Error ? error.message : String(error)}`); process.exit(1); } - const sweetistics = resolveSweetisticsConfig({ - sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey, - sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl, - }); - const engine = resolveEngineMode(opts.engine); - const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); const tweetId = extractTweetId(tweetIdOrUrl); - if (useSweetistics) { - if (!sweetistics.apiKey) { - console.error(`${p('err')}Sweetistics engine selected but no API key provided.`); - process.exit(1); - } - try { - const client = new SweetisticsClient({ - baseUrl: sweetistics.baseUrl, - apiKey: sweetistics.apiKey, - timeoutMs, - }); - let mediaIds: string[] | undefined; - if (media.length > 0) { - const uploaded: string[] = []; - for (const item of media) { - const res = await client.uploadMedia({ - data: item.buffer.toString('base64'), - mimeType: item.mime, - alt: item.alt, - }); - if (!res.success || !res.mediaId) { - throw new Error(res.error ?? 'Media upload failed'); - } - uploaded.push(res.mediaId); - } - mediaIds = uploaded; - } - const result = await client.tweet(text, tweetId, mediaIds); - if (result.success) { - console.log(`${p('ok')}Reply posted via Sweetistics!`); - if (result.tweetId) { - console.log(`${l('url')}https://x.com/i/status/${result.tweetId}`); - } - return; - } - console.error(`${p('err')}Sweetistics reply failed: ${result.error ?? 'Unknown error'}`); - process.exit(1); - } catch (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); - } - } - const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, ct0: opts.ct0, @@ -585,22 +427,6 @@ program if (result.success) { console.log(`${p('ok')}Reply posted successfully!`); console.log(`${l('url')}https://x.com/i/status/${result.tweetId}`); - } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) { - console.error(`${p('warn')}GraphQL reply failed (${result.error}); trying Sweetistics fallback...`); - const fallback = await new SweetisticsClient({ - baseUrl: sweetistics.baseUrl, - apiKey: requireSweetisticsApiKey(sweetistics.apiKey), - timeoutMs, - }).tweet(text, tweetId); - if (fallback.success) { - console.log(`${p('ok')}Reply posted via Sweetistics (fallback)!`); - if (fallback.tweetId) { - console.log(`${l('url')}https://x.com/i/status/${fallback.tweetId}`); - } - } else { - console.error(`${p('err')}Failed to post reply: ${result.error} | Sweetistics fallback: ${fallback.error}`); - process.exit(1); - } } else { console.error(`${p('err')}Failed to post reply: ${result.error}`); process.exit(1); @@ -616,41 +442,8 @@ program .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); - const sweetistics = resolveSweetisticsConfig({ - sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey, - sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl, - }); - const engine = resolveEngineMode(opts.engine); - const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); const tweetId = extractTweetId(tweetIdOrUrl); - if (useSweetistics) { - if (!sweetistics.apiKey) { - console.error(`${p('err')}Sweetistics engine selected but no API key provided.`); - process.exit(1); - } - const client = new SweetisticsClient({ - baseUrl: sweetistics.baseUrl, - apiKey: sweetistics.apiKey, - timeoutMs, - }); - const result = await client.read(tweetId); - if (result.success && result.tweet) { - if (cmdOpts.json) { - console.log(JSON.stringify(result.tweet, null, 2)); - } else { - console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`); - console.log(result.tweet.text); - if (result.tweet.createdAt) { - console.log(`\n${l('date')}${result.tweet.createdAt}`); - } - console.log(formatStatsLine(result.tweet, output)); - } - return; - } - console.error(`${p('err')}Failed to read tweet via Sweetistics: ${result.error ?? 'Unknown error'}`); - process.exit(1); - } const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, @@ -684,28 +477,6 @@ program } console.log(formatStatsLine(result.tweet, output)); } - } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) { - console.error(`${p('warn')}GraphQL read failed (${result.error}); trying Sweetistics fallback...`); - const fallback = await new SweetisticsClient({ - baseUrl: sweetistics.baseUrl, - apiKey: requireSweetisticsApiKey(sweetistics.apiKey), - timeoutMs, - }).read(tweetId); - if (fallback.success && fallback.tweet) { - if (cmdOpts.json) { - console.log(JSON.stringify(fallback.tweet, null, 2)); - } else { - console.log(`@${fallback.tweet.author.username} (${fallback.tweet.author.name}):`); - console.log(fallback.tweet.text); - if (fallback.tweet.createdAt) { - console.log(`\n${l('date')}${fallback.tweet.createdAt}`); - } - console.log(formatStatsLine(fallback.tweet, output)); - } - } else { - console.error(`${p('err')}Failed to read tweet: ${result.error} | Sweetistics fallback: ${fallback.error}`); - process.exit(1); - } } else { console.error(`${p('err')}Failed to read tweet: ${result.error}`); process.exit(1); @@ -721,31 +492,7 @@ program .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); - const sweetistics = resolveSweetisticsConfig({ - sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey, - sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl, - }); - const engine = resolveEngineMode(opts.engine); - const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); const tweetId = extractTweetId(tweetIdOrUrl); - if (useSweetistics) { - if (!sweetistics.apiKey) { - console.error(`${p('err')}Sweetistics engine selected but no API key provided.`); - process.exit(1); - } - const client = new SweetisticsClient({ - baseUrl: sweetistics.baseUrl, - apiKey: sweetistics.apiKey, - timeoutMs, - }); - const result = await client.replies(tweetId); - if (result.success && result.tweets) { - printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' }); - return; - } - console.error(`${p('err')}Failed to fetch replies via Sweetistics: ${result.error}`); - process.exit(1); - } const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, @@ -768,19 +515,6 @@ program if (result.success && result.tweets) { printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' }); - } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) { - console.error(`${p('warn')}GraphQL replies failed (${result.error}); trying Sweetistics fallback...`); - const fallback = await new SweetisticsClient({ - baseUrl: sweetistics.baseUrl, - apiKey: requireSweetisticsApiKey(sweetistics.apiKey), - timeoutMs, - }).replies(tweetId); - if (fallback.success && fallback.tweets) { - printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' }); - } else { - console.error(`${p('err')}Failed to fetch replies: ${result.error} | Sweetistics fallback: ${fallback.error}`); - process.exit(1); - } } else { console.error(`${p('err')}Failed to fetch replies: ${result.error}`); process.exit(1); @@ -796,31 +530,7 @@ program .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); - const sweetistics = resolveSweetisticsConfig({ - sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey, - sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl, - }); - const engine = resolveEngineMode(opts.engine); - const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); const tweetId = extractTweetId(tweetIdOrUrl); - if (useSweetistics) { - if (!sweetistics.apiKey) { - console.error(`${p('err')}Sweetistics engine selected but no API key provided.`); - process.exit(1); - } - const client = new SweetisticsClient({ - baseUrl: sweetistics.baseUrl, - apiKey: sweetistics.apiKey, - timeoutMs, - }); - const result = await client.thread(tweetId); - if (result.success && result.tweets) { - printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' }); - return; - } - console.error(`${p('err')}Failed to fetch thread via Sweetistics: ${result.error}`); - process.exit(1); - } const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, @@ -843,19 +553,6 @@ program if (result.success && result.tweets) { printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' }); - } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) { - console.error(`${p('warn')}GraphQL thread failed (${result.error}); trying Sweetistics fallback...`); - const fallback = await new SweetisticsClient({ - baseUrl: sweetistics.baseUrl, - apiKey: requireSweetisticsApiKey(sweetistics.apiKey), - timeoutMs, - }).thread(tweetId); - if (fallback.success && fallback.tweets) { - printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' }); - } else { - console.error(`${p('err')}Failed to fetch thread: ${result.error} | Sweetistics fallback: ${fallback.error}`); - process.exit(1); - } } else { console.error(`${p('err')}Failed to fetch thread: ${result.error}`); process.exit(1); @@ -873,31 +570,6 @@ program const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); const count = Number.parseInt(cmdOpts.count || '10', 10); - const sweetistics = resolveSweetisticsConfig({ - sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey, - sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl, - }); - const engine = resolveEngineMode(opts.engine); - const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); - - if (useSweetistics) { - if (!sweetistics.apiKey) { - console.error(`${p('err')}Sweetistics engine selected but no API key provided.`); - process.exit(1); - } - const client = new SweetisticsClient({ - baseUrl: sweetistics.baseUrl, - apiKey: sweetistics.apiKey, - timeoutMs, - }); - const result = await client.search(query, count); - if (result.success && result.tweets) { - printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' }); - return; - } - console.error(`${p('err')}Search failed via Sweetistics: ${result.error}`); - process.exit(1); - } const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, @@ -920,19 +592,6 @@ program if (result.success && result.tweets) { printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' }); - } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) { - console.error(`${p('warn')}GraphQL search 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 tweets found.' }); - } else { - console.error(`${p('err')}Search failed: ${result.error} | Sweetistics fallback: ${fallback.error}`); - process.exit(1); - } } else { console.error(`${p('err')}Search failed: ${result.error}`); process.exit(1); @@ -950,9 +609,6 @@ program const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); const count = Number.parseInt(cmdOpts.count || '10', 10); - const sweetistics = resolveSweetisticsConfig(opts); - const engine = resolveEngineMode(opts.engine); - const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user); if (fromUserOpt.error) { @@ -962,42 +618,13 @@ program let query: string | null = fromUserOpt.query; - if (useSweetistics) { - if (!sweetistics.apiKey) { - console.error(`${p('err')}Sweetistics engine selected but no API key provided.`); - process.exit(1); - } - const client = new SweetisticsClient({ - baseUrl: sweetistics.baseUrl, - apiKey: sweetistics.apiKey, - timeoutMs, - }); - - if (!query) { - const who = await client.getCurrentUser(); - const handle = normalizeHandle(who.user?.username); - if (!handle) { - console.error( - `${p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user .`, - ); - process.exit(1); - } - query = `@${handle}`; - } - - const result = await client.search(query, count); - if (result.success && result.tweets) { - printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' }); - return; - } - console.error(`${p('err')}Failed to fetch mentions via Sweetistics: ${result.error}`); - process.exit(1); - } - const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, ct0: opts.ct0, - chromeProfile: opts.chromeProfile, + chromeProfile: opts.chromeProfile || config.chromeProfile, + firefoxProfile: opts.firefoxProfile || config.firefoxProfile, + allowChrome: config.allowChrome ?? true, + allowFirefox: config.allowFirefox ?? true, }); for (const warning of warnings) { @@ -1015,27 +642,10 @@ program const who = await client.getCurrentUser(); const handle = normalizeHandle(who.user?.username); if (!handle) { - if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) { - console.error( - `${p('warn')}Could not determine current user (${who.error ?? 'Unknown error'}); trying Sweetistics fallback...`, - ); - const fallbackWho = await new SweetisticsClient({ - baseUrl: sweetistics.baseUrl, - apiKey: requireSweetisticsApiKey(sweetistics.apiKey), - timeoutMs, - }).getCurrentUser(); - const fallbackHandle = normalizeHandle(fallbackWho.user?.username); - if (!fallbackHandle) { - console.error(`${p('err')}Could not determine current user. Use --user .`); - process.exit(1); - } - query = `@${fallbackHandle}`; - } else { - console.error( - `${p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user .`, - ); - process.exit(1); - } + console.error( + `${p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user .`, + ); + process.exit(1); } else { query = `@${handle}`; } @@ -1045,19 +655,6 @@ program 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); @@ -1071,42 +668,6 @@ program .action(async () => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); - const sweetistics = resolveSweetisticsConfig({ - sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey, - sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl, - }); - const engine = resolveEngineMode(opts.engine || config.engine); - const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); - const resolvedEngine = useSweetistics ? 'sweetistics' : 'graphql'; - if (useSweetistics) { - if (!sweetistics.apiKey) { - console.error(`${p('err')}Sweetistics engine selected but no API key provided.`); - process.exit(1); - } - - const client = new SweetisticsClient({ - baseUrl: sweetistics.baseUrl, - apiKey: sweetistics.apiKey, - timeoutMs, - }); - const result = await client.getCurrentUser(); - - if (result.success && result.user) { - const handle = result.user.username ? `@${result.user.username}` : '(no handle)'; - const name = result.user.name || handle; - console.log(`${l('user')}${handle} (${name})`); - console.log(`${l('userId')}${result.user.id}`); - if (result.user.email) { - console.log(`${l('email')}${result.user.email}`); - } - console.log(`${l('engine')}${resolvedEngine}`); - console.log(`${l('credentials')}Sweetistics API key`); - return; - } - - console.error(`${p('err')}Failed to determine Sweetistics user: ${result.error ?? 'Unknown error'}`); - process.exit(1); - } const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, @@ -1138,29 +699,9 @@ program if (result.success && result.user) { console.log(`${l('user')}@${result.user.username} (${result.user.name})`); console.log(`${l('userId')}${result.user.id}`); - console.log(`${l('engine')}${resolvedEngine}`); + console.log(`${l('engine')}graphql`); console.log(`${l('credentials')}${credentialSource}`); } else { - // Fallback: try Sweetistics if available - if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) { - const fallback = await new SweetisticsClient({ - baseUrl: sweetistics.baseUrl, - apiKey: requireSweetisticsApiKey(sweetistics.apiKey), - timeoutMs, - }).getCurrentUser(); - if (fallback.success && fallback.user) { - const handle = fallback.user.username ? `@${fallback.user.username}` : '(no handle)'; - const name = fallback.user.name || handle; - console.log(`${l('user')}${handle} (${name})`); - console.log(`${l('userId')}${fallback.user.id}`); - if (fallback.user.email) { - console.log(`${l('email')}${fallback.user.email}`); - } - console.log(`${l('engine')}sweetistics (fallback)`); - console.log(`${l('credentials')}Sweetistics API key`); - return; - } - } console.error(`${p('err')}Failed to determine current user: ${result.error ?? 'Unknown error'}`); process.exit(1); } @@ -1175,7 +716,10 @@ program const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, ct0: opts.ct0, - chromeProfile: opts.chromeProfile, + chromeProfile: opts.chromeProfile || config.chromeProfile, + firefoxProfile: opts.firefoxProfile || config.firefoxProfile, + allowChrome: config.allowChrome ?? true, + allowFirefox: config.allowFirefox ?? true, }); console.log(`${p('info')}Credential check`); diff --git a/src/lib/engine.ts b/src/lib/engine.ts deleted file mode 100644 index c303625..0000000 --- a/src/lib/engine.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type EngineMode = 'graphql' | 'sweetistics' | 'auto'; - -export function resolveEngineMode(value?: string): EngineMode { - const normalized = (value || 'auto').toLowerCase(); - if (normalized === 'graphql' || normalized === 'sweetistics' || normalized === 'auto') { - return normalized; - } - return 'auto'; -} - -export function shouldUseSweetistics(engine: EngineMode, _hasApiKey: boolean): boolean { - if (engine === 'sweetistics') return true; - if (engine === 'graphql') return false; - return false; // auto: GraphQL primary, Sweetistics only as fallback -} - -export function shouldAllowSweetisticsFallback(engine: EngineMode, hasApiKey: boolean): boolean { - return engine === 'auto' && hasApiKey; -} diff --git a/src/lib/sweetistics-client.ts b/src/lib/sweetistics-client.ts deleted file mode 100644 index 354eb61..0000000 --- a/src/lib/sweetistics-client.ts +++ /dev/null @@ -1,537 +0,0 @@ -import { parse as devalueParse } from 'devalue'; - -export interface SweetisticsClientOptions { - baseUrl: string; - apiKey: string; - userAgent?: string; - timeoutMs?: number; -} - -export interface SweetisticsTweetResult { - success: boolean; - tweetId?: string; - error?: string; -} - -export interface SweetisticsTweet { - id: string; - text: string; - author: { - username: string; - name: string; - }; - createdAt?: string; - replyCount?: number; - retweetCount?: number; - likeCount?: number; - conversationId?: string; - inReplyToStatusId?: string; -} - -export interface SweetisticsTimelineResult { - success: boolean; - tweets?: SweetisticsTweet[]; - error?: string; -} - -export interface SweetisticsReadResult { - success: boolean; - tweet?: SweetisticsTweet; - error?: string; -} - -export interface SweetisticsUserResult { - success: boolean; - user?: { - id: string; - username?: string | null; - name?: string | null; - email?: string | null; - profileImageUrl?: string | null; - }; - error?: string; -} - -type TrpcResponseEnvelope = { result?: { data?: unknown; error?: unknown } }; - -function normalizeBaseUrl(value: string): string { - const trimmed = value.trim(); - if (!trimmed) return 'https://sweetistics.com'; - return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed; -} - -export class SweetisticsClient { - private readonly baseUrl: string; - private readonly apiKey: string; - private readonly userAgent?: string; - private readonly timeoutMs: number; - // Prevent hanging requests; keep Sweetistics calls snappy for CLI users. - private static readonly DEFAULT_REQUEST_TIMEOUT_MS = 15_000; - - constructor(options: SweetisticsClientOptions) { - this.baseUrl = normalizeBaseUrl(options.baseUrl); - this.apiKey = options.apiKey.trim(); - this.userAgent = options.userAgent; - this.timeoutMs = options.timeoutMs ?? SweetisticsClient.DEFAULT_REQUEST_TIMEOUT_MS; - if (!this.apiKey) { - throw new Error('Sweetistics API key is required'); - } - } - - async tweet(text: string, replyToTweetId?: string, mediaIds?: string[]): Promise { - const payload: Record = { text }; - if (replyToTweetId) { - payload.replyToTweetId = replyToTweetId; - } - if (mediaIds && mediaIds.length > 0) { - payload.mediaIds = mediaIds; - } - - let response: Response; - try { - response = await this.fetchWithTimeout(`${this.baseUrl}/api/actions/tweet`, { - method: 'POST', - headers: { - authorization: `Bearer ${this.apiKey}`, - 'content-type': 'application/json', - ...(this.userAgent ? { 'user-agent': this.userAgent } : {}), - }, - body: JSON.stringify(payload), - }); - } catch (error) { - return { success: false, error: this.normalizeError(error) }; - } - - let data: unknown; - try { - data = await response.json(); - } catch (error) { - return { - success: false, - error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`, - }; - } - - const success = - typeof (data as { success?: unknown })?.success === 'boolean' ? (data as { success: boolean }).success : false; - const tweetId = - typeof (data as { tweetId?: unknown })?.tweetId === 'string' ? (data as { tweetId?: string }).tweetId : undefined; - const errorMessage = - typeof (data as { error?: unknown })?.error === 'string' ? (data as { error?: string }).error : undefined; - - if (!response.ok || !success) { - const reason = errorMessage || `HTTP ${response.status}`; - return { success: false, error: reason }; - } - - return { success: true, tweetId }; - } - - async read(tweetId: string): Promise { - // Public REST route that returns a single tweet record - const url = `${this.baseUrl}/api/tweets/${encodeURIComponent(tweetId)}`; - let response: Response; - try { - response = await this.fetchWithTimeout(url, { - headers: { - authorization: `Bearer ${this.apiKey}`, - ...(this.userAgent ? { 'user-agent': this.userAgent } : {}), - }, - }); - } catch (error) { - return { success: false, error: this.normalizeError(error) }; - } - - if (!response.ok) { - return { success: false, error: `HTTP ${response.status}` }; - } - - // biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed - let data: any; - try { - data = await response.json(); - } catch (error) { - return { - success: false, - error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`, - }; - } - - if (!data?.id || !data?.author) { - return { success: false, error: 'Malformed tweet payload from Sweetistics' }; - } - - const tweet: SweetisticsTweet = { - id: String(data.id), - text: String(data.text ?? ''), - author: { - username: String(data.author.username ?? ''), - name: String(data.author.name ?? ''), - }, - createdAt: data.createdAt, - replyCount: data.metrics?.replyCount, - retweetCount: data.metrics?.retweetCount, - likeCount: data.metrics?.likeCount, - conversationId: data.conversationId, - // biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed - inReplyToStatusId: data.referencedTweets?.find?.((r: any) => r?.type === 'replied_to')?.id, - }; - - return { success: true, tweet }; - } - - async replies(tweetId: string): Promise { - return this.fetchConversation(tweetId, { excludeRoot: true }); - } - - async thread(tweetId: string): Promise { - return this.fetchConversation(tweetId, { excludeRoot: false }); - } - - async search(query: string, count: number): Promise { - const payload = { - query, - capabilities: [ - { - resource: 'tweets', - source: 'postgres', - limit: Math.max(1, Math.min(count, 50)), - offset: 0, - detail: 'summary', - includeRetweets: true, - }, - ], - }; - - let response: Response; - try { - response = await this.fetchWithTimeout(`${this.baseUrl}/api/trpc/search.execute?batch=1`, { - method: 'POST', - headers: { - authorization: `Bearer ${this.apiKey}`, - 'content-type': 'application/json', - ...(this.userAgent ? { 'user-agent': this.userAgent } : {}), - }, - body: JSON.stringify({ 0: { json: payload } }), - }); - } catch (error) { - return { success: false, error: this.normalizeError(error) }; - } - - if (!response.ok) { - return { success: false, error: `HTTP ${response.status}` }; - } - - // biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed - let body: any; - try { - body = await response.json(); - } catch (error) { - return { - success: false, - error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`, - }; - } - - const envelope = Array.isArray(body) ? body[0] : body; - if (envelope?.error) { - const message = envelope.error?.message ?? 'Unknown search error'; - return { success: false, error: message }; - } - const dataField = envelope?.result?.data; - if (!dataField) { - return { success: false, error: 'Missing data in Sweetistics search response' }; - } - - // biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed - let parsed: any; - try { - parsed = typeof dataField === 'string' ? devalueParse(dataField) : dataField; - } catch (error) { - return { - success: false, - error: `Sweetistics search decode failed: ${error instanceof Error ? error.message : String(error)}`, - }; - } - - const tweetsSection = parsed?.tweets; - // biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed - const items: any[] = tweetsSection?.items ?? []; - if (items.length === 0) { - return { success: true, tweets: [] }; - } - - const tweets: SweetisticsTweet[] = items.map((item) => { - const authorUsername = item.authorUsername || item.username || item.full?.author?.username || ''; - const authorName = item.authorName || item.full?.author?.name || authorUsername; - const createdAt = - typeof item.createdAt === 'string' - ? item.createdAt - : (item.createdAt?.toString?.() ?? - (Array.isArray(item.createdAt) && item.createdAt[0] === 'Date' ? item.createdAt[1] : undefined)); - const metrics = item.full?.metrics ?? item.full ?? item; - - return { - id: String(item.id), - text: String(item.text ?? item.full?.text ?? ''), - author: { username: String(authorUsername), name: String(authorName) }, - createdAt, - replyCount: metrics?.replyCount ?? metrics?.replies ?? 0, - retweetCount: metrics?.retweetCount ?? metrics?.retweets ?? 0, - likeCount: metrics?.likeCount ?? metrics?.likes ?? 0, - conversationId: item.conversationId ?? item.full?.conversationId, - inReplyToStatusId: item.full?.inReplyToStatusId ?? undefined, - }; - }); - - return { success: true, tweets }; - } - - async getCurrentUser(): Promise { - const headers = { - authorization: `Bearer ${this.apiKey}`, - 'content-type': 'application/json', - ...(this.userAgent ? { 'user-agent': this.userAgent } : {}), - } satisfies Record; - - let response: Response; - try { - response = await this.fetchWithTimeout(`${this.baseUrl}/api/trpc/user.getCurrent?batch=1`, { - method: 'POST', - headers, - body: JSON.stringify({ 0: { json: null } }), - }); - } catch (error) { - return { success: false, error: this.normalizeError(error) }; - } - - // Some deployments only allow GET for queries; fall back if POST is not allowed - if (response.status === 405) { - try { - response = await this.fetchWithTimeout( - `${this.baseUrl}/api/trpc/user.getCurrent?input=${encodeURIComponent('null')}`, - { - method: 'GET', - headers, - }, - ); - } catch (error) { - return { success: false, error: this.normalizeError(error) }; - } - } - - if (!response.ok) { - return { success: false, error: `HTTP ${response.status}` }; - } - - // biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed - let body: any; - try { - body = await response.json(); - } catch (error) { - return { - success: false, - error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`, - }; - } - - const envelope = Array.isArray(body) ? body[0] : body; - const maybeError = envelope?.error?.message || envelope?.result?.error?.message; - if (maybeError) { - return { success: false, error: String(maybeError) }; - } - - let data = envelope?.result?.data?.json ?? envelope?.result?.data ?? envelope?.json ?? envelope; - - try { - if (typeof data === 'string') { - data = devalueParse(data); - } else if (data && typeof data === 'object' && 'json' in (data as Record)) { - const inner = (data as Record).json; - data = typeof inner === 'string' ? devalueParse(inner) : inner; - } - } catch (error) { - return { - success: false, - error: `Sweetistics response decode failed: ${error instanceof Error ? error.message : String(error)}`, - }; - } - - const user = data && typeof data === 'object' ? data : null; - // biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed - if (!user || typeof (user as any).id !== 'string') { - return { success: false, error: 'Malformed user payload from Sweetistics' }; - } - - return { - success: true, - user: { - // biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed - id: String((user as any).id), - username: - // biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed - (user as any).username ?? (user as any).twitterUsername ?? null, - // biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed - name: (user as any).name ?? null, - // biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed - email: (user as any).email ?? null, - // biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed - profileImageUrl: (user as any).profileImageUrl ?? null, - }, - }; - } - - private async fetchConversation( - tweetId: string, - options: { excludeRoot: boolean; force?: boolean } = { excludeRoot: false, force: true }, - ): Promise { - const url = new URL(`${this.baseUrl}/api/trpc/tweets.getConversation`); - const input: Record = { tweetId }; - // Always request a fresh conversation to avoid cached Sweetistics data unless explicitly disabled - if (options.force ?? true) { - input.force = 'true'; - } - url.searchParams.set('input', JSON.stringify(input)); - - let response: Response; - try { - response = await this.fetchWithTimeout(url.toString(), { - headers: { - authorization: `Bearer ${this.apiKey}`, - ...(this.userAgent ? { 'user-agent': this.userAgent } : {}), - }, - }); - } catch (error) { - return { success: false, error: this.normalizeError(error) }; - } - - if (!response.ok) { - return { success: false, error: `HTTP ${response.status}` }; - } - - let envelope: TrpcResponseEnvelope; - try { - envelope = (await response.json()) as TrpcResponseEnvelope; - } catch (error) { - return { - success: false, - error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`, - }; - } - - if (!envelope?.result?.data) { - return { success: false, error: 'Missing data in Sweetistics response' }; - } - - // biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed - let parsed: any; - try { - const raw = - typeof envelope.result.data === 'string' ? devalueParse(envelope.result.data as string) : envelope.result.data; - parsed = raw; - } catch (error) { - return { - success: false, - error: `Sweetistics response decode failed: ${error instanceof Error ? error.message : String(error)}`, - }; - } - - const tweetIds: string[] = Array.isArray(parsed?.tweetIds) ? parsed.tweetIds.map(String) : []; - // biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed - const tweetsMap: Record = parsed?.tweets ?? {}; - - if (tweetIds.length === 0 || Object.keys(tweetsMap).length === 0) { - return { success: false, error: 'Conversation empty or unavailable' }; - } - - const rootId = tweetId; - const filteredIds = options.excludeRoot ? tweetIds.filter((id) => id !== rootId) : tweetIds; - - const tweets: SweetisticsTweet[] = filteredIds - .map((id) => tweetsMap[id]) - .filter(Boolean) - // biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed - .map((item: any) => ({ - id: String(item.id), - text: String(item.text ?? ''), - author: { - username: String(item.author?.username ?? ''), - name: String(item.author?.name ?? ''), - }, - createdAt: item.createdAt ?? item.timestamp ?? undefined, - replyCount: item.replyCount ?? item.metrics?.replyCount, - retweetCount: item.retweetCount ?? item.metrics?.retweetCount, - likeCount: item.likeCount ?? item.metrics?.likeCount, - conversationId: item.conversationId ?? undefined, - inReplyToStatusId: item.inReplyToStatusId ?? undefined, - })); - - return { success: true, tweets }; - } - - async uploadMedia(input: { - data: string; - mimeType: string; - alt?: string; - }): Promise<{ success: boolean; mediaId?: string; error?: string }> { - try { - const response = await this.fetchWithTimeout(`${this.baseUrl}/api/actions/media/upload`, { - method: 'POST', - headers: { - authorization: `Bearer ${this.apiKey}`, - 'content-type': 'application/json', - ...(this.userAgent ? { 'user-agent': this.userAgent } : {}), - }, - body: JSON.stringify(input), - }); - - let body: unknown; - try { - body = await response.json(); - } catch (error) { - return { success: false, error: this.normalizeError(error) }; - } - - const mediaId = - typeof (body as { mediaId?: unknown })?.mediaId === 'string' - ? (body as { mediaId: string }).mediaId - : undefined; - const errorMessage = - typeof (body as { error?: unknown })?.error === 'string' ? (body as { error: string }).error : undefined; - const errorCode = - typeof (body as { code?: unknown })?.code === 'string' ? (body as { code: string }).code : undefined; - - if (!response.ok || !mediaId) { - const details = [errorCode, errorMessage].filter(Boolean).join(': '); - return { success: false, error: details.length > 0 ? details : `HTTP ${response.status}` }; - } - return { success: true, mediaId }; - } catch (error) { - return { success: false, error: this.normalizeError(error) }; - } - } - - private async fetchWithTimeout(url: string, init?: RequestInit): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout( - () => controller.abort(new Error(`Request timed out after ${this.timeoutMs}ms`)), - this.timeoutMs, - ); - - try { - return await fetch(url, { ...init, signal: controller.signal }); - } finally { - clearTimeout(timeoutId); - } - } - - private normalizeError(error: unknown): string { - if (error instanceof Error) { - if ((error as { name?: string }).name === 'AbortError') { - return 'Request timed out'; - } - return error.message; - } - return String(error); - } -} diff --git a/tests/cli-args.test.ts b/tests/cli-args.test.ts index 5bf1ee5..6aeef7b 100644 --- a/tests/cli-args.test.ts +++ b/tests/cli-args.test.ts @@ -45,15 +45,8 @@ describe('cli-args', () => { }); it('preserves leading options before inferred read command', () => { - const result = resolveCliInvocation(['--engine', 'sweetistics', 'https://x.com/user/status/1234567890'], known); - expect(result.argv).toEqual([ - 'node', - 'bird', - '--engine', - 'sweetistics', - 'read', - 'https://x.com/user/status/1234567890', - ]); + const result = resolveCliInvocation(['--plain', 'https://x.com/user/status/1234567890'], known); + expect(result.argv).toEqual(['node', 'bird', '--plain', 'read', 'https://x.com/user/status/1234567890']); }); it('does not rewrite when a known command is provided', () => { diff --git a/tests/engine.test.ts b/tests/engine.test.ts deleted file mode 100644 index 5c745f2..0000000 --- a/tests/engine.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { resolveEngineMode, shouldAllowSweetisticsFallback, shouldUseSweetistics } from '../src/lib/engine.js'; - -describe('engine', () => { - it('normalizes engine values and defaults to auto', () => { - expect(resolveEngineMode()).toBe('auto'); - expect(resolveEngineMode('graphql')).toBe('graphql'); - expect(resolveEngineMode('sweetistics')).toBe('sweetistics'); - expect(resolveEngineMode('AUTO')).toBe('auto'); - expect(resolveEngineMode('nope')).toBe('auto'); - }); - - it('uses GraphQL as primary in auto mode', () => { - expect(shouldUseSweetistics('auto', false)).toBe(false); - expect(shouldUseSweetistics('auto', true)).toBe(false); - }); - - it('forces engine selection when explicitly set', () => { - expect(shouldUseSweetistics('sweetistics', false)).toBe(true); - expect(shouldUseSweetistics('graphql', true)).toBe(false); - }); - - it('only allows Sweetistics fallback in auto mode', () => { - expect(shouldAllowSweetisticsFallback('auto', true)).toBe(true); - expect(shouldAllowSweetisticsFallback('auto', false)).toBe(false); - expect(shouldAllowSweetisticsFallback('graphql', true)).toBe(false); - expect(shouldAllowSweetisticsFallback('sweetistics', true)).toBe(false); - }); -}); diff --git a/tests/sweetistics-client.test.ts b/tests/sweetistics-client.test.ts deleted file mode 100644 index ef9eb91..0000000 --- a/tests/sweetistics-client.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SweetisticsClient } from '../src/lib/sweetistics-client.js'; - -describe('SweetisticsClient', () => { - const originalFetch = globalThis.fetch; - - beforeEach(() => { - vi.resetAllMocks(); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it('posts tweet with bearer token and reply id', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ success: true, tweetId: '123' }), - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' }); - const result = await client.tweet('hello world', '456'); - - expect(result.success).toBe(true); - expect(result.tweetId).toBe('123'); - - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe('https://api.example.com/api/actions/tweet'); - expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' }); - expect(JSON.parse((init as RequestInit).body as string)).toEqual({ text: 'hello world', replyToTweetId: '456' }); - }); - - it('returns error when API responds with failure', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: false, - status: 401, - json: async () => ({ success: false, error: 'Unauthorized' }), - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' }); - const result = await client.tweet('test'); - - expect(result.success).toBe(false); - expect(result.error).toContain('Unauthorized'); - }); - - it('uploads media and passes mediaIds into tweet', async () => { - const fetchMock = vi - .fn() - // upload - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ mediaId: 'mid-1' }), - }) - // tweet - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ success: true, tweetId: '789' }), - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' }); - const uploadRes = await client.uploadMedia({ data: 'Zm9v', mimeType: 'image/png', alt: 'foo' }); - expect(uploadRes.success).toBe(true); - expect(uploadRes.mediaId).toBe('mid-1'); - - const tweetRes = await client.tweet('hi', undefined, ['mid-1']); - expect(tweetRes.success).toBe(true); - - // First call: upload; second call: tweet with mediaIds - const [, tweetInit] = fetchMock.mock.calls[1]; - expect(JSON.parse((tweetInit as RequestInit).body as string)).toMatchObject({ mediaIds: ['mid-1'] }); - }); - - it('reads a tweet', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ - id: '1', - text: 'hi', - author: { username: 'u', name: 'User' }, - metrics: { likeCount: 5 }, - }), - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' }); - const result = await client.read('1'); - - expect(result.success).toBe(true); - expect(result.tweet?.id).toBe('1'); - const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe('https://api.example.com/api/tweets/1'); - expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' }); - }); - - it('fetches replies', async () => { - const conversationPayload = { - tweetIds: ['1', '2'], - tweets: { - 1: { - id: '1', - text: 'root', - author: { username: 'root', name: 'Root' }, - conversationId: 'c1', - }, - 2: { - id: '2', - text: 'reply', - author: { username: 'reply', name: 'Reply' }, - conversationId: 'c1', - inReplyToStatusId: '1', - }, - }, - }; - - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ - result: { data: conversationPayload }, - }), - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' }); - const result = await client.replies('1'); - - expect(result.success).toBe(true); - expect(result.tweets?.[0].id).toBe('2'); - const [url, init] = fetchMock.mock.calls[0]; - expect(url).toContain('/api/trpc/tweets.getConversation'); - const params = new URL(url as string).searchParams.get('input'); - expect(params).toBe(JSON.stringify({ tweetId: '1', force: 'true' })); - expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' }); - }); - - it('fetches thread', async () => { - const conversationPayload = { - tweetIds: ['1', '2'], - tweets: { - 1: { id: '1', text: 'root', author: { username: 'root', name: 'Root' }, conversationId: 'c1' }, - 2: { id: '2', text: 'reply', author: { username: 'reply', name: 'Reply' }, conversationId: 'c1' }, - }, - }; - - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ - result: { data: conversationPayload }, - }), - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' }); - const result = await client.thread('1'); - - expect(result.success).toBe(true); - expect(result.tweets?.length).toBe(2); - const [url, init] = fetchMock.mock.calls[0]; - expect(url).toContain('/api/trpc/tweets.getConversation'); - const params = new URL(url as string).searchParams.get('input'); - expect(params).toBe(JSON.stringify({ tweetId: '1', force: 'true' })); - expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' }); - }); - - it('searches tweets', async () => { - const searchPayload = { - tweets: { - items: [ - { - id: '3', - text: 'needle', - authorUsername: 'n', - authorName: 'Needle', - createdAt: '2024-01-01', - metrics: { replyCount: 1, retweetCount: 2, likeCount: 3 }, - }, - ], - }, - }; - - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => [{ result: { data: searchPayload } }], - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' }); - const result = await client.search('needle', 5); - - expect(result.success).toBe(true); - expect(result.tweets?.[0].id).toBe('3'); - const [url, init] = fetchMock.mock.calls[0]; - expect(url).toContain('/api/trpc/search.execute'); - expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' }); - }); - - it('fetches current user', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => [{ result: { data: { json: { id: 'u1', username: 'tester', name: 'Test User' } } } }], - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' }); - const result = await client.getCurrentUser(); - - expect(result.success).toBe(true); - expect(result.user?.id).toBe('u1'); - expect(result.user?.username).toBe('tester'); - const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe('https://api.example.com/api/trpc/user.getCurrent?batch=1'); - expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' }); - }); - - it('falls back to GET when POST is not allowed', async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce({ ok: false, status: 405, json: async () => ({}) }) - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ result: { data: { json: { id: 'u2', username: 'fallback' } } } }), - }); - - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' }); - const result = await client.getCurrentUser(); - - expect(result.success).toBe(true); - expect(result.user?.id).toBe('u2'); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchMock.mock.calls[1][0]).toContain('input=null'); - }); - - it('propagates Sweetistics user errors', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => [{ result: { error: { message: 'bad key' } } }], - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' }); - const result = await client.getCurrentUser(); - - expect(result.success).toBe(false); - expect(result.error).toBe('bad key'); - }); - - it('propagates Sweetistics error message', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 500, - json: async () => [{ error: { message: 'boom' } }], - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' }); - const result = await client.search('needle', 1); - - expect(result.success).toBe(false); - expect(result.error).toBe('boom'); - }); -});