diff --git a/CHANGELOG.md b/CHANGELOG.md index 4426589..de10f9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Output controls: `--plain`, `--no-emoji`, `--no-color` (respects `NO_COLOR`). - `help` command: `bird help `. - 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. diff --git a/README.md b/README.md index 82f06fb..aca35d2 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/index.ts b/src/index.ts index c0ba0fc..dcd4e28 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,6 +55,14 @@ const collect = (value: string, previous: string[] = []) => { const p = (kind: Parameters[0]) => statusPrefix(kind, output); const l = (kind: Parameters[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) { diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index a843b2c..98751d3 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -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 { + return this.getJsonHeaders(); + } + + private getBaseHeaders(): Record { 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 { + return { + ...this.getBaseHeaders(), + 'content-type': 'application/json', + }; + } + + private getUploadHeaders(): Record { + // 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 { + await new Promise((resolve) => setTimeout(resolve, ms)); + } + + async uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise { + 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 { 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 { + async tweet(text: string, mediaIds?: string[]): Promise { 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 { + async reply(text: string, replyToTweetId: string, mediaIds?: string[]): Promise { 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: [], diff --git a/tests/twitter-client.test.ts b/tests/twitter-client.test.ts index 1e448bd..48ee5fc 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -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; + + 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;