adhearsion 2.3.5 → 2.4.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +0 -1
  3. data/CHANGELOG.md +14 -0
  4. data/Gemfile +2 -0
  5. data/README.markdown +21 -2
  6. data/adhearsion.gemspec +5 -4
  7. data/features/cli_plugin.feature +41 -0
  8. data/features/cli_start.feature +12 -4
  9. data/features/step_definitions/cli_steps.rb +12 -0
  10. data/features/support/env.rb +1 -1
  11. data/features/support/utils.rb +0 -1
  12. data/lib/adhearsion.rb +4 -1
  13. data/lib/adhearsion/call.rb +92 -22
  14. data/lib/adhearsion/call_controller.rb +19 -15
  15. data/lib/adhearsion/call_controller/dial.rb +157 -25
  16. data/lib/adhearsion/call_controller/menu_dsl/menu_builder.rb +8 -0
  17. data/lib/adhearsion/call_controller/output/async_player.rb +1 -1
  18. data/lib/adhearsion/call_controller/output/formatter.rb +1 -1
  19. data/lib/adhearsion/call_controller/output/player.rb +1 -1
  20. data/lib/adhearsion/calls.rb +2 -0
  21. data/lib/adhearsion/cli_commands.rb +3 -163
  22. data/lib/adhearsion/cli_commands/ahn_command.rb +141 -0
  23. data/lib/adhearsion/cli_commands/plugin_command.rb +74 -0
  24. data/lib/adhearsion/cli_commands/thor_errors.rb +36 -0
  25. data/lib/adhearsion/console.rb +14 -6
  26. data/lib/adhearsion/generators/app/templates/spec/call_controllers/simon_game_spec.rb +36 -36
  27. data/lib/adhearsion/generators/controller/templates/spec/controller_spec.rb +1 -1
  28. data/lib/adhearsion/generators/plugin/templates/plugin-template.gemspec.tt +0 -1
  29. data/lib/adhearsion/generators/plugin/templates/spec/plugin-template/controller_methods_spec.rb.tt +1 -1
  30. data/lib/adhearsion/generators/plugin/templates/spec/spec_helper.rb.tt +0 -1
  31. data/lib/adhearsion/logging.rb +5 -1
  32. data/lib/adhearsion/outbound_call.rb +16 -0
  33. data/lib/adhearsion/punchblock_plugin.rb +0 -2
  34. data/lib/adhearsion/punchblock_plugin/initializer.rb +7 -12
  35. data/lib/adhearsion/version.rb +1 -1
  36. data/spec/adhearsion/call_controller/dial_spec.rb +785 -32
  37. data/spec/adhearsion/call_controller/menu_dsl/menu_builder_spec.rb +10 -0
  38. data/spec/adhearsion/call_controller/output/async_player_spec.rb +1 -1
  39. data/spec/adhearsion/call_controller/output/player_spec.rb +1 -1
  40. data/spec/adhearsion/call_controller/output_spec.rb +3 -3
  41. data/spec/adhearsion/call_controller/record_spec.rb +1 -1
  42. data/spec/adhearsion/call_controller_spec.rb +13 -9
  43. data/spec/adhearsion/call_spec.rb +216 -51
  44. data/spec/adhearsion/calls_spec.rb +1 -1
  45. data/spec/adhearsion/console_spec.rb +20 -9
  46. data/spec/adhearsion/outbound_call_spec.rb +40 -6
  47. data/spec/adhearsion/punchblock_plugin/initializer_spec.rb +9 -21
  48. data/spec/adhearsion/punchblock_plugin_spec.rb +1 -1
  49. data/spec/adhearsion/router_spec.rb +1 -1
  50. data/spec/spec_helper.rb +11 -15
  51. data/spec/support/call_controller_test_helpers.rb +2 -2
  52. data/spec/support/punchblock_mocks.rb +2 -2
  53. metadata +41 -16
@@ -54,7 +54,11 @@ module Adhearsion
54
54
  end
55
55
  end
56
56
 
57
- attr_reader :call, :metadata
57
+ # @return [Call] The call object on which the controller is executing
58
+ attr_reader :call
59
+
60
+ # @return [Hash] The controller's metadata provided at invocation
61
+ attr_reader :metadata
58
62
 
