Add Sweetistics media uploads and CLI flags
This commit is contained in:
@@ -19,3 +19,4 @@
|
|||||||
### Fixed
|
### Fixed
|
||||||
- Fallback to scraping the authenticated settings page when Twitter account APIs return 404, so `whoami` still resolves the user.
|
- 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 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!"
|
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
|
### Reply to a tweet
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+96
-2
@@ -24,6 +24,10 @@ const program = new Command();
|
|||||||
|
|
||||||
const isTty = process.stdout.isTTY;
|
const isTty = process.stdout.isTTY;
|
||||||
const wrap = (styler: (text: string) => string) => (text: string) => (isTty ? styler(text) : text);
|
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 = {
|
const colors = {
|
||||||
banner: wrap((t) => kleur.bold().blue(t)),
|
banner: wrap((t) => kleur.bold().blue(t)),
|
||||||
@@ -101,6 +105,8 @@ program
|
|||||||
.option('--ct0 <token>', 'Twitter ct0 cookie')
|
.option('--ct0 <token>', 'Twitter ct0 cookie')
|
||||||
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction', config.chromeProfile)
|
.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('--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-api-key <key>', 'Sweetistics API key (or set SWEETISTICS_API_KEY)')
|
||||||
.option(
|
.option(
|
||||||
'--sweetistics-base-url <url>',
|
'--sweetistics-base-url <url>',
|
||||||
@@ -115,6 +121,8 @@ program
|
|||||||
|
|
||||||
type EngineMode = 'graphql' | 'sweetistics' | 'auto';
|
type EngineMode = 'graphql' | 'sweetistics' | 'auto';
|
||||||
|
|
||||||
|
type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer };
|
||||||
|
|
||||||
function resolveSweetisticsConfig(options: { sweetisticsApiKey?: string; sweetisticsBaseUrl?: string }) {
|
function resolveSweetisticsConfig(options: { sweetisticsApiKey?: string; sweetisticsBaseUrl?: string }) {
|
||||||
const apiKey =
|
const apiKey =
|
||||||
options.sweetisticsApiKey || process.env.SWEETISTICS_API_KEY || process.env.SWEETISTICS_LOCALHOST_API_KEY || null;
|
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
|
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(
|
function printTweets(
|
||||||
tweets: TweetData[],
|
tweets: TweetData[],
|
||||||
opts: { json?: boolean; emptyMessage?: string; showSeparator?: boolean } = {},
|
opts: { json?: boolean; emptyMessage?: string; showSeparator?: boolean } = {},
|
||||||
@@ -170,6 +208,13 @@ program
|
|||||||
.argument('<text>', 'Tweet text')
|
.argument('<text>', 'Tweet text')
|
||||||
.action(async (text: string) => {
|
.action(async (text: string) => {
|
||||||
const opts = program.opts();
|
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({
|
const sweetistics = resolveSweetisticsConfig({
|
||||||
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
||||||
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
|
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
|
||||||
@@ -187,7 +232,23 @@ program
|
|||||||
baseUrl: sweetistics.baseUrl,
|
baseUrl: sweetistics.baseUrl,
|
||||||
apiKey: sweetistics.apiKey,
|
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) {
|
if (result.success) {
|
||||||
console.log('✅ Tweet posted via Sweetistics!');
|
console.log('✅ Tweet posted via Sweetistics!');
|
||||||
if (result.tweetId) {
|
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({
|
const { cookies, warnings } = await resolveCredentials({
|
||||||
authToken: opts.authToken,
|
authToken: opts.authToken,
|
||||||
ct0: opts.ct0,
|
ct0: opts.ct0,
|
||||||
@@ -260,6 +326,13 @@ program
|
|||||||
.argument('<text>', 'Reply text')
|
.argument('<text>', 'Reply text')
|
||||||
.action(async (tweetIdOrUrl: string, text: string) => {
|
.action(async (tweetIdOrUrl: string, text: string) => {
|
||||||
const opts = program.opts();
|
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({
|
const sweetistics = resolveSweetisticsConfig({
|
||||||
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
||||||
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
|
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
|
||||||
@@ -278,7 +351,23 @@ program
|
|||||||
baseUrl: sweetistics.baseUrl,
|
baseUrl: sweetistics.baseUrl,
|
||||||
apiKey: sweetistics.apiKey,
|
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) {
|
if (result.success) {
|
||||||
console.log('✅ Reply posted via Sweetistics!');
|
console.log('✅ Reply posted via Sweetistics!');
|
||||||
if (result.tweetId) {
|
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({
|
const { cookies, warnings } = await resolveCredentials({
|
||||||
authToken: opts.authToken,
|
authToken: opts.authToken,
|
||||||
ct0: opts.ct0,
|
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 };
|
const payload: Record<string, unknown> = { text };
|
||||||
if (replyToTweetId) {
|
if (replyToTweetId) {
|
||||||
payload.replyToTweetId = replyToTweetId;
|
payload.replyToTweetId = replyToTweetId;
|
||||||
}
|
}
|
||||||
|
if (mediaIds && mediaIds.length > 0) {
|
||||||
|
payload.mediaIds = mediaIds;
|
||||||
|
}
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
@@ -458,6 +461,41 @@ export class SweetisticsClient {
|
|||||||
return { success: true, tweets };
|
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> {
|
private async fetchWithTimeout(url: string, init?: RequestInit): Promise<Response> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(
|
const timeoutId = setTimeout(
|
||||||
|
|||||||
Reference in New Issue
Block a user