From da22ef25d3d88b3729b075a2609cd5cb0d0b238a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 4 Dec 2025 14:25:59 +0000 Subject: [PATCH] Add Sweetistics media uploads and CLI flags --- CHANGELOG.md | 1 + README.md | 9 ++++ src/index.ts | 98 ++++++++++++++++++++++++++++++++++- src/lib/sweetistics-client.ts | 40 +++++++++++++- 4 files changed, 145 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 797286e..2c70faf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,3 +19,4 @@ ### Fixed - Fallback to scraping the authenticated settings page when Twitter account APIs return 404, so `whoami` still resolves the user. - Sweetistics calls now time out after 15s to avoid hanging CLI commands when the API is slow or unresponsive. +- Sweetistics tweeting now supports media uploads (images or single video) via new CLI `--media/--alt` flags. diff --git a/README.md b/README.md index c54d7bf..cf3673b 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,15 @@ When `allowChrome` or `allowFirefox` is set to `false`, that source is skipped e bird tweet "Hello from bird!" ``` +### Media uploads (Sweetistics only) +- Attach images or a single video with `--media` (repeatable) and optional `--alt` (aligned by order): + - `bird --engine sweetistics tweet "hi" --media img.png --alt "desc"` + - Up to 4 images, or 1 video (no mixing video+images). Supported: jpg, jpeg, png, webp, gif, mp4, mov. +- Media uploads currently require the Sweetistics engine (API key). GraphQL-only mode will reject `--media`. +- All Sweetistics calls have a 15s timeout so the CLI won’t hang if the API is slow or unreachable. + +⚠️ GraphQL mode uses X’s internal endpoints and is rate‑limited aggressively; expect 429s if you run many reads/writes without Sweetistics. + ### Reply to a tweet ```bash diff --git a/src/index.ts b/src/index.ts index 559a113..8145378 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,10 @@ const program = new Command(); const isTty = process.stdout.isTTY; const wrap = (styler: (text: string) => string) => (text: string) => (isTty ? styler(text) : text); +const collect = (value: string, previous: string[] = []) => { + previous.push(value); + return previous; +}; const colors = { banner: wrap((t) => kleur.bold().blue(t)), @@ -101,6 +105,8 @@ program .option('--ct0 ', 'Twitter ct0 cookie') .option('--chrome-profile ', 'Chrome profile name for cookie extraction', config.chromeProfile) .option('--firefox-profile ', 'Firefox profile name for cookie extraction', config.firefoxProfile) + .option('--media ', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, []) + .option('--alt ', 'Alt text for the corresponding --media (repeatable)', collect, []) .option('--sweetistics-api-key ', 'Sweetistics API key (or set SWEETISTICS_API_KEY)') .option( '--sweetistics-base-url ', @@ -115,6 +121,8 @@ program type EngineMode = 'graphql' | 'sweetistics' | 'auto'; +type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer }; + function resolveSweetisticsConfig(options: { sweetisticsApiKey?: string; sweetisticsBaseUrl?: string }) { const apiKey = options.sweetisticsApiKey || process.env.SWEETISTICS_API_KEY || process.env.SWEETISTICS_LOCALHOST_API_KEY || null; @@ -138,6 +146,36 @@ function shouldUseSweetistics(engine: EngineMode, hasApiKey: boolean): boolean { return hasApiKey; // auto } +function detectMime(path: string): string | null { + const ext = path.toLowerCase(); + if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) return 'image/jpeg'; + if (ext.endsWith('.png')) return 'image/png'; + if (ext.endsWith('.webp')) return 'image/webp'; + if (ext.endsWith('.gif')) return 'image/gif'; + if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) return 'video/mp4'; + if (ext.endsWith('.mov')) return 'video/quicktime'; + return null; +} + +function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] { + if (opts.media.length === 0) return []; + const specs: MediaSpec[] = []; + for (const [index, path] of opts.media.entries()) { + const mime = detectMime(path); + if (!mime) { + throw new Error(`Unsupported media type for ${path}. Supported: jpg, jpeg, png, webp, gif, mp4, mov`); + } + const buffer = readFileSync(path); + specs.push({ path, mime, buffer, alt: opts.alts[index] }); + } + + const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length; + if (videoCount > 1) throw new Error('Only one video can be attached'); + if (videoCount === 1 && specs.length > 1) throw new Error('Video cannot be combined with other media'); + if (specs.length > 4) throw new Error('Maximum 4 media attachments'); + return specs; +} + function printTweets( tweets: TweetData[], opts: { json?: boolean; emptyMessage?: string; showSeparator?: boolean } = {}, @@ -170,6 +208,13 @@ program .argument('', 'Tweet text') .action(async (text: string) => { const opts = program.opts(); + let media: MediaSpec[] = []; + try { + media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] }); + } catch (error) { + console.error(`❌ ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } const sweetistics = resolveSweetisticsConfig({ sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey, sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl, @@ -187,7 +232,23 @@ program baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey, }); - 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.toString('base64'), + mimeType: item.mime, + alt: item.alt, + }); + if (!res.success || !res.mediaId) { + throw new Error(res.error ?? 'Media upload failed'); + } + uploaded.push(res.mediaId); + } + mediaIds = uploaded; + } + const result = await client.tweet(text, undefined, mediaIds); if (result.success) { console.log('✅ Tweet posted via Sweetistics!'); if (result.tweetId) { @@ -203,6 +264,11 @@ program } } + if (media.length > 0) { + console.error('❌ Media uploads are only supported via Sweetistics. Provide SWEETISTICS_API_KEY or --engine sweetistics.'); + process.exit(1); + } + const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, ct0: opts.ct0, @@ -260,6 +326,13 @@ program .argument('', 'Reply text') .action(async (tweetIdOrUrl: string, text: string) => { const opts = program.opts(); + let media: MediaSpec[] = []; + try { + media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] }); + } catch (error) { + console.error(`❌ ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } const sweetistics = resolveSweetisticsConfig({ sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey, sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl, @@ -278,7 +351,23 @@ program baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey, }); - const result = await client.tweet(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.toString('base64'), + mimeType: item.mime, + alt: item.alt, + }); + if (!res.success || !res.mediaId) { + throw new Error(res.error ?? 'Media upload failed'); + } + uploaded.push(res.mediaId); + } + mediaIds = uploaded; + } + const result = await client.tweet(text, tweetId, mediaIds); if (result.success) { console.log('✅ Reply posted via Sweetistics!'); if (result.tweetId) { @@ -294,6 +383,11 @@ program } } + if (media.length > 0) { + console.error('❌ Media uploads are only supported via Sweetistics. Provide SWEETISTICS_API_KEY or --engine sweetistics.'); + process.exit(1); + } + const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, ct0: opts.ct0, diff --git a/src/lib/sweetistics-client.ts b/src/lib/sweetistics-client.ts index 72bf5c2..3096aea 100644 --- a/src/lib/sweetistics-client.ts +++ b/src/lib/sweetistics-client.ts @@ -75,11 +75,14 @@ export class SweetisticsClient { } } - async tweet(text: string, replyToTweetId?: string): Promise { + async tweet(text: string, replyToTweetId?: string, mediaIds?: string[]): Promise { const payload: Record = { text }; if (replyToTweetId) { payload.replyToTweetId = replyToTweetId; } + if (mediaIds && mediaIds.length > 0) { + payload.mediaIds = mediaIds; + } let response: Response; try { @@ -458,6 +461,41 @@ export class SweetisticsClient { return { success: true, tweets }; } + async uploadMedia(input: { data: string; mimeType: string; alt?: string }): Promise<{ success: boolean; mediaId?: string; error?: string }> { + try { + const response = await this.fetchWithTimeout(`${this.baseUrl}/api/actions/media/upload`, { + method: 'POST', + headers: { + authorization: `Bearer ${this.apiKey}`, + 'content-type': 'application/json', + ...(this.userAgent ? { 'user-agent': this.userAgent } : {}), + }, + body: JSON.stringify(input), + }); + + let body: unknown; + try { + body = await response.json(); + } catch (error) { + return { success: false, error: this.normalizeError(error) }; + } + + const mediaId = + typeof (body as { mediaId?: unknown })?.mediaId === 'string' + ? (body as { mediaId: string }).mediaId + : undefined; + const errorMessage = + typeof (body as { error?: unknown })?.error === 'string' ? (body as { error: string }).error : undefined; + + if (!response.ok || !mediaId) { + return { success: false, error: errorMessage ?? `HTTP ${response.status}` }; + } + return { success: true, mediaId }; + } catch (error) { + return { success: false, error: this.normalizeError(error) }; + } + } + private async fetchWithTimeout(url: string, init?: RequestInit): Promise { const controller = new AbortController(); const timeoutId = setTimeout(