Add Sweetistics media uploads and CLI flags

This commit is contained in:
Peter Steinberger
2025-12-04 14:25:59 +00:00
parent 689b10e280
commit da22ef25d3
4 changed files with 145 additions and 3 deletions
+1
View File
@@ -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.
+9
View File
@@ -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
+96 -2
View File
@@ -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 <token>', 'Twitter ct0 cookie')
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction', config.chromeProfile)
.option('--firefox-profile <name>', 'Firefox profile name for cookie extraction', config.firefoxProfile)
.option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, [])
.option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect, [])
.option('--sweetistics-api-key <key>', 'Sweetistics API key (or set SWEETISTICS_API_KEY)')
.option(
'--sweetistics-base-url <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('<text>', '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('<text>', '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,
+39 -1
View File
@@ -75,11 +75,14 @@ export class SweetisticsClient {
}
}
async tweet(text: string, replyToTweetId?: string): Promise<SweetisticsTweetResult> {
async tweet(text: string, replyToTweetId?: string, mediaIds?: string[]): Promise<SweetisticsTweetResult> {
const payload: Record<string, unknown> = { 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<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(