Add Sweetistics media uploads and CLI flags
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user