electric_slide 0.0.2 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,335 @@
1
+ # encoding: utf-8
2
+
3
+ # The default agent strategy
4
+ require 'electric_slide/agent_strategy/longest_idle'
5
+
6
+ class ElectricSlide
7
+ class CallQueue
8
+ include Celluloid
9
+ ENDED_CALL_EXCEPTIONS = [
10
+ Adhearsion::Call::Hangup,
11
+ Adhearsion::Call::ExpiredError,
12
+ Adhearsion::Call::CommandTimeout,
13
+ Celluloid::DeadActorError,
14
+ Punchblock::ProtocolError
15
+ ]
16
+
17
+ CONNECTION_TYPES = [
18
+ :call,
19
+ :bridge,
20
+ ].freeze
21
+
22
+ AGENT_RETURN_METHODS = [
23
+ :auto,
24
+ :manual,
25
+ ].freeze
26
+
27
+ def self.work(*args)
28
+ self.supervise *args
29
+ end
30
+
31
+ def initialize(opts = {})
32
+ agent_strategy = opts[:agent_strategy] || AgentStrategy::LongestIdle
33
+ @connection_type = opts[:connection_type] || :call
34
+ @agent_return_method = opts[:agent_return_method] || :auto
35
+
36
+ raise ArgumentError, "Invalid connection type; must be one of #{CONNECTION_TYPES.join ','}" unless CONNECTION_TYPES.include? @connection_type
37
+ raise ArgumentError, "Invalid requeue method; must be one of #{AGENT_RETURN_METHODS.join ','}" unless AGENT_RETURN_METHODS.include? @agent_return_method
38
+
39
+ @free_agents = [] # Needed to keep track of waiting order
40
+ @agents = [] # Needed to keep track of global list of agents
41
+ @queue = [] # Calls waiting for an agent
42
+
43
+ @strategy = agent_strategy.new
44
+ end
45
+
46
+ # Checks whether an agent is available to take a call
47
+ # @return [Boolean] True if an agent is available
48
+ def agent_available?
49
+ @strategy.agent_available?
50
+ end
51
+
52
+ # Returns information about the number of available agents
53
+ # The data returned depends on the AgentStrategy in use.
54
+ # The data will always include a :total count of the agents available
55
+ # @return [Hash] Summary information about agents available, depending on strategy
56
+ def available_agent_summary
57
+ # TODO: Make this a delegator?
58
+ @strategy.available_agent_summary
59
+ end
60
+
61
+ # Assigns the first available agent, marking the agent :busy
62
+ # @return {Agent}
63
+ def checkout_agent
64
+ agent = @strategy.checkout_agent
65
+ agent.presence = :busy
66
+ agent
67
+ end
68
+
69
+ # Returns a copy of the set of agents that are known to the queue
70
+ # @return [Array] Array of {Agent} objects
71
+ def get_agents
72
+ @agents.dup
73
+ end
74
+
75
+ # Returns a copy of the set of calls waiting to be answered that are known to the queue
76
+ # @return [Array] Array of Adhearsion::Call objects
77
+ def get_queued_calls
78
+ @queue.dup
79
+ end
80
+
81
+ # Finds an agent known to the queue by that agent's ID
82
+ # @param [String] id The ID of the agent to locate
83
+ # @return [Agent, Nil] {Agent} object if found, Nil otherwise
84
+ def get_agent(id)
85
+ @agents.detect { |agent| agent.id == id }
86
+ end
87
+
88
+ # Registers an agent to the queue
89
+ # @param [Agent] agent The agent to be added to the queue
90
+ def add_agent(agent)
91
+ abort ArgumentError.new("#add_agent called with nil object") if agent.nil?
92
+ case @connection_type
93
+ when :call
94
+ abort ArgumentError.new("Agent has no callable address") unless agent.address
95
+ when :bridge
96
+ abort ArgumentError.new("Agent has no active call") unless agent.call && agent.call.active?
97
+ unless agent.call[:electric_slide_callback_set]
98
+ agent.call.on_end { remove_agent agent }
99
+ agent.call[:electric_slide_callback_set] = true
100
+ end
101
+ end
102
+
103
+ logger.info "Adding agent #{agent} to the queue"
104
+ @agents << agent unless @agents.include? agent
105
+ @strategy << agent if agent.presence == :available
106
+ check_for_connections
107
+ end
108
+
109
+ # Marks an agent as available to take a call. To be called after an agent completes a call
110
+ # and is ready to take the next call.
111
+ # @param [Agent] agent The {Agent} that is being returned to the queue
112
+ # @param [Symbol] status The {Agent}'s new status
113
+ # @param [String, Optional] address The {Agent}'s address. Only specified if it has changed
114
+ def return_agent(agent, status = :available, address = nil)
115
+ logger.debug "Returning #{agent} to the queue"
116
+ agent.presence = status
117
+ agent.address = address if address
118
+
119
+ if agent.presence == :available
120
+ @strategy << agent
121
+ check_for_connections
122
+ end
123
+ agent
124
+ end
125
+
126
+ # Removes an agent from the queue entirely
127
+ # @param [Agent] agent The {Agent} to be removed from the queue
128
+ # @return [Agent, Nil] The Agent object if removed, Nil otherwise
129
+ def remove_agent(agent)
130
+ @strategy.delete agent
131
+ @agents.delete agent
132
+ logger.info "Removing agent #{agent} from the queue"
133
+ rescue Adhearsion::Call::ExpiredError
134
+ end
135
+
136
+ # Checks to see if any callers are waiting for an agent and attempts to connect them to
137
+ # an available agent
138
+ def check_for_connections
139
+ connect checkout_agent, get_next_caller while call_waiting? && agent_available?
140
+ end
141
+
142
+ # Add a call to the head of the queue. Among other reasons, this is used when a caller is sent
143
+ # to an agent, but the connection fails because the agent is not available.
144
+ # @param [Adhearsion::Call] call Caller to be added to the queue
145
+ def priority_enqueue(call)
146
+ # Don't reset the enqueue time in case this is a re-insert on agent failure
147
+ call[:enqueue_time] ||= Time.now
148
+ @queue.unshift call
149
+
150
+ check_for_connections
151
+ end
152
+
153
+ # Add a call to the end of the queue, the normal FIFO queue behavior
154
+ # @param [Adhearsion::Call] call Caller to be added to the queue
155
+ def enqueue(call)
156
+ ignoring_ended_calls do
157
+ logger.info "Adding call from #{remote_party call} to the queue"
158
+ call[:enqueue_time] = Time.now
159
+ @queue << call unless @queue.include? call
160
+
161
+ check_for_connections
162
+ end
163
+ end
164
+
165
+ # Remove a waiting call from the queue. Used if the caller hangs up or is otherwise removed.
166
+ # @param [Adhearsion::Call] call Caller to be removed from the queue
167
+ def abandon(call)
168
+ ignoring_ended_calls { logger.info "Caller #{remote_party call} has abandoned the queue" }
169
+ @queue.delete call
170
+ end
171
+
172
+ # Connect an {Agent} to a caller
173
+ # @param [Agent] agent Agent to be connected
174
+ # @param [Adhearsion::Call] call Caller to be connected
175
+ def connect(agent, queued_call)
176
+ unless queued_call.active?
177
+ logger.warn "Inactive queued call found in #connect"
178
+ return_agent agent
179
+ end
180
+
181
+ logger.info "Connecting #{agent} with #{remote_party queued_call}"
182
+ case @connection_type
183
+ when :call
184
+ call_agent agent, queued_call
185
+ when :bridge
186
+ unless agent.call.active?
187
+ logger.warn "Inactive agent call found in #connect, returning caller to queue"
188
+ priority_enqueue queued_call
189
+ end
190
+ bridge_agent agent, queued_call
191
+ end
192
+ rescue *ENDED_CALL_EXCEPTIONS
193
+ ignoring_ended_calls do
194
+ if queued_call.active?
195
+ logger.warn "Dead call exception in #connect but queued_call still alive, reinserting into queue"
196
+ priority_enqueue queued_call
197
+ end
198
+ end
199
+ ignoring_ended_calls do
200
+ if agent.call && agent.call.active?
201
+ logger.warn "Dead call exception in #connect but agent call still alive, reinserting into queue"
202
+ return_agent agent
203
+ end
204
+ end
205
+ end
206
+
207
+ def conditionally_return_agent(agent, return_method = @agent_return_method)
208
+ raise ArgumentError, "Invalid requeue method; must be one of #{AGENT_RETURN_METHODS.join ','}" unless AGENT_RETURN_METHODS.include? return_method
209
+
210
+ if agent && @agents.include?(agent) && agent.presence == :busy && return_method == :auto
211
+ logger.info "Returning agent #{agent.id} to queue"
212
+ return_agent agent
213
+ else
214
+ logger.debug "Not returning agent #{agent.inspect} to the queue"
215
+ end
216
+ end
217
+
218
+ # Returns the next waiting caller
219
+ # @return [Adhearsion::Call] The next waiting caller
220
+ def get_next_caller
221
+ @queue.shift
222
+ end
223
+
224
+ # Checks whether any callers are waiting
225
+ # @return [Boolean] True if a caller is waiting
226
+ def call_waiting?
227
+ @queue.length > 0
228
+ end
229
+
230
+ # Returns the number of callers waiting in the queue
231
+ # @return [Fixnum]
232
+ def calls_waiting
233
+ @queue.length
234
+ end
235
+
236
+ private
237
+ # Get the caller ID of the remote party.
238
+ # If this is an OutboundCall, use Call#to
239
+ # Otherwise, use Call#from
240
+ def remote_party(call)
241
+ call.is_a?(Adhearsion::OutboundCall) ? call.to : call.from
242
+ end
243
+
244
+
245
+ # @private
246
+ def ignoring_ended_calls
247
+ yield
248
+ rescue *ENDED_CALL_EXCEPTIONS
249
+ # This actor may previously have been shut down due to the call ending
250
+ end
251
+
252
+ def call_agent(agent, queued_call)
253
+ agent_call = Adhearsion::OutboundCall.new
254
+ agent_call[:agent] = agent
255
+ agent_call[:queued_call] = queued_call
256
+
257
+ # Stash the caller ID so we don't have to try to get it from a dead call object later
258
+ queued_caller_id = remote_party queued_call
259
+
260
+ # The call controller is actually run by #dial, here we skip joining if we do not have one
261
+ dial_options = agent.dial_options_for(self, queued_call)
262
+ unless dial_options[:confirm]
263
+ agent_call.on_answer { ignoring_ended_calls { agent_call.join queued_call.uri if queued_call.active? } }
264
+ end
265
+
266
+ # Disconnect agent if caller hangs up before agent answers
267
+ queued_call.on_end { ignoring_ended_calls { agent_call.hangup } }
268
+
269
+ agent_call.on_unjoined do
270
+ ignoring_ended_calls { agent_call.hangup }
271
+ ignoring_ended_calls { queued_call.hangup }
272
+ end
273
+
274
+ # Track whether the agent actually talks to the queued_call
275
+ connected = false
276
+ queued_call.on_joined { connected = true }
277
+
278
+ agent_call.on_end do |end_event|
279
+ # Ensure we don't return an agent that was removed or paused
280
+ conditionally_return_agent agent
281
+
282
+ agent.callback :disconnect, self, agent_call, queued_call
283
+
284
+ unless connected
285
+ if queued_call.alive? && queued_call.active?
286
+ ignoring_ended_calls { priority_enqueue queued_call }
287
+ logger.warn "Call did not connect to agent! Agent #{agent.id} call ended with #{end_event.reason}; reinserting caller #{queued_caller_id} into queue"
288
+ else
289
+ logger.warn "Caller #{queued_caller_id} hung up before being connected to an agent."
290
+ end
291
+ end
292
+ end
293
+
294
+ agent.callback :connect, self, agent_call, queued_call
295
+
296
+ agent_call.execute_controller_or_router_on_answer dial_options.delete(:confirm), dial_options.delete(:confirm_metadata)
297
+
298
+ agent_call.dial agent.address, dial_options
299
+ end
300
+
301
+ def bridge_agent(agent, queued_call)
302
+ # Stash caller ID to make log messages work even if calls end
303
+ queued_caller_id = remote_party queued_call
304
+ agent.call[:queued_call] = queued_call
305
+
306
+ agent.call.register_tmp_handler :event, Punchblock::Event::Unjoined do
307
+ agent.callback :disconnect, self, agent.call, queued_call
308
+ ignoring_ended_calls { queued_call.hangup }
309
+ ignoring_ended_calls { conditionally_return_agent agent if agent.call.active? }
310
+ agent.call[:queued_call] = nil
311
+ end
312
+
313
+ agent.callback :connect, self, agent.call, queued_call
314
+
315
+ agent.join queued_call if queued_call.active?
316
+ rescue *ENDED_CALL_EXCEPTIONS
317
+ ignoring_ended_calls do
318
+ if agent.call.active?
319
+ logger.info "Caller #{queued_caller_id} failed to connect to Agent #{agent.id} due to caller hangup"
320
+ conditionally_return_agent agent, :auto
321
+ else
322
+ # Agent's call has ended, so remove him from the queue
323
+ remove_agent agent
324
+ end
325
+ end
326
+
327
+ ignoring_ended_calls do
328
+ if queued_call.active?
329
+ priority_enqueue queued_call
330
+ logger.warn "Call failed to connect to Agent #{agent.id} due to agent hangup; reinserting caller #{queued_caller_id} into queue"
331
+ end
332
+ end
333
+ end
334
+ end
335
+ end
@@ -0,0 +1,10 @@
1
+ # encoding: utf-8
2
+ require 'adhearsion'
3
+
4
+ class ElectricSlide
5
+ class Plugin < Adhearsion::Plugin
6
+ init do
7
+ logger.info 'ElectricSlide plugin loaded.'
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,4 @@
1
+ # encoding: utf-8
2
+ class ElectricSlide
3
+ VERSION = '0.2.0'
4
+ end
@@ -0,0 +1,23 @@
1
+ # encoding: utf-8
2
+ require 'spec_helper'
3
+ require 'electric_slide/agent'
4
+
5
+ describe ElectricSlide::Agent do
6
+ let(:options) { { id: 1, address: '123@foo.com', presence: :available} }
7
+
8
+ class MyAgent < ElectricSlide::Agent
9
+ on_connect do
10
+ foo
11
+ end
12
+
13
+ def foo
14
+ :bar
15
+ end
16
+ end
17
+
18
+ subject {MyAgent.new options}
19
+
20
+ it 'executes a connect callback' do
21
+ expect(subject.callback(:connect)).to eql :bar
22
+ end
23
+ end
@@ -0,0 +1,43 @@
1
+ # encoding: utf-8
2
+
3
+ require 'spec_helper'
4
+ require 'electric_slide/agent_strategy/fixed_priority'
5
+ require 'ostruct'
6
+
7
+ describe ElectricSlide::AgentStrategy::FixedPriority do
8
+ let(:subject) { ElectricSlide::AgentStrategy::FixedPriority.new }
9
+ it 'should allow adding an agent with a specified priority' do
10
+ subject.agent_available?.should be false
11
+ subject << OpenStruct.new({ id: 101, priority: 1 })
12
+ subject.agent_available?.should be true
13
+ end
14
+
15
+ it 'should allow adding multiple agents at the same priority' do
16
+ agent1 = OpenStruct.new({ id: 101, priority: 2 })
17
+ agent2 = OpenStruct.new({ id: 102, priority: 2 })
18
+ subject << agent1
19
+ subject << agent2
20
+ subject.checkout_agent.should == agent1
21
+ end
22
+
23
+ it 'should return all agents of a higher priority before returning an agent of a lower priority' do
24
+ agent1 = OpenStruct.new({ id: 101, priority: 2 })
25
+ agent2 = OpenStruct.new({ id: 102, priority: 2 })
26
+ agent3 = OpenStruct.new({ id: 103, priority: 3 })
27
+ subject << agent1
28
+ subject << agent2
29
+ subject << agent3
30
+ subject.checkout_agent.should == agent1
31
+ subject.checkout_agent.should == agent2
32
+ subject.checkout_agent.should == agent3
33
+ end
34
+
35
+ it 'should detect an agent available if one is available at any priority' do
36
+ agent1 = OpenStruct.new({ id: 101, priority: 2 })
37
+ agent2 = OpenStruct.new({ id: 102, priority: 3 })
38
+ subject << agent1
39
+ subject << agent2
40
+ subject.checkout_agent
41
+ subject.agent_available?.should == true
42
+ end
43
+ end
@@ -0,0 +1,42 @@
1
+ # encoding: utf-8
2
+ require 'spec_helper'
3
+
4
+ describe ElectricSlide::CallQueue do
5
+ let(:queue) { ElectricSlide::CallQueue.new }
6
+ let(:call_a) { dummy_call }
7
+ let(:call_b) { dummy_call }
8
+ let(:call_c) { dummy_call }
9
+ before :each do
10
+ queue.enqueue call_a
11
+ queue.enqueue call_b
12
+ queue.enqueue call_c
13
+ end
14
+
15
+ it "should return callers in the same order they were enqueued" do
16
+ expect(queue.get_next_caller).to be call_a
17
+ expect(queue.get_next_caller).to be call_b
18
+ expect(queue.get_next_caller).to be call_c
19
+ end
20
+
21
+ it "should return a priority caller ahead of the queue" do
22
+ call_d = dummy_call
23
+ queue.priority_enqueue call_d
24
+ expect(queue.get_next_caller).to be call_d
25
+ expect(queue.get_next_caller).to be call_a
26
+ end
27
+
28
+ it "should remove a caller who abandons the queue" do
29
+ queue.enqueue call_a
30
+ queue.enqueue call_b
31
+ queue.abandon call_a
32
+ expect(queue.get_next_caller).to be call_b
33
+ end
34
+
35
+ it "should raise when given an invalid connection type" do
36
+ expect { ElectricSlide::CallQueue.new connection_type: :blah }.to raise_error
37
+ end
38
+
39
+ it "should raise when given an invalid Agent" do
40
+ expect { queue.add_agent nil }.to raise_error
41
+ end
42
+ end