diff --git a/package.json b/package.json index 04ef933..e0ea85a 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "scripts": { "build": "tsc", "dev": "tsx src/index.ts", + "bird": "pnpm run build && node dist/index.js", "test": "vitest run", "test:watch": "vitest", "lint": "biome check .", @@ -20,7 +21,9 @@ }, "dependencies": { "commander": "^12.1.0", - "devalue": "^5.1.0" + "devalue": "^5.1.0", + "json5": "^2.2.3", + "kleur": "^4.1.5" }, "devDependencies": { "@biomejs/biome": "^1.9.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f20be6..e166e9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: devalue: specifier: ^5.1.0 version: 5.5.0 + json5: + specifier: ^2.2.3 + version: 2.2.3 + kleur: + specifier: ^4.1.5 + version: 4.1.5 devDependencies: '@biomejs/biome': specifier: ^1.9.4 @@ -592,6 +598,15 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -1130,6 +1145,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + json5@2.2.3: {} + + kleur@4.1.5: {} + loupe@3.2.1: {} magic-string@0.30.21: diff --git a/src/index.ts b/src/index.ts index 3ceef0f..c4b4f47 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,11 @@ */ import { Command } from 'commander'; +import JSON5 from 'json5'; +import kleur from 'kleur'; +import { homedir } from 'node:os'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { resolveCredentials } from './lib/cookies.js'; import { extractTweetId } from './lib/extract-tweet-id.js'; import { TwitterClient, type TweetData } from './lib/twitter-client.js'; @@ -17,19 +22,87 @@ import { SweetisticsClient } from './lib/sweetistics-client.js'; const program = new Command(); +const isTty = process.stdout.isTTY; +const wrap = (styler: (text: string) => string) => (text: string) => (isTty ? styler(text) : text); + +const colors = { + banner: wrap((t) => kleur.bold().blue(t)), + subtitle: wrap((t) => kleur.dim(t)), + section: wrap((t) => kleur.bold().white(t)), + bullet: wrap((t) => kleur.blue(t)), + command: wrap((t) => kleur.bold().cyan(t)), + option: wrap((t) => kleur.cyan(t)), + argument: wrap((t) => kleur.magenta(t)), + description: wrap((t) => kleur.white(t)), + muted: wrap((t) => kleur.gray(t)), + accent: wrap((t) => kleur.green(t)), +}; + +type BirdConfig = { + engine?: EngineMode; + chromeProfile?: string; + firefoxProfile?: string; + sweetisticsApiKey?: string; + sweetisticsBaseUrl?: string; +}; + +function readConfigFile(path: string): Partial { + if (!existsSync(path)) return {}; + try { + const raw = readFileSync(path, 'utf8'); + const parsed = JSON5.parse(raw) as Partial; + return parsed ?? {}; + } catch (error) { + console.error(colors.muted(`⚠️ Failed to parse config at ${path}: ${error instanceof Error ? error.message : String(error)}`)); + return {}; + } +} + +function loadConfig(): BirdConfig { + const globalPath = join(homedir(), '.config', 'bird', 'config.json5'); + const localPath = join(process.cwd(), '.birdrc.json5'); + + return { + ...readConfigFile(globalPath), + ...readConfigFile(localPath), + }; +} + +const config = loadConfig(); + +program.addHelpText('beforeAll', () => `${colors.banner('bird CLI')} ${colors.subtitle('— tweet from your terminal')}`); + program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version('0.1.0'); +const formatExample = (command: string, description: string) => + `${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`; + +program.addHelpText( + 'afterAll', + () => `\n${colors.section('Examples')}\n${[ + formatExample('bird whoami', 'Show the logged-in account via GraphQL cookies'), + formatExample('bird --firefox-profile default-release whoami', 'Use Firefox profile cookies'), + formatExample('bird tweet "hello from bird"', 'Send a tweet'), + formatExample('bird replies https://x.com/user/status/1234567890123456789', 'Check replies to a tweet'), + ].join('\n\n')}`, +); + // Global options for authentication program .option('--auth-token ', 'Twitter auth_token cookie') .option('--ct0 ', 'Twitter ct0 cookie') - .option('--chrome-profile ', 'Chrome profile name for cookie extraction') + .option('--chrome-profile ', 'Chrome profile name for cookie extraction', config.chromeProfile) + .option('--firefox-profile ', 'Firefox profile name for cookie extraction', config.firefoxProfile) .option('--sweetistics-api-key ', 'Sweetistics API key (or set SWEETISTICS_API_KEY)') - .option('--sweetistics-base-url ', 'Sweetistics base URL', process.env.SWEETISTICS_BASE_URL || 'https://sweetistics.com') + .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 || 'auto', + process.env.BIRD_ENGINE || config.engine || 'auto', ); type EngineMode = 'graphql' | 'sweetistics' | 'auto'; @@ -92,7 +165,10 @@ program .argument('', 'Tweet text') .action(async (text: string) => { const opts = program.opts(); - const sweetistics = resolveSweetisticsConfig(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)); @@ -125,7 +201,8 @@ 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, }); for (const warning of warnings) { @@ -161,7 +238,10 @@ program .argument('', 'Reply text') .action(async (tweetIdOrUrl: string, text: string) => { const opts = program.opts(); - const sweetistics = resolveSweetisticsConfig(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); @@ -195,7 +275,8 @@ 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, }); for (const warning of warnings) { @@ -233,7 +314,10 @@ program .option('--json', 'Output as JSON') .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); - const sweetistics = resolveSweetisticsConfig(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)); @@ -267,7 +351,8 @@ 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, }); for (const warning of warnings) { @@ -309,7 +394,10 @@ program .option('--json', 'Output as JSON') .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); - const sweetistics = resolveSweetisticsConfig(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); @@ -331,7 +419,8 @@ 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, }); for (const warning of warnings) { @@ -362,7 +451,10 @@ program .option('--json', 'Output as JSON') .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); - const sweetistics = resolveSweetisticsConfig(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); @@ -384,7 +476,8 @@ 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, }); for (const warning of warnings) { @@ -417,7 +510,10 @@ program .action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => { const opts = program.opts(); const count = parseInt(cmdOpts.count || '10', 10); - const sweetistics = resolveSweetisticsConfig(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)); @@ -439,7 +535,8 @@ 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, }); for (const warning of warnings) { @@ -516,6 +613,71 @@ program } }); +// Whoami command - show the logged-in account +program + .command('whoami') + .description('Show which Twitter account the current credentials belong to') + .action(async () => { + const opts = program.opts(); + const sweetistics = resolveSweetisticsConfig(opts); + const engine = resolveEngineMode(opts.engine); + const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); + + if (useSweetistics) { + if (!sweetistics.apiKey) { + console.error('❌ Sweetistics engine selected but no API key provided.'); + process.exit(1); + } + + const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }); + 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(`🙋 Logged in via Sweetistics as ${handle} (${name})`); + console.log(`🪪 User ID: ${result.user.id}`); + if (result.user.email) { + console.log(`📧 ${result.user.email}`); + } + return; + } + + console.error(`❌ Failed to determine Sweetistics user: ${result.error ?? 'Unknown error'}`); + process.exit(1); + } + + const { cookies, warnings } = await resolveCredentials({ + authToken: opts.authToken, + ct0: opts.ct0, + chromeProfile: opts.chromeProfile, + }); + + for (const warning of warnings) { + console.error(`⚠️ ${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error('❌ Missing required credentials'); + process.exit(1); + } + + if (cookies.source) { + console.error(`📍 Using credentials from: ${cookies.source}`); + } + + const client = new TwitterClient({ cookies }); + const result = await client.getCurrentUser(); + + if (result.success && result.user) { + console.log(`🙋 Logged in as @${result.user.username} (${result.user.name})`); + console.log(`🪪 User ID: ${result.user.id}`); + } else { + console.error(`❌ Failed to determine current user: ${result.error ?? 'Unknown error'}`); + process.exit(1); + } + }); + // Check command - verify credentials program .command('check') @@ -565,4 +727,10 @@ program } }); +// Show help when invoked without any subcommand +if (process.argv.length <= 2) { + program.outputHelp(); + process.exit(0); +} + program.parse(); diff --git a/src/lib/cookies.ts b/src/lib/cookies.ts index e1bce8a..1825902 100644 --- a/src/lib/cookies.ts +++ b/src/lib/cookies.ts @@ -5,7 +5,7 @@ import { execSync } from 'node:child_process'; import { createDecipheriv, pbkdf2Sync } from 'node:crypto'; -import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -34,6 +34,26 @@ function getChromeCookiesPath(profile?: string): string { return join(home, 'Library', 'Application Support', 'Google', 'Chrome', profileDir, 'Cookies'); } +function getFirefoxCookiesPath(profile?: string): string | null { + const home = process.env.HOME || ''; + const profilesRoot = join(home, 'Library', 'Application Support', 'Firefox', 'Profiles'); + if (!existsSync(profilesRoot)) return null; + + if (profile) { + const candidate = join(profilesRoot, profile, 'cookies.sqlite'); + return existsSync(candidate) ? candidate : null; + } + + // Pick the default-release profile if present, otherwise first profile dir containing cookies.sqlite + const entries = readdirSync(profilesRoot, { withFileTypes: true }); + const defaultRelease = entries.find((entry) => entry.isDirectory() && entry.name.endsWith('.default-release')); + const targetDir = defaultRelease?.name ?? entries.find((e) => e.isDirectory())?.name; + if (!targetDir) return null; + + const candidate = join(profilesRoot, targetDir, 'cookies.sqlite'); + return existsSync(candidate) ? candidate : null; +} + /** * Decrypt Chrome cookie value using macOS keychain * Chrome encrypts cookies with a key stored in the keychain @@ -178,6 +198,74 @@ export async function extractCookiesFromChrome(profile?: string): Promise { + const warnings: string[] = []; + const cookies: TwitterCookies = { + authToken: null, + ct0: null, + source: null, + }; + + const cookiesPath = getFirefoxCookiesPath(profile); + + if (!cookiesPath) { + warnings.push('Firefox cookies database not found.'); + return { cookies, warnings }; + } + + let tempDir: string | null = null; + + try { + tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-')); + const tempDbPath = join(tempDir, 'cookies.sqlite'); + copyFileSync(cookiesPath, tempDbPath); + + const query = `SELECT name, value FROM moz_cookies WHERE host IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com') AND name IN ('auth_token', 'ct0');`; + + const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, { + encoding: 'utf8', + maxBuffer: 1024 * 1024, + }).trim(); + + if (result) { + for (const line of result.split('\n')) { + const [name, value] = line.split('|'); + if (!name || !value) continue; + if (name === 'auth_token' && !cookies.authToken) { + cookies.authToken = value; + } else if (name === 'ct0' && !cookies.ct0) { + cookies.ct0 = value; + } + } + } + + if (cookies.authToken || cookies.ct0) { + cookies.source = profile ? `Firefox profile "${profile}"` : 'Firefox default profile'; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + warnings.push(`Failed to read Firefox cookies: ${message}`); + } finally { + if (tempDir) { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + } + } + + if (!cookies.authToken && !cookies.ct0) { + warnings.push('No Twitter cookies found in Firefox. Make sure you are logged into x.com in Firefox.'); + } + + return { cookies, warnings }; +} + /** * Resolve Twitter credentials from multiple sources * Priority: CLI args > environment variables > Chrome cookies @@ -186,6 +274,7 @@ export async function resolveCredentials(options: { authToken?: string; ct0?: string; chromeProfile?: string; + firefoxProfile?: string; }): Promise { const warnings: string[] = []; const cookies: TwitterCookies = { @@ -245,12 +334,27 @@ export async function resolveCredentials(options: { } } + // 4. Firefox cookies (fallback if still missing) + if (!cookies.authToken || !cookies.ct0) { + const firefoxResult = await extractCookiesFromFirefox(options.firefoxProfile); + warnings.push(...firefoxResult.warnings); + + if (!cookies.authToken && firefoxResult.cookies.authToken) { + cookies.authToken = firefoxResult.cookies.authToken; + cookies.source = firefoxResult.cookies.source; + } + if (!cookies.ct0 && firefoxResult.cookies.ct0) { + cookies.ct0 = firefoxResult.cookies.ct0; + if (!cookies.source) cookies.source = firefoxResult.cookies.source; + } + } + // Validation if (!cookies.authToken) { - warnings.push('Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome'); + warnings.push('Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome/Firefox'); } if (!cookies.ct0) { - warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome'); + warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome/Firefox'); } return { cookies, warnings }; diff --git a/src/lib/sweetistics-client.ts b/src/lib/sweetistics-client.ts index d128acb..609a937 100644 --- a/src/lib/sweetistics-client.ts +++ b/src/lib/sweetistics-client.ts @@ -39,6 +39,18 @@ export interface SweetisticsReadResult { 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 { @@ -244,6 +256,80 @@ export class SweetisticsClient { 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 = await fetch(`${this.baseUrl}/api/trpc/user.getCurrent?batch=1`, { + method: 'POST', + headers, + body: JSON.stringify({ 0: { json: null } }), + }); + + // Some deployments only allow GET for queries; fall back if POST is not allowed + if (response.status === 405) { + response = await fetch(`${this.baseUrl}/api/trpc/user.getCurrent?input=${encodeURIComponent('null')}`, { + method: 'GET', + headers, + }); + } + + if (!response.ok) { + return { success: false, error: `HTTP ${response.status}` }; + } + + 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; + if (!user || typeof (user as any).id !== 'string') { + return { success: false, error: 'Malformed user payload from Sweetistics' }; + } + + return { + success: true, + user: { + id: String((user as any).id), + username: (user as any).username ?? (user as any).twitterUsername ?? null, + name: (user as any).name ?? null, + email: (user as any).email ?? null, + profileImageUrl: (user as any).profileImageUrl ?? null, + }, + }; + } + private async fetchConversation( tweetId: string, options: { excludeRoot: boolean }, diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index c78cd65..c2b56cc 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -81,6 +81,16 @@ export interface SearchResult { error?: string; } +export interface CurrentUserResult { + success: boolean; + user?: { + id: string; + username: string; + name: string; + }; + error?: string; +} + export interface TwitterClientOptions { cookies: TwitterCookies; userAgent?: string; @@ -683,6 +693,130 @@ export class TwitterClient { } } + /** + * Fetch the account associated with the current cookies + */ + async getCurrentUser(): Promise { + const candidateUrls = [ + 'https://x.com/i/api/account/settings.json', + 'https://api.twitter.com/1.1/account/settings.json', + 'https://x.com/i/api/account/verify_credentials.json?skip_status=true&include_entities=false', + 'https://api.twitter.com/1.1/account/verify_credentials.json?skip_status=true&include_entities=false', + ]; + + let lastError: string | undefined; + + for (const url of candidateUrls) { + try { + const response = await fetch(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (!response.ok) { + const text = await response.text(); + lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`; + continue; + } + + let data: any; + try { + data = await response.json(); + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + continue; + } + + const username = + typeof data?.screen_name === 'string' + ? data.screen_name + : typeof data?.user?.screen_name === 'string' + ? data.user.screen_name + : null; + + const name = + typeof data?.name === 'string' + ? data.name + : typeof data?.user?.name === 'string' + ? data.user.name + : username ?? ''; + + const userId = + typeof data?.user_id === 'string' + ? data.user_id + : typeof data?.user_id_str === 'string' + ? data.user_id_str + : typeof data?.user?.id_str === 'string' + ? data.user.id_str + : typeof data?.user?.id === 'string' + ? data.user.id + : null; + + if (username && userId) { + return { + success: true, + user: { + id: userId, + username, + name: name || username, + }, + }; + } + + lastError = 'Could not determine current user from response'; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + // Fallback: scrape the authenticated settings page (HTML) for screen_name/user_id + const profilePages = ['https://x.com/settings/account', 'https://twitter.com/settings/account']; + for (const page of profilePages) { + try { + const response = await fetch(page, { + headers: { + cookie: `auth_token=${this.authToken}; ct0=${this.ct0}`, + 'user-agent': this.userAgent, + }, + }); + + if (!response.ok) { + lastError = `HTTP ${response.status} (settings page)`; + continue; + } + + const html = await response.text(); + const usernameMatch = html.match(/"screen_name":"([^"]+)"/); + const idMatch = html.match(/"user_id"\s*:\s*"(\d+)"/); + const nameMatch = html.match(/"name":"([^"\\]*(?:\\.[^"\\]*)*)"/); + + const username = usernameMatch?.[1]; + const userId = idMatch?.[1]; + const name = nameMatch?.[1]?.replace(/\\"/g, '"'); + + if (username && userId) { + return { + success: true, + user: { + id: userId, + username, + name: name || username, + }, + }; + } + + lastError = 'Could not parse settings page for user info'; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { + success: false, + error: lastError ?? 'Unknown error fetching current user', + }; + } + /** * Get replies to a tweet by ID */ diff --git a/tests/cookies.test.ts b/tests/cookies.test.ts index 6f1ef79..211e646 100644 --- a/tests/cookies.test.ts +++ b/tests/cookies.test.ts @@ -98,9 +98,11 @@ describe('cookies', () => { const result = await resolveCredentials({}); expect(result.warnings).toContain( - 'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome', + 'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome/Firefox', + ); + expect(result.warnings).toContain( + 'Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome/Firefox', ); - expect(result.warnings).toContain('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome'); }); }); }); diff --git a/tests/sweetistics-client.test.ts b/tests/sweetistics-client.test.ts index ee3c2ad..96c3bfc 100644 --- a/tests/sweetistics-client.test.ts +++ b/tests/sweetistics-client.test.ts @@ -53,12 +53,10 @@ describe('SweetisticsClient', () => { ok: true, status: 200, json: async () => ({ - success: true, - tweet: { - id: '1', - text: 'hi', - author: { username: 'u', name: 'User' }, - }, + id: '1', + text: 'hi', + author: { username: 'u', name: 'User' }, + metrics: { likeCount: 5 }, }), }); globalThis.fetch = fetchMock as unknown as typeof fetch; @@ -69,17 +67,35 @@ describe('SweetisticsClient', () => { 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/twitter/tweet/1'); + 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 () => ({ - success: true, - tweets: [{ id: '2', text: 'reply', author: { username: 'r', name: 'Reply' } }], + result: { data: conversationPayload }, }), }); globalThis.fetch = fetchMock as unknown as typeof fetch; @@ -90,17 +106,24 @@ describe('SweetisticsClient', () => { expect(result.success).toBe(true); expect(result.tweets?.[0].id).toBe('2'); const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe('https://api.example.com/api/twitter/tweet/1/replies'); + expect(url).toContain('/api/trpc/tweets.getConversation'); 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 () => ({ - success: true, - tweets: [{ id: '1', text: 'root', author: { username: 'u', name: 'User' } }], + result: { data: conversationPayload }, }), }); globalThis.fetch = fetchMock as unknown as typeof fetch; @@ -109,20 +132,32 @@ describe('SweetisticsClient', () => { const result = await client.thread('1'); expect(result.success).toBe(true); - expect(result.tweets?.length).toBe(1); + expect(result.tweets?.length).toBe(2); const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe('https://api.example.com/api/twitter/tweet/1/thread'); + expect(url).toContain('/api/trpc/tweets.getConversation'); 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 () => ({ - success: true, - tweets: [{ id: '3', text: 'needle', author: { username: 'n', name: 'Needle' } }], - }), + json: async () => [{ result: { data: searchPayload } }], }); globalThis.fetch = fetchMock as unknown as typeof fetch; @@ -131,22 +166,76 @@ describe('SweetisticsClient', () => { expect(result.success).toBe(true); expect(result.tweets?.[0].id).toBe('3'); - const [url] = fetchMock.mock.calls[0]; - expect(url).toBe('https://api.example.com/api/twitter/search?q=needle&count=5'); - const [, init] = fetchMock.mock.calls[0]; + 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('propagates Sweetistics error message', async () => { + it('fetches current user', async () => { const fetchMock = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - json: async () => ({ success: false, error: 'boom' }), + 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.read('1'); + 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'); diff --git a/tests/twitter-client.test.ts b/tests/twitter-client.test.ts index b366d7c..dd44f29 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -296,4 +296,82 @@ describe('TwitterClient', () => { expect(result.error).toContain('HTTP 404'); }); }); + + describe('getCurrentUser', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as any; + }); + + it('returns mapped user details when present', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + user_id: '12345', + screen_name: 'tester', + name: 'Test User', + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getCurrentUser(); + + expect(result.success).toBe(true); + expect(result.user).toEqual({ id: '12345', username: 'tester', name: 'Test User' }); + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('account/settings'), expect.any(Object)); + }); + + it('returns error when response lacks identifiers', async () => { + mockFetch.mockImplementation(async () => ({ + ok: true, + status: 200, + json: async () => ({ language: 'en' }), + text: async () => '{"language":"en"}', + })); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getCurrentUser(); + + expect(result.success).toBe(false); + expect(result.error).toContain('Could not'); + }); + + it('surfaces HTTP errors', async () => { + mockFetch.mockImplementation(async () => ({ + ok: false, + status: 401, + text: async () => 'Unauthorized', + })); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getCurrentUser(); + + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 401'); + }); + + it('uses HTML fallback when API endpoints 404', async () => { + mockFetch + .mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' }) + .mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' }) + .mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' }) + .mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => '"screen_name":"fallback","user_id":"999"', + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getCurrentUser(); + + expect(result.success).toBe(true); + expect(result.user?.username).toBe('fallback'); + expect(result.user?.id).toBe('999'); + expect(mockFetch).toHaveBeenCalledTimes(5); + }); + }); });