├── .rspec ├── lib ├── slack-bot-server.rb ├── slack_bot_server │ ├── version.rb │ ├── local_queue.rb │ ├── logging.rb │ ├── simple_bot.rb │ ├── redis_queue.rb │ ├── remote_control.rb │ ├── server.rb │ └── bot.rb └── slack_bot_server.rb ├── Gemfile ├── Rakefile ├── .gitignore ├── spec ├── spec_helper.rb ├── slack_server_spec.rb ├── local_queue_spec.rb ├── redis_queue_spec.rb ├── remote_control_spec.rb ├── server_spec.rb └── bot_spec.rb ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE.txt ├── slack_bot_server.gemspec ├── CHANGELOG.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /lib/slack-bot-server.rb: -------------------------------------------------------------------------------- 1 | # Shim so that bundler can require the code based only on the gem name 2 | require 'slack_bot_server' 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in slack_bot_server.gemspec 4 | gemspec 5 | gem 'activesupport' 6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /lib/slack_bot_server/version.rb: -------------------------------------------------------------------------------- 1 | module SlackBotServer 2 | # The current version of the +SlackBotServer+ framework 3 | VERSION = "0.4.7" 4 | end 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | test_server 11 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'slack_bot_server' 3 | 4 | SlackBotServer.logger.level = Logger::ERROR 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.3.1 4 | - 2.4.1 5 | - ruby-head 6 | before_install: gem install bundler 7 | matrix: 8 | allow_failures: 9 | - rvm: ruby-head 10 | -------------------------------------------------------------------------------- /spec/slack_server_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe SlackBotServer do 4 | it 'has a version number' do 5 | expect(SlackBotServer::VERSION).not_to be nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/local_queue_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'slack_bot_server/local_queue' 3 | 4 | RSpec.describe SlackBotServer::LocalQueue do 5 | it "returns nil when empty and pop is called" do 6 | expect(subject.pop).to be_nil 7 | end 8 | 9 | it "can have objects pushed on it" do 10 | subject.push('hello') 11 | expect(subject.pop).to eq 'hello' 12 | end 13 | 14 | it 'returns the first object pushed' do 15 | subject.push('well') 16 | subject.push('hello') 17 | expect(subject.pop).to eq 'well' 18 | expect(subject.pop).to eq 'hello' 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/slack_bot_server.rb: -------------------------------------------------------------------------------- 1 | require 'slack_bot_server/version' 2 | require 'slack_bot_server/server' 3 | require 'logger' 4 | 5 | # A framework for running and controlling multiple bots. This 6 | # is designed to make it easier for developers to provide Slack 7 | # integration for their applications, instead of having individual 8 | # users run their own bot instances. 9 | module SlackBotServer 10 | # A Logger instance, defaulting to +INFO+ level 11 | def self.logger 12 | @logger ||= Logger.new(STDOUT) 13 | end 14 | 15 | # Assign the logger to be used by SlackBotServer 16 | # @param logger [Logger] 17 | def self.logger=(logger) 18 | @logger = logger 19 | end 20 | end 21 | 22 | SlackBotServer.logger.level = Logger::INFO 23 | -------------------------------------------------------------------------------- /lib/slack_bot_server/local_queue.rb: -------------------------------------------------------------------------------- 1 | # A local implementation of a queue. 2 | # 3 | # Obviously this can't be used to communicate between 4 | # multiple processes, let alone multiple machines, but 5 | # it serves to demonstrate the expected API. 6 | class SlackBotServer::LocalQueue 7 | # Creates a new local in-memory queue 8 | def initialize 9 | @queue = Queue.new 10 | end 11 | 12 | # Push a value onto the back of the queue 13 | def push(value) 14 | @queue << value 15 | end 16 | 17 | # Pop a value from the front of the queue 18 | # @return [Object, nil] returns the object from the front of the 19 | # queue, or nil if the queue is empty 20 | def pop 21 | value = @queue.pop(true) rescue ThreadError 22 | value == ThreadError ? nil : value 23 | end 24 | 25 | # Clear the queue 26 | # @return [nil] 27 | def clear 28 | @queue = Queue.new 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/slack_bot_server/logging.rb: -------------------------------------------------------------------------------- 1 | module SlackBotServer::Logging 2 | def log(*args) 3 | SlackBotServer.logger.info(log_string(*args)) 4 | end 5 | 6 | def debug(*args) 7 | SlackBotServer.logger.debug(log_string(*args)) 8 | end 9 | 10 | def log_error(e, *args) 11 | message = "ERROR: #{e} - #{e.message}" 12 | message += " (#{log_string(*args)})" if args.any? 13 | SlackBotServer.logger.warn(message) 14 | SlackBotServer.logger.warn(e.backtrace.join("\n")) 15 | end 16 | 17 | def log_string(*args) 18 | text = if args.length == 1 && args.first.is_a?(String) 19 | args.first 20 | else 21 | args.map { |a| a.is_a?(String) ? a : a.inspect }.join(", ") 22 | end 23 | prefix = if self.respond_to?(:bot_user_name) 24 | begin 25 | "[BOT/#{bot_user_name}]" 26 | rescue 27 | "[(BOT)/unknown]" 28 | end 29 | else 30 | nil 31 | end 32 | [prefix, text].join(" ") 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for helping make this software better! Contributing is super easy, with just a few guidelines. 4 | 5 | ## Bugs 6 | 7 | If you've found a bug in the software, please report it using [GitHub Issues](https://github.com/exciting-io/slack_bot_server/issues). Please include the version (or SHA) of this project that you're using, along with which version of Ruby and any other dependencies that you think might be relevant. 8 | 9 | ## Features 10 | 11 | If you'd got an idea for an improvement or a new feature, that's fantastic! We only ask that you also include specs to cover that behaviour, and that you implement it in a branch to easy merging. 12 | 13 | 1. Create a new branch for your feature 14 | 2. Implement it, along with new/modified specs 15 | 3. Submit a pull request describing the motivation for your new feature, and how to use it. 16 | 17 | Please don't bump the gem version -- we'll take care of that. 18 | 19 | Thanks again, and have a great day! 20 | -------------------------------------------------------------------------------- /lib/slack_bot_server/simple_bot.rb: -------------------------------------------------------------------------------- 1 | require 'slack_bot_server/bot' 2 | 3 | # A simple demonstration of a bot 4 | class SlackBotServer::SimpleBot < SlackBotServer::Bot 5 | # Set the username displayed in Slack 6 | username 'SimpleBot' 7 | 8 | # Respond to mentions in the connected chat room (defaults to #general). 9 | # As well as the normal data provided by Slack's API, we add the `message`, 10 | # which is the `text` parameter with the username stripped out. For example, 11 | # When a user sends 'simple_bot: how are you?', the `message` data contains 12 | # only 'how are you'. 13 | on_mention do |data| 14 | if data['message'] == 'who are you' 15 | reply text: "I am #{bot_user_name} (user id: #{bot_user_id}), connected to team #{team_name} with team id #{team_id}" 16 | else 17 | reply text: "You said '#{data['message']}', and I'm frankly fascinated." 18 | end 19 | end 20 | 21 | # Respond to messages sent via IM communication directly with the bot. 22 | on_im do 23 | reply text: "Hmm, OK, let me get back to you about that." 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 James Adam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/slack_bot_server/redis_queue.rb: -------------------------------------------------------------------------------- 1 | require 'multi_json' 2 | 3 | # An implementation of the quue interface that uses 4 | # Redis as a data conduit. 5 | class SlackBotServer::RedisQueue 6 | # Creates a new queue 7 | # @param redis [Redis] an instance of the ruby +Redis+ client. If 8 | # nil, one will be created using the default hostname and port 9 | # @param key [String] the key to store the queue against 10 | def initialize(redis: nil, key: 'slack_bot_server:queue') 11 | @key = key 12 | @redis = if redis 13 | redis 14 | else 15 | require 'redis' 16 | Redis.new 17 | end 18 | end 19 | 20 | # Push a value onto the back of the queue. 21 | # @param value [Object] this will be turned into JSON when stored 22 | def push(value) 23 | @redis.rpush @key, MultiJson.dump(value) 24 | end 25 | 26 | # Pop a value from the front of the queue 27 | # @return [Object] the object on the queue, reconstituted from its 28 | # JSON string 29 | def pop 30 | json_value = @redis.lpop @key 31 | if json_value 32 | MultiJson.load(json_value, symbolize_keys: true) 33 | else 34 | nil 35 | end 36 | end 37 | 38 | # Clears the queue 39 | # @return [nil] 40 | def clear 41 | @redis.del @key 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /slack_bot_server.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'slack_bot_server/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "slack-bot-server" 8 | spec.version = SlackBotServer::VERSION 9 | spec.authors = ["James Adam"] 10 | spec.email = ["james@lazyatom.com"] 11 | 12 | spec.summary = %q{A server for hosting slack bots.} 13 | spec.description = %q{This software lets you write and host multiple slack bots, potentially for multiple different teams or even services.} 14 | spec.homepage = "https://github.com/lazyatom/slack-bot-server" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_dependency "slack-ruby-client" 23 | spec.add_dependency "faye-websocket" 24 | spec.add_dependency "multi_json" 25 | 26 | spec.add_development_dependency "bundler" 27 | spec.add_development_dependency "rake" 28 | spec.add_development_dependency "redis" 29 | spec.add_development_dependency "rspec" 30 | spec.add_development_dependency "rspec-eventmachine" 31 | spec.add_development_dependency "yard" 32 | end 33 | -------------------------------------------------------------------------------- /spec/redis_queue_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe SlackBotServer::RedisQueue do 4 | let(:queue_key) { 'slack_bot_server:queue' } 5 | let(:redis) { double('redis') } 6 | subject { described_class.new(redis: redis) } 7 | 8 | it "will default to the default Redis connection if none is given" do 9 | redis_class = Class.new 10 | stub_const("Redis", redis_class) 11 | expect(redis_class).to receive(:new).and_return(redis) 12 | described_class.new() 13 | end 14 | 15 | it "allows specification of a custom key" do 16 | queue = described_class.new(redis: redis, key: 'custom-key') 17 | 18 | allow(MultiJson).to receive(:dump).and_return('json-value') 19 | expect(redis).to receive(:rpush).with('custom-key', 'json-value') 20 | 21 | queue.push('some value') 22 | end 23 | 24 | describe "#push" do 25 | it "pushes json value onto the right of the list" do 26 | object = Object.new 27 | allow(MultiJson).to receive(:dump).with(object).and_return('json-value') 28 | expect(redis).to receive(:rpush).with(queue_key, 'json-value') 29 | 30 | subject.push(object) 31 | end 32 | end 33 | 34 | describe "#pop" do 35 | context "when queue is empty" do 36 | before { allow(redis).to receive(:lpop).with(queue_key).and_return(nil) } 37 | 38 | it "returns nil" do 39 | expect(subject.pop).to be_nil 40 | end 41 | end 42 | 43 | context "when queue has an item" do 44 | it "returns JSON-decoded object" do 45 | object = Object.new 46 | allow(MultiJson).to receive(:load).with('json-value', symbolize_keys: true).and_return(object) 47 | expect(redis).to receive(:lpop).with(queue_key).and_return('json-value') 48 | 49 | expect(subject.pop).to eq object 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/remote_control_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'slack_bot_server/remote_control' 3 | 4 | RSpec.describe SlackBotServer::RemoteControl do 5 | let(:queue) { double('queue') } 6 | subject { described_class.new(queue: queue) } 7 | let(:key) { 'local-unique-key'} 8 | 9 | describe "#add_bot" do 10 | it "pushes an 'add_bot' command onto the queue with the given arguments" do 11 | expect(queue).to receive(:push).with([:add_bot, 'arg1', 'arg2', 'arg3']) 12 | subject.add_bot('arg1', 'arg2', 'arg3') 13 | end 14 | end 15 | 16 | describe "#remove_bot" do 17 | it "pushes a 'remove_bot' command onto the queue with the given key" do 18 | expect(queue).to receive(:push).with([:remove_bot, key]) 19 | subject.remove_bot(key) 20 | end 21 | end 22 | 23 | describe "#say" do 24 | it "pushes a 'send_message' command onto the queue with the given key and message data" do 25 | expect(queue).to receive(:push).with([:say, key, {text: 'hello'}]) 26 | subject.say(key, text: 'hello') 27 | end 28 | end 29 | 30 | describe "#broadcast" do 31 | it "pushes a 'send_message' command onto the queue with the given key and message data" do 32 | expect(queue).to receive(:push).with([:broadcast, key, {text: 'hello'}]) 33 | subject.broadcast(key, text: 'hello') 34 | end 35 | end 36 | 37 | describe "#say_to" do 38 | it "pushes a 'send_message' command onto the queue with the given key and message data" do 39 | expect(queue).to receive(:push).with([:say_to, key, 'userid', {text: 'hello'}]) 40 | subject.say_to(key, 'userid', text: 'hello') 41 | end 42 | end 43 | 44 | describe "#update" do 45 | it "pushes a 'update' command onto the queue with the given key and message data" do 46 | expect(queue).to receive(:push).with([:update, key, {text: 'hello'}]) 47 | subject.update(key, text: 'hello') 48 | end 49 | end 50 | 51 | describe "#call" do 52 | it "pushes a 'call' command onto the queue with the given arguments, for the bot with the given key" do 53 | args = [1, 2, 3] 54 | expect(queue).to receive(:push).with([:call, key, :method_name, args]) 55 | subject.call(key, :method_name, args) 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Unreleased 2 | 3 | 4 | ## 0.4.7 5 | 6 | ### Fixed 7 | - Bots added using numeric keys now work 8 | - Bots added before the server starts which raise errors when started should not longer take the server down 9 | 10 | ## 0.4.6 11 | 12 | ### Added 13 | - New `client` method for accessing the underlying Slack ruby client; this is useful for getting the channels or users (e.g. `client.channels` or `client.users`). 14 | 15 | ### Changes 16 | - Evaluate `on_slack_event` blocks against the bot instance, rather than the class. 17 | 18 | 19 | ## 0.4.5 20 | 21 | ### Added 22 | - Add `on_slack_event` for adding low-level event callbacks like `team_join` or `presence_changed` 23 | 24 | 25 | ## 0.4.4 26 | 27 | ### Added 28 | - Add new bot-private API method `user_message?(data)`, which returns true if the message is some utterance from a user 29 | 30 | ### Changes 31 | - Allow replying from the low-level `on(:message)` callback, although caution should be exercised when doing this (i.e. make sure you aren't replying to bot or other low-level API messages!) 32 | 33 | 34 | ## 0.4.3 35 | 36 | ### Added 37 | - Add `clear` method to queues 38 | 39 | ### Changes 40 | - Upgrate `slack-ruby-client` dependency to latest version 41 | 42 | 43 | ## 0.4.2 44 | 45 | ### Changes 46 | - Re-use error raising from within Slack::Web::Api 47 | - Prevent bots with the same key as existing bots from being added (@keydunov) 48 | 49 | 50 | ## 0.4.1 51 | 52 | ### Changes 53 | - Fixed detection of RTM-compatible messages 54 | 55 | 56 | ## 0.4.0 57 | 58 | ### Added 59 | - Allow bots to send a 'typing' message 60 | - Messages will be sent via the Real-Team API if possible (not all message parameters are acceptable there) 61 | - Subsequent bot callbacks won't fire if an earlier one returns `false` 62 | - `SlackBotServer::Bot` now exposes `bot_user_name`, `bot_user_id`, `team_name`, and `team_id` methods 63 | - The logger can now be set via `SlackBotServer.logger=` 64 | - Access the underlying Slack client via the `SlackBotServer::Bot#client` method 65 | 66 | ### Changes 67 | - Swapped internal API library from `slack-api` to `slack-ruby-client` 68 | - Improve internal bot logging API 69 | - Ensure rtm data is reloaded when reconnecting 70 | - Add missing/implicit requires to server.rb and bot.rb 71 | - Only listen for instructions on the queue if its non-nil 72 | - Fix bug where malformed bot key could crash when processing instructions 73 | - Allow `SlackBotServer::RedisQueue.new` to take a custom redis key; note that this has changed the argument format of the initialiser 74 | 75 | 76 | ## 0.3.0 77 | 78 | ### Changes 79 | - The `SlackBotServer::Server#on_new_proc` has been renamed to `Server#on_add` 80 | - The `add` and `add_bot` methods on `SlackBotServer::Server` and `SlackBotServer::RemoteControl` control have been merged as `add_bot` 81 | - Multiple arguments may be passed via the `add_bot` method to the block given to `SlackBotServer::on_add` 82 | -------------------------------------------------------------------------------- /lib/slack_bot_server/remote_control.rb: -------------------------------------------------------------------------------- 1 | # Send commands to a running SlackBotServer::Server instance 2 | # 3 | # This should be initialized with a queue that is shared with the 4 | # targetted server (e.g. the same local queue instance, or a 5 | # redis queue instance that points at the same redis server). 6 | class SlackBotServer::RemoteControl 7 | # Create a new instance of a remote control 8 | # @param queue [Object] any Object conforming to the queue API 9 | # (i.e. with #push and #pop methods) 10 | def initialize(queue:) 11 | @queue = queue 12 | end 13 | 14 | # Sends an +add_bot+ command to the {SlackBotServer::Server server}. 15 | # See {SlackBotServer::Server#add_bot} for arguments. 16 | def add_bot(*args) 17 | @queue.push([:add_bot, *args]) 18 | end 19 | 20 | # Sends a +remove_bot+ command to the server. 21 | # @param key [String] the key of the bot to remove. 22 | def remove_bot(key) 23 | @queue.push([:remove_bot, key]) 24 | end 25 | 26 | # Sends an +broadcast+ command to the {SlackBotServer::Server server}. 27 | # @param key [String] the key of the bot which should send the message 28 | # @param message_data [Hash] passed directly to 29 | # {SlackBotServer::Bot#broadcast}; see there for argument details. 30 | def broadcast(key, message_data) 31 | @queue.push([:broadcast, key, message_data]) 32 | end 33 | 34 | # Sends an +say+ command to the {SlackBotServer::Server server}. 35 | # @param key [String] the key of the bot which should send the message. 36 | # @param message_data [Hash] passed directly to 37 | # {SlackBotServer::Bot#say}; see there for argument details. 38 | def say(key, message_data) 39 | @queue.push([:say, key, message_data]) 40 | end 41 | 42 | # Sends an +say_to+ command to the {SlackBotServer::Server server}. 43 | # @param key [String] the key of the bot which should send the message. 44 | # @param user_id [String] the Slack user ID of the person who should 45 | # receive the message. 46 | # @param message_data [Hash] passed directly to 47 | # {SlackBotServer::Bot#say_to}; see there for argument details. 48 | def say_to(key, user_id, message_data) 49 | @queue.push([:say_to, key, user_id, message_data]) 50 | end 51 | 52 | # Sends an +update+ command to the {SlackBotServer::Server server}. 53 | # @param key [String] the key of the bot which should send the message. 54 | # @param message_data [Hash] passed directly to 55 | # {SlackBotServer::Bot#update}; see there for argument details. 56 | def update(key, message_data) 57 | @queue.push([:update, key, message_data]) 58 | end 59 | 60 | # Sends a message to be called directly on the slack web API. Generally 61 | # for debugging only. 62 | # @param key [String] the key of the bot which should send the message. 63 | # @param method [String, Symbol] the name of the method to call 64 | # @param args [Array] the arguments for the method to call 65 | def call(key, method, args) 66 | @queue.push([:call, key, method, args]) 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/server_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'rspec/em' 3 | 4 | RSpec.describe SlackBotServer::Server do 5 | let(:queue) { double('queue') } 6 | let(:server) { described_class.new(queue: queue)} 7 | 8 | describe "when not running" do 9 | it "does not start bots that are added" do 10 | bot = double(:bot, key: 'key') 11 | allow(bot).to receive(:start).never 12 | server.add_bot(bot) 13 | end 14 | end 15 | 16 | describe "sending instructions via the queue" do 17 | include RSpec::EM::FakeClock 18 | 19 | before { clock.stub } 20 | after { clock.reset } 21 | 22 | describe "adding a new bot" do 23 | it "calls add_bot on the server with the given arguments" do 24 | expect(server).to receive(:add_bot).with('arg1', 'arg2') 25 | enqueue_instruction :add_bot, 'arg1', 'arg2' 26 | run_server 27 | end 28 | end 29 | 30 | describe "removing a bot from the server" do 31 | it "calls remove_bot on the server with the given key" do 32 | allow(server).to receive(:bot).with('bot-key').and_return(double('bot')) 33 | expect(server).to receive(:remove_bot).with('bot-key') 34 | enqueue_instruction :remove_bot, 'bot-key' 35 | run_server 36 | end 37 | end 38 | 39 | describe "sending a message from a bot to all channels" do 40 | it "calls broadcast on the bot instance matching the given key with the message data" do 41 | bot = double('bot') 42 | allow(server).to receive(:bot).with('bot-key').and_return(bot) 43 | expect(bot).to receive(:broadcast).with('text' => 'hello') 44 | enqueue_instruction :broadcast, 'bot-key', text: 'hello' 45 | run_server 46 | end 47 | end 48 | 49 | describe "sending a message from a bot" do 50 | it "calls say on the bot instance matching the given key with the message data" do 51 | bot = double('bot') 52 | allow(server).to receive(:bot).with('bot-key').and_return(bot) 53 | expect(bot).to receive(:say).with('text' => 'hello') 54 | enqueue_instruction :say, 'bot-key', text: 'hello' 55 | run_server 56 | end 57 | end 58 | 59 | describe "sending a message from a bot to a specific user" do 60 | it "calls say_to on the bot instance matching the given key with the message data" do 61 | bot = double('bot') 62 | allow(server).to receive(:bot).with('bot-key').and_return(bot) 63 | expect(bot).to receive(:say_to).with('userid', 'text' => 'hello') 64 | enqueue_instruction :say_to, 'bot-key', 'userid', text: 'hello' 65 | run_server 66 | end 67 | end 68 | 69 | describe "updating a message" do 70 | it "calls update on the bot instance matching the given key with the message data" do 71 | bot = double('bot') 72 | allow(server).to receive(:bot).with('bot-key').and_return(bot) 73 | expect(bot).to receive(:update).with('text' => 'hello') 74 | enqueue_instruction :update, 'bot-key', text: 'hello' 75 | run_server 76 | end 77 | end 78 | 79 | describe "calling an arbitrary method on a bot" do 80 | it "calls 'call' on the bot" do 81 | bot = double('bot') 82 | allow(server).to receive(:bot).with('bot-key').and_return(bot) 83 | expect(bot).to receive(:call).with('method', ['args']) 84 | enqueue_instruction :call, 'bot-key', 'method', ['args'] 85 | run_server 86 | end 87 | end 88 | end 89 | 90 | describe "#add_bot" do 91 | let(:bot) { double('bot', key: 'key', start: nil) } 92 | let(:bot_factory) { double('bot factory', build: bot) } 93 | 94 | before do 95 | stub_running_server 96 | server.on_add { |*args| bot_factory.build(*args) } 97 | end 98 | 99 | it "builds the bot by passing the arguments to the add proc" do 100 | expect(bot_factory).to receive(:build).with('arg1', 'arg2').and_return(bot) 101 | 102 | server.add_bot('arg1', 'arg2') 103 | end 104 | 105 | it "starts the bot if the server is running" do 106 | expect(bot).to receive(:start) 107 | server.add_bot('args') 108 | end 109 | 110 | it "makes the bot available by key" do 111 | server.add_bot(bot) 112 | expect(server.bot('key')).to eq bot 113 | end 114 | 115 | it "does not add the bot if one already exists" do 116 | server.add_bot(bot) 117 | 118 | duplicate_bot = double('duplicate-bot', key: bot.key, start: nil) 119 | allow(bot_factory).to receive(:build).and_return(duplicate_bot) 120 | 121 | server.add_bot 122 | expect(server.bot(bot.key)).to eq bot 123 | end 124 | 125 | it "does not crash if starting the bot raises an error" do 126 | allow(bot).to receive(:start).and_raise(RuntimeError.new) 127 | expect { server.add_bot(bot) }.not_to raise_error 128 | end 129 | end 130 | 131 | describe "#remove_bot" do 132 | it "does nothing if the bot cannot be found" do 133 | allow(server).to receive(:bot).with('invalid-key').and_return(nil) 134 | expect { 135 | server.remove_bot('invalid-key') 136 | }.not_to raise_error 137 | end 138 | 139 | describe "with a valid key" do 140 | let(:bot) { double('bot', key: 'key', start: nil, stop: nil) } 141 | let(:bot_factory) { double('bot factory', build: bot) } 142 | 143 | before do 144 | server.on_add { |*args| bot_factory.build(*args) } 145 | server.add_bot 146 | end 147 | 148 | it "stops the bot" do 149 | expect(bot).to receive(:stop) 150 | server.remove_bot('key') 151 | end 152 | 153 | it "removes the bot" do 154 | server.remove_bot('key') 155 | expect(server.bot('key')).to be_nil 156 | end 157 | end 158 | end 159 | 160 | private 161 | 162 | def enqueue_instruction(*args) 163 | allow(queue).to receive(:pop).and_return(MultiJson.load(MultiJson.dump(args))) 164 | end 165 | 166 | def stub_running_server 167 | server.instance_eval { @running = true } 168 | end 169 | 170 | def run_server 171 | server.send(:listen_for_instructions) 172 | clock.tick(1) 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /lib/slack_bot_server/server.rb: -------------------------------------------------------------------------------- 1 | require 'slack_bot_server/bot' 2 | require 'slack_bot_server/simple_bot' 3 | require 'slack_bot_server/redis_queue' 4 | require 'slack_bot_server/logging' 5 | require 'eventmachine' 6 | 7 | # Implements a server for running multiple Slack bots. Bots can be 8 | # dynamically added and removed, and can be interacted with from 9 | # external services (like your application). 10 | # 11 | # To use this, you should create a script to run along side your 12 | # application. A simple example: 13 | # 14 | # #!/usr/bin/env ruby 15 | # 16 | # require 'slack_bot_server' 17 | # require 'slack_bot_server/redis_queue' 18 | # require 'slack_bot_server/simple_bot' 19 | # 20 | # # Use a Redis-based queue to add/remove bots and to trigger 21 | # # bot messages to be sent 22 | # queue = SlackBotServer::RedisQueue.new 23 | # 24 | # # Create a new server using that queue 25 | # server = SlackBotServer::Server.new(queue: queue) 26 | # 27 | # # How your application-specific should be created when the server 28 | # # is told about a new slack api token to connect with 29 | # server.on_add do |token| 30 | # # Return a new bot instance to the server. `SimpleBot` is a provided 31 | # # example bot with some very simple behaviour. 32 | # SlackBotServer::SimpleBot.new(token: token) 33 | # end 34 | # 35 | # # Start the server. This method blocks, and will not return until 36 | # # the server is killed. 37 | # server.start 38 | # 39 | # The key features are: 40 | # 41 | # * creating a queue as a conduit for commands from your app 42 | # * creating an instance of {Server} with that queue 43 | # * defining an #on_add block, which is run whenever you need to 44 | # start a new bot. This block contains the custom code relevant to 45 | # your particular service, most typically the instantiation of a bot 46 | # class that implements the logic you want 47 | # * calling {Server#start}, to actually run the server and start 48 | # listening for commands from the queue and connecting bots to Slack 49 | # itself 50 | # 51 | class SlackBotServer::Server 52 | include SlackBotServer::Logging 53 | 54 | attr_reader :queue 55 | 56 | # Creates a new {Server} 57 | # @param queue [Object] anything that implements the queue protocol 58 | # (e.g. #push and #pop) 59 | def initialize(queue: SlackBotServer::LocalQueue.new) 60 | @queue = queue 61 | @bots = {} 62 | @add_proc = -> (token) { SlackBotServer::SimpleBot.new(token: token) } 63 | @running = false 64 | end 65 | 66 | # Define the block which should be called when the #add_bot method is 67 | # called, or the +add_bot+ message is sent via a queue. This block 68 | # should return a bot (which responds to start), in which case it will 69 | # be added and started. If anything else is returned, it will be ignored. 70 | def on_add(&block) 71 | @add_proc = block 72 | end 73 | 74 | # Starts the server. This method will not return; call it at the 75 | # end of your server script. It will start all bots it knows about 76 | # (i.e. bots added via #add_bot before the server was started), 77 | # and then listen for new instructions. 78 | # @see Bot#start 79 | def start 80 | EM.run do 81 | @running = true 82 | @bots.each do |key, bot| 83 | begin 84 | bot.start 85 | rescue => e 86 | log_error(e, "attempting to start bot #{bot.key}") 87 | end 88 | end 89 | listen_for_instructions if queue 90 | end 91 | end 92 | 93 | # Starts the server in the background, via a Thread 94 | def start_in_background 95 | Thread.start { start } 96 | end 97 | 98 | # Find a bot added to this server. Returns nil if no bot was found 99 | # @param key [String] the key of the bot we're looking for 100 | # @return Bot 101 | def bot(key) 102 | @bots[key] 103 | end 104 | 105 | # Adds a bot to this server 106 | # Calls the block given to {#on_add} with the arguments given. The block 107 | # should yield a bot, typically a subclass of {Bot}. 108 | # @see #on_add 109 | def add_bot(*args) 110 | bot = @add_proc.call(*args) 111 | if bot.respond_to?(:start) && !bot(bot.key) 112 | log "adding bot #{bot}" 113 | @bots[bot.key] = bot 114 | bot.start if @running 115 | end 116 | rescue => e 117 | log_error(e, "Attempting to add bot", *args) 118 | end 119 | 120 | # Stops and removes a bot from the server 121 | # @param key [String] the key of the bot to remove 122 | # @see SlackBotServer::Bot#stop 123 | def remove_bot(key) 124 | if (bot = bot(key)) 125 | bot.stop 126 | @bots.delete(key) 127 | end 128 | rescue => e 129 | log_error(e, "Attempting to remove bot with key #{key}") 130 | end 131 | 132 | private 133 | 134 | def listen_for_instructions 135 | EM.add_periodic_timer(1) do 136 | begin 137 | next_message = queue.pop 138 | process_instruction(next_message) if next_message 139 | rescue => e 140 | log_error(e, "error handling instructions") 141 | end 142 | end 143 | end 144 | 145 | def process_instruction(instruction) 146 | type, *args = instruction 147 | bot_key = args.shift 148 | if type.to_sym == :add_bot 149 | log "adding bot: #{bot_key} #{args.inspect}" 150 | add_bot(bot_key, *args) 151 | else 152 | with_bot(bot_key) do |bot| 153 | case type.to_sym 154 | when :remove_bot 155 | remove_bot(bot_key) 156 | when :broadcast 157 | log "[#{bot_key}] broadcast: #{args}" 158 | bot.broadcast(*args) 159 | when :say 160 | log "[#{bot_key}] say: #{args}" 161 | bot.say(*args) 162 | when :say_to 163 | user_id, message_data = args 164 | log "[#{bot_key}] say_to: (#{user_id}) #{message_data}" 165 | bot.say_to(user_id, message_data) 166 | when :update 167 | message_data = args.first 168 | log "[#{bot_key}] update: #{message_data}" 169 | bot.update(message_data) 170 | when :call 171 | method, method_args = args 172 | bot.call(method, method_args) 173 | else 174 | log unknown_command: instruction 175 | end 176 | end 177 | end 178 | rescue => e 179 | log_error(e, "Error processing instruction: #{instruction.inspect}") 180 | raise e 181 | end 182 | 183 | # def log(message) 184 | # text = message.is_a?(String) ? message : message.inspect 185 | # SlackBotServer.logger.info(text) 186 | # end 187 | 188 | def with_bot(key) 189 | if bot = bot(key) 190 | yield bot 191 | else 192 | log("Unknown bot: #{key}") 193 | end 194 | end 195 | end 196 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SlackBotServer 2 | 3 | **This project is no longer being maintained** 4 | 5 | The Slack RTM API and bot mechanisms have changed quite a bit since this project was started, and it no longer fills a need we have. 6 | 7 | [![Build Status](https://travis-ci.org/exciting-io/slack-bot-server.svg)](https://travis-ci.org/exciting-io/slack-bot-server) [![Documentation](http://img.shields.io/badge/yard-docs-blue.svg)](http://www.rubydoc.info/github/exciting-io/slack-bot-server) 8 | 9 | If you're building an integration just for yourself, running a single bot isn't too hard and there are plenty of examples available. However, if you're building an integration for your *product* to connect with multiple teams, running multiple instances of that bot is a bit trickier. 10 | 11 | This server is designed to hopefully make it easier to manage running bots for multiple teams at the same time, including managing their connections and adding and removing them dynamically. 12 | 13 | ## Installation 14 | 15 | Add this line to your application's Gemfile: 16 | 17 | ```ruby 18 | gem 'slack-bot-server' 19 | ``` 20 | 21 | And then execute: 22 | 23 | $ bundle 24 | 25 | Or install it yourself as: 26 | 27 | $ gem install slack-bot-server 28 | 29 | 30 | ### Optional queue stores 31 | 32 | The default queueing mechanism uses Redis as its underlying store, but you are not tied to this - any object that has the API `#push`, `#pop` and `#clear` can be used -- and so Redis is not an explicit dependency. 33 | 34 | However, if you are happy to use Redis (as the examples below to), you should ensure to add the `redis` gem to your `Gemfile` or your local rubygems installation. 35 | 36 | 37 | ## Usage 38 | 39 | To use the server in your application, you'll need to create a short script that sets up your integration and then runs the server process. Here's a simple example: 40 | 41 | ```ruby 42 | #!/usr/bin/env ruby 43 | 44 | require 'slack_bot_server' 45 | require 'slack_bot_server/redis_queue' 46 | require 'slack_bot_server/simple_bot' 47 | 48 | # Use a Redis-based queue to add/remove bots and to trigger 49 | # bot messages to be sent 50 | queue = SlackBotServer::RedisQueue.new 51 | 52 | # Create a new server using that queue 53 | server = SlackBotServer::Server.new(queue: queue) 54 | 55 | # How your application-specific should be created when the server 56 | # is told about a new slack api token to connect with 57 | server.on_add do |token| 58 | # Return a new bot instance to the server. `SimpleBot` is a provided 59 | # example bot with some very simple behaviour. 60 | SlackBotServer::SimpleBot.new(token: token) 61 | end 62 | 63 | # Actually start the server. This line is blocking; code after 64 | # it won't be executed. 65 | server.start 66 | ``` 67 | 68 | If you're using Rails, I'd suggest you create your script as `bin/slack_server` (i.e. a file called `slack_server` in the `bin` directory you already have) 69 | 70 | Running this script will start a server and keep it running; you may wish to use a tool like [Foreman](http://ddollar.github.io/foreman/) to actually start it and manage it in production. Here's a sample `Procfile`: 71 | 72 | ``` 73 | web: bundle exec rails server 74 | slack_server: bundle exec rails runner bin/slack_server 75 | ``` 76 | 77 | By running the `bin/slack_server` script using `rails runner`, your bots get access to all the Rails models and libraries even when they are running outside of the main Rails web processes. 78 | 79 | ### Advanced server example 80 | 81 | This is a more advanced example of a server script, based on the that used by [Harmonia][harmonia], the product from which this was extracted. 82 | 83 | ```ruby 84 | #!/usr/bin/env ruby 85 | 86 | require 'slack_bot_server' 87 | require 'slack_bot_server/redis_queue' 88 | require 'harmonia/slack_bot' 89 | 90 | # Use a Redis-based queue to add/remove bots and to trigger 91 | # bot messages to be sent. In this case we connect to the same 92 | # redis instance as Resque, just for convenience. 93 | queue = SlackBotServer::RedisQueue.new(redis: Resque.redis) 94 | 95 | server = SlackBotServer::Server.new(queue: queue) 96 | 97 | # The `on_add` block can take any number arguments - basically whatever 98 | # is passed to the `add_bot` method (see below). Since the bot will almost 99 | # certainly need to use a Slack API token to actually connect to Slack, 100 | # this should either be one of the arguments, or be retrievable using one 101 | # of the arguments. 102 | # It should return a bot (something that responds to `start`); if anything 103 | # else is returned, it will be ignored. 104 | server.on_add do |token, team_id| 105 | # Our bots need to know some data about the team they are connecting 106 | # to, like specifics of their account and their tasks 107 | team_data = Harmonia.find_team_data(team_id) 108 | 109 | # Our bot instance stores that data in an instance variable internally 110 | # and then refers to it when it receives messages 111 | Harmonia::SlackBot.new(token: token, data: team_data) 112 | end 113 | 114 | # When the server starts we need to find all the teams which have already 115 | # set up integrations and ensure their bots are launched immediately 116 | Harmonia.teams.each do |team| 117 | # Any arguments can be passed to the `add_bot` method; they are passed 118 | # on to the proc supplied to `on_add` for the server. 119 | server.add_bot(team.slack_token, team.id) 120 | end 121 | 122 | # Actually start the server. The pre-loaded bots will connect immediately, 123 | # and we can add new bots by sending messages using the queue. 124 | server.start 125 | ``` 126 | 127 | ### Writing a bot 128 | 129 | The provided example `SimpleBot` illustrates the main ways to build a bot: 130 | 131 | ```ruby 132 | require 'slack_bot_server/bot' 133 | 134 | class SlackBotServer::SimpleBot < SlackBotServer::Bot 135 | # Set the friendly username displayed in Slack 136 | username 'SimpleBot' 137 | # Set the image to use as an avatar icon in Slack 138 | icon_url 'http://my.server.example.com/assets/icon.png' 139 | 140 | # Respond to mentions in the connected chat room (defaults to #general). 141 | # As well as the normal data provided by Slack's API, we add the `message`, 142 | # which is the `text` parameter with the username stripped out. For example, 143 | # When a user sends 'simple_bot: how are you?', the `message` data contains 144 | # only 'how are you'. 145 | on_mention do |data| 146 | if data['message'] == 'who are you' 147 | reply text: "I am #{bot_user_name} (user id: #{bot_user_id}, connected to team #{team_name} with team id #{team_id}" 148 | else 149 | reply text: "You said '#{data.message}', and I'm frankly fascinated." 150 | end 151 | end 152 | 153 | # Respond to messages sent via IM communication directly with the bot. 154 | on_im do 155 | reply text: "Hmm, OK, let me get back to you about that." 156 | end 157 | end 158 | ``` 159 | 160 | As well as the special `on_mention` and `on_im` blocks, there are a number 161 | of other hooks that you can use when writing a bot: 162 | 163 | * `on :message` -- will fire for every message that's received from Slack in 164 | the rooms that this bot is a member of 165 | * `on :start` -- will fire when the bot establishes a connection to Slack 166 | (note that periodic disconnections will occur, so this hook is best used 167 | to gather data about the current state of Slack. You should not assume 168 | this is the first time the bot has ever connected) 169 | * `on :finish` -- will fire when the bot is disconnected from Slack. This 170 | may be because a disconnection happened, or might be because the bot was 171 | removed from the server via the `remove_bot` command. You can check if 172 | the bot was accidentally/intermittently disconnected via the `running?` 173 | method, which will return true unless the bot was explicitly stopped. 174 | 175 | ## Slack App setup 176 | 177 | As well as defining your bots in your own application, you need to tell Slack 178 | itself about your app. You can do this at https://api.slack.com. You'll want to 179 | create an "Installable Slack apps for any team to use". 180 | 181 | There's some amount of documentation preamble to read, but once you follow the 182 | prompts, you'll be asked to choose an app name and the Slack team that "owns" 183 | this app, after which you'll be given your app _credentials_ -- a 'Client ID' 184 | and a 'Client Secret'. You'll need these to configure your app properly. 185 | 186 | #### OAuth setup 187 | 188 | Still on the Slack site, you'll also need to set up your app for OAuth in order 189 | to be able to use the 'Add to Slack' button later. Click on 'OAuth & Permissions' 190 | in the sidebar, and then enter the urls your application runs at as valid 191 | 'Redirect URLs'. 192 | 193 | You only really need to include the start of the URL, since a 194 | partial match is fine. For example, for [Harmonia][harmonia] I have two URLs: 195 | 196 | * https://harmonia.io 197 | * http://harmonia.dev 198 | 199 | These are the URLs for the production service, and the URL I use locally, which 200 | lets me test things out without deploying them. The actual URL includes a longer 201 | path component, but you don't need to include this here. 202 | 203 | #### Add to Slack button 204 | 205 | Here's the general form of an 'Add to Slack' button: 206 | 207 | 208 | Add to Slack 211 | 212 | 213 | Slack may change this; you can check https://api.slack.com/docs/slack-button for 214 | their button builder if necessary. 215 | 216 | You should replace `CLIENT_ID` and `CLIENT_SECRET` with the values you were given 217 | when you created the app on Slack's site. `SCOPES` should be something like 218 | `bot,team:read` (see Slack's API documentation for what these and other scopes 219 | mean). 220 | 221 | The `REDIRECT_URI` should be the URI to an endpoint in _your_ app where 222 | you will intercept the Oauth request. 223 | 224 | ### OAuth endpoints in your app 225 | 226 | It's worthwhile understanding a little about OAuth; Slack provides some good 227 | background here: https://api.slack.com/docs/oauth 228 | 229 | For the sake of this example, let's assume you're using Rails. Here's what a 230 | simple OAuth setup might look like, approximately: 231 | 232 | In `config/routes.rb`: 233 | 234 | get '/slack_oauth', as: 'slack_oauth', to: 'slack_controller#oauth' 235 | 236 | In `app/controllers/slack_controller.rb`: 237 | 238 | class SlackController < ApplicationController 239 | def oauth 240 | if params['code'] 241 | slack_client = Slack::Web::Client.new 242 | response = slack_client.oauth_access( 243 | code: params['code'], 244 | client_id: ENV['SLACK_CLIENT_ID'], 245 | client_secret: ENV['SLACK_CLIENT_SECRET'], 246 | redirect_uri: slack_oauth_url(account_id: current_account.id) 247 | ) 248 | if response['ok'] 249 | # the response object will now contain the access tokens you 250 | # need; something like 251 | # { 252 | # "access_token": "xoxp-XXXXXXXX-XXXXXXXX-XXXXX", 253 | # "scope": "bot,team:read", 254 | # "team_name": "Team Installing Your Bot", 255 | # "team_id": "XXXXXXXXXX", 256 | # "bot":{ 257 | # "bot_user_id":"UTTTTTTTTTTR", 258 | # "bot_access_token":"xoxb-XXXXXXXXXXXX-TTTTTTTTTTTTTT" 259 | # } 260 | # } 261 | # At the very least you should store the `bot_access_token` and 262 | # probably the `access_token` too. 263 | SlackIntegration.create( 264 | account_id: params['account_id'], 265 | access_token: response['access_token'], 266 | bot_access_token: response['bot']['bot_access_token'] 267 | ) 268 | else 269 | # there was a failure; check in the response 270 | end 271 | else 272 | redirect_to '/' # they cancelled adding the integration 273 | end 274 | end 275 | end 276 | 277 | Here our controller responds to a request from Slack with a code, and uses that 278 | code to obtain access tokens for the user's slack team. 279 | 280 | You'll almost certainly want to associate the created `SlackIntegration` with 281 | another model (e.g. an account, user or team) in your own application; I've done 282 | this here by including the `account_id` in the redirect_uri that we send back 283 | to Slack. 284 | 285 | In `app/models/slack_integration.rb`: 286 | 287 | # Assumes a table including `access_token` and `bot_access_token` as 288 | # strings 289 | require 'slack_bot_server/remote_control' 290 | 291 | class SlackIntegration < ActiveRecord::Base 292 | after_create :add_to_slack_server 293 | 294 | private 295 | 296 | def add_to_slack_server 297 | queue = SlackBotServer::RedisQueue.new(redis: Redis.new) 298 | slack_remote = SlackBotServer::RemoteControl.new(queue: queue) 299 | slack_remote.add_bot(self.bot_access_token) 300 | end 301 | end 302 | 303 | For more explanation about that last method, read on... 304 | 305 | ### Managing bots 306 | 307 | When someone in your application wants to connect their account with Slack, they'll need to provide a bot API token, which your application should store. 308 | 309 | In order to actually create and connect their bot, you can use the remote 310 | control to add the token to the server. 311 | 312 | ```ruby 313 | # Somewhere within your application 314 | require 'slack_bot_server/remote_control' 315 | 316 | queue = SlackBotServer::RedisQueue.new(redis: Redis.new) 317 | slack_remote = SlackBotServer::RemoteControl.new(queue: queue) 318 | slack_remote.add_bot('user-accounts-slack-api-token') 319 | ``` 320 | 321 | This will queue a bot be added by the server, using the `on_add` block provided in the server script. 322 | 323 | When a bot is created and added within the server, it is stored using a key, which the bot class itself can define, but defaults to the slack api token used to instantiate the bot. 324 | 325 | Similarly, if a user disables their Slack integration, we should remove the bot. To remove a bot, call the `remove_bot` method on the remote using the key for the appropriate bot: 326 | 327 | ```ruby 328 | slack_remote.remove_bot('bot-key-which-is-normally-the-slack-api-token') 329 | ``` 330 | 331 | ### Getting bots to talk 332 | 333 | Up to this point, your bots could only respond to mentions and IM messages, but it's often useful to be able to externally trigger a bot into making an announcement. 334 | 335 | We can tell a bot to send a message into its default room fairly simply using the remote: 336 | 337 | ```ruby 338 | slack_remote.say('bot-key', channel: '#general', text: 'I have an important announcement to make!') 339 | ``` 340 | 341 | ## Development 342 | 343 | After checking out the repo, run `bundle` to install dependencies. Then, run `rake rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. Run `bundle exec slack_bot_server` to use the gem in this directory, ignoring other installed copies of this gem. 344 | 345 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 346 | 347 | ## Contributing 348 | 349 | Bug reports and pull requests are welcome on GitHub at https://github.com/exciting-io/slack-bot-server. 350 | 351 | 352 | ## License 353 | 354 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 355 | 356 | [harmonia]: https://harmonia.io 357 | -------------------------------------------------------------------------------- /lib/slack_bot_server/bot.rb: -------------------------------------------------------------------------------- 1 | require 'slack_bot_server/logging' 2 | require 'slack' 3 | require 'slack-ruby-client' 4 | 5 | # A superclass for integration bot implementations. 6 | # 7 | # A simple example: 8 | # 9 | # class MyBot < SlackBotServer::Bot 10 | # # Set the friendly username displayed in Slack 11 | # username 'My Bot' 12 | # # Set the image to use as an avatar icon in Slack 13 | # icon_url 'http://my.server.example.com/assets/icon.png' 14 | # 15 | # # Respond to mentions in the connected chat room (defaults to #general). 16 | # # As well as the normal data provided by Slack's API, we add the `message`, 17 | # # which is the `text` parameter with the username stripped out. For example, 18 | # # When a user sends 'simple_bot: how are you?', the `message` data contains 19 | # # only 'how are you'. 20 | # on_mention do |data| 21 | # if data['message'] == 'who are you' 22 | # reply text: "I am #{bot_user_name} (user id: #{bot_user_id}, connected to team #{team_name} with team id #{team_id}" 23 | # else 24 | # reply text: "You said '#{data['message']}', and I'm frankly fascinated." 25 | # end 26 | # end 27 | # 28 | # # Respond to messages sent via IM communication directly with the bot. 29 | # on_im do 30 | # reply text: "Hmm, OK, let me get back to you about that." 31 | # end 32 | # end 33 | # 34 | class SlackBotServer::Bot 35 | include SlackBotServer::Logging 36 | extend SlackBotServer::Logging 37 | 38 | # The user ID of the special slack user +SlackBot+ 39 | SLACKBOT_USER_ID = 'USLACKBOT' 40 | 41 | attr_reader :key, :token, :client 42 | 43 | # Raised if there was an error while trying to connect to Slack 44 | class ConnectionError < Slack::Web::Api::Error; end 45 | 46 | # Create a new bot. 47 | # This is normally called from within the block passed to 48 | # {SlackBotServer::Server#on_add}, which should return a new 49 | # bot instance. 50 | # @param token [String] the Slack bot token to use for authentication 51 | # @param key [String] a key used to target messages to this bot from 52 | # your application when using {RemoteControl}. If not provided, 53 | # this defaults to the token. 54 | def initialize(token:, key: nil) 55 | @token = token 56 | @key = key || @token 57 | @connected = false 58 | @running = false 59 | end 60 | 61 | # Returns the username (for @ replying) of the bot user we are connected as, 62 | # e.g. +'simple_bot'+ 63 | def bot_user_name 64 | client.self.name 65 | end 66 | 67 | # Returns the ID of the bot user we are connected as, e.g. +'U123456'+ 68 | def bot_user_id 69 | client.self.id 70 | end 71 | 72 | # Returns the name of the team we are connected to, e.g. +'My Team'+ 73 | def team_name 74 | client.team.name 75 | end 76 | 77 | # Returns the ID of the team we are connected to, e.g. +'T234567'+ 78 | def team_id 79 | client.team.id 80 | end 81 | 82 | # Send a message to Slack 83 | # @param options [Hash] a hash containing any of the following: 84 | # channel:: the name ('#general'), or the ID of the channel to send to 85 | # text:: the actual text of the message 86 | # username:: the name the message should appear from; defaults to the 87 | # value given to `username` in the Bot class definition 88 | # icon_url:: the image url to use as the avatar for this message; 89 | # defaults to the value given to `icon_url` in the Bot 90 | # class definition 91 | def say(options) 92 | message = symbolize_keys(default_message_options.merge(options)) 93 | pingback_target = message.delete(:pingback) 94 | 95 | result = if rtm_incompatible_message?(message) 96 | debug "Sending via Web API", message 97 | client.web_client.chat_postMessage(message) 98 | else 99 | debug "Sending via RTM API", message 100 | client.message(message) 101 | end 102 | 103 | if pingback_target 104 | handle_pingback(channel: result.channel, ts: result.message.ts, target: pingback_target) 105 | end 106 | 107 | result 108 | end 109 | 110 | # Update a message on Slack 111 | # @param options [Hash] a hash containing any of the following: 112 | # ts:: The timestamp of the original_message you want to update 113 | # channel:: The channel ID of the message to update 114 | # text:: the actual text of the message 115 | def update(options) 116 | message = symbolize_keys(options) 117 | 118 | debug "Sending via Web API", message 119 | client.web_client.chat_update(message) 120 | end 121 | 122 | # Sends a message to every channel this bot is a member of 123 | # @param options [Hash] As {#say}, although the +:channel+ option is 124 | # redundant 125 | def broadcast(options) 126 | client.channels.each do |id, _| 127 | say(options.merge(channel: id)) 128 | end 129 | end 130 | 131 | # Sends a reply to the same channel as the last message that was 132 | # received by this bot. 133 | # @param options [Hash] As {#say}, although the +:channel+ option is 134 | # redundant 135 | def reply(options) 136 | channel = @last_received_user_message.channel 137 | say(options.merge(channel: channel)) 138 | end 139 | 140 | # Sends a message via IM to a user 141 | # @param user_id [String] the Slack user ID of the person to receive this message 142 | # @param options [Hash] As {#say}, although the +:channel+ option is 143 | # redundant 144 | def say_to(user_id, options) 145 | result = client.web_client.im_open(user: user_id) 146 | channel = result.channel.id 147 | say(options.merge(channel: channel)) 148 | end 149 | 150 | # Sends a typing notification 151 | # @param options [Hash] can contain +:channel+, which should be an ID; if no options 152 | # are provided, the channel from the most recently recieved message is used 153 | def typing(options={}) 154 | last_received_channel = @last_received_user_message ? @last_received_user_message.channel : nil 155 | default_options = {channel: last_received_channel} 156 | client.typing(default_options.merge(options)) 157 | end 158 | 159 | # Call a method directly in this instance 160 | def call(method, args) 161 | send(method, *args) 162 | end 163 | 164 | # Call a method directly on the Slack web API (via Slack::Web::Client). 165 | # Useful for debugging only. 166 | def slack_call(method, args) 167 | client.web_client.send(method, args) 168 | end 169 | 170 | # Starts the bot running. 171 | # You should not call this method; instead, the server will call it 172 | # when it is ready for the bot to connect 173 | # @see Server#start 174 | def start 175 | @client = ::Slack::RealTime::Client.new(token: @token) 176 | @running = true 177 | 178 | client.on :open do |event| 179 | @connected = true 180 | log "connected to '#{team_name}'" 181 | run_callbacks(:start) 182 | end 183 | 184 | client.on :message do |data| 185 | begin 186 | debug message: data 187 | @last_received_user_message = data 188 | handle_message(data) 189 | rescue => e 190 | log_error e, "Error handling message #{data.inspect}" 191 | end 192 | end 193 | 194 | client.on :close do |event| 195 | log "disconnected" 196 | @connected = false 197 | run_callbacks(:finish) 198 | end 199 | 200 | register_low_level_callbacks 201 | 202 | client.start_async 203 | rescue Slack::Web::Api::Error => e 204 | log "Connection error for bot #{key}" 205 | raise ConnectionError.new(e.message, e.response) 206 | end 207 | 208 | # Stops the bot from running. You should not call this method; instead 209 | # send the server a +remote_bot+ message 210 | # @see Server#remove_bot 211 | def stop 212 | log "closing connection" 213 | @running = false 214 | client.stop! 215 | log "closed" 216 | end 217 | 218 | # Returns +true+ if this bot is (or should be) running 219 | def running? 220 | @running 221 | end 222 | 223 | # Returns +true+ if this bot is currently connected to Slack 224 | def connected? 225 | @connected 226 | end 227 | 228 | class << self 229 | attr_reader :mention_keywords 230 | 231 | # Sets the username this bot should use 232 | # 233 | # class MyBot < SlackBotServer::Bot 234 | # username 'My Bot' 235 | # 236 | # # etc 237 | # end 238 | # 239 | # will result in the friendly name 'My Bot' appearing beside 240 | # the messages in your Slack rooms 241 | def username(name) 242 | default_message_options[:username] = name 243 | end 244 | 245 | # Sets the image to use as an avatar for this bot 246 | # 247 | # class MyBot < SlackBotServer::Bot 248 | # icon_url 'http://example.com/bot.png' 249 | # 250 | # # etc 251 | # end 252 | def icon_url(url) 253 | default_message_options[:icon_url] = url 254 | end 255 | 256 | # Sets the keywords in messages that will trigger the 257 | # +on_mention+ callback 258 | # 259 | # class MyBot < SlackBotServer::Bot 260 | # mention_as 'hey', 'bot' 261 | # 262 | # # etc 263 | # end 264 | # 265 | # will mean the +on_mention+ callback fires for messages 266 | # like "hey you!" and "bot, what are you thinking". 267 | # 268 | # Mention keywords are only matched at the start of messages, 269 | # so the text "I love you, bot" won't trigger this callback. 270 | # To implement general keyword spotting, use a custom 271 | # +on :message+ callback. 272 | # 273 | # If this is not called, the default mention keyword is the 274 | # bot username, e.g. +simple_bot+ 275 | def mention_as(*keywords) 276 | @mention_keywords = keywords 277 | end 278 | 279 | # Holds default options to send with each message to Slack 280 | def default_message_options 281 | @default_message_options ||= {type: 'message'} 282 | end 283 | 284 | # All callbacks defined on this class 285 | def callbacks 286 | @callbacks ||= {} 287 | end 288 | 289 | # Returns all callbacks (including those in superclasses) for a given 290 | # event type 291 | def callbacks_for(type) 292 | if superclass.respond_to?(:callbacks_for) 293 | matching_callbacks = superclass.callbacks_for(type) 294 | else 295 | matching_callbacks = [] 296 | end 297 | matching_callbacks += callbacks[type.to_sym] if callbacks[type.to_sym] 298 | matching_callbacks 299 | end 300 | 301 | # Register a callback 302 | # 303 | # class MyBot < SlackBotServer::Bot 304 | # on :message do 305 | # reply text: 'I heard a message, so now I am responding!' 306 | # end 307 | # end 308 | # 309 | # Possible callbacks are: 310 | # +:start+ :: fires when the bot establishes a connection to Slack 311 | # +:finish+ :: fires when the bot is disconnected from Slack 312 | # +:message+ :: fires when any message is sent in any channel the bot is 313 | # connected to 314 | # 315 | # Multiple blocks for each type can be registered; they will be run 316 | # in the order they are defined. 317 | # 318 | # If any block returns +false+, later blocks will not be fired. 319 | def on(type, &block) 320 | callbacks[type.to_sym] ||= [] 321 | callbacks[type.to_sym] << block 322 | end 323 | 324 | # Define a callback to run when any of the mention keywords are 325 | # present in a message. 326 | # 327 | # Typically this will be for messages in open channels, where as 328 | # user directs a message to this bot, e.g. "@simple_bot hello" 329 | # 330 | # By default, the mention keyword is simply the bot's username 331 | # e.g. +simple_bot+ 332 | # 333 | # As well as the raw Slack data about the message, the data +Hash+ 334 | # yielded to the given block will contain a +'message'+ key, 335 | # which holds the text sent with the keyword removed. 336 | def on_mention(&block) 337 | on(:message) do |data| 338 | debug on_message: data, bot_message: bot_message?(data) 339 | if !bot_message?(data) && 340 | (data.text =~ /\A(#{mention_keywords.join('|')})[\s\:](.*)/i || 341 | data.text =~ /\A(<@#{bot_user_id}>)[\s\:](.*)/) && 342 | user_message?(data) 343 | message = $2.strip 344 | @last_received_user_message.merge!(message: message) 345 | instance_exec(@last_received_user_message, &block) 346 | end 347 | end 348 | end 349 | 350 | # Define a callback to run when any a user sends a direct message 351 | # to this bot 352 | def on_im(&block) 353 | on(:message) do |data| 354 | debug on_im: data, bot_message: bot_message?(data), is_im_channel: is_im_channel?(data.channel) 355 | if !bot_message?(data) && is_im_channel?(data.channel) && user_message?(data) 356 | @last_received_user_message.merge!(message: data.text) 357 | instance_exec(@last_received_user_message, &block) 358 | end 359 | end 360 | end 361 | 362 | # Define a callback to run when any a user sends a file in direct message 363 | # to this bot 364 | def on_file(&block) 365 | on(:message) do |data| 366 | debug on_file: data, bot_message: bot_message?(data), is_im_channel: is_im_channel?(data.channel) 367 | if !bot_message?(data) && is_im_channel?(data.channel) && file_message?(data) 368 | @last_received_user_message.merge!(message: data.text) 369 | instance_exec(@last_received_user_message, &block) 370 | end 371 | end 372 | end 373 | 374 | def low_level_callbacks 375 | @low_level_callbacks ||= [] 376 | end 377 | 378 | # Define a callback to use when a low-level slack event is fired 379 | def on_slack_event(name, &block) 380 | self.low_level_callbacks << [name, block] 381 | end 382 | end 383 | 384 | on :finish do 385 | start if @running 386 | end 387 | 388 | # Returns a String representation of this {Bot} 389 | # @return String 390 | def to_s 391 | "<#{self.class.name} key:#{key}>" 392 | end 393 | 394 | private 395 | 396 | attr_reader :client 397 | 398 | def handle_message(data) 399 | run_callbacks(data.type, data) 400 | end 401 | 402 | def run_callbacks(type, data=nil) 403 | relevant_callbacks = self.class.callbacks_for(type) 404 | relevant_callbacks.each do |c| 405 | response = instance_exec(data, &c) 406 | break if response == false 407 | end 408 | end 409 | 410 | def handle_pingback(channel:, ts:, target:) 411 | # leave subclasses to implement this 412 | end 413 | 414 | def register_low_level_callbacks 415 | self.class.low_level_callbacks.each do |(type, callback)| 416 | client.on(type) do |*args| 417 | begin 418 | instance_exec(*args, &callback) 419 | rescue => e 420 | log_error e 421 | end 422 | end 423 | end 424 | end 425 | 426 | def is_im_channel?(id) 427 | client.ims[id] != nil 428 | end 429 | 430 | def bot_message?(data) 431 | data.subtype == 'bot_message' || 432 | data.user == SLACKBOT_USER_ID || 433 | data.user == bot_user_id || 434 | change_to_previous_bot_message?(data) 435 | end 436 | 437 | def change_to_previous_bot_message?(data) 438 | data.subtype == 'message_changed' && 439 | data.previous_message.user == bot_user_id 440 | end 441 | 442 | def user_message?(data) 443 | !bot_message?(data) && data.subtype.nil? 444 | end 445 | 446 | def file_message?(data) 447 | !bot_message?(data) && data.subtype == 'file_share' 448 | end 449 | 450 | def rtm_incompatible_message?(data) 451 | !(data[:attachments].nil? && 452 | data[:username].nil? && 453 | data[:icon_url].nil? && 454 | data[:icon_emoji].nil? && 455 | data[:channel].match(/^#/).nil?) 456 | end 457 | 458 | def default_message_options 459 | self.class.default_message_options 460 | end 461 | 462 | def mention_keywords 463 | self.class.mention_keywords || [bot_user_name] 464 | end 465 | 466 | def symbolize_keys(hash) 467 | hash.keys.each do |key| 468 | hash[key.to_sym] = hash.delete(key) 469 | end 470 | hash 471 | end 472 | end 473 | -------------------------------------------------------------------------------- /spec/bot_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'faye/websocket' 3 | 4 | RSpec.describe SlackBotServer::Bot do 5 | let(:slack_rtm_api) { ::Slack::RealTime::Client.new } 6 | let(:slack_web_api) { double('slack api', rtm_start: {'url' => 'ws://example.com'}) } 7 | let(:im_list) { [] } 8 | let(:channel_list) { [{'id' => 'ABC123', 'is_member' => true}] } 9 | let(:bot_user_id) { 'U123456' } 10 | 11 | before do 12 | stub_websocket 13 | allow(::Slack::RealTime::Client).to receive(:new).and_return(slack_rtm_api) 14 | allow(slack_rtm_api).to receive(:web_client).and_return(slack_web_api) 15 | 16 | allow(::Slack::Web::Client).to receive(:new).and_return(slack_web_api) 17 | allow(slack_web_api).to receive(:auth_test).and_return({'ok' => true}) 18 | allow(slack_web_api).to receive(:rtm_start).and_return({ 19 | 'ok' => true, 20 | 'self' => {'name' => 'test_bot', 'id' => bot_user_id}, 21 | 'team' => {'name' => 'team name', 'id' => 'T123456'}, 22 | 'ims' => im_list, 23 | 'channels' => channel_list, 24 | 'url' => 'ws://example.dev/slack' 25 | }) 26 | end 27 | 28 | specify "#user returns the name of the bot" do 29 | expect(bot_instance.bot_user_name).to eq 'test_bot' 30 | end 31 | 32 | specify "#user_id returns the user id of the bot" do 33 | expect(bot_instance.bot_user_id).to eq bot_user_id 34 | end 35 | 36 | specify "#team returns the team name the bot is connected to" do 37 | expect(bot_instance.team_name).to eq 'team name' 38 | end 39 | 40 | specify "#team_id returns the id of the team the bot is connected to" do 41 | expect(bot_instance.team_id).to eq 'T123456' 42 | end 43 | 44 | it "raises an exception if the token was rejected by slack" do 45 | allow(slack_rtm_api).to receive(:start_async).and_raise(Slack::Web::Api::Error.new('error', 'error-response')) 46 | expect { bot_instance }.to raise_error(SlackBotServer::Bot::ConnectionError) 47 | end 48 | 49 | it "allows setting the default username" do 50 | bot = bot_instance do 51 | username 'TestBot' 52 | end 53 | expect(slack_web_api).to receive(:chat_postMessage).with(hash_including(username: 'TestBot')) 54 | 55 | bot.say text: 'hello', channel: '#general' 56 | end 57 | 58 | it "allows setting the default icon url" do 59 | bot = bot_instance do 60 | icon_url 'http://example.com/icon.png' 61 | end 62 | expect(slack_web_api).to receive(:chat_postMessage).with(hash_including(icon_url: 'http://example.com/icon.png')) 63 | 64 | bot.say text: 'hello', channel: '#general' 65 | end 66 | 67 | it 'invokes :start callbacks after connecting' do 68 | check_instance = double('check') 69 | expect(check_instance).to receive(:call) 70 | 71 | bot_instance do 72 | on :start do 73 | check_instance.call 74 | end 75 | end 76 | end 77 | 78 | it 'stops rtm client when stopping' do 79 | bot = bot_instance 80 | expect(slack_rtm_api).to receive(:stop!) 81 | bot.stop 82 | end 83 | 84 | it 'defaults the key to equal the token' do 85 | token = 'slack-api-token' 86 | bot_class = Class.new(described_class) 87 | bot = bot_class.new(token: token) 88 | expect(bot.key).to eq token 89 | end 90 | 91 | context 'sending messages' do 92 | let(:bot) { bot_instance } 93 | 94 | it "can broadcast messages to all channels" do 95 | expect(slack_web_api).to receive(:chat_postMessage).with(hash_including(channel: 'ABC123')) 96 | 97 | bot.broadcast text: 'hello', username: 'Bot' 98 | end 99 | 100 | it "can send a message to a specific channel" do 101 | expect(slack_web_api).to receive(:chat_postMessage).with(hash_including(channel: '#general')) 102 | 103 | bot.say channel: '#general', text: 'hello', username: 'Bot' 104 | end 105 | 106 | it "can send messages as DMs to a specific user" do 107 | reply = double('im_open_reply', channel: double('channel message', id: 'D123')) 108 | expect(slack_web_api).to receive(:im_open).with(hash_including(user: bot_user_id)).and_return(reply) 109 | expect(slack_web_api).to receive(:chat_postMessage).with(hash_including(channel: 'D123', text: 'hello')) 110 | 111 | bot.say_to(bot_user_id, text: 'hello', username: 'Bot') 112 | end 113 | end 114 | 115 | context 'handling pingbacks' do 116 | let(:bot) { bot_instance } 117 | let(:dummy_message) { OpenStruct.new(channel: '#general', message: OpenStruct.new(ts: '12345.678')) } 118 | 119 | before do 120 | allow(slack_web_api).to receive(:chat_postMessage).and_return(dummy_message) 121 | end 122 | 123 | it "does nothing if no pingback option was given" do 124 | expect(bot).not_to receive(:handle_pingback) 125 | 126 | bot.say(text: 'hello', channel: '#general') 127 | end 128 | 129 | it "calls store_pingback with the channel, ts and target parameters if a pingback was given" do 130 | expect(bot).to receive(:handle_pingback).with(channel: '#general', ts: '12345.678', target: 'target-identifier') 131 | 132 | bot.say(text: 'hello', channel: '#general', pingback: 'target-identifier') 133 | end 134 | end 135 | 136 | context "RTM messaging" do 137 | let(:bot) { bot_instance } 138 | 139 | before do 140 | allow(slack_web_api).to receive(:chat_postMessage) 141 | expect(slack_rtm_api).not_to receive(:send) 142 | end 143 | 144 | it "won't send via RTM if the message contains attachments" do 145 | bot.say channel: 'C123456', text: 'hello', attachments: 'attachment-data' 146 | end 147 | 148 | it "won't send via RTM if the message has a username" do 149 | bot.say channel: 'C123456', text: 'hello', username: 'Dave' 150 | end 151 | 152 | it "won't send via RTM if the message has an icon URL" do 153 | bot.say channel: 'C123456', text: 'hello', icon_url: 'http://icon.example.com' 154 | end 155 | 156 | it "won't send via RTM if the message has an icon emoji" do 157 | bot.say channel: 'C123456', text: 'hello', icon_emoji: ':+1:' 158 | end 159 | 160 | it "won't send via RTM if the channel isn't a channel ID" do 161 | bot.say channel: '#general', text: 'hello' 162 | end 163 | end 164 | 165 | describe "#typing" do 166 | let(:channel_id) { 'im123' } 167 | let(:im_list) { [{'id' => channel_id, 'is_im' => true}] } 168 | 169 | it "send a typing message via RTM" do 170 | expect(slack_rtm_api).to receive(:typing).with(include(channel: 'C123')) 171 | bot_instance.typing(channel: 'C123', id: '123') 172 | end 173 | 174 | it "sends a typing even to the last received slack channel by default" do 175 | bot_instance do 176 | on_im do |message| 177 | typing 178 | end 179 | end 180 | 181 | expect(slack_rtm_api).to receive(:typing).with(include(channel: channel_id)) 182 | send_message('channel' => channel_id, 'text' => 'hi') 183 | end 184 | end 185 | 186 | describe 'handling events from Slack' do 187 | let(:check) { double('check') } 188 | 189 | it 'invokes the message handling block with the event data' do 190 | check_instance = check 191 | bot_instance do 192 | on :message do |message| 193 | check_instance.call(message) 194 | end 195 | end 196 | 197 | expect(check).to receive(:call).with(hash_including('text' => 'message!')) 198 | send_message('text' => 'message!') 199 | end 200 | 201 | it 'invokes multiple handling blocks if given' do 202 | check_1 = double('check 1') 203 | check_2 = double('check 2') 204 | bot_instance do 205 | on :message do |message| 206 | check_1.call(message) 207 | end 208 | on :message do |message| 209 | check_2.call(message) 210 | end 211 | end 212 | 213 | expect(check_1).to receive(:call).with(hash_including('text' => 'message!')) 214 | expect(check_2).to receive(:call).with(hash_including('text' => 'message!')) 215 | send_message('text' => 'message!') 216 | end 217 | 218 | it 'does not invoke later callbacks if earlier ones return false' do 219 | check_1 = double('check 1') 220 | check_2 = double('check 2') 221 | bot_instance do 222 | on :message do |message| 223 | check_1.call(message) 224 | false 225 | end 226 | on :message do |message| 227 | check_2.call(message) 228 | end 229 | end 230 | 231 | expect(check_1).to receive(:call).with(hash_including('text' => 'message!')) 232 | expect(check_2).not_to receive(:call) 233 | send_message('text' => 'message!') 234 | end 235 | 236 | describe "on_mention" do 237 | before do 238 | instance_check = check 239 | bot_instance do 240 | on_mention do |message| 241 | instance_check.call(message) 242 | end 243 | end 244 | end 245 | 246 | describe 'when name is mentioned' do 247 | it 'invokes on_mention blocks when username is mentioned' do 248 | expect(check).to receive(:call).with(hash_including('text' => 'test_bot is great')) 249 | send_message('text' => 'test_bot is great') 250 | end 251 | 252 | it 'extracts message without username into message parameter' do 253 | expect(check).to receive(:call).with(hash_including('message' => 'is great')) 254 | send_message('text' => 'test_bot is great') 255 | end 256 | 257 | it 'matches name in other cases' do 258 | expect(check).to receive(:call).with(hash_including('message' => 'is great')) 259 | send_message('text' => 'Test_BOT is great') 260 | end 261 | end 262 | 263 | describe 'when name is used in @mention' do 264 | it 'invokes on_mention blocks when test_bot is mentioned' do 265 | expect(check).to receive(:call).with(hash_including('text' => '<@U123456> is great')) 266 | send_message('text' => '<@U123456> is great') 267 | end 268 | 269 | it 'extracts message without username into message parameter' do 270 | expect(check).to receive(:call).with(hash_including('message' => 'is great')) 271 | send_message('text' => '<@U123456> is great') 272 | end 273 | end 274 | 275 | describe 'when name is not at the start of the message' do 276 | it 'invokes on_mention blocks when username is mentioned' do 277 | expect(check).not_to receive(:call) 278 | send_message('text' => 'I hate test_bot') 279 | end 280 | end 281 | 282 | it 'ignores mentions from other bots' do 283 | expect(check).not_to receive(:call) 284 | send_message('text' => 'test_bot is worse than me', 'subtype' => 'bot_message') 285 | end 286 | 287 | it 'sends replies back to the same channel' do 288 | allow(check).to receive(:call) 289 | bot_instance do 290 | on_mention do |message| 291 | reply text: 'hello' 292 | end 293 | end 294 | 295 | expect(slack_web_api).to receive(:chat_postMessage).with(hash_including(text: 'hello', channel: '#channel')) 296 | send_message('channel' => '#channel', 'text' => 'test_bot hey') 297 | end 298 | 299 | context 'when mention keywords have been specified' do 300 | it 'matches each word' do 301 | allow(check).to receive(:call) 302 | instance_check = check 303 | bot_instance do 304 | mention_as 'hey', 'dude', 'yo bot' 305 | on_mention do |message| 306 | instance_check.call(message) 307 | end 308 | end 309 | 310 | expect(instance_check).to receive(:call).with(hash_including('message' => 'you')) 311 | send_message('text' => 'hey you') 312 | 313 | expect(check).to receive(:call).with(hash_including('message' => 'what?')) 314 | send_message('text' => 'Dude what?') 315 | 316 | expect(check).to receive(:call).with(hash_including('message' => 'lets code')) 317 | send_message('text' => 'YO BOT lets code') 318 | end 319 | end 320 | end 321 | 322 | describe "on_im" do 323 | let(:channel_id) { 'im123' } 324 | let(:im_list) { [{'id' => channel_id, 'is_im' => true}] } 325 | 326 | before do 327 | instance_check = check 328 | allow(check).to receive(:call) 329 | bot_instance do 330 | on_im do |message| 331 | instance_check.call(message) 332 | end 333 | end 334 | end 335 | 336 | it 'invokes on_im block even without username mention' do 337 | expect(check).to receive(:call) 338 | send_message('channel' => channel_id, 'text' => 'hey you') 339 | end 340 | 341 | it 'does not invoke the block if the message is from a bot' do 342 | expect(check).not_to receive(:call) 343 | send_message('channel' => channel_id, 'text' => 'hey you', 'subtype' => 'bot_message') 344 | end 345 | 346 | it 'does not invoke the block if the message is an expansion of a message from a bot' do 347 | expect(check).not_to receive(:call) 348 | send_message('channel' => channel_id, 'subtype' => 'message_changed', 'previous_message' => {'user' => bot_user_id}) 349 | end 350 | 351 | it 'does not invoke the block if the message is from SlackBot' do 352 | expect(check).not_to receive(:call) 353 | send_message('channel' => channel_id, 'user' => SlackBotServer::Bot::SLACKBOT_USER_ID) 354 | end 355 | 356 | it 'does not invoke block for messages to non-IM channels bot is in' do 357 | expect(check).not_to receive(:call) 358 | send_message('channel' => 'other123', 'text' => 'hey you') 359 | end 360 | 361 | it 'recognises new IM channels created by users' do 362 | send_message('type' => 'im_created', 'channel' => {'id' => 'other123', 'is_im' => true}) 363 | 364 | expect(check).to receive(:call) 365 | send_message('channel' => 'other123', 'text' => 'we need to talk') 366 | end 367 | 368 | it 'sends replies back to the same channel' do 369 | allow(check).to receive(:call) 370 | bot_instance do 371 | on_im do |message| 372 | reply text: 'hello', username: 'Dave' 373 | end 374 | end 375 | 376 | expect(slack_web_api).to receive(:chat_postMessage).with(hash_including(text: 'hello', channel: channel_id)) 377 | send_message('channel' => channel_id, 'text' => 'hi') 378 | end 379 | end 380 | 381 | describe 'on_file' do 382 | let(:channel_id) { 'im123' } 383 | let(:im_list) { [{'id' => channel_id, 'is_im' => true}] } 384 | 385 | before do 386 | instance_check = check 387 | allow(check).to receive(:call) 388 | bot_instance do 389 | on_file do |message| 390 | instance_check.call(message) 391 | end 392 | end 393 | end 394 | 395 | it 'invokes on_file block if the message is a file' do 396 | expect(check).to receive(:call) 397 | send_message('channel' => channel_id, 'subtype' => 'file_share') 398 | end 399 | 400 | it 'does not invoke the block if the message is from SlackBot' do 401 | expect(check).not_to receive(:call) 402 | send_message('channel' => channel_id, 'subtype' => 'file_share', 'user' => SlackBotServer::Bot::SLACKBOT_USER_ID) 403 | end 404 | 405 | it 'does not invoke block for messages to non-IM channels bot is in' do 406 | expect(check).not_to receive(:call) 407 | send_message('channel' => 'other123', 'subtype' => 'file_share') 408 | end 409 | end 410 | 411 | context 'on_slack_event' do 412 | it 'registers the callback with the underlying slack client' do 413 | instance_check = check 414 | allow(check).to receive(:call) 415 | bot_instance do 416 | on_slack_event :team_join do |data| 417 | instance_check.call(data) 418 | end 419 | end 420 | 421 | stub_websocket.trigger(:message, double('event', data: MultiJson.dump({'type' => 'team_join', 'data' => 'blah', 'user' => {'id' => bot_user_id}}))) 422 | end 423 | end 424 | end 425 | 426 | describe "update" do 427 | let(:bot) { bot_instance } 428 | 429 | it "update a message" do 430 | expect(slack_web_api).to receive(:chat_update).with(hash_including(ts: '1234.567', text: 'new text', channel: '#general')) 431 | 432 | bot.update ts: '1234.567', text: 'new text', channel: '#general' 433 | end 434 | 435 | end 436 | 437 | private 438 | 439 | def send_message(attributes) 440 | default_attributes = {'type' => 'message', 'text' => 'blah'} 441 | message = double('websocket message event', data: MultiJson.dump(default_attributes.merge(attributes))) 442 | stub_websocket.trigger(:message, message) 443 | end 444 | 445 | def bot_instance(token: 'token', key: 'key', &block) 446 | if @bot_instance 447 | @bot_class.class_eval(&block) 448 | else 449 | @fake_websocket = FakeWebsocket.new 450 | allow(Faye::WebSocket::Client).to receive(:new).and_return(@fake_websocket) 451 | 452 | @bot_class = Class.new(described_class, &block) 453 | @bot_instance = @bot_class.new(token: token, key: key) 454 | @bot_instance.start 455 | stub_websocket.trigger(:open, nil) 456 | end 457 | @bot_instance 458 | end 459 | 460 | class FakeWebsocket 461 | def initialize 462 | @callbacks = {} 463 | end 464 | 465 | def on(type, &block) 466 | @callbacks[type] = block 467 | end 468 | 469 | def trigger(type, event) 470 | @callbacks[type].call(event) 471 | end 472 | end 473 | 474 | def stub_websocket 475 | # @fake_websocket ||= FakeWebsocket.new 476 | # allow(Faye::WebSocket::Client).to receive(:new).and_return(@fake_websocket) 477 | @fake_websocket 478 | end 479 | end 480 | --------------------------------------------------------------------------------