feat: add config defaults, Firefox cookies, and Sweetistics whoami

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