├── lib ├── twibot │ ├── tweets.rb │ ├── macros.rb │ ├── handlers.rb │ ├── config.rb │ └── bot.rb ├── hash.rb └── twibot.rb ├── test ├── test_twibot.rb ├── test_hash.rb ├── test_helper.rb ├── test_config.rb ├── test_handler.rb └── test_bot.rb ├── .bnsignore ├── Rakefile ├── History.txt ├── twibot.gemspec └── Readme.rdoc /lib/twibot/tweets.rb: -------------------------------------------------------------------------------- 1 | module Twibot 2 | module Tweets 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/test_twibot.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.join(File.dirname(__FILE__), 'test_helper')) unless defined?(Twibot) 2 | -------------------------------------------------------------------------------- /lib/hash.rb: -------------------------------------------------------------------------------- 1 | class Hash 2 | def symbolize_keys! 3 | replace(inject({}) do |hash,(key,value)| 4 | hash[key.to_sym] = value.is_a?(Hash) ? value.symbolize_keys! : value 5 | hash 6 | end) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /.bnsignore: -------------------------------------------------------------------------------- 1 | # The list of files that should be ignored by Mr Bones. 2 | # Lines that start with '#' are comments. 3 | # 4 | # A .gitignore file can be used instead by setting it as the ignore 5 | # file in your Rakefile: 6 | # 7 | # PROJ.ignore_file = '.gitignore' 8 | # 9 | # For a project with a C extension, the following would be a good set of 10 | # exclude patterns (uncomment them if you want to use them): 11 | # *.[oa] 12 | # *~ 13 | announcement.txt 14 | coverage 15 | doc 16 | pkg 17 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Look in the tasks/setup.rb file for the various options that can be 2 | # configured in this Rakefile. The .rake files in the tasks directory 3 | # are where the options are used. 4 | 5 | begin 6 | require 'bones' 7 | Bones.setup 8 | rescue LoadError 9 | begin 10 | load 'tasks/setup.rb' 11 | rescue LoadError 12 | raise RuntimeError, '### please install the "bones" gem ###' 13 | end 14 | end 15 | 16 | ensure_in_path 'lib' 17 | require 'twibot' 18 | 19 | task :default => 'test:run' 20 | 21 | PROJ.name = 'twibot' 22 | PROJ.authors = 'Christian Johansen' 23 | PROJ.email = 'christian@cjohansen.no' 24 | PROJ.url = 'http://github.com/bjeanes/twibot/' 25 | PROJ.version = Twibot::VERSION 26 | PROJ.rubyforge.name = 'twibot' 27 | PROJ.readme_file = 'Readme.rdoc' 28 | PROJ.rdoc.remote_dir = 'twibot' 29 | 30 | depend_on "mbbx6spp-twitter4r", "0.3.1" 31 | -------------------------------------------------------------------------------- /test/test_hash.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.join(File.dirname(__FILE__), 'test_helper')) unless defined?(Twibot) 2 | 3 | class TestHash < Test::Unit::TestCase 4 | should "convert string keys to symbols" do 5 | hash = { "one" => 1, "two" => 2 } 6 | hash.symbolize_keys! 7 | 8 | assert_equal 1, hash[:one] 9 | assert_equal 2, hash[:two] 10 | assert_nil hash["one"] 11 | assert_nil hash["two"] 12 | end 13 | 14 | should "convert string keys and preserve symbol keys" do 15 | hash = { "one" => 1, :two => 2 } 16 | hash.symbolize_keys! 17 | 18 | assert_equal 1, hash[:one] 19 | assert_equal 2, hash[:two] 20 | assert_nil hash["one"] 21 | assert_nil hash["two"] 22 | end 23 | 24 | should "convert hashes recursively" do 25 | hash = { "one" => 1, :two => { "three" => 3, "four" => 4 } } 26 | hash.symbolize_keys! 27 | 28 | assert_equal 1, hash[:one] 29 | assert_equal 3, hash[:two][:three] 30 | assert_equal 4, hash[:two][:four] 31 | assert_nil hash["one"] 32 | assert_nil hash["two"] 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'test/unit' 3 | require 'shoulda' 4 | require 'mocha' 5 | require File.join(File.dirname(__FILE__), '..', 'lib', 'twibot') 6 | 7 | module Test::Unit::Assertions 8 | def assert_hashes_equal(expected, actual, message = nil) 9 | full_message = build_message(message, < expected but was 11 | . 12 | EOT 13 | assert_block(full_message) do 14 | break false if expected.keys.length != actual.keys.length 15 | expected.keys.all? { |k| expected[k] == actual[k] } 16 | end 17 | end 18 | 19 | def assert_hashes_not_equal(expected, actual, message = nil) 20 | full_message = build_message(message, < expected but was 22 | . 23 | EOT 24 | assert_block(full_message) do 25 | break false if expected.keys.length != actual.keys.length 26 | expected.keys.any? { |k| expected[k] != actual[k] } 27 | end 28 | end 29 | end 30 | 31 | def twitter_message(from, text) 32 | Twitter::Message.new(:id => 1, 33 | :sender => Twitter::User.new(:screen_name => from), 34 | :text => text, 35 | :recipient => "twibot", 36 | :created_at => Time.now) 37 | end 38 | 39 | def tweet(from, text) 40 | Twitter::Status.new(:id => 1, 41 | :text => text, 42 | :user => Twitter::User.new(:screen_name => from), 43 | :created_at => Time.now) 44 | end 45 | -------------------------------------------------------------------------------- /History.txt: -------------------------------------------------------------------------------- 1 | == 0.1.7 / 2009-06-01 2 | 3 | * New feature - choose how Twibot processes incoming tweets on startup 4 | (process all, process new [old behaviour], or process from a given ID) 5 | Bodaniel Jeanes 6 | * Substantially improved error handling. Now survives all common network 7 | stability issues 8 | * Added a host configuration option. The host name is displayed along all 9 | output from Twibot. Currently Twitter4R does nothing with this option, 10 | Twibot knowing about it should make it easier to put Twibot/Twitter4R on 11 | other services like Laconica instances 12 | 13 | == 0.1.6 / 2009-04-13 14 | 15 | * Fixed configure block not actually working for username and password 16 | Bodaniel Jeanes 17 | * Minor updates in tests 18 | 19 | == 0.1.5 / 2009-04-12 20 | 21 | * Added support for regular expression routes 22 | * Make timeline_for option configurable, ie in config: timeline_for: :public 23 | * Fixed bug: Users where unlawfully rejected when their screen name started with 24 | a capital letter (Wilco) 25 | * Fixed bug: Twibot crashed if there were no handlers registered 26 | 27 | == 0.1.4 / 2009-03-24 28 | 29 | * Removed some warnings 30 | * Added error handling to avoid Twibot crashing when Twitter is down (Ben Vandgrift) 31 | * Fixed bug: receiving tweets from named users crashed Twibot (Jens Ohlig) 32 | 33 | == 0.1.3 / 2009-03-19 34 | 35 | * Ruby 1.9 support 36 | 37 | == 0.1.2 / 2009-03-18 38 | 39 | * Removed some warnings 40 | * Applied patch from Dan Van Derveer fixing a few minor bugs related to the 41 | options hash sent to Twitter4R 42 | 43 | == 0.1.1 / 2009-03-15 44 | 45 | * Fixed dependency 46 | 47 | == 0.1.0 / 2009-03-15 48 | 49 | * 1 major enhancement 50 | * Birthday! 51 | -------------------------------------------------------------------------------- /twibot.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | Gem::Specification.new do |s| 4 | s.name = %q{twibot} 5 | s.version = "0.1.6" 6 | 7 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 8 | s.authors = ["Christian Johansen"] 9 | s.date = %q{2009-04-13} 10 | s.description = %q{Twibot (pronounced like "Abbot"), is a Ruby microframework for creating Twitter bots, heavily inspired by Sinatra.} 11 | s.email = %q{christian@cjohansen.no} 12 | s.extra_rdoc_files = ["History.txt", "Readme.rdoc"] 13 | s.files = ["History.txt", "Rakefile", "Readme.rdoc", "lib/hash.rb", "lib/twibot.rb", "lib/twibot/bot.rb", "lib/twibot/config.rb", "lib/twibot/handlers.rb", "lib/twibot/macros.rb", "lib/twibot/tweets.rb", "test/test_bot.rb", "test/test_config.rb", "test/test_handler.rb", "test/test_hash.rb", "test/test_helper.rb", "test/test_twibot.rb", "twibot.gemspec"] 14 | s.has_rdoc = true 15 | s.homepage = %q{http://github.com/bjeanes/twibot/} 16 | s.rdoc_options = ["--main", "Readme.rdoc"] 17 | s.require_paths = ["lib"] 18 | s.rubyforge_project = %q{twibot} 19 | s.rubygems_version = %q{1.3.1} 20 | s.summary = %q{Twibot (pronounced like "Abbot"), is a Ruby microframework for creating Twitter bots, heavily inspired by Sinatra} 21 | s.test_files = ["test/test_helper.rb", "test/test_config.rb", "test/test_hash.rb", "test/test_twibot.rb", "test/test_bot.rb", "test/test_handler.rb"] 22 | 23 | if s.respond_to? :specification_version then 24 | current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION 25 | s.specification_version = 2 26 | 27 | if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then 28 | s.add_runtime_dependency(%q, [">= 0.3.1"]) 29 | s.add_development_dependency(%q, [">= 2.4.0"]) 30 | else 31 | s.add_dependency(%q, [">= 0.3.1"]) 32 | s.add_dependency(%q, [">= 2.4.0"]) 33 | end 34 | else 35 | s.add_dependency(%q, [">= 0.3.1"]) 36 | s.add_dependency(%q, [">= 2.4.0"]) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/twibot.rb: -------------------------------------------------------------------------------- 1 | require 'time' 2 | require 'twitter' 3 | require 'twitter/client' 4 | require 'yaml' 5 | require File.join(File.dirname(__FILE__), 'hash') 6 | 7 | module Twibot 8 | 9 | # :stopdoc: 10 | VERSION = '0.1.7' 11 | LIBPATH = ::File.expand_path(::File.dirname(__FILE__)) + ::File::SEPARATOR 12 | PATH = ::File.dirname(LIBPATH) + ::File::SEPARATOR 13 | # :startdoc: 14 | 15 | # Returns the version string for the library. 16 | # 17 | def self.version 18 | VERSION 19 | end 20 | 21 | # Returns the library path for the module. If any arguments are given, 22 | # they will be joined to the end of the libray path using 23 | # File.join. 24 | # 25 | def self.libpath( *args ) 26 | args.empty? ? LIBPATH : ::File.join(LIBPATH, args.flatten) 27 | end 28 | 29 | # Returns the lpath for the module. If any arguments are given, 30 | # they will be joined to the end of the path using 31 | # File.join. 32 | # 33 | def self.path( *args ) 34 | args.empty? ? PATH : ::File.join(PATH, args.flatten) 35 | end 36 | 37 | # Utility method used to require all files ending in .rb that lie in the 38 | # directory below this file that has the same name as the filename passed 39 | # in. Optionally, a specific _directory_ name can be passed in such that 40 | # the _filename_ does not have to be equivalent to the directory. 41 | # 42 | def self.require_all_libs_relative_to( fname, dir = nil ) 43 | dir ||= File.basename(fname, '.*') 44 | search_me = File.expand_path(File.join(File.dirname(fname), dir, '**', '*.rb')) 45 | Dir.glob(search_me).sort.each {|rb| require rb } 46 | end 47 | 48 | @@app_file = lambda do 49 | ignore = [ 50 | /lib\/twibot.*\.rb/, # Library 51 | /\(.*\)/, # Generated code 52 | /custom_require\.rb/ # RubyGems require 53 | ] 54 | 55 | path = caller.map { |line| line.split(/:\d/, 2).first }.find do |file| 56 | next if ignore.any? { |pattern| file =~ pattern } 57 | file 58 | end 59 | 60 | path || $0 61 | end.call 62 | 63 | # 64 | # File name of the application file. Inspired by Sinatra 65 | # 66 | def self.app_file 67 | @@app_file 68 | end 69 | 70 | # 71 | # Runs application if application file is the script being executed 72 | # 73 | def self.run? 74 | self.app_file == $0 75 | end 76 | 77 | end # module Twibot 78 | 79 | Twitter::Client.configure do |config| 80 | config.application_name = 'Twibot' 81 | config.application_version = Twibot.version 82 | config.application_url = 'http://github.com/cjohansen/twibot' 83 | end 84 | 85 | Twibot.require_all_libs_relative_to(__FILE__) 86 | 87 | # EOF 88 | -------------------------------------------------------------------------------- /lib/twibot/macros.rb: -------------------------------------------------------------------------------- 1 | module Twibot 2 | @@prompt = false 3 | 4 | def self.prompt=(p) 5 | @@prompt = f 6 | end 7 | 8 | module Macros 9 | def self.included(mod) 10 | @@bot = nil 11 | end 12 | 13 | def configure(&blk) 14 | bot.configure(&blk) 15 | end 16 | 17 | def message(pattern = nil, options = {}, &blk) 18 | add_handler(:message, pattern, options, &blk) 19 | end 20 | 21 | def reply(pattern = nil, options = {}, &blk) 22 | add_handler(:reply, pattern, options, &blk) 23 | end 24 | 25 | def tweet(pattern = nil, options = {}, &blk) 26 | add_handler(:tweet, pattern, options, &blk) 27 | end 28 | 29 | def follower(&blk) 30 | add_handler(:follower, nil, {}, &blk) 31 | end 32 | 33 | def hashtag(tag_or_tags, pattern = nil, options = {}, &blk) 34 | query = [tag_or_tags].flatten.map {|ht| ht.to_s[0] == ?# ? ht.to_s : "##{ht}"}.join(" OR ") 35 | add_handler([:search, query], pattern, options, &blk) 36 | end 37 | alias_method :hashtags, :hashtag 38 | 39 | def search(query, pattern = nil, options = {}, &blk) 40 | add_handler([:search, query], pattern, options, &blk) 41 | end 42 | 43 | def after(event=:all, &blk) 44 | add_hook :"after_#{event}", &blk 45 | end 46 | 47 | def before(event=:all, &blk) 48 | add_hook :"before_#{event}", &blk 49 | end 50 | 51 | def twitter 52 | bot.twitter 53 | end 54 | 55 | alias_method :client, :twitter 56 | 57 | def post_tweet(msg) 58 | message = msg.respond_to?(:text) ? msg.text : msg 59 | puts message 60 | client.status(:post, message) 61 | end 62 | 63 | def post_reply(status, msg) 64 | text = msg.respond_to?(:text) ? msg.text : msg 65 | reply_to_screen_name = status.user.screen_name 66 | reply_to_status_id = status.id 67 | message = "@#{reply_to_screen_name} #{text}" 68 | puts message 69 | client.status(:reply, message, reply_to_status_id) 70 | end 71 | 72 | def run? 73 | !@@bot.nil? 74 | end 75 | 76 | private 77 | def add_handler(type, pattern, options, &blk) 78 | bot.add_handler(type, Twibot::Handler.new(pattern, options, &blk)) 79 | end 80 | 81 | def add_hook(hook, &blk) 82 | bot.add_hook(hook, &blk) 83 | end 84 | 85 | def bot 86 | return @@bot unless @@bot.nil? 87 | 88 | begin 89 | @@bot = Twibot::Bot.new nil, true 90 | rescue Exception 91 | @@bot = Twibot::Bot.new(Twibot::Config.default << Twibot::CliConfig.new, true) 92 | end 93 | 94 | @@bot 95 | end 96 | 97 | def self.bot=(bot) 98 | @@bot = bot 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /test/test_config.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.join(File.dirname(__FILE__), 'test_helper')) unless defined?(Twibot) 2 | require 'stringio' 3 | 4 | class TestConfig < Test::Unit::TestCase 5 | should "default configuration be a hash" do 6 | assert_not_nil Twibot::Config::DEFAULT 7 | assert Twibot::Config::DEFAULT.is_a?(Hash) 8 | end 9 | 10 | should "initialize with no options" do 11 | assert_hashes_equal({}, Twibot::Config.new.settings) 12 | end 13 | 14 | should "return config from add" do 15 | config = Twibot::Config.new 16 | assert_equal config, config.add(Twibot::Config.new) 17 | end 18 | 19 | should "alias add to <<" do 20 | config = Twibot::Config.new 21 | assert config.respond_to?(:<<) 22 | assert config << Twibot::Config.new 23 | end 24 | 25 | should "mirror method_missing as config getters" do 26 | config = Twibot::Config.default << Twibot::Config.new 27 | assert_equal Twibot::Config::DEFAULT[:min_interval], config.min_interval 28 | assert_equal Twibot::Config::DEFAULT[:login], config.login 29 | end 30 | 31 | should "mirror missing methods as config setters" do 32 | config = Twibot::Config.default << Twibot::Config.new 33 | assert_equal Twibot::Config::DEFAULT[:min_interval], config.min_interval 34 | 35 | val = config.min_interval 36 | config.min_interval = val + 5 37 | assert_not_equal Twibot::Config::DEFAULT[:min_interval], config.min_interval 38 | assert_equal val + 5, config.min_interval 39 | end 40 | 41 | should "not override default hash" do 42 | config = Twibot::Config.default 43 | hash = Twibot::Config::DEFAULT 44 | 45 | config.min_interval = 0 46 | config.max_interval = 0 47 | 48 | assert_hashes_not_equal Twibot::Config::DEFAULT, config.to_hash 49 | assert_hashes_equal hash, Twibot::Config::DEFAULT 50 | end 51 | 52 | should "return merged configuration from to_hash" do 53 | config = Twibot::Config.new 54 | config.min_interval = 10 55 | config.max_interval = 10 56 | 57 | config2 = Twibot::Config.new({}) 58 | config2.min_interval = 1 59 | config << config2 60 | options = config.to_hash 61 | 62 | assert_equal 10, options[:max_interval] 63 | assert_equal 1, options[:min_interval] 64 | end 65 | end 66 | 67 | class TestCliConfig < Test::Unit::TestCase 68 | should "configure from options" do 69 | config = Twibot::CliConfig.new %w{--min-interval 10 --max-interval 15} 70 | assert_equal 10, config.min_interval 71 | assert_equal 15, config.max_interval 72 | end 73 | end 74 | 75 | class TestFileConfig < Test::Unit::TestCase 76 | should "subclass config for file config" do 77 | assert Twibot::FileConfig.new(StringIO.new).is_a?(Twibot::Config) 78 | end 79 | 80 | should "read settings from stream" do 81 | config = Twibot::FileConfig.new(StringIO.new <<-YAML) 82 | min_interval: 10 83 | max_interval: 20 84 | YAML 85 | 86 | assert_equal 10, config.min_interval 87 | assert_equal 20, config.max_interval 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/twibot/handlers.rb: -------------------------------------------------------------------------------- 1 | module Twibot 2 | module Handlers 3 | # 4 | # Add a handler for this bot 5 | # 6 | def add_handler(type, handler) 7 | handlers_for_type(type) << handler 8 | handler 9 | end 10 | 11 | def handlers_for_type(type) 12 | if type.is_a? Array 13 | handlers[type.first][type.last] ||= [] 14 | else 15 | handlers[type] || {} 16 | end 17 | end 18 | 19 | def dispatch(type, message) 20 | handlers_for_type(type).each { |handler| handler.dispatch(message) } 21 | end 22 | 23 | def handlers 24 | @handlers ||= { 25 | :message => [], 26 | :reply => [], 27 | :tweet => [], 28 | :follower => [], 29 | :search => {} 30 | } 31 | end 32 | 33 | def handlers=(hash) 34 | @handlers = hash 35 | end 36 | end 37 | 38 | # 39 | # A Handler object is an object which can handle a direct message, tweet or 40 | # at reply. 41 | # 42 | class Handler 43 | attr_reader :options 44 | def initialize(pattern = nil, options = {}, &blk) 45 | if pattern.is_a?(Hash) 46 | options = pattern 47 | pattern = nil 48 | end 49 | 50 | @options = options 51 | @options[:from].collect! { |s| s.to_s } if @options[:from] && @options[:from].is_a?(Array) 52 | @options[:from] = [@options[:from].to_s] if @options[:from] && @options[:from].is_a?(String) 53 | @handler = nil 54 | @handler = block_given? ? blk : nil 55 | self.pattern = pattern 56 | end 57 | 58 | # 59 | # Parse pattern string and set options 60 | # 61 | def pattern=(pattern) 62 | return if pattern.nil? || pattern == "" 63 | 64 | if pattern.is_a?(Regexp) 65 | @options[:pattern] = pattern 66 | return 67 | end 68 | 69 | words = pattern.split.collect { |s| s.strip } # Get all words in pattern 70 | @options[:tokens] = words.inject([]) do |sum, token| # Find all tokens, ie :symbol :like :names 71 | next sum unless token =~ /^:.*/ # Don't process regular words 72 | sym = token.sub(":", "").to_sym # Turn token string into symbol, ie ":token" => :token 73 | regex = @options[sym] || '[^\s]+' # Fetch regex if configured, else use any character but space matching 74 | pattern.sub!(/(^|\s)#{token}(\s|$)/, '\1(' + regex.to_s + ')\2') # Make sure regex captures named switch 75 | sum << sym 76 | end 77 | 78 | @options[:pattern] = /#{pattern}(\s.+)?/ 79 | end 80 | 81 | # 82 | # Determines if this handler is suited to handle an incoming message 83 | # 84 | def recognize?(message) 85 | return false if @options[:pattern] && message.text !~ @options[:pattern] # Pattern check 86 | 87 | users = @options[:from] ? @options[:from] : nil 88 | sender = message.respond_to?(:sender) ? message.sender : message.user 89 | return false if users && !users.include?(sender.screen_name.downcase) # Check allowed senders 90 | true 91 | end 92 | 93 | # 94 | # Process message to build params hash and pass message along with params of 95 | # to +handle+ 96 | # 97 | def dispatch(message) 98 | return unless recognize?(message) 99 | @params = {} 100 | 101 | if @options[:pattern] && @options[:tokens] 102 | matches = message.text.match(@options[:pattern]) 103 | @options[:tokens].each_with_index { |token, i| @params[token] = matches[i+1] } 104 | @params[:text] = (matches[@options[:tokens].length+1] || "").strip 105 | elsif @options[:pattern] && !@options[:tokens] 106 | @params = message.text.match(@options[:pattern]).to_a[1..-1] || [] 107 | else 108 | @params[:text] = message.text 109 | end 110 | 111 | handle(message, @params) 112 | end 113 | 114 | # 115 | # Handle a message. Calls the internal Proc with the message and the params 116 | # hash as parameters. 117 | # 118 | def handle(message, params) 119 | @handler.call(message, params) if @handler 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/twibot/config.rb: -------------------------------------------------------------------------------- 1 | require 'optparse' 2 | 3 | module Twibot 4 | # 5 | # Twibot configuration. Use either Twibot::CliConfig.new or 6 | # TwibotFileConfig.new setup a new bot from either command line or file 7 | # (respectively). Configurations can be chained so they override each other: 8 | # 9 | # config = Twibot::FileConfig.new 10 | # config << Twibot::CliConfig.new 11 | # config.to_hash 12 | # 13 | # The preceding example will create a configuration which is based on a 14 | # configuration file but have certain values overridden from the command line. 15 | # This can be used for instance to store everything but the Twitter account 16 | # password in your configuration file. Then you can just provide the password 17 | # when running the bot. 18 | # 19 | class Config 20 | attr_reader :settings 21 | 22 | DEFAULT = { 23 | :host => "twitter.com", 24 | :min_interval => 30, 25 | :max_interval => 300, 26 | :interval_step => 10, 27 | :log_level => "info", 28 | :log_file => nil, 29 | :login => nil, 30 | :password => nil, 31 | :process => :new, 32 | :prompt => false, 33 | :daemonize => false, 34 | :include_friends => false, 35 | :timeline_for => :public 36 | } 37 | 38 | def initialize(settings = {}) 39 | @configs = [] 40 | @settings = settings 41 | end 42 | 43 | # 44 | # Add a configuration object to override given settings 45 | # 46 | def add(config) 47 | @configs << config 48 | self 49 | end 50 | 51 | alias_method :<<, :add 52 | 53 | # 54 | # Makes it possible to access configuration settings as attributes 55 | # 56 | def method_missing(name, *args, &block) 57 | regex = /=$/ 58 | attr_name = name.to_s.sub(regex, '').to_sym 59 | return super if name == attr_name && !@settings.key?(attr_name) 60 | 61 | if name != attr_name 62 | @settings[attr_name] = args.first 63 | end 64 | 65 | @settings[attr_name] 66 | end 67 | 68 | # 69 | # Merges configurations and returns a hash with all options 70 | # 71 | def to_hash 72 | hash = {}.merge(@settings) 73 | @configs.each { |conf| hash.merge!(conf.to_hash) } 74 | hash 75 | end 76 | 77 | def self.default 78 | Config.new({}.merge(DEFAULT)) 79 | end 80 | end 81 | 82 | # 83 | # Configuration from command line 84 | # 85 | class CliConfig < Config 86 | 87 | def initialize(args = $*) 88 | super() 89 | 90 | @parser = OptionParser.new do |opts| 91 | opts.banner += "Usage: #{File.basename(Twibot.app_file)} [options]" 92 | 93 | opts.on("-m", "--min-interval SECS", Integer, "Minimum poll interval in seconds") { |i| @settings[:min_interval] = i } 94 | opts.on("-x", "--max-interval SECS", Integer, "Maximum poll interval in seconds") { |i| @settings[:max_interval] = i } 95 | opts.on("-s", "--interval-step SECS", Integer, "Poll interval step in seconds") { |i| @settings[:interval_step] = i } 96 | opts.on("-f", "--log-file FILE", "Log file") { |f| @settings[:log_file] = f } 97 | opts.on("-l", "--log-level LEVEL", "Log level (err, warn, info, debug), default id info") { |l| @settings[:log_level] = l } 98 | opts.on("-u", "--login LOGIN", "Twitter login") { |l| @settings[:login] = l } 99 | opts.on("-p", "--password PASSWORD", "Twitter password") { |p| @settings[:password] = p } 100 | opts.on("-h", "--help", "Show this message") { puts opts; exit } 101 | 102 | begin 103 | require 'daemons' 104 | opts.on("-d", "--daemonize", "Run as background process (Not implemented)") { |t| @settings[:daemonize] = true } 105 | rescue LoadError 106 | end 107 | 108 | end.parse!(args) 109 | end 110 | end 111 | 112 | # 113 | # Configuration from files 114 | # 115 | class FileConfig < Config 116 | 117 | # 118 | # Accepts a stream or a file to read configuration from 119 | # Default is to read configuration from ./config/bot.yml 120 | # 121 | # If a stream is passed it is not closed from within the method 122 | # 123 | def initialize(fos = File.expand_path("config/bot.yml")) 124 | stream = fos.is_a?(String) ? File.open(fos, "r") : fos 125 | 126 | begin 127 | config = YAML.load(stream.read) 128 | config.symbolize_keys! if config 129 | rescue Exception => err 130 | puts err.message 131 | puts "Unable to load configuration, aborting" 132 | exit 133 | ensure 134 | stream.close if fos.is_a?(String) 135 | end 136 | 137 | super config.is_a?(Hash) ? config : {} 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /test/test_handler.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.join(File.dirname(__FILE__), 'test_helper')) unless defined?(Twibot) 2 | 3 | class TestHandler < Test::Unit::TestCase 4 | context "pattern writer" do 5 | should "abort on empty values" do 6 | handler = Twibot::Handler.new 7 | 8 | handler.pattern = nil 9 | assert_nil handler.instance_eval { @options[:pattern] } 10 | assert_nil handler.instance_eval { @options[:tokens] } 11 | 12 | handler.pattern = "" 13 | assert_nil handler.instance_eval { @options[:pattern] } 14 | assert_nil handler.instance_eval { @options[:tokens] } 15 | end 16 | 17 | should "turn regular pattern into regex" do 18 | handler = Twibot::Handler.new 19 | handler.pattern = "command" 20 | 21 | assert_equal(/command(\s.+)?/, handler.instance_eval { @options[:pattern] }) 22 | assert_equal 0, handler.instance_eval { @options[:tokens] }.length 23 | end 24 | 25 | should "convert single named switch to regex" do 26 | handler = Twibot::Handler.new 27 | handler.pattern = ":command" 28 | 29 | assert_equal(/([^\s]+)(\s.+)?/, handler.instance_eval { @options[:pattern] }) 30 | assert_equal 1, handler.instance_eval { @options[:tokens] }.length 31 | assert_equal :command, handler.instance_eval { @options[:tokens].first } 32 | end 33 | 34 | should "convert several named switches to regexen" do 35 | handler = Twibot::Handler.new 36 | handler.pattern = ":command fixed_word :subcommand" 37 | 38 | assert_equal(/([^\s]+) fixed_word ([^\s]+)(\s.+)?/, handler.instance_eval { @options[:pattern] }) 39 | assert_equal 2, handler.instance_eval { @options[:tokens] }.length 40 | assert_equal :command, handler.instance_eval { @options[:tokens].first } 41 | assert_equal :subcommand, handler.instance_eval { @options[:tokens][1] } 42 | end 43 | 44 | should "convert several named switches to regexen specified by options" do 45 | handler = Twibot::Handler.new(":time :hour", :hour => /\d\d/) 46 | 47 | assert_equal(/([^\s]+) ((?-mix:\d\d))(\s.+)?/, handler.instance_eval { @options[:pattern] }) 48 | assert_equal 2, handler.instance_eval { @options[:tokens] }.length 49 | assert_equal :time, handler.instance_eval { @options[:tokens].first } 50 | assert_equal :hour, handler.instance_eval { @options[:tokens][1] } 51 | end 52 | end 53 | 54 | should "recognize empty pattern" do 55 | handler = Twibot::Handler.new 56 | message = twitter_message "cjno", "A twitter direct message" 57 | 58 | assert handler.recognize?(message) 59 | end 60 | 61 | should "recognize empty pattern and allowed user" do 62 | handler = Twibot::Handler.new "", :from => "cjno" 63 | message = twitter_message "cjno", "A twitter direct message" 64 | assert handler.recognize?(message) 65 | 66 | handler = Twibot::Handler.new "", :from => ["cjno", "irbno"] 67 | assert handler.recognize?(message) 68 | end 69 | 70 | should "not recognize empty pattern and disallowed user" do 71 | handler = Twibot::Handler.new "", :from => "irbno" 72 | message = twitter_message "cjno", "A twitter direct message" 73 | assert !handler.recognize?(message) 74 | 75 | handler = Twibot::Handler.new "", :from => ["irbno", "satan"] 76 | assert !handler.recognize?(message) 77 | end 78 | 79 | should "recognize fixed pattern and no user" do 80 | handler = Twibot::Handler.new "time" 81 | message = twitter_message "cjno", "time oslo norway" 82 | assert handler.recognize?(message) 83 | end 84 | 85 | should "recognize dynamic pattern and no user" do 86 | handler = Twibot::Handler.new "time :city :country" 87 | message = twitter_message "cjno", "time oslo norway" 88 | assert handler.recognize?(message) 89 | end 90 | 91 | should "not recognize dynamic pattern and no user" do 92 | handler = Twibot::Handler.new "time :city :country" 93 | message = twitter_message "cjno", "oslo norway what is the time?" 94 | assert !handler.recognize?(message) 95 | end 96 | 97 | should "recognize fixed pattern and user" do 98 | handler = Twibot::Handler.new "time", :from => ["cjno", "irbno"] 99 | message = twitter_message "cjno", "time oslo norway" 100 | assert handler.recognize?(message) 101 | end 102 | 103 | should "recognize dynamic pattern and user" do 104 | handler = Twibot::Handler.new "time :city :country", :from => ["cjno", "irbno"] 105 | message = twitter_message "cjno", "time oslo norway" 106 | assert handler.recognize?(message) 107 | end 108 | 109 | should "not recognize dynamic pattern and user" do 110 | handler = Twibot::Handler.new "time :city :country", :from => ["cjno", "irbno"] 111 | message = twitter_message "dude", "time oslo norway" 112 | assert !handler.recognize?(message) 113 | end 114 | 115 | should "recognize symbol users" do 116 | handler = Twibot::Handler.new "time :city :country", :from => [:cjno, :irbno] 117 | message = twitter_message "dude", "time oslo norway" 118 | assert !handler.recognize?(message) 119 | 120 | message = twitter_message("cjno", "time oslo norway") 121 | assert handler.recognize?(message) 122 | end 123 | 124 | should "recognize tweets from allowed users" do 125 | handler = Twibot::Handler.new :from => [:cjno, :irbno] 126 | message = tweet "cjno", "time oslo norway" 127 | assert handler.recognize?(message) 128 | end 129 | 130 | should "recognize tweets from allowed users with capital screen names" do 131 | handler = Twibot::Handler.new :from => [:cjno, :irbno] 132 | message = tweet "Cjno", "time oslo norway" 133 | assert handler.recognize?(message) 134 | end 135 | 136 | should "accept options as only argument" do 137 | handler = Twibot::Handler.new :from => :cjno 138 | assert_equal(:cjno, handler.instance_eval { @options[:from] }) 139 | assert_nil handler.instance_eval { @options[:pattern] } 140 | end 141 | 142 | should "provide parameters in params hash" do 143 | handler = Twibot::Handler.new("time :city :country", :from => ["cjno", "irbno"]) do |message, params| 144 | assert_equal "oslo", params[:city] 145 | assert_equal "norway", params[:country] 146 | end 147 | 148 | message = twitter_message "cjno", "time oslo norway" 149 | assert handler.recognize?(message) 150 | handler.dispatch(message) 151 | end 152 | 153 | should "call constructor block from handle" do 154 | handler = Twibot::Handler.new("time :city :country", :from => ["cjno", "irbno"]) do |message, params| 155 | raise "Boom!" 156 | end 157 | 158 | assert_raise(RuntimeError) do 159 | handler.handle(nil, nil) 160 | end 161 | end 162 | 163 | should "recognize regular expressions" do 164 | handler = Twibot::Handler.new /(?:what|where) is (.*)/i 165 | message = twitter_message "dude", "Where is this shit?" 166 | assert handler.recognize?(message) 167 | 168 | message = twitter_message "dude", "How is this shit?" 169 | assert !handler.recognize?(message) 170 | end 171 | 172 | should "recognize regular expressions from specific users" do 173 | handler = Twibot::Handler.new /(?:what|where) is (.*)/i, :from => "cjno" 174 | message = twitter_message "dude", "Where is this shit?" 175 | assert !handler.recognize?(message) 176 | 177 | message = twitter_message "cjno", "Where is this shit?" 178 | assert handler.recognize?(message) 179 | end 180 | 181 | should "provide parameters as arrays when matching regular expressions" do 182 | handler = Twibot::Handler.new(/time ([^\s]*) ([^\s]*)/) do |message, params| 183 | assert_equal "oslo", params[0] 184 | assert_equal "norway", params[1] 185 | end 186 | 187 | message = twitter_message "cjno", "time oslo norway" 188 | assert handler.recognize?(message) 189 | handler.dispatch(message) 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /Readme.rdoc: -------------------------------------------------------------------------------- 1 | = IMPORTANT 2 | 3 | THIS PROJECT IS DEAD. IT DOES NOT WORK. THE CODE IS KEPT HERE ONLY FOR HISTORIC PURPOSES. 4 | 5 | DO NOT USE THIS PROJECT. 6 | 7 | = Twibot 8 | Official URL: http://github.com/cjohansen/twibot/tree/master 9 | Christian Johansen (http://www.cjohansen.no) 10 | Twitter: @cjno 11 | 12 | == Description 13 | 14 | Twibot (pronounced like "Abbot"), is a Ruby microframework for creating Twitter 15 | bots, heavily inspired by Sinatra. 16 | 17 | == Usage 18 | 19 | === Simple example 20 | 21 | require 'twibot' 22 | 23 | # Receive messages, and tweet them publicly 24 | # 25 | message do |message, params| 26 | post_tweet message 27 | end 28 | 29 | # Respond to @replies if they come from the right crowd 30 | # 31 | reply :from => [:cjno, :irbno] do |message, params| 32 | post_reply message, "I agree" 33 | end 34 | 35 | # Listen in and log tweets 36 | # 37 | tweet do |message, params| 38 | MyApp.log_tweet(message) 39 | end 40 | 41 | # Search for tweets matching a query. The available search operators 42 | # are explained here: 43 | # 44 | search "twibot" do |message, params| 45 | # do_something 46 | end 47 | 48 | # Search for tweets with a hashtag 49 | # see: 50 | # 51 | # Note: hashtag is just a convenience wrapper 52 | # around search. It will invoke the search 53 | # before and after filters. 54 | # 55 | hashtag "twibot" do |message, params| 56 | # do_something 57 | end 58 | 59 | # Search for tweets with one of a number of hashtags 60 | # see: 61 | # 62 | # Note: hashtags is just an alias to hashtag 63 | # 64 | hashtags [:twibot, :ruby, "twitter4r"] do |message, params| 65 | # do_something 66 | end 67 | 68 | # Process any new followers. user_id will be 69 | # the user's Numeric id and params will always 70 | # be an empty Hash. 71 | # 72 | # add_friend!(id) is a convenience wrapper around the 73 | # twitter4r friendship method. remove_friend!(id) 74 | # is also available. 75 | # 76 | follower do |user_id, params| 77 | # keep out the riff-raff... 78 | bot.add_friend!(user_id) unless user_id == 890631 79 | end 80 | 81 | # add some set-up code that will be called 82 | # before each polling cycle. :all is the 83 | # default, so it can safely be omitted 84 | # 85 | before :all do 86 | MyApp.log("Started polling at #{Time.now}") 87 | end 88 | 89 | # the after hook for the polling cycle gets 90 | # passed the number of messages that were 91 | # processed 92 | # 93 | after :all do |message_count| 94 | MyApp.log("Finished polling at #{Time.now}. Got #{message_count} messages.") 95 | end 96 | 97 | # each action has before and after hooks available: 98 | # - follower 99 | # - message 100 | # - reply 101 | # - search 102 | # - tweet 103 | # 104 | # there can be only one before and one after callback 105 | # registered for a given type. the callback block 106 | # will be called with no arguments. 107 | # 108 | # Note: hashtag and hashtags are just wrappers around 109 | # search and do not have their own hooks. Use the 110 | # search hooks when using hashtag or hashtags. 111 | # 112 | before :message do 113 | MyApp.is_processing_a_message = true 114 | end 115 | 116 | after :message do 117 | MyApp.is_processing_a_message = false 118 | end 119 | 120 | after :follower do 121 | MyApp.log("I have another follower!") 122 | end 123 | 124 | 125 | === Running the bot 126 | 127 | To run the bot, simply do: 128 | 129 | ruby bot.rb 130 | 131 | === Configuration 132 | 133 | Twibot looks for a configuration file in ./config/bot.yml. It should contain 134 | atleast: 135 | 136 | login: twitter_login 137 | password: twitter_password 138 | 139 | You can also pass configuration as command line arguments: 140 | 141 | ruby bot.rb --login myaccount 142 | 143 | ...or configure with Ruby: 144 | 145 | configure do |conf| 146 | conf.login = "my_account" 147 | do 148 | 149 | If you don't specify login and/or password in any of these ways, Twibot will 150 | prompt you for those. 151 | 152 | If you want to change how Twibot is configured, you can setup the bot instance 153 | manually and give it only the configuration options you want: 154 | 155 | # Create bot only with default configuration 156 | require 'twibot' 157 | bot = Twibot::Bot.new(Twibot::Config.default) 158 | 159 | # Application here... 160 | 161 | If you want command line arguments you can do: 162 | 163 | require 'twibot' 164 | bot = Twibot::Bot.new(Twibot::Config.default << Twibot::CliConfig.new) 165 | 166 | To disable the buffering of the Twibot log file, set the `log_flush` config 167 | option to `true`: 168 | 169 | configure do |conf| 170 | conf.log_file = File.join(DAEMON_ROOT, 'log', 'twitterd.log') 171 | conf.log_level = "info" 172 | conf.log_flush = true 173 | end 174 | 175 | === "Routes" 176 | 177 | Like Sinatra, and other web app frameworks, Twibot supports "routes": patterns 178 | to match incoming tweets and messages: 179 | 180 | require 'twibot' 181 | 182 | tweet "time :country :city" do |message,params| 183 | time = MyTimeService.lookup(params[:country], params[:city]) 184 | client.message :post, "Time is #{time} in #{params[:city]}, #{params[:country]}" 185 | end 186 | 187 | You can have several "tweet" blocks (or "message" or "reply"). The first one to 188 | match an incoming tweet/message will handle it. 189 | 190 | As of the upcoming 0.1.5/0.2.0, Twibot also supports regular expressions as routes: 191 | 192 | require 'twibot' 193 | 194 | tweet /^time ([^\s]*) ([^\s]*)/ do |message, params| 195 | # params is an array of matches when using regexp routes 196 | time = MyTimeService.lookup(params[0], params[1]) 197 | client.message :post, "Time is #{time} in #{params[:city]}, #{params[:country]}" 198 | end 199 | 200 | === Working with the Twitter API 201 | 202 | The DSL gives you access to your Twitter client instance through "client" (or "twitter"): 203 | 204 | message do 205 | twitter.status :post, "Hello world" # Also: client.status :post, "Hello world" 206 | end 207 | 208 | == Requirements 209 | 210 | Twitter4r. You'll need atleast 0.3.1, which is currently only available from GitHub. 211 | Versions of Twitter4r prior to 0.3.1 does not allow for the since_id parameter to be 212 | appended to URLs to the REST API. Twibot needs these to only fetch fresh messages 213 | and tweets. 214 | 215 | == Installation 216 | 217 | gem install twibot 218 | 219 | == Is it Ruby 1.9? 220 | 221 | As of Twibot 0.1.3, yes it is! All tests pass, please give feedback from real world 222 | usage if you have trouble. 223 | 224 | == Polling 225 | 226 | Twitter pulled the plug on it's xmpp service last year. This means that Twibot backed 227 | bots needs to poll the Twitter service to keep up. Twitter has a request limit on 70 228 | reqs/hour, so you should configure your bot not to make more than that, else it will 229 | fail. You can ask for your bot account to be put on the whitelist which allows you to 230 | make 20.000 reqs/hour, and shouldn't be a problem so long as your intentions are good 231 | (I think). 232 | 233 | Twibot polls like this: 234 | * Poll messages if any message handlers exist 235 | * Poll tweets if any tweet or reply handlers exist 236 | * Sleep for +interval+ seconds 237 | * Go over again 238 | 239 | As long as Twibot finds any messages and/or tweets, the interval stays the same 240 | (min_interval configuration switch). If nothing was found however, the interval to 241 | sleep is increased by interval_step configuration option. This happens until it 242 | reaches max_interval, where it will stay until Twibot finds anything. 243 | 244 | == Contributors 245 | 246 | * Dan Van Derveer (bug fixes) - http://dan.van.derveer.com/ 247 | * Ben Vandgrift (Twitter downtime error handling) - http://neovore.com/ 248 | * Jens Ohlig (warnings) 249 | * Wilco van Duinkerken (bug fixes) - http://www.sparkboxx.com/ 250 | * Bodaniel Jeanes (configure block fix) - http://bjeanes.github.com/ 251 | 252 | == License 253 | 254 | (The MIT License) 255 | 256 | Copyright (c) 2009 Christian Johansen 257 | 258 | Permission is hereby granted, free of charge, to any person obtaining 259 | a copy of this software and associated documentation files (the 260 | 'Software'), to deal in the Software without restriction, including 261 | without limitation the rights to use, copy, modify, merge, publish, 262 | distribute, sublicense, and/or sell copies of the Software, and to 263 | permit persons to whom the Software is furnished to do so, subject to 264 | the following conditions: 265 | 266 | The above copyright notice and this permission notice shall be 267 | included in all copies or substantial portions of the Software. 268 | 269 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 270 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 271 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 272 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 273 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 274 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 275 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 276 | -------------------------------------------------------------------------------- /test/test_bot.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.join(File.dirname(__FILE__), 'test_helper')) unless defined?(Twibot) 2 | require 'fileutils' 3 | 4 | class TestBot < Test::Unit::TestCase 5 | should "not raise errors when initialized" do 6 | assert_nothing_raised do 7 | Twibot::Bot.new Twibot::Config.new 8 | end 9 | end 10 | 11 | should "raise errors when initialized without config file" do 12 | assert_raise SystemExit do 13 | Twibot::Bot.new 14 | end 15 | end 16 | 17 | should "not raise error on initialize when config file exists" do 18 | if File.exists?("config") 19 | FileUtils.rm("config/bot.yml") 20 | else 21 | FileUtils.mkdir("config") 22 | end 23 | 24 | File.open("config/bot.yml", "w") { |f| f.puts "" } 25 | 26 | assert_nothing_raised do 27 | Twibot::Bot.new 28 | end 29 | 30 | FileUtils.rm_rf("config") 31 | end 32 | 33 | should "provide configuration settings as methods" do 34 | bot = Twibot::Bot.new Twibot::Config.new(:max_interval => 3) 35 | assert_equal 3, bot.max_interval 36 | end 37 | 38 | should "return logger instance" do 39 | bot = Twibot::Bot.new(Twibot::Config.default << Twibot::Config.new) 40 | assert bot.log.is_a?(Logger) 41 | end 42 | 43 | should "respect configured log level" do 44 | bot = Twibot::Bot.new(Twibot::Config.new(:log_level => "info")) 45 | assert_equal Logger::INFO, bot.log.level 46 | 47 | bot = Twibot::Bot.new(Twibot::Config.new(:log_level => "warn")) 48 | assert_equal Logger::WARN, bot.log.level 49 | end 50 | 51 | should "should return false from receive without handlers" do 52 | bot = Twibot::Bot.new(Twibot::Config.new) 53 | assert !bot.receive_messages 54 | assert !bot.receive_replies 55 | assert !bot.receive_tweets 56 | end 57 | 58 | context "with the process option specified" do 59 | setup do 60 | @bot = Twibot::Bot.new(@config = Twibot::Config.default) 61 | @bot.stubs(:prompt?).returns(false) 62 | @bot.stubs(:twitter).returns(stub) 63 | @bot.stubs(:processed).returns(stub) 64 | 65 | # stop Bot actually starting during tests 66 | @bot.stubs(:poll) 67 | end 68 | 69 | should "not process tweets prior to bot launch if :process option is set to :new" do 70 | @bot.stubs(:handlers).returns({:tweet => [stub], :reply => []}) 71 | 72 | # Should fetch the latest ID for both messages and tweets 73 | @bot.twitter.expects(:messages).with(:received, { :count => 1 }). 74 | returns([stub(:id => (message_id = stub))]).once 75 | @bot.twitter.expects(:timeline_for).with(:public, { :count => 1 }). 76 | returns([stub(:id => (tweet_id = stub))]).once 77 | 78 | # And set them to the since_id value to be used for future polling 79 | @bot.processed.expects(:[]=).with(:message, message_id) 80 | @bot.processed.expects(:[]=).with(:tweet, tweet_id) 81 | @bot.processed.expects(:[]=).with(:reply, tweet_id) 82 | 83 | @bot.configure { |c| c.process = :new } 84 | @bot.run! 85 | end 86 | 87 | [:all, nil].each do |value| 88 | should "process all tweets if :process option is set to #{value.inspect}" do 89 | @bot.twitter.expects(:messages).never 90 | @bot.twitter.expects(:timeline_for).never 91 | 92 | # Shout not set the any value for the since_id tweets 93 | @bot.processed.expects(:[]=).never 94 | 95 | @bot.configure { |c| c.process = value } 96 | @bot.run! 97 | end 98 | end 99 | 100 | should "process all tweets after the ID specified in the :process option" do 101 | tweet_id = 12345 102 | 103 | @bot.processed.expects(:[]=).with(anything, 12345).times(3) 104 | 105 | @bot.configure { |c| c.process = tweet_id } 106 | @bot.run! 107 | end 108 | 109 | should "raise exit when the :process option is not recognized" do 110 | @bot.configure { |c| c.process = "something random" } 111 | assert_raise(SystemExit) { @bot.run! } 112 | end 113 | 114 | end 115 | 116 | should "receive message" do 117 | bot = Twibot::Bot.new(Twibot::Config.new(:log_level => "error")) 118 | bot.add_handler(:message, Twibot::Handler.new) 119 | bot.twitter.expects(:messages).with(:received, {}).returns([twitter_message("cjno", "Hei der!")]) 120 | 121 | assert bot.receive_messages 122 | end 123 | 124 | should "remember last received message" do 125 | bot = Twibot::Bot.new(Twibot::Config.new(:log_level => "error")) 126 | bot.add_handler(:message, Twibot::Handler.new) 127 | bot.twitter.expects(:messages).with(:received, {}).returns([twitter_message("cjno", "Hei der!")]) 128 | assert_equal 1, bot.receive_messages 129 | 130 | bot.twitter.expects(:messages).with(:received, { :since_id => 1 }).returns([]) 131 | assert_equal 0, bot.receive_messages 132 | end 133 | 134 | should "receive tweet" do 135 | bot = Twibot::Bot.new(Twibot::Config.new(:log_level => "error")) 136 | bot.add_handler(:tweet, Twibot::Handler.new) 137 | bot.twitter.expects(:timeline_for).with(:public, {}).returns([tweet("cjno", "Hei der!")]) 138 | 139 | assert_equal 1, bot.receive_tweets 140 | end 141 | 142 | should "receive friend tweets if configured" do 143 | bot = Twibot::Bot.new(Twibot::Config.new({:log_level => "error", :timeline_for => :friends})) 144 | bot.add_handler(:tweet, Twibot::Handler.new) 145 | bot.twitter.expects(:timeline_for).with(:friends, {}).returns([tweet("cjno", "Hei der!")]) 146 | 147 | assert_equal 1, bot.receive_tweets 148 | end 149 | 150 | should "remember received tweets" do 151 | bot = Twibot::Bot.new(Twibot::Config.new(:log_level => "error")) 152 | bot.add_handler(:tweet, Twibot::Handler.new) 153 | bot.twitter.expects(:timeline_for).with(:public, {}).returns([tweet("cjno", "Hei der!")]) 154 | assert_equal 1, bot.receive_tweets 155 | 156 | bot.twitter.expects(:timeline_for).with(:public, { :since_id => 1 }).returns([]) 157 | assert_equal 0, bot.receive_tweets 158 | end 159 | 160 | should "receive reply when tweet starts with login" do 161 | bot = Twibot::Bot.new(Twibot::Config.new(:log_level => "error", :login => "irbno")) 162 | bot.add_handler(:reply, Twibot::Handler.new) 163 | bot.twitter.expects(:status).with(:replies, {}).returns([tweet("cjno", "@irbno Hei der!")]) 164 | 165 | assert_equal 1, bot.receive_replies 166 | end 167 | 168 | should "remember received replies" do 169 | bot = Twibot::Bot.new(Twibot::Config.new(:log_level => "error", :login => "irbno")) 170 | bot.add_handler(:reply, Twibot::Handler.new) 171 | bot.twitter.expects(:status).with(:replies, {}).returns([tweet("cjno", "@irbno Hei der!")]) 172 | assert_equal 1, bot.receive_replies 173 | 174 | bot.twitter.expects(:status).with(:replies, { :since_id => 1 }).returns([]) 175 | assert_equal 0, bot.receive_replies 176 | end 177 | 178 | should "use public as default timeline method for tweet 'verb'" do 179 | bot = Twibot::Bot.new(Twibot::Config.default) 180 | assert_equal :public, bot.instance_eval { @config.to_hash[:timeline_for] } 181 | end 182 | 183 | context "sandboxed network errors" do 184 | should "rescue certain errors" do 185 | bot = Twibot::Bot.new(Twibot::Config.default) 186 | 187 | assert_nothing_raised do 188 | bot.send(:sandbox) { raise Twitter::RESTError.new } 189 | bot.send(:sandbox) { raise Errno::ECONNRESET.new } 190 | bot.send(:sandbox) { raise Timeout::Error.new } 191 | bot.send(:sandbox) { raise EOFError.new } 192 | bot.send(:sandbox) { raise Errno::ETIMEDOUT.new } 193 | bot.send(:sandbox) { raise JSON::ParserError.new } 194 | bot.send(:sandbox) { raise OpenSSL::SSL::SSLError.new } 195 | bot.send(:sandbox) { raise SystemStackError.new } 196 | end 197 | end 198 | 199 | should "return default value if error is rescued" do 200 | bot = Twibot::Bot.new(Twibot::Config.default) 201 | assert_equal(42, bot.send(:sandbox, 42) { raise Twitter::RESTError }) 202 | end 203 | 204 | should "not return default value when no error was raised" do 205 | bot = Twibot::Bot.new(Twibot::Config.default) 206 | assert_equal(65, bot.send(:sandbox, 42) { 65 }) 207 | end 208 | 209 | should "not swallow unknown errors" do 210 | bot = Twibot::Bot.new(Twibot::Config.default) 211 | 212 | assert_raise StandardError do 213 | bot.send(:sandbox) { raise StandardError.new "Oops!" } 214 | end 215 | end 216 | end 217 | end 218 | 219 | class TestBotMacros < Test::Unit::TestCase 220 | should "provide configure macro" do 221 | assert respond_to?(:configure) 222 | end 223 | 224 | should "yield configuration" do 225 | Twibot::Macros.bot = Twibot::Bot.new Twibot::Config.default 226 | bot.prompt = false 227 | 228 | conf = nil 229 | assert_nothing_raised { configure { |c| conf = c } } 230 | assert conf.is_a?(Twibot::Config) 231 | end 232 | 233 | should "add handler" do 234 | Twibot::Macros.bot = Twibot::Bot.new Twibot::Config.default 235 | bot.prompt = false 236 | 237 | handler = add_handler(:message, ":command", :from => :cjno) 238 | assert handler.is_a?(Twibot::Handler), handler.class 239 | end 240 | 241 | should "provide twitter macro" do 242 | assert respond_to?(:twitter) 243 | assert respond_to?(:client) 244 | end 245 | 246 | context "posting replies" do 247 | should "work with string messages" do 248 | text = "Hey there" 249 | status = Twitter::Status.new(:id => 123, 250 | :text => "Some text", 251 | :user => Twitter::User.new(:screen_name => "cjno")) 252 | client.expects(:status).with(:reply, "@cjno #{text}", 123).returns(true) 253 | 254 | assert post_reply(status, text) 255 | end 256 | 257 | should "work with status object messages" do 258 | reply = Twitter::Status.new :text => "Hey there" 259 | status = Twitter::Status.new(:id => 123, 260 | :text => "Some text", 261 | :user => Twitter::User.new(:screen_name => "cjno")) 262 | client.expects(:status).with(:reply, "@cjno Hey there", 123).returns(true) 263 | 264 | assert post_reply(status, reply) 265 | end 266 | end 267 | end 268 | 269 | class TestBotHandlers < Test::Unit::TestCase 270 | 271 | should "include handlers" do 272 | bot = Twibot::Bot.new(Twibot::Config.new) 273 | 274 | assert_not_nil bot.handlers 275 | assert_not_nil bot.handlers[:message] 276 | assert_not_nil bot.handlers[:reply] 277 | assert_not_nil bot.handlers[:tweet] 278 | end 279 | 280 | should "add handler" do 281 | bot = Twibot::Bot.new(Twibot::Config.new) 282 | bot.add_handler :message, Twibot::Handler.new 283 | assert_equal 1, bot.handlers[:message].length 284 | 285 | bot.add_handler :message, Twibot::Handler.new 286 | assert_equal 2, bot.handlers[:message].length 287 | 288 | bot.add_handler :reply, Twibot::Handler.new 289 | assert_equal 1, bot.handlers[:reply].length 290 | 291 | bot.add_handler :reply, Twibot::Handler.new 292 | assert_equal 2, bot.handlers[:reply].length 293 | 294 | bot.add_handler :tweet, Twibot::Handler.new 295 | assert_equal 1, bot.handlers[:tweet].length 296 | 297 | bot.add_handler :tweet, Twibot::Handler.new 298 | assert_equal 2, bot.handlers[:tweet].length 299 | end 300 | end 301 | -------------------------------------------------------------------------------- /lib/twibot/bot.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require File.join(File.expand_path(File.dirname(__FILE__)), 'macros') 3 | require File.join(File.expand_path(File.dirname(__FILE__)), 'handlers') 4 | 5 | module Twibot 6 | # 7 | # Main bot "controller" class 8 | # 9 | class Bot 10 | include Twibot::Handlers 11 | attr_reader :twitter 12 | attr_writer :prompt 13 | 14 | def initialize(options = nil, prompt = false) 15 | @prompt = prompt 16 | @conf = nil 17 | @config = options || Twibot::Config.default << Twibot::FileConfig.new << Twibot::CliConfig.new 18 | @log = nil 19 | @abort = false 20 | rescue Exception => krash 21 | raise SystemExit.new(krash.message) 22 | end 23 | 24 | def prompt? 25 | @prompt 26 | end 27 | 28 | def processed 29 | @processed ||= { 30 | :message => nil, 31 | :reply => nil, 32 | :tweet => nil, 33 | :search => {} 34 | } 35 | end 36 | 37 | def twitter 38 | @twitter ||= Twitter::Client.new(:login => config[:login], 39 | :password => config[:password], 40 | :host => config[:host]) 41 | end 42 | 43 | # 44 | # Run application 45 | # 46 | def run! 47 | puts "Twibot #{Twibot::VERSION} imposing as @#{login} on #{config[:host]}" 48 | 49 | trap(:INT) do 50 | puts "\nAnd it's a wrap. See ya soon!" 51 | exit 52 | end 53 | 54 | case config[:process] 55 | when :all, nil 56 | # do nothing so it will fetch ALL 57 | when :new 58 | # Make sure we don't process messages and tweets received prior to bot launch 59 | messages = twitter.messages(:received, { :count => 1 }) 60 | processed[:message] = messages.first.id if messages.length > 0 61 | 62 | handle_tweets = !handlers.nil? && handlers_for_type(:tweet).length + handlers_for_type(:reply).length + handlers_for_type(:search).keys.length > 0 63 | # handle_tweets ||= handlers_for_type(:search).keys.length > 0 64 | tweets = [] 65 | 66 | sandbox do 67 | tweets = handle_tweets ? twitter.timeline_for(config[:timeline_for], { :count => 1 }) : [] 68 | end 69 | 70 | processed[:tweet] = tweets.first.id if tweets.length > 0 71 | processed[:reply] = tweets.first.id if tweets.length > 0 72 | 73 | # for searches, use latest tweet on public timeline 74 | # 75 | if handle_tweets && config[:timeline_for].to_s != "public" 76 | sandbox { tweets = twitter.timeline_for(:public, { :count => 1 }) } 77 | end 78 | if tweets.length > 0 79 | handlers_for_type(:search).each_key {|q| processed[:search][q] = tweets.first.id } 80 | end 81 | 82 | load_followers 83 | 84 | when Numeric, /\d+/ # a tweet ID to start from 85 | processed[:tweet] = processed[:reply] = processed[:message] = config[:process] 86 | handlers[:search].each_key {|q| processed[:search][q] = config[:process] } 87 | else abort "Unknown process option #{config[:process]}, aborting..." 88 | end 89 | 90 | load_friends unless handlers_for_type(:follower).empty? 91 | 92 | poll 93 | end 94 | 95 | # 96 | # Poll Twitter API in a loop and pass on messages and tweets when they appear 97 | # 98 | def poll 99 | max = max_interval 100 | step = interval_step 101 | interval = min_interval 102 | 103 | while !@abort do 104 | run_hook :before_all 105 | message_count = 0 106 | message_count += receive_messages || 0 107 | message_count += receive_replies || 0 108 | message_count += receive_tweets || 0 109 | message_count += receive_searches || 0 110 | 111 | receive_followers 112 | 113 | run_hook :after_all, message_count 114 | 115 | interval = message_count > 0 ? min_interval : [interval + step, max].min 116 | 117 | log.debug "#{config[:host]} sleeping for #{interval}s" 118 | sleep interval 119 | end 120 | end 121 | 122 | 123 | def friend_ids 124 | @friend_ids ||= {} 125 | end 126 | 127 | def add_friend!(user_or_id, only_local=false) 128 | id = id_for_user_or_id(user_or_id) 129 | sandbox(0) { twitter.friend(:add, id) } unless only_local 130 | friend_ids[id] = true 131 | end 132 | 133 | def remove_friend!(user_or_id, only_local=false) 134 | id = id_for_user_or_id(user_or_id) 135 | sandbox(0) { twitter.friend(:remove, id) } unless only_local 136 | friend_ids[id] = false 137 | end 138 | 139 | def is_friend?(user_or_id) 140 | !!friend_ids[id_for_user_or_id(user_or_id)] 141 | end 142 | 143 | def follower_ids 144 | @follower_ids ||= {} 145 | end 146 | 147 | def add_follower!(user_or_id) 148 | follower_ids[id_for_user_or_id(user_or_id)] = true 149 | end 150 | 151 | def remove_follower!(user_or_id) 152 | follower_ids[id_for_user_or_id(user_or_id)] = false 153 | end 154 | 155 | def is_follower?(user_or_id) 156 | !!follower_ids[id_for_user_or_id(user_or_id)] 157 | end 158 | 159 | def id_for_user_or_id(user_or_id) 160 | (user_or_id.respond_to?(:screen_name) ? user_or_id.id : user_or_id).to_i 161 | end 162 | 163 | 164 | # 165 | # retrieve a list of friend ids and store it as a Hash 166 | # 167 | def load_friends 168 | sandbox(0) do 169 | twitter.graph(:friends, config[:login]).each {|id| add_friend!(id, true) } 170 | end 171 | end 172 | 173 | # 174 | # retrieve a list of friend ids and store it as a Hash 175 | # 176 | def load_followers 177 | sandbox(0) do 178 | twitter.graph(:followers, config[:login]).each {|id| add_follower!(id) } 179 | end 180 | end 181 | 182 | 183 | # 184 | # returns a Hash of all registered hooks 185 | # 186 | def hooks 187 | @hooks ||= {} 188 | end 189 | 190 | # 191 | # registers a block to be called at the given +event+ 192 | # 193 | def add_hook(event, &blk) 194 | hooks[event.to_sym] = blk 195 | end 196 | 197 | # 198 | # calls the hook method for the +event+ if one has 199 | # been defined 200 | # 201 | def run_hook(event, *args) 202 | hooks[event.to_sym].call(*args) if hooks[event.to_sym].respond_to? :call 203 | end 204 | 205 | # 206 | # Receive direct messages 207 | # 208 | def receive_messages 209 | type = :message 210 | return false unless handlers_for_type(type).length > 0 211 | options = {} 212 | options[:since_id] = processed[type] if processed[type] 213 | 214 | sandbox(0) do 215 | dispatch_messages(type, twitter.messages(:received, options), %w{message messages}) 216 | end 217 | end 218 | 219 | # 220 | # Receive tweets 221 | # 222 | def receive_tweets 223 | type = :tweet 224 | return false unless handlers_for_type(type).length > 0 225 | options = {} 226 | options[:since_id] = processed[type] if processed[type] 227 | 228 | sandbox(0) do 229 | dispatch_messages(type, twitter.timeline_for(config.to_hash[:timeline_for] || :public, options), %w{tweet tweets}) 230 | end 231 | end 232 | 233 | # 234 | # Receive tweets that start with @ 235 | # 236 | def receive_replies 237 | type = :reply 238 | return false unless handlers_for_type(type).length > 0 239 | options = {} 240 | options[:since_id] = processed[type] if processed[type] 241 | 242 | sandbox(0) do 243 | dispatch_messages(type, twitter.status(:replies, options), %w{reply replies}) 244 | end 245 | end 246 | 247 | # 248 | # Receive tweets that match the query parameters 249 | # 250 | def receive_searches 251 | result_count = 0 252 | 253 | handlers_for_type(:search).each_pair do |query, search_handlers| 254 | options = { :q => query, :rpp => 100 } 255 | [:lang, :geocode].each do |param| 256 | options[param] = search_handlers.first.options[param] if search_handlers.first.options[param] 257 | end 258 | options[:since_id] = processed[:search][query] if processed[:search][query] 259 | 260 | result_count += sandbox(0) do 261 | dispatch_messages([:search, query], twitter.search(options.merge(options)), %w{tweet tweets}.map {|l| "#{l} for \"#{query}\""}) 262 | end 263 | end 264 | 265 | result_count 266 | end 267 | 268 | # 269 | # Receive any new followers 270 | # 271 | def receive_followers 272 | newbies = [] 273 | sandbox(0) do 274 | twitter.graph(:followers, config[:login]).each {|id| newbies << id unless is_friend?(id) or is_follower?(id) } 275 | newbies.each do |id| 276 | add_follower!(id) 277 | with_hooks(:follower) { handlers_for_type(:follower).each {|h| h.handle(id, {}) } } 278 | end 279 | end 280 | log.info "#{config[:host]}: Received #{newbies.size} new #{newbies.size == 1 ? 'follower' : 'followers'}" 281 | end 282 | 283 | # 284 | # Dispatch a collection of messages 285 | # 286 | def dispatch_messages(type, messages, labels) 287 | messages.each {|message| with_hooks(type) { dispatch(type, message) } } 288 | # Avoid picking up messages over again 289 | if type.is_a? Array # [TODO] (mikedemers) this is an ugly hack 290 | processed[type.first][type.last] = messages.first.id if messages.length > 0 291 | else 292 | processed[type] = messages.first.id if messages.length > 0 293 | end 294 | 295 | num = messages.length 296 | log.info "#{config[:host]}: Received #{num} #{num == 1 ? labels[0] : labels[1]}" 297 | num 298 | end 299 | 300 | # 301 | # invokes the given block, running the before and 302 | # after hooks for the given type 303 | # 304 | def with_hooks(type, &blk) 305 | event = type.is_a?(Array) ? type.first : type 306 | run_hook :"before_#{event}" 307 | value = yield 308 | run_hook :"after_#{event}" 309 | value 310 | end 311 | 312 | # 313 | # Return logger instance 314 | # 315 | def log 316 | return @log if @log 317 | os = config[:log_file] ? File.open(config[:log_file], "a") : $stdout 318 | os.sync = !!config[:log_flush] 319 | @log = Logger.new(os) 320 | @log.level = Logger.const_get(config[:log_level] ? config[:log_level].upcase : "INFO") 321 | @log 322 | end 323 | 324 | # 325 | # Configure bot 326 | # 327 | def configure 328 | yield @config 329 | @conf = nil 330 | @twitter = nil 331 | end 332 | 333 | private 334 | # 335 | # Map configuration settings 336 | # 337 | def method_missing(name, *args, &block) 338 | return super unless config.key?(name) 339 | 340 | self.class.send(:define_method, name) { config[name] } 341 | config[name] 342 | end 343 | 344 | # 345 | # Return configuration 346 | # 347 | def config 348 | return @conf if @conf 349 | @conf = @config.to_hash 350 | 351 | if prompt? && (!@conf[:login] || !@conf[:password]) 352 | # No need to rescue LoadError - if the gem is missing then config will 353 | # be incomplete, something which will be detected elsewhere 354 | begin 355 | require 'highline' 356 | hl = HighLine.new 357 | 358 | @config.login = hl.ask("Twitter login: ") unless @conf[:login] 359 | @config.password = hl.ask("Twitter password: ") { |q| q.echo = '*' } unless @conf[:password] 360 | @conf = @config.to_hash 361 | rescue LoadError 362 | raise SystemExit.new( <<-HELP 363 | Unable to continue without login and password. Do one of the following: 364 | 1) Install the HighLine gem (gem install highline) to be prompted for credentials 365 | 2) Create a config/bot.yml with login: and password: 366 | 3) Put a configure { |conf| conf.login = "..." } block in your bot application 367 | 4) Run bot with --login and --password options 368 | HELP 369 | ) 370 | end 371 | end 372 | 373 | @conf 374 | end 375 | 376 | # 377 | # Takes a block and executes it in a sandboxed network environment. It 378 | # catches and logs most common network connectivity and timeout errors. 379 | # 380 | # The method takes an optional parameter. If set, this value will be 381 | # returned in case an error was raised. 382 | # 383 | def sandbox(return_value = nil) 384 | begin 385 | return_value = yield 386 | rescue Twitter::RESTError => e 387 | log.error("Failed to connect to Twitter. It's likely down for a bit:") 388 | log.error(e.to_s) 389 | rescue Errno::ECONNRESET => e 390 | log.error("Connection was reset") 391 | log.error(e.to_s) 392 | rescue Timeout::Error => e 393 | log.error("Timeout") 394 | log.error(e.to_s) 395 | rescue EOFError => e 396 | log.error(e.to_s) 397 | rescue Errno::ETIMEDOUT => e 398 | log.error("Timeout") 399 | log.error(e.to_s) 400 | rescue JSON::ParserError => e 401 | log.error("JSON Parsing error") 402 | log.error(e.to_s) 403 | rescue OpenSSL::SSL::SSLError => e 404 | log.error("SSL error") 405 | log.error(e.to_s) 406 | rescue SystemStackError => e 407 | log.error(e.to_s) 408 | end 409 | 410 | return return_value 411 | end 412 | end 413 | end 414 | 415 | # Expose DSL 416 | include Twibot::Macros 417 | 418 | # Run bot if macros has been used 419 | at_exit do 420 | raise $! if $! 421 | @@bot.run! if run? 422 | end 423 | --------------------------------------------------------------------------------