electric_slide 0.0.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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