larynx 0.1.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,73 @@
1
+ module Larynx
2
+ class Command
3
+ include Callbacks
4
+ attr_reader :command
5
+
6
+ define_callback :before, :after
7
+
8
+ def initialize(command, params=nil, &block)
9
+ @command, @params, @callbacks = command, params, {}
10
+ after(&block) if block_given?
11
+ end
12
+
13
+ def to_s
14
+ @command
15
+ end
16
+
17
+ def name
18
+ @command
19
+ end
20
+
21
+ def interruptable?
22
+ false
23
+ end
24
+ end
25
+
26
+ class CallCommand < Command
27
+ def name
28
+ "#{@command}#{" #{@params}" if @params}"
29
+ end
30
+
31
+ def to_s
32
+ cmd = "#{@command}"
33
+ cmd << " #{@params}" if @params
34
+ cmd << "\n\n"
35
+ end
36
+ end
37
+
38
+ class ApiCommand < Command
39
+ def name
40
+ "#{@command}#{" #{@params}" if @params}"
41
+ end
42
+
43
+ def to_s
44
+ cmd = "api #{@command}"
45
+ cmd << " #{@params}" if @params
46
+ cmd << "\n\n"
47
+ end
48
+ end
49
+
50
+ class AppCommand < Command
51
+ def initialize(command, params=nil, options={}, &block)
52
+ super command, params, &block
53
+ @options = options.reverse_merge(:bargein => true)
54
+ end
55
+
56
+ def name
57
+ "#{@command}#{" '#{@params}'" if @params}"
58
+ end
59
+
60
+ def to_s
61
+ cmd = "sendmsg\n"
62
+ cmd << "call-command: execute\n"
63
+ cmd << "execute-app-name: #{@command}\n"
64
+ cmd << "execute-app-arg: #{@params}\n" if @params
65
+ cmd << "event-lock: #{@options[:lock]}\n" if @options[:lock]
66
+ cmd << "\n"
67
+ end
68
+
69
+ def interruptable?
70
+ @options[:bargein]
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,88 @@
1
+ module Larynx
2
+ module Commands
3
+
4
+ def connect(&block)
5
+ execute CallCommand.new('connect', &block)
6
+ end
7
+
8
+ def myevents(&block)
9
+ execute CallCommand.new('myevents', &block)
10
+ end
11
+
12
+ def filter(type, &block)
13
+ execute CallCommand.new('filter', type, &block)
14
+ end
15
+
16
+ def linger(&block)
17
+ execute CallCommand.new('linger', &block)
18
+ end
19
+
20
+ def answer(&block)
21
+ execute AppCommand.new('answer', &block)
22
+ end
23
+
24
+ def hangup(&block)
25
+ execute AppCommand.new('hangup', &block)
26
+ end
27
+
28
+ def playback(data, options={}, &block)
29
+ execute AppCommand.new('playback', data, options, &block)
30
+ end
31
+ alias_method :play, :playback
32
+
33
+ def speak(data, options={}, &block)
34
+ execute AppCommand.new('speak', data, options, &block)
35
+ end
36
+
37
+ def phrase(data, options={}, &block)
38
+ execute AppCommand.new('phrase', data, options, &block)
39
+ end
40
+
41
+
42
+ # Executes read command with some default values.
43
+ # Allows length option which expands into minimum and maximum length values. Length can be a range.
44
+ # Passes user input into callback block.
45
+ #
46
+ # Defaults:
47
+ # timeout: 5000 or 5 seconds
48
+ # termchar: #
49
+ #
50
+ # Example:
51
+ #
52
+ # read(:minimum => 1, :maximum => 2, :sound_file => 'en/us/callie/conference/8000/conf-pin.wav') {|input|
53
+ # speak "You entered #{input}"
54
+ # }
55
+ #
56
+ # Or
57
+ #
58
+ # read(:length => 1..2, :sound_file => 'en/us/callie/conference/8000/conf-pin.wav') {|input|
59
+ # speak "You entered #{input}"
60
+ # }
61
+ #
62
+ def read(options={}, &block)
63
+ options.reverse_merge!(:timeout => 5000, :var_name => 'read_result', :termchar => '#')
64
+ options[:bargein] = false
65
+
66
+ if length = options.delete(:length)
67
+ values = length.is_a?(Range) ? [length.first, length.last] : [length, length]
68
+ options.merge!(:minimum => values[0], :maximum => values[1])
69
+ end
70
+
71
+ order = [:minimum, :maximum, :sound_file, :var_name, :timeout, :termchar]
72
+ data = order.inject('') {|data, key| data += " #{options[key]}"; data }.strip
73
+
74
+ execute AppCommand.new('read', data, options).after {
75
+ block.call(response.body[:variable_read_result])
76
+ }
77
+ end
78
+
79
+ def prompt(options={}, &block)
80
+ execute Prompt.new(self, options, &block).command
81
+ end
82
+
83
+ def break!
84
+ execute AppCommand.new('break'), true
85
+ end
86
+
87
+ end
88
+ end
@@ -0,0 +1,143 @@
1
+ module Larynx
2
+ class NoPromptDefined < StandardError; end
3
+
4
+ module Fields
5
+
6
+ def self.included(base)
7
+ base.extend ClassMethods
8
+ base.send :include, InstanceMethods
9
+ end
10
+
11
+ module ClassMethods
12
+ attr_accessor :fields
13
+
14
+ def field(name, options={}, &block)
15
+ @fields ||= []
16
+ @fields << Field.new(name, options, &block)
17
+ attr_accessor name
18
+ end
19
+
20
+ end
21
+
22
+ module InstanceMethods
23
+
24
+ def next_field(field_name=nil)
25
+ @current_field ||= 0
26
+ @current_field = index_of_field(field_name) if field_name
27
+ if field = self.class.fields[@current_field]
28
+ field.run(self)
29
+ @current_field += 1
30
+ field
31
+ end
32
+ end
33
+
34
+ def index_of_field(name)
35
+ field = self.class.fields.find {|f| f.name == name }
36
+ self.class.fields.index(field)
37
+ end
38
+
39
+ end
40
+
41
+ class Field
42
+ include Callbacks
43
+
44
+ attr_reader :name
45
+ define_callback :setup, :validate, :invalid, :success, :failure
46
+
47
+ def initialize(name, options, &block)
48
+ @name, @callbacks = name, {}
49
+ @options = options.reverse_merge(:attempts => 3)
50
+ @prompt_queue = []
51
+
52
+ instance_eval(&block)
53
+ raise(Larynx::NoPromptDefined, 'A field requires a prompt to be defined') if @prompt_queue.empty?
54
+ end
55
+
56
+ def prompt(options)
57
+ add_prompt(options)
58
+ end
59
+
60
+ def reprompt(options)
61
+ raise 'A reprompt can only be used after a prompt' if @prompt_queue.empty?
62
+ add_prompt(options)
63
+ end
64
+
65
+ def add_prompt(options)
66
+ repeats = options.delete(:repeats) || 1
67
+ options.merge!(@options.slice(:length, :min_length, :max_length))
68
+ @prompt_queue += ([options] * repeats)
69
+ end
70
+
71
+ def current_prompt
72
+ options = (@prompt_queue[@attempt-1] || @prompt_queue.last).dup
73
+ method = ([:play, :speak, :phrase] & options.keys).first
74
+ message = options[method].is_a?(Symbol) ? @app.send(options[method]) : options[method]
75
+ options[method] = message
76
+
77
+ Prompt.new(call, options) {|input|
78
+ set_instance_variables(input)
79
+ evaluate_input
80
+ }
81
+ end
82
+
83
+ def execute_prompt
84
+ call.execute current_prompt.command
85
+ end
86
+
87
+ def increment_attempts
88
+ @attempt += 1
89
+ end
90
+
91
+ def fire_callback(callback)
92
+ if block = @callbacks[callback]
93
+ @app.instance_eval(&block)
94
+ else
95
+ true
96
+ end
97
+ end
98
+
99
+ def valid?
100
+ @value.size >= minimum_length && fire_callback(:validate)
101
+ end
102
+
103
+ def evaluate_input
104
+ if valid?
105
+ fire_callback(:success)
106
+ else
107
+ fire_callback(:invalid)
108
+ if @attempt < @options[:attempts]
109
+ increment_attempts
110
+ execute_prompt
111
+ else
112
+ fire_callback(:failure)
113
+ end
114
+ end
115
+ end
116
+
117
+ def set_instance_variables(input)
118
+ @value = input
119
+ @app.send("#{@name}=", input)
120
+ end
121
+
122
+ def maximum_length
123
+ @options[:max_length] || @options[:length]
124
+ end
125
+
126
+ def minimum_length
127
+ @options[:min_length] || @options[:length] || 1
128
+ end
129
+
130
+ def run(app)
131
+ @app = app
132
+ @attempt = 1
133
+ fire_callback(:setup)
134
+ execute_prompt
135
+ end
136
+
137
+ def call
138
+ @app.call
139
+ end
140
+ end
141
+
142
+ end
143
+ end
@@ -0,0 +1,19 @@
1
+ module Larynx
2
+ class Form < Application
3
+ include Fields
4
+
5
+ def self.setup(&block)
6
+ @@setup = block
7
+ end
8
+
9
+ def run
10
+ instance_eval &@@setup if @@setup
11
+ next_field
12
+ end
13
+
14
+ def restart_form
15
+ @current_field = 0
16
+ run
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,8 @@
1
+ module Larynx
2
+ class Logger < ::Logger
3
+ def format_message(severity, timestamp, progname, msg)
4
+ time = timestamp.strftime("%Y-%m-%dT%H:%M:%S.") << "%06d" % timestamp.usec
5
+ "[%s] %5s: %s\n" % [time, severity, msg]
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,35 @@
1
+ module Larynx
2
+ module Observable
3
+
4
+ def add_observer(object)
5
+ @observers ||= []
6
+ @observers << object
7
+ end
8
+
9
+ def remove_observer(object)
10
+ @observers && @observers.delete(object)
11
+ end
12
+
13
+ def clear_observers!
14
+ @observers = []
15
+ end
16
+
17
+ # Like an observer stack which only notifies top observer
18
+ def notify_current_observer(event, data=nil)
19
+ return unless @observers
20
+ obs = @observers.last
21
+ if obs.respond_to?(event)
22
+ data ? obs.send(event, data) : obs.send(event)
23
+ end
24
+ end
25
+
26
+ def notify_observers(event, data=nil)
27
+ return unless @observers
28
+ @observers.each do |obs|
29
+ next unless obs.respond_to?(event)
30
+ data ? obs.send(event, data) : obs.send(event)
31
+ end
32
+ end
33
+
34
+ end
35
+ end
@@ -0,0 +1,88 @@
1
+ module Larynx
2
+ class NoPromptCommandValue < StandardError; end
3
+
4
+ # The prompt class neatly wraps up a convention where you prompt for input of
5
+ # certain length. The prompt waits until the required input length is reached,
6
+ # the user presses the terminator button or the time runs out. Think of the
7
+ # play_and_get_digits command except it works for speak as well. It also
8
+ # provides a bargein option to allow or prevent the user from interrupting
9
+ # the speech or playback.
10
+ #
11
+ # Pass a block to the method as a callback which receives input as an argument.
12
+ #
13
+ class Prompt
14
+ attr_reader :call
15
+
16
+ def initialize(call, options, &block)
17
+ @call, @options, @block = call, options, block
18
+ @options.reverse_merge!(:bargein => true, :timeout => 10, :interdigit_timeout => 3, :termchar => '#')
19
+ raise NoPromptCommandValue, "No output command value supplied. Use one of playback, speak or phrase keys." if command_name.blank?
20
+ end
21
+
22
+ def command
23
+ @command ||= AppCommand.new(command_name, message, :bargein => @options[:bargein]).
24
+ before { call.clear_input }.
25
+ after {
26
+ if prompt_finished?
27
+ finalise
28
+ else
29
+ call.add_observer self
30
+ add_digit_timer
31
+ add_input_timer
32
+ end
33
+ }
34
+ end
35
+
36
+ def input
37
+ (call.input.last == termchar ? call.input[0..-2] : call.input).join
38
+ end
39
+
40
+ def prompt_finished?
41
+ call.input.last == termchar || call.input.size == maximum_length
42
+ end
43
+
44
+ def termchar
45
+ @options[:termchar]
46
+ end
47
+
48
+ def maximum_length
49
+ @options[:max_length] || @options[:length]
50
+ end
51
+
52
+ def command_name
53
+ ([:play, :speak, :phrase] & @options.keys).first.to_s
54
+ end
55
+
56
+ def message
57
+ @options[command_name.to_sym]
58
+ end
59
+
60
+ def finalise
61
+ call.remove_observer self
62
+ @block.call(input)
63
+ end
64
+
65
+ def dtmf_received(digit)
66
+ if prompt_finished?
67
+ call.stop_timer(:input)
68
+ call.cancel_timer(:digit)
69
+ else
70
+ call.restart_timer(:digit)
71
+ end
72
+ end
73
+
74
+ def add_digit_timer
75
+ call.timer(:digit, @options[:interdigit_timeout]) {
76
+ call.cancel_timer :input
77
+ finalise
78
+ }
79
+ end
80
+
81
+ def add_input_timer
82
+ call.timer(:input, @options[:timeout]) {
83
+ call.cancel_timer :digit
84
+ finalise
85
+ }
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,57 @@
1
+ module Larynx
2
+ class Response
3
+ attr_reader :header, :body
4
+
5
+ def initialize(header, body)
6
+ @header = CallHandler.headers_2_hash(header)
7
+ if body
8
+ @body = body.match(/:/) ? CallHandler.headers_2_hash(body) : body
9
+ @body.each {|k,v| v.chomp! if v.is_a?(String)}
10
+ end
11
+ end
12
+
13
+ def reply?
14
+ @header[:content_type] == 'command/reply'
15
+ end
16
+
17
+ def event?
18
+ @header[:content_type] == 'text/event-plain'
19
+ end
20
+
21
+ def ok?
22
+ @header[:reply_text] =~ /\+OK/
23
+ end
24
+
25
+ def error?
26
+ @header[:reply_text] =~ /ERR/
27
+ end
28
+
29
+ def executing?
30
+ event_name == 'CHANNEL_EXECUTE'
31
+ end
32
+
33
+ def executed?
34
+ event_name == 'CHANNEL_EXECUTE_COMPLETE'
35
+ end
36
+
37
+ def command_name
38
+ @body[:application] if @body
39
+ end
40
+
41
+ def event_name
42
+ @body[:event_name] if @body
43
+ end
44
+
45
+ def dtmf?
46
+ @body[:event_name] == 'DTMF' if @body
47
+ end
48
+
49
+ def speech?
50
+ @body[:event_name] == 'DETECTED_SPEECH' if @body
51
+ end
52
+
53
+ def disconnect?
54
+ @header[:content_type] == 'text/disconnect-notice'
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,26 @@
1
+ module Larynx
2
+ # Adds restart to EM timer class. Implementation influenced by EM::PeriodicTimer class
3
+ # so hopefully it should not cause any issues.
4
+ class RestartableTimer < EM::Timer
5
+ def initialize(interval, callback=nil, &block)
6
+ @interval = interval
7
+ @code = callback || block
8
+ schedule
9
+ end
10
+
11
+ # Restart the timer
12
+ def restart
13
+ cancel
14
+ schedule
15
+ end
16
+
17
+ def schedule
18
+ @signature = EM::add_timer(@interval, method(:fire))
19
+ end
20
+
21
+ def fire
22
+ @code.call
23
+ end
24
+
25
+ end
26
+ end
@@ -0,0 +1,20 @@
1
+ module Larynx
2
+ class Session
3
+ attr_reader :variables
4
+
5
+ def initialize(data)
6
+ @variables = data
7
+ end
8
+
9
+ def method_missing(method, *args, &block)
10
+ if @variables.has_key?(method.to_sym)
11
+ @variables[method.to_sym]
12
+ end
13
+ end
14
+
15
+ def [](key)
16
+ @variables[key]
17
+ end
18
+
19
+ end
20
+ end
@@ -0,0 +1,3 @@
1
+ module Larynx
2
+ VERSION = '0.1.0'
3
+ end
data/lib/larynx.rb ADDED
@@ -0,0 +1,109 @@
1
+ require 'rubygems'
2
+ require 'eventmachine'
3
+ require 'active_support'
4
+ require 'logger'
5
+ require 'daemons/daemonize'
6
+
7
+ require 'larynx/version'
8
+ require 'larynx/logger'
9
+ require 'larynx/observable'
10
+ require 'larynx/callbacks'
11
+ require 'larynx/session'
12
+ require 'larynx/response'
13
+ require 'larynx/command'
14
+ require 'larynx/commands'
15
+ require 'larynx/prompt'
16
+ require 'larynx/application'
17
+ require 'larynx/fields'
18
+ require 'larynx/form'
19
+ require 'larynx/restartable_timer'
20
+ require 'larynx/call_handler'
21
+
22
+ module Larynx
23
+ class << self
24
+ include Callbacks
25
+
26
+ define_callback :connect, :answer, :hungup
27
+
28
+ def parse_options(args=ARGV)
29
+ @options = {
30
+ :ip => "0.0.0.0",
31
+ :port => 8084,
32
+ :pid_file => './larynx.pid',
33
+ :log_file => './larynx.log'
34
+ }
35
+ opts = OptionParser.new
36
+ opts.banner = "Usage: larynx [options]"
37
+ opts.separator ''
38
+ opts.separator "Larynx is a tool to develop FreeSWITCH IVR applications in Ruby."
39
+ opts.on('-i', '--ip IP', 'Listen for connections on this IP') {|ip| @options[:ip] = ip }
40
+ opts.on('-p', '--port PORT', 'Listen on this port', Integer) {|port| @options[:port] = port }
41
+ opts.on('-d', '--daemonize', 'Run as daemon') { @options[:daemonize] = true }
42
+ opts.on('-l', '--log-file FILE', 'Defaults to /app/root/larynx.log') {|log| @options[:log_file] = log }
43
+ opts.on( '--pid-file FILE', 'Defaults to /app/root/larynx.pid') {|pid| @options[:pid_file] = pid }
44
+ opts.on('-h', '--help', 'This is it') { $stderr.puts opts; exit 0 }
45
+ opts.on('-v', '--version') { $stderr.puts "Larynx version #{Larynx::VERSION}"; exit 0 }
46
+ opts.parse!(args)
47
+ end
48
+
49
+ def setup_logger
50
+ logger = Larynx::Logger.new(@options[:log_file])
51
+ logger.level = Logger::INFO
52
+ Object.const_set "LARYNX_LOGGER", logger
53
+ end
54
+
55
+ def graceful_exit
56
+ LARYNX_LOGGER.info "Shutting down Larynx"
57
+ EM.stop_server @em_signature
58
+ @em_signature = nil
59
+ remove_pid_file if @options[:daemonize]
60
+ exit 130
61
+ end
62
+
63
+ def daemonize
64
+ Daemonize.daemonize
65
+ Dir.chdir LARYNX_ROOT
66
+ File.open(@options[:pid_file], 'w+') {|f| f.write("#{Process.pid}\n") }
67
+ end
68
+
69
+ def remove_pid_file
70
+ File.delete @options[:pid_file]
71
+ end
72
+
73
+ def trap_signals
74
+ trap('TERM') { graceful_exit }
75
+ trap('INT') { graceful_exit }
76
+ end
77
+
78
+ def setup_app
79
+ if ARGV[0].nil?
80
+ $stderr.puts "You must specify an application file"
81
+ exit -1
82
+ end
83
+ Object.const_set "LARYNX_ROOT", File.expand_path(File.dirname(ARGV[0]))
84
+ require File.expand_path(ARGV[0])
85
+ end
86
+
87
+ def start_server
88
+ LARYNX_LOGGER.info "Larynx starting up on #{@options[:ip]}:#{@options[:port]}"
89
+ EM::run {
90
+ @em_signature = EM::start_server @options[:ip], @options[:port], Larynx::CallHandler
91
+ }
92
+ end
93
+
94
+ def run
95
+ parse_options(ARGV)
96
+ setup_app
97
+ daemonize if @options[:daemonize]
98
+ setup_logger
99
+ trap_signals
100
+ start_server
101
+ end
102
+
103
+ def running?
104
+ !@em_signature.nil?
105
+ end
106
+ end
107
+ end
108
+
109
+ Larynx.run unless defined?(TEST)