59
63
  # @private
60
64
  attr_reader :block
@@ -105,7 +109,7 @@ module Adhearsion
105
109
  execute_callbacks :before_call
106
110
  run
107
111
  rescue Call::Hangup
108
- logger.info "Call was hung up"
112
+ logger.info "Call was hung up while executing a controller"
109
113
  rescue SyntaxError, StandardError => e
110
114
  Events.trigger :exception, [e, logger]
111
115
  ensure
@@ -156,18 +160,6 @@ module Adhearsion
156
160
  @after_call ||= execute_callbacks :after_call
157
161
  end
158
162
 
159
- #
160
- # Hangup the call, and execute after_call callbacks
161
- #
162
- # @param [Hash] headers
163
- #
164
- def hangup(headers = nil)
165
- block_until_resumed
166
- call.hangup headers
167
- after_call
168
- raise Call::Hangup
169
- end
170
-
171
163
  # @private
172
164
  def write_and_await_response(command)
173
165
  block_until_resumed
@@ -195,6 +187,17 @@ module Adhearsion
195
187
  call.answer(*args)
196
188
  end
197
189
 
190
+ #
191
+ # Hangup the call, and execute after_call callbacks
192
+ #
193
+ # @param [Hash] headers
194
+ #
195
+ def hangup(headers = nil)
196
+ block_until_resumed
197
+ call.hangup headers
198
+ raise Call::Hangup
199
+ end
200
+
198
201
  #
199
202
  # Reject the call
200
203
  #
@@ -203,6 +206,7 @@ module Adhearsion
203
206
  def reject(*args)
204
207
  block_until_resumed
205
208
  call.reject(*args)
209
+ raise Call::Hangup
206
210
  end
207
211
 
208
212
  #
@@ -238,7 +242,7 @@ module Adhearsion
238
242
  block_until_resumed
239
243
  async = (target.is_a?(Hash) ? target : options).delete :async
240
244
  join_command = call.join target, options
241
- waiter = join_command.call_id || join_command.mixer_name
245
+ waiter = join_command.call_uri || join_command.mixer_name
242
246
  if async
243
247
  call.wait_for_joined waiter
244
248
  else
@@ -33,7 +33,7 @@ module Adhearsion
33
33
  # @option options [Numeric] :for this option can be thought of best as a timeout.
34
34
  # i.e. timeout after :for if no one answers the call
35
35
  #
36
- # @option options [CallController] :confirm the controller to execute on answered outbound calls to give an opportunity to screen the call. The calls will be joined if the outbound call is still active after this controller completes.
36
+ # @option options [CallController] :confirm the controller to execute on the first outbound call to be answered, to give an opportunity to screen the call. The calls will be joined if the outbound call is still active after this controller completes.
37
37
  # @option options [Hash] :confirm_metadata Metadata to set on the confirmation controller before executing it. This is shared between all calls if dialing multiple endpoints; if you care about it being mutated, you should provide an immutable value (using eg https://github.com/harukizaemon/hamster).
38
38
  #
39
39
  # @example Make a call to the PSTN using my SIP provider for VoIP termination
@@ -47,8 +47,22 @@ module Adhearsion
47
47
  #
48
48
  # @return [DialStatus] the status of the dial operation
49
49
  #
50
- def dial(to, options = {}, latch = nil)
51
- dial = Dial.new to, options, latch, call
50
+ def dial(to, options = {})
51
+ dial = Dial.new to, options, call
52
+ dial.run
53
+ dial.await_completion
54
+ dial.cleanup_calls
55
+ dial.status
56
+ end
57
+
58
+ # Dial one or more third parties and join one to this call after execution of a confirmation controller.
59
+ # Confirmation will be attempted on all answered calls, and calls will be allowed to progress through confirmation in parallel. The first to complete confirmation will be joined to the A-leg, with the others being hung up.
60
+ #
61
+ # @option options [CallController] :apology controller to execute on calls which lose the race to complete confirmation before they are hung up
62
+ #
63
+ # @see #dial
64
+ def dial_and_confirm(to, options = {})
65
+ dial = ParallelConfirmationDial.new to, options, call
52
66
  dial.run
