import { runtimeQueryIds } from './runtime-query-ids.js'; import { type OperationName, QUERY_IDS, TARGET_QUERY_ID_OPERATIONS } from './twitter-client-constants.js'; import type { CurrentUserResult, TwitterClientOptions } from './twitter-client-types.js'; import { normalizeQuoteDepth } from './twitter-client-utils.js'; const TRAILING_SLASHES = /\/+$/; // biome-ignore lint/suspicious/noExplicitAny: TS mixin base constructor requirement. export type Constructor = new (...args: any[]) => T; // biome-ignore lint/suspicious/noExplicitAny: TS mixin base constructor requirement. export type AbstractConstructor = abstract new (...args: any[]) => T; export type Mixin, TAdded> = abstract new ( ...args: ConstructorParameters ) => TwitterClientBase & TAdded; export abstract class TwitterClientBase { protected relayBaseUrl: 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.timeoutMs = options.timeoutMs; this.quoteDepth = normalizeQuoteDepth(options.quoteDepth); } protected abstract getCurrentUser(): Promise; protected async sleep(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } protected async getQueryId(operationName: OperationName): Promise { const cached = await runtimeQueryIds.getQueryId(operationName); return cached ?? QUERY_IDS[operationName]; } protected async refreshQueryIds(): Promise { if (process.env.NODE_ENV === 'test') { return; } try { await runtimeQueryIds.refresh(TARGET_QUERY_ID_OPERATIONS, { force: true }); } catch { // ignore refresh failures; callers will fall back to baked-in IDs } } protected async getTweetDetailQueryIds(): Promise { const primary = await this.getQueryId('TweetDetail'); return Array.from(new Set([primary, '97JF30KziU00483E_8elBA', 'aFvUsJm2c-oDkJV75blV6g'])); } protected async getSearchTimelineQueryIds(): Promise { const primary = await this.getQueryId('SearchTimeline'); return Array.from(new Set([primary, 'M1jEez78PEfVfbQLvlWMvQ', '5h0kNbk3ii97rmfY6CdgAA', 'Tp1sewRU1AsZpBWhqCZicQ'])); } protected async fetchWithTimeout(url: string, init: RequestInit): Promise { const relayUrl = this.toRelayUrl(url); if (!this.timeoutMs || this.timeoutMs <= 0) { return fetch(relayUrl, init); } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); try { return await fetch(relayUrl, { ...init, signal: controller.signal }); } finally { clearTimeout(timeoutId); } } protected getHeaders(): Record { return {}; } private normalizeRelayBaseUrl(value: string | undefined): string { const trimmed = value?.trim(); if (!trimmed) { throw new Error('TWITTER_RELAY_BASE_URL is required'); } return trimmed.replace(TRAILING_SLASHES, ''); } private toRelayUrl(url: string): string { const parsed = new URL(url); if (!['x.com', 'twitter.com', 'api.twitter.com', 'upload.twitter.com'].includes(parsed.hostname)) { throw new Error(`Unsupported Twitter API host: ${parsed.hostname}`); } return `${this.relayBaseUrl}${parsed.pathname}${parsed.search}`; } protected getJsonHeaders(): Record { return { 'content-type': 'application/json' }; } protected getUploadHeaders(): Record { return {}; } protected async ensureClientUserId(): Promise { if (process.env.NODE_ENV === 'test') { return; } if (this.clientUserId) { return; } const result = await this.getCurrentUser(); if (result.success && result.user?.id) { this.clientUserId = result.user.id; } } }