client.v1.write.js•18.6 kB
import * as fs from 'fs';
import { API_V1_1_PREFIX, API_V1_1_UPLOAD_PREFIX } from '../globals';
import { hasMultipleItems } from '../helpers';
import { EUploadMimeType, } from '../types';
import TwitterApiv1ReadOnly from './client.v1.read';
import { getFileHandle, getFileSizeFromFileHandle, getMediaCategoryByMime, getMimeType, readFileIntoBuffer, readNextPartOf, sleepSecs } from './media-helpers.v1';
const UPLOAD_ENDPOINT = 'media/upload.json';
/**
* Base Twitter v1 client with read/write rights.
*/
export default class TwitterApiv1ReadWrite extends TwitterApiv1ReadOnly {
constructor() {
super(...arguments);
this._prefix = API_V1_1_PREFIX;
}
/**
* Get a client with only read rights.
*/
get readOnly() {
return this;
}
/* Tweet API */
/**
* Post a new tweet.
* https://developer.x.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-statuses-update
*/
tweet(status, payload = {}) {
const queryParams = {
status,
tweet_mode: 'extended',
...payload,
};
return this.post('statuses/update.json', queryParams);
}
/**
* Quote an existing tweet.
* https://developer.x.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-statuses-update
*/
async quote(status, quotingStatusId, payload = {}) {
const url = 'https://x.com/i/statuses/' + quotingStatusId;
return this.tweet(status, { ...payload, attachment_url: url });
}
/**
* Post a series of tweets.
* https://developer.x.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-statuses-update
*/
async tweetThread(tweets) {
const postedTweets = [];
for (const tweet of tweets) {
// Retrieve the last sent tweet
const lastTweet = postedTweets.length ? postedTweets[postedTweets.length - 1] : null;
// Build the tweet query params
const queryParams = { ...(typeof tweet === 'string' ? ({ status: tweet }) : tweet) };
// Reply to an existing tweet if needed
const inReplyToId = lastTweet ? lastTweet.id_str : queryParams.in_reply_to_status_id;
const status = queryParams.status;
if (inReplyToId) {
postedTweets.push(await this.reply(status, inReplyToId, queryParams));
}
else {
postedTweets.push(await this.tweet(status, queryParams));
}
}
return postedTweets;
}
/**
* Reply to an existing tweet. Shortcut to `.tweet` with tweaked parameters.
* https://developer.x.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-statuses-update
*/
reply(status, in_reply_to_status_id, payload = {}) {
return this.tweet(status, {
auto_populate_reply_metadata: true,
in_reply_to_status_id,
...payload,
});
}
/**
* Delete an existing tweet belonging to you.
* https://developer.x.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-statuses-destroy-id
*/
deleteTweet(tweetId) {
return this.post('statuses/destroy/:id.json', { tweet_mode: 'extended' }, { params: { id: tweetId } });
}
/* User API */
/**
* Report the specified user as a spam account to Twitter.
* Additionally, optionally performs the equivalent of POST blocks/create on behalf of the authenticated user.
* https://developer.x.com/en/docs/twitter-api/v1/accounts-and-users/mute-block-report-users/api-reference/post-users-report_spam
*/
reportUserAsSpam(options) {
return this.post('users/report_spam.json', { tweet_mode: 'extended', ...options });
}
/**
* Turn on/off Retweets and device notifications from the specified user.
* https://developer.x.com/en/docs/twitter-api/v1/accounts-and-users/follow-search-get-users/api-reference/post-friendships-update
*/
updateFriendship(options) {
return this.post('friendships/update.json', options);
}
/**
* Follow the specified user.
* https://developer.x.com/en/docs/twitter-api/v1/accounts-and-users/follow-search-get-users/api-reference/post-friendships-create
*/
createFriendship(options) {
return this.post('friendships/create.json', options);
}
/**
* Unfollow the specified user.
* https://developer.x.com/en/docs/twitter-api/v1/accounts-and-users/follow-search-get-users/api-reference/post-friendships-destroy
*/
destroyFriendship(options) {
return this.post('friendships/destroy.json', options);
}
/* Account API */
/**
* Update current account settings for authenticating user.
* https://developer.x.com/en/docs/twitter-api/v1/accounts-and-users/manage-account-settings/api-reference/get-account-settings
*/
updateAccountSettings(options) {
return this.post('account/settings.json', options);
}
/**
* Sets some values that users are able to set under the "Account" tab of their settings page.
* https://developer.x.com/en/docs/twitter-api/v1/accounts-and-users/manage-account-settings/api-reference/post-account-update_profile
*/
updateAccountProfile(options) {
return this.post('account/update_profile.json', options);
}
/**
* Uploads a profile banner on behalf of the authenticating user.
* https://developer.x.com/en/docs/twitter-api/v1/accounts-and-users/manage-account-settings/api-reference/post-account-update_profile_banner
*/
async updateAccountProfileBanner(file, options = {}) {
const queryParams = {
banner: await readFileIntoBuffer(file),
...options,
};
return this.post('account/update_profile_banner.json', queryParams, { forceBodyMode: 'form-data' });
}
/**
* Updates the authenticating user's profile image.
* https://developer.x.com/en/docs/twitter-api/v1/accounts-and-users/manage-account-settings/api-reference/post-account-update_profile_image
*/
async updateAccountProfileImage(file, options = {}) {
const queryParams = {
tweet_mode: 'extended',
image: await readFileIntoBuffer(file),
...options,
};
return this.post('account/update_profile_image.json', queryParams, { forceBodyMode: 'form-data' });
}
/**
* Removes the uploaded profile banner for the authenticating user.
* https://developer.x.com/en/docs/twitter-api/v1/accounts-and-users/manage-account-settings/api-reference/post-account-remove_profile_banner
*/
removeAccountProfileBanner() {
return this.post('account/remove_profile_banner.json');
}
/* Lists */
/**
* Creates a new list for the authenticated user.
* https://developer.x.com/en/docs/twitter-api/v1/accounts-and-users/create-manage-lists/api-reference/post-lists-create
*/
createList(options) {
return this.post('lists/create.json', { tweet_mode: 'extended', ...options });
}
/**
* Updates the specified list. The authenticated user must own the list to be able to update it.
* https://developer.x.com/en/docs/twitter-api/v1/accounts-and-users/create-manage-lists/api-reference/post-lists-update
*/
updateList(options) {
return this.post('lists/update.json', { tweet_mode: 'extended', ...options });
}
/**
* Deletes the specified list. The authenticated user must own the list to be able to destroy it.
* https://developer.x.com/en/docs/twitter-api/v1/accounts-and-users/create-manage-lists/api-reference/post-lists-destroy
*/
removeList(options) {
return this.post('lists/destroy.json', { tweet_mode: 'extended', ...options });
}
/**
* Adds multiple members to a list, by specifying a comma-separated list of member ids or screen names.
* If you add a single `user_id` or `screen_name`, it will target `lists/members/create.json`, otherwise
* it will target `lists/members/create_all.json`.
* https://developer.x.com/en/docs/twitter-api/v1/accounts-and-users/create-manage-lists/api-reference/post-lists-members-create_all
*/
addListMembers(options) {
const hasMultiple = (options.user_id && hasMultipleItems(options.user_id)) || (options.screen_name && hasMultipleItems(options.screen_name));
const endpoint = hasMultiple ? 'lists/members/create_all.json' : 'lists/members/create.json';
return this.post(endpoint, options);
}
/**
* Removes multiple members to a list, by specifying a comma-separated list of member ids or screen names.
* If you add a single `user_id` or `screen_name`, it will target `lists/members/destroy.json`, otherwise
* it will target `lists/members/destroy_all.json`.
* https://developer.x.com/en/docs/twitter-api/v1/accounts-and-users/create-manage-lists/api-reference/post-lists-members-destroy_all
*/
removeListMembers(options) {
const hasMultiple = (options.user_id && hasMultipleItems(options.user_id)) || (options.screen_name && hasMultipleItems(options.screen_name));
const endpoint = hasMultiple ? 'lists/members/destroy_all.json' : 'lists/members/destroy.json';
return this.post(endpoint, options);
}
/**
* Subscribes the authenticated user to the specified list.
* https://developer.x.com/en/docs/twitter-api/v1/accounts-and-users/create-manage-lists/api-reference/post-lists-subscribers-create
*/
subscribeToList(options) {
return this.post('lists/subscribers/create.json', { tweet_mode: 'extended', ...options });
}
/**
* Unsubscribes the authenticated user of the specified list.
* https://developer.x.com/en/docs/twitter-api/v1/accounts-and-users/create-manage-lists/api-reference/post-lists-subscribers-destroy
*/
unsubscribeOfList(options) {
return this.post('lists/subscribers/destroy.json', { tweet_mode: 'extended', ...options });
}
/* Media upload API */
/**
* This endpoint can be used to provide additional information about the uploaded media_id.
* This feature is currently only supported for images and GIFs.
* https://developer.x.com/en/docs/twitter-api/v1/media/upload-media/api-reference/post-media-metadata-create
*/
createMediaMetadata(mediaId, metadata) {
return this.post('media/metadata/create.json', { media_id: mediaId, ...metadata }, { prefix: API_V1_1_UPLOAD_PREFIX, forceBodyMode: 'json' });
}
/**
* Use this endpoint to associate uploaded subtitles to an uploaded video. You can associate subtitles to video before or after Tweeting.
* **To obtain subtitle media ID, you must upload each subtitle file separately using `.uploadMedia()` method.**
*
* https://developer.x.com/en/docs/twitter-api/v1/media/upload-media/api-reference/post-media-subtitles-create
*/
createMediaSubtitles(mediaId, subtitles) {
return this.post('media/subtitles/create.json', { media_id: mediaId, media_category: 'TweetVideo', subtitle_info: { subtitles } }, { prefix: API_V1_1_UPLOAD_PREFIX, forceBodyMode: 'json' });
}
/**
* Use this endpoint to dissociate subtitles from a video and delete the subtitles. You can dissociate subtitles from a video before or after Tweeting.
* https://developer.x.com/en/docs/twitter-api/v1/media/upload-media/api-reference/post-media-subtitles-delete
*/
deleteMediaSubtitles(mediaId, ...languages) {
return this.post('media/subtitles/delete.json', {
media_id: mediaId,
media_category: 'TweetVideo',
subtitle_info: { subtitles: languages.map(lang => ({ language_code: lang })) },
}, { prefix: API_V1_1_UPLOAD_PREFIX, forceBodyMode: 'json' });
}
async uploadMedia(file, options = {}, returnFullMediaData = false) {
var _a;
const chunkLength = (_a = options.chunkLength) !== null && _a !== void 0 ? _a : (1024 * 1024);
const { fileHandle, mediaCategory, fileSize, mimeType } = await this.getUploadMediaRequirements(file, options);
// Get the file handle (if not buffer)
try {
// Finally! We can send INIT message.
const mediaData = await this.post(UPLOAD_ENDPOINT, {
command: 'INIT',
total_bytes: fileSize,
media_type: mimeType,
media_category: mediaCategory,
additional_owners: options.additionalOwners,
shared: options.shared ? true : undefined,
}, { prefix: API_V1_1_UPLOAD_PREFIX });
// Upload the media chunk by chunk
await this.mediaChunkedUpload(fileHandle, chunkLength, mediaData.media_id_string, options.maxConcurrentUploads);
// Finalize media
const fullMediaData = await this.post(UPLOAD_ENDPOINT, {
command: 'FINALIZE',
media_id: mediaData.media_id_string,
}, { prefix: API_V1_1_UPLOAD_PREFIX });
if (fullMediaData.processing_info && fullMediaData.processing_info.state !== 'succeeded') {
// Must wait if video is still computed
await this.awaitForMediaProcessingCompletion(fullMediaData);
}
// Video is ready, return media_id
if (returnFullMediaData) {
return fullMediaData;
}
else {
return fullMediaData.media_id_string;
}
}
finally {
// Close file if any
if (typeof file === 'number') {
// eslint-disable-next-line @typescript-eslint/no-empty-function
fs.close(file, () => { });
}
else if (typeof fileHandle === 'object' && !(fileHandle instanceof Buffer)) {
fileHandle.close();
}
}
}
async awaitForMediaProcessingCompletion(fullMediaData) {
var _a;
// eslint-disable-next-line no-constant-condition
while (true) {
fullMediaData = await this.mediaInfo(fullMediaData.media_id_string);
const { processing_info } = fullMediaData;
if (!processing_info || processing_info.state === 'succeeded') {
// Ok, completed!
return;
}
if ((_a = processing_info.error) === null || _a === void 0 ? void 0 : _a.code) {
const { name, message } = processing_info.error;
throw new Error(`Failed to process media: ${name} - ${message}.`);
}
if (processing_info.state === 'failed') {
// No error data
throw new Error('Failed to process the media.');
}
if (processing_info.check_after_secs) {
// Await for given seconds
await sleepSecs(processing_info.check_after_secs);
}
else {
// No info; Await for 5 seconds
await sleepSecs(5);
}
}
}
async getUploadMediaRequirements(file, { mimeType, type, target, longVideo } = {}) {
// Get the file handle (if not buffer)
let fileHandle;
try {
fileHandle = await getFileHandle(file);
// Get the mimetype
const realMimeType = getMimeType(file, type, mimeType);
// Get the media category
let mediaCategory;
// If explicit longmp4 OR explicit MIME type and not DM target
if (realMimeType === EUploadMimeType.Mp4 && ((!mimeType && !type && target !== 'dm') || longVideo)) {
mediaCategory = 'amplify_video';
}
else {
mediaCategory = getMediaCategoryByMime(realMimeType, target !== null && target !== void 0 ? target : 'tweet');
}
return {
fileHandle,
mediaCategory,
fileSize: await getFileSizeFromFileHandle(fileHandle),
mimeType: realMimeType,
};
}
catch (e) {
// Close file if any
if (typeof file === 'number') {
// eslint-disable-next-line @typescript-eslint/no-empty-function
fs.close(file, () => { });
}
else if (typeof fileHandle === 'object' && !(fileHandle instanceof Buffer)) {
fileHandle.close();
}
throw e;
}
}
async mediaChunkedUpload(fileHandle, chunkLength, mediaId, maxConcurrentUploads = 3) {
// Send chunk by chunk
let chunkIndex = 0;
if (maxConcurrentUploads < 1) {
throw new RangeError('Bad maxConcurrentUploads parameter.');
}
// Creating a buffer for doing file stuff (if we don't have one)
const buffer = fileHandle instanceof Buffer ? undefined : Buffer.alloc(chunkLength);
// Sliced/filled buffer returned for each part
let readBuffer;
// Needed to know when we should stop reading the file
let nread;
// Needed to use the buffer object (file handles always "remembers" file position)
let offset = 0;
[readBuffer, nread] = await readNextPartOf(fileHandle, chunkLength, offset, buffer);
offset += nread;
// Handle max concurrent uploads
const currentUploads = new Set();
// Read buffer until file is completely read
while (nread) {
const mediaBufferPart = readBuffer.slice(0, nread);
// Sent part if part has something inside
if (mediaBufferPart.length) {
const request = this.post(UPLOAD_ENDPOINT, {
command: 'APPEND',
media_id: mediaId,
segment_index: chunkIndex,
media: mediaBufferPart,
}, { prefix: API_V1_1_UPLOAD_PREFIX });
currentUploads.add(request);
request.then(() => {
currentUploads.delete(request);
});
chunkIndex++;
}
if (currentUploads.size >= maxConcurrentUploads) {
// Await for first promise to be finished
await Promise.race(currentUploads);
}
[readBuffer, nread] = await readNextPartOf(fileHandle, chunkLength, offset, buffer);
offset += nread;
}
await Promise.all([...currentUploads]);
}
}