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
+183 -15
View File
@@ -10,6 +10,11 @@
*/
import { Command } from 'commander';
import JSON5 from 'json5';
import kleur from 'kleur';
import { homedir } from 'node:os';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { resolveCredentials } from './lib/cookies.js';
import { extractTweetId } from './lib/extract-tweet-id.js';
import { TwitterClient, type TweetData } from './lib/twitter-client.js';
@@ -17,19 +22,87 @@ import { SweetisticsClient } from './lib/sweetistics-client.js';
const program = new Command();
const isTty = process.stdout.isTTY;
const wrap = (styler: (text: string) => string) => (text: string) => (isTty ? styler(text) : text);
const colors = {
banner: wrap((t) => kleur.bold().blue(t)),
subtitle: wrap((t) => kleur.dim(t)),
section: wrap((t) => kleur.bold().white(t)),
bullet: wrap((t) => kleur.blue(t)),
command: wrap((t) => kleur.bold().cyan(t)),
option: wrap((t) => kleur.cyan(t)),
argument: wrap((t) => kleur.magenta(t)),
description: wrap((t) => kleur.white(t)),
muted: wrap((t) => kleur.gray(t)),
accent: wrap((t) => kleur.green(t)),
};
type BirdConfig = {
engine?: EngineMode;
chromeProfile?: string;
firefoxProfile?: string;
sweetisticsApiKey?: string;
sweetisticsBaseUrl?: string;
};
function readConfigFile(path: string): Partial<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');
const formatExample = (command: string, description: string) =>
`${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`;
program.addHelpText(
'afterAll',
() => `\n${colors.section('Examples')}\n${[
formatExample('bird whoami', 'Show the logged-in account via GraphQL cookies'),
formatExample('bird --firefox-profile default-release whoami', 'Use Firefox profile cookies'),
formatExample('bird tweet "hello from bird"', 'Send a tweet'),
formatExample('bird replies https://x.com/user/status/1234567890123456789', 'Check replies to a tweet'),
].join('\n\n')}`,
);
// Global options for authentication
program
.option('--auth-token <token>', 'Twitter auth_token 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-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(
'--engine <engine>',
'Engine: graphql | sweetistics | auto',
process.env.BIRD_ENGINE || 'auto',
process.env.BIRD_ENGINE || config.engine || 'auto',
);
type EngineMode = 'graphql' | 'sweetistics' | 'auto';
@@ -92,7 +165,10 @@ program
.argument('<text>', 'Tweet text')
.action(async (text: string) => {
const opts = program.opts();
const sweetistics = resolveSweetisticsConfig(opts);
const sweetistics = resolveSweetisticsConfig({
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
});
const engine = resolveEngineMode(opts.engine);
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
@@ -125,7 +201,8 @@ program
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
});
for (const warning of warnings) {
@@ -161,7 +238,10 @@ program
.argument('<text>', 'Reply text')
.action(async (tweetIdOrUrl: string, text: string) => {
const opts = program.opts();
const sweetistics = resolveSweetisticsConfig(opts);
const sweetistics = resolveSweetisticsConfig({
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
});
const engine = resolveEngineMode(opts.engine);
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
const tweetId = extractTweetId(tweetIdOrUrl);
@@ -195,7 +275,8 @@ program
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
});
for (const warning of warnings) {
@@ -233,7 +314,10 @@ program
.option('--json', 'Output as JSON')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts();
const sweetistics = resolveSweetisticsConfig(opts);
const sweetistics = resolveSweetisticsConfig({
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
});
const engine = resolveEngineMode(opts.engine);
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
@@ -267,7 +351,8 @@ program
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
});
for (const warning of warnings) {
@@ -309,7 +394,10 @@ program
.option('--json', 'Output as JSON')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts();
const sweetistics = resolveSweetisticsConfig(opts);
const sweetistics = resolveSweetisticsConfig({
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
});
const engine = resolveEngineMode(opts.engine);
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
const tweetId = extractTweetId(tweetIdOrUrl);
@@ -331,7 +419,8 @@ program
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
});
for (const warning of warnings) {
@@ -362,7 +451,10 @@ program
.option('--json', 'Output as JSON')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts();
const sweetistics = resolveSweetisticsConfig(opts);
const sweetistics = resolveSweetisticsConfig({
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
});
const engine = resolveEngineMode(opts.engine);
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
const tweetId = extractTweetId(tweetIdOrUrl);
@@ -384,7 +476,8 @@ program
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
});
for (const warning of warnings) {
@@ -417,7 +510,10 @@ program
.action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => {
const opts = program.opts();
const count = parseInt(cmdOpts.count || '10', 10);
const sweetistics = resolveSweetisticsConfig(opts);
const sweetistics = resolveSweetisticsConfig({
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
});
const engine = resolveEngineMode(opts.engine);
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
@@ -439,7 +535,8 @@ program
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
});
for (const warning of warnings) {
@@ -516,6 +613,71 @@ program
}
});
// Whoami command - show the logged-in account
program
.command('whoami')
.description('Show which Twitter account the current credentials belong to')
.action(async () => {
const opts = program.opts();
const sweetistics = resolveSweetisticsConfig(opts);
const engine = resolveEngineMode(opts.engine);
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
if (useSweetistics) {
if (!sweetistics.apiKey) {
console.error('❌ Sweetistics engine selected but no API key provided.');
process.exit(1);
}
const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey });
const result = await client.getCurrentUser();
if (result.success && result.user) {
const handle = result.user.username ? `@${result.user.username}` : '(no handle)';
const name = result.user.name || handle;
console.log(`🙋 Logged in via Sweetistics as ${handle} (${name})`);
console.log(`🪪 User ID: ${result.user.id}`);
if (result.user.email) {
console.log(`📧 ${result.user.email}`);
}
return;
}
console.error(`❌ Failed to determine Sweetistics user: ${result.error ?? 'Unknown error'}`);
process.exit(1);
}
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile,
});
for (const warning of warnings) {
console.error(`⚠️ ${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error('❌ Missing required credentials');
process.exit(1);
}
if (cookies.source) {
console.error(`📍 Using credentials from: ${cookies.source}`);
}
const client = new TwitterClient({ cookies });
const result = await client.getCurrentUser();
if (result.success && result.user) {
console.log(`🙋 Logged in as @${result.user.username} (${result.user.name})`);
console.log(`🪪 User ID: ${result.user.id}`);
} else {
console.error(`❌ Failed to determine current user: ${result.error ?? 'Unknown error'}`);
process.exit(1);
}
});
// Check command - verify credentials
program
.command('check')
@@ -565,4 +727,10 @@ program
}
});
// Show help when invoked without any subcommand
if (process.argv.length <= 2) {
program.outputHelp();
process.exit(0);
}
program.parse();
+107 -3
View File
@@ -5,7 +5,7 @@
import { execSync } from 'node:child_process';
import { createDecipheriv, pbkdf2Sync } from 'node:crypto';
import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -34,6 +34,26 @@ function getChromeCookiesPath(profile?: string): string {
return join(home, 'Library', 'Application Support', 'Google', 'Chrome', profileDir, 'Cookies');
}
function getFirefoxCookiesPath(profile?: string): string | null {
const home = process.env.HOME || '';
const profilesRoot = join(home, 'Library', 'Application Support', 'Firefox', 'Profiles');
if (!existsSync(profilesRoot)) return null;
if (profile) {
const candidate = join(profilesRoot, profile, 'cookies.sqlite');
return existsSync(candidate) ? candidate : null;
}
// Pick the default-release profile if present, otherwise first profile dir containing cookies.sqlite
const entries = readdirSync(profilesRoot, { withFileTypes: true });
const defaultRelease = entries.find((entry) => entry.isDirectory() && entry.name.endsWith('.default-release'));
const targetDir = defaultRelease?.name ?? entries.find((e) => e.isDirectory())?.name;
if (!targetDir) return null;
const candidate = join(profilesRoot, targetDir, 'cookies.sqlite');
return existsSync(candidate) ? candidate : null;
}
/**
* Decrypt Chrome cookie value using macOS keychain
* Chrome encrypts cookies with a key stored in the keychain
@@ -178,6 +198,74 @@ export async function extractCookiesFromChrome(profile?: string): Promise<Cookie
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
* Priority: CLI args > environment variables > Chrome cookies
@@ -186,6 +274,7 @@ export async function resolveCredentials(options: {
authToken?: string;
ct0?: string;
chromeProfile?: string;
firefoxProfile?: string;
}): Promise<CookieExtractionResult> {
const warnings: string[] = [];
const cookies: TwitterCookies = {
@@ -245,12 +334,27 @@ export async function resolveCredentials(options: {
}
}
// 4. Firefox cookies (fallback if still missing)
if (!cookies.authToken || !cookies.ct0) {
const firefoxResult = await extractCookiesFromFirefox(options.firefoxProfile);
warnings.push(...firefoxResult.warnings);
if (!cookies.authToken && firefoxResult.cookies.authToken) {
cookies.authToken = firefoxResult.cookies.authToken;
cookies.source = firefoxResult.cookies.source;
}
if (!cookies.ct0 && firefoxResult.cookies.ct0) {
cookies.ct0 = firefoxResult.cookies.ct0;
if (!cookies.source) cookies.source = firefoxResult.cookies.source;
}
}
// Validation
if (!cookies.authToken) {
warnings.push('Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome');
warnings.push('Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome/Firefox');
}
if (!cookies.ct0) {
warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome');
warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome/Firefox');
}
return { cookies, warnings };
+86
View File
@@ -39,6 +39,18 @@ export interface SweetisticsReadResult {
error?: string;
}
export interface SweetisticsUserResult {
success: boolean;
user?: {
id: string;
username?: string | null;
name?: string | null;
email?: string | null;
profileImageUrl?: string | null;
};
error?: string;
}
type TrpcResponseEnvelope = { result?: { data?: unknown; error?: unknown } };
function normalizeBaseUrl(value: string): string {
@@ -244,6 +256,80 @@ export class SweetisticsClient {
return { success: true, tweets };
}
async getCurrentUser(): Promise<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(
tweetId: string,
options: { excludeRoot: boolean },
+134
View File
@@ -81,6 +81,16 @@ export interface SearchResult {
error?: string;
}
export interface CurrentUserResult {
success: boolean;
user?: {
id: string;
username: string;
name: string;
};
error?: string;
}
export interface TwitterClientOptions {
cookies: TwitterCookies;
userAgent?: string;
@@ -683,6 +693,130 @@ export class TwitterClient {
}
}
/**
* Fetch the account associated with the current cookies
*/
async getCurrentUser(): Promise<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
*/