├── .gitignore ├── .ruby-version ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── basil.gemspec ├── bin └── basil ├── config └── example.yml ├── lib ├── basil.rb ├── basil │ ├── chat_history.rb │ ├── cli.rb │ ├── config.rb │ ├── daemon.rb │ ├── dispatchable.rb │ ├── email.rb │ ├── email │ │ ├── checker.rb │ │ └── mail.rb │ ├── http.rb │ ├── lock.rb │ ├── loggers.rb │ ├── message.rb │ ├── options.rb │ ├── plugin.rb │ ├── server.rb │ ├── skype.rb │ ├── skype_message.rb │ ├── storage.rb │ ├── timer.rb │ ├── utils.rb │ ├── version.rb │ └── worker.rb └── skype │ └── ext.rb ├── plugins ├── airbrake.rb ├── call_me.rb ├── canadize.rb ├── chuck_norris.rb ├── commit.rb ├── confluence.rb ├── defprogramming.rb ├── echo.rb ├── eval.rb ├── factoids.rb ├── fight.rb ├── git.rb ├── give.rb ├── google.rb ├── help.rb ├── isitdown.rb ├── its_a_trap.rb ├── jenkins.rb ├── jira.rb ├── karma.rb ├── messages.rb ├── piratize.rb ├── punish_geoff.rb ├── quotedb.rb ├── reload.rb ├── restart.rb ├── rock_paper_scissors.rb ├── rubygems.rb ├── seen.rb ├── shame.rb ├── test.rb ├── tweet.rb ├── version.rb └── you.rb └── spec ├── basil ├── chat_history_spec.rb ├── cli_spec.rb ├── config_spec.rb ├── daemon_spec.rb ├── dispatchable_spec.rb ├── email │ ├── checker_spec.rb │ └── mail_spec.rb ├── email_spec.rb ├── http_spec.rb ├── lock_spec.rb ├── message_spec.rb ├── plugin_spec.rb ├── server_spec.rb ├── skype_message_spec.rb ├── skype_spec.rb ├── timer_spec.rb ├── utils_spec.rb └── worker_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | doc 3 | coverage 4 | config/basil.yml 5 | *.pstore 6 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 1.9 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :test do 6 | gem "fakeweb" 7 | gem "rspec" 8 | gem "simplecov" 9 | end 10 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | basil (0.1.0.pre) 5 | fakefs 6 | faster_xml_simple 7 | log4r 8 | nokogiri 9 | ruby-dbus 10 | ruby-skype 11 | twitter 12 | 13 | GEM 14 | remote: http://rubygems.org/ 15 | specs: 16 | diff-lcs (1.1.3) 17 | fakefs (0.4.2) 18 | fakeweb (1.3.0) 19 | faraday (0.8.4) 20 | multipart-post (~> 1.1) 21 | faster_xml_simple (0.5.0) 22 | libxml-ruby (>= 0.3.8.4) 23 | libxml-ruby (2.4.0) 24 | log4r (1.1.10) 25 | multi_json (1.5.0) 26 | multipart-post (1.1.5) 27 | nokogiri (1.5.6) 28 | rspec (2.12.0) 29 | rspec-core (~> 2.12.0) 30 | rspec-expectations (~> 2.12.0) 31 | rspec-mocks (~> 2.12.0) 32 | rspec-core (2.12.2) 33 | rspec-expectations (2.12.1) 34 | diff-lcs (~> 1.1.3) 35 | rspec-mocks (2.12.1) 36 | ruby-dbus (0.9.0) 37 | ruby-skype (0.1.0.pre.2) 38 | simple_oauth (0.2.0) 39 | simplecov (0.7.1) 40 | multi_json (~> 1.0) 41 | simplecov-html (~> 0.7.1) 42 | simplecov-html (0.7.1) 43 | twitter (4.4.2) 44 | faraday (~> 0.8) 45 | multi_json (~> 1.3) 46 | simple_oauth (~> 0.2) 47 | 48 | PLATFORMS 49 | ruby 50 | 51 | DEPENDENCIES 52 | basil! 53 | fakeweb 54 | rspec 55 | simplecov 56 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 patrick brisbin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A skype bot. 2 | 3 | **NOTE**: It [appears][] the API this project uses is no longer 4 | available. That means this project is effectively dead. If/when 5 | another API becomes available that works across Linux and OSX, I 6 | will revive the project. 7 | 8 | [appears]: https://support.skype.com/en/faq/FA12349/skype-says-my-application-will-stop-working-with-skype-in-december-2013-why-is-that 9 | 10 | ## Installation 11 | 12 | Basil is meant to be run from source. Ruby is interpreted, so there's 13 | not much technical difference to this approach. 14 | 15 | It is recommended mainly because he expects `./plugins` and `./config` 16 | to exist and running from within a clone of this repo is the simplest, 17 | most transparent way to accomplish that. 18 | 19 | To get started, please head over to the [wiki][]. 20 | 21 | [wiki]: https://github.com/pbrisbin/basil/wiki 22 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rdoc/task' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new do |t| 5 | t.pattern = "./spec/**/*_spec.rb" 6 | t.rspec_opts = '-c' 7 | t.verbose = false 8 | end 9 | 10 | Rake::RDocTask.new do |rd| 11 | rd.title = 'Basil' 12 | rd.rdoc_dir = './doc' 13 | rd.rdoc_files.include("README.md", "lib/**/*.rb") 14 | end 15 | 16 | desc "shortcut to run basil in cli mode" 17 | task :cli do 18 | cmd = 'bundle exec bin/basil --cli' 19 | cmd += ' --debug' if ENV['DEBUG'] 20 | 21 | system(cmd) 22 | end 23 | 24 | task :default => :spec 25 | -------------------------------------------------------------------------------- /basil.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "basil/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "basil" 7 | s.version = Basil::VERSION 8 | s.authors = ["patrick brisbin"] 9 | s.email = ["pbrisbin@gmail.com"] 10 | s.homepage = "http://github.com/pbrisbin/basil" 11 | s.summary = "basil is a simple bot" 12 | s.description = "basil is a simple bot" 13 | 14 | s.files = `git ls-files`.split("\n") 15 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 16 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 17 | s.require_paths = ["lib"] 18 | s.licenses = ["MIT"] 19 | 20 | s.add_runtime_dependency "fakefs" 21 | s.add_runtime_dependency "faster_xml_simple" 22 | s.add_runtime_dependency "log4r" 23 | s.add_runtime_dependency "nokogiri" 24 | s.add_runtime_dependency "ruby-dbus" 25 | s.add_runtime_dependency "ruby-skype" 26 | s.add_runtime_dependency "twitter" 27 | end 28 | -------------------------------------------------------------------------------- /bin/basil: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'basil' 3 | 4 | Basil.run ARGV 5 | -------------------------------------------------------------------------------- /config/example.yml: -------------------------------------------------------------------------------- 1 | ######################################################################### 2 | # Main basil configuration. Basil will use the defaults shown for any 3 | # values you don't specify. 4 | ######################################################################### 5 | 6 | # Used to identify messages as to basil 7 | #me: basil 8 | 9 | # Plugin files are loaded from here 10 | #plugins_directory: ./plugins 11 | 12 | # Storage puts its data here 13 | #pstore_file: ./basil.pstore 14 | 15 | # Locking servers put their lock file here 16 | #lock_file: /tmp/basil.lock 17 | 18 | # When run as daemon, where to send our output 19 | #log_file: tmp/basil.log 20 | 21 | # When run as daemon, where to place our pid file 22 | #pid_file: tmp/basil.pid 23 | 24 | # Email will not be checked if not defined 25 | #email: 26 | #interval: 30 # seconds 27 | #server: imap.gmail.com 28 | #port: 993 29 | #username: X 30 | #password: X 31 | #inbox: INBOX 32 | #verify: true 33 | 34 | # This may be required on OSX for HTTPS requests to work 35 | #https_cert_file: /opt/local/share/curl/curl-ca-bundle.crt 36 | 37 | ######################################################################### 38 | # Additional configuration specific to plugins. It is not guaranteed 39 | # that plugins will work without specifying values for these. 40 | # 41 | # Note that you can add whatever keys you want here and access their 42 | # values from your own plugins simply with value = Basil::Config.key 43 | ######################################################################### 44 | 45 | jenkins: 46 | host: jenkins.example.com 47 | port: 80 48 | user: X 49 | password: X # Api key in newer versions 50 | broadcast_chat: "chat to hear about broken builds (name, not topic)" 51 | broadcast_chats: 52 | some-job: "chat to hear about broken builds for 'some-job'" 53 | another-job: "chat to hear about broken builds for 'another-job'" 54 | 55 | jira: 56 | host: jira.example.com 57 | port: 443 58 | user: X 59 | password: X 60 | 61 | confluence: 62 | host: confluence.example.com 63 | port: 443 64 | user: X 65 | password: X 66 | 67 | airbrake: 68 | account: some_name 69 | project: some_number # https://some_name.airbrakeapp.com//errors 70 | token: some_long_hash # available in airbrake admin settings 71 | 72 | twitter: 73 | consumer_key: xxx 74 | consumer_secret: xxx 75 | oauth_token: xxx 76 | oauth_token_secret: xxx 77 | -------------------------------------------------------------------------------- /lib/basil.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | 3 | Signal.trap('INT') { puts 'killed.'; exit 1 } 4 | 5 | at_exit do 6 | if (ex = $!) && !ex.is_a?(SystemExit) 7 | Basil.logger.debug "Exiting due to unhandled exception" 8 | Basil.logger.debug ex 9 | end 10 | end 11 | 12 | module Basil 13 | autoload :ChatHistory, 'basil/chat_history' 14 | autoload :Cli, 'basil/cli' 15 | autoload :Config, 'basil/config' 16 | autoload :Daemon, 'basil/daemon' 17 | autoload :Dispatchable, 'basil/dispatchable' 18 | autoload :Email, 'basil/email' 19 | autoload :HTTP, 'basil/http' 20 | autoload :Lock, 'basil/lock' 21 | autoload :Loggers, 'basil/loggers' 22 | autoload :Message, 'basil/message' 23 | autoload :Options, 'basil/options' 24 | autoload :Plugin, 'basil/plugin' 25 | autoload :Server, 'basil/server' 26 | autoload :Skype, 'basil/skype' 27 | autoload :SkypeMessage, 'basil/skype_message' 28 | autoload :Storage, 'basil/storage' 29 | autoload :Timer, 'basil/timer' 30 | autoload :Utils, 'basil/utils' 31 | autoload :VERSION, 'basil/version' 32 | autoload :Worker, 'basil/worker' 33 | 34 | Loggers.init! 35 | 36 | class << self 37 | extend Forwardable 38 | delegate [:respond_to, :watch_for, :check_email] => Plugin 39 | 40 | def run(argv) 41 | options = Options.new 42 | options.parse(argv) 43 | 44 | Config.load! 45 | 46 | case argv.first 47 | when 'start' 48 | Daemon.start(false) 49 | when 'stop' 50 | Daemon.stop 51 | when 'restart' 52 | Daemon.stop 53 | Daemon.start(false) 54 | else 55 | Daemon.start 56 | end 57 | 58 | rescue => ex 59 | logger.fatal ex 60 | 61 | exit 1 62 | end 63 | 64 | def logger 65 | @logger ||= Loggers['main'] 66 | end 67 | 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/basil/chat_history.rb: -------------------------------------------------------------------------------- 1 | module Basil 2 | module ChatHistory 3 | KEY = :chat_history 4 | LIM = 100 5 | 6 | class << self 7 | def store(obj) 8 | store_message(obj.to_message) 9 | end 10 | 11 | def store_message(message) 12 | with_history(message.chat) do |history| 13 | history.unshift(message) 14 | history.pop while history.length > LIM 15 | end 16 | end 17 | 18 | # Messages are returned most recent first. Valid option keys are 19 | # +:from+ and +:to+ which limit the results accordingly. 20 | def get_messages(chat, options = {}) 21 | with_history(chat) do |history| 22 | messages = history.dup 23 | 24 | if options.has_key?(:from) 25 | messages.keep_if { |msg| msg.from_name =~ /#{options[:from]}/i } 26 | end 27 | 28 | if options.has_key?(:to) 29 | messages.keep_if { |msg| msg.to =~ /#{options[:to]}/i } 30 | end 31 | 32 | messages 33 | end 34 | end 35 | 36 | def clear_history(chat) 37 | with_history(chat, &:clear) 38 | end 39 | 40 | private 41 | 42 | def with_history(chat, &block) 43 | Storage.with_storage do |store| 44 | store[KEY] ||= {} 45 | store[KEY][chat] ||= [] 46 | 47 | yield(store[KEY][chat]) 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/basil/cli.rb: -------------------------------------------------------------------------------- 1 | module Basil 2 | class Cli < Server 3 | def main_loop 4 | loop { print '> '; yield } 5 | end 6 | 7 | def accept_message(*args) 8 | Message.new( 9 | :to => Config.me, 10 | :from => ENV['USER'], 11 | :text => $stdin.gets.chomp, 12 | :chat => 'cli' 13 | ) 14 | end 15 | 16 | def send_message(msg) 17 | puts msg.text 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/basil/config.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | 3 | module Basil 4 | module Config 5 | DEFAULTS = { 6 | 'me' => 'basil', 7 | 'server_class' => Skype, 8 | 'lock_file' => File.join('', 'tmp', 'basil.lock'), # NB: /tmp 9 | 'plugins_directory' => File.join('plugins'), 10 | 'pstore_file' => File.join('basil.pstore'), 11 | 'config_file' => File.join('config', 'basil.yml'), 12 | 'log_file' => File.join('tmp', 'basil.log'), 13 | 'pid_file' => File.join('tmp', 'basil.pid'), 14 | 'email' => {}, 15 | 'extras' => {} 16 | } 17 | 18 | class << self 19 | attr_writer(*DEFAULTS.keys) 20 | 21 | def method_missing(key, *) 22 | attribute(key) || extras["#{key}"] 23 | end 24 | 25 | def load! 26 | if config_file && File.exists?(config_file) 27 | set_attributes(YAML::load(File.read(config_file))) 28 | end 29 | rescue => ex 30 | Basil.logger.warn "Error loading #{config_file}:" 31 | Basil.logger.warn ex 32 | end 33 | 34 | attr_writer :server 35 | 36 | def server 37 | @server ||= server_class.new 38 | end 39 | 40 | attr_writer :background 41 | 42 | def background? 43 | @background && !cli? 44 | end 45 | 46 | def foreground? 47 | !background? 48 | end 49 | 50 | def check_email? 51 | !( cli? || email.empty? ) 52 | end 53 | 54 | def cli? 55 | server.is_a?(Cli) 56 | end 57 | 58 | def hide(&block) 59 | current = extras 60 | self.extras = {} 61 | 62 | yield 63 | 64 | ensure 65 | self.extras = current 66 | end 67 | 68 | private 69 | 70 | def set_attributes(hsh) 71 | DEFAULTS.keys.each do |key| 72 | if hsh.has_key?(key) 73 | value = hsh.delete(key) 74 | send("#{key}=", value) 75 | end 76 | end 77 | 78 | # leftovers go into #extra 79 | self.extras = hsh 80 | end 81 | 82 | def attribute(key) 83 | ivar = :"@#{key}" 84 | 85 | if instance_variables.include?(ivar) 86 | instance_variable_get(ivar) 87 | else 88 | DEFAULTS["#{key}"] 89 | end 90 | end 91 | 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/basil/daemon.rb: -------------------------------------------------------------------------------- 1 | module Basil 2 | module Daemon 3 | class << self 4 | 5 | def start(foreground = Config.foreground?) 6 | if foreground 7 | Config.server.start 8 | else 9 | pid = fork_process 10 | puts "forked. pid: #{pid}" 11 | end 12 | end 13 | 14 | def stop 15 | pid = File.read(Config.pid_file).strip rescue nil 16 | pid && system("kill #{pid}") 17 | end 18 | 19 | private 20 | 21 | def fork_process 22 | fork do 23 | redirect_io 24 | 25 | logger.info "=== Started: #{Process.pid} ===" 26 | 27 | File.open(Config.pid_file, 'w') do |fh| 28 | fh.puts "#{Process.pid}" 29 | end 30 | 31 | Config.server.start 32 | end 33 | end 34 | 35 | def redirect_io 36 | quietly { STDIN.reopen('/dev/null') } 37 | 38 | ex = quietly do 39 | STDOUT.reopen(Config.log_file, 'a') 40 | STDOUT.sync = true 41 | end 42 | 43 | if ex && ex.is_a?(Exception) 44 | logger.warn ex 45 | logger.warn 'Closing stdout entirely' 46 | 47 | quietly { STDOUT.reopen('/dev/null') } 48 | end 49 | 50 | quietly do 51 | STDERR.reopen(STDOUT) 52 | STDERR.sync = true 53 | end 54 | end 55 | 56 | def quietly(&block) 57 | yield 58 | rescue Exception => ex 59 | ex 60 | end 61 | 62 | def logger 63 | @logger ||= Loggers['daemon'] 64 | end 65 | 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/basil/dispatchable.rb: -------------------------------------------------------------------------------- 1 | module Basil 2 | module Dispatchable 3 | def dispatch 4 | ChatHistory.store(self) 5 | 6 | logger.debug "Dispatching #{self}" 7 | 8 | each_plugin do |plugin| 9 | begin 10 | plugin.execute_on(self) 11 | rescue => ex 12 | logger.warn ex 13 | end 14 | end 15 | rescue => ex 16 | logger.warn ex 17 | end 18 | 19 | def match?(plugin) 20 | raise NotImplementedError, "#{self.class} must implement #{__method__}" 21 | end 22 | 23 | def each_plugin(&block) 24 | raise NotImplementedError, "#{self.class} must implement #{__method__}" 25 | end 26 | 27 | def to_message 28 | raise NotImplementedError, "#{self.class} must implement #{__method__}" 29 | end 30 | 31 | private 32 | 33 | def logger 34 | @logger ||= Loggers['dispatching'] 35 | end 36 | 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/basil/email.rb: -------------------------------------------------------------------------------- 1 | module Basil 2 | module Email 3 | autoload :Checker, 'basil/email/checker' 4 | autoload :Mail, 'basil/email/mail' 5 | 6 | class << self 7 | 8 | attr_reader :thread 9 | 10 | def check 11 | interval = Config.email['interval'] || 30 12 | 13 | @thread = Timer.new(:sleep => interval) do 14 | Worker.new do 15 | checker = Checker.new 16 | checker.run 17 | end 18 | end 19 | end 20 | 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/basil/email/checker.rb: -------------------------------------------------------------------------------- 1 | require 'net/imap' 2 | 3 | module Basil 4 | module Email 5 | class Checker 6 | def run 7 | with_imap do |imap| 8 | imap.search(['NOT', 'DELETED']).each do |message_id| 9 | logger.debug "Found message #{message_id}" 10 | handle_message_id(imap, message_id) 11 | end 12 | end 13 | rescue => ex 14 | logger.error ex 15 | end 16 | 17 | private 18 | 19 | def handle_message_id(imap, message_id) 20 | mail = Mail.parse(imap.fetch(message_id, 'RFC822').first.attr['RFC822']) 21 | mail and mail.dispatch 22 | logger.debug "Handled message #{message_id}" 23 | rescue => ex 24 | logger.error ex 25 | ensure 26 | delete_message_id(imap, message_id) 27 | end 28 | 29 | def delete_message_id(imap, message_id) 30 | imap.store(message_id, "+FLAGS", [:Deleted]) 31 | logger.debug "Deleted message #{message_id}" 32 | rescue => ex 33 | logger.error ex 34 | end 35 | 36 | def with_imap(config = Config.email, &block) 37 | imap = connect_to_imap(config) 38 | 39 | yield imap 40 | 41 | ensure 42 | disconnect(imap) if imap 43 | end 44 | 45 | def connect_to_imap(config) 46 | logger.debug "Connecting to IMAP" 47 | imap = Net::IMAP.new( 48 | config['server'], 49 | config['port'], 50 | config.fetch('verify', true) 51 | ) 52 | imap.login(config['username'], config['password']) 53 | imap.select(config['inbox']) 54 | 55 | imap 56 | end 57 | 58 | def disconnect(imap) 59 | imap.logout 60 | imap.disconnect 61 | logger.debug "Disconnected from IMAP" 62 | end 63 | 64 | def logger 65 | @logger ||= Loggers['email'] 66 | end 67 | 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/basil/email/mail.rb: -------------------------------------------------------------------------------- 1 | module Basil 2 | module Email 3 | # This class represents a parsed email. Headers are accessed like 4 | # array indices and body is provided as a method. The parsing is 5 | # naive, but it works for our purpose. 6 | class Mail 7 | include Dispatchable 8 | 9 | attr_reader :body 10 | 11 | def self.parse(content) 12 | header_lines = [] 13 | headers = {} 14 | 15 | lines = content.split(/\r\n/) 16 | 17 | while !(line = lines.shift).empty? 18 | if line =~ /^\s+(.*)/ # continuation 19 | last = header_lines.pop 20 | line = "#{last} #{$1}" if last 21 | end 22 | 23 | header_lines << line 24 | end 25 | 26 | body = lines.join("\n") 27 | 28 | header_lines.each do |hl| 29 | if hl =~ /^([^:]+):(.*)$/ 30 | headers[$1] = $2.strip 31 | end 32 | end 33 | 34 | new(headers, body) 35 | end 36 | 37 | def initialize(headers, body) 38 | @headers, @body = headers, body 39 | end 40 | 41 | def [](arg) 42 | @headers[arg] 43 | end 44 | 45 | def each_plugin(&block) 46 | Plugin.email_checkers.each(&block) 47 | end 48 | 49 | def match?(plugin) 50 | plugin.match?(self['Subject']) 51 | end 52 | 53 | def to_message 54 | Message.new( 55 | :to => Config.me, 56 | :from => self['From'], 57 | :text => body, 58 | :chat => 'email' 59 | ) 60 | end 61 | 62 | def to_s 63 | "#" 64 | end 65 | 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/basil/http.rb: -------------------------------------------------------------------------------- 1 | module Basil 2 | module HTTP 3 | class << self 4 | # 5 | # get(url) OR 6 | # 7 | # get(options) 8 | # options['host'] - required 9 | # options['port'] - optional, defaults to 80 10 | # options['path'] - optional, defaults to / 11 | # options['username'] - optional 12 | # options['password'] - optional 13 | # 14 | # This method handles mostly params sanitization and logging, with 15 | # the heavy lifting done by a Request instance. 16 | # 17 | def get(options) 18 | if options.is_a?(String) # simple url 19 | options = from_url(options) 20 | end 21 | 22 | logger.debug 'GET' 23 | logger.debug mask(options) 24 | 25 | request = Request.new(options) 26 | response = request.get 27 | 28 | unless response.is_a?(Net::HTTPOK) 29 | logger.warn 'Non-200 HTTP Response' 30 | logger.warn response 31 | end 32 | 33 | response 34 | end 35 | 36 | private 37 | 38 | def from_url(url) 39 | uri = URI.parse(url) 40 | 41 | {}.tap do |options| 42 | options['host'] = uri.host 43 | options['port'] = uri.port 44 | options['path'] = uri.path 45 | options['path'] << "?#{uri.query}" if uri.query 46 | end 47 | end 48 | 49 | def mask(options) 50 | if options.has_key?('password') 51 | options.merge('password' => 'xxx') 52 | else 53 | options 54 | end 55 | end 56 | 57 | def logger 58 | @logger ||= Loggers['http'] 59 | end 60 | end 61 | 62 | class Request 63 | def initialize(options) 64 | @host = options.fetch('host') { raise ArgumentError, "options['host'] is required" } 65 | @port = options.fetch('port', 80) 66 | @username = options['user'] 67 | @password = options['password'] 68 | 69 | # surprisingly, URI.parse can give you an empty path which is 70 | # not valid for Net::HTTP. sigh. 71 | @path = options['path'] 72 | @path = '/' if path.nil? || path.empty? 73 | end 74 | 75 | def get 76 | require(secure? ? 'net/https' : 'net/http') 77 | 78 | net = Net::HTTP.new(host, port) 79 | 80 | if secure? 81 | net.use_ssl = true 82 | net.ca_file = Config.https_cert_file # OSX fix 83 | end 84 | 85 | net.start do |http| 86 | req = Net::HTTP::Get.new(path) 87 | req.basic_auth(username, password) if authenticate? 88 | 89 | http.request(req) 90 | end 91 | end 92 | 93 | def secure? 94 | port == 443 95 | end 96 | 97 | private 98 | 99 | attr_reader :host, :port, :path, :username, :password 100 | 101 | def authenticate? 102 | !!( username || password ) 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/basil/lock.rb: -------------------------------------------------------------------------------- 1 | module Basil 2 | module Lock 3 | class << self 4 | 5 | def guard(&block) 6 | error if File.exists?(Config.lock_file) 7 | 8 | begin 9 | File.open(Config.lock_file, 'w') { } 10 | 11 | yield 12 | 13 | ensure 14 | File.unlink(Config.lock_file) 15 | end 16 | end 17 | 18 | def error 19 | raise <<-EOM 20 | 21 | Lock file present at #{Config.lock_file}! If you're sure no 22 | other process is running, remove this file and try again. 23 | 24 | EOM 25 | end 26 | 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/basil/loggers.rb: -------------------------------------------------------------------------------- 1 | require 'log4r' 2 | 3 | module Basil 4 | module Loggers 5 | LOGGER_NAMES = %w( 6 | daemon 7 | dispatching 8 | email 9 | http 10 | main 11 | plugins 12 | server 13 | workers 14 | ) 15 | 16 | class << self 17 | include Log4r 18 | 19 | def init! 20 | outputter = StdoutOutputter.new('stdout') 21 | 22 | Logger.global.level = INFO 23 | 24 | LOGGER_NAMES.each do |name| 25 | logger = Logger.new(name) 26 | logger.add(outputter) 27 | end 28 | end 29 | 30 | def [](name) 31 | # an invalid name returns the root logger (a NullObject) 32 | Logger[name] || Logger.global 33 | end 34 | 35 | def level=(level) 36 | Logger.each do |_,logger| 37 | logger.level = level 38 | end 39 | end 40 | 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/basil/message.rb: -------------------------------------------------------------------------------- 1 | module Basil 2 | class Message 3 | include Dispatchable 4 | 5 | attr_reader :from, :from_name, :text, :time 6 | attr_accessor :to, :chat 7 | 8 | def self.from_message(message, options = {}) 9 | args = { 10 | :to => message.to, 11 | :from => message.from, 12 | :from_name => message.from_name, 13 | :text => message.text, 14 | :chat => message.chat 15 | }.merge(options) 16 | 17 | new(args) 18 | end 19 | 20 | def initialize(options) 21 | @from = options.fetch(:from) { raise ArgumentError, 'from is required' } 22 | 23 | @to = options[:to] 24 | @from_name = options.fetch(:from_name, @from) 25 | @chat = options[:chat] 26 | @text = options.fetch(:text, '') 27 | @time = Time.now 28 | end 29 | 30 | def each_plugin(&block) 31 | Plugin.responders.each(&block) if to_me? 32 | Plugin.watchers.each(&block) 33 | end 34 | 35 | def match?(plugin) 36 | plugin.match?(text) 37 | end 38 | 39 | def to_me? 40 | to && to.downcase == Config.me.downcase 41 | end 42 | 43 | def say(text) 44 | server.send_message( 45 | Message.from_message(self, :to => nil, :text => text) 46 | ) 47 | end 48 | 49 | def reply(text) 50 | server.send_message( 51 | Message.from_message(self, :to => self.from_name, :text => text) 52 | ) 53 | end 54 | 55 | def forward(to) 56 | server.send_message( 57 | Message.from_message(self, :to => to) 58 | ) 59 | end 60 | 61 | def to_message 62 | self 63 | end 64 | 65 | def to_s 66 | "#" 67 | end 68 | 69 | private 70 | 71 | def server 72 | Config.server 73 | end 74 | 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/basil/options.rb: -------------------------------------------------------------------------------- 1 | require 'optparse' 2 | 3 | module Basil 4 | class Options 5 | attr_reader :config 6 | 7 | def initialize(config = Config) 8 | @config = config 9 | end 10 | 11 | def parse(argv) 12 | OptionParser.new do |o| 13 | o.banner = 'usage: basil [options] [start|stop|restart]' 14 | o.separator '' 15 | o.on( '--cli', 'Run the CLI server' ) { config.server = Cli.new } 16 | o.on( '--debug', 'Log at the DEBUG level') { Loggers.level = 0 } # DEBUG 17 | o.on( '--quiet', 'Turn off all logging' ) { Loggers.level = 6 } # OFF 18 | o.separator '' 19 | o.on( '--daemon', 'Daemonize self' ) { config.background = true } 20 | o.on( '--pid-file FILE', 'PID file location') { |f| config.pid_file = f } 21 | o.on( '--log-file FILE', 'Log file location') { |f| config.log_file = f } 22 | o.separator '' 23 | end.parse!(argv) 24 | 25 | argv 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/basil/plugin.rb: -------------------------------------------------------------------------------- 1 | module Basil 2 | class Plugin 3 | include Utils 4 | 5 | private_class_method :new 6 | 7 | # Look for regex only in messages that are to basil 8 | def self.respond_to(regex, &block) 9 | new(regex, &block).tap { |p| responders << p } 10 | end 11 | 12 | # Look for regex in any messages sent in the chat 13 | def self.watch_for(regex, &block) 14 | new(regex, &block).tap { |p| watchers << p } 15 | end 16 | 17 | # Look for regex in the subject of any emails basil receives 18 | def self.check_email(regex, &block) 19 | new(regex, &block).tap { |p| email_checkers << p } 20 | end 21 | 22 | def self.responders 23 | @responders ||= [] 24 | end 25 | 26 | def self.watchers 27 | @watchers ||= [] 28 | end 29 | 30 | def self.email_checkers 31 | @email_checkers ||= [] 32 | end 33 | 34 | def self.load! 35 | dir = Config.plugins_directory 36 | 37 | if Dir.exists?(dir) 38 | Dir.glob("#{dir}/*").sort.each do |f| 39 | begin load(f) 40 | rescue Exception => ex 41 | Basil.logger.warn ex 42 | end 43 | end 44 | end 45 | end 46 | 47 | attr_accessor :description 48 | 49 | def initialize(regex, &block) 50 | @regex = regex.is_a?(String) ? Regexp.new("^#{regex}$") : regex 51 | 52 | define_singleton_method(:execute, &block) 53 | end 54 | 55 | def match?(text) 56 | regex.match(text) 57 | end 58 | 59 | def execute_on(obj) 60 | @msg = obj.to_message 61 | @match_data = obj.match?(self) or return 62 | 63 | logger.debug "Executing #{self} (matched: #{@match_data})" 64 | 65 | execute 66 | end 67 | 68 | def to_s 69 | "#" 70 | end 71 | 72 | def has_help? 73 | !!description 74 | end 75 | 76 | def help_text 77 | "#{regex.inspect} => #{description}." 78 | end 79 | 80 | private 81 | 82 | attr_reader :regex 83 | 84 | def logger 85 | @logger ||= Loggers['plugins'] 86 | end 87 | 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/basil/server.rb: -------------------------------------------------------------------------------- 1 | module Basil 2 | class Server 3 | # Redefines #start to be wrapped in a lock file, ensuring no more 4 | # than one instance of your server can be run at a time 5 | def self.lock_start 6 | alias_method :original_start, :start 7 | 8 | define_method(:start) do 9 | Lock.guard do 10 | original_start 11 | end 12 | end 13 | end 14 | 15 | def start 16 | Plugin.load! 17 | 18 | Email.check if Config.check_email? 19 | 20 | main_loop do |*args| 21 | msg = accept_message(*args) 22 | msg and msg.dispatch 23 | end 24 | end 25 | 26 | def main_loop 27 | raise NotImplementedError, "#{self.class} must implement #{__method__}" 28 | end 29 | 30 | def accept_message(*args) 31 | raise NotImplementedError, "#{self.class} must implement #{__method__}" 32 | end 33 | 34 | def send_message(msg) 35 | raise NotImplementedError, "#{self.class} must implement #{__method__}" 36 | end 37 | 38 | private 39 | 40 | def logger 41 | @logger ||= Loggers['server'] 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/basil/skype.rb: -------------------------------------------------------------------------------- 1 | require 'skype' 2 | require 'skype/ext' 3 | 4 | module Basil 5 | class Skype < Server 6 | 7 | lock_start 8 | 9 | def main_loop 10 | skype.on_chatmessage_received { |id| yield(id) } 11 | skype.connect 12 | skype.run 13 | end 14 | 15 | def accept_message(message_id) 16 | logger.info "Accepting #{message_id}" 17 | 18 | msg = SkypeMessage.new(skype, message_id) 19 | 20 | Message.new( 21 | :from => msg.from_handle, 22 | :from_name => msg.from_dispname, 23 | :to => msg.to, 24 | :chat => msg.chatname, 25 | :text => msg.text 26 | ) 27 | 28 | rescue ::Skype::Errors::GeneralError => ex 29 | logger.error ex; nil 30 | end 31 | 32 | def send_message(msg) 33 | logger.info "Sending \"#{msg.text}\" to #{msg.chat}" 34 | 35 | prefix = msg.to && "#{msg.to.split(' ').first}, " 36 | 37 | skype.connect unless skype.connected? 38 | skype.message_chat(msg.chat, "#{prefix}#{msg.text}") 39 | 40 | rescue ::DBus::InvalidPacketException => ex 41 | logger.error ex; nil # TODO: find out why this happens 42 | rescue ::Skype::Errors::GeneralError => ex 43 | logger.error ex; nil 44 | end 45 | 46 | private 47 | 48 | def skype 49 | @skype ||= ::Skype.new(Config.me) 50 | end 51 | 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/basil/skype_message.rb: -------------------------------------------------------------------------------- 1 | module Basil 2 | class SkypeMessage 3 | # We can assume this always matches since the first group is 4 | # optional and the second group is effectively +.*+ 5 | BODY_MASK = 6 | / 7 | ^( @(?\w+)[,;:]?\s+ | # @name style 8 | (?\w+)[,;:]\s+ # using punctuation 9 | )? 10 | (?.*)$ # rest of message 11 | /mx 12 | 13 | attr_reader :chatname, 14 | :from_handle, 15 | :from_dispname, 16 | :body 17 | 18 | def initialize(skype, message_id) 19 | @chatname = skype.get("CHATMESSAGE #{message_id} CHATNAME") 20 | @from_handle = skype.get("CHATMESSAGE #{message_id} FROM_HANDLE") 21 | @from_dispname = skype.get("CHATMESSAGE #{message_id} FROM_DISPNAME") 22 | @body = skype.get("CHATMESSAGE #{message_id} BODY") 23 | @private_chat = skype.get("CHAT #{chatname} MEMBERS").split(' ').length == 2 24 | end 25 | 26 | def to 27 | matched_body[:to] 28 | end 29 | 30 | def text 31 | matched_body[:text] 32 | end 33 | 34 | private 35 | 36 | def private_chat? 37 | @private_chat 38 | end 39 | 40 | def matched_body 41 | @matched_body ||= BODY_MASK.match(adjusted_body) 42 | end 43 | 44 | def adjusted_body 45 | if body =~ /^(!|>)/ 46 | return body.sub(/^!\s*/, "#{Config.me}, ") 47 | .sub(/^>\s*/, "#{Config.me}, eval ") 48 | end 49 | 50 | private_chat? ? "#{Config.me}, #{body}" : body 51 | end 52 | 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/basil/storage.rb: -------------------------------------------------------------------------------- 1 | require "pstore" 2 | 3 | module Basil 4 | module Storage 5 | class << self 6 | 7 | def with_storage(&block) 8 | pstore.transaction do 9 | yield pstore 10 | end 11 | end 12 | 13 | private 14 | 15 | def pstore 16 | @pstore ||= PStore.new(Config.pstore_file) 17 | end 18 | 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/basil/timer.rb: -------------------------------------------------------------------------------- 1 | module Basil 2 | class Timer 3 | DEFAULT_SLEEP = 30 4 | 5 | def initialize(options = {}) 6 | once = options.fetch(:once, false) 7 | sleep_time = options.fetch(:sleep, DEFAULT_SLEEP) 8 | sleep_before = options.fetch(:sleep_before, 0) 9 | sleep_after = options.fetch(:sleep_after, sleep_time) 10 | 11 | @thread = Thread.new do 12 | loop do 13 | sleep(sleep_before) 14 | 15 | yield if block_given? 16 | 17 | break if once 18 | 19 | sleep(sleep_after) 20 | end 21 | end 22 | end 23 | 24 | def method_missing(*args, &block) 25 | @thread.send(*args, &block) 26 | end 27 | 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/basil/utils.rb: -------------------------------------------------------------------------------- 1 | module Basil 2 | module Utils 3 | # Simply returns the +chat+ attribute for the Message currently 4 | # being handled. 5 | def chat 6 | @msg.chat 7 | end 8 | 9 | # Accesses chat history 10 | def chat_history(options = {}) 11 | chat = options.delete(:chat) 12 | 13 | ChatHistory.get_messages(chat || self.chat, options) 14 | end 15 | 16 | # Purges chat history 17 | def purge_history!(chat = self.chat) 18 | ChatHistory.clear(chat) 19 | end 20 | 21 | def trim(str) 22 | str.lines.map(&:strip).join("\n") 23 | end 24 | 25 | def escape(str) 26 | require 'cgi' 27 | CGI::escape("#{str}".strip) 28 | end 29 | 30 | # See Basil::HTTP.get 31 | def get_http(options) 32 | HTTP.get(options) 33 | end 34 | 35 | # Pass-through to get_http but yields to the block for conversion 36 | # (see get_json, xml or html for uses). 37 | def parse_http(*args, &block) 38 | resp = get_http(*args) 39 | yield resp.body if resp 40 | end 41 | 42 | def get_json(*args) 43 | require 'json' 44 | parse_http(*args) { |b| JSON.parse(b) } 45 | end 46 | 47 | def get_xml(*args) 48 | require 'faster_xml_simple' 49 | parse_http(*args) { |b| FasterXmlSimple.xml_in(b) } 50 | end 51 | 52 | def get_html(*args) 53 | require 'nokogiri' 54 | parse_http(*args) { |b| Nokogiri::HTML.parse(b) } 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/basil/version.rb: -------------------------------------------------------------------------------- 1 | module Basil 2 | VERSION = "0.1.0.pre" 3 | end 4 | -------------------------------------------------------------------------------- /lib/basil/worker.rb: -------------------------------------------------------------------------------- 1 | module Basil 2 | class Worker 3 | TIMEOUT = 30 4 | 5 | attr_reader :pid, :exitstatus 6 | 7 | def initialize(&block) 8 | @pid = Process.fork(&block) 9 | 10 | logger.debug "#{pid}: spawned" 11 | 12 | t = monitor(pid) 13 | 14 | Process.wait(pid) 15 | @exitstatus = $?.exitstatus 16 | 17 | logger.debug "#{pid}: exited (status: #{exitstatus})" 18 | 19 | t.exit if t.alive? 20 | end 21 | 22 | private 23 | 24 | def monitor(pid) 25 | options = { 26 | :once => true, 27 | :sleep_before => TIMEOUT 28 | } 29 | 30 | Timer.new(options) do 31 | logger.error "killing #{pid} (timed out)" 32 | system("kill -9 #{pid}") 33 | end 34 | end 35 | 36 | def logger 37 | @logger ||= Loggers['workers'] 38 | end 39 | 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/skype/ext.rb: -------------------------------------------------------------------------------- 1 | class Skype 2 | # 3 | # Overrides 4 | # 5 | def received_command(command_str) 6 | cmd, args = command_str.split(/\s+/, 2) 7 | 8 | return unless cmd 9 | 10 | listeners[cmd.downcase.to_sym].each do |block| 11 | block.call(args) 12 | end 13 | end 14 | 15 | # 16 | # Extensions 17 | # 18 | def get(property) 19 | response = send_raw_command("GET #{property}") 20 | response[property.length..-1].strip 21 | end 22 | 23 | def message_chat(name, message) 24 | send_raw_command("CHATMESSAGE #{name} #{message}") 25 | end 26 | 27 | def on(event, &block) 28 | listeners[event] << block if block 29 | end 30 | 31 | def on_chatmessage_received(&block) 32 | on(:chatmessage) do |args| 33 | id, _, status = args.split(' ') 34 | yield(id) if status && status == 'RECEIVED' 35 | end 36 | end 37 | 38 | private 39 | 40 | def listeners 41 | @listeners ||= Hash.new { |h,k| h[k] = [] } 42 | end 43 | 44 | end 45 | -------------------------------------------------------------------------------- /plugins/airbrake.rb: -------------------------------------------------------------------------------- 1 | class Airbrake 2 | HOST ||= [Basil::Config.airbrake['account'], 'airbrake', 'io'].join('.') 3 | 4 | class ErrorGroup 5 | def initialize(group_xml) 6 | @group = group_xml 7 | end 8 | 9 | def to_s 10 | most_recent_at = @group['most-recent-notice-at']['__content__'] 11 | notices_count = @group['notices-count']['__content__'] 12 | error_id = @group['id']['__content__'] 13 | error_message = @group['error-message'] 14 | error_class = @group['error-class'] 15 | 16 | <<-EOS 17 | --- 18 | ##{error_id}(#{notices_count}) last seen:#{most_recent_at} 19 | #{error_class}: #{error_message} 20 | => https://#{HOST}/errors/#{error_id} 21 | EOS 22 | end 23 | end 24 | 25 | def initialize(plugin) 26 | # we assume we only care about one environment and it's specified 27 | # in the config file as project. 28 | project = "#{Basil::Config.airbrake['project']}" 29 | token = "#{Basil::Config.airbrake['token']}" 30 | 31 | path = if project != '' 32 | "/projects/#{project}/errors.xml?auth_token=#{token}" 33 | else 34 | "/errors.xml?auth_token=#{token}" 35 | end 36 | 37 | @xml = plugin.get_xml('host' => HOST, 'port' => 443, 'path' => path) 38 | end 39 | 40 | def groups(limit = 5) 41 | @xml['groups']['group'].take(limit).map do |g| 42 | ErrorGroup.new(g) 43 | end 44 | end 45 | end 46 | 47 | Basil.respond_to(/^(show me )?airbrake( errors)?/i) { 48 | 49 | @msg.reply "5 most recent airbrake errors:" 50 | 51 | Airbrake.new(self).groups.each do |g| 52 | @msg.say trim("#{g}") 53 | end 54 | 55 | }.description = "shows the five most recent airbrake errors in production." 56 | -------------------------------------------------------------------------------- /plugins/call_me.rb: -------------------------------------------------------------------------------- 1 | # 2 | # basil, call me a taxi 3 | # => fine, you're a taxi. 4 | # 5 | Basil.respond_to(/^call me a (.*)/) { 6 | 7 | @msg.say "fine, you're a #{@match_data[1]}." 8 | 9 | } 10 | -------------------------------------------------------------------------------- /plugins/canadize.rb: -------------------------------------------------------------------------------- 1 | Basil.respond_to(/canadize (.+)/) { 2 | 3 | @msg.say "#{@match_data[1]}, eh?" 4 | 5 | } 6 | -------------------------------------------------------------------------------- /plugins/chuck_norris.rb: -------------------------------------------------------------------------------- 1 | Basil.watch_for(/(chuck norris|jack bauer)/i) { 2 | 3 | query = @match_data[0].downcase == 'jack bauer' ? '?firstName=Jack&lastName=Bauer' : '' 4 | @msg.say get_json("http://api.icndb.com/jokes/random#{query}")['value']['joke'] 5 | 6 | } 7 | -------------------------------------------------------------------------------- /plugins/commit.rb: -------------------------------------------------------------------------------- 1 | # https://github.com/github/hubot-scripts/blob/master/src/scripts/commitmessage.coffee 2 | Basil.respond_to(/commit ?message/i) { 3 | 4 | @msg.say get_http("http://whatthecommit.com/index.txt").body 5 | 6 | }.description = "give a random commit message" 7 | -------------------------------------------------------------------------------- /plugins/confluence.rb: -------------------------------------------------------------------------------- 1 | Basil.respond_to(/^(confluence|wiki) (.+)/) { 2 | 3 | begin 4 | xml = get_xml(Basil::Config.confluence.merge( 5 | 'path' => "/rest/prototype/1/search?query=#{escape(@match_data[2])}")) 6 | 7 | result = xml['results']['result'] 8 | result = result.first if result.is_a?(Array) 9 | 10 | @msg.say result['link'].first['href'] 11 | 12 | rescue 13 | @msg.say 'no results found.' 14 | end 15 | 16 | }.description = 'searches confluence' 17 | -------------------------------------------------------------------------------- /plugins/defprogramming.rb: -------------------------------------------------------------------------------- 1 | # https://github.com/github/hubot-scripts/blob/master/src/scripts/defprogramming.coffee 2 | Basil.respond_to(/^def ?programming$/) { 3 | 4 | @msg.say get_html("http://www.defprogramming.com/random").search('cite a p').first.children.to_s 5 | 6 | }.description = "print a random quote from defprogramming.com" 7 | -------------------------------------------------------------------------------- /plugins/echo.rb: -------------------------------------------------------------------------------- 1 | Basil.respond_to(/^(echo|say) (.*)/) { 2 | 3 | @msg.say @match_data[2] 4 | 5 | }.description = "says what it's told" 6 | -------------------------------------------------------------------------------- /plugins/eval.rb: -------------------------------------------------------------------------------- 1 | # ideas taken from moxy's sandbox_eval 2 | # 3 | # https://github.com/jondot/moxy/blob/master/lib/moxy/sandbox_eval.rb 4 | # 5 | require 'fakefs/safe' 6 | require 'stringio' 7 | require 'timeout' 8 | 9 | Sandbox = Struct.new(:plugin, :msg, :code) do 10 | def evaluate 11 | result = sandboxed do 12 | plugin.instance_eval(<<-EOC) 13 | FakeFS::FileSystem.clear 14 | 15 | $SAFE = 3 16 | 17 | begin; #{code} end 18 | EOC 19 | end 20 | 21 | str = @stdout.string 22 | 23 | msg.say str if str != '' 24 | msg.say "=> #{result.inspect}" 25 | 26 | rescue Exception => ex 27 | Basil.logger.warn ex 28 | end 29 | 30 | private 31 | 32 | def sandboxed(&block) 33 | setup 34 | 35 | Basil::Config.hide do 36 | Timeout::timeout(5) do 37 | # thread required to isolate SAFE value 38 | Thread.new { yield }.value 39 | end 40 | end 41 | ensure 42 | teardown 43 | end 44 | 45 | def setup 46 | FakeFS.activate! 47 | $stdout = @stdout = StringIO.new 48 | end 49 | 50 | def teardown 51 | $stdout = STDOUT 52 | FakeFS.deactivate! 53 | end 54 | end 55 | 56 | Basil.respond_to(/^eval (.*)/) { 57 | 58 | Sandbox.new(self, @msg, @match_data[1].strip).evaluate 59 | 60 | }.description = 'evaluates ruby expressions' 61 | -------------------------------------------------------------------------------- /plugins/factoids.rb: -------------------------------------------------------------------------------- 1 | class Factoids 2 | KEY = :factoids 3 | 4 | def self.all(&block) 5 | Basil::Storage.with_storage do |store| 6 | yield(store[KEY] ||= {}) 7 | end 8 | end 9 | 10 | def self.get(key) 11 | all { |facts| facts[key] } 12 | end 13 | end 14 | 15 | # allows canned-response plugins to be added run-time by anyone 16 | Basil.respond_to(/^(\w+) is <(reply|say)>(.+)/) { 17 | 18 | key = @match_data[1] 19 | action = @match_data[2] 20 | fact = @match_data[3] 21 | 22 | Factoids.all do |facts| 23 | facts[key] = { 24 | :action => action, 25 | :fact => fact, 26 | :created => Time.now, 27 | :by => @msg.from_name, 28 | :requested => 0, 29 | :locked => false # TODO 30 | } 31 | end 32 | 33 | @msg.say 'Ta-da!' 34 | 35 | }.description = 'store a new factoid (or overwrite existing)' 36 | 37 | Basil::Plugin.respond_to(/^\w+$/) { 38 | 39 | key = @match_data[0] 40 | 41 | Factoids.all do |facts| 42 | if fact = facts[key] 43 | @msg.send(fact[:action], fact[:fact]) 44 | fact[:requested] += 1 45 | end 46 | end 47 | 48 | } 49 | 50 | Basil.respond_to(/^factinfo (\w+)$/) { 51 | 52 | key = @match_data[1] 53 | 54 | if fact = Factoids.get(key) 55 | @msg.say "fact #{key}: created #{fact[:created]} by #{fact[:by]}, requested #{fact[:requested]} time(s)." 56 | @msg.say "<#{fact[:action]}> #{fact[:fact]}" 57 | end 58 | 59 | }.description = 'give information about a factoid' 60 | 61 | Basil.respond_to(/^(del|rm) ?fact(oid)? (\w+)$/) { 62 | 63 | Factoids.all do |facts| 64 | facts.delete(@match_data[3]) 65 | end 66 | 67 | @msg.say 'Ta-da!' 68 | 69 | }.description = 'remove a factoid' 70 | -------------------------------------------------------------------------------- /plugins/fight.rb: -------------------------------------------------------------------------------- 1 | Basil.respond_to(/^fight (\S+) (\S+)/) { 2 | 3 | play = lambda do |a,b| 4 | score_a = (1..9001).to_a.shuffle.first 5 | score_b = (1..9001).to_a.shuffle.first 6 | 7 | if score_a == score_b 8 | play.call(a,b) 9 | else 10 | "#{a}: #{score_a}, #{b}: #{score_b}" 11 | end 12 | end 13 | 14 | score = play.call(@match_data[1], @match_data[2]) 15 | 16 | @msg.say score 17 | 18 | }.description = 'plays out a fictional battle between two combatants' 19 | -------------------------------------------------------------------------------- /plugins/git.rb: -------------------------------------------------------------------------------- 1 | GIT_COMMANDS ||= Hash.new { 2 | # unknown commands will execute this 3 | "echo 'usage: git [ #{GIT_COMMANDS.keys.join(' | ')}' ]" 4 | } 5 | 6 | GIT_COMMANDS['pull'] = 'git pull origin master' 7 | GIT_COMMANDS['show'] = 'git log HEAD --oneline | head -n 1' 8 | 9 | Basil.respond_to(/^git (.*)$/) do 10 | # Note: assumes if we're in any git repo, we're in /our/ git repo. 11 | if system('git status &>/dev/null') 12 | @msg.reply `#{GIT_COMMANDS[@match_data[1]]}` 13 | else 14 | @msg.reply "Sorry, I'm not running in my repo :(" 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /plugins/give.rb: -------------------------------------------------------------------------------- 1 | Basil.respond_to(/^give (\w+) (.*)/) { 2 | 3 | Basil::Message.from_message( 4 | @msg, 5 | :from => @match_data[1], 6 | :from_name => @match_data[1], 7 | :text => @match_data[2].strip 8 | ).dispatch 9 | 10 | }.description = 'executes a plugin replying to someone else' 11 | -------------------------------------------------------------------------------- /plugins/google.rb: -------------------------------------------------------------------------------- 1 | Basil.respond_to(/^g(oogle)? (.*)/) { 2 | 3 | url = "http://ajax.googleapis.com/ajax/services/search/web?v=1.0&q=#{escape(@match_data[2])}" 4 | 5 | if result = get_json(url)['responseData']['results'].first 6 | @msg.reply "#{result['titleNoFormatting']}: #{result['unescapedUrl']}" 7 | else 8 | @msg.reply "nothing found." 9 | end 10 | 11 | }.description = 'consults the almighty google' 12 | -------------------------------------------------------------------------------- /plugins/help.rb: -------------------------------------------------------------------------------- 1 | Basil.respond_to('help') { 2 | 3 | Basil::Plugin.responders.each do |p| 4 | @msg.say p.help_text if p.has_help? 5 | end 6 | 7 | Basil::Plugin.watchers.each do |p| 8 | @msg.say p.help_text if p.has_help? 9 | end 10 | 11 | }.description = "lists the bot's triggers" 12 | -------------------------------------------------------------------------------- /plugins/isitdown.rb: -------------------------------------------------------------------------------- 1 | Basil.respond_to(/^is (\S+) down\??$/) { 2 | 3 | text = get_html("http://www.isup.me/#{@match_data[1]}").at('#container').children.to_s 4 | 5 | @msg.say text.strip.split("\n").first.strip.gsub(/<\/?a.*?>/, '') 6 | 7 | }.description = "see if a site is down for everyone or just you" 8 | -------------------------------------------------------------------------------- /plugins/its_a_trap.rb: -------------------------------------------------------------------------------- 1 | Basil.watch_for(/it'?s a trap/) { 2 | 3 | @msg.say 'http://images.t-nation.com/forum_images/9/b/9bbda_ORIG-admiral_ackbar_its_a_trap.jpg' 4 | 5 | } 6 | -------------------------------------------------------------------------------- /plugins/jenkins.rb: -------------------------------------------------------------------------------- 1 | module Jenkins 2 | class Path 3 | include Basil::Utils 4 | 5 | # add an accessor method +method+ available in the api's json at 6 | # +key+. if a block is passed, it will be called on the value before 7 | # returning it. 8 | def self.def_accessor(method, key, &block) 9 | conversions[method] = block 10 | 11 | self.class_eval %{ 12 | def #{method} 13 | value = json['#{key}'] # may be nil 14 | conv = self.class.conversions[#{method.inspect}] 15 | 16 | (value && conv) ? conv.call(value) : value 17 | end 18 | } 19 | end 20 | 21 | def self.conversions 22 | @conversions ||= {} 23 | end 24 | 25 | def path 26 | raise "Subclass must implement" 27 | end 28 | 29 | def url 30 | "http://#{Basil::Config.jenkins['host']}#{path}" 31 | end 32 | 33 | def json 34 | unless @json 35 | opts = Basil::Config.jenkins 36 | opts['path'] = "#{path}api/json" 37 | 38 | @json = get_json(opts) 39 | end 40 | 41 | @json 42 | end 43 | end 44 | 45 | class Status < Path 46 | def_accessor(:jobs, 'jobs') do |v| 47 | v.map do |h| 48 | status = case h['color'] 49 | when /blue/ ; 'is green' 50 | when /red/ ; 'is FAILING' 51 | when /aborted/ ; 'aborted' 52 | when /disabled/; 'disabled' 53 | else 'status unknown' 54 | end 55 | 56 | "* #{h['name']}: build #{status}" 57 | end.join("\n") 58 | end 59 | 60 | def path 61 | '/' 62 | end 63 | end 64 | 65 | class Job < Path 66 | def_accessor(:passing?, 'color') { |v| v =~ /blue/ } 67 | def_accessor(:failing?, 'color') { |v| v =~ /red/ } 68 | def_accessor(:aborted?, 'color') { |v| v =~ /aborted/ } 69 | def_accessor(:disabled?, 'color') { |v| v =~ /disabled/ } 70 | def_accessor(:builds, 'builds') { |v| v.map {|h| h['number']} } 71 | def_accessor(:health_report, 'healthReport') { |v| v.map {|h| h['description']}.join("\n") } 72 | def_accessor(:next_build_number, 'nextBuildNumber') 73 | def_accessor(:last_successful_build, 'lastSuccessfulBuild') { |v| v['number'] rescue nil } 74 | 75 | attr_reader :name 76 | 77 | def initialize(name) 78 | @name = name 79 | end 80 | 81 | def path 82 | "/job/#{name}/" 83 | end 84 | 85 | def status 86 | return 'build is green!' if passing? 87 | return 'last build failed.' if failing? 88 | return 'last build aborted' if aborted? 89 | return 'currently disabled' if disabled? 90 | 91 | 'current status unknown' 92 | end 93 | 94 | def build! 95 | opts = Basil::Config.jenkins 96 | opts['path'] = "#{url}/build" 97 | 98 | res = get_http(opts) 99 | 100 | if res.is_a? ::Net::HTTPFound 101 | "Build started" 102 | else 103 | "Could not start build (#{res.code})" 104 | end 105 | end 106 | end 107 | 108 | class Build < Path 109 | def_accessor(:building?, 'building') 110 | def_accessor(:duration, 'duration') 111 | def_accessor(:result, 'result') 112 | def_accessor(:fail_count, 'actions') { |v| (v[4]["failCount"] rescue '?') || '?' } 113 | def_accessor(:culprits, 'culprits') { |v| v.map {|h| h['fullName']}.join(', ') } 114 | def_accessor(:committers, 'changeSet') { |v| v['items'].map {|h| h['user']}.uniq.join(', ') } 115 | 116 | attr_reader :name, :number 117 | 118 | def initialize(name, number) 119 | @name, @number = name, number 120 | end 121 | 122 | def path 123 | "/job/#{name}/#{number}/" 124 | end 125 | 126 | def chat 127 | chats = Basil::Config.jenkins.fetch('broadcast_chats', {}) 128 | 129 | chats.fetch(name) do 130 | Basil::Config.jenkins['broadcast_chat'] 131 | end 132 | end 133 | end 134 | end 135 | 136 | Basil.check_email(/jenkins build is back to normal : (\w+) #(\d+)/i) do 137 | name, number = @match_data.captures 138 | 139 | build = Jenkins::Build.new(name, number) 140 | 141 | @msg.chat = build.chat 142 | 143 | @msg.say trim(<<-EOM) 144 | (sun) #{build.name} is back to normal! 145 | Thanks go to #{build.committers} (ninja) 146 | EOM 147 | end 148 | 149 | Basil.check_email(/build failed in Jenkins: (\w+) #(\d+)/i) do 150 | name, number = @match_data.captures 151 | 152 | build = Jenkins::Build.new(name, number) 153 | 154 | @msg.chat = build.chat 155 | 156 | @msg.say trim(<<-EOM) 157 | (rain) #{build.name} ##{build.number} failed! 158 | #{build.fail_count} failure(s). Culprits identified as #{build.culprits} 159 | Please see #{build.url} for more details. 160 | EOM 161 | end 162 | 163 | Basil.respond_to('jenkins') { 164 | 165 | @msg.say Jenkins::Status.new.jobs 166 | 167 | }.description = 'display the status of all jenkins builds' 168 | 169 | Basil.respond_to(/^jenkins (\w+)/) { 170 | 171 | job = Jenkins::Job.new(@match_data[1]) 172 | 173 | @msg.say trim(<<-EOM) 174 | #{job.name}: #{job.status} 175 | #{job.health_report} 176 | EOM 177 | 178 | }.description = 'retrieves info on a specific jenkins job' 179 | 180 | Basil.respond_to(/^who broke (.+?)\??$/) { 181 | 182 | job = Jenkins::Job.new(@match_data[1]) 183 | 184 | if job.passing? 185 | return @msg.say "#{job.name} is currently green!" 186 | end 187 | 188 | build = Jenkins::Build.new(job.name, job.builds.last) 189 | 190 | @msg.say trim(<<-EOM) 191 | The last completed build was #{build.number}" 192 | Culprits are #{build.culprits}. 193 | EOM 194 | 195 | }.description = 'tells you the likely culprits for a broken build' 196 | 197 | Basil.respond_to(/^build (\w+)/) { 198 | 199 | @msg.say Jenkins::Job.new(@match_data[1]).build! 200 | 201 | }.description = 'triggers a build for the specified job' 202 | -------------------------------------------------------------------------------- /plugins/jira.rb: -------------------------------------------------------------------------------- 1 | module Basil 2 | class JiraApi 3 | include Utils 4 | 5 | def initialize(path) 6 | @path = path 7 | end 8 | 9 | def method_missing(meth, *args) 10 | json[meth.to_s] if json 11 | end 12 | 13 | private 14 | 15 | def json 16 | @json ||= get_json(Config.jira.merge( 17 | 'path' => '/rest/api/2' + @path)) 18 | end 19 | end 20 | 21 | class JiraTicket 22 | TIMEOUT ||= 30 * 60 # 30 minutes 23 | 24 | def initialize(key) 25 | @key = key.upcase 26 | @json = nil 27 | end 28 | 29 | def description 30 | "#{url} : #{title}" 31 | end 32 | 33 | def found? 34 | !title.nil? 35 | end 36 | 37 | def url 38 | @url ||= "https://#{Config.jira['host']}/browse/#{@key}" 39 | end 40 | 41 | def title 42 | @title ||= json.fields['summary'] rescue nil 43 | end 44 | 45 | private 46 | 47 | def json 48 | @json ||= JiraApi.new("/issue/#{@key}") 49 | end 50 | end 51 | end 52 | 53 | Basil.watch_for(/\w+-\d+/) { 54 | tickets = [] 55 | responses = [] 56 | 57 | # people might mention more than one ticket in a message 58 | found = @msg.text.scan(/\w+-\d+/).uniq 59 | 60 | # don't spam the channel if people mention the same core ticket within 61 | # a specified timeout period. 62 | Basil::Storage.with_storage do |store| 63 | store[:jira_timeouts] ||= {} 64 | 65 | found.each do |id| 66 | timeout = store[:jira_timeouts][id] 67 | 68 | if !timeout || Time.now > timeout 69 | tickets << id 70 | store[:jira_timeouts][id] = Time.now + Basil::JiraTicket::TIMEOUT 71 | end 72 | end 73 | end 74 | 75 | tickets.each do |id| 76 | ticket = Basil::JiraTicket.new(id) 77 | 78 | if ticket.found? 79 | url = ticket.url 80 | title = ticket.title 81 | 82 | # don't spam information that's already present in the 83 | # triggering message 84 | url_present = @msg.text.include?(url) 85 | title_present = @msg.text.include?(title) 86 | 87 | unless url_present && title_present 88 | if url_present 89 | responses << "#{id} : #{title}" 90 | elsif title_present 91 | responses << "#{url}" 92 | else 93 | responses << "#{ticket.description}" 94 | end 95 | end 96 | end 97 | end 98 | 99 | @msg.say responses.join("\n") if responses.any? 100 | } 101 | 102 | Basil.respond_to(/^jira search (.+)/i) { 103 | 104 | jql = escape('summary ~ "?" OR description ~ "?" OR comment ~ "?"'.gsub('?', @match_data[1].strip)) 105 | json = Basil::JiraApi.new("/search?jql=#{jql}") 106 | 107 | if (issues = json.issues) && issues.any? 108 | @msg.reply 'First 10 results:' 109 | 110 | issues[0..10].each do |issue| 111 | ticket = Basil::JiraTicket.new(issue['key']) 112 | @msg.say ticket.description if ticket.found? 113 | end 114 | else 115 | @msg.reply "no results found." 116 | end 117 | 118 | }.description = 'find JIRA cards with given search term(s)' 119 | -------------------------------------------------------------------------------- /plugins/karma.rb: -------------------------------------------------------------------------------- 1 | class Karma 2 | KEY = :karma_tracker 3 | 4 | def initialize(word) 5 | @word = word 6 | end 7 | 8 | def increment! 9 | with_values { |v| v[word] += 1 } 10 | end 11 | 12 | def decrement! 13 | with_values { |v| v[word] -= 1 } 14 | end 15 | 16 | def value 17 | @value ||= with_values { |v| v[word] } 18 | end 19 | 20 | def to_s 21 | if value == 0 22 | "nuetral karma" 23 | elsif value > 0 24 | "positive karma (+#{value})" 25 | else 26 | "negative karma (#{value})" 27 | end 28 | end 29 | 30 | private 31 | 32 | attr_reader :word 33 | 34 | def with_values(&block) 35 | Basil::Storage.with_storage do |store| 36 | yield(store[KEY] ||= {}) 37 | end 38 | end 39 | end 40 | 41 | # when foo-- or foo++ is mentioned in conversation, foo's karma is 42 | # decremented or incremented. 43 | Basil.watch_for(/(\w+)(--|\+\+)($|[!?.,:; ])/) { 44 | 45 | karma = Karma.new(@match_data[1]) 46 | 47 | case @match_data[2] 48 | when '++' then karma.increment! 49 | when '--' then karma.decrement! 50 | end 51 | 52 | } 53 | 54 | Basil.respond_to(/^karma (\w+)/) { 55 | 56 | word = @match_data[1] 57 | karma = Karma.new(word) 58 | 59 | @msg.reply "#{word} currently has #{karma}" 60 | 61 | }.description = "report a word's current karma" 62 | -------------------------------------------------------------------------------- /plugins/messages.rb: -------------------------------------------------------------------------------- 1 | module Messages 2 | class << self 3 | 4 | def leave(to, from, message) 5 | with_messages do |messages| 6 | messages << { 7 | :time => Time.now, 8 | :to => to, 9 | :from => from, 10 | :message => message, 11 | :notified => false 12 | } 13 | end 14 | end 15 | 16 | def check(name) 17 | with_messages do |messages| 18 | my_messages = messages.select do |message| 19 | name =~ /#{message[:to]}/i 20 | end 21 | 22 | my_messages.each do |message| 23 | messages.delete(message) 24 | end 25 | 26 | my_messages 27 | end 28 | end 29 | 30 | def any?(name) 31 | with_messages do |messages| 32 | any = false 33 | 34 | messages.each do |message| 35 | if !message[:notified] && name =~ /#{message[:to]}/i 36 | any = message[:notified] = true 37 | end 38 | end 39 | 40 | any 41 | end 42 | end 43 | 44 | private 45 | 46 | def with_messages 47 | Basil::Storage.with_storage do |store| 48 | yield(store[:tell_messages] ||= []) 49 | end 50 | end 51 | 52 | end 53 | end 54 | 55 | Basil.respond_to(/^tell ([^:]*): (.+)/) { 56 | 57 | Messages.leave(@match_data[1], @msg.from_name, @match_data[2]) 58 | 59 | @msg.reply "consider it noted." 60 | 61 | }.description = "Leave a message for someone" 62 | 63 | Basil.respond_to(/^(do i have any |any )?messages\??$/i) { 64 | 65 | messages = Messages.check(@msg.from_name) 66 | 67 | if messages.any? 68 | @msg.reply 'your messages:' 69 | 70 | messages.each do |msg| 71 | @msg.say trim(<<-EOM) 72 | #{msg[:time].strftime("On %D, at %r")}, #{msg[:from]} wrote: 73 | > #{msg[:message]} 74 | EOM 75 | end 76 | else 77 | @msg.reply 'no messages.' 78 | end 79 | 80 | }.description = "See if anyone's left you a message" 81 | 82 | Basil.watch_for(/.*/) { 83 | 84 | if Messages.any?(@msg.from_name) 85 | @msg.reply "someone's left you a message. say 'messages?' to me to check them." 86 | end 87 | 88 | } 89 | -------------------------------------------------------------------------------- /plugins/piratize.rb: -------------------------------------------------------------------------------- 1 | Basil.respond_to(/piratize (.+)/) { 2 | 3 | url = "http://postlikeapirate.com/AJAXtranslate.php?typing=#{escape(@match_data[1])}" 4 | @msg.say get_html(url).css('p').first.content 5 | 6 | } 7 | -------------------------------------------------------------------------------- /plugins/punish_geoff.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | MEAN_RESPONSES = [ 4 | "Please shut up Geoff.", 5 | "That's quite enough.", 6 | "(yawn)", 7 | "touché", 8 | "(highfive)" 9 | ] 10 | 11 | NICE_RESPONSES = [ 12 | "Gooooooo teeeaam!!!!!!", 13 | "(y)" 14 | ] 15 | 16 | MONDAY_RESPONSES = [ 17 | "Shut up, Geoff.", 18 | "Not today, Geoff.", 19 | "(n)" 20 | ] 21 | 22 | Basil.watch_for(/go *team/i) do 23 | geoff = Basil::Config.geoffs_name 24 | 25 | if geoff && @msg.from == geoff 26 | if Time.now.wday == 1 27 | @msg.say MONDAY_RESPONSES.sample 28 | else 29 | if rand(100) < 5 # 5% of the time be nice 30 | @msg.say NICE_RESPONSES.sample 31 | else 32 | @msg.say MEAN_RESPONSES.sample 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /plugins/quotedb.rb: -------------------------------------------------------------------------------- 1 | class QuoteDb 2 | KEY ||= :quotedb_grabs 3 | 4 | def initialize(plugin) 5 | @plugin = plugin 6 | end 7 | 8 | def grab(name) 9 | if msg = @plugin.chat_history(:from => name).first 10 | with_quotes do |quotes| 11 | quotes << { :who => msg.from_name, :what => msg.text } 12 | end 13 | end 14 | end 15 | 16 | def quote(name) 17 | Quote.new(quotes_for(name).last) 18 | end 19 | 20 | def random_quote(name) 21 | Quote.new(quotes_for(name).sample) 22 | end 23 | 24 | private 25 | 26 | def quotes_for(name) 27 | with_quotes do |quotes| 28 | quotes.select do 29 | |msg| msg[:who] =~ /#{name}/i 30 | end 31 | end 32 | end 33 | 34 | def with_quotes(&block) 35 | Basil::Storage.with_storage do |store| 36 | yield(store[KEY] ||= []) 37 | end 38 | end 39 | 40 | class Quote 41 | def initialize(options) 42 | @who = options[:who] 43 | @what = options[:what] 44 | end 45 | 46 | def to_s 47 | "<#@who> #@what" 48 | end 49 | end 50 | end 51 | 52 | Basil.respond_to(/^grab (.+)/) do 53 | 54 | @msg.say 'Ta-da!' if QuoteDb.new(self).grab(@match_data[1]) 55 | 56 | end 57 | 58 | Basil.respond_to(/^q(uote)? (.+)/) do 59 | 60 | @msg.say QuoteDb.new(self).quote(@match_data[2]) 61 | 62 | end 63 | 64 | Basil.respond_to(/^rq(uote)? (.+)/) do 65 | 66 | @msg.say QuoteDb.new(self).random_quote(@match_data[2]) 67 | 68 | end 69 | -------------------------------------------------------------------------------- /plugins/reload.rb: -------------------------------------------------------------------------------- 1 | module Basil 2 | class Plugin 3 | def self.count_loaded 4 | responders.length + watchers.length + email_checkers.length 5 | end 6 | 7 | def self.clear_loaded! 8 | responders.clear 9 | watchers.clear 10 | email_checkers.clear 11 | end 12 | end 13 | end 14 | 15 | Basil.respond_to('reload') { 16 | 17 | prev = Basil::Plugin.count_loaded 18 | 19 | Basil::Plugin.clear_loaded! 20 | Basil::Plugin.load! 21 | 22 | cur = Basil::Plugin.count_loaded 23 | 24 | @msg.say "#{prev} plugins removed, #{cur} plugins (re)loaded." 25 | 26 | }.description = 'reloads all plugins' 27 | -------------------------------------------------------------------------------- /plugins/restart.rb: -------------------------------------------------------------------------------- 1 | Basil.respond_to('restart') { 2 | 3 | begin 4 | system('bundle exec ./bin/basil restart &') 5 | @msg.say 'restarting...' 6 | rescue 7 | end 8 | 9 | }.description = 'restarts basil entirely' 10 | -------------------------------------------------------------------------------- /plugins/rock_paper_scissors.rb: -------------------------------------------------------------------------------- 1 | module Basil 2 | Choices ||= [:rock, :paper, :scissors] 3 | 4 | class RockPaperScissors 5 | def initialize(player_a, player_b) 6 | @a, @b = player_a, player_b 7 | end 8 | 9 | def throw_em!(out) 10 | @a_threw = Choices.shuffle.first 11 | @b_threw = Choices.shuffle.first 12 | 13 | @msg.say "#{@a} threw #{@a_threw}" 14 | @msg.say "#{@b} threw #{@b_threw}" 15 | 16 | if winner = get_winner 17 | @msg.say "#{winner} wins!" 18 | else 19 | @msg.say "tie." 20 | throw_em!(out) 21 | end 22 | end 23 | 24 | def get_winner 25 | case [@a_threw,@b_threw] 26 | # rock breaks scissors 27 | when [:rock,:scissors] then @a 28 | when [:scissors,:rock] then @b 29 | 30 | # scissors cut paper 31 | when [:scissors,:paper] then @a 32 | when [:paper,:scissors] then @b 33 | 34 | # paper covers rock 35 | when [:paper,:rock] then @a 36 | when [:rock,:paper] then @b 37 | 38 | else nil # tie 39 | end 40 | end 41 | end 42 | end 43 | 44 | Basil.respond_to(/^(rps|rock ?paper ?scissors) (\w+) (\w+)$/) { 45 | 46 | Basil::RockPaperScissors.new(@match_data[2], @match_data[3]).throw_em!(@msg) 47 | 48 | }.description = "plays out a fake game of rock paper scissors" 49 | -------------------------------------------------------------------------------- /plugins/rubygems.rb: -------------------------------------------------------------------------------- 1 | # port of https://github.com/github/hubot-scripts/blob/master/src/scripts/rubygems.coffee 2 | Basil.respond_to(/^gem search (.+)/i) { 3 | 4 | gems = get_json("http://rubygems.org/api/v1/search.json?query=#{escape(@match_data[1])}")[0..4] 5 | 6 | if gems && gems.any? 7 | gems.each do |gem| 8 | @msg.say "#{gem['name']}: https://rubygems.org/gems/#{gem['name']}" 9 | end 10 | else 11 | @msg.say "no results found." 12 | end 13 | 14 | }.description = "searches rubygems.org" 15 | -------------------------------------------------------------------------------- /plugins/seen.rb: -------------------------------------------------------------------------------- 1 | Basil.respond_to(/^seen (.+?)\??$/) { 2 | 3 | if msg = chat_history(:from => @match_data[1].strip).first 4 | @msg.reply "#{msg.from_name} was last seen on #{msg.time.strftime("%D, at %r")} saying \"#{msg.text}\"." 5 | end 6 | 7 | }.description = "displays when the person was last seen in chat" 8 | -------------------------------------------------------------------------------- /plugins/shame.rb: -------------------------------------------------------------------------------- 1 | Basil.respond_to(/^shame *(.+)/) { 2 | 3 | @msg.say "For shame #{@match_data[1]}, FOR SHAME!" 4 | 5 | }.description = 'publicly shame someone or something' 6 | -------------------------------------------------------------------------------- /plugins/test.rb: -------------------------------------------------------------------------------- 1 | Basil.respond_to('test') { 2 | 3 | @msg.say "Hello world from #{self.inspect}!" 4 | 5 | }.description = 'tests that the bot is working' 6 | -------------------------------------------------------------------------------- /plugins/tweet.rb: -------------------------------------------------------------------------------- 1 | require 'twitter' 2 | 3 | Twitter.configure do |config| 4 | conf = Basil::Config.twitter 5 | 6 | config.consumer_key = conf['consumer_key'] 7 | config.consumer_secret = conf['consumer_secret'] 8 | config.oauth_token = conf['oauth_token'] 9 | config.oauth_token_secret = conf['oauth_token_secret'] 10 | end 11 | 12 | Basil.respond_to(/tweet (.+)/) { 13 | 14 | message = @match_data[1] 15 | 16 | if message == 'that' 17 | if msg = chat_history.first 18 | message = msg.text 19 | end 20 | end 21 | 22 | Twitter.update(message) 23 | 24 | @msg.say "successfully twittereded!" 25 | 26 | }.description = 'sends tweets as @basilthebot' 27 | -------------------------------------------------------------------------------- /plugins/version.rb: -------------------------------------------------------------------------------- 1 | Basil.respond_to('version') { @msg.say Basil::VERSION } 2 | Basil.respond_to('ruby version') { @msg.say RUBY_VERSION } 3 | -------------------------------------------------------------------------------- /plugins/you.rb: -------------------------------------------------------------------------------- 1 | # 2 | # basil, you're a bread compartment 3 | # => no, YOU are a bread compartment 4 | # 5 | Basil.respond_to(/^you(.*)/) { 6 | 7 | @msg.reply "no, YOU#{@match_data[1]}!" 8 | 9 | } 10 | -------------------------------------------------------------------------------- /spec/basil/chat_history_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Basil 4 | describe ChatHistory do 5 | before do 6 | @msgs = [ Message.new(:to => 'jim', :from => 'bob', :from_name => 'Bob', :chat => 'chat_a'), 7 | Message.new(:to => 'bob', :from => 'jim', :from_name => 'Jim', :chat => 'chat_a'), 8 | Message.new(:to => 'jim', :from => 'bob', :from_name => 'Bob', :chat => 'chat_b'), 9 | Message.new(:to => 'bob', :from => 'jim', :from_name => 'Jim', :chat => 'chat_b') ] 10 | 11 | @msgs.each { |msg| ChatHistory.store_message(msg) } 12 | end 13 | 14 | after do 15 | ChatHistory.clear_history('chat_a') 16 | ChatHistory.clear_history('chat_b') 17 | end 18 | 19 | it "can store any object that is coercible to a message" do 20 | msg = double('msg') 21 | obj = double('obj', :to_message => msg) 22 | 23 | ChatHistory.should_receive(:store_message).with(msg) 24 | 25 | ChatHistory.store(obj) 26 | end 27 | 28 | it "can fetch messages for a chat" do 29 | msgs = ChatHistory.get_messages('chat_a') 30 | msgs.should == [@msgs[1], @msgs[0]] 31 | 32 | msgs = ChatHistory.get_messages('chat_b') 33 | msgs.should == [@msgs[3], @msgs[2]] 34 | end 35 | 36 | it "can fetch messages to someone" do 37 | msgs = ChatHistory.get_messages('chat_a', :to => 'Jim') 38 | msgs.should == [@msgs[0]] 39 | end 40 | 41 | it "can fetch messages from someone" do 42 | msgs = ChatHistory.get_messages('chat_a', :from => 'Jim') 43 | msgs.should == [@msgs[1]] 44 | end 45 | 46 | it "can purge chat history" do 47 | ChatHistory.clear_history('chat_a') 48 | ChatHistory.get_messages('chat_a').should be_empty 49 | ChatHistory.get_messages('chat_b').should_not be_empty 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/basil/cli_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Basil 4 | describe Cli do 5 | subject { described_class.new } 6 | 7 | it_behaves_like "a Server" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/basil/config_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Basil 4 | describe Config do 5 | # Use a dup so other tests aren't affected 6 | subject { Config.dup } 7 | 8 | it "should have some defaults" do 9 | subject.me.should == 'basil' 10 | subject.server_class.should == Skype 11 | end 12 | 13 | it "should have overridable defaults" do 14 | subject.me = 'not basil' 15 | subject.me.should == 'not basil' 16 | 17 | subject.server = :a_server 18 | subject.server.should == :a_server 19 | end 20 | 21 | it "should lazily instantiate server_class" do 22 | subject.server_class = Cli 23 | 24 | subject.server = nil 25 | subject.server.should be_a(Cli) 26 | end 27 | 28 | it "should load extras" do 29 | # ensure the exists? check passes 30 | subject.stub(:config_file).and_return(__FILE__) 31 | 32 | # but don't actually load from it 33 | File.stub(:read).and_return( 34 | {'foo' => :foo, 'bar' => :bar}.to_yaml 35 | ) 36 | 37 | subject.foo.should be_nil 38 | subject.bar.should be_nil 39 | 40 | subject.load! 41 | 42 | subject.foo.should == :foo 43 | subject.bar.should == :bar 44 | end 45 | 46 | it "can be hidden" do 47 | subject.extras = :not_nil 48 | 49 | subject.hide do 50 | subject.extras.should == {} 51 | end 52 | 53 | subject.extras.should == :not_nil 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/basil/daemon_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Basil 4 | describe Daemon do 5 | subject { described_class } 6 | 7 | let(:server) { double("server").as_null_object } 8 | 9 | before { Config.stub(:server).and_return(server) } 10 | 11 | context "start" do 12 | context "when in forground" do 13 | it "starts the server" do 14 | server.should_receive(:start) 15 | subject.start(true) 16 | end 17 | end 18 | 19 | context "when in background" do 20 | before do 21 | Config.stub(:foreground?).and_return(false) 22 | 23 | subject.stub(:puts) 24 | subject.stub(:fork).and_yield 25 | 26 | File.stub(:open).and_yield(double.as_null_object) 27 | 28 | [STDIN, STDOUT, STDERR].each do |io| 29 | io.stub(:reopen) 30 | io.stub(:sync) 31 | end 32 | end 33 | 34 | it "writes a pid file" do 35 | Process.stub(:pid).and_return(123) 36 | 37 | fh = double("file handle") 38 | fh.should_receive(:puts).with("123") 39 | 40 | File.should_receive(:open).with(Config.pid_file, 'w').and_yield(fh) 41 | 42 | subject.start 43 | end 44 | 45 | it "redirects IO to the configured log file" do 46 | STDIN.should_receive(:reopen).with("/dev/null") 47 | STDOUT.should_receive(:reopen).with(Config.log_file, 'a') 48 | STDERR.should_receive(:reopen).with(STDOUT) 49 | 50 | subject.start 51 | end 52 | 53 | it "starts the server" do 54 | Config.server.should_receive(:start) 55 | 56 | subject.start 57 | end 58 | end 59 | end 60 | 61 | context "stop" do 62 | it "kills the pid found in the pid-file" do 63 | File.should_receive(:read).with(Config.pid_file).and_return(" 123 ") 64 | subject.should_receive(:system).with("kill 123") 65 | 66 | subject.stop 67 | end 68 | end 69 | 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/basil/dispatchable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Basil 4 | class DispatchableDouble 5 | include Dispatchable 6 | 7 | def each_plugin(&block) 8 | end 9 | 10 | def match?(plugin) 11 | end 12 | 13 | def to_message 14 | Message.new(:from => 'x') 15 | end 16 | end 17 | 18 | describe DispatchableDouble do 19 | it_behaves_like "a Dispatchable" 20 | end 21 | 22 | describe Dispatchable do 23 | subject { DispatchableDouble.new } 24 | 25 | before { ChatHistory.stub(:store) } 26 | 27 | it "should store the object in chat history" do 28 | ChatHistory.should_receive(:store).with(subject) 29 | 30 | subject.dispatch 31 | end 32 | 33 | it "should handle errors during dispatching" do 34 | subject.stub(:each_plugin).and_raise 35 | 36 | expect { subject.dispatch }.to_not raise_error 37 | end 38 | 39 | context "with registered plugins" do 40 | let(:plugin1) { double('Plugin 1') } 41 | let(:plugin2) { double('Plugin 2') } 42 | let(:plugin3) { double('Plugin 3') } 43 | 44 | before do 45 | subject.stub(:each_plugin).and_yield(plugin1) 46 | .and_yield(plugin2) 47 | .and_yield(plugin3) 48 | end 49 | 50 | it "should execute each plugin on itself" do 51 | plugin1.should_receive(:execute_on).with(subject) 52 | plugin2.should_receive(:execute_on).with(subject) 53 | plugin3.should_receive(:execute_on).with(subject) 54 | 55 | subject.dispatch 56 | end 57 | 58 | it "should handle errors during execution" do 59 | plugin1.should_receive(:execute_on).with(subject) 60 | plugin2.should_receive(:execute_on).with(subject).and_raise 61 | plugin3.should_receive(:execute_on).with(subject) 62 | 63 | expect { subject.dispatch }.to_not raise_error 64 | end 65 | end 66 | 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/basil/email/checker_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Basil 4 | module Email 5 | describe Checker do 6 | let(:imap) { double('imap') } 7 | 8 | let(:attrs) do 9 | double('attrs', :attr => {'RFC822' => 'message body'}) 10 | end 11 | 12 | before do 13 | Net::IMAP.stub(:new).and_return(imap) 14 | end 15 | 16 | it "should check for mail" do 17 | # before 18 | imap.should_receive(:login).ordered 19 | imap.should_receive(:select).ordered 20 | 21 | # the search 22 | imap.should_receive(:search).ordered.and_return(['message_id']) 23 | imap.should_receive(:fetch).ordered.with('message_id', 'RFC822').and_return([attrs]) 24 | 25 | # the handling 26 | mail = mock 27 | mail.should_receive(:dispatch) 28 | 29 | Email::Mail.should_receive(:parse).with('message body').and_return(mail) 30 | 31 | # after 32 | imap.should_receive(:store).ordered 33 | imap.should_receive(:logout).ordered 34 | imap.should_receive(:disconnect).ordered 35 | 36 | subject.run 37 | end 38 | end 39 | 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/basil/email/mail_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Basil 4 | module Email 5 | describe Mail do 6 | subject do 7 | content = [ 8 | 'Date: A date', 9 | 'Subject: A subject', 10 | 'To: A to address', 11 | 'From: A from address', 12 | 'Foo: A header with', 13 | ' continuation', 14 | '', 15 | 'Some multi-', 16 | 'line body' 17 | ].join("\r\n") # CRLF 18 | 19 | described_class.parse(content) 20 | end 21 | 22 | it_behaves_like "a Dispatchable" 23 | 24 | it "provides header access" do 25 | subject['Date'].should == 'A date' 26 | subject['Subject'].should == 'A subject' 27 | subject['To'].should == 'A to address' 28 | subject['From'].should == 'A from address' 29 | subject['Foo'].should == 'A header with continuation' 30 | end 31 | 32 | it "has a body" do 33 | subject.body.should == "Some multi-\nline body" 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/basil/email_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Basil 4 | describe Email do 5 | let(:checker) do 6 | double("Checker").tap do |checker| 7 | Email::Checker.stub(:new).and_return(checker) 8 | end 9 | end 10 | 11 | before do 12 | Timer.stub(:new).and_yield 13 | Worker.stub(:new).and_yield 14 | end 15 | 16 | it "should run the email checker" do 17 | checker.should_receive(:run) 18 | 19 | Email.check 20 | end 21 | 22 | it "should pass interval to the timer" do 23 | Config.stub(:email).and_return({'interval' => 5 }) 24 | 25 | Timer.should_receive(:new).with(:sleep => 5) 26 | 27 | Email.check 28 | end 29 | 30 | it "should set thread" do 31 | thread = double("Timer thread") 32 | 33 | Timer.stub(:new).and_return(thread) 34 | 35 | Email.check 36 | 37 | Email.thread.should == thread 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/basil/http_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'fakeweb' 3 | 4 | module Basil 5 | describe HTTP, 'get' do 6 | subject { described_class } 7 | 8 | before { FakeWeb.allow_net_connect = false } 9 | 10 | after { FakeWeb.clean_registry } 11 | 12 | it "accepts a simple url" do 13 | FakeWeb.register_uri(:get, "http://x.com", :body => 'A body') 14 | 15 | subject.get('http://x.com').body.should == 'A body' 16 | end 17 | 18 | it "accepts an options hash for HTTPS and basic auth" do 19 | FakeWeb.register_uri(:get, 'https://u:p@x.com/y', :body => 'A body') 20 | 21 | subject.get('host' => 'x.com', 'port' => 443, 'path' => '/y', 22 | 'user' => 'u', 'password' => 'p').body.should == 'A body' 23 | end 24 | 25 | it "warns on non-200 requests" do 26 | FakeWeb.register_uri(:get, "http://notfound.com", :body => 'Not found', 27 | :status => ['404', 'Not Found']) 28 | 29 | # some test coupling here 30 | Loggers['http'].should_receive(:warn).with('Non-200 HTTP Response') 31 | Loggers['http'].should_receive(:warn).with(kind_of(Net::HTTPNotFound)) 32 | 33 | subject.get('http://notfound.com').body.should == 'Not found' 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/basil/lock_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Basil 4 | describe Lock do 5 | subject { described_class } 6 | 7 | before do 8 | Config.stub(:lock_file).and_return('/tmp/basil_test.lock') 9 | end 10 | 11 | after do 12 | if File.exists?(Config.lock_file) 13 | File.unlink(Config.lock_file) 14 | end 15 | end 16 | 17 | it "should write and cleanup a lock file" do 18 | expect { 19 | Lock.guard do 20 | # ensures the file's created 21 | raise unless File.exists?(Config.lock_file) 22 | end 23 | }.to_not raise_error 24 | 25 | File.exists?(Config.lock_file).should be_false 26 | end 27 | 28 | it "should error and leave it if a lock file exists" do 29 | File.open(Config.lock_file, 'w') { } 30 | 31 | expect { Lock.guard { } }.to raise_error 32 | 33 | File.exists?(Config.lock_file).should be_true 34 | end 35 | 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/basil/message_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Basil 4 | describe Message do 5 | context 'constructors' do 6 | subject { described_class } 7 | 8 | describe '#initialize' do 9 | it "should raise on invalid arguments" do 10 | lambda { subject.new(:to => 'you') }.should raise_error(ArgumentError) 11 | end 12 | 13 | it "works with only from specified" do 14 | msg = subject.new(:from => 'x') 15 | 16 | msg.from.should == 'x' 17 | msg.from_name.should == 'x' 18 | msg.text.should == '' 19 | 20 | msg.to.should be_nil 21 | msg.chat.should be_nil 22 | end 23 | end 24 | 25 | describe 'from_message' do 26 | it "constructs a new message from the first" do 27 | msg = subject.from_message(described_class.new(:from => 'me'), :to => 'you') 28 | 29 | msg.from.should == 'me' 30 | msg.to.should == 'you' 31 | end 32 | end 33 | end 34 | 35 | describe 'an instance' do 36 | subject { described_class.new(:from => 'x') } 37 | 38 | it_behaves_like "a Dispatchable" 39 | 40 | it "has accessible to and chat attributes" do 41 | subject.to = 'other to' 42 | subject.chat = 'other chat' 43 | 44 | subject.to.should == 'other to' 45 | subject.chat.should == 'other chat' 46 | end 47 | 48 | it "sets a time attribute" do 49 | subject.time.should_not be_nil 50 | end 51 | 52 | it "provides to_me? case insensitively" do 53 | Config.stub(:me).and_return('someone') 54 | 55 | subject.to_me?.should be_false 56 | 57 | %w( someone SomeOne SOMEONE ).each do |me| 58 | subject.to = me 59 | subject.to_me?.should be_true 60 | end 61 | end 62 | 63 | context 'interacting with the server' do 64 | subject do 65 | described_class.new( 66 | :to => 'to', 67 | :from => 'from', 68 | :from_name => 'from name', 69 | :text => 'text', 70 | :chat => 'chat' 71 | ) 72 | end 73 | 74 | let(:server) do 75 | Struct.new(:sent_messages) do 76 | def send_message(msg) 77 | self.sent_messages << "#{msg.to}, #{msg.text}" 78 | end 79 | end.new([]) 80 | end 81 | 82 | before do 83 | Config.stub(:server).and_return(server) 84 | end 85 | 86 | it "can say things" do 87 | subject.say "some text" 88 | subject.say "some other text" 89 | 90 | server.sent_messages.should == [', some text', ', some other text'] 91 | end 92 | 93 | it "can reply to itself" do 94 | subject.reply "some text" 95 | 96 | server.sent_messages.should == ['from name, some text'] 97 | end 98 | 99 | it "can forward itself" do 100 | subject.forward('new to') 101 | 102 | server.sent_messages.should == ['new to, text'] 103 | end 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/basil/plugin_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Basil 4 | describe Plugin do 5 | subject { described_class } 6 | 7 | describe 'load' do 8 | before do 9 | plugin_files = %w( d.rb x.rb a.rb ) 10 | 11 | Dir.stub(:exists?).and_return(true) 12 | Dir.stub(:glob).and_return(plugin_files) 13 | end 14 | 15 | it "loads plugins alphabetically" do 16 | subject.should_receive(:load).with('a.rb').ordered 17 | subject.should_receive(:load).with('d.rb').ordered 18 | subject.should_receive(:load).with('x.rb').ordered 19 | 20 | subject.load! 21 | end 22 | 23 | it "rescues any errors" do 24 | subject.stub(:load).and_raise 25 | 26 | lambda { subject.load! }.should_not raise_error 27 | end 28 | end 29 | 30 | context 'registration' do 31 | let(:responder) { subject.respond_to(/regex/) { self } } 32 | let(:watcher) { subject.watch_for(/regex/) { self } } 33 | let(:checker) { subject.check_email(/regex/) { self } } 34 | 35 | before do 36 | subject.responders.clear 37 | subject.watchers.clear 38 | subject.email_checkers.clear 39 | end 40 | 41 | it "registers correctly" do 42 | subject.responders.should == [responder] 43 | subject.watchers.should == [watcher] 44 | subject.email_checkers.should == [checker] 45 | end 46 | 47 | it "assigns an execute block" do 48 | responder.execute.should == responder 49 | watcher.execute.should == watcher 50 | checker.execute.should == checker 51 | end 52 | 53 | it "has an accessible description" do 54 | responder.description.should be_nil 55 | responder.description = 'A description' 56 | responder.description.should == 'A description' 57 | end 58 | end 59 | 60 | describe '#match?' do 61 | it "compares its regex with the supplied text" do 62 | instance = subject.respond_to(/(foo).*(bar)/) { } 63 | instance.match?('foo and bar').captures.should == %w( foo bar ) 64 | end 65 | 66 | it "considers strings as anchored regex" do 67 | instance = subject.respond_to('string') { } 68 | instance.match?('a string here').should be_false 69 | instance.match?('string').should be_true 70 | end 71 | end 72 | 73 | describe '#execute_on' do 74 | let(:obj) { double('obj') } 75 | let(:msg) { double('msg') } 76 | 77 | before do 78 | obj.stub(:to_message).and_return(msg) 79 | 80 | @instance = subject.respond_to(/x/) { [@msg, @match_data] } 81 | end 82 | 83 | it "does nothing on non-matches" do 84 | obj.should_receive(:match?).with(@instance).and_return(nil) 85 | 86 | @instance.execute_on(obj).should be_nil 87 | end 88 | 89 | it "executes with correct instance variables on matches" do 90 | obj.should_receive(:match?).with(@instance).and_return(:not_nil) 91 | 92 | @instance.execute_on(obj).should == [msg, :not_nil] 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /spec/basil/server_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Basil 4 | describe Server do 5 | subject { Class.new(Server).new } 6 | 7 | it "loads plugins and runs the main loop" do 8 | Plugin.should_receive(:load!) 9 | 10 | msg = mock 11 | msg.should_receive(:dispatch) 12 | 13 | subject.should_receive(:main_loop).and_yield('some', 'args') 14 | subject.should_receive(:accept_message).with('some' ,'args').and_return(msg) 15 | 16 | subject.start 17 | end 18 | 19 | it "uses Lock.guard when start is locked" do 20 | subject.stub(:main_loop) 21 | subject.stub(:accept_message) 22 | 23 | subject.class.lock_start 24 | 25 | Lock.should_receive(:guard).and_yield 26 | 27 | subject.start 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/basil/skype_message_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Basil 4 | describe SkypeMessage do 5 | let(:skype) { double('skype') } 6 | 7 | before do 8 | skype.stub(:get).and_return('some property') # unused default 9 | skype.stub(:get).with("CHATMESSAGE 1 CHATNAME").and_return('chatname') 10 | skype.stub(:get).with("CHATMESSAGE 1 FROM_HANDLE").and_return('jsmith') 11 | skype.stub(:get).with("CHATMESSAGE 1 FROM_DISPNAME").and_return('Jim Smith') 12 | end 13 | 14 | def accept(text) 15 | skype.stub(:get).with("CHATMESSAGE 1 BODY").and_return(text) 16 | 17 | SkypeMessage.new(skype, 1) 18 | end 19 | 20 | it "gets skype info via the API" do 21 | msg = accept('some text') 22 | 23 | msg.chatname.should == 'chatname' 24 | msg.from_handle.should == 'jsmith' 25 | msg.from_dispname.should == 'Jim Smith' 26 | msg.body.should == 'some text' 27 | end 28 | 29 | context "when in private chat" do 30 | before do 31 | skype.stub(:get).with("CHAT chatname MEMBERS").and_return('one two') 32 | end 33 | 34 | it "sees everything as to me" do 35 | ['plain message', 'to, someone else'].each do |text| 36 | msg = accept(text) 37 | 38 | msg.to.should == Config.me 39 | msg.text.should == text 40 | end 41 | end 42 | 43 | it "strips bang and bracket shortcuts" do 44 | accept('!command').text.should == 'command' 45 | accept('! command').text.should == 'command' 46 | accept('> code').text.should == 'eval code' 47 | accept('> code').text.should == 'eval code' 48 | end 49 | end 50 | 51 | context "when not in private chat" do 52 | before do 53 | skype.stub(:get).with("CHAT chatname MEMBERS").and_return('one two three') 54 | end 55 | 56 | it "parses BODY correctly for typical TO and TEXT components" do 57 | examples = { 58 | '@someone some text' => ['someone', 'some text'], 59 | '@someone, some text' => ['someone', 'some text'], 60 | '@someone: some text' => ['someone', 'some text'], 61 | '@someone; some text' => ['someone', 'some text'], 62 | 'someone, some text' => ['someone', 'some text'], 63 | 'someone: some text' => ['someone', 'some text'], 64 | 'someone; some text' => ['someone', 'some text'] 65 | } 66 | 67 | examples.each do |k,v| 68 | msg = accept(k) 69 | 70 | [msg.to, msg.text].should == v 71 | end 72 | end 73 | 74 | it "sees bang commands as to me" do 75 | ['!some text', '! some text'].each do |text| 76 | msg = accept(text) 77 | 78 | msg.to.should == Config.me 79 | msg.text.should == 'some text' 80 | end 81 | end 82 | 83 | it "interprets > as evaluation" do 84 | msg = accept('> some code') 85 | 86 | msg.to.should == Config.me 87 | msg.text.should == 'eval some code' 88 | end 89 | 90 | it "handles plain messages" do 91 | msg = accept('some text') 92 | 93 | msg.to.should be_nil 94 | msg.text.should == 'some text' 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/basil/skype_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Basil 4 | describe Skype do 5 | let(:skype) { double('skype') } 6 | 7 | before { subject.stub(:skype).and_return(skype) } 8 | 9 | it_behaves_like "a Server" 10 | 11 | it "listens to skype in its main loop " do 12 | skype.should_receive(:on_chatmessage_received) 13 | skype.should_receive(:connect) 14 | skype.should_receive(:run) 15 | 16 | subject.main_loop 17 | end 18 | 19 | it "builds a Message from the SkypeMessage" do 20 | skype_message = double('skype_message', 21 | :from_handle => 'from', 22 | :from_dispname => 'from_name', 23 | :to => 'to', 24 | :chatname => 'chat', 25 | :text => 'text') 26 | 27 | SkypeMessage.should_receive(:new).with(skype, 1).and_return(skype_message) 28 | 29 | msg = subject.accept_message(1) 30 | 31 | msg.from.should == 'from' 32 | msg.from_name.should == 'from_name' 33 | msg.to.should == 'to' 34 | msg.chat.should == 'chat' 35 | msg.text.should == 'text' 36 | end 37 | 38 | context "#send_message" do 39 | let(:message) do 40 | Message.new( 41 | :to => 'john smith', 42 | :from => 'x', 43 | :text => 'text', 44 | :chat => 'chat' 45 | ) 46 | end 47 | 48 | before do 49 | skype.stub(:connect) 50 | skype.stub(:connected?) 51 | skype.stub(:message_chat) 52 | end 53 | 54 | it "connects when not connected" do 55 | skype.stub(:connected?).and_return(false) 56 | skype.should_receive(:connect) 57 | 58 | subject.send_message(message) 59 | end 60 | 61 | it "does not connect if already connected" do 62 | skype.stub(:connected?).and_return(true) 63 | skype.should_not_receive(:connect) 64 | 65 | subject.send_message(message) 66 | end 67 | 68 | it "sends a formatted message" do 69 | skype.should_receive(:message_chat).with('chat', 'john, text') 70 | 71 | subject.send_message(message) 72 | end 73 | 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/basil/timer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Basil 4 | describe Timer do 5 | let(:thread) { double("Thread") } 6 | 7 | before do 8 | Thread.stub(:new).and_yield.and_return(thread) 9 | end 10 | 11 | it "should spawn a new thread" do 12 | Thread.should_receive(:new).and_yield 13 | 14 | sentinal = nil 15 | 16 | Timer.new(:once => true) { sentinal = true } 17 | 18 | sentinal.should be_true 19 | end 20 | 21 | it "should delegate all calls to the thread" do 22 | thread.should_receive(:any_method) 23 | 24 | timer = Timer.new(:once => true) 25 | timer.any_method 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/basil/utils_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Basil 4 | describe Utils do 5 | let(:plugin) { double.tap { |p| p.extend(Utils) } } 6 | 7 | it "provides current chat's history" do 8 | options = { :foo => 'foo', :bar => 'bar' } 9 | ChatHistory.should_receive(:get_messages).with('chat', options) 10 | 11 | plugin.stub(:chat).and_return('chat') 12 | plugin.chat_history(options) 13 | end 14 | 15 | it "purges current chat's history" do 16 | ChatHistory.should_receive(:clear).with('chat') 17 | 18 | plugin.stub(:chat).and_return('chat') 19 | plugin.purge_history! 20 | end 21 | 22 | it "provides parse_http" do 23 | resp = double("resp", :body => 'a body') 24 | plugin.should_receive(:get_http).with('args').and_return(resp) 25 | 26 | result = plugin.parse_http('args') { |b| b } 27 | result.should == 'a body' 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/basil/worker_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Basil 4 | describe Worker do 5 | let(:pid) { 123 } 6 | let(:timer) { double("Timer", :alive? => false) } 7 | 8 | before do 9 | Timer.stub(:new).and_return(timer) 10 | Process.stub(:fork).and_yield.and_return(pid) 11 | Process.stub(:wait) { system("true") } 12 | end 13 | 14 | it "should fork and set pid" do 15 | Process.should_receive(:fork).and_yield.and_return(pid) 16 | 17 | w = Worker.new { true } 18 | w.pid.should == pid 19 | end 20 | 21 | it "should monitor the process and kill if needed" do 22 | Worker.any_instance.should_receive(:system).with("kill -9 #{pid}") 23 | 24 | Timer.should_receive(:new).and_yield.and_return(timer) 25 | 26 | Worker.new { true } 27 | end 28 | 29 | it "should exit the monitoring thread process is OK" do 30 | timer.stub(:alive?).and_return(true) 31 | timer.should_receive(:exit) 32 | 33 | Worker.new { true } 34 | end 35 | 36 | it "should wait and set exit status" do 37 | Process.should_receive(:wait).with(pid) { system("false") } 38 | 39 | w = Worker.new { true } 40 | w.exitstatus.should == 1 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start do 3 | add_filter "/spec/" 4 | add_filter "/plugins/" 5 | end 6 | 7 | require 'basil' 8 | 9 | module Basil 10 | # disable logging 11 | Loggers.level = 6 # OFF 12 | 13 | # disable any real server 14 | Config.server = :no_server 15 | 16 | # mock storage 17 | def Storage.with_storage(&block) 18 | yield(@hash ||= {}) 19 | end 20 | 21 | shared_examples_for "a Dispatchable" do 22 | it "must respond to template methods" do 23 | subject.should respond_to(:match?) 24 | subject.should respond_to(:each_plugin) 25 | end 26 | 27 | it "must be coercible to Message" do 28 | subject.to_message.should be_a(Message) 29 | end 30 | end 31 | 32 | shared_examples_for "a Server" do 33 | it "should respond to the template methods" do 34 | subject.should respond_to(:main_loop) 35 | subject.should respond_to(:accept_message) 36 | subject.should respond_to(:send_message) 37 | end 38 | end 39 | end 40 | --------------------------------------------------------------------------------