chore: reformat with 2-space indentation (biome)
This commit is contained in:
+145
-145
@@ -20,183 +20,183 @@ program.name('bird').description('Post tweets and replies via Twitter/X GraphQL
|
||||
|
||||
// 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');
|
||||
.option('--auth-token <token>', 'Twitter auth_token cookie')
|
||||
.option('--ct0 <token>', 'Twitter ct0 cookie')
|
||||
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction');
|
||||
|
||||
// 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,
|
||||
});
|
||||
.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}`);
|
||||
}
|
||||
for (const warning of warnings) {
|
||||
console.error(`⚠️ ${warning}`);
|
||||
}
|
||||
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
console.error('❌ Missing required credentials');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
console.error('❌ Missing required credentials');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (cookies.source) {
|
||||
console.error(`📍 Using credentials from: ${cookies.source}`);
|
||||
}
|
||||
if (cookies.source) {
|
||||
console.error(`📍 Using credentials from: ${cookies.source}`);
|
||||
}
|
||||
|
||||
const client = new TwitterClient({ cookies });
|
||||
const result = await client.tweet(text);
|
||||
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);
|
||||
}
|
||||
});
|
||||
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,
|
||||
});
|
||||
.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}`);
|
||||
}
|
||||
for (const warning of warnings) {
|
||||
console.error(`⚠️ ${warning}`);
|
||||
}
|
||||
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
console.error('❌ Missing required credentials');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
console.error('❌ Missing required credentials');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (cookies.source) {
|
||||
console.error(`📍 Using credentials from: ${cookies.source}`);
|
||||
}
|
||||
if (cookies.source) {
|
||||
console.error(`📍 Using credentials from: ${cookies.source}`);
|
||||
}
|
||||
|
||||
const tweetId = extractTweetId(tweetIdOrUrl);
|
||||
console.error(`📝 Replying to tweet: ${tweetId}`);
|
||||
const tweetId = extractTweetId(tweetIdOrUrl);
|
||||
console.error(`📝 Replying to tweet: ${tweetId}`);
|
||||
|
||||
const client = new TwitterClient({ cookies });
|
||||
const result = await client.reply(text, 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);
|
||||
}
|
||||
});
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Read command - fetch tweet content
|
||||
program
|
||||
.command('read')
|
||||
.description('Read/fetch a tweet by ID or URL')
|
||||
.argument('<tweet-id-or-url>', 'Tweet ID or URL to read')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
||||
const opts = program.opts();
|
||||
const { cookies, warnings } = await resolveCredentials({
|
||||
authToken: opts.authToken,
|
||||
ct0: opts.ct0,
|
||||
chromeProfile: opts.chromeProfile,
|
||||
});
|
||||
.command('read')
|
||||
.description('Read/fetch a tweet by ID or URL')
|
||||
.argument('<tweet-id-or-url>', 'Tweet ID or URL to read')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
||||
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}`);
|
||||
}
|
||||
for (const warning of warnings) {
|
||||
console.error(`⚠️ ${warning}`);
|
||||
}
|
||||
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
console.error('❌ Missing required credentials');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
console.error('❌ Missing required credentials');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tweetId = extractTweetId(tweetIdOrUrl);
|
||||
const client = new TwitterClient({ cookies });
|
||||
const result = await client.getTweet(tweetId);
|
||||
const tweetId = extractTweetId(tweetIdOrUrl);
|
||||
const client = new TwitterClient({ cookies });
|
||||
const result = await client.getTweet(tweetId);
|
||||
|
||||
if (result.success && result.tweet) {
|
||||
if (cmdOpts.json) {
|
||||
console.log(JSON.stringify(result.tweet, null, 2));
|
||||
} else {
|
||||
console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`);
|
||||
console.log(result.tweet.text);
|
||||
if (result.tweet.createdAt) {
|
||||
console.log(`\n📅 ${result.tweet.createdAt}`);
|
||||
}
|
||||
console.log(
|
||||
`❤️ ${result.tweet.likeCount ?? 0} 🔁 ${result.tweet.retweetCount ?? 0} 💬 ${result.tweet.replyCount ?? 0}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error(`❌ Failed to read tweet: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
if (result.success && result.tweet) {
|
||||
if (cmdOpts.json) {
|
||||
console.log(JSON.stringify(result.tweet, null, 2));
|
||||
} else {
|
||||
console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`);
|
||||
console.log(result.tweet.text);
|
||||
if (result.tweet.createdAt) {
|
||||
console.log(`\n📅 ${result.tweet.createdAt}`);
|
||||
}
|
||||
console.log(
|
||||
`❤️ ${result.tweet.likeCount ?? 0} 🔁 ${result.tweet.retweetCount ?? 0} 💬 ${result.tweet.replyCount ?? 0}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error(`❌ Failed to read tweet: ${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,
|
||||
});
|
||||
.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));
|
||||
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.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.ct0) {
|
||||
console.log(`✅ ct0: ${cookies.ct0.slice(0, 10)}...`);
|
||||
} else {
|
||||
console.log('❌ ct0: not found');
|
||||
}
|
||||
|
||||
if (cookies.source) {
|
||||
console.log(`📍 Source: ${cookies.source}`);
|
||||
}
|
||||
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 (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);
|
||||
}
|
||||
});
|
||||
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();
|
||||
|
||||
+175
-175
@@ -10,28 +10,28 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface TwitterCookies {
|
||||
authToken: string | null;
|
||||
ct0: string | null;
|
||||
source: string | null;
|
||||
authToken: string | null;
|
||||
ct0: string | null;
|
||||
source: string | null;
|
||||
}
|
||||
|
||||
export interface CookieExtractionResult {
|
||||
cookies: TwitterCookies;
|
||||
warnings: string[];
|
||||
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;
|
||||
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');
|
||||
const home = process.env.HOME || '';
|
||||
const profileDir = profile || 'Default';
|
||||
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
|
||||
*/
|
||||
function decryptCookieValue(encryptedHex: string): string | null {
|
||||
try {
|
||||
// Convert hex to buffer
|
||||
const encryptedValue = Buffer.from(encryptedHex, 'hex');
|
||||
try {
|
||||
// Convert hex to buffer
|
||||
const encryptedValue = Buffer.from(encryptedHex, 'hex');
|
||||
|
||||
if (encryptedValue.length < 4) {
|
||||
return null;
|
||||
}
|
||||
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');
|
||||
}
|
||||
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();
|
||||
// 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;
|
||||
}
|
||||
if (!keyOutput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Derive the key using PBKDF2
|
||||
const salt = 'saltysalt';
|
||||
const iterations = 1003;
|
||||
const keyLength = 16;
|
||||
// Derive the key using PBKDF2
|
||||
const salt = 'saltysalt';
|
||||
const iterations = 1003;
|
||||
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)
|
||||
const iv = Buffer.alloc(16, 0x20);
|
||||
const encryptedData = encryptedValue.subarray(3); // Skip "v10" or "v11" prefix
|
||||
// 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);
|
||||
const decipher = createDecipheriv('aes-128-cbc', derivedKey, iv);
|
||||
decipher.setAutoPadding(true);
|
||||
|
||||
let decrypted = decipher.update(encryptedData);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
let decrypted = decipher.update(encryptedData);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
|
||||
return decrypted.toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return decrypted.toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,84 +90,84 @@ function decryptCookieValue(encryptedHex: string): string | null {
|
||||
* @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 warnings: string[] = [];
|
||||
const cookies: TwitterCookies = {
|
||||
authToken: null,
|
||||
ct0: null,
|
||||
source: null,
|
||||
};
|
||||
|
||||
const cookiesPath = getChromeCookiesPath(profile);
|
||||
const cookiesPath = getChromeCookiesPath(profile);
|
||||
|
||||
if (!existsSync(cookiesPath)) {
|
||||
warnings.push(`Chrome cookies database not found at: ${cookiesPath}`);
|
||||
return { cookies, warnings };
|
||||
}
|
||||
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;
|
||||
// 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);
|
||||
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`);
|
||||
}
|
||||
// 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');`;
|
||||
// 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();
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
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.');
|
||||
}
|
||||
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 };
|
||||
return { cookies, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,75 +175,75 @@ export async function extractCookiesFromChrome(profile?: string): Promise<Cookie
|
||||
* Priority: CLI args > environment variables > Chrome cookies
|
||||
*/
|
||||
export async function resolveCredentials(options: {
|
||||
authToken?: string;
|
||||
ct0?: string;
|
||||
chromeProfile?: string;
|
||||
authToken?: string;
|
||||
ct0?: string;
|
||||
chromeProfile?: string;
|
||||
}): Promise<CookieExtractionResult> {
|
||||
const warnings: string[] = [];
|
||||
const cookies: TwitterCookies = {
|
||||
authToken: null,
|
||||
ct0: null,
|
||||
source: null,
|
||||
};
|
||||
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';
|
||||
}
|
||||
// 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'];
|
||||
// 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.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
// 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 };
|
||||
return { cookies, warnings };
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* 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 {
|
||||
// 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;
|
||||
// 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;
|
||||
}
|
||||
|
||||
+400
-400
@@ -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
|
||||
// as Twitter rotates them. Check browser network tab for current values.
|
||||
const QUERY_IDS = {
|
||||
CreateTweet: 'znCVAd692dKBq9MgkEhKPQ',
|
||||
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
|
||||
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
|
||||
TweetDetail: 'nBS-WpgA6ZG0CyNHD517JQ',
|
||||
CreateTweet: 'znCVAd692dKBq9MgkEhKPQ',
|
||||
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
|
||||
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
|
||||
TweetDetail: 'nBS-WpgA6ZG0CyNHD517JQ',
|
||||
};
|
||||
|
||||
export interface TweetResult {
|
||||
success: boolean;
|
||||
tweetId?: string;
|
||||
error?: string;
|
||||
success: boolean;
|
||||
tweetId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TweetData {
|
||||
id: string;
|
||||
text: string;
|
||||
author: {
|
||||
username: string;
|
||||
name: string;
|
||||
};
|
||||
createdAt?: string;
|
||||
replyCount?: number;
|
||||
retweetCount?: number;
|
||||
likeCount?: number;
|
||||
id: string;
|
||||
text: string;
|
||||
author: {
|
||||
username: string;
|
||||
name: string;
|
||||
};
|
||||
createdAt?: string;
|
||||
replyCount?: number;
|
||||
retweetCount?: number;
|
||||
likeCount?: number;
|
||||
}
|
||||
|
||||
export interface GetTweetResult {
|
||||
success: boolean;
|
||||
tweet?: TweetData;
|
||||
error?: string;
|
||||
success: boolean;
|
||||
tweet?: TweetData;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TwitterClientOptions {
|
||||
cookies: TwitterCookies;
|
||||
userAgent?: string;
|
||||
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 }>;
|
||||
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;
|
||||
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';
|
||||
}
|
||||
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 findTweetInInstructions(
|
||||
instructions:
|
||||
| Array<{
|
||||
entries?: Array<{
|
||||
content?: {
|
||||
itemContent?: {
|
||||
tweet_results?: {
|
||||
result?: {
|
||||
rest_id?: string;
|
||||
legacy?: {
|
||||
full_text?: string;
|
||||
created_at?: string;
|
||||
reply_count?: number;
|
||||
retweet_count?: number;
|
||||
favorite_count?: number;
|
||||
};
|
||||
core?: {
|
||||
user_results?: {
|
||||
result?: {
|
||||
legacy?: {
|
||||
screen_name?: string;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}>
|
||||
| undefined,
|
||||
tweetId: string,
|
||||
) {
|
||||
if (!instructions) return undefined;
|
||||
private findTweetInInstructions(
|
||||
instructions:
|
||||
| Array<{
|
||||
entries?: Array<{
|
||||
content?: {
|
||||
itemContent?: {
|
||||
tweet_results?: {
|
||||
result?: {
|
||||
rest_id?: string;
|
||||
legacy?: {
|
||||
full_text?: string;
|
||||
created_at?: string;
|
||||
reply_count?: number;
|
||||
retweet_count?: number;
|
||||
favorite_count?: number;
|
||||
};
|
||||
core?: {
|
||||
user_results?: {
|
||||
result?: {
|
||||
legacy?: {
|
||||
screen_name?: string;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}>
|
||||
| undefined,
|
||||
tweetId: string,
|
||||
) {
|
||||
if (!instructions) return undefined;
|
||||
|
||||
for (const instruction of instructions) {
|
||||
for (const entry of instruction.entries || []) {
|
||||
const result = entry.content?.itemContent?.tweet_results?.result;
|
||||
if (result?.rest_id === tweetId) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const instruction of instructions) {
|
||||
for (const entry of instruction.entries || []) {
|
||||
const result = entry.content?.itemContent?.tweet_results?.result;
|
||||
if (result?.rest_id === tweetId) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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/',
|
||||
};
|
||||
}
|
||||
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/',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tweet details by ID
|
||||
*/
|
||||
async getTweet(tweetId: string): Promise<GetTweetResult> {
|
||||
const variables = {
|
||||
focalTweetId: tweetId,
|
||||
with_rux_injections: false,
|
||||
rankingMode: 'Relevance',
|
||||
includePromotedContent: true,
|
||||
withCommunity: true,
|
||||
withQuickPromoteEligibilityTweetFields: true,
|
||||
withBirdwatchNotes: true,
|
||||
withVoice: true,
|
||||
};
|
||||
/**
|
||||
* Get tweet details by ID
|
||||
*/
|
||||
async getTweet(tweetId: string): Promise<GetTweetResult> {
|
||||
const variables = {
|
||||
focalTweetId: tweetId,
|
||||
with_rux_injections: false,
|
||||
rankingMode: 'Relevance',
|
||||
includePromotedContent: true,
|
||||
withCommunity: true,
|
||||
withQuickPromoteEligibilityTweetFields: true,
|
||||
withBirdwatchNotes: true,
|
||||
withVoice: true,
|
||||
};
|
||||
|
||||
const features = {
|
||||
rweb_tipjar_consumption_enabled: true,
|
||||
responsive_web_graphql_exclude_directive_enabled: true,
|
||||
verified_phone_label_enabled: false,
|
||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
||||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||||
communities_web_enable_tweet_community_results_fetch: true,
|
||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||
articles_preview_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,
|
||||
freedom_of_speech_not_reach_fetch_enabled: true,
|
||||
standardized_nudges_misinfo: true,
|
||||
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
||||
rweb_video_timestamps_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: true,
|
||||
responsive_web_enhance_cards_enabled: false,
|
||||
};
|
||||
const features = {
|
||||
rweb_tipjar_consumption_enabled: true,
|
||||
responsive_web_graphql_exclude_directive_enabled: true,
|
||||
verified_phone_label_enabled: false,
|
||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
||||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||||
communities_web_enable_tweet_community_results_fetch: true,
|
||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||
articles_preview_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,
|
||||
freedom_of_speech_not_reach_fetch_enabled: true,
|
||||
standardized_nudges_misinfo: true,
|
||||
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
||||
rweb_video_timestamps_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: true,
|
||||
responsive_web_enhance_cards_enabled: false,
|
||||
};
|
||||
|
||||
const params = new URLSearchParams({
|
||||
variables: JSON.stringify(variables),
|
||||
features: JSON.stringify(features),
|
||||
});
|
||||
const params = new URLSearchParams({
|
||||
variables: JSON.stringify(variables),
|
||||
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 {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
return {
|
||||
success: false,
|
||||
error: `HTTP ${response.status}: ${text.slice(0, 200)}`,
|
||||
};
|
||||
}
|
||||
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 {
|
||||
data?: {
|
||||
tweetResult?: {
|
||||
result?: {
|
||||
rest_id?: string;
|
||||
legacy?: {
|
||||
full_text?: string;
|
||||
created_at?: string;
|
||||
reply_count?: number;
|
||||
retweet_count?: number;
|
||||
favorite_count?: number;
|
||||
};
|
||||
core?: {
|
||||
user_results?: {
|
||||
result?: {
|
||||
legacy?: {
|
||||
screen_name?: string;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
threaded_conversation_with_injections_v2?: {
|
||||
instructions?: Array<{
|
||||
entries?: Array<{
|
||||
content?: {
|
||||
itemContent?: {
|
||||
tweet_results?: {
|
||||
result?: {
|
||||
rest_id?: string;
|
||||
legacy?: {
|
||||
full_text?: string;
|
||||
created_at?: string;
|
||||
reply_count?: number;
|
||||
retweet_count?: number;
|
||||
favorite_count?: number;
|
||||
};
|
||||
core?: {
|
||||
user_results?: {
|
||||
result?: {
|
||||
legacy?: {
|
||||
screen_name?: string;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
const data = (await response.json()) as {
|
||||
data?: {
|
||||
tweetResult?: {
|
||||
result?: {
|
||||
rest_id?: string;
|
||||
legacy?: {
|
||||
full_text?: string;
|
||||
created_at?: string;
|
||||
reply_count?: number;
|
||||
retweet_count?: number;
|
||||
favorite_count?: number;
|
||||
};
|
||||
core?: {
|
||||
user_results?: {
|
||||
result?: {
|
||||
legacy?: {
|
||||
screen_name?: string;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
threaded_conversation_with_injections_v2?: {
|
||||
instructions?: Array<{
|
||||
entries?: Array<{
|
||||
content?: {
|
||||
itemContent?: {
|
||||
tweet_results?: {
|
||||
result?: {
|
||||
rest_id?: string;
|
||||
legacy?: {
|
||||
full_text?: string;
|
||||
created_at?: string;
|
||||
reply_count?: number;
|
||||
retweet_count?: number;
|
||||
favorite_count?: number;
|
||||
};
|
||||
core?: {
|
||||
user_results?: {
|
||||
result?: {
|
||||
legacy?: {
|
||||
screen_name?: string;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.errors.map((e) => e.message).join(', '),
|
||||
};
|
||||
}
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.errors.map((e) => e.message).join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
// Prefer direct tweetResult if present, otherwise search the conversation thread
|
||||
const tweetResult =
|
||||
data.data?.tweetResult?.result ??
|
||||
this.findTweetInInstructions(data.data?.threaded_conversation_with_injections_v2?.instructions, tweetId);
|
||||
// Prefer direct tweetResult if present, otherwise search the conversation thread
|
||||
const tweetResult =
|
||||
data.data?.tweetResult?.result ??
|
||||
this.findTweetInInstructions(data.data?.threaded_conversation_with_injections_v2?.instructions, tweetId);
|
||||
|
||||
if (!tweetResult) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Tweet not found in response',
|
||||
};
|
||||
}
|
||||
if (!tweetResult) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Tweet not found in response',
|
||||
};
|
||||
}
|
||||
|
||||
const legacy = tweetResult.legacy;
|
||||
const userLegacy = tweetResult.core?.user_results?.result?.legacy;
|
||||
const legacy = tweetResult.legacy;
|
||||
const userLegacy = tweetResult.core?.user_results?.result?.legacy;
|
||||
|
||||
if (!legacy?.full_text || !userLegacy?.screen_name) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Incomplete tweet data',
|
||||
};
|
||||
}
|
||||
if (!legacy?.full_text || !userLegacy?.screen_name) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Incomplete tweet data',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
tweet: {
|
||||
id: tweetResult.rest_id || tweetId,
|
||||
text: legacy.full_text,
|
||||
author: {
|
||||
username: userLegacy.screen_name,
|
||||
name: userLegacy.name || userLegacy.screen_name,
|
||||
},
|
||||
createdAt: legacy.created_at,
|
||||
replyCount: legacy.reply_count,
|
||||
retweetCount: legacy.retweet_count,
|
||||
likeCount: legacy.favorite_count,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
tweet: {
|
||||
id: tweetResult.rest_id || tweetId,
|
||||
text: legacy.full_text,
|
||||
author: {
|
||||
username: userLegacy.screen_name,
|
||||
name: userLegacy.name || userLegacy.screen_name,
|
||||
},
|
||||
createdAt: legacy.created_at,
|
||||
replyCount: legacy.reply_count,
|
||||
retweetCount: legacy.retweet_count,
|
||||
likeCount: legacy.favorite_count,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: [],
|
||||
};
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
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);
|
||||
}
|
||||
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: [],
|
||||
};
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
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);
|
||||
}
|
||||
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`;
|
||||
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,
|
||||
});
|
||||
const body = JSON.stringify({
|
||||
variables,
|
||||
features,
|
||||
queryId: QUERY_IDS.CreateTweet,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body,
|
||||
});
|
||||
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)}`,
|
||||
};
|
||||
}
|
||||
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;
|
||||
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(', '),
|
||||
};
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: 'Tweet created but no ID returned',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user