feat: support relay profile selection
CI / test (push) Successful in 10m2s

This commit is contained in:
2026-06-24 18:30:24 +09:00
parent 7a036e244d
commit 3f370cc4b6
24 changed files with 160 additions and 25 deletions
+3 -2
View File
@@ -105,14 +105,15 @@ export function createProgram(ctx: CliContext): Command {
`\n\n${ctx.colors.section('Config')}\n${ctx.colors.muted(
` Reads ${ctx.colors.argument('~/.config/bird/config.json5')} and ${ctx.colors.argument('./.birdrc.json5')} (JSON5)`,
)}\n${ctx.colors.muted(
` Supports: relayBaseUrl, timeoutMs, quoteDepth`,
` Supports: relayBaseUrl, profileName, timeoutMs, quoteDepth`,
)}\n\n${ctx.colors.section('Env')}\n${ctx.colors.muted(
` ${ctx.colors.option('TWITTER_RELAY_BASE_URL')}, ${ctx.colors.option('NO_COLOR')}, ${ctx.colors.option('BIRD_TIMEOUT_MS')}, ${ctx.colors.option('BIRD_QUOTE_DEPTH')}`,
` ${ctx.colors.option('TWITTER_RELAY_BASE_URL')}, ${ctx.colors.option('BIRD_PROFILE_NAME')}, ${ctx.colors.option('NO_COLOR')}, ${ctx.colors.option('BIRD_TIMEOUT_MS')}, ${ctx.colors.option('BIRD_QUOTE_DEPTH')}`,
)}`,
);
program
.option('--relay-base-url <url>', 'twitter safe relay base URL', ctx.config.relayBaseUrl)
.option('--profile-name <name>', 'twitter safe relay profile name')
.option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect)
.option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect)
.option('--timeout <ms>', 'Request timeout in milliseconds')
+11
View File
@@ -17,6 +17,7 @@ import type { TweetData } from '../lib/twitter-client.js';
export type BirdConfig = {
relayBaseUrl?: string;
profileName?: string;
timeoutMs?: number;
quoteDepth?: number;
};
@@ -44,6 +45,7 @@ export type CliContext = {
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;
@@ -138,6 +140,10 @@ 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);
@@ -235,6 +241,10 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn
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 [];
@@ -367,6 +377,7 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn
applyOutputFromCommand,
resolveTimeoutFromOptions,
resolveQuoteDepthFromOptions,
resolveProfileNameFromOptions,
resolveRelayBaseUrlFromOptions,
loadMedia,
printTweets,
+2 -1
View File
@@ -35,6 +35,7 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
}
const maxPages = maxPagesParsed.value;
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const usePagination = Boolean(cmdOpts.all || cmdOpts.cursor);
if (maxPages !== undefined && !usePagination) {
@@ -46,7 +47,7 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
process.exit(1);
}
const client = new TwitterClient({ relayBaseUrl, timeoutMs });
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs });
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
if (cmdOpts.folderId && !folderId) {
console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
+2 -1
View File
@@ -10,6 +10,7 @@ export function registerCheckCommand(program: Command, ctx: CliContext): void {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
console.log(`${ctx.p('info')}Relay check`);
console.log('-'.repeat(40));
@@ -21,7 +22,7 @@ export function registerCheckCommand(program: Command, ctx: CliContext): void {
console.log(`${ctx.p('ok')}relay: ${relayBaseUrl}`);
const client = new TwitterClient({ relayBaseUrl, timeoutMs });
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs });
const result = await client.getCurrentUser();
if (result.success && result.user) {
+2 -1
View File
@@ -15,13 +15,14 @@ export function registerHomeCommand(program: Command, ctx: CliContext): void {
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
if (!Number.isFinite(count) || count <= 0) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
const client = new TwitterClient({ relayBaseUrl, timeoutMs });
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs });
const includeRaw = cmdOpts.jsonFull ?? false;
const result = cmdOpts.following
+4 -2
View File
@@ -43,8 +43,9 @@ export function registerListsCommand(program: Command, ctx: CliContext): void {
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '100', 10);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs });
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs });
const result = cmdOpts.memberOf ? await client.getListMemberships(count) : await client.getOwnedLists(count);
@@ -109,8 +110,9 @@ export function registerListsCommand(program: Command, ctx: CliContext): void {
process.exit(1);
}
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const timelineOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages: pagination.maxPages, cursor: pagination.cursor };
+2 -1
View File
@@ -102,6 +102,7 @@ export function registerNewsCommand(program: Command, ctx: CliContext): void {
const count = Number.parseInt(cmdOpts.count || '10', 10);
const tweetsPerItem = Number.parseInt(cmdOpts.tweetsPerItem || '5', 10);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
if (Number.isNaN(count) || count < 1) {
console.error(`${ctx.p('err')}--count must be a positive number`);
@@ -134,7 +135,7 @@ export function registerNewsCommand(program: Command, ctx: CliContext): void {
// If no specific tabs selected, use defaults (all tabs except trending)
const tabsToFetch = tabs.length > 0 ? tabs : undefined;
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const withTweets = cmdOpts.withTweets ?? false;
const aiOnly = cmdOpts.aiOnly ?? false;
+4 -2
View File
@@ -41,7 +41,8 @@ export function registerPostCommands(program: Command, ctx: CliContext): void {
process.exit(1);
}
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const profileName = ctx.resolveProfileNameFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
const mediaIds = await uploadMediaOrExit(client, media, ctx);
const result = await client.tweet(text, mediaIds);
@@ -72,9 +73,10 @@ export function registerPostCommands(program: Command, ctx: CliContext): void {
}
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
console.error(`${ctx.p('info')}Replying to tweet: ${tweetId}`);
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
const mediaIds = await uploadMediaOrExit(client, media, ctx);
const result = await client.reply(text, tweetId, mediaIds);
+6 -3
View File
@@ -18,8 +18,9 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const result = await client.getTweet(tweetId, { includeRaw });
@@ -69,8 +70,9 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
process.exit(1);
}
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const result = pagination.usePagination
@@ -136,8 +138,9 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
process.exit(1);
}
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const result = pagination.usePagination
+4 -2
View File
@@ -32,6 +32,7 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
const count = Number.parseInt(cmdOpts.count || '10', 10);
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const usePagination = cmdOpts.all || cmdOpts.cursor;
if (maxPages !== undefined && !usePagination) {
@@ -47,7 +48,7 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
process.exit(1);
}
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const searchOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages, cursor: cmdOpts.cursor };
@@ -90,8 +91,9 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
let query: string | null = fromUserOpt.query;
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
if (!query) {
const who = await client.getCurrentUser();
+2 -1
View File
@@ -11,8 +11,9 @@ export function registerUnbookmarkCommand(program: Command, ctx: CliContext): vo
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs });
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs });
let failures = 0;
for (const input of tweetIdOrUrls) {
+2 -1
View File
@@ -87,8 +87,9 @@ export function registerUserTweetsCommand(program: Command, ctx: CliContext): vo
process.exit(2);
}
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
// Look up user ID from username
console.error(`${ctx.p('info')}Looking up @${username}...`);
+8 -4
View File
@@ -37,8 +37,9 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
process.exit(1);
}
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs });
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs });
let userId = cmdOpts.user;
if (!userId) {
@@ -187,8 +188,9 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
process.exit(1);
}
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs });
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs });
let userId = cmdOpts.user;
if (!userId) {
@@ -328,6 +330,7 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
const count = Number.parseInt(cmdOpts.count || '20', 10);
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const usePagination = cmdOpts.all || cmdOpts.cursor;
if (maxPages !== undefined && !usePagination) {
@@ -343,7 +346,7 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
process.exit(1);
}
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const timelineOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages, cursor: cmdOpts.cursor };
@@ -373,8 +376,9 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth });
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
const result = await client.getCurrentUser();
if (result.success && result.user) {
+10 -3
View File
@@ -15,12 +15,14 @@ export type Mixin<TBase extends AbstractConstructor<TwitterClientBase>, TAdded>
export abstract class TwitterClientBase {
protected relayBaseUrl: string;
protected profileName?: string;
protected timeoutMs?: number;
protected quoteDepth: number;
protected clientUserId?: string;
constructor(options: TwitterClientOptions) {
this.relayBaseUrl = this.normalizeRelayBaseUrl(options.relayBaseUrl ?? process.env.TWITTER_RELAY_BASE_URL);
this.profileName = this.normalizeProfileName(options.profileName);
this.timeoutMs = options.timeoutMs;
this.quoteDepth = normalizeQuoteDepth(options.quoteDepth);
}
@@ -73,7 +75,7 @@ export abstract class TwitterClientBase {
}
protected getHeaders(): Record<string, string> {
return {};
return this.profileName ? { 'x-profile-name': this.profileName } : {};
}
private normalizeRelayBaseUrl(value: string | undefined): string {
@@ -84,6 +86,11 @@ export abstract class TwitterClientBase {
return trimmed.replace(TRAILING_SLASHES, '');
}
private normalizeProfileName(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed || undefined;
}
private toRelayUrl(url: string): string {
const parsed = new URL(url);
if (!['x.com', 'twitter.com', 'api.twitter.com', 'upload.twitter.com'].includes(parsed.hostname)) {
@@ -93,11 +100,11 @@ export abstract class TwitterClientBase {
}
protected getJsonHeaders(): Record<string, string> {
return { 'content-type': 'application/json' };
return { 'content-type': 'application/json', ...this.getHeaders() };
}
protected getUploadHeaders(): Record<string, string> {
return {};
return this.getHeaders();
}
protected async ensureClientUserId(): Promise<void> {
+1
View File
@@ -326,6 +326,7 @@ export interface FollowingResult {
export interface TwitterClientOptions {
relayBaseUrl?: string;
profileName?: string;
timeoutMs?: number;
// Max depth for quoted tweets (0 disables). Defaults to 1.
quoteDepth?: number;