53
67
  dial.await_completion
54
68
  dial.cleanup_calls
@@ -58,9 +72,9 @@ module Adhearsion
58
72
  class Dial
59
73
  attr_accessor :status
60
74
 
61
- def initialize(to, options, latch, call)
75
+ def initialize(to, options, call)
62
76
  raise Call::Hangup unless call.alive? && call.active?
63
- @options, @latch, @call = options, latch, call
77
+ @options, @call = options, call
64
78
  @targets = to.respond_to?(:has_key?) ? to : Array(to)
65
79
  set_defaults
66
80
  end
@@ -68,7 +82,7 @@ module Adhearsion
68
82
  def set_defaults
69
83
  @status = DialStatus.new
70
84
 
71
- @latch ||= CountDownLatch.new @targets.size
85
+ @latch = CountDownLatch.new @targets.size
72
86
 
73
87
  @options[:from] ||= @call.from
74
88
 
@@ -93,38 +107,43 @@ module Adhearsion
93
107
  @calls = @targets.map do |target, specific_options|
94
108
  new_call = OutboundCall.new
95
109
 
110
+ join_status = JoinStatus.new
111
+ status.joins[new_call] = join_status
112
+
96
113
  new_call.on_end do |event|
97
114
  @latch.countdown! unless new_call["dial_countdown_#{@call.id}"]
98
- status.error! if event.reason == :error
115
+ if event.reason == :error
116
+ status.error!
117
+ join_status.errored!
118
+ end
99
119
  end
100
120
 
101
121
  new_call.on_answer do |event|
102
- @calls.each do |call_to_hangup, _|
103
- begin
104
- next if call_to_hangup.id == new_call.id
105
- logger.debug "#dial hanging up call #{call_to_hangup.id} because this call has been answered by another channel"
106
- call_to_hangup.hangup
107
- rescue Celluloid::DeadActorError
108
- # This actor may previously have been shut down due to the call ending
109
- end
110
- end
122
+ pre_confirmation_tasks new_call
111
123
 
112
124
  new_call.on_unjoined @call do |unjoined|
113
125
  new_call["dial_countdown_#{@call.id}"] = true
126
+ join_status.ended
114
127
  @latch.countdown!
115
128
  end
116
129
 
117
130
  if @confirmation_controller
118
131
  status.unconfirmed!
119
- new_call.execute_controller @confirmation_controller.new(new_call, @confirmation_metadata), lambda { |call| call.signal :confirmed }
120
- new_call.wait :confirmed
132
+ join_status.unconfirmed!
133
+ condition = Celluloid::Condition.new
134
+ new_call.execute_controller @confirmation_controller.new(new_call, @confirmation_metadata), lambda { |call| condition.broadcast }
135
+ condition.wait
121
136
  end
122
137
 
123
- if new_call.alive? && new_call.active?
124
- logger.debug "#dial joining call #{new_call.id} to #{@call.id}"
138
+ if new_call.alive? && new_call.active? && status.result != :answer
139
+ logger.info "#dial joining call #{new_call.id} to #{@call.id}"
140
+ pre_join_tasks new_call
125
141
  @call.answer
142
+ join_status.started
126
143
  new_call.join @call
127
- status.answer!
144
+ status.answer!(new_call)
145
+ elsif status.result == :answer
146
+ join_status.lost_confirmation!
128
147
  end
129
148
  end
130
149
 
@@ -148,8 +167,18 @@ module Adhearsion
148
167
  end
149
168
 
150
169
  def cleanup_calls
151
- logger.debug "#dial finished. Hanging up #{@calls.size} outbound calls: #{@calls.map(&:id).join ", "}."
152
- @calls.each do |outbound_call|
170
+ calls_to_hangup = @calls.map do |call|
171
+ begin
172
+ [call.id, call] if call.active?
173
+ rescue Celluloid::DeadActorError
174
+ end
175
+ end.compact
176
+ if calls_to_hangup.size.zero?
177
+ logger.info "#dial finished with no remaining outbound calls"
178
+ return
179
+ end
180
+ logger.info "#dial finished. Hanging up #{calls_to_hangup.size} outbound calls which are still active: #{calls_to_hangup.map(&:first).join ", "}."
181
+ calls_to_hangup.each do |id, outbound_call|
153
182
  begin
