freeswitch-esl-client 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,218 @@
1
+ module FreeswitchESL
2
+ module Outbound
3
+ # A live outbound call handed to us by FreeSWITCH.
4
+ #
5
+ # All the app methods are fire-and-forget: FreeSWITCH queues the commands
6
+ # on this channel and executes them in order, so
7
+ #
8
+ # session.answer
9
+ # session.play('ivr/ivr-welcome.wav')
10
+ # session.hangup
11
+ #
12
+ # behaves as expected. Register event handlers with #on if you need to
13
+ # react to the call (DTMF, bridge, hangup...).
14
+ class Session
15
+ # Events an outbound socket has to subscribe to explicitly; without the
16
+ # `event` command FreeSWITCH delivers nothing.
17
+ DEFAULT_EVENTS = %w[
18
+ CHANNEL_CREATE CHANNEL_ANSWER CHANNEL_BRIDGE CHANNEL_UNBRIDGE
19
+ CHANNEL_HANGUP CHANNEL_HANGUP_COMPLETE CHANNEL_DESTROY CHANNEL_STATE
20
+ CHANNEL_EXECUTE CHANNEL_EXECUTE_COMPLETE DTMF RECORD_STOP PLAYBACK_STOP
21
+ BACKGROUND_JOB CUSTOM
22
+ ].freeze
23
+
24
+ attr_reader :uuid, :caller_id_number, :caller_id_name, :destination_number,
25
+ :headers, :connection, :state, :hangup_cause
26
+
27
+ def initialize(conn, headers)
28
+ @connection = conn
29
+ @headers = headers || {}
30
+ @uuid = @headers['Unique-ID']
31
+ @caller_id_number = @headers['Caller-Caller-ID-Number'] || @headers['variable_caller_id_number']
32
+ @caller_id_name = @headers['Caller-Caller-ID-Name'] || @headers['variable_caller_id_name']
33
+ @destination_number = @headers['Caller-Destination-Number'] ||
34
+ @headers['variable_destination_number'] ||
35
+ @headers['Caller-ANI']
36
+ @state = @headers['Channel-State']
37
+ @context = @headers['Caller-Context']
38
+ @answered = false
39
+ @ended = false
40
+ @handlers = Hash.new { |h, k| h[k] = [] }
41
+ end
42
+
43
+ # ---------------------------------------------------------------- events
44
+
45
+ # session.on('CHANNEL_ANSWER') { |event| ... }
46
+ # session.on('DTMF') { |event| ... }
47
+ # session.on('*') { |event| ... }
48
+ def on(event_name, &block)
49
+ @handlers[event_name.to_s.upcase] << block
50
+ self
51
+ end
52
+
53
+ def handle_event(event, _raw_body = nil)
54
+ name = event['Event-Name'].to_s.upcase
55
+ @state = event['Channel-State'] if event['Channel-State']
56
+
57
+ case name
58
+ when 'CHANNEL_ANSWER'
59
+ @answered = true
60
+ when 'CHANNEL_HANGUP_COMPLETE'
61
+ @hangup_cause = event['Hangup-Cause']
62
+ @ended = true
63
+ when 'CHANNEL_DESTROY'
64
+ @ended = true
65
+ end
66
+
67
+ Array(@handlers[name]).each { |handler| safe_call(handler, event) }
68
+ Array(@handlers['*']).each { |handler| safe_call(handler, event) }
69
+ self
70
+ end
71
+
72
+ def safe_call(handler, event)
73
+ handler.call(event)
74
+ rescue StandardError => e
75
+ connection&.logger&.error "[FS-ESL OUTBOUND] handler error: #{e.class}: #{e.message}"
76
+ end
77
+
78
+ def answered?
79
+ @answered
80
+ end
81
+
82
+ def ended?
83
+ @ended
84
+ end
85
+
86
+ # --------------------------------------------------------------- commands
87
+
88
+ def answer
89
+ execute('answer')
90
+ @answered = true
91
+ self
92
+ end
93
+
94
+ def pre_answer
95
+ execute('pre_answer')
96
+ self
97
+ end
98
+
99
+ def ring_ready
100
+ execute('ring_ready')
101
+ self
102
+ end
103
+
104
+ def hangup(cause = 'NORMAL_CLEARING')
105
+ execute('hangup', cause)
106
+ self
107
+ end
108
+
109
+ def play(file)
110
+ execute('playback', file)
111
+ self
112
+ end
113
+
114
+ def play_and_get_digits(min = 1, max = 10, tries = 3, timeout = 3000, terminators = '#',
115
+ file = 'silence_stream://10', invalid_file = nil, var_name = 'digits',
116
+ digit_timeout = 3000)
117
+ args = [
118
+ min, max, tries, timeout, terminators, file,
119
+ invalid_file || 'silence_stream://150', var_name, digit_timeout
120
+ ].join(' ')
121
+ execute('play_and_get_digits', args)
122
+ self
123
+ end
124
+
125
+ def read(min = 1, max = 10, file = 'silence_stream://10', timeout = 3000,
126
+ terminators = '#', var_name = 'digits')
127
+ execute('read', [min, max, file, var_name, timeout, terminators].join(' '))
128
+ self
129
+ end
130
+
131
+ def record(file, limit = 3600, silence_thresh = 200, silence_hits = 5)
132
+ execute('record', [file, limit, silence_thresh, silence_hits].join(' '))
133
+ self
134
+ end
135
+ alias record_file record
136
+
137
+ def speak(text, engine: nil, voice: nil)
138
+ if engine && voice
139
+ execute('speak', "#{engine}|#{voice}|#{text}")
140
+ else
141
+ execute('speak', text)
142
+ end
143
+ self
144
+ end
145
+
146
+ def bridge(dialstring)
147
+ execute('bridge', dialstring)
148
+ self
149
+ end
150
+
151
+ def transfer(extension, dialplan: 'XML', context: 'default')
152
+ execute('transfer', "#{extension} #{dialplan} #{context}")
153
+ self
154
+ end
155
+
156
+ def park
157
+ execute('park')
158
+ self
159
+ end
160
+
161
+ def set(name, value)
162
+ execute('set', "#{name}=#{value}")
163
+ self
164
+ end
165
+
166
+ def export(name, value)
167
+ execute('export', "#{name}=#{value}")
168
+ self
169
+ end
170
+
171
+ def unset(name)
172
+ execute('unset', name)
173
+ self
174
+ end
175
+
176
+ def sleep(ms)
177
+ execute('sleep', ms.to_s)
178
+ self
179
+ end
180
+
181
+ def flush_dtmf
182
+ execute('flush_dtmf')
183
+ self
184
+ end
185
+
186
+ def send_dtmf(dtmf)
187
+ execute('send_dtmf', dtmf)
188
+ self
189
+ end
190
+
191
+ def execute(app, args = nil, &block)
192
+ connection.execute(app, args, &block)
193
+ self
194
+ end
195
+
196
+ def api(command, &block)
197
+ connection.api(command, &block)
198
+ end
199
+
200
+ def bgapi(command, &block)
201
+ connection.bgapi(command, &block)
202
+ end
203
+
204
+ def send_command(command)
205
+ connection.send_command(command)
206
+ end
207
+
208
+ # ---------------------------------------------------------------- helpers
209
+
210
+ def variable(name)
211
+ @headers["variable_#{name}"] || @headers[name]
212
+ end
213
+ alias [] variable
214
+
215
+ def close; end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,106 @@
1
+ module FreeswitchESL
2
+ # Parses ESL header blocks ("Key: value" lines) and reassembles complete
3
+ # frames from a byte stream.
4
+ #
5
+ # ESL frames are always:
6
+ #
7
+ # Key: value\n
8
+ # ...
9
+ # \n
10
+ # <body of Content-Length bytes>
11
+ #
12
+ # The body can itself contain blank lines (api/response output, event bodies),
13
+ # so splitting the stream on "\n\n" is not correct — Content-Length must be
14
+ # honoured.
15
+ class Parser
16
+ class << self
17
+ def parse_headers(block)
18
+ headers = {}
19
+ return headers if block.nil?
20
+
21
+ block.split(/\r?\n/).each do |line|
22
+ next if line.empty?
23
+ next unless line.include?(':')
24
+
25
+ key, value = line.split(':', 2)
26
+ headers[key.strip] = value.to_s.strip
27
+ end
28
+ headers
29
+ end
30
+
31
+ # Splits an event body into [headers, body]. FreeSWITCH events embed their
32
+ # own header block and, for BACKGROUND_JOB, the command output follows a
33
+ # blank line.
34
+ def parse_event(raw)
35
+ raw = raw.to_s
36
+ idx = raw.index("\n\n")
37
+ return [parse_headers(raw), ''] unless idx
38
+
39
+ [parse_headers(raw[0...idx]), raw[(idx + 2)..] || '']
40
+ end
41
+
42
+ # Backwards compatible helper: parse a single complete frame string.
43
+ def parse_frame(frame)
44
+ frame = frame.to_s
45
+ idx = frame.index("\n\n")
46
+ if idx
47
+ headers = parse_headers(frame[0...idx])
48
+ body = frame[(idx + 2)..] || ''
49
+ else
50
+ headers = parse_headers(frame)
51
+ body = ''
52
+ end
53
+
54
+ {
55
+ headers: headers,
56
+ body: body,
57
+ content_type: headers['Content-Type'] || headers['content-type']
58
+ }
59
+ end
60
+ end
61
+ end
62
+
63
+ # Incremental ESL frame reassembler.
64
+ #
65
+ # framer = FreeswitchESL::Framer.new
66
+ # framer << data
67
+ # framer.each_frame { |frame| ... }
68
+ class Framer
69
+ def initialize
70
+ @buffer = +''.b
71
+ end
72
+
73
+ def <<(data)
74
+ @buffer << data.to_s.b
75
+ self
76
+ end
77
+
78
+ def each_frame
79
+ loop do
80
+ header_end = @buffer.index("\n\n")
81
+ break unless header_end
82
+
83
+ headers = Parser.parse_headers(@buffer.byteslice(0, header_end))
84
+ body_start = header_end + 2
85
+ length = headers['Content-Length'].to_i
86
+ length = 0 if length.negative?
87
+
88
+ if length.zero?
89
+ @buffer = @buffer.byteslice(body_start..) || +''.b
90
+ yield({ headers: headers, body: +'', content_type: headers['Content-Type'] })
91
+ next
92
+ end
93
+
94
+ break if @buffer.bytesize < body_start + length
95
+
96
+ body = @buffer.byteslice(body_start, length)
97
+ @buffer = @buffer.byteslice((body_start + length)..) || +''.b
98
+ yield({ headers: headers, body: body, content_type: headers['Content-Type'] })
99
+ end
100
+ end
101
+
102
+ def empty?
103
+ @buffer.empty?
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,61 @@
1
+ module FreeswitchESL
2
+ class Response
3
+ attr_reader :headers, :body, :raw
4
+
5
+ def initialize(headers = {}, body = '', raw = '')
6
+ @headers = headers || {}
7
+ @body = body || ''
8
+ @raw = raw
9
+ end
10
+
11
+ # bgapi responses carry the command output in the body and have no
12
+ # Reply-Text header, so both are taken into account.
13
+ def success?
14
+ reply = reply_text.to_s
15
+ return false if reply.start_with?('-')
16
+ return true if reply.start_with?('+OK')
17
+
18
+ !body.to_s.lstrip.start_with?('-')
19
+ end
20
+
21
+ def error?
22
+ !success?
23
+ end
24
+
25
+ def reply_text
26
+ @headers['Reply-Text'] || @headers['reply-text'] || first_body_line
27
+ end
28
+
29
+ def job_uuid
30
+ @headers['Job-UUID'] || @headers['job-uuid']
31
+ end
32
+
33
+ def event_name
34
+ @headers['Event-Name']
35
+ end
36
+
37
+ # UUID returned by `originate` (`+OK <uuid>`) — nil for other commands.
38
+ def uuid
39
+ reply_text.to_s[/\+OK\s+([0-9a-f-]{36})/, 1]
40
+ end
41
+
42
+ def [](key)
43
+ @headers[key]
44
+ end
45
+
46
+ # The command output (alias of #body, reads better for api/bgapi).
47
+ def output
48
+ body
49
+ end
50
+
51
+ def to_s
52
+ body.to_s
53
+ end
54
+
55
+ private
56
+
57
+ def first_body_line
58
+ body.to_s.lines.first.to_s.strip
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,3 @@
1
+ module FreeswitchESL
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,4 @@
1
+ # The gem is published as `freeswitch-esl-client` (the name `freeswitch-esl` was
2
+ # already taken on RubyGems), while the library itself is still called
3
+ # `freeswitch-esl`. Both require paths load the same code.
4
+ require 'freeswitch-esl'
@@ -0,0 +1,25 @@
1
+ require 'freeswitch-esl/version'
2
+ require 'freeswitch-esl/errors'
3
+ require 'freeswitch-esl/parser'
4
+ require 'freeswitch-esl/response'
5
+ require 'freeswitch-esl/connection'
6
+ require 'freeswitch-esl/client'
7
+ require 'freeswitch-esl/outbound/connection'
8
+ require 'freeswitch-esl/outbound/session'
9
+ require 'freeswitch-esl/outbound/server'
10
+
11
+ module FreeswitchESL
12
+ class << self
13
+ def new(**options)
14
+ Client.new(**options)
15
+ end
16
+
17
+ def outbound_server(**options, &block)
18
+ Outbound::Server.new(**options, &block)
19
+ end
20
+
21
+ def start_outbound_server(**options, &block)
22
+ Outbound::Server.new(**options, &block).start
23
+ end
24
+ end
25
+ end
metadata ADDED
@@ -0,0 +1,128 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: freeswitch-esl-client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Michael
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: eventmachine
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.2'
19
+ - - ">="
20
+ - !ruby/object:Gem::Version
21
+ version: 1.2.7
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - "~>"
27
+ - !ruby/object:Gem::Version
28
+ version: '1.2'
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: 1.2.7
32
+ - !ruby/object:Gem::Dependency
33
+ name: logger
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '1.6'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '1.6'
46
+ - !ruby/object:Gem::Dependency
47
+ name: rake
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '13.0'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '13.0'
60
+ - !ruby/object:Gem::Dependency
61
+ name: rspec
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '3.0'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '3.0'
74
+ description: |
75
+ EventMachine-based client for the FreeSWITCH Event Socket Layer (mod_event_socket).
76
+ Supports inbound mode (connect to port 8021, subscribe to events, run api/bgapi,
77
+ auto-reconnect) and outbound mode (an embedded TCP server that FreeSWITCH dials into
78
+ with the socket application, so call control lives in Ruby instead of dialplan XML).
79
+ Includes a binary-safe ESL frame parser, an originate helper covering FreeSWITCH
80
+ variable scopes and positional arguments, and per-channel outbound sessions.
81
+ email:
82
+ - michael@example.com
83
+ executables: []
84
+ extensions: []
85
+ extra_rdoc_files: []
86
+ files:
87
+ - CHANGELOG.md
88
+ - LICENSE
89
+ - README.md
90
+ - freeswitch-esl-client.gemspec
91
+ - lib/freeswitch-esl-client.rb
92
+ - lib/freeswitch-esl.rb
93
+ - lib/freeswitch-esl/client.rb
94
+ - lib/freeswitch-esl/connection.rb
95
+ - lib/freeswitch-esl/errors.rb
96
+ - lib/freeswitch-esl/outbound/connection.rb
97
+ - lib/freeswitch-esl/outbound/server.rb
98
+ - lib/freeswitch-esl/outbound/session.rb
99
+ - lib/freeswitch-esl/parser.rb
100
+ - lib/freeswitch-esl/response.rb
101
+ - lib/freeswitch-esl/version.rb
102
+ homepage: https://github.com/miclast/freeswitch-esl-client
103
+ licenses:
104
+ - MIT
105
+ metadata:
106
+ homepage_uri: https://github.com/miclast/freeswitch-esl-client
107
+ changelog_uri: https://github.com/miclast/freeswitch-esl-client/blob/main/CHANGELOG.md
108
+ bug_tracker_uri: https://github.com/miclast/freeswitch-esl-client/issues
109
+ documentation_uri: https://www.rubydoc.info/gems/freeswitch-esl-client
110
+ rubygems_mfa_required: 'true'
111
+ rdoc_options: []
112
+ require_paths:
113
+ - lib
114
+ required_ruby_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '3.2'
119
+ required_rubygems_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ requirements: []
125
+ rubygems_version: 4.0.23
126
+ specification_version: 4
127
+ summary: Ruby client for the FreeSWITCH Event Socket Layer (inbound and outbound).
128
+ test_files: []