chore: reformat with 2-space indentation (biome)

This commit is contained in:
Peter Steinberger
2025-11-28 08:09:32 +01:00
parent 0f0bd5d680
commit 5ae8801b23
11 changed files with 1170 additions and 1169 deletions
+31 -30
View File
@@ -1,32 +1,33 @@
{ {
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"organizeImports": { "organizeImports": {
"enabled": true "enabled": true
}, },
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true, "recommended": true,
"complexity": { "complexity": {
"noForEach": "off" "noForEach": "off"
}, },
"style": { "style": {
"noNonNullAssertion": "off" "noNonNullAssertion": "off"
} }
} }
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"indentStyle": "tab", "indentStyle": "space",
"lineWidth": 120 "indentWidth": 2,
}, "lineWidth": 120
"javascript": { },
"formatter": { "javascript": {
"quoteStyle": "single", "formatter": {
"semicolons": "always" "quoteStyle": "single",
} "semicolons": "always"
}, }
"files": { },
"ignore": ["dist/", "node_modules/", "*.d.ts"] "files": {
} "ignore": ["dist/", "node_modules/", "*.d.ts"]
}
} }
+31 -31
View File
@@ -1,33 +1,33 @@
{ {
"name": "bird", "name": "bird",
"version": "0.1.0", "version": "0.1.0",
"description": "CLI tool for tweeting and replying via Twitter/X GraphQL API", "description": "CLI tool for tweeting and replying via Twitter/X GraphQL API",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"bin": { "bin": {
"bird": "dist/index.js" "bird": "dist/index.js"
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"dev": "tsx src/index.ts", "dev": "tsx src/index.ts",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"lint": "biome check .", "lint": "biome check .",
"lint:fix": "biome check --write .", "lint:fix": "biome check --write .",
"format": "biome format --write .", "format": "biome format --write .",
"binary": "bun build --compile --minify src/index.ts --outfile bird" "binary": "bun build --compile --minify src/index.ts --outfile bird"
}, },
"dependencies": { "dependencies": {
"commander": "^12.1.0" "commander": "^12.1.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vitest": "^2.1.6" "vitest": "^2.1.6"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
} }
} }
+145 -145
View File
@@ -20,183 +20,183 @@ program.name('bird').description('Post tweets and replies via Twitter/X GraphQL
// Global options for authentication // Global options for authentication
program program
.option('--auth-token <token>', 'Twitter auth_token cookie') .option('--auth-token <token>', 'Twitter auth_token cookie')
.option('--ct0 <token>', 'Twitter ct0 cookie') .option('--ct0 <token>', 'Twitter ct0 cookie')
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction'); .option('--chrome-profile <name>', 'Chrome profile name for cookie extraction');
// Tweet command // Tweet command
program program
.command('tweet') .command('tweet')
.description('Post a new tweet') .description('Post a new tweet')
.argument('<text>', 'Tweet text') .argument('<text>', 'Tweet text')
.action(async (text: string) => { .action(async (text: string) => {
const opts = program.opts(); const opts = program.opts();
const { cookies, warnings } = await resolveCredentials({ const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken, authToken: opts.authToken,
ct0: opts.ct0, ct0: opts.ct0,
chromeProfile: opts.chromeProfile, chromeProfile: opts.chromeProfile,
}); });
for (const warning of warnings) { for (const warning of warnings) {
console.error(`⚠️ ${warning}`); console.error(`⚠️ ${warning}`);
} }
if (!cookies.authToken || !cookies.ct0) { if (!cookies.authToken || !cookies.ct0) {
console.error('❌ Missing required credentials'); console.error('❌ Missing required credentials');
process.exit(1); process.exit(1);
} }
if (cookies.source) { if (cookies.source) {
console.error(`📍 Using credentials from: ${cookies.source}`); console.error(`📍 Using credentials from: ${cookies.source}`);
} }
const client = new TwitterClient({ cookies }); const client = new TwitterClient({ cookies });
const result = await client.tweet(text); const result = await client.tweet(text);
if (result.success) { if (result.success) {
console.log('✅ Tweet posted successfully!'); console.log('✅ Tweet posted successfully!');
console.log(`🔗 https://x.com/i/status/${result.tweetId}`); console.log(`🔗 https://x.com/i/status/${result.tweetId}`);
} else { } else {
console.error(`❌ Failed to post tweet: ${result.error}`); console.error(`❌ Failed to post tweet: ${result.error}`);
process.exit(1); process.exit(1);
} }
}); });
// Reply command // Reply command
program program
.command('reply') .command('reply')
.description('Reply to an existing tweet') .description('Reply to an existing tweet')
.argument('<tweet-id-or-url>', 'Tweet ID or URL to reply to') .argument('<tweet-id-or-url>', 'Tweet ID or URL to reply to')
.argument('<text>', 'Reply text') .argument('<text>', 'Reply text')
.action(async (tweetIdOrUrl: string, text: string) => { .action(async (tweetIdOrUrl: string, text: string) => {
const opts = program.opts(); const opts = program.opts();
const { cookies, warnings } = await resolveCredentials({ const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken, authToken: opts.authToken,
ct0: opts.ct0, ct0: opts.ct0,
chromeProfile: opts.chromeProfile, chromeProfile: opts.chromeProfile,
}); });
for (const warning of warnings) { for (const warning of warnings) {
console.error(`⚠️ ${warning}`); console.error(`⚠️ ${warning}`);
} }
if (!cookies.authToken || !cookies.ct0) { if (!cookies.authToken || !cookies.ct0) {
console.error('❌ Missing required credentials'); console.error('❌ Missing required credentials');
process.exit(1); process.exit(1);
} }
if (cookies.source) { if (cookies.source) {
console.error(`📍 Using credentials from: ${cookies.source}`); console.error(`📍 Using credentials from: ${cookies.source}`);
} }
const tweetId = extractTweetId(tweetIdOrUrl); const tweetId = extractTweetId(tweetIdOrUrl);
console.error(`📝 Replying to tweet: ${tweetId}`); console.error(`📝 Replying to tweet: ${tweetId}`);
const client = new TwitterClient({ cookies }); const client = new TwitterClient({ cookies });
const result = await client.reply(text, tweetId); const result = await client.reply(text, tweetId);
if (result.success) { if (result.success) {
console.log('✅ Reply posted successfully!'); console.log('✅ Reply posted successfully!');
console.log(`🔗 https://x.com/i/status/${result.tweetId}`); console.log(`🔗 https://x.com/i/status/${result.tweetId}`);
} else { } else {
console.error(`❌ Failed to post reply: ${result.error}`); console.error(`❌ Failed to post reply: ${result.error}`);
process.exit(1); process.exit(1);
} }
}); });
// Read command - fetch tweet content // Read command - fetch tweet content
program program
.command('read') .command('read')
.description('Read/fetch a tweet by ID or URL') .description('Read/fetch a tweet by ID or URL')
.argument('<tweet-id-or-url>', 'Tweet ID or URL to read') .argument('<tweet-id-or-url>', 'Tweet ID or URL to read')
.option('--json', 'Output as JSON') .option('--json', 'Output as JSON')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts(); const opts = program.opts();
const { cookies, warnings } = await resolveCredentials({ const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken, authToken: opts.authToken,
ct0: opts.ct0, ct0: opts.ct0,
chromeProfile: opts.chromeProfile, chromeProfile: opts.chromeProfile,
}); });
for (const warning of warnings) { for (const warning of warnings) {
console.error(`⚠️ ${warning}`); console.error(`⚠️ ${warning}`);
} }
if (!cookies.authToken || !cookies.ct0) { if (!cookies.authToken || !cookies.ct0) {
console.error('❌ Missing required credentials'); console.error('❌ Missing required credentials');
process.exit(1); process.exit(1);
} }
const tweetId = extractTweetId(tweetIdOrUrl); const tweetId = extractTweetId(tweetIdOrUrl);
const client = new TwitterClient({ cookies }); const client = new TwitterClient({ cookies });
const result = await client.getTweet(tweetId); const result = await client.getTweet(tweetId);
if (result.success && result.tweet) { if (result.success && result.tweet) {
if (cmdOpts.json) { if (cmdOpts.json) {
console.log(JSON.stringify(result.tweet, null, 2)); console.log(JSON.stringify(result.tweet, null, 2));
} else { } else {
console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`); console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`);
console.log(result.tweet.text); console.log(result.tweet.text);
if (result.tweet.createdAt) { if (result.tweet.createdAt) {
console.log(`\n📅 ${result.tweet.createdAt}`); console.log(`\n📅 ${result.tweet.createdAt}`);
} }
console.log( console.log(
`❤️ ${result.tweet.likeCount ?? 0} 🔁 ${result.tweet.retweetCount ?? 0} 💬 ${result.tweet.replyCount ?? 0}`, `❤️ ${result.tweet.likeCount ?? 0} 🔁 ${result.tweet.retweetCount ?? 0} 💬 ${result.tweet.replyCount ?? 0}`,
); );
} }
} else { } else {
console.error(`❌ Failed to read tweet: ${result.error}`); console.error(`❌ Failed to read tweet: ${result.error}`);
process.exit(1); process.exit(1);
} }
}); });
// Check command - verify credentials // Check command - verify credentials
program program
.command('check') .command('check')
.description('Check credential availability') .description('Check credential availability')
.action(async () => { .action(async () => {
const opts = program.opts(); const opts = program.opts();
const { cookies, warnings } = await resolveCredentials({ const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken, authToken: opts.authToken,
ct0: opts.ct0, ct0: opts.ct0,
chromeProfile: opts.chromeProfile, chromeProfile: opts.chromeProfile,
}); });
console.log('🔍 Credential Check'); console.log('🔍 Credential Check');
console.log('─'.repeat(40)); console.log('─'.repeat(40));
if (cookies.authToken) { if (cookies.authToken) {
console.log(`✅ auth_token: ${cookies.authToken.slice(0, 10)}...`); console.log(`✅ auth_token: ${cookies.authToken.slice(0, 10)}...`);
} else { } else {
console.log('❌ auth_token: not found'); console.log('❌ auth_token: not found');
} }
if (cookies.ct0) { if (cookies.ct0) {
console.log(`✅ ct0: ${cookies.ct0.slice(0, 10)}...`); console.log(`✅ ct0: ${cookies.ct0.slice(0, 10)}...`);
} else { } else {
console.log('❌ ct0: not found'); console.log('❌ ct0: not found');
} }
if (cookies.source) { if (cookies.source) {
console.log(`📍 Source: ${cookies.source}`); console.log(`📍 Source: ${cookies.source}`);
} }
if (warnings.length > 0) { if (warnings.length > 0) {
console.log('\n⚠️ Warnings:'); console.log('\n⚠️ Warnings:');
for (const warning of warnings) { for (const warning of warnings) {
console.log(` - ${warning}`); console.log(` - ${warning}`);
} }
} }
if (cookies.authToken && cookies.ct0) { if (cookies.authToken && cookies.ct0) {
console.log('\n✅ Ready to tweet!'); console.log('\n✅ Ready to tweet!');
} else { } else {
console.log('\n❌ Missing credentials. Options:'); console.log('\n❌ Missing credentials. Options:');
console.log(' 1. Login to x.com in Chrome'); console.log(' 1. Login to x.com in Chrome');
console.log(' 2. Set AUTH_TOKEN and CT0 environment variables'); console.log(' 2. Set AUTH_TOKEN and CT0 environment variables');
console.log(' 3. Use --auth-token and --ct0 flags'); console.log(' 3. Use --auth-token and --ct0 flags');
process.exit(1); process.exit(1);
} }
}); });
program.parse(); program.parse();
+175 -175
View File
@@ -10,28 +10,28 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
export interface TwitterCookies { export interface TwitterCookies {
authToken: string | null; authToken: string | null;
ct0: string | null; ct0: string | null;
source: string | null; source: string | null;
} }
export interface CookieExtractionResult { export interface CookieExtractionResult {
cookies: TwitterCookies; cookies: TwitterCookies;
warnings: string[]; warnings: string[];
} }
function normalizeValue(value: unknown): string | null { function normalizeValue(value: unknown): string | null {
if (typeof value === 'string') { if (typeof value === 'string') {
const trimmed = value.trim(); const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null; return trimmed.length > 0 ? trimmed : null;
} }
return null; return null;
} }
function getChromeCookiesPath(profile?: string): string { function getChromeCookiesPath(profile?: string): string {
const home = process.env.HOME || ''; const home = process.env.HOME || '';
const profileDir = profile || 'Default'; const profileDir = profile || 'Default';
return join(home, 'Library', 'Application Support', 'Google', 'Chrome', profileDir, 'Cookies'); return join(home, 'Library', 'Application Support', 'Google', 'Chrome', profileDir, 'Cookies');
} }
/** /**
@@ -39,50 +39,50 @@ function getChromeCookiesPath(profile?: string): string {
* Chrome encrypts cookies with a key stored in the keychain * Chrome encrypts cookies with a key stored in the keychain
*/ */
function decryptCookieValue(encryptedHex: string): string | null { function decryptCookieValue(encryptedHex: string): string | null {
try { try {
// Convert hex to buffer // Convert hex to buffer
const encryptedValue = Buffer.from(encryptedHex, 'hex'); const encryptedValue = Buffer.from(encryptedHex, 'hex');
if (encryptedValue.length < 4) { if (encryptedValue.length < 4) {
return null; return null;
} }
const version = encryptedValue.subarray(0, 3).toString('utf8'); const version = encryptedValue.subarray(0, 3).toString('utf8');
if (version !== 'v10' && version !== 'v11') { if (version !== 'v10' && version !== 'v11') {
// Not encrypted, just return as string // Not encrypted, just return as string
return encryptedValue.toString('utf8'); return encryptedValue.toString('utf8');
} }
// Get encryption key from keychain // Get encryption key from keychain
const keyOutput = execSync('security find-generic-password -s "Chrome Safe Storage" -w 2>/dev/null || echo ""', { const keyOutput = execSync('security find-generic-password -s "Chrome Safe Storage" -w 2>/dev/null || echo ""', {
encoding: 'utf8', encoding: 'utf8',
}).trim(); }).trim();
if (!keyOutput) { if (!keyOutput) {
return null; return null;
} }
// Derive the key using PBKDF2 // Derive the key using PBKDF2
const salt = 'saltysalt'; const salt = 'saltysalt';
const iterations = 1003; const iterations = 1003;
const keyLength = 16; const keyLength = 16;
const derivedKey = pbkdf2Sync(keyOutput, salt, iterations, keyLength, 'sha1'); const derivedKey = pbkdf2Sync(keyOutput, salt, iterations, keyLength, 'sha1');
// Decrypt using AES-128-CBC with empty IV (16 bytes of 0x20/space) // Decrypt using AES-128-CBC with empty IV (16 bytes of 0x20/space)
const iv = Buffer.alloc(16, 0x20); const iv = Buffer.alloc(16, 0x20);
const encryptedData = encryptedValue.subarray(3); // Skip "v10" or "v11" prefix const encryptedData = encryptedValue.subarray(3); // Skip "v10" or "v11" prefix
const decipher = createDecipheriv('aes-128-cbc', derivedKey, iv); const decipher = createDecipheriv('aes-128-cbc', derivedKey, iv);
decipher.setAutoPadding(true); decipher.setAutoPadding(true);
let decrypted = decipher.update(encryptedData); let decrypted = decipher.update(encryptedData);
decrypted = Buffer.concat([decrypted, decipher.final()]); decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString('utf8'); return decrypted.toString('utf8');
} catch { } catch {
return null; return null;
} }
} }
/** /**
@@ -90,84 +90,84 @@ function decryptCookieValue(encryptedHex: string): string | null {
* @param profile - Chrome profile name (optional, uses Default if not specified) * @param profile - Chrome profile name (optional, uses Default if not specified)
*/ */
export async function extractCookiesFromChrome(profile?: string): Promise<CookieExtractionResult> { export async function extractCookiesFromChrome(profile?: string): Promise<CookieExtractionResult> {
const warnings: string[] = []; const warnings: string[] = [];
const cookies: TwitterCookies = { const cookies: TwitterCookies = {
authToken: null, authToken: null,
ct0: null, ct0: null,
source: null, source: null,
}; };
const cookiesPath = getChromeCookiesPath(profile); const cookiesPath = getChromeCookiesPath(profile);
if (!existsSync(cookiesPath)) { if (!existsSync(cookiesPath)) {
warnings.push(`Chrome cookies database not found at: ${cookiesPath}`); warnings.push(`Chrome cookies database not found at: ${cookiesPath}`);
return { cookies, warnings }; return { cookies, warnings };
} }
// Chrome locks the database, so we need to copy it // Chrome locks the database, so we need to copy it
let tempDir: string | null = null; let tempDir: string | null = null;
try { try {
tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-')); tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-'));
const tempDbPath = join(tempDir, 'Cookies'); const tempDbPath = join(tempDir, 'Cookies');
copyFileSync(cookiesPath, tempDbPath); copyFileSync(cookiesPath, tempDbPath);
// Also copy the WAL and SHM files if they exist // Also copy the WAL and SHM files if they exist
const walPath = `${cookiesPath}-wal`; const walPath = `${cookiesPath}-wal`;
const shmPath = `${cookiesPath}-shm`; const shmPath = `${cookiesPath}-shm`;
if (existsSync(walPath)) { if (existsSync(walPath)) {
copyFileSync(walPath, `${tempDbPath}-wal`); copyFileSync(walPath, `${tempDbPath}-wal`);
} }
if (existsSync(shmPath)) { if (existsSync(shmPath)) {
copyFileSync(shmPath, `${tempDbPath}-shm`); copyFileSync(shmPath, `${tempDbPath}-shm`);
} }
// Use sqlite3 CLI to query cookies (no native deps required!) // Use sqlite3 CLI to query cookies (no native deps required!)
const query = `SELECT name, hex(encrypted_value) as encrypted_hex FROM cookies WHERE host_key IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com') AND name IN ('auth_token', 'ct0');`; const query = `SELECT name, hex(encrypted_value) as encrypted_hex FROM cookies WHERE host_key IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com') AND name IN ('auth_token', 'ct0');`;
const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, { const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, {
encoding: 'utf8', encoding: 'utf8',
maxBuffer: 1024 * 1024, maxBuffer: 1024 * 1024,
}).trim(); }).trim();
if (result) { if (result) {
for (const line of result.split('\n')) { for (const line of result.split('\n')) {
const [name, encryptedHex] = line.split('|'); const [name, encryptedHex] = line.split('|');
if (!name || !encryptedHex) continue; if (!name || !encryptedHex) continue;
const decryptedValue = decryptCookieValue(encryptedHex); const decryptedValue = decryptCookieValue(encryptedHex);
if (decryptedValue) { if (decryptedValue) {
if (name === 'auth_token' && !cookies.authToken) { if (name === 'auth_token' && !cookies.authToken) {
cookies.authToken = decryptedValue; cookies.authToken = decryptedValue;
} else if (name === 'ct0' && !cookies.ct0) { } else if (name === 'ct0' && !cookies.ct0) {
cookies.ct0 = decryptedValue; cookies.ct0 = decryptedValue;
} }
} }
} }
} }
if (cookies.authToken || cookies.ct0) { if (cookies.authToken || cookies.ct0) {
cookies.source = profile ? `Chrome profile "${profile}"` : 'Chrome default profile'; cookies.source = profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
} }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
warnings.push(`Failed to read Chrome cookies: ${message}`); warnings.push(`Failed to read Chrome cookies: ${message}`);
} finally { } finally {
// Cleanup temp files // Cleanup temp files
if (tempDir) { if (tempDir) {
try { try {
rmSync(tempDir, { recursive: true, force: true }); rmSync(tempDir, { recursive: true, force: true });
} catch { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
} }
if (!cookies.authToken && !cookies.ct0) { if (!cookies.authToken && !cookies.ct0) {
warnings.push('No Twitter cookies found in Chrome. Make sure you are logged into x.com in Chrome.'); warnings.push('No Twitter cookies found in Chrome. Make sure you are logged into x.com in Chrome.');
} }
return { cookies, warnings }; return { cookies, warnings };
} }
/** /**
@@ -175,75 +175,75 @@ export async function extractCookiesFromChrome(profile?: string): Promise<Cookie
* Priority: CLI args > environment variables > Chrome cookies * Priority: CLI args > environment variables > Chrome cookies
*/ */
export async function resolveCredentials(options: { export async function resolveCredentials(options: {
authToken?: string; authToken?: string;
ct0?: string; ct0?: string;
chromeProfile?: string; chromeProfile?: string;
}): Promise<CookieExtractionResult> { }): Promise<CookieExtractionResult> {
const warnings: string[] = []; const warnings: string[] = [];
const cookies: TwitterCookies = { const cookies: TwitterCookies = {
authToken: null, authToken: null,
ct0: null, ct0: null,
source: null, source: null,
}; };
// 1. CLI arguments (highest priority) // 1. CLI arguments (highest priority)
if (options.authToken) { if (options.authToken) {
cookies.authToken = options.authToken; cookies.authToken = options.authToken;
cookies.source = 'CLI argument'; cookies.source = 'CLI argument';
} }
if (options.ct0) { if (options.ct0) {
cookies.ct0 = options.ct0; cookies.ct0 = options.ct0;
if (!cookies.source) cookies.source = 'CLI argument'; if (!cookies.source) cookies.source = 'CLI argument';
} }
// 2. Environment variables // 2. Environment variables
const envAuthKeys = ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN']; const envAuthKeys = ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'];
const envCt0Keys = ['CT0', 'TWITTER_CT0']; const envCt0Keys = ['CT0', 'TWITTER_CT0'];
if (!cookies.authToken) { if (!cookies.authToken) {
for (const key of envAuthKeys) { for (const key of envAuthKeys) {
const value = normalizeValue(process.env[key]); const value = normalizeValue(process.env[key]);
if (value) { if (value) {
cookies.authToken = value; cookies.authToken = value;
cookies.source = `env ${key}`; cookies.source = `env ${key}`;
break; break;
} }
} }
} }
if (!cookies.ct0) { if (!cookies.ct0) {
for (const key of envCt0Keys) { for (const key of envCt0Keys) {
const value = normalizeValue(process.env[key]); const value = normalizeValue(process.env[key]);
if (value) { if (value) {
cookies.ct0 = value; cookies.ct0 = value;
if (!cookies.source) cookies.source = `env ${key}`; if (!cookies.source) cookies.source = `env ${key}`;
break; break;
} }
} }
} }
// 3. Chrome cookies (fallback) // 3. Chrome cookies (fallback)
if (!cookies.authToken || !cookies.ct0) { if (!cookies.authToken || !cookies.ct0) {
const chromeResult = await extractCookiesFromChrome(options.chromeProfile); const chromeResult = await extractCookiesFromChrome(options.chromeProfile);
warnings.push(...chromeResult.warnings); warnings.push(...chromeResult.warnings);
if (!cookies.authToken && chromeResult.cookies.authToken) { if (!cookies.authToken && chromeResult.cookies.authToken) {
cookies.authToken = chromeResult.cookies.authToken; cookies.authToken = chromeResult.cookies.authToken;
cookies.source = chromeResult.cookies.source; cookies.source = chromeResult.cookies.source;
} }
if (!cookies.ct0 && chromeResult.cookies.ct0) { if (!cookies.ct0 && chromeResult.cookies.ct0) {
cookies.ct0 = chromeResult.cookies.ct0; cookies.ct0 = chromeResult.cookies.ct0;
if (!cookies.source) cookies.source = chromeResult.cookies.source; if (!cookies.source) cookies.source = chromeResult.cookies.source;
} }
} }
// Validation // Validation
if (!cookies.authToken) { 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');
} }
if (!cookies.ct0) { 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');
} }
return { cookies, warnings }; return { cookies, warnings };
} }
+7 -7
View File
@@ -2,11 +2,11 @@
* Extract tweet ID from a Twitter/X URL or return the input unchanged if it's already an ID. * Extract tweet ID from a Twitter/X URL or return the input unchanged if it's already an ID.
*/ */
export function extractTweetId(input: string): string { export function extractTweetId(input: string): string {
// If it's a URL, extract the tweet ID // If it's a URL, extract the tweet ID
const urlMatch = input.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/); const urlMatch = input.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/);
if (urlMatch) { if (urlMatch) {
return urlMatch[1]; return urlMatch[1];
} }
// Assume it's already an ID // Assume it's already an ID
return input; return input;
} }
+400 -400
View File
@@ -9,454 +9,454 @@ const TWITTER_API_BASE = 'https://x.com/i/api/graphql';
// Twitter GraphQL query IDs - these may need to be updated periodically // Twitter GraphQL query IDs - these may need to be updated periodically
// as Twitter rotates them. Check browser network tab for current values. // as Twitter rotates them. Check browser network tab for current values.
const QUERY_IDS = { const QUERY_IDS = {
CreateTweet: 'znCVAd692dKBq9MgkEhKPQ', CreateTweet: 'znCVAd692dKBq9MgkEhKPQ',
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA', CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A', FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
TweetDetail: 'nBS-WpgA6ZG0CyNHD517JQ', TweetDetail: 'nBS-WpgA6ZG0CyNHD517JQ',
}; };
export interface TweetResult { export interface TweetResult {
success: boolean; success: boolean;
tweetId?: string; tweetId?: string;
error?: string; error?: string;
} }
export interface TweetData { export interface TweetData {
id: string; id: string;
text: string; text: string;
author: { author: {
username: string; username: string;
name: string; name: string;
}; };
createdAt?: string; createdAt?: string;
replyCount?: number; replyCount?: number;
retweetCount?: number; retweetCount?: number;
likeCount?: number; likeCount?: number;
} }
export interface GetTweetResult { export interface GetTweetResult {
success: boolean; success: boolean;
tweet?: TweetData; tweet?: TweetData;
error?: string; error?: string;
} }
export interface TwitterClientOptions { export interface TwitterClientOptions {
cookies: TwitterCookies; cookies: TwitterCookies;
userAgent?: string; userAgent?: string;
} }
interface CreateTweetResponse { interface CreateTweetResponse {
data?: { data?: {
create_tweet?: { create_tweet?: {
tweet_results?: { tweet_results?: {
result?: { result?: {
rest_id?: string; rest_id?: string;
legacy?: { legacy?: {
full_text?: string; full_text?: string;
}; };
}; };
}; };
}; };
}; };
errors?: Array<{ message: string; code?: number }>; errors?: Array<{ message: string; code?: number }>;
} }
export class TwitterClient { export class TwitterClient {
private authToken: string; private authToken: string;
private ct0: string; private ct0: string;
private userAgent: string; private userAgent: string;
constructor(options: TwitterClientOptions) { constructor(options: TwitterClientOptions) {
if (!options.cookies.authToken || !options.cookies.ct0) { if (!options.cookies.authToken || !options.cookies.ct0) {
throw new Error('Both authToken and ct0 cookies are required'); throw new Error('Both authToken and ct0 cookies are required');
} }
this.authToken = options.cookies.authToken; this.authToken = options.cookies.authToken;
this.ct0 = options.cookies.ct0; this.ct0 = options.cookies.ct0;
this.userAgent = this.userAgent =
options.userAgent || options.userAgent ||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
} }
private findTweetInInstructions( private findTweetInInstructions(
instructions: instructions:
| Array<{ | Array<{
entries?: Array<{ entries?: Array<{
content?: { content?: {
itemContent?: { itemContent?: {
tweet_results?: { tweet_results?: {
result?: { result?: {
rest_id?: string; rest_id?: string;
legacy?: { legacy?: {
full_text?: string; full_text?: string;
created_at?: string; created_at?: string;
reply_count?: number; reply_count?: number;
retweet_count?: number; retweet_count?: number;
favorite_count?: number; favorite_count?: number;
}; };
core?: { core?: {
user_results?: { user_results?: {
result?: { result?: {
legacy?: { legacy?: {
screen_name?: string; screen_name?: string;
name?: string; name?: string;
}; };
}; };
}; };
}; };
}; };
}; };
}; };
}; };
}>; }>;
}> }>
| undefined, | undefined,
tweetId: string, tweetId: string,
) { ) {
if (!instructions) return undefined; if (!instructions) return undefined;
for (const instruction of instructions) { for (const instruction of instructions) {
for (const entry of instruction.entries || []) { for (const entry of instruction.entries || []) {
const result = entry.content?.itemContent?.tweet_results?.result; const result = entry.content?.itemContent?.tweet_results?.result;
if (result?.rest_id === tweetId) { if (result?.rest_id === tweetId) {
return result; return result;
} }
} }
} }
return undefined; return undefined;
} }
private getHeaders(): Record<string, string> { private getHeaders(): Record<string, string> {
return { return {
authorization: authorization:
'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA', 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
'content-type': 'application/json', 'content-type': 'application/json',
'x-csrf-token': this.ct0, 'x-csrf-token': this.ct0,
'x-twitter-auth-type': 'OAuth2Session', 'x-twitter-auth-type': 'OAuth2Session',
'x-twitter-active-user': 'yes', 'x-twitter-active-user': 'yes',
'x-twitter-client-language': 'en', 'x-twitter-client-language': 'en',
cookie: `auth_token=${this.authToken}; ct0=${this.ct0}`, cookie: `auth_token=${this.authToken}; ct0=${this.ct0}`,
'user-agent': this.userAgent, 'user-agent': this.userAgent,
origin: 'https://x.com', origin: 'https://x.com',
referer: 'https://x.com/', referer: 'https://x.com/',
}; };
} }
/** /**
* Get tweet details by ID * Get tweet details by ID
*/ */
async getTweet(tweetId: string): Promise<GetTweetResult> { async getTweet(tweetId: string): Promise<GetTweetResult> {
const variables = { const variables = {
focalTweetId: tweetId, focalTweetId: tweetId,
with_rux_injections: false, with_rux_injections: false,
rankingMode: 'Relevance', rankingMode: 'Relevance',
includePromotedContent: true, includePromotedContent: true,
withCommunity: true, withCommunity: true,
withQuickPromoteEligibilityTweetFields: true, withQuickPromoteEligibilityTweetFields: true,
withBirdwatchNotes: true, withBirdwatchNotes: true,
withVoice: true, withVoice: true,
}; };
const features = { const features = {
rweb_tipjar_consumption_enabled: true, rweb_tipjar_consumption_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true, responsive_web_graphql_exclude_directive_enabled: true,
verified_phone_label_enabled: false, verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true, creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true, responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
communities_web_enable_tweet_community_results_fetch: true, communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true, c9s_tweet_anatomy_moderator_badge_enabled: true,
articles_preview_enabled: true, articles_preview_enabled: true,
responsive_web_edit_tweet_api_enabled: true, responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true, view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true, longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true, responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false, tweet_awards_web_tipping_enabled: false,
creator_subscriptions_quote_tweet_preview_enabled: false, creator_subscriptions_quote_tweet_preview_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true, freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true, standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
rweb_video_timestamps_enabled: true, rweb_video_timestamps_enabled: true,
longform_notetweets_rich_text_read_enabled: true, longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true, longform_notetweets_inline_media_enabled: true,
responsive_web_enhance_cards_enabled: false, responsive_web_enhance_cards_enabled: false,
}; };
const params = new URLSearchParams({ const params = new URLSearchParams({
variables: JSON.stringify(variables), variables: JSON.stringify(variables),
features: JSON.stringify(features), features: JSON.stringify(features),
}); });
const url = `${TWITTER_API_BASE}/${QUERY_IDS.TweetDetail}/TweetDetail?${params}`; const url = `${TWITTER_API_BASE}/${QUERY_IDS.TweetDetail}/TweetDetail?${params}`;
try { try {
const response = await fetch(url, { const response = await fetch(url, {
method: 'GET', method: 'GET',
headers: this.getHeaders(), headers: this.getHeaders(),
}); });
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
return { return {
success: false, success: false,
error: `HTTP ${response.status}: ${text.slice(0, 200)}`, error: `HTTP ${response.status}: ${text.slice(0, 200)}`,
}; };
} }
const data = (await response.json()) as { const data = (await response.json()) as {
data?: { data?: {
tweetResult?: { tweetResult?: {
result?: { result?: {
rest_id?: string; rest_id?: string;
legacy?: { legacy?: {
full_text?: string; full_text?: string;
created_at?: string; created_at?: string;
reply_count?: number; reply_count?: number;
retweet_count?: number; retweet_count?: number;
favorite_count?: number; favorite_count?: number;
}; };
core?: { core?: {
user_results?: { user_results?: {
result?: { result?: {
legacy?: { legacy?: {
screen_name?: string; screen_name?: string;
name?: string; name?: string;
}; };
}; };
}; };
}; };
}; };
}; };
threaded_conversation_with_injections_v2?: { threaded_conversation_with_injections_v2?: {
instructions?: Array<{ instructions?: Array<{
entries?: Array<{ entries?: Array<{
content?: { content?: {
itemContent?: { itemContent?: {
tweet_results?: { tweet_results?: {
result?: { result?: {
rest_id?: string; rest_id?: string;
legacy?: { legacy?: {
full_text?: string; full_text?: string;
created_at?: string; created_at?: string;
reply_count?: number; reply_count?: number;
retweet_count?: number; retweet_count?: number;
favorite_count?: number; favorite_count?: number;
}; };
core?: { core?: {
user_results?: { user_results?: {
result?: { result?: {
legacy?: { legacy?: {
screen_name?: string; screen_name?: string;
name?: string; name?: string;
}; };
}; };
}; };
}; };
}; };
}; };
}; };
}; };
}>; }>;
}>; }>;
}; };
}; };
errors?: Array<{ message: string }>; errors?: Array<{ message: string }>;
}; };
if (data.errors && data.errors.length > 0) { if (data.errors && data.errors.length > 0) {
return { return {
success: false, success: false,
error: data.errors.map((e) => e.message).join(', '), error: data.errors.map((e) => e.message).join(', '),
}; };
} }
// Prefer direct tweetResult if present, otherwise search the conversation thread // Prefer direct tweetResult if present, otherwise search the conversation thread
const tweetResult = const tweetResult =
data.data?.tweetResult?.result ?? data.data?.tweetResult?.result ??
this.findTweetInInstructions(data.data?.threaded_conversation_with_injections_v2?.instructions, tweetId); this.findTweetInInstructions(data.data?.threaded_conversation_with_injections_v2?.instructions, tweetId);
if (!tweetResult) { if (!tweetResult) {
return { return {
success: false, success: false,
error: 'Tweet not found in response', error: 'Tweet not found in response',
}; };
} }
const legacy = tweetResult.legacy; const legacy = tweetResult.legacy;
const userLegacy = tweetResult.core?.user_results?.result?.legacy; const userLegacy = tweetResult.core?.user_results?.result?.legacy;
if (!legacy?.full_text || !userLegacy?.screen_name) { if (!legacy?.full_text || !userLegacy?.screen_name) {
return { return {
success: false, success: false,
error: 'Incomplete tweet data', error: 'Incomplete tweet data',
}; };
} }
return { return {
success: true, success: true,
tweet: { tweet: {
id: tweetResult.rest_id || tweetId, id: tweetResult.rest_id || tweetId,
text: legacy.full_text, text: legacy.full_text,
author: { author: {
username: userLegacy.screen_name, username: userLegacy.screen_name,
name: userLegacy.name || userLegacy.screen_name, name: userLegacy.name || userLegacy.screen_name,
}, },
createdAt: legacy.created_at, createdAt: legacy.created_at,
replyCount: legacy.reply_count, replyCount: legacy.reply_count,
retweetCount: legacy.retweet_count, retweetCount: legacy.retweet_count,
likeCount: legacy.favorite_count, likeCount: legacy.favorite_count,
}, },
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
}; };
} }
} }
/** /**
* Post a new tweet * Post a new tweet
*/ */
async tweet(text: string): Promise<TweetResult> { async tweet(text: string): Promise<TweetResult> {
const variables = { const variables = {
tweet_text: text, tweet_text: text,
dark_request: false, dark_request: false,
media: { media: {
media_entities: [], media_entities: [],
possibly_sensitive: false, possibly_sensitive: false,
}, },
semantic_annotation_ids: [], semantic_annotation_ids: [],
}; };
const features = { const features = {
communities_web_enable_tweet_community_results_fetch: true, communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true, c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_edit_tweet_api_enabled: true, responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true, view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true, longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true, responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false, tweet_awards_web_tipping_enabled: false,
creator_subscriptions_quote_tweet_preview_enabled: false, creator_subscriptions_quote_tweet_preview_enabled: false,
longform_notetweets_rich_text_read_enabled: true, longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true, longform_notetweets_inline_media_enabled: true,
articles_preview_enabled: true, articles_preview_enabled: true,
rweb_video_timestamps_enabled: true, rweb_video_timestamps_enabled: true,
rweb_tipjar_consumption_enabled: true, rweb_tipjar_consumption_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true, responsive_web_graphql_exclude_directive_enabled: true,
verified_phone_label_enabled: false, verified_phone_label_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true, freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true, standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
responsive_web_graphql_timeline_navigation_enabled: true, responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_enhance_cards_enabled: false, responsive_web_enhance_cards_enabled: false,
}; };
return this.createTweet(variables, features); return this.createTweet(variables, features);
} }
/** /**
* Reply to an existing tweet * Reply to an existing tweet
*/ */
async reply(text: string, replyToTweetId: string): Promise<TweetResult> { async reply(text: string, replyToTweetId: string): Promise<TweetResult> {
const variables = { const variables = {
tweet_text: text, tweet_text: text,
reply: { reply: {
in_reply_to_tweet_id: replyToTweetId, in_reply_to_tweet_id: replyToTweetId,
exclude_reply_user_ids: [], exclude_reply_user_ids: [],
}, },
dark_request: false, dark_request: false,
media: { media: {
media_entities: [], media_entities: [],
possibly_sensitive: false, possibly_sensitive: false,
}, },
semantic_annotation_ids: [], semantic_annotation_ids: [],
}; };
const features = { const features = {
communities_web_enable_tweet_community_results_fetch: true, communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true, c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_edit_tweet_api_enabled: true, responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true, view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true, longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true, responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false, tweet_awards_web_tipping_enabled: false,
creator_subscriptions_quote_tweet_preview_enabled: false, creator_subscriptions_quote_tweet_preview_enabled: false,
longform_notetweets_rich_text_read_enabled: true, longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true, longform_notetweets_inline_media_enabled: true,
articles_preview_enabled: true, articles_preview_enabled: true,
rweb_video_timestamps_enabled: true, rweb_video_timestamps_enabled: true,
rweb_tipjar_consumption_enabled: true, rweb_tipjar_consumption_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true, responsive_web_graphql_exclude_directive_enabled: true,
verified_phone_label_enabled: false, verified_phone_label_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true, freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true, standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
responsive_web_graphql_timeline_navigation_enabled: true, responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_enhance_cards_enabled: false, responsive_web_enhance_cards_enabled: false,
}; };
return this.createTweet(variables, features); return this.createTweet(variables, features);
} }
private async createTweet( private async createTweet(
variables: Record<string, unknown>, variables: Record<string, unknown>,
features: Record<string, boolean>, features: Record<string, boolean>,
): Promise<TweetResult> { ): Promise<TweetResult> {
const url = `${TWITTER_API_BASE}/${QUERY_IDS.CreateTweet}/CreateTweet`; const url = `${TWITTER_API_BASE}/${QUERY_IDS.CreateTweet}/CreateTweet`;
const body = JSON.stringify({ const body = JSON.stringify({
variables, variables,
features, features,
queryId: QUERY_IDS.CreateTweet, queryId: QUERY_IDS.CreateTweet,
}); });
try { try {
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: this.getHeaders(), headers: this.getHeaders(),
body, body,
}); });
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
return { return {
success: false, success: false,
error: `HTTP ${response.status}: ${text.slice(0, 200)}`, error: `HTTP ${response.status}: ${text.slice(0, 200)}`,
}; };
} }
const data = (await response.json()) as CreateTweetResponse; const data = (await response.json()) as CreateTweetResponse;
if (data.errors && data.errors.length > 0) { if (data.errors && data.errors.length > 0) {
return { return {
success: false, success: false,
error: data.errors.map((e) => e.message).join(', '), error: data.errors.map((e) => e.message).join(', '),
}; };
} }
const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id; const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id;
if (tweetId) { if (tweetId) {
return { return {
success: true, success: true,
tweetId, tweetId,
}; };
} }
return { return {
success: false, success: false,
error: 'Tweet created but no ID returned', error: 'Tweet created but no ID returned',
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
}; };
} }
} }
} }
+23 -23
View File
@@ -2,31 +2,31 @@ import { describe, expect, it } from 'vitest';
import { extractTweetId } from '../src/lib/extract-tweet-id.js'; import { extractTweetId } from '../src/lib/extract-tweet-id.js';
describe('CLI utilities', () => { describe('CLI utilities', () => {
describe('extractTweetId', () => { describe('extractTweetId', () => {
it('should extract ID from x.com URL', () => { it('should extract ID from x.com URL', () => {
const url = 'https://x.com/steipete/status/1234567890123456789'; const url = 'https://x.com/steipete/status/1234567890123456789';
expect(extractTweetId(url)).toBe('1234567890123456789'); expect(extractTweetId(url)).toBe('1234567890123456789');
}); });
it('should extract ID from twitter.com URL', () => { it('should extract ID from twitter.com URL', () => {
const url = 'https://twitter.com/steipete/status/1234567890123456789'; const url = 'https://twitter.com/steipete/status/1234567890123456789';
expect(extractTweetId(url)).toBe('1234567890123456789'); expect(extractTweetId(url)).toBe('1234567890123456789');
}); });
it('should extract ID from URL with query params', () => { it('should extract ID from URL with query params', () => {
const url = 'https://x.com/steipete/status/1234567890123456789?s=20'; const url = 'https://x.com/steipete/status/1234567890123456789?s=20';
expect(extractTweetId(url)).toBe('1234567890123456789'); expect(extractTweetId(url)).toBe('1234567890123456789');
}); });
it('should return ID as-is if already an ID', () => { it('should return ID as-is if already an ID', () => {
const id = '1234567890123456789'; const id = '1234567890123456789';
expect(extractTweetId(id)).toBe('1234567890123456789'); expect(extractTweetId(id)).toBe('1234567890123456789');
}); });
it('should handle URLs with www prefix', () => { it('should handle URLs with www prefix', () => {
// Note: our regex handles this because \w+ matches any word chars after the domain // Note: our regex handles this because \w+ matches any word chars after the domain
const url = 'https://x.com/user_name/status/1234567890123456789'; const url = 'https://x.com/user_name/status/1234567890123456789';
expect(extractTweetId(url)).toBe('1234567890123456789'); expect(extractTweetId(url)).toBe('1234567890123456789');
}); });
}); });
}); });
+75 -75
View File
@@ -2,105 +2,105 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock child_process.execSync to prevent actual shell commands // Mock child_process.execSync to prevent actual shell commands
vi.mock('node:child_process', () => ({ vi.mock('node:child_process', () => ({
execSync: vi.fn(() => ''), execSync: vi.fn(() => ''),
})); }));
// Mock fs to prevent actual file operations // Mock fs to prevent actual file operations
vi.mock('node:fs', () => ({ vi.mock('node:fs', () => ({
existsSync: vi.fn(() => false), existsSync: vi.fn(() => false),
copyFileSync: vi.fn(), copyFileSync: vi.fn(),
mkdtempSync: vi.fn(() => '/tmp/test-dir'), mkdtempSync: vi.fn(() => '/tmp/test-dir'),
rmSync: vi.fn(), rmSync: vi.fn(),
})); }));
describe('cookies', () => { describe('cookies', () => {
const originalEnv = process.env; const originalEnv = process.env;
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
process.env = { ...originalEnv }; process.env = { ...originalEnv };
// Clear Twitter-related env vars // Clear Twitter-related env vars
process.env.AUTH_TOKEN = undefined; process.env.AUTH_TOKEN = undefined;
process.env.TWITTER_AUTH_TOKEN = undefined; process.env.TWITTER_AUTH_TOKEN = undefined;
process.env.CT0 = undefined; process.env.CT0 = undefined;
process.env.TWITTER_CT0 = undefined; process.env.TWITTER_CT0 = undefined;
}); });
afterEach(() => { afterEach(() => {
process.env = originalEnv; process.env = originalEnv;
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
describe('resolveCredentials', () => { describe('resolveCredentials', () => {
it('should prioritize CLI arguments over env vars', async () => { it('should prioritize CLI arguments over env vars', async () => {
process.env.AUTH_TOKEN = 'env_auth'; process.env.AUTH_TOKEN = 'env_auth';
process.env.CT0 = 'env_ct0'; process.env.CT0 = 'env_ct0';
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ const result = await resolveCredentials({
authToken: 'cli_auth', authToken: 'cli_auth',
ct0: 'cli_ct0', ct0: 'cli_ct0',
}); });
expect(result.cookies.authToken).toBe('cli_auth'); expect(result.cookies.authToken).toBe('cli_auth');
expect(result.cookies.ct0).toBe('cli_ct0'); expect(result.cookies.ct0).toBe('cli_ct0');
expect(result.cookies.source).toBe('CLI argument'); expect(result.cookies.source).toBe('CLI argument');
}); });
it('should use AUTH_TOKEN env var', async () => { it('should use AUTH_TOKEN env var', async () => {
process.env.AUTH_TOKEN = 'test_auth_token'; process.env.AUTH_TOKEN = 'test_auth_token';
process.env.CT0 = 'test_ct0'; process.env.CT0 = 'test_ct0';
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({}); const result = await resolveCredentials({});
expect(result.cookies.authToken).toBe('test_auth_token'); expect(result.cookies.authToken).toBe('test_auth_token');
expect(result.cookies.ct0).toBe('test_ct0'); expect(result.cookies.ct0).toBe('test_ct0');
expect(result.cookies.source).toBe('env AUTH_TOKEN'); expect(result.cookies.source).toBe('env AUTH_TOKEN');
}); });
it('should use TWITTER_AUTH_TOKEN env var as fallback', async () => { it('should use TWITTER_AUTH_TOKEN env var as fallback', async () => {
process.env.TWITTER_AUTH_TOKEN = 'twitter_auth'; process.env.TWITTER_AUTH_TOKEN = 'twitter_auth';
process.env.TWITTER_CT0 = 'twitter_ct0'; process.env.TWITTER_CT0 = 'twitter_ct0';
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({}); const result = await resolveCredentials({});
expect(result.cookies.authToken).toBe('twitter_auth'); expect(result.cookies.authToken).toBe('twitter_auth');
expect(result.cookies.ct0).toBe('twitter_ct0'); expect(result.cookies.ct0).toBe('twitter_ct0');
}); });
it('should trim whitespace from values', async () => { it('should trim whitespace from values', async () => {
process.env.AUTH_TOKEN = ' trimmed_auth '; process.env.AUTH_TOKEN = ' trimmed_auth ';
process.env.CT0 = ' trimmed_ct0 '; process.env.CT0 = ' trimmed_ct0 ';
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({}); const result = await resolveCredentials({});
expect(result.cookies.authToken).toBe('trimmed_auth'); expect(result.cookies.authToken).toBe('trimmed_auth');
expect(result.cookies.ct0).toBe('trimmed_ct0'); expect(result.cookies.ct0).toBe('trimmed_ct0');
}); });
it('should treat empty strings as null', async () => { it('should treat empty strings as null', async () => {
process.env.AUTH_TOKEN = ' '; process.env.AUTH_TOKEN = ' ';
process.env.CT0 = ''; process.env.CT0 = '';
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({}); const result = await resolveCredentials({});
expect(result.cookies.authToken).toBeNull(); expect(result.cookies.authToken).toBeNull();
expect(result.cookies.ct0).toBeNull(); expect(result.cookies.ct0).toBeNull();
expect(result.warnings.length).toBeGreaterThan(0); expect(result.warnings.length).toBeGreaterThan(0);
}); });
it('should warn when credentials are missing', async () => { it('should warn when credentials are missing', async () => {
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({}); const result = await resolveCredentials({});
expect(result.warnings).toContain( 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',
); );
expect(result.warnings).toContain('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome'); expect(result.warnings).toContain('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome');
}); });
}); });
}); });
+254 -254
View File
@@ -2,298 +2,298 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { TwitterClient } from '../src/lib/twitter-client.js';
describe('TwitterClient', () => { describe('TwitterClient', () => {
const originalFetch = global.fetch; const originalFetch = global.fetch;
const validCookies = { const validCookies = {
authToken: 'test_auth_token', authToken: 'test_auth_token',
ct0: 'test_ct0_token', ct0: 'test_ct0_token',
source: 'test', source: 'test',
}; };
afterEach(() => { afterEach(() => {
global.fetch = originalFetch; global.fetch = originalFetch;
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
describe('constructor', () => { describe('constructor', () => {
it('should throw if authToken is missing', () => { it('should throw if authToken is missing', () => {
expect( expect(
() => () =>
new TwitterClient({ new TwitterClient({
cookies: { authToken: null, ct0: 'test', source: null }, cookies: { authToken: null, ct0: 'test', source: null },
}), }),
).toThrow('Both authToken and ct0 cookies are required'); ).toThrow('Both authToken and ct0 cookies are required');
}); });
it('should throw if ct0 is missing', () => { it('should throw if ct0 is missing', () => {
expect( expect(
() => () =>
new TwitterClient({ new TwitterClient({
cookies: { authToken: 'test', ct0: null, source: null }, cookies: { authToken: 'test', ct0: null, source: null },
}), }),
).toThrow('Both authToken and ct0 cookies are required'); ).toThrow('Both authToken and ct0 cookies are required');
}); });
it('should create client with valid cookies', () => { it('should create client with valid cookies', () => {
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ cookies: validCookies });
expect(client).toBeInstanceOf(TwitterClient); expect(client).toBeInstanceOf(TwitterClient);
}); });
}); });
describe('tweet', () => { describe('tweet', () => {
let mockFetch: ReturnType<typeof vi.fn>; let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => { beforeEach(() => {
mockFetch = vi.fn(); mockFetch = vi.fn();
global.fetch = mockFetch; global.fetch = mockFetch;
}); });
it('should post a tweet successfully', async () => { it('should post a tweet successfully', async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
ok: true, ok: true,
json: async () => ({ json: async () => ({
data: { data: {
create_tweet: { create_tweet: {
tweet_results: { tweet_results: {
result: { result: {
rest_id: '1234567890', rest_id: '1234567890',
legacy: { legacy: {
full_text: 'Hello world!', full_text: 'Hello world!',
}, },
}, },
}, },
}, },
}, },
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Hello world!'); const result = await client.tweet('Hello world!');
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.tweetId).toBe('1234567890'); expect(result.tweetId).toBe('1234567890');
expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0]; const [url, options] = mockFetch.mock.calls[0];
expect(url).toContain('CreateTweet'); expect(url).toContain('CreateTweet');
expect(options.method).toBe('POST'); expect(options.method).toBe('POST');
const body = JSON.parse(options.body); const body = JSON.parse(options.body);
expect(body.variables.tweet_text).toBe('Hello world!'); expect(body.variables.tweet_text).toBe('Hello world!');
}); });
it('should handle API errors', async () => { it('should handle API errors', async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
ok: true, ok: true,
json: async () => ({ json: async () => ({
errors: [{ message: 'Rate limit exceeded', code: 88 }], errors: [{ message: 'Rate limit exceeded', code: 88 }],
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Test'); const result = await client.tweet('Test');
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('Rate limit exceeded'); expect(result.error).toContain('Rate limit exceeded');
}); });
it('should handle HTTP errors', async () => { it('should handle HTTP errors', async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
ok: false, ok: false,
status: 403, status: 403,
text: async () => 'Forbidden', text: async () => 'Forbidden',
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Test'); const result = await client.tweet('Test');
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 403'); expect(result.error).toContain('HTTP 403');
}); });
it('should handle network errors', async () => { it('should handle network errors', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error')); mockFetch.mockRejectedValueOnce(new Error('Network error'));
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Test'); const result = await client.tweet('Test');
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBe('Network error'); expect(result.error).toBe('Network error');
}); });
it('should surface missing tweet ID when API responds without rest_id', async () => { it('should surface missing tweet ID when API responds without rest_id', async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
ok: true, ok: true,
json: async () => ({ json: async () => ({
data: { data: {
create_tweet: { create_tweet: {
tweet_results: { tweet_results: {
result: { result: {
legacy: { full_text: 'No id' }, legacy: { full_text: 'No id' },
}, },
}, },
}, },
}, },
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Hello world!'); const result = await client.tweet('Hello world!');
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBe('Tweet created but no ID returned'); expect(result.error).toBe('Tweet created but no ID returned');
}); });
}); });
describe('reply', () => { describe('reply', () => {
let mockFetch: ReturnType<typeof vi.fn>; let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => { beforeEach(() => {
mockFetch = vi.fn(); mockFetch = vi.fn();
global.fetch = mockFetch; global.fetch = mockFetch;
}); });
it('should post a reply with correct reply_to_tweet_id', async () => { it('should post a reply with correct reply_to_tweet_id', async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
ok: true, ok: true,
json: async () => ({ json: async () => ({
data: { data: {
create_tweet: { create_tweet: {
tweet_results: { tweet_results: {
result: { result: {
rest_id: '9876543210', rest_id: '9876543210',
}, },
}, },
}, },
}, },
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ cookies: validCookies });
const result = await client.reply('This is a reply', '1234567890'); const result = await client.reply('This is a reply', '1234567890');
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.tweetId).toBe('9876543210'); expect(result.tweetId).toBe('9876543210');
const [, options] = mockFetch.mock.calls[0]; const [, options] = mockFetch.mock.calls[0];
const body = JSON.parse(options.body); const body = JSON.parse(options.body);
expect(body.variables.reply.in_reply_to_tweet_id).toBe('1234567890'); expect(body.variables.reply.in_reply_to_tweet_id).toBe('1234567890');
expect(body.variables.tweet_text).toBe('This is a reply'); expect(body.variables.tweet_text).toBe('This is a reply');
}); });
}); });
describe('getTweet', () => { describe('getTweet', () => {
let mockFetch: ReturnType<typeof vi.fn>; let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => { beforeEach(() => {
mockFetch = vi.fn(); mockFetch = vi.fn();
global.fetch = mockFetch; global.fetch = mockFetch;
}); });
it('should return tweet data from root tweetResult', async () => { it('should return tweet data from root tweetResult', async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
ok: true, ok: true,
json: async () => ({ json: async () => ({
data: { data: {
tweetResult: { tweetResult: {
result: { result: {
rest_id: '12345', rest_id: '12345',
legacy: { legacy: {
full_text: 'Root tweet text', full_text: 'Root tweet text',
created_at: 'Mon Jan 01 00:00:00 +0000 2024', created_at: 'Mon Jan 01 00:00:00 +0000 2024',
reply_count: 1, reply_count: 1,
retweet_count: 2, retweet_count: 2,
favorite_count: 3, favorite_count: 3,
}, },
core: { core: {
user_results: { user_results: {
result: { result: {
legacy: { legacy: {
screen_name: 'user', screen_name: 'user',
name: 'User Name', name: 'User Name',
}, },
}, },
}, },
}, },
}, },
}, },
}, },
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ cookies: validCookies });
const result = await client.getTweet('12345'); const result = await client.getTweet('12345');
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.tweet?.id).toBe('12345'); expect(result.tweet?.id).toBe('12345');
expect(result.tweet?.text).toBe('Root tweet text'); expect(result.tweet?.text).toBe('Root tweet text');
expect(result.tweet?.author.username).toBe('user'); expect(result.tweet?.author.username).toBe('user');
}); });
it('should return tweet data found inside conversation instructions', async () => { it('should return tweet data found inside conversation instructions', async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
ok: true, ok: true,
json: async () => ({ json: async () => ({
data: { data: {
threaded_conversation_with_injections_v2: { threaded_conversation_with_injections_v2: {
instructions: [ instructions: [
{ {
entries: [ entries: [
{ {
content: { content: {
itemContent: { itemContent: {
tweet_results: { tweet_results: {
result: { result: {
rest_id: '6789', rest_id: '6789',
legacy: { legacy: {
full_text: 'Nested text', full_text: 'Nested text',
created_at: 'Mon Jan 01 00:00:00 +0000 2024', created_at: 'Mon Jan 01 00:00:00 +0000 2024',
reply_count: 0, reply_count: 0,
retweet_count: 0, retweet_count: 0,
favorite_count: 0, favorite_count: 0,
}, },
core: { core: {
user_results: { user_results: {
result: { result: {
legacy: { legacy: {
screen_name: 'nestuser', screen_name: 'nestuser',
name: 'Nested User', name: 'Nested User',
}, },
}, },
}, },
}, },
}, },
}, },
}, },
}, },
}, },
], ],
}, },
], ],
}, },
}, },
}), }),
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ cookies: validCookies });
const result = await client.getTweet('6789'); const result = await client.getTweet('6789');
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.tweet?.text).toBe('Nested text'); expect(result.tweet?.text).toBe('Nested text');
expect(result.tweet?.author.username).toBe('nestuser'); expect(result.tweet?.author.username).toBe('nestuser');
}); });
it('should report HTTP errors from getTweet', async () => { it('should report HTTP errors from getTweet', async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
ok: false, ok: false,
status: 404, status: 404,
text: async () => 'Not Found', text: async () => 'Not Found',
}); });
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ cookies: validCookies });
const result = await client.getTweet('missing'); const result = await client.getTweet('missing');
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 404'); expect(result.error).toContain('HTTP 404');
}); });
}); });
}); });
+18 -18
View File
@@ -1,20 +1,20 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"lib": ["ES2022"], "lib": ["ES2022"],
"outDir": "dist", "outDir": "dist",
"rootDir": "src", "rootDir": "src",
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
"sourceMap": true, "sourceMap": true,
"resolveJsonModule": true "resolveJsonModule": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"] "exclude": ["node_modules", "dist", "tests"]
} }
+11 -11
View File
@@ -1,15 +1,15 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
test: { test: {
globals: true, globals: true,
environment: 'node', environment: 'node',
include: ['tests/**/*.test.ts'], include: ['tests/**/*.test.ts'],
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'html'], reporter: ['text', 'html'],
include: ['src/**/*.ts'], include: ['src/**/*.ts'],
exclude: ['src/index.ts'], exclude: ['src/index.ts'],
}, },
}, },
}); });