116 lines
4.0 KiB
TypeScript
116 lines
4.0 KiB
TypeScript
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<T = object> = new (...args: any[]) => T;
|
|
// biome-ignore lint/suspicious/noExplicitAny: TS mixin base constructor requirement.
|
|
export type AbstractConstructor<T = object> = abstract new (...args: any[]) => T;
|
|
export type Mixin<TBase extends AbstractConstructor<TwitterClientBase>, TAdded> = abstract new (
|
|
...args: ConstructorParameters<TBase>
|
|
) => 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<CurrentUserResult>;
|
|
|
|
protected async sleep(ms: number): Promise<void> {
|
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
protected async getQueryId(operationName: OperationName): Promise<string> {
|
|
const cached = await runtimeQueryIds.getQueryId(operationName);
|
|
return cached ?? QUERY_IDS[operationName];
|
|
}
|
|
|
|
protected async refreshQueryIds(): Promise<void> {
|
|
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<string[]> {
|
|
const primary = await this.getQueryId('TweetDetail');
|
|
return Array.from(new Set([primary, '97JF30KziU00483E_8elBA', 'aFvUsJm2c-oDkJV75blV6g']));
|
|
}
|
|
|
|
protected async getSearchTimelineQueryIds(): Promise<string[]> {
|
|
const primary = await this.getQueryId('SearchTimeline');
|
|
return Array.from(new Set([primary, 'M1jEez78PEfVfbQLvlWMvQ', '5h0kNbk3ii97rmfY6CdgAA', 'Tp1sewRU1AsZpBWhqCZicQ']));
|
|
}
|
|
|
|
protected async fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
|
|
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<string, string> {
|
|
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<string, string> {
|
|
return { 'content-type': 'application/json' };
|
|
}
|
|
|
|
protected getUploadHeaders(): Record<string, string> {
|
|
return {};
|
|
}
|
|
|
|
protected async ensureClientUserId(): Promise<void> {
|
|
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;
|
|
}
|
|
}
|
|
}
|