Files
bird/src/lib/twitter-client-media.ts
T

176 lines
6.5 KiB
TypeScript

import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_MEDIA_METADATA_URL, TWITTER_UPLOAD_URL } from './twitter-client-constants.js';
import type { UploadMediaResult } from './twitter-client-types.js';
export interface TwitterClientMediaMethods {
uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise<UploadMediaResult>;
}
export function withMedia<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientMediaMethods> {
abstract class TwitterClientMedia extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
private mediaCategoryForMime(mimeType: string): string | null {
if (mimeType.startsWith('image/')) {
if (mimeType === 'image/gif') {
return 'tweet_gif';
}
return 'tweet_image';
}
if (mimeType.startsWith('video/')) {
return 'tweet_video';
}
return null;
}
async uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise<UploadMediaResult> {
const category = this.mediaCategoryForMime(input.mimeType);
if (!category) {
return { success: false, error: `Unsupported media type: ${input.mimeType}` };
}
try {
const initParams = new URLSearchParams({
command: 'INIT',
total_bytes: String(input.data.byteLength),
media_type: input.mimeType,
media_category: category,
});
const initResp = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, {
method: 'POST',
headers: this.getUploadHeaders(),
body: initParams,
});
if (!initResp.ok) {
const text = await initResp.text();
return { success: false, error: `HTTP ${initResp.status}: ${text.slice(0, 200)}` };
}
const initBody = (await initResp.json()) as { media_id_string?: string; media_id?: string | number };
const mediaId =
typeof initBody.media_id_string === 'string'
? initBody.media_id_string
: initBody.media_id !== undefined
? String(initBody.media_id)
: undefined;
if (!mediaId) {
return { success: false, error: 'Media upload INIT did not return media_id' };
}
const chunkSize = 5 * 1024 * 1024;
let segmentIndex = 0;
for (let offset = 0; offset < input.data.byteLength; offset += chunkSize) {
const chunk = input.data.slice(offset, Math.min(input.data.byteLength, offset + chunkSize));
const form = new FormData();
form.set('command', 'APPEND');
form.set('media_id', mediaId);
form.set('segment_index', String(segmentIndex));
form.set('media', new Blob([chunk], { type: input.mimeType }), 'media');
const appendResp = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, {
method: 'POST',
headers: this.getUploadHeaders(),
body: form,
});
if (!appendResp.ok) {
const text = await appendResp.text();
return { success: false, error: `HTTP ${appendResp.status}: ${text.slice(0, 200)}` };
}
segmentIndex += 1;
}
const finalizeParams = new URLSearchParams({ command: 'FINALIZE', media_id: mediaId });
const finalizeResp = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, {
method: 'POST',
headers: this.getUploadHeaders(),
body: finalizeParams,
});
if (!finalizeResp.ok) {
const text = await finalizeResp.text();
return { success: false, error: `HTTP ${finalizeResp.status}: ${text.slice(0, 200)}` };
}
const finalizeBody = (await finalizeResp.json()) as {
processing_info?: {
state?: string;
check_after_secs?: number;
error?: { message?: string; name?: string };
};
};
const info = finalizeBody.processing_info;
if (info?.state && info.state !== 'succeeded') {
let attempts = 0;
while (attempts < 20) {
if (info.state === 'failed') {
const msg = info.error?.message || info.error?.name || 'Media processing failed';
return { success: false, error: msg };
}
const delaySecs = Number.isFinite(info.check_after_secs) ? Math.max(1, info.check_after_secs as number) : 2;
await this.sleep(delaySecs * 1000);
const statusUrl = `${TWITTER_UPLOAD_URL}?${new URLSearchParams({
command: 'STATUS',
media_id: mediaId,
}).toString()}`;
const statusResp = await this.fetchWithTimeout(statusUrl, {
method: 'GET',
headers: this.getUploadHeaders(),
});
if (!statusResp.ok) {
const text = await statusResp.text();
return { success: false, error: `HTTP ${statusResp.status}: ${text.slice(0, 200)}` };
}
const statusBody = (await statusResp.json()) as {
processing_info?: {
state?: string;
check_after_secs?: number;
error?: { message?: string; name?: string };
};
};
if (!statusBody.processing_info) {
break;
}
info.state = statusBody.processing_info.state;
info.check_after_secs = statusBody.processing_info.check_after_secs;
info.error = statusBody.processing_info.error;
if (info.state === 'succeeded') {
break;
}
attempts += 1;
}
}
if (input.alt && input.mimeType.startsWith('image/')) {
const metaResp = await this.fetchWithTimeout(TWITTER_MEDIA_METADATA_URL, {
method: 'POST',
headers: this.getJsonHeaders(),
body: JSON.stringify({ media_id: mediaId, alt_text: { text: input.alt } }),
});
if (!metaResp.ok) {
const text = await metaResp.text();
return { success: false, error: `HTTP ${metaResp.status}: ${text.slice(0, 200)}` };
}
}
return { success: true, mediaId };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
}
return TwitterClientMedia;
}