Tweet later with Delayed Job

One of my favorite uses of delayed_job is scheduling jobs to be run in the future. We’ve done this on a few client projects, mostly with emails. For example, we needed to send appointment reminder emails 15 minutes before an appointment began. A relatively simple exercise. On creation of the appointment, schedule a job to be run at 15 minutes before the appointment begins.

What if you wanted to have a message scheduled to be tweeted 10 minutes before a blog was published? Let’s walk through that scenario.

Twitter Integration

First and foremost, register an application with Twitter to obtain your OAuth credentials. Make a note of your consumer_key and consumer_secret and add them to an initializer.

# config/initializers/twitter.rb

Twitter.configure do |config|
  config.consumer_key = "CONSUMER_KEY"
  config.consumer_secret = "CONSUMER_SECRET"
end

Next, add the Twitter and OAuth gems to our Gemfile so we can interact with the Twitter API. Run bundle to install the gems.

gem 'twitter'
gem 'oauth'

Then, emulate what’s happening in the example twitter-app. You’ll find that they add a few methods to the application_controller.rb that sets up the OAuth Consumer and a Twitter Client that can be used on subsequent requests.

Take a look at the sessions_controller.rb to see how they handle the connect/destroy workflow. On the callback, you’ll want to save the access_token.token, access_token.secret, and the access_token.params[:screen_name] so we can use those to send tweets with later.

Delayed Job Integration

Let’s move on to the fun stuff! Not that OAuth isn’t fun… who doesn’t love a good dance?

Not every blog post needs to have a scheduled tweet. If you haven’t already, you’ll probably want to add a few fields to your model; add something like schedule_tweet, tweet_content, tweet_at, and tweeted. This’ll help define what needs to get tweeted, when, and if it successfully tweeted.

After you’ve added your fields, install delayed_job by following the directions in the readme.

You’ll also want to add a few fields to your view. Here’s what I came up with:

  Twitter Announcement

  <%= form.check_box :schedule_tweet %> <%= form.label :schedule_tweet, "Tweet this?" %>

  <%= text_field_tag :minutes %> <%= label_tag :minutes, "minutes before" %>

  <%= form.text_area :tweet_content %>

I used a text_field_tag for the minutes for flexibility and so we could subtract them from the future published time of the blog post and then set our tweet_at timestamp to the result.

@post.tweet_at = @post.publish_at - params[:minutes].to_i.minutes if @post.schedule_tweet?

The next part is where the Delayed Job magic gets to happen. After the post is saved, schedule the delivery of the tweet.

def deliver_tweet
  client = Twitter::Client.new(:oauth_token => user.twitter_oauth_token, :oauth_token_secret => user.twitter_oauth_secret)
  client.update(tweet_content)
  update_attributes :tweeted => true
end
handle_asynchronously :deliver_tweet, :run_at => Proc.new {|p| p.tweet_at }

handle_asynchronously allows for the same options as you can pass to delay. In addition, though, and here’s where the awesome is, the values can be Proc objects allowing call time evaluation of the value.

So, in this example, we schedule a job for the future to be run at our tweet_at time (X minutes before the publish_at time). When the job is run, the method checks to make sure the post has not already been tweeted and time has passed before it instantiates a Twitter client with the users’ credentials, publishes the tweet, and updates the tweeted attribute.

Changing the time

What happens if you come back later and want to change the scheduled delivery to be farther in the future? You might be tempted to go into Delayed Job and modify the job, or delete it and add a new on. We recommend a much simpler pattern: make the jobs smart.

We let it add a new job on every save, then wrap the job’s logic in a conditional. If the conditions are still the same as when it was scheduled, go ahead and do it. If not, we assume this job is unneeded and just let it complete without doing anything. The other scheduled job will handle it.

def deliver_tweet
  if !tweeted? && Time.zone.now >= tweet_at
    client = Twitter::Client.new(:oauth_token => user.twitter_oauth_token, :oauth_token_secret => user.twitter_oauth_secret)
    client.update(tweet_content)
    update_attributes :tweeted => true
  end
end
handle_asynchronously :deliver_tweet, :run_at => Proc.new {|p| p.tweet_at }

Testing with Cucumber

Let me provide you with a quick scenario on how I’d test this with Cucumber using FakeWeb and Timecop. Factory Girl step definitions handle most of this scenario; we only need a few custom steps.

Scenario: Can manage blog posts
  Given the following user exists:
    | email             | password |
    | [email protected] | testing  |
  And I sign in with "[email protected]/testing"
  And I am tracking twitter traffic

  When I follow "New Post"
  And I fill in "Title" with "My First Pet"
  And I fill in "Content" with "My first pet was a cat named Snowball…"
  And I fill in "Publish At" with "6:00 PM"
  And I check "Tweet this?"
  And I fill in "minutes before" with "10"
  And I fill in "Tweet content" with "Get ready to learn about my first pet!"
  And I press "Save"

  Then I should see "My First Pet"
  And "My First Pet" should have a scheduled tweet for "5:50 PM"

  When the time is "5:51 PM"
  Then a tweet should be posted with "Get ready to learn about my first pet!"

Register the twitter status update uri with FakeWeb.

# features/step_definitions/common_steps.rb

Given 'I am tracking twitter traffic' do
  FakeWeb.register_uri(:post, 'https://api.twitter.com/1/statuses/update.json', :status => ["200", "OK"])
end

Ensure that after the post is created, the tweet_at is scheduled close to the time we think it should be.

# features/step_definitions/post_steps.rb

Then /^"([^"]*)" should have a scheduled tweet for "([^"]*)"$/ do |title, time|
  post = Post.where(:title => title).first

  post.tweet_at.should be_close(Time.zone.parse(time), 1.minute)
end

Using Timecop, travel through time and run the delayed jobs.

# features/step_definitions/common_steps.rb

When /^the time is "([^"]*)"$/ do |time|
  Timecop.travel(Time.zone.parse(time))
  Given "delayed jobs are run"
end

Given 'delayed jobs are run' do
  success, fail = Delayed::Worker.new.work_off
  unless fail.zero?
    puts Delayed::Job.all.reject{|j| j.last_error.blank? }.to_yaml
    raise "#{fail}/#{success} delayed jobs failed."
  end
end

Then, we have one last step to make sure that the tweet was delivered and was caught by FakeWeb.

# features/step_definitions/post_steps.rb

Then /^a tweet should be posted with "([^"]*)"$/ do |tweet|
  URI.decode(FakeWeb.last_request.body).gsub('+', ' ').should include(tweet)
end

Whew! We did it. Now go schedule whatever you need.

bryckbost@gmail.com

Comments

  1. blake41@gmail.com
    blake johnson
    June 12, 2011 at 0:27 AM

    Hey Brian,

    I’m using delayed job in a similar fashion to you.  I set each jobs run_at field to be an hour after the last job.  However, when I run rake jobs:worker it immediately processes all jobs rather than waiting to run at the run_at time.  I’m running in development, do you have any idea if that would matter?  Or have you had any similar problem?

  2. aditya15417@hotmail.com
    Aditya H
    August 09, 2011 at 5:14 AM

    Would be nice to have a full code demonstrating this somewhere

  3. August 11, 2011 at 13:03 PM

    @Aditya: I’ll try and build out an example app this weekend.

  4. October 16, 2011 at 13:17 PM

    @blake, my guess is that your run_at time is being set to UTC and it is in the past. I know your comment is old, but for the sake of anyone else who may come across this here is a post from Greg Benedict with a solution http://www.gregbenedict.com/2009/08/19/is-delayed-job-run_at-datetime-giving-you-fits/