Tweeting media using the Twitter API

Illustration of media being uploaded to Twitter

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.