154
183
  outbound_call.hangup
155
184
  rescue Celluloid::DeadActorError
@@ -157,15 +186,66 @@ module Adhearsion
157
186
  end
158
187
  end
159
188
  end
189
+
190
+ private
191
+
192
+ def pre_confirmation_tasks(call)
193
+ on_all_except call do |target_call|
194
+ logger.info "#dial hanging up call #{target_call.id} because this call has been answered by another channel"
195
+ target_call.hangup
196
+ end
197
+ end
198
+
199
+ def pre_join_tasks(call)
200
+ end
201
+
202
+ def on_all_except(call)
203
+ @calls.each do |target_call, _|
204
+ begin
205
+ next if target_call.id == call.id
206
+ yield target_call
207
+ rescue Celluloid::DeadActorError
208
+ # This actor may previously have been shut down due to the call ending
209
+ end
210
+ end
211
+ end
212
+ end
213
+
214
+ class ParallelConfirmationDial < Dial
215
+ def set_defaults
216
+ super
217
+ @apology_controller = @options.delete :apology
218
+ end
219
+
220
+ private
221
+
222
+ def pre_confirmation_tasks(call)
223
+ end
224
+
225
+ def pre_join_tasks(call)
226
+ on_all_except call do |target_call|
227
+ if @apology_controller
228
+ logger.info "#dial apologising to call #{target_call.id} because this call has been confirmed by another channel"
229
+ target_call.async.execute_controller @apology_controller.new(target_call, @confirmation_metadata), ->(call) { call.hangup }
230
+ else
231
+ logger.info "#dial hanging up call #{target_call.id} because this call has been confirmed by another channel"
232
+ target_call.hangup
233
+ end
234
+ end
235
+ end
160
236
  end
161
237
 
162
238
  class DialStatus
163
239
  # The collection of calls created during the dial operation
164
- attr_accessor :calls
240
+ attr_accessor :calls, :joined_call
241
+
242
+ # A collection of status objects indexed by call. Provides status on the joins such as duration
243
+ attr_accessor :joins
165
244
 
166
245
  # @private
167
246
  def initialize
168
247
  @result = nil
248
+ @joins = {}
169
249
  end
170
250
 
171
251
  #
@@ -177,7 +257,8 @@ module Adhearsion
177
257
  end
178
258
 
179
259
  # @private
180
- def answer!
260
+ def answer!(call)
261
+ @joined_call = call
181
262
  @result = :answer
182
263
  end
183
264
 
@@ -197,6 +278,57 @@ module Adhearsion
197
278
  end
198
279
  end
199
280
 
281
+ class JoinStatus
282
+ # The time at which the calls were joined
283
+ attr_accessor :start_time
284
+
285
+ # Time at which the join was broken
286
+ attr_accessor :end_time
287
+
288
+ def initialize
289
+ @result = :no_answer
290
+ end
291
+
292
+ # The result of the attempt to join calls
293
+ # Can be:
294
+ # * :joined - The calls were sucessfully joined
295
+ # * :no_answer - The attempt to dial the third-party was cancelled before they answered
296
+ # * :unconfirmed - The callee did not complete confirmation
297
+ # * :lost_confirmation - The callee completed confirmation, but was beaten by another
298
+ # * :error - The call ended with some error
299
+ attr_reader :result
300
+
301
+ # The duration for which the calls were joined. Does not include time spent in confirmation controllers or after being separated.
302
+ def duration
303
+ if start_time && end_time
304
+ end_time - start_time
305
+ else
306
+ 0.0
307
+ end
308
+ end
309
+
310
+ def errored!
311
+ @result = :error
312
+ end
313
+
314
+ def unconfirmed!
315
+ @result = :unconfirmed
316
+ end
317
+
318
+ def lost_confirmation!
319
+ @result = :lost_confirmation
320
+ end
321
+
322
+ def started
323
+ @start_time = Time.now
324
+ @result = :joined
325
+ end
326
+
327
+ def ended
328
+ @end_time = Time.now
329
+ end
330
+ end
331
+
200
332
  end
