388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
import { existsSync, readFileSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import type { Command } from 'commander';
|
|
import JSON5 from 'json5';
|
|
import kleur from 'kleur';
|
|
import { extractTweetId } from '../lib/extract-tweet-id.js';
|
|
import {
|
|
hyperlink,
|
|
labelPrefix,
|
|
type OutputConfig,
|
|
resolveOutputConfigFromArgv,
|
|
resolveOutputConfigFromCommander,
|
|
statusPrefix,
|
|
} from '../lib/output.js';
|
|
import type { TweetData } from '../lib/twitter-client.js';
|
|
|
|
export type BirdConfig = {
|
|
relayBaseUrl?: string;
|
|
profileName?: string;
|
|
timeoutMs?: number;
|
|
quoteDepth?: number;
|
|
};
|
|
|
|
export type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer };
|
|
|
|
export type CliContext = {
|
|
isTty: boolean;
|
|
getOutput: () => OutputConfig;
|
|
colors: {
|
|
banner: (t: string) => string;
|
|
subtitle: (t: string) => string;
|
|
section: (t: string) => string;
|
|
bullet: (t: string) => string;
|
|
command: (t: string) => string;
|
|
option: (t: string) => string;
|
|
argument: (t: string) => string;
|
|
description: (t: string) => string;
|
|
muted: (t: string) => string;
|
|
accent: (t: string) => string;
|
|
};
|
|
p: (kind: Parameters<typeof statusPrefix>[0]) => string;
|
|
l: (kind: Parameters<typeof labelPrefix>[0]) => string;
|
|
config: BirdConfig;
|
|
applyOutputFromCommand: (command: Command) => void;
|
|
resolveTimeoutFromOptions: (options: { timeout?: string | number }) => number | undefined;
|
|
resolveQuoteDepthFromOptions: (options: { quoteDepth?: string | number }) => number | undefined;
|
|
resolveProfileNameFromOptions: (opts: ProfileOptions) => string | undefined;
|
|
resolveRelayBaseUrlFromOptions: (opts: RelayOptions) => string | undefined;
|
|
loadMedia: (opts: { media: string[]; alts: string[] }) => MediaSpec[];
|
|
printTweets: (tweets: TweetData[], opts?: { json?: boolean; emptyMessage?: string; showSeparator?: boolean }) => void;
|
|
printTweetsResult: (
|
|
result: {
|
|
tweets?: TweetData[];
|
|
nextCursor?: string;
|
|
},
|
|
opts: {
|
|
json: boolean;
|
|
usePagination: boolean;
|
|
emptyMessage: string;
|
|
},
|
|
) => void;
|
|
extractTweetId: (tweetIdOrUrl: string) => string;
|
|
};
|
|
|
|
function resolveTimeoutMs(...values: Array<string | number | undefined | null>): number | undefined {
|
|
for (const value of values) {
|
|
if (value === undefined || value === null || value === '') {
|
|
continue;
|
|
}
|
|
const parsed = typeof value === 'number' ? value : Number(value);
|
|
if (Number.isFinite(parsed) && parsed > 0) {
|
|
return parsed;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function resolveQuoteDepth(...values: Array<string | number | undefined | null>): number | undefined {
|
|
for (const value of values) {
|
|
if (value === undefined || value === null || value === '') {
|
|
continue;
|
|
}
|
|
const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10);
|
|
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
return Math.floor(parsed);
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function detectMime(path: string): string | null {
|
|
const ext = path.toLowerCase();
|
|
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) {
|
|
return 'image/jpeg';
|
|
}
|
|
if (ext.endsWith('.png')) {
|
|
return 'image/png';
|
|
}
|
|
if (ext.endsWith('.webp')) {
|
|
return 'image/webp';
|
|
}
|
|
if (ext.endsWith('.gif')) {
|
|
return 'image/gif';
|
|
}
|
|
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) {
|
|
return 'video/mp4';
|
|
}
|
|
if (ext.endsWith('.mov')) {
|
|
return 'video/quicktime';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function readConfigFile(path: string, warn: (message: string) => void): Partial<BirdConfig> {
|
|
if (!existsSync(path)) {
|
|
return {};
|
|
}
|
|
try {
|
|
const raw = readFileSync(path, 'utf8');
|
|
const parsed = JSON5.parse(raw) as Partial<BirdConfig>;
|
|
return parsed ?? {};
|
|
} catch (error) {
|
|
warn(`Failed to parse config at ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function loadConfig(warn: (message: string) => void): BirdConfig {
|
|
const globalPath = join(homedir(), '.config', 'bird', 'config.json5');
|
|
const localPath = join(process.cwd(), '.birdrc.json5');
|
|
|
|
return {
|
|
...readConfigFile(globalPath, warn),
|
|
...readConfigFile(localPath, warn),
|
|
};
|
|
}
|
|
|
|
type RelayOptions = {
|
|
relayBaseUrl?: string;
|
|
};
|
|
|
|
type ProfileOptions = {
|
|
profileName?: string;
|
|
};
|
|
|
|
export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEnv = process.env): CliContext {
|
|
const isTty = process.stdout.isTTY;
|
|
let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, env, isTty);
|
|
kleur.enabled = output.color;
|
|
|
|
const wrap =
|
|
(styler: (text: string) => string): ((text: string) => string) =>
|
|
(text: string): 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)),
|
|
};
|
|
|
|
const p = (kind: Parameters<typeof statusPrefix>[0]): string => {
|
|
const prefix = statusPrefix(kind, output);
|
|
if (output.plain || !output.color) {
|
|
return prefix;
|
|
}
|
|
if (kind === 'ok') {
|
|
return kleur.green(prefix);
|
|
}
|
|
if (kind === 'warn') {
|
|
return kleur.yellow(prefix);
|
|
}
|
|
if (kind === 'err') {
|
|
return kleur.red(prefix);
|
|
}
|
|
if (kind === 'info') {
|
|
return kleur.cyan(prefix);
|
|
}
|
|
return kleur.gray(prefix);
|
|
};
|
|
|
|
const l = (kind: Parameters<typeof labelPrefix>[0]): string => {
|
|
const prefix = labelPrefix(kind, output);
|
|
if (output.plain || !output.color) {
|
|
return prefix;
|
|
}
|
|
if (kind === 'url') {
|
|
return kleur.cyan(prefix);
|
|
}
|
|
if (kind === 'date') {
|
|
return kleur.magenta(prefix);
|
|
}
|
|
if (kind === 'source') {
|
|
return kleur.gray(prefix);
|
|
}
|
|
if (kind === 'engine') {
|
|
return kleur.blue(prefix);
|
|
}
|
|
if (kind === 'credentials') {
|
|
return kleur.yellow(prefix);
|
|
}
|
|
if (kind === 'user') {
|
|
return kleur.cyan(prefix);
|
|
}
|
|
if (kind === 'userId') {
|
|
return kleur.magenta(prefix);
|
|
}
|
|
if (kind === 'email') {
|
|
return kleur.green(prefix);
|
|
}
|
|
return kleur.gray(prefix);
|
|
};
|
|
|
|
const config = loadConfig((message) => {
|
|
console.error(colors.muted(`${p('warn')}${message}`));
|
|
});
|
|
|
|
function applyOutputFromCommand(command: Command): void {
|
|
const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean };
|
|
output = resolveOutputConfigFromCommander(opts, env, isTty);
|
|
kleur.enabled = output.color;
|
|
}
|
|
|
|
function resolveTimeoutFromOptions(options: { timeout?: string | number }): number | undefined {
|
|
return resolveTimeoutMs(options.timeout, config.timeoutMs, env.BIRD_TIMEOUT_MS);
|
|
}
|
|
|
|
function resolveQuoteDepthFromOptions(options: { quoteDepth?: string | number }): number | undefined {
|
|
return resolveQuoteDepth(options.quoteDepth, config.quoteDepth, env.BIRD_QUOTE_DEPTH);
|
|
}
|
|
|
|
function resolveRelayBaseUrlFromOptions(opts: RelayOptions): string | undefined {
|
|
return opts.relayBaseUrl || config.relayBaseUrl || env.TWITTER_RELAY_BASE_URL;
|
|
}
|
|
|
|
function resolveProfileNameFromOptions(opts: ProfileOptions): string | undefined {
|
|
return opts.profileName || env.BIRD_PROFILE_NAME || config.profileName;
|
|
}
|
|
|
|
function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] {
|
|
if (opts.media.length === 0) {
|
|
return [];
|
|
}
|
|
const specs: MediaSpec[] = [];
|
|
for (const [index, path] of opts.media.entries()) {
|
|
const mime = detectMime(path);
|
|
if (!mime) {
|
|
throw new Error(`Unsupported media type for ${path}. Supported: jpg, jpeg, png, webp, gif, mp4, mov`);
|
|
}
|
|
const buffer = readFileSync(path);
|
|
specs.push({ path, mime, buffer, alt: opts.alts[index] });
|
|
}
|
|
|
|
const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length;
|
|
if (videoCount > 1) {
|
|
throw new Error('Only one video can be attached');
|
|
}
|
|
if (videoCount === 1 && specs.length > 1) {
|
|
throw new Error('Video cannot be combined with other media');
|
|
}
|
|
if (specs.length > 4) {
|
|
throw new Error('Maximum 4 media attachments');
|
|
}
|
|
return specs;
|
|
}
|
|
|
|
function printTweets(
|
|
tweets: TweetData[],
|
|
opts: { json?: boolean; emptyMessage?: string; showSeparator?: boolean } = {},
|
|
) {
|
|
if (opts.json) {
|
|
console.log(JSON.stringify(tweets, null, 2));
|
|
return;
|
|
}
|
|
if (tweets.length === 0) {
|
|
console.log(opts.emptyMessage ?? 'No tweets found.');
|
|
return;
|
|
}
|
|
const useEmoji = output.emoji && !output.plain;
|
|
const articleLabel = useEmoji ? '📰' : 'Article:';
|
|
const mediaLabel = (type: 'photo' | 'video' | 'animated_gif'): string => {
|
|
if (useEmoji) {
|
|
return type === 'video' ? '🎬' : type === 'animated_gif' ? '🔄' : '🖼️';
|
|
}
|
|
return type === 'video' ? 'VIDEO:' : type === 'animated_gif' ? 'GIF:' : 'PHOTO:';
|
|
};
|
|
const quotePrefix = useEmoji ? { top: '┌─', mid: '│ ', bot: '└─' } : { top: '> ', mid: '> ', bot: '> ' };
|
|
|
|
for (const tweet of tweets) {
|
|
console.log(`\n@${tweet.author.username} (${tweet.author.name}):`);
|
|
|
|
// Display tweet text, with article indicator if present
|
|
if (tweet.article) {
|
|
// Full body mode: text starts with article title (from extractArticleText)
|
|
// Preview mode: text is short tweet intro that doesn't start with title
|
|
const hasFullBody = tweet.text.startsWith(tweet.article.title);
|
|
if (hasFullBody) {
|
|
console.log(`${articleLabel} ${tweet.text}`);
|
|
} else {
|
|
console.log(`${articleLabel} ${tweet.article.title}`);
|
|
if (tweet.article.previewText) {
|
|
console.log(` ${tweet.article.previewText}`);
|
|
}
|
|
}
|
|
} else {
|
|
console.log(tweet.text);
|
|
}
|
|
|
|
// Display media attachments
|
|
if (tweet.media && tweet.media.length > 0) {
|
|
for (const m of tweet.media) {
|
|
console.log(`${mediaLabel(m.type)} ${m.url}`);
|
|
}
|
|
}
|
|
|
|
// Display quoted tweet
|
|
if (tweet.quotedTweet) {
|
|
console.log(`${quotePrefix.top} QT @${tweet.quotedTweet.author.username}:`);
|
|
const qtText = tweet.quotedTweet.article
|
|
? `${articleLabel} ${tweet.quotedTweet.article.title}`
|
|
: tweet.quotedTweet.text;
|
|
// Indent and truncate quoted tweet text
|
|
const maxLen = 280;
|
|
const truncated = qtText.length > maxLen ? `${qtText.slice(0, maxLen)}...` : qtText;
|
|
for (const line of truncated.split('\n').slice(0, 4)) {
|
|
console.log(`${quotePrefix.mid}${line}`);
|
|
}
|
|
// Display quoted tweet media
|
|
if (tweet.quotedTweet.media && tweet.quotedTweet.media.length > 0) {
|
|
for (const m of tweet.quotedTweet.media) {
|
|
console.log(`${quotePrefix.mid}${mediaLabel(m.type)} ${m.url}`);
|
|
}
|
|
}
|
|
console.log(
|
|
`${quotePrefix.bot} https://x.com/${tweet.quotedTweet.author.username}/status/${tweet.quotedTweet.id}`,
|
|
);
|
|
}
|
|
|
|
if (tweet.createdAt) {
|
|
console.log(`${l('date')}${tweet.createdAt}`);
|
|
}
|
|
const tweetUrl = `https://x.com/${tweet.author.username}/status/${tweet.id}`;
|
|
console.log(`${l('url')}${hyperlink(tweetUrl, tweetUrl, output)}`);
|
|
if (opts.showSeparator ?? true) {
|
|
console.log('─'.repeat(50));
|
|
}
|
|
}
|
|
}
|
|
|
|
function printTweetsResult(
|
|
result: { tweets?: TweetData[]; nextCursor?: string },
|
|
opts: { json: boolean; usePagination: boolean; emptyMessage: string },
|
|
) {
|
|
const tweets = result.tweets ?? [];
|
|
if (opts.json && opts.usePagination) {
|
|
console.log(JSON.stringify({ tweets, nextCursor: result.nextCursor ?? null }, null, 2));
|
|
return;
|
|
}
|
|
printTweets(tweets, { json: opts.json, emptyMessage: opts.emptyMessage });
|
|
}
|
|
|
|
return {
|
|
isTty,
|
|
getOutput: () => output,
|
|
colors,
|
|
p,
|
|
l,
|
|
config,
|
|
applyOutputFromCommand,
|
|
resolveTimeoutFromOptions,
|
|
resolveQuoteDepthFromOptions,
|
|
resolveProfileNameFromOptions,
|
|
resolveRelayBaseUrlFromOptions,
|
|
loadMedia,
|
|
printTweets,
|
|
printTweetsResult,
|
|
extractTweetId,
|
|
};
|
|
}
|