176 lines
6.5 KiB
TypeScript
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;
|
|
}
|