Tweeting media using the Twitter API
As part of a recent side project, I integrated Twitter with an web application, including building a way for users to sign in with Twitter and then send a tweet with media attached.
This use-case is well supported by the Twitter API and is relatively well documented. However, I struggled to find complete documentation for a web client (in my case, React) and server running NodeJS, covering uploading media and sending a tweet with that media attached. It is one of the more complex use-cases in the Twitter API, with multiple API calls required and strict requirements on the data format and size of media.
This article documents my approach to implement the client and server code.
Client implementation
First, here's how I approached preparing media on the client and uploading the relevant information to the server. This approach covers two cases: a) the media is available as a data URL, b) the data is available as a JavaScript File
object (e.g. from a <file />
input).
async function sendTweet(
authToken: AuthToken,
text: string,
mediaAsDataUrl: string
) {
// Prepare your media as a `File`, in this case converting
// it from a data URL
const mediaType: string = mediaAsDataUrl.split(',')[0].split(':')[1].split(';')[0]; // MIME type of the media
const mediaBlob: Blob = getBlobFromDataUrl(mediaAsDataUrl);
const mediaFile: File = new File([mediaBlob], mediaType, mediaBlob);
// If you already have media as a `File`, you can skip to
// this line and read your file
const reader = new FileReader();
reader.readAsArrayBuffer(mediaFile);
reader.onload = () => {
const mediaBuffer = Buffer.from(reader.result);
const media: MediaItem = {
base64EncodedMediaDataUrlContent: mediaBuffer.toString('base64'),
mediaSize: mediaBuffer.byteLength,
mediaType,
};
try {
const result = cloudFunction('tweet', {
authToken,
text,
media,
});
if (result.data.error) {
return onError(result.data.error);
}
return onSuccess(result);
} catch (error){
return onError(error);
}
}
}
Server implementation
On the server there are two phases to handle: 1. Upload the media, 2. Send the tweet with the provided text and media ID pointing to the uploaded media.
function uploadMediaAndSendTweet(
authToken: AuthToken,
text: string,
media: MediaItem
) {
const { base64EncodedMediaDataUrlContent, mediaType, mediaSize } = media;
return uploadMediaChunked(authToken, base64EncodedMediaDataUrlContent, mediaType, mediaSize)
.then((mediaDetails) => sendTweetWithMedia(authToken, text, [mediaDetails.media_id_string]))
.catch((error) => {
console.warn("Failed to tweet media");
console.warn(error);
return { error };
});
};
Let's break down each part of the server code, starting with uploading the chunked media:
function uploadMediaChunked(
authToken: AuthToken,
base64EncodedMediaDataUrlContent: string,
mediaType: string,
mediaSize: number
) {
let mediaId: string;
const mediaCategory: string = mediaType.indexOf("video") > -1 ?
"tweet_video" :
mediaType.indexOf("gif") > -1 ?
"tweet_gif" :
"tweet_image";
// First, we chunk the media data into 1MB max chunks
// (Twitter's limit is 5MB per chunk, and a total of
// 5MB for images, 15MB for GIFs and up to 512MB for
// video files)
const maxChunkSizeBytes = 1 * 1024 * 1024; // 1MB
const chunks: string[] = [];
if (mediaSize <= maxChunkSizeBytes) {
chunks.push(base64EncodedMediaDataUrlContent);
} else {
let remainingSize = mediaSize;
const mediaBuffer = Buffer.from(base64EncodedMediaDataUrlContent, "base64");
while (remainingSize > 0) {
chunks.push(
mediaBuffer.slice(
mediaSize - remainingSize,
mediaSize - remainingSize + maxChunkSizeBytes
).toString("base64")
);
remainingSize = Math.max(0, remainingSize - maxChunkSizeBytes);
}
}
// Next, we initiate an upload using the Twitter API
return twitterPost(
// POST upload.twitter.com/1.1/media/upload.json
authToken,
"media/upload",
{
command: "INIT",
total_bytes: mediaSize,
media_type: mediaType,
media_category: mediaCategory,
}
).then((mediaDetails) => {
mediaId = mediaDetails.media_id_string;
// Then, we upload all of the chunks of the media data
return Promise.all(chunks.map((chunkData, i) => twitterPost(
// POST upload.twitter.com/1.1/media/upload.json
authToken,
"media/upload",
{
command: "APPEND",
media_id: mediaId
media_data: chunkData,
segment_index: i,
}
)));
}).then(() => twitterPost(
authToken,
// POST upload.twitter.com/1.1/media/upload.json
"media/upload",
{
command: "FINALIZE",
media_id: mediaId,
})
).then((result) => {
// We finalise the upload, then return the API response
// which includes the `media_id_str` that will let us
// reference this uploaded media
return result;
});
}
And then the simpler operation of sending this media with some text in a tweet:
function sendTweetWithMedia(
authToken: AuthToken,
tweetText: string,
mediaIds: string[]
) {
// We request to send a tweet with the text provided and
// the media referenced by the provided IDs attached
return twitterPost(
// POST api.twitter.com/1.1/statuses/update.json
authToken,
"statuses/update",
{
status: tweetText,
media_ids: mediaIds.join(","),
}
).then((result) => {
return result;
});
}
Limitations
This approach doesn't support adding alt-text for accessibility. It could be extended to support this by passing an additional parameter from the client to the server and making an additional call to the media/metadata/create
endpoint.
Feedback
If you have feedback on this implementation or you're working on something similar, I'd love to hear from you. You can reply on Twitter below!
Have a lovely day.
© 2023, Graham Macphee.