201
333
  end
202
334
  end
@@ -77,6 +77,14 @@ module Adhearsion
77
77
  end
78
78
  end
79
79
 
80
+ def method_missing(method_name, *args, &block)
81
+ if @context
82
+ @context.send method_name, *args, &block
83
+ else
84
+ super
85
+ end
86
+ end
87
+
80
88
  end # class MenuBuilder
81
89
 
82
90
  end
@@ -10,7 +10,7 @@ module Adhearsion
10
10
  # @raises [PlaybackError] if (one of) the given argument(s) could not be played
11
11
  #
12
12
  def output(content, options = {})
13
- options.merge! :ssml => content.to_s
13
+ options.merge! :ssml => content
14
14
  component = new_output options
15
15
  component.register_event_handler Punchblock::Event::Complete do |event|
16
16
  controller.logger.error event if event.reason.is_a?(Punchblock::Event::Complete::Error)
@@ -8,7 +8,7 @@ module Adhearsion
8
8
  class Formatter
9
9
 
10
10
  def ssml_for_collection(collection)
11
- collection.inject RubySpeech::SSML::Speak.new do |doc, argument|
11
+ collection.inject RubySpeech::SSML.draw do |doc, argument|
12
12
  doc + case argument
13
13
  when Hash
14
14
  ssml_for argument.delete(:value), argument
@@ -10,7 +10,7 @@ module Adhearsion
10
10
  # @raises [PlaybackError] if (one of) the given argument(s) could not be played
11
11
  #
12
12
  def output(content, options = {}, &block)
13
- options.merge! :ssml => content.to_s
13
+ options.merge! :ssml => content
14
14
  component = new_output options
15
15
  if block
16
16
  controller.execute_component_and_await_completion component, &block
@@ -49,6 +49,8 @@ module Adhearsion
49
49
  call_id = key call
50
50
  remove_inactive_call call
51
51
  return unless reason
52
+ Adhearsion::Events.trigger :exception, reason
53
+ logger.error "Call #{call_id} terminated abnormally due to #{reason}. Forcing hangup."
52
54
  PunchblockPlugin.client.execute_command Punchblock::Command::Hangup.new, :async => true, :call_id => call_id
53
55
  end
54
56
  end
@@ -22,168 +22,8 @@ end
22
22
 
23
23
  module Adhearsion
24
24
  module CLI
