diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..4e1e0d2 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--color diff --git a/Gemfile b/Gemfile index e45e65f..06618ce 100644 --- a/Gemfile +++ b/Gemfile @@ -1,2 +1,3 @@ -source :rubygems +source 'https://rubygems.org' gemspec + diff --git a/Guardfile b/Guardfile new file mode 100644 index 0000000..eb34976 --- /dev/null +++ b/Guardfile @@ -0,0 +1,9 @@ +# encoding: utf-8 + +group 'rspec' do + guard 'rspec', cmd: 'bundle exec rspec' do + watch(%r{^spec/.+_spec\.rb$}) + watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } + watch('spec/spec_helper.rb') { "spec/" } + end +end diff --git a/LICENSE b/LICENSE index 053116e..0f9aa4a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2011 The Adhearsion Foundation +Copyright (c) 2011 The Adhearsion Foundation, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.markdown b/README.markdown new file mode 100644 index 0000000..b76868f --- /dev/null +++ b/README.markdown @@ -0,0 +1,153 @@ +Electric Slide - Simple Call Distribution for Adhearsion +==================================================================== + +This library implements a simple FIFO (First-In, First-Out) call queue for Adhearsion. + +To ensure proper operation, a few things are assumed: + +* Agents will only be logged into a single queue at a time + If you have two types of agents (say "support" and "sales") then you should have two queues, each with their own pool of agents +* Agent authentication will happen before entering the queue - it is not the queue's concern +* The strategy for callers is FIFO: the caller who has been waiting the longest is the next to get an agent +* Queues will be implemented as a Celluloid Actor, which should protect the call selection strategies against race conditions +* There are two ways to connect an agent: + - If the Agent object provides an `address` attribute, and the queue's `connection_type` is set to `call`, then the queue will call the agent when a caller is waiting + - If the Agent object provides a `call` attribute, and the queue's `connection_type` is set to `bridge`, then the call queue will bridge the agent to the caller. In this mode, the agent hanging up will log him out of the queue + +TODO: +* Example for using Matrioska to offer Agents and Callers interactivity while waiting +* How to handle MOH + +## WARNING! + +While you can have ElectricSlide keep track of custom queues, it is recommended to use the built-in CallQueue object. + +The authors of ElectricSlide recommend NOT to subclass, monkeypatch, or otherwise alter the CallQueue implementation, as the likelihood of creating subtle race conditions is high. + +Example Queue +------------- + +```ruby +my_queue = ElectricSlide.create :my_queue, ElectricSlide::CallQueue + +# Another way to get a handle on a queue +ElectricSlide.create :my_queue +my_queue = ElectricSlide.get_queue :my_queue +``` + + +Example CallController for Queued Call +-------------------------------------- + +```ruby +class EnterTheQueue < Adhearsion::CallController + def run + answer + + # Play music-on-hold to the caller until joined to an agent + player = play 'http://moh-server.example.com/stream.mp3', repeat_times: 0 + call.on_joined do + player.stop! + end + + ElectricSlide.get_queue(:my_queue).enqueue call + + # The controller will exit, but the call will remain up + # The call will automatically hang up after speaking to an agent + call.auto_hangup = false + end +end +``` + + +Adding an Agent to the Queue +---------------------------- + +ElectricSlide expects to be given a objects that quack like an agent. You can use the built-in `ElectricSlide::Agent` class, or you can provide your own. + +To add an agent who will receive calls whenever a call is enqueued, do something like this: + +```ruby +agent = ElectricSlide::Agent.new id: 1, address: 'sip:agent1@example.com', presence: :available +ElectricSlide.get_queue(:my_queue).add_agent agent +``` + +To inform the queue that the agent is no longer available you *must* use the ElectricSlide queue interface. /Do not attempt to alter agent objects directly!/ + +```ruby +ElectricSlide.update_agent 1, presence: offline +``` + +If it is more convenient, you may also pass `#update_agent` an Agent-like object: + +```ruby +options = { + id: 1, + address: 'sip:agent1@example.com', + presence: offline +} +agent = ElectricSlide::Agent.new options +ElectricSlide.update_agent 1, agent +``` + +Switching connection types +-------------------------- + +ElectricSlide provides two methods for connecting callers to agents: +- `:call`: (default) If the Agent object provides an `address` attribute, and the queue's `connection_type` is set to `call`, then the queue will call the agent when a caller is waiting +- `:bridge`: If the Agent object provides a `call` attribute, and the queue's `connection_type` is set to `bridge`, then the call queue will bridge the agent to the caller. In this mode, the agent hanging up will log him out of the queue + +To select the connection type, specify it when creating the queue: + +```ruby +ElectricSlide.create_queue :my_queue, ElectricSlide::CallQueue, connection_type: :bridge +``` + +Selecting an Agent distribution strategy +---------------------------------------- + +Different use-cases have different requirements for selecting the next agent to take a call. ElectricSlide provides two strategies which may be used. You are also welcome to create your own distribution strategy by implementing the same interface as described in `ElectricSlide::AgentStrategy::LongestIdle`. + +To select an agent strategy, specify it when creating the queue: + +```ruby +ElectricSlide.create_queue :my_queue, ElectricSlide::CallQueue, agent_strategy: ElectricSlide::AgentStrategy::LongestIdle +``` + +Two strategies are provided out-of-the-box: + +* `ElectricSlide::AgentStrategy::LongestIdle` selects the agent that has been idle for the longest amount of time. +* `ElectricSlide::AgentStrategy::FixedPriority` selects the agent with the lowest numeric priority first. In the event that more than one agent is available at a given priority, then the agent that has been idle the longest at the lowest numeric priority is selected. + +Custom Agent Behavior +---------------------------- + +If you need custom functionality to occur whenever an Agent is selected to take a call, you can use the callbacks on the Agent object: + +* `on_connect` +* `on_disconnect` + +Confirmation Controllers +------------------------ + +In case you need to execute a confirmation controller on the call that is placed to the agent, such as "Press 1 to accept the call", you currently need to pass in the confirmation class name and the call object as metadata in the `call_options_for` callback in your `ElectricSlide::Agent` subclass. + +```ruby +# an example from the Agent subclass +def dial_options_for(queue, queued_call) + { + from: caller_digits(queued_call.from), + timeout: on_pstn? ? APP_CONFIG.agent_timeout * 3 : APP_CONFIG.agent_timeout, + confirm: MyConfirmationController, + confirm_metadata: {caller: queued_call, agent: self}, + } +end +``` + +You then need to handle the join in your confirmation controller, using for example: + +```ruby +call.join metadata[:caller] if confirm! +``` + +where `confirm!` is your logic for deciding if you want the call to be connected or not. Hanging up during the confirmation controller or letting it finish without any action will result in the call being sent to the next agent. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..18ce7fe --- /dev/null +++ b/Rakefile @@ -0,0 +1,15 @@ +#!/usr/bin/env ruby +# encoding: utf-8 +# -*- ruby -*- +ENV['RUBY_FLAGS'] = "-I#{%w(lib ext bin spec).join(File::PATH_SEPARATOR)}" + +require 'rubygems' +require 'bundler/gem_tasks' +require 'bundler/setup' + +require 'rspec/core/rake_task' +RSpec::Core::RakeTask.new + +task ci: ['ci:setup:rspec', :spec] +task default: :spec + diff --git a/ahn_acd.gemspec b/ahn_acd.gemspec deleted file mode 100644 index e275858..0000000 --- a/ahn_acd.gemspec +++ /dev/null @@ -1,32 +0,0 @@ -GEM_FILES = %w{ - ahn_acd.gemspec - lib/ahn_acd.rb - lib/ahn_acd/queue_strategy.rb - lib/ahn_acd/round_robin.rb - lib/ahn_acd/round_robin_meetme.rb - config/ahn_acd.yml -} - -Gem::Specification.new do |s| - s.name = "ahn_acd" - s.version = "0.0.1" - - s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= - s.authors = ["Ben Klang"] - - s.date = Date.today.to_s - s.description = "Automatic Call Distributor (ACD) for Adhearsion. Currently implements only Round Robin distribution strategies." - s.email = "dev&adhearsion.com" - - s.files = GEM_FILES - - s.has_rdoc = true - s.homepage = "http://github.com/adhearsion/ahn_acd" - s.require_paths = ["lib"] - s.rubygems_version = "1.2.0" - s.summary = "Automatic Call Distributor for Adhearsion" - - s.add_runtime_dependency 'adhearsion', ['~> 1.2.0'] - - s.specification_version = 2 -end diff --git a/electric_slide.gemspec b/electric_slide.gemspec new file mode 100644 index 0000000..fcf5c86 --- /dev/null +++ b/electric_slide.gemspec @@ -0,0 +1,36 @@ +# encoding: utf-8 +$:.push File.expand_path("../lib", __FILE__) +require 'electric_slide/version' +require 'date' + +Gem::Specification.new do |s| + s.name = "electric_slide" + s.version = ElectricSlide::VERSION + + s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= + s.authors = ["Ben Klang"] + + s.date = Date.today.to_s + s.description = "Automatic Call Distributor (ACD) for Adhearsion. Currently implements only Round Robin distribution strategies." + s.email = "dev&adhearsion.com" + + s.files = `git ls-files`.split("\n") + + s.has_rdoc = true + s.homepage = "http://github.com/adhearsion/electric_slide" + s.require_paths = ["lib"] + s.rubygems_version = "1.2.0" + s.summary = "Automatic Call Distributor for Adhearsion" + + s.add_runtime_dependency 'adhearsion' + s.add_runtime_dependency 'countdownlatch' + s.add_runtime_dependency 'activesupport' + s.add_development_dependency 'rspec', ['>= 2.5.0'] + s.add_development_dependency 'ci_reporter' + s.add_development_dependency 'guard' + s.add_development_dependency 'guard-rspec' + s.add_development_dependency 'simplecov' + s.add_development_dependency 'simplecov-rcov' + + s.specification_version = 2 +end diff --git a/lib/ahn_acd.rb b/lib/ahn_acd.rb deleted file mode 100644 index 3fa396d..0000000 --- a/lib/ahn_acd.rb +++ /dev/null @@ -1,83 +0,0 @@ -#methods_for :dialplan do -#end -# -#initialization do -# COMPONENTS.ahn_acd[:queues].each do |q| -# AhnQueue.create q[:name], q[:queue_type], q[:agent_type] -# end -#end - -class AhnQueue - include Singleton - - def initialize - @queues = {} - end - - def create(name, queue_type, agent_type = Agent) - synchronize do - @queues[name] = const_get(queue_type).new unless @queues.has_key?(name) - @queues[name].extend agent_type - end - end - - def get_queue(name) - synchronize do - @queues[name] - end - end - - def self.method_missing(method, *args, &block) - instance.send method, *args, &block - end - - class QueuedCall - attr_accessor :call, :queued_time - - def initialize(call) - @call = call - @queued_time = Time.now - end - - def hold - call.execute 'StartMusicOnHold' - @latch = CountDownLatch.new 1 - @latch.wait - call.execute 'StopMusicOnHold' - end - - def make_ready! - @latch.countdown! - end - end - - class Agent - def work(agent_call) - loop do - agent_call.execute 'Bridge', @queue.next_call - end - end - end - - class CalloutAgent - def work(agent_channel) - @queue.next_call.each do |next_call| - next_call.dial agent_channel - end - end - end - - class MeetMeAgent - include Agent - - def work(agent_call) - loop do - agent_call.join agent_conf, @queue.next_call - end - end - end - - class BridgeAgent - include Agent - end -end diff --git a/lib/ahn_acd/queue_strategy.rb b/lib/ahn_acd/queue_strategy.rb deleted file mode 100644 index d466fa8..0000000 --- a/lib/ahn_acd/queue_strategy.rb +++ /dev/null @@ -1,17 +0,0 @@ -require 'countdownlatch' - -class AhnAcd - module QueueStrategy - def wrap_call(call) - QueuedCall.new(call) unless call.respond_to?(:queued_time) - end - - def priority_enqueue(call) - enqueue call - end - - def enqueue(call) - call.hold - end - end -end diff --git a/lib/ahn_acd/round_robin.rb b/lib/ahn_acd/round_robin.rb deleted file mode 100644 index 006d781..0000000 --- a/lib/ahn_acd/round_robin.rb +++ /dev/null @@ -1,35 +0,0 @@ -require 'thread' - -class AhnAcd - class RoundRobin - include QueueStrategy - - def initialize - @queue = [] - @conditional = ConditionVariable.new - end - - def next_call - call = nil - synchronize do - @conditional.wait(@mutex) if @queue.length == 0 - call = @queue.pop - end - - call.make_ready! - call - end - - # TODO: Add mechanism to add calls with higher priority to the front of the queue. - - def enqueue(call) - call = wrap_call(call) - synchronize do - @queue << call - @conditional.signal if @queue.length == 1 - end - super - end - end -end - diff --git a/lib/ahn_acd/round_robin_meetme.rb b/lib/ahn_acd/round_robin_meetme.rb deleted file mode 100644 index 4436686..0000000 --- a/lib/ahn_acd/round_robin_meetme.rb +++ /dev/null @@ -1,35 +0,0 @@ -class AhnAcd - class RoundRobinMeetme - include QueueStrategy - - def initialize(call) - @queue = [] - end - - def next_call - call = synchronize do - @queue.pop - end - - call.make_ready! - call - end - - def priority_enqueue(call) - call = wrap_call(call) - - synchronize do - @queue.unshift call - end - super - end - - def enqueue(call) - synchronize do - @queue << call - end - end - end -end - - diff --git a/lib/electric_slide.rb b/lib/electric_slide.rb new file mode 100644 index 0000000..766985c --- /dev/null +++ b/lib/electric_slide.rb @@ -0,0 +1,68 @@ +# encoding: utf-8 +require 'celluloid' +require 'singleton' + +require 'adhearsion/version' + +if Gem::Version.new(Adhearsion::VERSION) < Gem::Version.new('3.0.0') + # Backport https://github.com/adhearsion/adhearsion/commit/8c6855612c70dd822fb4e4c2006d1fdc9d05fe23 to avoid confusion around dead calls + require 'adhearsion/call' + class Adhearsion::Call::ActorProxy < Celluloid::ActorProxy + def active? + alive? && super + rescue Adhearsion::Call::ExpiredError + false + end + end +end + +%w( + call_queue + plugin +).each { |f| require "electric_slide/#{f}" } + +class ElectricSlide + include Singleton + + def initialize + @mutex = Mutex.new + @queues = {} + end + + def create(name, queue_class = nil, *args) + fail "Queue with name #{name} already exists!" if @queues.key? name + + queue_class ||= CallQueue + @queues[name] = queue_class.work *args + # Return the queue instance or current actor + get_queue name + end + + def get_queue!(name) + fail "Queue #{name} not found!" unless @queues.key?(name) + get_queue name + end + + def get_queue(name) + queue = @queues[name] + if queue.respond_to? :actors + # In case we have a Celluloid supervision group, get the current actor + queue.actors.first + else + queue + end + end + + def shutdown_queue(name) + queue = get_queue name + queue.terminate + @queues.delete name + end + + def self.method_missing(method, *args, &block) + @@mutex ||= Mutex.new + @@mutex.synchronize do + instance.send method, *args, &block + end + end +end diff --git a/lib/electric_slide/agent.rb b/lib/electric_slide/agent.rb new file mode 100644 index 0000000..5e65c1e --- /dev/null +++ b/lib/electric_slide/agent.rb @@ -0,0 +1,48 @@ +# encoding: utf-8 +class ElectricSlide::Agent + attr_accessor :id, :address, :presence, :call, :connect_callback, :disconnect_callback + + # @param [Hash] opts Agent parameters + # @option opts [String] :id The Agent's ID + # @option opts [String] :address The Agent's contact address + # @option opts [Symbol] :presence The Agent's current presence. Must be one of :available, :on_call, :away, :offline + def initialize(opts = {}) + @id = opts[:id] + @address = opts[:address] + @presence = opts[:presence] + end + + def callback(type, *args) + callback = self.class.instance_variable_get "@#{type}_callback" + instance_exec *args, &callback if callback && callback.respond_to?(:call) + end + + + # Provide a block to be called when this agent is connected to a caller + # The block will be passed the queue, the agent call and the client call + def self.on_connect(&block) + @connect_callback = block + end + + # Provide a block to be called when this agent is disconnected to a caller + # The block will be passed the queue, the agent call and the client call + def self.on_disconnect(&block) + @disconnect_callback = block + end + + # Called to provide options for calling this agent that are passed to #dial + def dial_options_for(queue, queued_call) + {} + end + + def join(queued_call) + # For use in queues that need bridge connections + @call.join queued_call + end + + # FIXME: Use delegator? + def from + @call.from + end +end + diff --git a/lib/electric_slide/agent_strategy/fixed_priority.rb b/lib/electric_slide/agent_strategy/fixed_priority.rb new file mode 100644 index 0000000..8987294 --- /dev/null +++ b/lib/electric_slide/agent_strategy/fixed_priority.rb @@ -0,0 +1,55 @@ +# encoding: utf-8 + +class ElectricSlide + class AgentStrategy + class FixedPriority + def initialize + @priorities = {} + end + + def agent_available? + !!@priorities.detect do |priority, agents| + agents.present? + end + end + + # Returns information about the number of available agents + # The data returned depends on the AgentStrategy in use. + # @return [Hash] Summary information about agents available, depending on strategy + # :total: The total number of available agents + # :priorities: A Hash containing the number of available agents at each priority + def available_agent_summary + @priorities.inject({}) do |summary, data| + priority, agents = *data + summary[:total] ||= 0 + summary[:total] += agents.count + summary[:priorities] ||= {} + summary[:priorities][priority] = agents.count + summary + end + end + + def checkout_agent + _, agents = @priorities.detect do |priority, agents| + agents.present? + end + agents.shift + end + + def <<(agent) + # TODO: How aggressively do we check for agents duplicated in multiple priorities? + raise ArgumentError, "Agents must have a specified priority" unless agent.respond_to?(:priority) + priority = agent.priority || 999999 + @priorities[priority] ||= [] + @priorities[priority] << agent unless @priorities[priority].include? agent + end + + def delete(agent) + @priorities.detect do |priority, agents| + agents.delete(agent) + end + end + end + end +end + diff --git a/lib/electric_slide/agent_strategy/longest_idle.rb b/lib/electric_slide/agent_strategy/longest_idle.rb new file mode 100644 index 0000000..23a9a19 --- /dev/null +++ b/lib/electric_slide/agent_strategy/longest_idle.rb @@ -0,0 +1,39 @@ +# encoding: utf-8 + +class ElectricSlide + class AgentStrategy + class LongestIdle + def initialize + @free_agents = [] # Needed to keep track of waiting order + end + + # Checks whether an agent is available to take a call + # @return [Boolean] True if an agent is available + def agent_available? + @free_agents.count > 0 + end + + # Returns a count of the number of available agents + # @return [Hash] Hash of information about available agents + # This strategy only returns the count of agents available with :total + def available_agent_summary + { total: @free_agents.count } + end + + # Assigns the first available agent, marking the agent :busy + # @return {Agent} + def checkout_agent + @free_agents.shift + end + + def <<(agent) + @free_agents << agent unless @free_agents.include?(agent) + end + + def delete(agent) + @free_agents.delete(agent) + end + end + end +end + diff --git a/lib/electric_slide/call_queue.rb b/lib/electric_slide/call_queue.rb new file mode 100644 index 0000000..194d55e --- /dev/null +++ b/lib/electric_slide/call_queue.rb @@ -0,0 +1,335 @@ +# encoding: utf-8 + +# The default agent strategy +require 'electric_slide/agent_strategy/longest_idle' + +class ElectricSlide + class CallQueue + include Celluloid + ENDED_CALL_EXCEPTIONS = [ + Adhearsion::Call::Hangup, + Adhearsion::Call::ExpiredError, + Adhearsion::Call::CommandTimeout, + Celluloid::DeadActorError, + Punchblock::ProtocolError + ] + + CONNECTION_TYPES = [ + :call, + :bridge, + ].freeze + + AGENT_RETURN_METHODS = [ + :auto, + :manual, + ].freeze + + def self.work(*args) + self.supervise *args + end + + def initialize(opts = {}) + agent_strategy = opts[:agent_strategy] || AgentStrategy::LongestIdle + @connection_type = opts[:connection_type] || :call + @agent_return_method = opts[:agent_return_method] || :auto + + raise ArgumentError, "Invalid connection type; must be one of #{CONNECTION_TYPES.join ','}" unless CONNECTION_TYPES.include? @connection_type + raise ArgumentError, "Invalid requeue method; must be one of #{AGENT_RETURN_METHODS.join ','}" unless AGENT_RETURN_METHODS.include? @agent_return_method + + @free_agents = [] # Needed to keep track of waiting order + @agents = [] # Needed to keep track of global list of agents + @queue = [] # Calls waiting for an agent + + @strategy = agent_strategy.new + end + + # Checks whether an agent is available to take a call + # @return [Boolean] True if an agent is available + def agent_available? + @strategy.agent_available? + end + + # Returns information about the number of available agents + # The data returned depends on the AgentStrategy in use. + # The data will always include a :total count of the agents available + # @return [Hash] Summary information about agents available, depending on strategy + def available_agent_summary + # TODO: Make this a delegator? + @strategy.available_agent_summary + end + + # Assigns the first available agent, marking the agent :busy + # @return {Agent} + def checkout_agent + agent = @strategy.checkout_agent + agent.presence = :busy + agent + end + + # Returns a copy of the set of agents that are known to the queue + # @return [Array] Array of {Agent} objects + def get_agents + @agents.dup + end + + # Returns a copy of the set of calls waiting to be answered that are known to the queue + # @return [Array] Array of Adhearsion::Call objects + def get_queued_calls + @queue.dup + end + + # Finds an agent known to the queue by that agent's ID + # @param [String] id The ID of the agent to locate + # @return [Agent, Nil] {Agent} object if found, Nil otherwise + def get_agent(id) + @agents.detect { |agent| agent.id == id } + end + + # Registers an agent to the queue + # @param [Agent] agent The agent to be added to the queue + def add_agent(agent) + abort ArgumentError.new("#add_agent called with nil object") if agent.nil? + case @connection_type + when :call + abort ArgumentError.new("Agent has no callable address") unless agent.address + when :bridge + abort ArgumentError.new("Agent has no active call") unless agent.call && agent.call.active? + unless agent.call[:electric_slide_callback_set] + agent.call.on_end { remove_agent agent } + agent.call[:electric_slide_callback_set] = true + end + end + + logger.info "Adding agent #{agent} to the queue" + @agents << agent unless @agents.include? agent + @strategy << agent if agent.presence == :available + check_for_connections + end + + # Marks an agent as available to take a call. To be called after an agent completes a call + # and is ready to take the next call. + # @param [Agent] agent The {Agent} that is being returned to the queue + # @param [Symbol] status The {Agent}'s new status + # @param [String, Optional] address The {Agent}'s address. Only specified if it has changed + def return_agent(agent, status = :available, address = nil) + logger.debug "Returning #{agent} to the queue" + agent.presence = status + agent.address = address if address + + if agent.presence == :available + @strategy << agent + check_for_connections + end + agent + end + + # Removes an agent from the queue entirely + # @param [Agent] agent The {Agent} to be removed from the queue + # @return [Agent, Nil] The Agent object if removed, Nil otherwise + def remove_agent(agent) + @strategy.delete agent + @agents.delete agent + logger.info "Removing agent #{agent} from the queue" + rescue Adhearsion::Call::ExpiredError + end + + # Checks to see if any callers are waiting for an agent and attempts to connect them to + # an available agent + def check_for_connections + connect checkout_agent, get_next_caller while call_waiting? && agent_available? + end + + # Add a call to the head of the queue. Among other reasons, this is used when a caller is sent + # to an agent, but the connection fails because the agent is not available. + # @param [Adhearsion::Call] call Caller to be added to the queue + def priority_enqueue(call) + # Don't reset the enqueue time in case this is a re-insert on agent failure + call[:enqueue_time] ||= Time.now + @queue.unshift call + + check_for_connections + end + + # Add a call to the end of the queue, the normal FIFO queue behavior + # @param [Adhearsion::Call] call Caller to be added to the queue + def enqueue(call) + ignoring_ended_calls do + logger.info "Adding call from #{remote_party call} to the queue" + call[:enqueue_time] = Time.now + @queue << call unless @queue.include? call + + check_for_connections + end + end + + # Remove a waiting call from the queue. Used if the caller hangs up or is otherwise removed. + # @param [Adhearsion::Call] call Caller to be removed from the queue + def abandon(call) + ignoring_ended_calls { logger.info "Caller #{remote_party call} has abandoned the queue" } + @queue.delete call + end + + # Connect an {Agent} to a caller + # @param [Agent] agent Agent to be connected + # @param [Adhearsion::Call] call Caller to be connected + def connect(agent, queued_call) + unless queued_call.active? + logger.warn "Inactive queued call found in #connect" + return_agent agent + end + + logger.info "Connecting #{agent} with #{remote_party queued_call}" + case @connection_type + when :call + call_agent agent, queued_call + when :bridge + unless agent.call.active? + logger.warn "Inactive agent call found in #connect, returning caller to queue" + priority_enqueue queued_call + end + bridge_agent agent, queued_call + end + rescue *ENDED_CALL_EXCEPTIONS + ignoring_ended_calls do + if queued_call.active? + logger.warn "Dead call exception in #connect but queued_call still alive, reinserting into queue" + priority_enqueue queued_call + end + end + ignoring_ended_calls do + if agent.call && agent.call.active? + logger.warn "Dead call exception in #connect but agent call still alive, reinserting into queue" + return_agent agent + end + end + end + + def conditionally_return_agent(agent, return_method = @agent_return_method) + raise ArgumentError, "Invalid requeue method; must be one of #{AGENT_RETURN_METHODS.join ','}" unless AGENT_RETURN_METHODS.include? return_method + + if agent && @agents.include?(agent) && agent.presence == :busy && return_method == :auto + logger.info "Returning agent #{agent.id} to queue" + return_agent agent + else + logger.debug "Not returning agent #{agent.inspect} to the queue" + end + end + + # Returns the next waiting caller + # @return [Adhearsion::Call] The next waiting caller + def get_next_caller + @queue.shift + end + + # Checks whether any callers are waiting + # @return [Boolean] True if a caller is waiting + def call_waiting? + @queue.length > 0 + end + + # Returns the number of callers waiting in the queue + # @return [Fixnum] + def calls_waiting + @queue.length + end + + private + # Get the caller ID of the remote party. + # If this is an OutboundCall, use Call#to + # Otherwise, use Call#from + def remote_party(call) + call.is_a?(Adhearsion::OutboundCall) ? call.to : call.from + end + + + # @private + def ignoring_ended_calls + yield + rescue *ENDED_CALL_EXCEPTIONS + # This actor may previously have been shut down due to the call ending + end + + def call_agent(agent, queued_call) + agent_call = Adhearsion::OutboundCall.new + agent_call[:agent] = agent + agent_call[:queued_call] = queued_call + + # Stash the caller ID so we don't have to try to get it from a dead call object later + queued_caller_id = remote_party queued_call + + # The call controller is actually run by #dial, here we skip joining if we do not have one + dial_options = agent.dial_options_for(self, queued_call) + unless dial_options[:confirm] + agent_call.on_answer { ignoring_ended_calls { agent_call.join queued_call.uri if queued_call.active? } } + end + + # Disconnect agent if caller hangs up before agent answers + queued_call.on_end { ignoring_ended_calls { agent_call.hangup } } + + agent_call.on_unjoined do + ignoring_ended_calls { agent_call.hangup } + ignoring_ended_calls { queued_call.hangup } + end + + # Track whether the agent actually talks to the queued_call + connected = false + queued_call.on_joined { connected = true } + + agent_call.on_end do |end_event| + # Ensure we don't return an agent that was removed or paused + conditionally_return_agent agent + + agent.callback :disconnect, self, agent_call, queued_call + + unless connected + if queued_call.alive? && queued_call.active? + ignoring_ended_calls { priority_enqueue queued_call } + logger.warn "Call did not connect to agent! Agent #{agent.id} call ended with #{end_event.reason}; reinserting caller #{queued_caller_id} into queue" + else + logger.warn "Caller #{queued_caller_id} hung up before being connected to an agent." + end + end + end + + agent.callback :connect, self, agent_call, queued_call + + agent_call.execute_controller_or_router_on_answer dial_options.delete(:confirm), dial_options.delete(:confirm_metadata) + + agent_call.dial agent.address, dial_options + end + + def bridge_agent(agent, queued_call) + # Stash caller ID to make log messages work even if calls end + queued_caller_id = remote_party queued_call + agent.call[:queued_call] = queued_call + + agent.call.register_tmp_handler :event, Punchblock::Event::Unjoined do + agent.callback :disconnect, self, agent.call, queued_call + ignoring_ended_calls { queued_call.hangup } + ignoring_ended_calls { conditionally_return_agent agent if agent.call.active? } + agent.call[:queued_call] = nil + end + + agent.callback :connect, self, agent.call, queued_call + + agent.join queued_call if queued_call.active? + rescue *ENDED_CALL_EXCEPTIONS + ignoring_ended_calls do + if agent.call.active? + logger.info "Caller #{queued_caller_id} failed to connect to Agent #{agent.id} due to caller hangup" + conditionally_return_agent agent, :auto + else + # Agent's call has ended, so remove him from the queue + remove_agent agent + end + end + + ignoring_ended_calls do + if queued_call.active? + priority_enqueue queued_call + logger.warn "Call failed to connect to Agent #{agent.id} due to agent hangup; reinserting caller #{queued_caller_id} into queue" + end + end + end + end +end diff --git a/lib/electric_slide/plugin.rb b/lib/electric_slide/plugin.rb new file mode 100644 index 0000000..fedaa79 --- /dev/null +++ b/lib/electric_slide/plugin.rb @@ -0,0 +1,10 @@ +# encoding: utf-8 +require 'adhearsion' + +class ElectricSlide + class Plugin < Adhearsion::Plugin + init do + logger.info 'ElectricSlide plugin loaded.' + end + end +end diff --git a/lib/electric_slide/version.rb b/lib/electric_slide/version.rb new file mode 100644 index 0000000..1427afd --- /dev/null +++ b/lib/electric_slide/version.rb @@ -0,0 +1,4 @@ +# encoding: utf-8 +class ElectricSlide + VERSION = '0.2.0' +end diff --git a/spec/ahn_acd_spec.rb b/spec/ahn_acd_spec.rb deleted file mode 100644 index a92c4eb..0000000 --- a/spec/ahn_acd_spec.rb +++ /dev/null @@ -1,8 +0,0 @@ -require 'rubygems' -require 'bundler' -Bundler.setup -Bundler.require - -require 'adhearsion/component_manager/spec_framework' - -component_name.upcase = ComponentTester.new("ahn_acd", File.dirname(__FILE__) + "/../..") diff --git a/spec/electric_slide/agent_spec.rb b/spec/electric_slide/agent_spec.rb new file mode 100644 index 0000000..07d8313 --- /dev/null +++ b/spec/electric_slide/agent_spec.rb @@ -0,0 +1,23 @@ +# encoding: utf-8 +require 'spec_helper' +require 'electric_slide/agent' + +describe ElectricSlide::Agent do + let(:options) { { id: 1, address: '123@foo.com', presence: :available} } + + class MyAgent < ElectricSlide::Agent + on_connect do + foo + end + + def foo + :bar + end + end + + subject {MyAgent.new options} + + it 'executes a connect callback' do + expect(subject.callback(:connect)).to eql :bar + end +end diff --git a/spec/electric_slide/agent_strategy/fixed_priority_spec.rb b/spec/electric_slide/agent_strategy/fixed_priority_spec.rb new file mode 100644 index 0000000..2d40c56 --- /dev/null +++ b/spec/electric_slide/agent_strategy/fixed_priority_spec.rb @@ -0,0 +1,43 @@ +# encoding: utf-8 + +require 'spec_helper' +require 'electric_slide/agent_strategy/fixed_priority' +require 'ostruct' + +describe ElectricSlide::AgentStrategy::FixedPriority do + let(:subject) { ElectricSlide::AgentStrategy::FixedPriority.new } + it 'should allow adding an agent with a specified priority' do + subject.agent_available?.should be false + subject << OpenStruct.new({ id: 101, priority: 1 }) + subject.agent_available?.should be true + end + + it 'should allow adding multiple agents at the same priority' do + agent1 = OpenStruct.new({ id: 101, priority: 2 }) + agent2 = OpenStruct.new({ id: 102, priority: 2 }) + subject << agent1 + subject << agent2 + subject.checkout_agent.should == agent1 + end + + it 'should return all agents of a higher priority before returning an agent of a lower priority' do + agent1 = OpenStruct.new({ id: 101, priority: 2 }) + agent2 = OpenStruct.new({ id: 102, priority: 2 }) + agent3 = OpenStruct.new({ id: 103, priority: 3 }) + subject << agent1 + subject << agent2 + subject << agent3 + subject.checkout_agent.should == agent1 + subject.checkout_agent.should == agent2 + subject.checkout_agent.should == agent3 + end + + it 'should detect an agent available if one is available at any priority' do + agent1 = OpenStruct.new({ id: 101, priority: 2 }) + agent2 = OpenStruct.new({ id: 102, priority: 3 }) + subject << agent1 + subject << agent2 + subject.checkout_agent + subject.agent_available?.should == true + end +end diff --git a/spec/electric_slide/call_queue_spec.rb b/spec/electric_slide/call_queue_spec.rb new file mode 100644 index 0000000..8e66ff9 --- /dev/null +++ b/spec/electric_slide/call_queue_spec.rb @@ -0,0 +1,42 @@ +# encoding: utf-8 +require 'spec_helper' + +describe ElectricSlide::CallQueue do + let(:queue) { ElectricSlide::CallQueue.new } + let(:call_a) { dummy_call } + let(:call_b) { dummy_call } + let(:call_c) { dummy_call } + before :each do + queue.enqueue call_a + queue.enqueue call_b + queue.enqueue call_c + end + + it "should return callers in the same order they were enqueued" do + expect(queue.get_next_caller).to be call_a + expect(queue.get_next_caller).to be call_b + expect(queue.get_next_caller).to be call_c + end + + it "should return a priority caller ahead of the queue" do + call_d = dummy_call + queue.priority_enqueue call_d + expect(queue.get_next_caller).to be call_d + expect(queue.get_next_caller).to be call_a + end + + it "should remove a caller who abandons the queue" do + queue.enqueue call_a + queue.enqueue call_b + queue.abandon call_a + expect(queue.get_next_caller).to be call_b + end + + it "should raise when given an invalid connection type" do + expect { ElectricSlide::CallQueue.new connection_type: :blah }.to raise_error + end + + it "should raise when given an invalid Agent" do + expect { queue.add_agent nil }.to raise_error + end +end diff --git a/spec/electric_slide_spec.rb b/spec/electric_slide_spec.rb new file mode 100644 index 0000000..9d2659a --- /dev/null +++ b/spec/electric_slide_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe ElectricSlide do + context "creating a queue" do + after :each do + ElectricSlide.shutdown_queue :fake + end + + let(:queue_class) { double :fake_queue_class } + let(:queue_inst) { double :fake_queue_instance } + + it "should default to an ElectricSlide::CallQueue if one is not specified" do + ElectricSlide.create :fake + expect { ElectricSlide.get_queue :fake }.to_not raise_error + end + + it "should start the queue upon registration" do + expect(queue_class).to receive(:work).once.and_return queue_inst + expect(queue_inst).to receive(:terminate).once + ElectricSlide.create :fake, queue_class + end + + it "should preserve additional queue arguments" do + queue = double(:fake_queue) + expect(queue_class).to receive(:work).with(:foo, :bar, :baz).once.and_return queue_inst + expect(queue_inst).to receive(:terminate).once + ElectricSlide.create :fake, queue_class, :foo, :bar, :baz + end + + it "should not allow a second queue to be created with the same name" do + ElectricSlide.create :fake + expect { ElectricSlide.create :fake }.to raise_error + end + end + + it "should raise if attempting to work with a queue that doesn't exist" do + expect { ElectricSlide.get_queue!("does not exist!") }.to raise_error + expect { ElectricSlide.shutdown_queue("does not exist!") }.to raise_error + end + +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..769d7c5 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,18 @@ +$:.push File.join(File.dirname(__FILE__), '..', 'lib') +Thread.abort_on_exception = true + +%w( + adhearsion + electric_slide + rspec/core +).each { |r| require r } + +RSpec.configure do |config| + config.filter_run focus: true + config.run_all_when_everything_filtered = true +end + +def dummy_call + Adhearsion::Call.new +end +