A fix for direct @username tweets with image attachments using tmhOauth

Direct link to code download: https://github.com/tkivari/tmhOAuth/

Being one of the first reliable OAuth libraries for Twitter to support image attachments to tweets has also made thmOauth one of the most popular.

That being said, there is an ongoing issue with the tmhOauth library that has popped up for me from time to time, which other users have also encountered, and although it isn’t a deal-breaking bug, diagnosing the problem is usually a bit of a head-scratcher, and left unresolved, it can be quite annoying for your app’s users.

The issue arises when you use tmhOauth to try to tweet or reply directly to an @username with a tweet that contains also contains an image attachment.

The following code comes straight out of the tmhOauth’s image upload example, and it works perfectly:

$code = $tmhOAuth->request(
  'POST',
  'https://upload.twitter.com/1/statuses/update_with_media.json',
  array(
    'media[]'  => "@mypic.jpg;type=image/jpeg;filename=mypic.jpg",
    'status'   => 'Picture time',
  ),
  true, // use auth
  true  // multipart
);

However, changing the status field in the array as follows will break the library and the tweet will fail:

$code = $tmhOAuth->request(
  'POST',
  'https://upload.twitter.com/1/statuses/update_with_media.json',
  array(
    'media[]'  => "@mypic.jpg;type=image/jpeg;filename=mypic.jpg",
    'status'   => '@themattharris is it still picture time?',
  ),
  true, // use auth
  true  // multipart
);

The problem is caused by the way PHP’s libCurl interprets the “@” symbol at the beginning of a POST field’s value in a multipart form.  When a field’s value is prefixed with “@”, libCurl assumes the field is referencing a file whose contents should be posted, rather than the actual supplied contents of the field.  This is as much a libCurl bug as it is a tmhOauth bug, but it is far easier to patch tmhOauth, so that is what we’re going to do.

The first thing we’ll need to learn is what a posted form looks like, so we can build our form headers manually, rather than allowing PHP/Curl to interpret the values itself.    According to the W3C, a typical form submission might look something like this:

   Content-Type: multipart/form-data; boundary=AaB03x
   Content-Length: 23423423

   --AaB03x
   Content-Disposition: form-data; name="status"

   Picture time!
   --AaB03x
   Content-Disposition: form-data; name="media[]"; filename="mypic.jpg"
   Content-Type: image/jpeg

   ... contents of mypic.jpg ...
   --AaB03x--

Let’s assume that the boundary=AaB03x in the above example is a randomly generated string, and could potentially be anything. It’s used as a form field delimiter by the user agent (ie – your browser) to build the form data to submit, so that the receiving server can understand where one field ends and another begins. So, the first thing we need to add to tmhOauth is a randomly generated delimiter that we can use throughout the library to denote the beginning and ending of our form and field data. Add the variable to the tmhOauth class declaration:

class tmhOAuth {
  const VERSION = '0.7.0';

  var $response = array();

  var $delim = "";

Notice that we are initializing the variable with an empty string. In the class constructor we will add code to randomly generate the form delimiter using PHP’s uniqid() function:

$this->delim = "-------------------" . uniqid();

Now we’re ready to start building some multipart form data!  The first thing we should add are some functions to return the form fields in the format we want. Let’s add some functions to handle text fields and image attachments first:

  private function mediaField($key,$file,$filename,$type) {
      $field = "--" . $this->delim . "\r\n";
      $field .= 'Content-Disposition: form-data; name="' . $key . '"; filename="'.$filename.'"' . "\r\n";
      $field .= 'Content-Type: ' . $type . "\r\n";
      $field .= "\r\n";
      $field .= file_get_contents($file) . "\r\n";

      return $field;
  }  

  private function textField($key,$param) {
    $field = "--" . $this->delim . "\r\n";
    $field .= 'Content-Disposition: form-data; name="' . $key . '"';
    $field .= "\r\n\r\n";
    $field .= $param . "\r\n";

    return $field;
  }

These functions simply generate a text block to define a POST field for the form. The mediaField() function generates a POST field for a file attachment by specifying a filename and content-type, and including the file’s actual contents, while the textField() function generates a simple text field. Next, we’ll define a function that uses these two functions to build the full form data for the tweet with media:

  private function buildPostFields() {

      $formData = "";

      foreach ($this->request_params as $key => $param) {

          if (substr($param,0,1) == "@") {
              @list($file,$type,$filename) = $this->getMediaAttribs($param);
              if(!empty($file) && file_exists($file)) {
                  $formData .= $this->mediaField($key,$file,$filename,$type);
              } else { // It's not a file - it's a twitter username!
                  $formData .= $this->textField($key,$param); // just a plain text field
              }
          } else {
              $formData .= $this->textField($key,$param); // just a plain text field
          }
      }

      $formData .= "--" . $this->delim . "--\r\n\r\n"; // final post header delimiter

      return $formData;
  }

The difference between the form data built by the buildPostFields() function and libCurl is that this function checks to make sure that the file exists before trying to insert its contents into the form. If the file does not exist, that is a good indicator that the “@” at the beginning of the string refers to a Twitter username rather than a file to attach to the tweet, so we need to treat is as plain text. There is just one more function that we need to define, and that is the getMediaAttribs() function called above. This function simply checks the content type of the image attachment and ensures that all of the required file data is available before we try to tweet the picture:

  private function getMediaAttribs($param) {

      // if there are already semicolons in the string, the user has specified all of the required fields in the request.
      // No need to continue...
      if (strpos($param,";")) {
          // strip the "@", we're not gonna need it where we're going
          if (substr($param,0,1) == "@") $param = substr($param,1,strlen($param));
          return explode(";",$param);
      }

      $file = substr($param,1,strlen($param));

      // if the file doesn't exist, there's not much we can do about it, so just forget it - the twitter post is going to fail anyway
      if (!file_exists($file))
           return array(null,null,null);

      // we're going to have to get the mime type manually
      // we'll also have to set $filename to be the same as the last part of the $file string

      $fileinfo = getimagesize($file);
      $filetype = $fileinfo['mime'];

      $filename = substr($file,strrpos($file,DIRECTORY_SEPARATOR),strlen($file));

      return array($file,$filetype,$filename);
  }

And that’s it! All that’s left is integrating this code into the tmhOauth project, and you can see how that’s done by checking out the code here: https://github.com/tkivari/tmhOAuth.

I hope this fix will help someone out there avoid the headache of trying to figure out why their tweets won’t work. If you have any questions about using this fix, please feel free to contact me or reply to this post.

See you next time!

Leave a Reply

Your email address will not be published. Required fields are marked *