feat(graphql): upload media
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
- Output controls: `--plain`, `--no-emoji`, `--no-color` (respects `NO_COLOR`).
|
- Output controls: `--plain`, `--no-emoji`, `--no-color` (respects `NO_COLOR`).
|
||||||
- `help` command: `bird help <command>`.
|
- `help` command: `bird help <command>`.
|
||||||
- Runtime GraphQL query ID refresh: `bird query-ids --fresh` (cached on disk; auto-retry on 404; override cache via `BIRD_QUERY_IDS_CACHE`).
|
- 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
|
### Fixed
|
||||||
- CLI `--version`: read version from `package.json`/`VERSION` (no hardcoded string) + append git sha when available.
|
- CLI `--version`: read version from `package.json`/`VERSION` (no hardcoded string) + append git sha when available.
|
||||||
|
|||||||
@@ -126,10 +126,12 @@ Exit codes:
|
|||||||
|
|
||||||
`bird --version` prints `package.json` version plus current git sha when available, e.g. `0.2.0 (3df7969b)`.
|
`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.
|
- 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:
|
Example:
|
||||||
|
|
||||||
|
|||||||
+53
-19
@@ -55,6 +55,14 @@ const collect = (value: string, previous: string[] = []) => {
|
|||||||
const p = (kind: Parameters<typeof statusPrefix>[0]) => statusPrefix(kind, output);
|
const p = (kind: Parameters<typeof statusPrefix>[0]) => statusPrefix(kind, output);
|
||||||
const l = (kind: Parameters<typeof labelPrefix>[0]) => labelPrefix(kind, output);
|
const l = (kind: Parameters<typeof labelPrefix>[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) {
|
function applyOutputFromCommand(command: Command) {
|
||||||
const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean };
|
const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean };
|
||||||
output = resolveOutputConfigFromCommander(opts, process.env, isTty);
|
output = resolveOutputConfigFromCommander(opts, process.env, isTty);
|
||||||
@@ -399,10 +407,9 @@ program
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (media.length > 0) {
|
const hasVideo = media.some((m) => m.mime.startsWith('video/'));
|
||||||
console.error(
|
if (hasVideo) {
|
||||||
`${p('err')}Media uploads are only supported via Sweetistics. Provide SWEETISTICS_API_KEY or --engine sweetistics.`,
|
console.error(`${p('err')}Video uploads are only supported via Sweetistics (for now).`);
|
||||||
);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +436,21 @@ program
|
|||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies, timeoutMs });
|
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) {
|
if (result.success) {
|
||||||
console.log(`${p('ok')}Tweet posted successfully!`);
|
console.log(`${p('ok')}Tweet posted successfully!`);
|
||||||
@@ -438,7 +459,7 @@ program
|
|||||||
console.error(`${p('warn')}GraphQL tweet failed (${result.error}); trying Sweetistics fallback...`);
|
console.error(`${p('warn')}GraphQL tweet failed (${result.error}); trying Sweetistics fallback...`);
|
||||||
const fallback = await new SweetisticsClient({
|
const fallback = await new SweetisticsClient({
|
||||||
baseUrl: sweetistics.baseUrl,
|
baseUrl: sweetistics.baseUrl,
|
||||||
apiKey: sweetistics.apiKey,
|
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
}).tweet(text);
|
}).tweet(text);
|
||||||
if (fallback.success) {
|
if (fallback.success) {
|
||||||
@@ -526,10 +547,9 @@ program
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (media.length > 0) {
|
const hasVideo = media.some((m) => m.mime.startsWith('video/'));
|
||||||
console.error(
|
if (hasVideo) {
|
||||||
`${p('err')}Media uploads are only supported via Sweetistics. Provide SWEETISTICS_API_KEY or --engine sweetistics.`,
|
console.error(`${p('err')}Video uploads are only supported via Sweetistics (for now).`);
|
||||||
);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,7 +578,21 @@ program
|
|||||||
console.error(`${p('info')}Replying to tweet: ${tweetId}`);
|
console.error(`${p('info')}Replying to tweet: ${tweetId}`);
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies, timeoutMs });
|
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) {
|
if (result.success) {
|
||||||
console.log(`${p('ok')}Reply posted successfully!`);
|
console.log(`${p('ok')}Reply posted successfully!`);
|
||||||
@@ -567,7 +601,7 @@ program
|
|||||||
console.error(`${p('warn')}GraphQL reply failed (${result.error}); trying Sweetistics fallback...`);
|
console.error(`${p('warn')}GraphQL reply failed (${result.error}); trying Sweetistics fallback...`);
|
||||||
const fallback = await new SweetisticsClient({
|
const fallback = await new SweetisticsClient({
|
||||||
baseUrl: sweetistics.baseUrl,
|
baseUrl: sweetistics.baseUrl,
|
||||||
apiKey: sweetistics.apiKey,
|
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
}).tweet(text, tweetId);
|
}).tweet(text, tweetId);
|
||||||
if (fallback.success) {
|
if (fallback.success) {
|
||||||
@@ -666,7 +700,7 @@ program
|
|||||||
console.error(`${p('warn')}GraphQL read failed (${result.error}); trying Sweetistics fallback...`);
|
console.error(`${p('warn')}GraphQL read failed (${result.error}); trying Sweetistics fallback...`);
|
||||||
const fallback = await new SweetisticsClient({
|
const fallback = await new SweetisticsClient({
|
||||||
baseUrl: sweetistics.baseUrl,
|
baseUrl: sweetistics.baseUrl,
|
||||||
apiKey: sweetistics.apiKey,
|
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
}).read(tweetId);
|
}).read(tweetId);
|
||||||
if (fallback.success && fallback.tweet) {
|
if (fallback.success && fallback.tweet) {
|
||||||
@@ -750,7 +784,7 @@ program
|
|||||||
console.error(`${p('warn')}GraphQL replies failed (${result.error}); trying Sweetistics fallback...`);
|
console.error(`${p('warn')}GraphQL replies failed (${result.error}); trying Sweetistics fallback...`);
|
||||||
const fallback = await new SweetisticsClient({
|
const fallback = await new SweetisticsClient({
|
||||||
baseUrl: sweetistics.baseUrl,
|
baseUrl: sweetistics.baseUrl,
|
||||||
apiKey: sweetistics.apiKey,
|
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
}).replies(tweetId);
|
}).replies(tweetId);
|
||||||
if (fallback.success && fallback.tweets) {
|
if (fallback.success && fallback.tweets) {
|
||||||
@@ -825,7 +859,7 @@ program
|
|||||||
console.error(`${p('warn')}GraphQL thread failed (${result.error}); trying Sweetistics fallback...`);
|
console.error(`${p('warn')}GraphQL thread failed (${result.error}); trying Sweetistics fallback...`);
|
||||||
const fallback = await new SweetisticsClient({
|
const fallback = await new SweetisticsClient({
|
||||||
baseUrl: sweetistics.baseUrl,
|
baseUrl: sweetistics.baseUrl,
|
||||||
apiKey: sweetistics.apiKey,
|
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
}).thread(tweetId);
|
}).thread(tweetId);
|
||||||
if (fallback.success && fallback.tweets) {
|
if (fallback.success && fallback.tweets) {
|
||||||
@@ -902,7 +936,7 @@ program
|
|||||||
console.error(`${p('warn')}GraphQL search failed (${result.error}); trying Sweetistics fallback...`);
|
console.error(`${p('warn')}GraphQL search failed (${result.error}); trying Sweetistics fallback...`);
|
||||||
const fallback = await new SweetisticsClient({
|
const fallback = await new SweetisticsClient({
|
||||||
baseUrl: sweetistics.baseUrl,
|
baseUrl: sweetistics.baseUrl,
|
||||||
apiKey: sweetistics.apiKey,
|
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
}).search(query, count);
|
}).search(query, count);
|
||||||
if (fallback.success && fallback.tweets) {
|
if (fallback.success && fallback.tweets) {
|
||||||
@@ -999,7 +1033,7 @@ program
|
|||||||
);
|
);
|
||||||
const fallbackWho = await new SweetisticsClient({
|
const fallbackWho = await new SweetisticsClient({
|
||||||
baseUrl: sweetistics.baseUrl,
|
baseUrl: sweetistics.baseUrl,
|
||||||
apiKey: sweetistics.apiKey,
|
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
}).getCurrentUser();
|
}).getCurrentUser();
|
||||||
const fallbackHandle = normalizeHandle(fallbackWho.user?.username);
|
const fallbackHandle = normalizeHandle(fallbackWho.user?.username);
|
||||||
@@ -1027,7 +1061,7 @@ program
|
|||||||
console.error(`${p('warn')}GraphQL mentions failed (${result.error}); trying Sweetistics fallback...`);
|
console.error(`${p('warn')}GraphQL mentions failed (${result.error}); trying Sweetistics fallback...`);
|
||||||
const fallback = await new SweetisticsClient({
|
const fallback = await new SweetisticsClient({
|
||||||
baseUrl: sweetistics.baseUrl,
|
baseUrl: sweetistics.baseUrl,
|
||||||
apiKey: sweetistics.apiKey,
|
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
}).search(query, count);
|
}).search(query, count);
|
||||||
if (fallback.success && fallback.tweets) {
|
if (fallback.success && fallback.tweets) {
|
||||||
@@ -1123,7 +1157,7 @@ program
|
|||||||
if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
|
if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
|
||||||
const fallback = await new SweetisticsClient({
|
const fallback = await new SweetisticsClient({
|
||||||
baseUrl: sweetistics.baseUrl,
|
baseUrl: sweetistics.baseUrl,
|
||||||
apiKey: sweetistics.apiKey,
|
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
}).getCurrentUser();
|
}).getCurrentUser();
|
||||||
if (fallback.success && fallback.user) {
|
if (fallback.success && fallback.user) {
|
||||||
|
|||||||
+177
-5
@@ -8,6 +8,8 @@ import { runtimeQueryIds } from './runtime-query-ids.js';
|
|||||||
|
|
||||||
const TWITTER_API_BASE = 'https://x.com/i/api/graphql';
|
const TWITTER_API_BASE = 'https://x.com/i/api/graphql';
|
||||||
const TWITTER_GRAPHQL_POST_URL = '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
|
// 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
|
// scripts/update-query-ids.ts. The fallback values keep the client usable if
|
||||||
@@ -183,6 +185,12 @@ export interface TweetResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UploadMediaResult {
|
||||||
|
success: boolean;
|
||||||
|
mediaId?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TweetData {
|
export interface TweetData {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -332,10 +340,13 @@ export class TwitterClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getHeaders(): Record<string, string> {
|
private getHeaders(): Record<string, string> {
|
||||||
|
return this.getJsonHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBaseHeaders(): Record<string, string> {
|
||||||
return {
|
return {
|
||||||
authorization:
|
authorization:
|
||||||
'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
|
'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
|
||||||
'content-type': 'application/json',
|
|
||||||
'x-csrf-token': this.ct0,
|
'x-csrf-token': this.ct0,
|
||||||
'x-twitter-auth-type': 'OAuth2Session',
|
'x-twitter-auth-type': 'OAuth2Session',
|
||||||
'x-twitter-active-user': 'yes',
|
'x-twitter-active-user': 'yes',
|
||||||
@@ -347,6 +358,167 @@ export class TwitterClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getJsonHeaders(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
...this.getBaseHeaders(),
|
||||||
|
'content-type': 'application/json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUploadHeaders(): Record<string, string> {
|
||||||
|
// 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<void> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise<UploadMediaResult> {
|
||||||
|
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 | null>): string | undefined {
|
private firstText(...values: Array<string | undefined | null>): string | undefined {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
@@ -998,12 +1170,12 @@ export class TwitterClient {
|
|||||||
/**
|
/**
|
||||||
* Post a new tweet
|
* Post a new tweet
|
||||||
*/
|
*/
|
||||||
async tweet(text: string): Promise<TweetResult> {
|
async tweet(text: string, mediaIds?: string[]): Promise<TweetResult> {
|
||||||
const variables = {
|
const variables = {
|
||||||
tweet_text: text,
|
tweet_text: text,
|
||||||
dark_request: false,
|
dark_request: false,
|
||||||
media: {
|
media: {
|
||||||
media_entities: [],
|
media_entities: (mediaIds ?? []).map((id) => ({ media_id: id, tagged_users: [] })),
|
||||||
possibly_sensitive: false,
|
possibly_sensitive: false,
|
||||||
},
|
},
|
||||||
semantic_annotation_ids: [],
|
semantic_annotation_ids: [],
|
||||||
@@ -1052,7 +1224,7 @@ export class TwitterClient {
|
|||||||
/**
|
/**
|
||||||
* Reply to an existing tweet
|
* Reply to an existing tweet
|
||||||
*/
|
*/
|
||||||
async reply(text: string, replyToTweetId: string): Promise<TweetResult> {
|
async reply(text: string, replyToTweetId: string, mediaIds?: string[]): Promise<TweetResult> {
|
||||||
const variables = {
|
const variables = {
|
||||||
tweet_text: text,
|
tweet_text: text,
|
||||||
reply: {
|
reply: {
|
||||||
@@ -1061,7 +1233,7 @@ export class TwitterClient {
|
|||||||
},
|
},
|
||||||
dark_request: false,
|
dark_request: false,
|
||||||
media: {
|
media: {
|
||||||
media_entities: [],
|
media_entities: (mediaIds ?? []).map((id) => ({ media_id: id, tagged_users: [] })),
|
||||||
possibly_sensitive: false,
|
possibly_sensitive: false,
|
||||||
},
|
},
|
||||||
semantic_annotation_ids: [],
|
semantic_annotation_ids: [],
|
||||||
|
|||||||
@@ -83,6 +83,36 @@ describe('TwitterClient', () => {
|
|||||||
expect(body.features.creator_subscriptions_tweet_preview_api_enabled).toBe(true);
|
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 () => {
|
it('retries CreateTweet via /i/api/graphql when operation URL 404s', async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
@@ -187,6 +217,75 @@ describe('TwitterClient', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('uploadMedia', () => {
|
||||||
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
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', () => {
|
describe('reply', () => {
|
||||||
let mockFetch: ReturnType<typeof vi.fn>;
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user