feat(graphql): upload media
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
- Output controls: `--plain`, `--no-emoji`, `--no-color` (respects `NO_COLOR`).
|
||||
- `help` command: `bird help <command>`.
|
||||
- Runtime GraphQL query ID refresh: `bird query-ids --fresh` (cached on disk; auto-retry on 404; override cache via `BIRD_QUERY_IDS_CACHE`).
|
||||
- GraphQL media uploads for images/GIFs via `--media` (video still requires Sweetistics).
|
||||
|
||||
### Fixed
|
||||
- CLI `--version`: read version from `package.json`/`VERSION` (no hardcoded string) + append git sha when available.
|
||||
|
||||
@@ -126,10 +126,12 @@ Exit codes:
|
||||
|
||||
`bird --version` prints `package.json` version plus current git sha when available, e.g. `0.2.0 (3df7969b)`.
|
||||
|
||||
## Media uploads (Sweetistics only)
|
||||
## Media uploads
|
||||
|
||||
- Attach media with `--media` (repeatable) and optional `--alt` per item.
|
||||
- Up to 4 images, or 1 video (no mixing). Supported: jpg, jpeg, png, webp, gif, mp4, mov.
|
||||
- Up to 4 images/GIFs, or 1 video (no mixing). Supported: jpg, jpeg, png, webp, gif, mp4, mov.
|
||||
- `--engine graphql`: images/GIFs supported (uploads via Twitter legacy upload endpoint + cookies).
|
||||
- `--engine sweetistics`: images + video supported (requires `SWEETISTICS_API_KEY`).
|
||||
|
||||
Example:
|
||||
|
||||
|
||||
+53
-19
@@ -55,6 +55,14 @@ const collect = (value: string, previous: string[] = []) => {
|
||||
const p = (kind: Parameters<typeof statusPrefix>[0]) => statusPrefix(kind, output);
|
||||
const l = (kind: Parameters<typeof labelPrefix>[0]) => labelPrefix(kind, output);
|
||||
|
||||
function requireSweetisticsApiKey(apiKey: string | null): string {
|
||||
if (!apiKey) {
|
||||
console.error(`${p('err')}Sweetistics API key missing. Set SWEETISTICS_API_KEY or pass --sweetistics-api-key.`);
|
||||
process.exit(1);
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
function applyOutputFromCommand(command: Command) {
|
||||
const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean };
|
||||
output = resolveOutputConfigFromCommander(opts, process.env, isTty);
|
||||
@@ -399,10 +407,9 @@ program
|
||||
}
|
||||
}
|
||||
|
||||
if (media.length > 0) {
|
||||
console.error(
|
||||
`${p('err')}Media uploads are only supported via Sweetistics. Provide SWEETISTICS_API_KEY or --engine sweetistics.`,
|
||||
);
|
||||
const hasVideo = media.some((m) => m.mime.startsWith('video/'));
|
||||
if (hasVideo) {
|
||||
console.error(`${p('err')}Video uploads are only supported via Sweetistics (for now).`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -429,7 +436,21 @@ program
|
||||
}
|
||||
|
||||
const client = new TwitterClient({ cookies, timeoutMs });
|
||||
const result = await client.tweet(text);
|
||||
let mediaIds: string[] | undefined;
|
||||
if (media.length > 0) {
|
||||
const uploaded: string[] = [];
|
||||
for (const item of media) {
|
||||
const res = await client.uploadMedia({ data: item.buffer, mimeType: item.mime, alt: item.alt });
|
||||
if (!res.success || !res.mediaId) {
|
||||
console.error(`${p('err')}Media upload failed: ${res.error ?? 'Unknown error'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
uploaded.push(res.mediaId);
|
||||
}
|
||||
mediaIds = uploaded;
|
||||
}
|
||||
|
||||
const result = await client.tweet(text, mediaIds);
|
||||
|
||||
if (result.success) {
|
||||
console.log(`${p('ok')}Tweet posted successfully!`);
|
||||
@@ -438,7 +459,7 @@ program
|
||||
console.error(`${p('warn')}GraphQL tweet failed (${result.error}); trying Sweetistics fallback...`);
|
||||
const fallback = await new SweetisticsClient({
|
||||
baseUrl: sweetistics.baseUrl,
|
||||
apiKey: sweetistics.apiKey,
|
||||
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||
timeoutMs,
|
||||
}).tweet(text);
|
||||
if (fallback.success) {
|
||||
@@ -526,10 +547,9 @@ program
|
||||
}
|
||||
}
|
||||
|
||||
if (media.length > 0) {
|
||||
console.error(
|
||||
`${p('err')}Media uploads are only supported via Sweetistics. Provide SWEETISTICS_API_KEY or --engine sweetistics.`,
|
||||
);
|
||||
const hasVideo = media.some((m) => m.mime.startsWith('video/'));
|
||||
if (hasVideo) {
|
||||
console.error(`${p('err')}Video uploads are only supported via Sweetistics (for now).`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -558,7 +578,21 @@ program
|
||||
console.error(`${p('info')}Replying to tweet: ${tweetId}`);
|
||||
|
||||
const client = new TwitterClient({ cookies, timeoutMs });
|
||||
const result = await client.reply(text, tweetId);
|
||||
let mediaIds: string[] | undefined;
|
||||
if (media.length > 0) {
|
||||
const uploaded: string[] = [];
|
||||
for (const item of media) {
|
||||
const res = await client.uploadMedia({ data: item.buffer, mimeType: item.mime, alt: item.alt });
|
||||
if (!res.success || !res.mediaId) {
|
||||
console.error(`${p('err')}Media upload failed: ${res.error ?? 'Unknown error'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
uploaded.push(res.mediaId);
|
||||
}
|
||||
mediaIds = uploaded;
|
||||
}
|
||||
|
||||
const result = await client.reply(text, tweetId, mediaIds);
|
||||
|
||||
if (result.success) {
|
||||
console.log(`${p('ok')}Reply posted successfully!`);
|
||||
@@ -567,7 +601,7 @@ program
|
||||
console.error(`${p('warn')}GraphQL reply failed (${result.error}); trying Sweetistics fallback...`);
|
||||
const fallback = await new SweetisticsClient({
|
||||
baseUrl: sweetistics.baseUrl,
|
||||
apiKey: sweetistics.apiKey,
|
||||
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||
timeoutMs,
|
||||
}).tweet(text, tweetId);
|
||||
if (fallback.success) {
|
||||
@@ -666,7 +700,7 @@ program
|
||||
console.error(`${p('warn')}GraphQL read failed (${result.error}); trying Sweetistics fallback...`);
|
||||
const fallback = await new SweetisticsClient({
|
||||
baseUrl: sweetistics.baseUrl,
|
||||
apiKey: sweetistics.apiKey,
|
||||
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||
timeoutMs,
|
||||
}).read(tweetId);
|
||||
if (fallback.success && fallback.tweet) {
|
||||
@@ -750,7 +784,7 @@ program
|
||||
console.error(`${p('warn')}GraphQL replies failed (${result.error}); trying Sweetistics fallback...`);
|
||||
const fallback = await new SweetisticsClient({
|
||||
baseUrl: sweetistics.baseUrl,
|
||||
apiKey: sweetistics.apiKey,
|
||||
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||
timeoutMs,
|
||||
}).replies(tweetId);
|
||||
if (fallback.success && fallback.tweets) {
|
||||
@@ -825,7 +859,7 @@ program
|
||||
console.error(`${p('warn')}GraphQL thread failed (${result.error}); trying Sweetistics fallback...`);
|
||||
const fallback = await new SweetisticsClient({
|
||||
baseUrl: sweetistics.baseUrl,
|
||||
apiKey: sweetistics.apiKey,
|
||||
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||
timeoutMs,
|
||||
}).thread(tweetId);
|
||||
if (fallback.success && fallback.tweets) {
|
||||
@@ -902,7 +936,7 @@ program
|
||||
console.error(`${p('warn')}GraphQL search failed (${result.error}); trying Sweetistics fallback...`);
|
||||
const fallback = await new SweetisticsClient({
|
||||
baseUrl: sweetistics.baseUrl,
|
||||
apiKey: sweetistics.apiKey,
|
||||
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||
timeoutMs,
|
||||
}).search(query, count);
|
||||
if (fallback.success && fallback.tweets) {
|
||||
@@ -999,7 +1033,7 @@ program
|
||||
);
|
||||
const fallbackWho = await new SweetisticsClient({
|
||||
baseUrl: sweetistics.baseUrl,
|
||||
apiKey: sweetistics.apiKey,
|
||||
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||
timeoutMs,
|
||||
}).getCurrentUser();
|
||||
const fallbackHandle = normalizeHandle(fallbackWho.user?.username);
|
||||
@@ -1027,7 +1061,7 @@ program
|
||||
console.error(`${p('warn')}GraphQL mentions failed (${result.error}); trying Sweetistics fallback...`);
|
||||
const fallback = await new SweetisticsClient({
|
||||
baseUrl: sweetistics.baseUrl,
|
||||
apiKey: sweetistics.apiKey,
|
||||
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||
timeoutMs,
|
||||
}).search(query, count);
|
||||
if (fallback.success && fallback.tweets) {
|
||||
@@ -1123,7 +1157,7 @@ program
|
||||
if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
|
||||
const fallback = await new SweetisticsClient({
|
||||
baseUrl: sweetistics.baseUrl,
|
||||
apiKey: sweetistics.apiKey,
|
||||
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||
timeoutMs,
|
||||
}).getCurrentUser();
|
||||
if (fallback.success && fallback.user) {
|
||||
|
||||
+177
-5
@@ -8,6 +8,8 @@ import { runtimeQueryIds } from './runtime-query-ids.js';
|
||||
|
||||
const TWITTER_API_BASE = 'https://x.com/i/api/graphql';
|
||||
const TWITTER_GRAPHQL_POST_URL = 'https://x.com/i/api/graphql';
|
||||
const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/i/media/upload.json';
|
||||
const TWITTER_MEDIA_METADATA_URL = 'https://x.com/i/api/1.1/media/metadata/create.json';
|
||||
|
||||
// Query IDs rotate frequently; the values in query-ids.json are refreshed by
|
||||
// scripts/update-query-ids.ts. The fallback values keep the client usable if
|
||||
@@ -183,6 +185,12 @@ export interface TweetResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UploadMediaResult {
|
||||
success: boolean;
|
||||
mediaId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TweetData {
|
||||
id: string;
|
||||
text: string;
|
||||
@@ -332,10 +340,13 @@ export class TwitterClient {
|
||||
}
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
return this.getJsonHeaders();
|
||||
}
|
||||
|
||||
private getBaseHeaders(): Record<string, string> {
|
||||
return {
|
||||
authorization:
|
||||
'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
|
||||
'content-type': 'application/json',
|
||||
'x-csrf-token': this.ct0,
|
||||
'x-twitter-auth-type': 'OAuth2Session',
|
||||
'x-twitter-active-user': 'yes',
|
||||
@@ -347,6 +358,167 @@ export class TwitterClient {
|
||||
};
|
||||
}
|
||||
|
||||
private getJsonHeaders(): Record<string, string> {
|
||||
return {
|
||||
...this.getBaseHeaders(),
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private getUploadHeaders(): Record<string, string> {
|
||||
// Note: do not set content-type; URLSearchParams/FormData need to set it (incl boundary) themselves.
|
||||
return this.getBaseHeaders();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private async sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
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}` };
|
||||
|
||||
// Keep scope small: images/gif first. Video requires longer processing + larger chunks.
|
||||
if (input.mimeType.startsWith('video/')) {
|
||||
return { success: false, error: 'Video uploads are not supported via GraphQL yet (use Sweetistics)' };
|
||||
}
|
||||
|
||||
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 })}`;
|
||||
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) {
|
||||
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) };
|
||||
}
|
||||
}
|
||||
|
||||
private firstText(...values: Array<string | undefined | null>): string | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string') {
|
||||
@@ -998,12 +1170,12 @@ export class TwitterClient {
|
||||
/**
|
||||
* Post a new tweet
|
||||
*/
|
||||
async tweet(text: string): Promise<TweetResult> {
|
||||
async tweet(text: string, mediaIds?: string[]): Promise<TweetResult> {
|
||||
const variables = {
|
||||
tweet_text: text,
|
||||
dark_request: false,
|
||||
media: {
|
||||
media_entities: [],
|
||||
media_entities: (mediaIds ?? []).map((id) => ({ media_id: id, tagged_users: [] })),
|
||||
possibly_sensitive: false,
|
||||
},
|
||||
semantic_annotation_ids: [],
|
||||
@@ -1052,7 +1224,7 @@ export class TwitterClient {
|
||||
/**
|
||||
* Reply to an existing tweet
|
||||
*/
|
||||
async reply(text: string, replyToTweetId: string): Promise<TweetResult> {
|
||||
async reply(text: string, replyToTweetId: string, mediaIds?: string[]): Promise<TweetResult> {
|
||||
const variables = {
|
||||
tweet_text: text,
|
||||
reply: {
|
||||
@@ -1061,7 +1233,7 @@ export class TwitterClient {
|
||||
},
|
||||
dark_request: false,
|
||||
media: {
|
||||
media_entities: [],
|
||||
media_entities: (mediaIds ?? []).map((id) => ({ media_id: id, tagged_users: [] })),
|
||||
possibly_sensitive: false,
|
||||
},
|
||||
semantic_annotation_ids: [],
|
||||
|
||||
@@ -83,6 +83,36 @@ describe('TwitterClient', () => {
|
||||
expect(body.features.creator_subscriptions_tweet_preview_api_enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('supports attaching media IDs', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
create_tweet: {
|
||||
tweet_results: {
|
||||
result: {
|
||||
rest_id: '1234567890',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const result = await client.tweet('Hello world!', ['111', '222']);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0];
|
||||
const body = JSON.parse(options.body);
|
||||
expect(body.variables.media.media_entities).toEqual([
|
||||
{ media_id: '111', tagged_users: [] },
|
||||
{ media_id: '222', tagged_users: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('retries CreateTweet via /i/api/graphql when operation URL 404s', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
@@ -187,6 +217,75 @@ describe('TwitterClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadMedia', () => {
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
});
|
||||
|
||||
it('uploads an image and sets alt text', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ media_id_string: '999' }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const data = new Uint8Array([1, 2, 3, 4]);
|
||||
const result = await client.uploadMedia({ data, mimeType: 'image/png', alt: 'alt text' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.mediaId).toBe('999');
|
||||
expect(mockFetch).toHaveBeenCalledTimes(4);
|
||||
|
||||
const [initUrl, initOptions] = mockFetch.mock.calls[0];
|
||||
expect(String(initUrl)).toContain('upload.twitter.com');
|
||||
expect(initOptions.method).toBe('POST');
|
||||
expect(initOptions.body).toBeInstanceOf(URLSearchParams);
|
||||
expect((initOptions.body as URLSearchParams).get('command')).toBe('INIT');
|
||||
expect((initOptions.body as URLSearchParams).get('media_type')).toBe('image/png');
|
||||
|
||||
const [, appendOptions] = mockFetch.mock.calls[1];
|
||||
expect(appendOptions.method).toBe('POST');
|
||||
expect(appendOptions.body).toBeInstanceOf(FormData);
|
||||
const appendBody = appendOptions.body as FormData;
|
||||
expect(appendBody.get('command')).toBe('APPEND');
|
||||
expect(appendBody.get('media_id')).toBe('999');
|
||||
expect(appendBody.get('segment_index')).toBe('0');
|
||||
expect(appendBody.get('media')).toBeInstanceOf(Blob);
|
||||
|
||||
const [, finalizeOptions] = mockFetch.mock.calls[2];
|
||||
expect(finalizeOptions.body).toBeInstanceOf(URLSearchParams);
|
||||
expect((finalizeOptions.body as URLSearchParams).get('command')).toBe('FINALIZE');
|
||||
expect((finalizeOptions.body as URLSearchParams).get('media_id')).toBe('999');
|
||||
|
||||
const [metaUrl, metaOptions] = mockFetch.mock.calls[3];
|
||||
expect(String(metaUrl)).toContain('/media/metadata/create.json');
|
||||
expect(metaOptions.method).toBe('POST');
|
||||
expect(JSON.parse(metaOptions.body)).toEqual({ media_id: '999', alt_text: { text: 'alt text' } });
|
||||
});
|
||||
|
||||
it('rejects video uploads', async () => {
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const data = new Uint8Array([1, 2, 3]);
|
||||
const result = await client.uploadMedia({ data, mimeType: 'video/mp4' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Video uploads are not supported');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reply', () => {
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user