25
- class AhnCommand < Thor
26
- map %w(-h --h -help --help) => :help
27
- map %w(-v --v -version --version) => :version
28
- map %w(-) => :start
29
-
30
- check_unknown_options!
31
-
32
- def self.exit_on_failure?
33
- true
34
- end
35
-
36
- desc "create /path/to/directory", "Create a new Adhearsion application under the given path"
37
- def create(path)
38
- require 'adhearsion/generators/app/app_generator'
39
- Generators::AppGenerator.start
40
- end
41
-
42
- desc "generate [generator_name] arguments", Generators.help
43
- def generate(generator_name = nil, *args)
44
- if generator_name
45
- Generators.invoke generator_name
46
- else
47
- help 'generate'
48
- end
49
- end
50
-
51
- desc "version", "Shows Adhearsion version"
52
- def version
53
- say "Adhearsion v#{Adhearsion::VERSION}"
54
- exit 0
55
- end
56
-
57
- desc "start </path/to/directory>", "Start the Adhearsion server in the foreground with a console"
58
- def start(path = nil)
59
- start_app path, :console
60
- end
61
-
62
- desc "daemon </path/to/directory>", "Start the Adhearsion server in the background"
63
- method_option :pidfile, :type => :string, :aliases => %w(--pid-file)
64
- def daemon(path = nil)
65
- start_app path, :daemon, options[:pidfile]
66
- end
67
-
68
- desc "stop </path/to/directory>", "Stop a running Adhearsion server"
69
- method_option :pidfile, :type => :string, :aliases => %w(--pid-file)
70
- def stop(path = nil)
71
- execute_from_app_dir! path
72
-
73
- pid_file = if options[:pidfile]
74
- File.exists?(File.expand_path(options[:pidfile])) ?
75
- options[:pidfile] :
76
- File.join(path, options[:pidfile])
77
- else
78
- path = Dir.pwd
79
- File.join path, Adhearsion::Initializer::DEFAULT_PID_FILE_NAME
80
- end
81
- pid_file = File.expand_path pid_file
82
-
83
- begin
84
- pid = File.read(pid_file).to_i
85
- rescue
86
- raise PIDReadError, pid_file
87
- end
88
-
89
- raise PIDReadError, pid_file if pid.nil?
90
-
91
- say "Stopping Adhearsion server at #{path} with pid #{pid}"
92
- waiting_timeout = Time.now + 15
93
- begin
94
- ::Process.kill "TERM", pid
95
- sleep 0.25 while process_exists?(pid) && Time.now < waiting_timeout
96
- ::Process.kill "KILL", pid
97
- rescue Errno::ESRCH
98
- end
99
-
100
- File.delete pid_file if File.exists? pid_file
101
- end
102
-
103
- desc "restart </path/to/directory>", "Restart the Adhearsion server"
104
- method_option :pidfile, :type => :string, :aliases => %w(--pid-file)
105
- def restart(path = nil)
106
- execute_from_app_dir! path
107
- begin
108
- invoke :stop
109
- rescue PIDReadError => e
110
- puts e.message
111
- end
112
- invoke :daemon
113
- end
114
-
115
- protected
116
-
117
- def start_app(path, mode, pid_file = nil)
118
- execute_from_app_dir! path
119
- say "Starting Adhearsion server at #{Dir.pwd}"
120
- Adhearsion::Initializer.start :mode => mode, :pid_file => pid_file
121
- end
122
-
123
- def execute_from_app_dir!(path)
124
- return if in_app? and running_script_ahn?
125
-
126
- path ||= Dir.pwd if in_app?
127
-
128
- raise PathRequired, ARGV[0] if path.nil? or path.empty?
129
- raise PathInvalid, path unless ScriptAhnLoader.in_ahn_application?(path)
130
-
131
- Dir.chdir path do
132
- args = ARGV.dup
133
- args[1] = File.expand_path path
134
- ScriptAhnLoader.exec_script_ahn! args
135
- end
136
- end
137
-
138
- def running_script_ahn?
139
- $0.to_s == "script/ahn"
140
- end
141
-
142
- def in_app?
143
- ScriptAhnLoader.in_ahn_application? or ScriptAhnLoader.in_ahn_application_subdirectory?
144
- end
145
-
146
- def process_exists?(pid = nil)
147
- # FIXME: Raise some error here
148
- return false if pid.nil?
149
- `ps -p #{pid} | sed -e '1d'`.strip.empty?
150
- end
151
-
152
- def method_missing(action, *args)
153
- help
154
- raise UnknownCommand, [action, *args] * " "
155
- end
156
- end # AhnCommand
157
-
158
- class UnknownCommand < Thor::Error
159
- def initialize(cmd)
160
- super "Unknown command: #{cmd}"
161
- end
162
- end
163
-
164
- class PathRequired < Thor::Error
165
- def initialize(cmd)
166
- super "A valid path is required for #{cmd}, unless run from an Adhearson app directory"
167
- end
168
- end
169
-
170
- class UnknownGeneratorError < Thor::Error
171
- def initialize(gentype)
172
- puts "Please specify generator to use (#{Adhearsion::Generators.mappings.keys.join(", ")})"
173
- super "Unknown command: #{gentype}"
174
- end
175
- end
176
-
177
- class PathInvalid < Thor::Error
178
- def initialize(path)
179
- super "Directory #{path} does not belong to an Adhearsion project!"
180
- end
181
- end
182
-
183
- class PIDReadError < Thor::Error
184
- def initialize(path)
185
- super "Could not read pid from the file #{path}"
186
- end
187
- end
25
+ require 'adhearsion/cli_commands/plugin_command'
26
+ require 'adhearsion/cli_commands/ahn_command'
27
+ require 'adhearsion/cli_commands/thor_errors'
188
28
  end
189
29
  end