From f66f8d6a52b5186ac315e3cb2d9133991a1859e2 Mon Sep 17 00:00:00 2001 From: Sven Fuchs Date: Sun, 28 Mar 2010 19:49:04 +0200 Subject: [PATCH] first commit --- lib/command.rb | 18 +++++++++ lib/command/base.rb | 14 +++++++ lib/command/message.rb | 45 +++++++++++++++++++++ lib/command/poller.rb | 3 ++ lib/command/poller/twitter.rb | 37 ++++++++++++++++++ test/message_test.rb | 8 ++++ test/poller_test.rb | 73 +++++++++++++++++++++++++++++++++++ test/test_declarative.rb | 26 +++++++++++++ test/test_helper.rb | 46 ++++++++++++++++++++++ 9 files changed, 270 insertions(+) create mode 100644 lib/command.rb create mode 100644 lib/command/base.rb create mode 100644 lib/command/message.rb create mode 100644 lib/command/poller.rb create mode 100644 lib/command/poller/twitter.rb create mode 100644 test/message_test.rb create mode 100644 test/poller_test.rb create mode 100644 test/test_declarative.rb create mode 100644 test/test_helper.rb diff --git a/lib/command.rb b/lib/command.rb new file mode 100644 index 0000000..65e3cf1 --- /dev/null +++ b/lib/command.rb @@ -0,0 +1,18 @@ +require 'active_support/inflector' +require 'active_support/core_ext/module/delegation' + +class Command + autoload :Base, 'command/base' + autoload :Message, 'command/message' + autoload :Poller, 'command/poller' + + class << self + def queue(receiver, message) + Message.if_unprocessed(message) do |message| + message.commands.each { |type| new(type, message).dispatch } # we don't have a queue yet, so just dispatch + end + end + end + + include Base +end \ No newline at end of file diff --git a/lib/command/base.rb b/lib/command/base.rb new file mode 100644 index 0000000..3d8f443 --- /dev/null +++ b/lib/command/base.rb @@ -0,0 +1,14 @@ +module Command::Base + attr_reader :command, :message, :arguments + delegate :receiver, :source, :to => :message + + def initialize(command, message) + @command = command + @message = message + @arguments = message.arguments + end + + def dispatch + send(command) + end +end \ No newline at end of file diff --git a/lib/command/message.rb b/lib/command/message.rb new file mode 100644 index 0000000..38c6b98 --- /dev/null +++ b/lib/command/message.rb @@ -0,0 +1,45 @@ +class Command::Message + class << self + def if_unprocessed(data) + return if find_by_message_id(data[:message_id]) + yield create(data.merge(:received_at => Time.now)) + end + + def max_message_id # TODO how the fuck would i not jump through all of these hoops + database = CouchPotato.database.instance_variable_get(:@database) + spec = view_max_message_id + query = CouchPotato::View::ViewQuery.new(database, spec.design_document, spec.view_name, spec.map_function, spec.reduce_function) + result = query.query_view!(spec.view_parameters)['rows'].first + result['value'] if result + end + end + + COMMAND_PATTERN = /(?:!|#)([\w]+)/ + ARGUMENT_PATTERN = /[\S]+:[\S]+/ + + include SimplyStored::Couch + + property :message_id + property :text + property :sender + property :receiver + property :source + property :received_at + + view :by_message_id, :key => :message_id + view :view_max_message_id, :type => :custom, + :map => "function(doc) { if(doc.ruby_class == 'Command::Message') { emit(null, doc['message_id']); } }", + :reduce => "function(key, values, rereduce) { return Math.max.apply(Math, values); }" + + def initialize(data = {}) + data.each { |key, value| self.send("#{key}=", value) } + end + + def commands + text.scan(COMMAND_PATTERN).flatten + end + + def arguments + text.scan(ARGUMENT_PATTERN) + end +end \ No newline at end of file diff --git a/lib/command/poller.rb b/lib/command/poller.rb new file mode 100644 index 0000000..c7b9119 --- /dev/null +++ b/lib/command/poller.rb @@ -0,0 +1,3 @@ +module Command::Poller + autoload :Twitter, 'command/poller/twitter' +end diff --git a/lib/command/poller/twitter.rb b/lib/command/poller/twitter.rb new file mode 100644 index 0000000..89525b0 --- /dev/null +++ b/lib/command/poller/twitter.rb @@ -0,0 +1,37 @@ +# checks for new replies only once + +require 'twibot' + +class Command::Poller::Twitter < Twibot::Bot + def initialize(type, login, password) + super(Twibot::Config.default << { + :login => login, + :password => password, + :process => Command::Message.max_message_id, + :min_interval => 0, + :max_interval => 0 + }) + + add_handler(type, handler(login)) + end + + def handler(receiver) + Twibot::Handler.new(Command::Message::COMMAND_PATTERN) do |message, *args| + Command.queue(receiver, + :receiver => receiver, + :message_id => message.id, + :sender => message.user.screen_name, + :text => message.text, + :source => 'twitter' + ) + end + end + + def receive_messages + super.tap { @abort = true } # we only poll once + end + + def receive_replies + super.tap { @abort = true } # we only poll once + end +end diff --git a/test/message_test.rb b/test/message_test.rb new file mode 100644 index 0000000..7342336 --- /dev/null +++ b/test/message_test.rb @@ -0,0 +1,8 @@ +require File.expand_path('../test_helper', __FILE__) + +class MessageTwitterTest < Test::Unit::TestCase + test "max_message_id returns the latest message_id" do + %w(12345 12346 12347 12348).each { |id| msg(id).save } + assert_equal 12348, Command::Message.max_message_id + end +end \ No newline at end of file diff --git a/test/poller_test.rb b/test/poller_test.rb new file mode 100644 index 0000000..cf98cb8 --- /dev/null +++ b/test/poller_test.rb @@ -0,0 +1,73 @@ +require File.expand_path('../test_helper', __FILE__) +require 'stringio' + +class PollerTest < Test::Unit::TestCase + def setup + setup_stubs + end + + def process!(from, message, id = '12345') + status = twitter_status(from, message, id) + bot = Command::Poller::Twitter.new(:reply, 'rugb_test', 'password') + bot.handler('rugb_test').dispatch(status) + end + + test "polls from twitter once and handles new replies by queueing commands" do + Command::Message.stubs(:max_message_id).returns(12345) + poller = Command::Poller::Twitter.new(:reply, 'rugb_test', 'password') + + replies = [twitter_status('svenfuchs', '@rugb_test !update')] + poller.twitter.expects(:status).with(:replies, { :since_id => 12345 }).returns(replies) + + message = { :message_id => '12345', :receiver => 'rugb_test', :sender => 'svenfuchs', :text => '@rugb_test !update', :source => 'twitter' } + Command.expects(:queue).with('rugb_test', message) + log = capture_stdout { poller.run! } + + assert_match /imposing as @rugb_test/, log + assert_match /Received 1 reply/, log + end + + test 'updating w/ a me url and a github handle' do + process!('svenfuchs', '!update json:http://tinyurl.com/yc7t8bv github:svenphoox') + identity = Identity.find_by_handle('svenphoox') + + assert_equal 'svenphoox', identity.github['handle'] + assert_equal 'Sven', identity.github['name'] + end + + test 'updating an existing profile' do + process!('svenfuchs', '!create', '12345') + assert Identity.find_by_handle('svenfuchs') + + process!('svenfuchs', '!update json:http://tinyurl.com/yc7t8bv', '12346') + identity = Identity.find_by_handle('svenfuchs') + + assert_equal 'svenfuchs', identity.twitter['handle'] + assert_equal 'Sven Fuchs', identity.twitter['name'] + assert_equal 'svenfuchs', identity.json['irc'] + end + + test 'logs processed messages' do + now = Time.now + Time.stubs(:now).returns(now) + + process!('svenfuchs', '!update') + Identity.find_by_handle('svenfuchs') + message = Command::Message.find_by_message_id('12345') + + assert_equal 'rugb_test', message.receiver + assert_equal 'svenfuchs', message.sender + assert_equal '!update', message.text + assert_equal '12345', message.message_id + assert_equal now.to_s, Time.parse(message.received_at).to_s + end + + test 'does not process an already processed message' do + process!('svenfuchs', '!update') + Identity.find_by_handle('svenfuchs') + + Command.expects(:new).never # TODO + Identity.find_by_handle('svenfuchs') + end + +end \ No newline at end of file diff --git a/test/test_declarative.rb b/test/test_declarative.rb new file mode 100644 index 0000000..f1c14bf --- /dev/null +++ b/test/test_declarative.rb @@ -0,0 +1,26 @@ +module TestMethod + def self.included(base) + base.class_eval do + def self.test(name, &block) + test_name = "test_#{name.gsub(/\s+/,'_')}".to_sym + defined = instance_method(test_name) rescue false + raise "#{test_name} is already defined in #{self}" if defined + if block_given? + define_method(test_name, &block) + else + define_method(test_name) do + flunk "No implementation provided for #{name}" + end + end + end + end + end +end + +class Module + include TestMethod +end + +class Test::Unit::TestCase + include TestMethod +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..a1827d1 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,46 @@ +$: << File.expand_path('../..', __FILE__) +$: << File.expand_path('../../lib', __FILE__) + +require 'test/unit' +require 'rack/test' +require 'mocha' +require 'twibot' +require File.expand_path('../test_declarative', __FILE__) + +require 'command' + +CouchPotato::Config.database_name = "http://localhost:5984/command" + +class Test::Unit::TestCase + def teardown + Command::Message.all.each { |message| message.delete } + end + + def command(type, receiver, sender, text = '', source = 'twitter') + Command.new(type, msg(12345, text, sender, receiver, source)) + end + + def msg(id = 12345, text = 'text', sender = 'sender', receiver = 'receiver', source = 'twitter') + Command::Message.create :message_id => id, + :text => text, + :sender => sender, + :receiver => receiver, + :source => source, + :received_at => Time.now + end + + def twitter_status(from, message, id = '12345') + Twitter::Status.new(:id => id, :user => twitter_sender(from), :text => message, :created_at => Time.now) + end + + def twitter_sender(name) + Twitter::User.new(:screen_name => name) + end + + def capture_stdout + @stdout, $stdout = $stdout, (io = StringIO.new) + yield + $stdout = @stdout + io.string + end +end