librevox 0.8 → 1.0.0.alpha1
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.
- checksums.yaml +5 -5
- data/README.md +266 -167
- data/lib/librevox/applications.rb +80 -67
- data/lib/librevox/client.rb +39 -0
- data/lib/librevox/command_socket.rb +12 -26
- data/lib/librevox/commands.rb +23 -23
- data/lib/librevox/listener/base.rb +56 -29
- data/lib/librevox/listener/inbound.rb +40 -15
- data/lib/librevox/listener/outbound.rb +40 -34
- data/lib/librevox/protocol/connection.rb +41 -0
- data/lib/librevox/protocol/response.rb +60 -0
- data/lib/librevox/runner.rb +37 -0
- data/lib/librevox/server.rb +33 -0
- data/lib/librevox/version.rb +5 -0
- data/lib/librevox.rb +35 -41
- metadata +66 -36
- data/Rakefile +0 -6
- data/TODO +0 -29
- data/lib/librevox/response.rb +0 -52
- data/librevox.gemspec +0 -37
- data/spec/helper.rb +0 -86
- data/spec/librevox/listener/spec_inbound.rb +0 -22
- data/spec/librevox/listener/spec_outbound.rb +0 -300
- data/spec/librevox/listener.rb +0 -142
- data/spec/librevox/spec_applications.rb +0 -238
- data/spec/librevox/spec_commands.rb +0 -103
- data/spec/librevox/spec_response.rb +0 -67
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6d47382ef56ccc15ea2aacf0e4bc5f4bf197fdb7338a25e7841a7f85a96ebe31
|
|
4
|
+
data.tar.gz: 86736dac97c19aaa298693bfe99c3f75a9b829777f37ea20d2932a1f5221dc3b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: efea41f41b6e92608d8dbe69035cbc537c7c1da77c15767c92aa1679633d57c840f26ac37a740f4df3a9ef24a24103bad9bdf2a1ed514bc8d73fd33e0a9576f9
|
|
7
|
+
data.tar.gz: 6a1502164b5df5387b6f1a12c65d379f3fb13caec7a27201c4aa97d1122a22ac667021991802f8158b53e3bd91d2b8b4a0f401709ebee9973331b960bfecff67
|
data/README.md
CHANGED
|
@@ -1,193 +1,292 @@
|
|
|
1
1
|
# Librevox
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
[
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
A Ruby library for interacting with [FreeSWITCH](http://www.freeswitch.org) through [mod_event_socket](https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Modules/mod_event_socket_1048924/), using async I/O.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Prerequisites](#prerequisites)
|
|
8
|
+
- [Installation](#installation)
|
|
9
|
+
- [Inbound Listener](#inbound-listener)
|
|
10
|
+
- [Events](#events)
|
|
11
|
+
- [Event Filtering](#event-filtering)
|
|
12
|
+
- [Outbound Listener](#outbound-listener)
|
|
13
|
+
- [Dialplan](#dialplan)
|
|
14
|
+
- [API Commands](#api-commands)
|
|
15
|
+
- [Starting Listeners](#starting-listeners)
|
|
16
|
+
- [Closing Connections](#closing-connections)
|
|
17
|
+
- [Command Socket](#command-socket)
|
|
18
|
+
- [Configuration](#configuration)
|
|
19
|
+
- [Event Socket Protocol](#event-socket-protocol)
|
|
20
|
+
- [Outbound session lifecycle](#outbound-session-lifecycle)
|
|
21
|
+
- [sendmsg and application execution](#sendmsg-and-application-execution)
|
|
22
|
+
- [event-lock](#event-lock)
|
|
23
|
+
- [Two fibers per connection](#two-fibers-per-connection)
|
|
24
|
+
- [API Documentation](#api-documentation)
|
|
25
|
+
- [License](#license)
|
|
11
26
|
|
|
12
27
|
## Prerequisites
|
|
13
28
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
[wiki page on mod_event_socket](http://wiki.freeswitch.org/wiki/Event_Socket) is
|
|
18
|
-
a good place to start.
|
|
29
|
+
You should be familiar with [mod_event_socket](https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Modules/mod_event_socket_1048924/) and the differences between inbound and outbound event sockets before getting started.
|
|
30
|
+
|
|
31
|
+
Requires Ruby 3.0+.
|
|
19
32
|
|
|
20
|
-
|
|
33
|
+
## Installation
|
|
21
34
|
|
|
22
|
-
|
|
35
|
+
Add to your Gemfile:
|
|
23
36
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
37
|
+
```ruby
|
|
38
|
+
gem "librevox"
|
|
39
|
+
```
|
|
27
40
|
|
|
28
|
-
|
|
41
|
+
## Inbound Listener
|
|
29
42
|
|
|
30
|
-
|
|
31
|
-
an event with the specified name arrives.
|
|
43
|
+
Subclass `Librevox::Listener::Inbound` to create an inbound listener. It connects to FreeSWITCH and subscribes to events.
|
|
32
44
|
|
|
33
|
-
|
|
45
|
+
### Events
|
|
34
46
|
|
|
35
|
-
|
|
36
|
-
techniques:
|
|
47
|
+
React to events in two ways:
|
|
37
48
|
|
|
38
|
-
|
|
49
|
+
1. Override `on_event`, called for every event.
|
|
50
|
+
2. Use `event` hooks for specific event names.
|
|
39
51
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
# You can add a hook for a certain event:
|
|
46
|
-
event :channel_hangup do
|
|
47
|
-
# It is instance_eval'ed, so you can use your instance methods etc:
|
|
48
|
-
do_something
|
|
49
|
-
end
|
|
52
|
+
```ruby
|
|
53
|
+
class MyInbound < Librevox::Listener::Inbound
|
|
54
|
+
def on_event(e)
|
|
55
|
+
puts "Got event: #{e.content[:event_name]}"
|
|
56
|
+
end
|
|
50
57
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
...
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def do_something
|
|
58
|
-
...
|
|
59
|
-
end
|
|
60
|
-
end
|
|
58
|
+
event :channel_hangup do
|
|
59
|
+
do_something
|
|
60
|
+
end
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
# The hook block receives a Response when it takes an argument:
|
|
63
|
+
event :channel_bridge do |e|
|
|
64
|
+
puts e.content[:caller_caller_id_number]
|
|
65
|
+
end
|
|
63
66
|
|
|
64
|
-
|
|
67
|
+
def do_something
|
|
68
|
+
# ...
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
```
|
|
65
72
|
|
|
66
|
-
###
|
|
73
|
+
### Event Filtering
|
|
74
|
+
|
|
75
|
+
By default, inbound listeners subscribe to all events. Use `events` to limit which events are received, and `filters` to filter by header values:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
class MyInbound < Librevox::Listener::Inbound
|
|
79
|
+
events ['CHANNEL_EXECUTE', 'CUSTOM foo']
|
|
80
|
+
filters 'Caller-Context' => ['default', 'example'],
|
|
81
|
+
'Caller-Privacy-Hide-Name' => 'no'
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Outbound Listener
|
|
86
|
+
|
|
87
|
+
Subclass `Librevox::Listener::Outbound` to create an outbound listener. FreeSWITCH connects to it when a call hits a socket application in the dialplan.
|
|
67
88
|
|
|
68
|
-
|
|
69
|
-
but it only receives events related to that given session.
|
|
89
|
+
Outbound listeners have the same event functionality as inbound, but scoped to the session.
|
|
70
90
|
|
|
71
91
|
### Dialplan
|
|
72
92
|
|
|
73
|
-
When
|
|
74
|
-
`session_initiated` is called. This is where you set up your dialplan:
|
|
75
|
-
|
|
76
|
-
def session_initiated
|
|
77
|
-
answer do
|
|
78
|
-
set "some_var", "some value" do
|
|
79
|
-
playback "path/to/file" do
|
|
80
|
-
hangup
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
All channel variables are available as a hash named `session`.
|
|
87
|
-
|
|
88
|
-
When using applications that expect a reply, such as `play_and_get_digits`,
|
|
89
|
-
you have to use callbacks to read the value, as the function itself returns
|
|
90
|
-
immediately due to the async nature of EventMachine:
|
|
91
|
-
|
|
92
|
-
def session_initiated
|
|
93
|
-
answer do
|
|
94
|
-
play_and_get_digits "enter-number.wav", "error.wav" do |digit|
|
|
95
|
-
puts "User pressed #{digit}"
|
|
96
|
-
playback "thanks-for-the-input.wav" do
|
|
97
|
-
hangup
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
You can also use the commands defined in `Librevox::Command`, which, to avoid
|
|
104
|
-
namespace clashes, are accessed through the `api` object:
|
|
105
|
-
|
|
106
|
-
def session_initiated
|
|
107
|
-
answer do
|
|
108
|
-
api.status
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
They can be used in conjunction with applications, and do also take a block,
|
|
113
|
-
passing the response to an eventual block argument.
|
|
114
|
-
|
|
115
|
-
## Starting listeners
|
|
116
|
-
|
|
117
|
-
To start a single listener, connection/listening on localhost on the default
|
|
118
|
-
port is quite simple:
|
|
119
|
-
|
|
120
|
-
Librevox.start SomeListener
|
|
121
|
-
|
|
122
|
-
it takes an optional hash with arguments:
|
|
123
|
-
|
|
124
|
-
Librevox.start SomeListener, :host => "1.2.3.4", :port => "8087", :auth => "pwd"
|
|
125
|
-
|
|
126
|
-
Multiple listeners can be started at once by passing a block to `Librevox.start`:
|
|
127
|
-
|
|
128
|
-
Librevox.start do
|
|
129
|
-
run SomeListener
|
|
130
|
-
run OtherListener, :port => "8080"
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
## Closing connection
|
|
134
|
-
|
|
135
|
-
After a session has finished, e.g. because the calling part hangs up, an
|
|
136
|
-
outbound socket still has its connection to FreeSWITCH open, so we can get post-
|
|
137
|
-
session events. Therefore it is important that you close the connection manually
|
|
138
|
-
when you are done. Otherwise you will have 'hanging' sessions, cloggering up
|
|
139
|
-
your system. This can safely be done with `close_connection_after_writing`,
|
|
140
|
-
which will wait for all outgoing data to be send before closing the connection.
|
|
141
|
-
It is aliased as `done` for convenience.
|
|
142
|
-
|
|
143
|
-
Unless you are doing something specific, closing the connection on CHANNEL_HANGUP
|
|
144
|
-
is most likely sufficient:
|
|
145
|
-
|
|
146
|
-
class MyListener < Librevox::Listener::Outbound
|
|
147
|
-
event :channel_hangup do
|
|
148
|
-
done
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
## Using `Librevox::CommandSocket`
|
|
153
|
-
|
|
154
|
-
Librevox also ships with a CommandSocket class, which allows you to connect
|
|
155
|
-
to the FreeSWITCH management console, from which you can originate calls,
|
|
156
|
-
restart FreeSWITCH etc.
|
|
157
|
-
|
|
158
|
-
>> require `librevox/command_socket`
|
|
159
|
-
=> true
|
|
160
|
-
|
|
161
|
-
>> socket = Librevox::CommandSocket.new
|
|
162
|
-
=> #<Librevox::CommandSocket:0xb7a89104 @server=“127.0.0.1”,
|
|
163
|
-
@socket=#<TCPSocket:0xb7a8908c>, @port=“8021”, @auth=“ClueCon”>
|
|
164
|
-
|
|
165
|
-
>> socket.originate('sofia/user/coltrane', :extension => "1234")
|
|
166
|
-
>> #<Librevox::Response:0x10179d388 @content="+OK de0ecbbe-e847...">
|
|
167
|
-
|
|
168
|
-
>> socket.status
|
|
169
|
-
>> > #<Librevox::Response:0x1016acac8 ...>
|
|
170
|
-
|
|
171
|
-
## Further documentation
|
|
172
|
-
|
|
173
|
-
All applications and commands are documented in the code. You can run
|
|
174
|
-
`yardoc` from the root of the source tree to generate YARD docs. Look under
|
|
175
|
-
the `Librevox::Commands` and `Librevox::Applications` modules.
|
|
176
|
-
|
|
177
|
-
## Extras
|
|
178
|
-
|
|
179
|
-
* Source: [http://github.com/vangberg/librevox](http://github.com/vangberg/librevox)
|
|
180
|
-
* API docs: [http://rdoc.info/projects/vangberg/librevox](http://rdoc.info/projects/vangberg/librevox)
|
|
181
|
-
* Mailing list: librevox@librelist.com
|
|
182
|
-
* IRC: #librevox @ irc.freenode.net
|
|
93
|
+
When FreeSWITCH connects, `session_initiated` is called. Build your dialplan here.
|
|
183
94
|
|
|
184
|
-
|
|
95
|
+
Each application call blocks until FreeSWITCH signals completion (`CHANNEL_EXECUTE_COMPLETE`), so applications execute sequentially:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
class MyOutbound < Librevox::Listener::Outbound
|
|
99
|
+
def session_initiated
|
|
100
|
+
answer
|
|
101
|
+
digit = play_and_get_digits "enter-digit.wav", "bad-digit.wav"
|
|
102
|
+
bridge "sofia/gateway/trunk/#{digit}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Applications that read input (like `play_and_get_digits` and `read`) return the collected value directly.
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
def session_initiated
|
|
111
|
+
answer
|
|
112
|
+
set "foo", "bar"
|
|
113
|
+
multiset "baz" => "1", "qux" => "2"
|
|
114
|
+
playback "welcome.wav"
|
|
115
|
+
hangup
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
For apps not yet wrapped by a named helper, call `application` directly:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
application "park"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Channel variables are available through `session` (a hash) and `variable`:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
def session_initiated
|
|
129
|
+
answer
|
|
130
|
+
number = variable(:destination_number)
|
|
131
|
+
playback "greeting-#{number}.wav"
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### API Commands
|
|
136
|
+
|
|
137
|
+
To avoid name clashes between applications and commands, commands are accessed through `api`:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
def session_initiated
|
|
141
|
+
answer
|
|
142
|
+
api.status
|
|
143
|
+
api.originate 'sofia/user/coltrane', extension: "1234"
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Starting Listeners
|
|
148
|
+
|
|
149
|
+
Start a single listener:
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
Librevox.start MyInbound
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
With connection options:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
Librevox.start MyInbound, host: "1.2.3.4", port: 8021, auth: "secret"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Start multiple listeners:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
Librevox.start do
|
|
165
|
+
run MyInbound
|
|
166
|
+
run MyOutbound, port: 8084
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Default ports are 8021 for inbound and 8084 for outbound.
|
|
171
|
+
|
|
172
|
+
## Closing Connections
|
|
173
|
+
|
|
174
|
+
After a session ends (e.g. the caller hangs up), the outbound socket connection to FreeSWITCH remains open for post-session events. Close it manually when done to avoid lingering sessions. Use `done` (alias for `close_connection_after_writing`):
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
class MyOutbound < Librevox::Listener::Outbound
|
|
178
|
+
event :channel_hangup do
|
|
179
|
+
done
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Command Socket
|
|
185
185
|
|
|
186
|
-
|
|
187
|
-
(c) 2011-2014 Firmafon ApS <info@firmafon.dk>
|
|
186
|
+
`Librevox::CommandSocket` connects to the FreeSWITCH management console for one-off commands:
|
|
188
187
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
188
|
+
```ruby
|
|
189
|
+
require "librevox/command_socket"
|
|
190
|
+
|
|
191
|
+
socket = Librevox::CommandSocket.new(server: "127.0.0.1", port: 8021, auth: "ClueCon")
|
|
192
|
+
|
|
193
|
+
socket.originate 'sofia/user/coltrane', extension: "1234"
|
|
194
|
+
#=> #<Librevox::Protocol::Response ...>
|
|
195
|
+
|
|
196
|
+
socket.status
|
|
197
|
+
#=> #<Librevox::Protocol::Response ...>
|
|
198
|
+
|
|
199
|
+
socket.close
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Configuration
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
Librevox.options[:log_file] = "librevox.log" # default: STDOUT
|
|
206
|
+
Librevox.options[:log_level] = Logger::DEBUG # default: Logger::INFO
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
When started with `Librevox.start`, sending `SIGHUP` to the process reopens the log file, making it compatible with `logrotate(1)`.
|
|
210
|
+
|
|
211
|
+
## Event Socket Protocol
|
|
212
|
+
|
|
213
|
+
Understanding the outbound event socket protocol is important for working on
|
|
214
|
+
librevox internals.
|
|
215
|
+
|
|
216
|
+
### Outbound session lifecycle
|
|
217
|
+
|
|
218
|
+
When FreeSWITCH hits a `socket` application in the dialplan, it connects to the
|
|
219
|
+
outbound listener. The listener sends three setup commands before any
|
|
220
|
+
application logic runs:
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
Listener → FS: connect
|
|
224
|
+
FS → Listener: (channel data — becomes @session)
|
|
225
|
+
|
|
226
|
+
Listener → FS: myevents
|
|
227
|
+
FS → Listener: command/reply +OK
|
|
228
|
+
|
|
229
|
+
Listener → FS: linger
|
|
230
|
+
FS → Listener: command/reply +OK → triggers session_initiated
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### sendmsg and application execution
|
|
234
|
+
|
|
235
|
+
When an application (e.g. `answer`, `playback`, `bridge`) is executed via
|
|
236
|
+
`sendmsg`, FreeSWITCH always sends the `command/reply +OK` immediately — it is
|
|
237
|
+
an acknowledgement that the sendmsg was received, **not** that the application
|
|
238
|
+
finished. Application completion is signalled by a `CHANNEL_EXECUTE_COMPLETE`
|
|
239
|
+
event:
|
|
240
|
+
|
|
241
|
+
```
|
|
242
|
+
Listener → FS: sendmsg
|
|
243
|
+
call-command: execute
|
|
244
|
+
execute-app-name: playback
|
|
245
|
+
execute-app-arg: welcome.wav
|
|
246
|
+
event-lock: true
|
|
247
|
+
|
|
248
|
+
FS → Listener: command/reply +OK ← immediate ack
|
|
249
|
+
FS → Listener: CHANNEL_EXECUTE event ← app started
|
|
250
|
+
...app is running...
|
|
251
|
+
FS → Listener: CHANNEL_EXECUTE_COMPLETE event ← app finished
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### event-lock
|
|
255
|
+
|
|
256
|
+
The `event-lock: true` header serializes application execution **on the
|
|
257
|
+
channel**. It does not change what is sent back on the socket.
|
|
258
|
+
|
|
259
|
+
Without `event-lock`, if multiple sendmsg commands are pipelined, FreeSWITCH
|
|
260
|
+
may dequeue and start executing the next application before the current one
|
|
261
|
+
finishes. With `event-lock: true`, FreeSWITCH sets an internal flag
|
|
262
|
+
(`CF_EVENT_LOCK`) on the channel that prevents the next queued sendmsg from
|
|
263
|
+
being processed until the current application completes.
|
|
264
|
+
|
|
265
|
+
### Two fibers per connection
|
|
266
|
+
|
|
267
|
+
Librevox runs two fibers for each outbound connection:
|
|
268
|
+
|
|
269
|
+
- **Session fiber** (`run_session`) — runs the setup sequence and then
|
|
270
|
+
`session_initiated`. Each `command` or `application` call blocks the fiber
|
|
271
|
+
until the reply arrives.
|
|
272
|
+
- **Read fiber** (`read_loop`) — reads messages from the socket and dispatches
|
|
273
|
+
them to `Async::Queue` instances, waking the session fiber.
|
|
274
|
+
|
|
275
|
+
An `Async::Semaphore(1)` mutex on `command` ensures only one command is
|
|
276
|
+
in-flight at a time, so replies are always delivered to the correct caller.
|
|
277
|
+
This also serializes commands issued by event hooks (which run in their own
|
|
278
|
+
fibers) with the main session flow.
|
|
279
|
+
|
|
280
|
+
## API Documentation
|
|
281
|
+
|
|
282
|
+
Applications and commands are documented with YARD. Generate docs with:
|
|
283
|
+
|
|
284
|
+
```
|
|
285
|
+
yard doc
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
See `Librevox::Applications` and `Librevox::Commands` for the full API reference.
|
|
289
|
+
|
|
290
|
+
## License
|
|
192
291
|
|
|
193
|
-
|
|
292
|
+
MIT. See `LICENSE` for details.
|