require 'twitter' require 'tweetstream' module Safubot class KnownUser key :twitter, Hash, :default => nil # Dump of twitter user data. class << self # Retrieve or construct a KnownUser by the given Twitter screen name. # @param name_or_id The Twitter screen_name (String) or id (Integer). def by_twitter(name_or_id) user = if name_or_id.is_a? String KnownUser.where('twitter.screen_name' => name_or_id).first else KnownUser.where('twitter.id' => name_or_id).first end if user.nil? details = ::Twitter.user(name_or_id) # This second lookup is necessary as the screen_name that comes back from # Twitter.user might not be the same as the one we were originally provided # with. user = KnownUser.where('twitter.screen_name' => details.screen_name).first || KnownUser.create(:twitter => details.attrs, :name => details.screen_name) end user end end # Plucky query with Tweets scoped to this user's twitter id. def tweets Twitter::Tweet.where('raw.user.id' => self.twitter['id']) end end class Request # Extend the Request class with Twitter-related source_type scoping. scope :tweet, :source_type => "Safubot::Twitter::Tweet" scope :dm, :source_type => "Safubot::Twitter::DirectMessage" end # XMPP-specific functionality. module Twitter # Tweet is both a Request source and general-purpose tweet storage. # Mentions are automatically made into Requests, but not timeline tweets. class Tweet include MongoMapper::Document safe key :raw, Hash # Hash from the JSON returned by the Twitter API. set_collection_name 'twitter.tweet' one :request, :as => :source, :class_name => "Safubot::Request" class << self ## # Quickly look up a Tweet. # @param id The Twitter id. def [](id) Tweet.where('raw.id' => id).first end ## # Find or create a Tweet. # @param status An appropriate ::Twitter or ::TweetStream object. def from(status) Tweet.where('raw.id' => status['id']||status.id).first || Tweet.create(:raw => status['attrs']||status) end end # The plain text. def text self.raw['text'] end # Plain text without the leading mentions. def targetless_text text.gsub(/^(@\w+ )+/, '') end # Retrieve a substring containing the leading mentions. def header_mentions m = text.match(/(@\w+ )+/) m.nil? ? [] : m[0].split end # Returns the original tweet of which this is a RT if available. def original_tweet self.raw['retweeted_status'] && Tweet.from(self.raw['retweeted_status']) end # Finds or creates a KnownUser connected to this Tweet. def user KnownUser.by_twitter(self.raw['user']['id']) end # Finds the user's screen_name. def username user.twitter['screen_name'] end # Returns a Time object specifying the time of publication. def posted_at Time.parse(self.raw['created_at']) end # Finds or creates a Request sourced from this Tweet. def make_request self.request || Request.create(:user => user, :source => self, :text => self.targetless_text) end end # DirectMessage is a Request source. # All DirectMessages are made into Requests as they are received. class DirectMessage include MongoMapper::Document safe set_collection_name 'twitter.dm' key :raw, Hash one :request, :as => :source, :class_name => "Safubot::Request" class << self ## # Find or create a DirectMessage. # @param message An appropriate ::Twitter or ::TweetStream object. def from(message) DirectMessage.where('raw.id' => message['id']||message.id).first || DirectMessage.create(:raw => message['attrs']||message) end end # The plain text. def text self.raw['text'] end # Finds the user's screen_name. def username self.raw['sender']['screen_name'] end # Finds or creates a KnownUser connected to this DirectMessage. def user KnownUser.by_twitter(username) end # Finds or creates a Request sourced from this DirectMessage. def make_request self.request || Request.create(:user => user, :source => self, :text => self.text) end end # A Twitter::Bot instance provides a Safubot::Bot with Twitter-specific processing. class Bot include Evented attr_reader :username, :client, :opts, :stream, :pid ## # Sends a Twitter-sourced Response to the appropriate target. # @param resp Response to send. def send(resp) source = resp.request.source if source.is_a?(DirectMessage) @client.direct_message_create(source.raw['sender']['screen_name'], resp.text) elsif source.is_a?(Tweet) reply source, resp.text else raise NotImplementedError, "Don't know how to send response to a #{req.source.class}!" end end ## # Emit a request event unless the request is already processed. # @param req Request to handle. def handle_request(req) emit(:request, req) unless req.nil? || req.processed end ## # Stores a DM and creates a matching Request as needed. # @param message A raw JSON-derived direct message. def handle_message(message) return if message.sender.screen_name == @username handle_request(DirectMessage.from(message).make_request) end ## # Stores a tweet. If this tweet is directed at us, create a matching Request. # Otherwise, emit a :timeline event. # @param status A raw JSON-derived tweet. def handle_tweet(status) return if status.user.screen_name == @username if status.text.match(/@#{@username}/i) handle_request(Tweet.from(status).make_request) else emit(:timeline, Tweet.from(status)) end end # Constructs the appropriate series of mentions for a reply to this tweet. def reply_header(tweet) (["@#{tweet.username}"] + (tweet.header_mentions - ["@#{@username}"])).join end # Replies to a tweet using the appropriate mentions. # @param tweet A Tweet object to respond to. # @param text The response text. def reply(tweet, text) @client.update("#{reply_header(tweet)} #{text}", :in_reply_to_status_id => tweet.raw['id']) end # Pulls DMs and mentions using the AJAX API. Used in tandem with the streaming API # to ensure we don't miss too much while we're offline. def pull begin @client.direct_messages.each do |message| handle_message(message) end @client.mentions.each do |mention| handle_tweet(mention) end rescue ::Twitter::Error::ServiceUnavailable Log.error "Twitter: Couldn't pull tweets due to temporary service unavailability." rescue Exception => e Log.error "Twitter: Unhandled error: #{error_report(e)}" end end # Initializes the TweetStream client. def init_stream @stream = TweetStream::Client.new(@opts) @stream.on_direct_message do |message| handle_message(message) end @stream.on_error do |err| if err.match(/invalid status code: 401/) Log.error "TweetStream authentication failure!" else Log.error "Unhandled TweetStream error: #{error_report($!)}" end end @stream.on_inited do Log.info("TweetStream client is online at @#{@username} :3") emit(:ready) end end # Runs the TweetStream client. def run_stream begin @stream.userstream do |status| handle_tweet(status) end rescue Exception => e if e.is_a?(Interrupt) || e.is_a?(SignalException) stop else Log.error "TweetStream client exited unexpectedly: #{error_report(e)}" Log.error "Restarting TweetStream client in 5 seconds." sleep 5; init_stream; run_stream end end end # Starts our TweetStream client running in a new process. def run @pid = Process.fork do Signal.trap("TERM") { stop } init_stream run_stream end end # Shut down the TweetStream client. def stop if @stream @stream.stop @stream = nil Log.info("TweetStream client shutdown complete.") else Process.kill("TERM", @pid) if @pid end end ## # @param options These are passed straight through to ::TweetStream and # ::Twitter, but the :username is ours and important. def initialize(options={}) defaults = { :username => nil, :consumer_key => nil, :consumer_secret => nil, :oauth_token => nil, :oauth_token_secret => nil, :auth_method => :oauth } @opts = defaults.merge(options) DirectMessage.ensure_index('raw.id', :unique => true) @username = @opts[:username] @client = Object::Twitter::Client.new(@opts) end end end end