Initial commit: twitter-cli for posting tweets via GraphQL API
Features: - Post tweets and replies via Twitter GraphQL API - Cookie extraction from Chrome (macOS) - Env var and CLI argument authentication - 19 passing tests with vitest - Biome for linting
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
twitter-cli.bin
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# twitter-cli
|
||||||
|
|
||||||
|
A command-line tool for posting tweets and replies via Twitter's GraphQL API.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Projects/twitter-cli
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Post a tweet
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./twitter-cli tweet "Hello from twitter-cli!"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reply to a tweet
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using tweet URL
|
||||||
|
./twitter-cli reply "https://x.com/user/status/1234567890" "This is my reply"
|
||||||
|
|
||||||
|
# Using tweet ID directly
|
||||||
|
./twitter-cli reply 1234567890 "This is my reply"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check credentials
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./twitter-cli check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
The tool resolves credentials in the following order of priority:
|
||||||
|
|
||||||
|
1. **CLI arguments** (highest priority)
|
||||||
|
```bash
|
||||||
|
./twitter-cli --auth-token "xxx" --ct0 "yyy" tweet "Hello"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Environment variables**
|
||||||
|
```bash
|
||||||
|
export AUTH_TOKEN="xxx"
|
||||||
|
export CT0="yyy"
|
||||||
|
./twitter-cli tweet "Hello"
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternative env var names: `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`
|
||||||
|
|
||||||
|
3. **Chrome cookies** (fallback - macOS only)
|
||||||
|
- Automatically extracts from Chrome's cookie database
|
||||||
|
- Requires Chrome to be logged into x.com
|
||||||
|
- May prompt for keychain access on first run
|
||||||
|
|
||||||
|
### Getting Your Cookies
|
||||||
|
|
||||||
|
1. Open Chrome and log into x.com
|
||||||
|
2. Open DevTools (Cmd+Option+I)
|
||||||
|
3. Go to Application > Cookies > x.com
|
||||||
|
4. Copy the values for `auth_token` and `ct0`
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run in development mode
|
||||||
|
pnpm run dev tweet "Test"
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
pnpm test
|
||||||
|
|
||||||
|
# Run linter
|
||||||
|
pnpm run lint
|
||||||
|
|
||||||
|
# Fix lint issues
|
||||||
|
pnpm run lint:fix
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Chrome cookie extraction requires macOS (uses `sqlite3` and `security` CLI tools)
|
||||||
|
- The keychain access may block when running over SSH - use env vars instead
|
||||||
|
- Twitter may rotate GraphQL query IDs - update `src/lib/twitter-client.ts` if needed
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||||
|
"organizeImports": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"complexity": {
|
||||||
|
"noForEach": "off"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "tab",
|
||||||
|
"lineWidth": 120
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"semicolons": "always"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignore": ["dist/", "node_modules/", "*.d.ts"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "twitter-cli",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "CLI tool for tweeting and replying via Twitter GraphQL API",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"bin": {
|
||||||
|
"twitter-cli": "dist/index.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsx src/index.ts",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"lint": "biome check .",
|
||||||
|
"lint:fix": "biome check --write .",
|
||||||
|
"format": "biome format --write .",
|
||||||
|
"binary": "bun build --compile --minify src/index.ts --outfile twitter-cli"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^12.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^1.9.4",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vitest": "^2.1.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1271
File diff suppressed because it is too large
Load Diff
+167
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* twitter-cli - CLI tool for posting tweets and replies
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* twitter-cli tweet "Hello world!"
|
||||||
|
* twitter-cli reply <tweet-id> "This is a reply"
|
||||||
|
* twitter-cli reply <tweet-url> "This is a reply"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { resolveCredentials } from './lib/cookies.js';
|
||||||
|
import { TwitterClient } from './lib/twitter-client.js';
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
program.name('twitter-cli').description('Post tweets and replies via Twitter GraphQL API').version('0.1.0');
|
||||||
|
|
||||||
|
// Global options for authentication
|
||||||
|
program
|
||||||
|
.option('--auth-token <token>', 'Twitter auth_token cookie')
|
||||||
|
.option('--ct0 <token>', 'Twitter ct0 cookie')
|
||||||
|
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract tweet ID from URL or return as-is if already an ID
|
||||||
|
*/
|
||||||
|
function extractTweetId(input: string): string {
|
||||||
|
// If it's a URL, extract the tweet ID
|
||||||
|
const urlMatch = input.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/);
|
||||||
|
if (urlMatch) {
|
||||||
|
return urlMatch[1];
|
||||||
|
}
|
||||||
|
// Assume it's already an ID
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tweet command
|
||||||
|
program
|
||||||
|
.command('tweet')
|
||||||
|
.description('Post a new tweet')
|
||||||
|
.argument('<text>', 'Tweet text')
|
||||||
|
.action(async (text: string) => {
|
||||||
|
const opts = program.opts();
|
||||||
|
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.tweet(text);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log('✅ Tweet posted successfully!');
|
||||||
|
console.log(`🔗 https://x.com/i/status/${result.tweetId}`);
|
||||||
|
} else {
|
||||||
|
console.error(`❌ Failed to post tweet: ${result.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reply command
|
||||||
|
program
|
||||||
|
.command('reply')
|
||||||
|
.description('Reply to an existing tweet')
|
||||||
|
.argument('<tweet-id-or-url>', 'Tweet ID or URL to reply to')
|
||||||
|
.argument('<text>', 'Reply text')
|
||||||
|
.action(async (tweetIdOrUrl: string, text: string) => {
|
||||||
|
const opts = program.opts();
|
||||||
|
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 tweetId = extractTweetId(tweetIdOrUrl);
|
||||||
|
console.error(`📝 Replying to tweet: ${tweetId}`);
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies });
|
||||||
|
const result = await client.reply(text, tweetId);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log('✅ Reply posted successfully!');
|
||||||
|
console.log(`🔗 https://x.com/i/status/${result.tweetId}`);
|
||||||
|
} else {
|
||||||
|
console.error(`❌ Failed to post reply: ${result.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check command - verify credentials
|
||||||
|
program
|
||||||
|
.command('check')
|
||||||
|
.description('Check credential availability')
|
||||||
|
.action(async () => {
|
||||||
|
const opts = program.opts();
|
||||||
|
const { cookies, warnings } = await resolveCredentials({
|
||||||
|
authToken: opts.authToken,
|
||||||
|
ct0: opts.ct0,
|
||||||
|
chromeProfile: opts.chromeProfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('🔍 Credential Check');
|
||||||
|
console.log('─'.repeat(40));
|
||||||
|
|
||||||
|
if (cookies.authToken) {
|
||||||
|
console.log(`✅ auth_token: ${cookies.authToken.slice(0, 10)}...`);
|
||||||
|
} else {
|
||||||
|
console.log('❌ auth_token: not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cookies.ct0) {
|
||||||
|
console.log(`✅ ct0: ${cookies.ct0.slice(0, 10)}...`);
|
||||||
|
} else {
|
||||||
|
console.log('❌ ct0: not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cookies.source) {
|
||||||
|
console.log(`📍 Source: ${cookies.source}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
console.log('\n⚠️ Warnings:');
|
||||||
|
for (const warning of warnings) {
|
||||||
|
console.log(` - ${warning}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cookies.authToken && cookies.ct0) {
|
||||||
|
console.log('\n✅ Ready to tweet!');
|
||||||
|
} else {
|
||||||
|
console.log('\n❌ Missing credentials. Options:');
|
||||||
|
console.log(' 1. Login to x.com in Chrome');
|
||||||
|
console.log(' 2. Set AUTH_TOKEN and CT0 environment variables');
|
||||||
|
console.log(' 3. Use --auth-token and --ct0 flags');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
program.parse();
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* Chrome cookie extraction for Twitter authentication
|
||||||
|
* Uses macOS sqlite3 CLI and keychain for decryption - no native dependencies!
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { createDecipheriv, pbkdf2Sync } from 'node:crypto';
|
||||||
|
import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
export interface TwitterCookies {
|
||||||
|
authToken: string | null;
|
||||||
|
ct0: string | null;
|
||||||
|
source: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CookieExtractionResult {
|
||||||
|
cookies: TwitterCookies;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeValue(value: unknown): string | null {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChromeCookiesPath(profile?: string): string {
|
||||||
|
const home = process.env.HOME || '';
|
||||||
|
const profileDir = profile || 'Default';
|
||||||
|
return join(home, 'Library', 'Application Support', 'Google', 'Chrome', profileDir, 'Cookies');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt Chrome cookie value using macOS keychain
|
||||||
|
* Chrome encrypts cookies with a key stored in the keychain
|
||||||
|
*/
|
||||||
|
function decryptCookieValue(encryptedHex: string): string | null {
|
||||||
|
try {
|
||||||
|
// Convert hex to buffer
|
||||||
|
const encryptedValue = Buffer.from(encryptedHex, 'hex');
|
||||||
|
|
||||||
|
if (encryptedValue.length < 4) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = encryptedValue.subarray(0, 3).toString('utf8');
|
||||||
|
if (version !== 'v10' && version !== 'v11') {
|
||||||
|
// Not encrypted, just return as string
|
||||||
|
return encryptedValue.toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get encryption key from keychain
|
||||||
|
const keyOutput = execSync('security find-generic-password -s "Chrome Safe Storage" -w 2>/dev/null || echo ""', {
|
||||||
|
encoding: 'utf8',
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
if (!keyOutput) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the key using PBKDF2
|
||||||
|
const salt = 'saltysalt';
|
||||||
|
const iterations = 1003;
|
||||||
|
const keyLength = 16;
|
||||||
|
|
||||||
|
const derivedKey = pbkdf2Sync(keyOutput, salt, iterations, keyLength, 'sha1');
|
||||||
|
|
||||||
|
// Decrypt using AES-128-CBC with empty IV (16 bytes of 0x20/space)
|
||||||
|
const iv = Buffer.alloc(16, 0x20);
|
||||||
|
const encryptedData = encryptedValue.subarray(3); // Skip "v10" or "v11" prefix
|
||||||
|
|
||||||
|
const decipher = createDecipheriv('aes-128-cbc', derivedKey, iv);
|
||||||
|
decipher.setAutoPadding(true);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encryptedData);
|
||||||
|
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||||
|
|
||||||
|
return decrypted.toString('utf8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract Twitter cookies from Chrome browser using sqlite3 CLI
|
||||||
|
* @param profile - Chrome profile name (optional, uses Default if not specified)
|
||||||
|
*/
|
||||||
|
export async function extractCookiesFromChrome(profile?: string): Promise<CookieExtractionResult> {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const cookies: TwitterCookies = {
|
||||||
|
authToken: null,
|
||||||
|
ct0: null,
|
||||||
|
source: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cookiesPath = getChromeCookiesPath(profile);
|
||||||
|
|
||||||
|
if (!existsSync(cookiesPath)) {
|
||||||
|
warnings.push(`Chrome cookies database not found at: ${cookiesPath}`);
|
||||||
|
return { cookies, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chrome locks the database, so we need to copy it
|
||||||
|
let tempDir: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-'));
|
||||||
|
const tempDbPath = join(tempDir, 'Cookies');
|
||||||
|
copyFileSync(cookiesPath, tempDbPath);
|
||||||
|
|
||||||
|
// Also copy the WAL and SHM files if they exist
|
||||||
|
const walPath = `${cookiesPath}-wal`;
|
||||||
|
const shmPath = `${cookiesPath}-shm`;
|
||||||
|
if (existsSync(walPath)) {
|
||||||
|
copyFileSync(walPath, `${tempDbPath}-wal`);
|
||||||
|
}
|
||||||
|
if (existsSync(shmPath)) {
|
||||||
|
copyFileSync(shmPath, `${tempDbPath}-shm`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
for (const line of result.split('\n')) {
|
||||||
|
const [name, encryptedHex] = line.split('|');
|
||||||
|
if (!name || !encryptedHex) continue;
|
||||||
|
|
||||||
|
const decryptedValue = decryptCookieValue(encryptedHex);
|
||||||
|
if (decryptedValue) {
|
||||||
|
if (name === 'auth_token' && !cookies.authToken) {
|
||||||
|
cookies.authToken = decryptedValue;
|
||||||
|
} else if (name === 'ct0' && !cookies.ct0) {
|
||||||
|
cookies.ct0 = decryptedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cookies.authToken || cookies.ct0) {
|
||||||
|
cookies.source = profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
warnings.push(`Failed to read Chrome cookies: ${message}`);
|
||||||
|
} finally {
|
||||||
|
// Cleanup temp files
|
||||||
|
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 Chrome. Make sure you are logged into x.com in Chrome.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cookies, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve Twitter credentials from multiple sources
|
||||||
|
* Priority: CLI args > environment variables > Chrome cookies
|
||||||
|
*/
|
||||||
|
export async function resolveCredentials(options: {
|
||||||
|
authToken?: string;
|
||||||
|
ct0?: string;
|
||||||
|
chromeProfile?: string;
|
||||||
|
}): Promise<CookieExtractionResult> {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const cookies: TwitterCookies = {
|
||||||
|
authToken: null,
|
||||||
|
ct0: null,
|
||||||
|
source: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. CLI arguments (highest priority)
|
||||||
|
if (options.authToken) {
|
||||||
|
cookies.authToken = options.authToken;
|
||||||
|
cookies.source = 'CLI argument';
|
||||||
|
}
|
||||||
|
if (options.ct0) {
|
||||||
|
cookies.ct0 = options.ct0;
|
||||||
|
if (!cookies.source) cookies.source = 'CLI argument';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Environment variables
|
||||||
|
const envAuthKeys = ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'];
|
||||||
|
const envCt0Keys = ['CT0', 'TWITTER_CT0'];
|
||||||
|
|
||||||
|
if (!cookies.authToken) {
|
||||||
|
for (const key of envAuthKeys) {
|
||||||
|
const value = normalizeValue(process.env[key]);
|
||||||
|
if (value) {
|
||||||
|
cookies.authToken = value;
|
||||||
|
cookies.source = `env ${key}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cookies.ct0) {
|
||||||
|
for (const key of envCt0Keys) {
|
||||||
|
const value = normalizeValue(process.env[key]);
|
||||||
|
if (value) {
|
||||||
|
cookies.ct0 = value;
|
||||||
|
if (!cookies.source) cookies.source = `env ${key}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Chrome cookies (fallback)
|
||||||
|
if (!cookies.authToken || !cookies.ct0) {
|
||||||
|
const chromeResult = await extractCookiesFromChrome(options.chromeProfile);
|
||||||
|
warnings.push(...chromeResult.warnings);
|
||||||
|
|
||||||
|
if (!cookies.authToken && chromeResult.cookies.authToken) {
|
||||||
|
cookies.authToken = chromeResult.cookies.authToken;
|
||||||
|
cookies.source = chromeResult.cookies.source;
|
||||||
|
}
|
||||||
|
if (!cookies.ct0 && chromeResult.cookies.ct0) {
|
||||||
|
cookies.ct0 = chromeResult.cookies.ct0;
|
||||||
|
if (!cookies.source) cookies.source = chromeResult.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');
|
||||||
|
}
|
||||||
|
if (!cookies.ct0) {
|
||||||
|
warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cookies, warnings };
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* Twitter GraphQL API client for posting tweets and replies
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TwitterCookies } from './cookies.js';
|
||||||
|
|
||||||
|
const TWITTER_API_BASE = 'https://x.com/i/api/graphql';
|
||||||
|
|
||||||
|
// Twitter GraphQL query IDs - these may need to be updated periodically
|
||||||
|
// as Twitter rotates them. Check browser network tab for current values.
|
||||||
|
const QUERY_IDS = {
|
||||||
|
CreateTweet: 'znCVAd692dKBq9MgkEhKPQ',
|
||||||
|
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
|
||||||
|
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TweetResult {
|
||||||
|
success: boolean;
|
||||||
|
tweetId?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwitterClientOptions {
|
||||||
|
cookies: TwitterCookies;
|
||||||
|
userAgent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateTweetResponse {
|
||||||
|
data?: {
|
||||||
|
create_tweet?: {
|
||||||
|
tweet_results?: {
|
||||||
|
result?: {
|
||||||
|
rest_id?: string;
|
||||||
|
legacy?: {
|
||||||
|
full_text?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
errors?: Array<{ message: string; code?: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TwitterClient {
|
||||||
|
private authToken: string;
|
||||||
|
private ct0: string;
|
||||||
|
private userAgent: string;
|
||||||
|
|
||||||
|
constructor(options: TwitterClientOptions) {
|
||||||
|
if (!options.cookies.authToken || !options.cookies.ct0) {
|
||||||
|
throw new Error('Both authToken and ct0 cookies are required');
|
||||||
|
}
|
||||||
|
this.authToken = options.cookies.authToken;
|
||||||
|
this.ct0 = options.cookies.ct0;
|
||||||
|
this.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';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHeaders(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
authorization:
|
||||||
|
'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-csrf-token': this.ct0,
|
||||||
|
'x-twitter-auth-type': 'OAuth2Session',
|
||||||
|
'x-twitter-active-user': 'yes',
|
||||||
|
'x-twitter-client-language': 'en',
|
||||||
|
cookie: `auth_token=${this.authToken}; ct0=${this.ct0}`,
|
||||||
|
'user-agent': this.userAgent,
|
||||||
|
origin: 'https://x.com',
|
||||||
|
referer: 'https://x.com/',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post a new tweet
|
||||||
|
*/
|
||||||
|
async tweet(text: string): Promise<TweetResult> {
|
||||||
|
const variables = {
|
||||||
|
tweet_text: text,
|
||||||
|
dark_request: false,
|
||||||
|
media: {
|
||||||
|
media_entities: [],
|
||||||
|
possibly_sensitive: false,
|
||||||
|
},
|
||||||
|
semantic_annotation_ids: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const features = {
|
||||||
|
communities_web_enable_tweet_community_results_fetch: true,
|
||||||
|
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||||
|
responsive_web_edit_tweet_api_enabled: true,
|
||||||
|
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
||||||
|
view_counts_everywhere_api_enabled: true,
|
||||||
|
longform_notetweets_consumption_enabled: true,
|
||||||
|
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
||||||
|
tweet_awards_web_tipping_enabled: false,
|
||||||
|
creator_subscriptions_quote_tweet_preview_enabled: false,
|
||||||
|
longform_notetweets_rich_text_read_enabled: true,
|
||||||
|
longform_notetweets_inline_media_enabled: true,
|
||||||
|
articles_preview_enabled: true,
|
||||||
|
rweb_video_timestamps_enabled: true,
|
||||||
|
rweb_tipjar_consumption_enabled: true,
|
||||||
|
responsive_web_graphql_exclude_directive_enabled: true,
|
||||||
|
verified_phone_label_enabled: false,
|
||||||
|
freedom_of_speech_not_reach_fetch_enabled: true,
|
||||||
|
standardized_nudges_misinfo: 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_timeline_navigation_enabled: true,
|
||||||
|
responsive_web_enhance_cards_enabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.createTweet(variables, features);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reply to an existing tweet
|
||||||
|
*/
|
||||||
|
async reply(text: string, replyToTweetId: string): Promise<TweetResult> {
|
||||||
|
const variables = {
|
||||||
|
tweet_text: text,
|
||||||
|
reply: {
|
||||||
|
in_reply_to_tweet_id: replyToTweetId,
|
||||||
|
exclude_reply_user_ids: [],
|
||||||
|
},
|
||||||
|
dark_request: false,
|
||||||
|
media: {
|
||||||
|
media_entities: [],
|
||||||
|
possibly_sensitive: false,
|
||||||
|
},
|
||||||
|
semantic_annotation_ids: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const features = {
|
||||||
|
communities_web_enable_tweet_community_results_fetch: true,
|
||||||
|
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||||
|
responsive_web_edit_tweet_api_enabled: true,
|
||||||
|
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
||||||
|
view_counts_everywhere_api_enabled: true,
|
||||||
|
longform_notetweets_consumption_enabled: true,
|
||||||
|
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
||||||
|
tweet_awards_web_tipping_enabled: false,
|
||||||
|
creator_subscriptions_quote_tweet_preview_enabled: false,
|
||||||
|
longform_notetweets_rich_text_read_enabled: true,
|
||||||
|
longform_notetweets_inline_media_enabled: true,
|
||||||
|
articles_preview_enabled: true,
|
||||||
|
rweb_video_timestamps_enabled: true,
|
||||||
|
rweb_tipjar_consumption_enabled: true,
|
||||||
|
responsive_web_graphql_exclude_directive_enabled: true,
|
||||||
|
verified_phone_label_enabled: false,
|
||||||
|
freedom_of_speech_not_reach_fetch_enabled: true,
|
||||||
|
standardized_nudges_misinfo: 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_timeline_navigation_enabled: true,
|
||||||
|
responsive_web_enhance_cards_enabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.createTweet(variables, features);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createTweet(
|
||||||
|
variables: Record<string, unknown>,
|
||||||
|
features: Record<string, boolean>,
|
||||||
|
): Promise<TweetResult> {
|
||||||
|
const url = `${TWITTER_API_BASE}/${QUERY_IDS.CreateTweet}/CreateTweet`;
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
variables,
|
||||||
|
features,
|
||||||
|
queryId: QUERY_IDS.CreateTweet,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `HTTP ${response.status}: ${text.slice(0, 200)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as CreateTweetResponse;
|
||||||
|
|
||||||
|
if (data.errors && data.errors.length > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: data.errors.map((e) => e.message).join(', '),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id;
|
||||||
|
if (tweetId) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
tweetId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Tweet created but no ID returned',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
describe('CLI utilities', () => {
|
||||||
|
describe('extractTweetId', () => {
|
||||||
|
// Testing the URL extraction logic inline since it's a simple function
|
||||||
|
function extractTweetId(input: string): string {
|
||||||
|
const urlMatch = input.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/);
|
||||||
|
if (urlMatch) {
|
||||||
|
return urlMatch[1];
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should extract ID from x.com URL', () => {
|
||||||
|
const url = 'https://x.com/steipete/status/1234567890123456789';
|
||||||
|
expect(extractTweetId(url)).toBe('1234567890123456789');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract ID from twitter.com URL', () => {
|
||||||
|
const url = 'https://twitter.com/steipete/status/1234567890123456789';
|
||||||
|
expect(extractTweetId(url)).toBe('1234567890123456789');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract ID from URL with query params', () => {
|
||||||
|
const url = 'https://x.com/steipete/status/1234567890123456789?s=20';
|
||||||
|
expect(extractTweetId(url)).toBe('1234567890123456789');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return ID as-is if already an ID', () => {
|
||||||
|
const id = '1234567890123456789';
|
||||||
|
expect(extractTweetId(id)).toBe('1234567890123456789');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle URLs with www prefix', () => {
|
||||||
|
// Note: our regex handles this because \w+ matches any word chars after the domain
|
||||||
|
const url = 'https://x.com/user_name/status/1234567890123456789';
|
||||||
|
expect(extractTweetId(url)).toBe('1234567890123456789');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock child_process.execSync to prevent actual shell commands
|
||||||
|
vi.mock('node:child_process', () => ({
|
||||||
|
execSync: vi.fn(() => ''),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock fs to prevent actual file operations
|
||||||
|
vi.mock('node:fs', () => ({
|
||||||
|
existsSync: vi.fn(() => false),
|
||||||
|
copyFileSync: vi.fn(),
|
||||||
|
mkdtempSync: vi.fn(() => '/tmp/test-dir'),
|
||||||
|
rmSync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('cookies', () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
// Clear Twitter-related env vars
|
||||||
|
process.env.AUTH_TOKEN = undefined;
|
||||||
|
process.env.TWITTER_AUTH_TOKEN = undefined;
|
||||||
|
process.env.CT0 = undefined;
|
||||||
|
process.env.TWITTER_CT0 = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveCredentials', () => {
|
||||||
|
it('should prioritize CLI arguments over env vars', async () => {
|
||||||
|
process.env.AUTH_TOKEN = 'env_auth';
|
||||||
|
process.env.CT0 = 'env_ct0';
|
||||||
|
|
||||||
|
const { resolveCredentials } = await import('../src/lib/cookies.js');
|
||||||
|
const result = await resolveCredentials({
|
||||||
|
authToken: 'cli_auth',
|
||||||
|
ct0: 'cli_ct0',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.cookies.authToken).toBe('cli_auth');
|
||||||
|
expect(result.cookies.ct0).toBe('cli_ct0');
|
||||||
|
expect(result.cookies.source).toBe('CLI argument');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use AUTH_TOKEN env var', async () => {
|
||||||
|
process.env.AUTH_TOKEN = 'test_auth_token';
|
||||||
|
process.env.CT0 = 'test_ct0';
|
||||||
|
|
||||||
|
const { resolveCredentials } = await import('../src/lib/cookies.js');
|
||||||
|
const result = await resolveCredentials({});
|
||||||
|
|
||||||
|
expect(result.cookies.authToken).toBe('test_auth_token');
|
||||||
|
expect(result.cookies.ct0).toBe('test_ct0');
|
||||||
|
expect(result.cookies.source).toBe('env AUTH_TOKEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use TWITTER_AUTH_TOKEN env var as fallback', async () => {
|
||||||
|
process.env.TWITTER_AUTH_TOKEN = 'twitter_auth';
|
||||||
|
process.env.TWITTER_CT0 = 'twitter_ct0';
|
||||||
|
|
||||||
|
const { resolveCredentials } = await import('../src/lib/cookies.js');
|
||||||
|
const result = await resolveCredentials({});
|
||||||
|
|
||||||
|
expect(result.cookies.authToken).toBe('twitter_auth');
|
||||||
|
expect(result.cookies.ct0).toBe('twitter_ct0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace from values', async () => {
|
||||||
|
process.env.AUTH_TOKEN = ' trimmed_auth ';
|
||||||
|
process.env.CT0 = ' trimmed_ct0 ';
|
||||||
|
|
||||||
|
const { resolveCredentials } = await import('../src/lib/cookies.js');
|
||||||
|
const result = await resolveCredentials({});
|
||||||
|
|
||||||
|
expect(result.cookies.authToken).toBe('trimmed_auth');
|
||||||
|
expect(result.cookies.ct0).toBe('trimmed_ct0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should treat empty strings as null', async () => {
|
||||||
|
process.env.AUTH_TOKEN = ' ';
|
||||||
|
process.env.CT0 = '';
|
||||||
|
|
||||||
|
const { resolveCredentials } = await import('../src/lib/cookies.js');
|
||||||
|
const result = await resolveCredentials({});
|
||||||
|
|
||||||
|
expect(result.cookies.authToken).toBeNull();
|
||||||
|
expect(result.cookies.ct0).toBeNull();
|
||||||
|
expect(result.warnings.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn when credentials are missing', async () => {
|
||||||
|
const { resolveCredentials } = await import('../src/lib/cookies.js');
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
expect(result.warnings).toContain('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { TwitterClient } from '../src/lib/twitter-client.js';
|
||||||
|
|
||||||
|
describe('TwitterClient', () => {
|
||||||
|
const validCookies = {
|
||||||
|
authToken: 'test_auth_token',
|
||||||
|
ct0: 'test_ct0_token',
|
||||||
|
source: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should throw if authToken is missing', () => {
|
||||||
|
expect(
|
||||||
|
() =>
|
||||||
|
new TwitterClient({
|
||||||
|
cookies: { authToken: null, ct0: 'test', source: null },
|
||||||
|
}),
|
||||||
|
).toThrow('Both authToken and ct0 cookies are required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if ct0 is missing', () => {
|
||||||
|
expect(
|
||||||
|
() =>
|
||||||
|
new TwitterClient({
|
||||||
|
cookies: { authToken: 'test', ct0: null, source: null },
|
||||||
|
}),
|
||||||
|
).toThrow('Both authToken and ct0 cookies are required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create client with valid cookies', () => {
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
expect(client).toBeInstanceOf(TwitterClient);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tweet', () => {
|
||||||
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should post a tweet successfully', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
create_tweet: {
|
||||||
|
tweet_results: {
|
||||||
|
result: {
|
||||||
|
rest_id: '1234567890',
|
||||||
|
legacy: {
|
||||||
|
full_text: 'Hello world!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.tweet('Hello world!');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.tweetId).toBe('1234567890');
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [url, options] = mockFetch.mock.calls[0];
|
||||||
|
expect(url).toContain('CreateTweet');
|
||||||
|
expect(options.method).toBe('POST');
|
||||||
|
|
||||||
|
const body = JSON.parse(options.body);
|
||||||
|
expect(body.variables.tweet_text).toBe('Hello world!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle API errors', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
errors: [{ message: 'Rate limit exceeded', code: 88 }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.tweet('Test');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('Rate limit exceeded');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle HTTP errors', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
text: async () => 'Forbidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.tweet('Test');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('HTTP 403');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle network errors', async () => {
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.tweet('Test');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('Network error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reply', () => {
|
||||||
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should post a reply with correct reply_to_tweet_id', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
create_tweet: {
|
||||||
|
tweet_results: {
|
||||||
|
result: {
|
||||||
|
rest_id: '9876543210',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.reply('This is a reply', '1234567890');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.tweetId).toBe('9876543210');
|
||||||
|
|
||||||
|
const [, options] = mockFetch.mock.calls[0];
|
||||||
|
const body = JSON.parse(options.body);
|
||||||
|
expect(body.variables.reply.in_reply_to_tweet_id).toBe('1234567890');
|
||||||
|
expect(body.variables.tweet_text).toBe('This is a reply');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "tests"]
|
||||||
|
}
|
||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# twitter-cli - CLI tool for posting tweets and replies
|
||||||
|
# Requires Node.js 20+ and pnpm
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR" && exec pnpm exec tsx src/index.ts "$@"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'html'],
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: ['src/index.ts'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user