freeswitch-esl 0.1.0 → 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.
@@ -4,7 +4,8 @@ require "timeout"
4
4
 
5
5
  require "freeswitch/esl/protocol/event"
6
6
  require "freeswitch/esl/protocol/message"
7
- require "freeswitch/esl/connection/message_reader"
7
+ require "freeswitch/esl/connection/command_dispatcher"
8
+ require "freeswitch/esl/connection/command_request"
8
9
  require "freeswitch/esl/connection/event_dispatcher"
9
10
 
10
11
  module Freeswitch
@@ -15,77 +16,71 @@ module Freeswitch
15
16
  # {#initialize_socket} to start the reader and event-dispatcher threads.
16
17
  #
17
18
  # Threading model:
18
- # * A *reader thread* reads messages from the socket and routes them:
19
- # - command/api replies → @response_queue (consumed by the calling thread)
20
- # - events → @event_queue (consumed by the dispatcher thread)
19
+ # * A reader thread owned by {CommandDispatcher} reads messages from the
20
+ # socket and routes them to pending command executions (FIFO) or to the
21
+ # event dispatcher.
21
22
  # * A *dispatcher thread* processes @event_queue and calls registered handlers.
22
- # * Command execution is serialised by @send_mutex so that each send+receive
23
- # pair is atomic and responses are never mixed up.
24
- # * On timeout the connection is closed because the protocol state is unknown.
23
+ # * Command enqueue + write is atomic under a mutex in CommandDispatcher,
24
+ # guaranteeing queue order == socket write order.
25
25
  class Connection
26
- MSG_TERMINATOR = "\n\n"
27
- DEFAULT_TIMEOUT = 5
26
+ include Freeswitch::ESL::Logger
28
27
 
29
28
  def initialize(socket)
30
29
  initialize_socket(socket)
31
30
  end
32
31
 
33
- # Send a raw ESL command and return the {Message} reply.
34
- def send_command(command, timeout: DEFAULT_TIMEOUT)
32
+ # Send a raw ESL command and return a CommandRequest object that can be used to wait for the response.
33
+ def send_command(command, timeout: nil)
35
34
  raise DisconnectedError, "Connection is closed" if closed?
36
35
 
37
- # Keep write+read together so one thread cannot read another thread's
38
- # reply by mistake.
39
- @send_mutex.synchronize do
40
- @socket.write("#{command}#{MSG_TERMINATOR}")
41
- receive_response(timeout: timeout)
42
- end
43
- end
44
-
45
- # Execute a synchronous +api+ command. Returns the {Message} with the
46
- # result in {Message#body}.
47
- def api(command, args = nil, timeout: DEFAULT_TIMEOUT)
48
- parts = ["api", command, args].compact
49
- @send_mutex.synchronize do
50
- @socket.write("#{parts.join(' ')}#{MSG_TERMINATOR}")
51
- receive_response(timeout: timeout)
52
- end
36
+ @command_dispatcher.execute_command(command, timeout:)
53
37
  end
54
38
 
55
39
  # Execute a background +bgapi+ command. Returns the Job-UUID string.
56
40
  # The optional block is called with the BACKGROUND_JOB {Event} when the
57
41
  # result arrives.
58
- def bgapi(command, args = nil, timeout: DEFAULT_TIMEOUT, &block)
59
- parts = ["bgapi", command, args].compact
60
- job_uuid = @send_mutex.synchronize do
61
- @socket.write("#{parts.join(' ')}#{MSG_TERMINATOR}")
62
- reply = receive_response(timeout: timeout)
63
- reply["Job-UUID"]
64
- end
42
+ def bgapi(command, *args, timeout: nil, &block)
43
+ raise DisconnectedError, "Connection is closed" if closed?
44
+
45
+ parts = ["bgapi", command, *args].compact
46
+ logger.debug("[BGAPI] #{parts.join(' ')}")
47
+ cmd = @command_dispatcher.execute_command(parts.join(" "), timeout:).wait
48
+ job_uuid = cmd.response["Job-UUID"]
49
+ raise CommandError, "FreeSWITCH bgapi did not return a Job-UUID" unless job_uuid
65
50
 
66
- @event_dispatcher.register_bgapi_handler(job_uuid, block) if block && job_uuid
51
+ @event_dispatcher.register_bgapi_handler(job_uuid, block) if block
67
52
  job_uuid
68
53
  end
69
54
 
55
+ # Return the number of commands currently waiting for a response.
56
+ def pending_commands_count
57
+ @command_dispatcher.pending_commands_count
58
+ end
59
+
60
+ # Return an array of Job-UUID strings for all pending bgapi commands.
61
+ def pending_bgapi_command_uuids
62
+ @event_dispatcher.pending_bgapi_command_uuids
63
+ end
64
+
70
65
  # Subscribe to one or more event names (JSON format). Pass no arguments
71
66
  # or +"ALL"+ to receive every event.
72
67
  def subscribe(*event_names)
73
68
  events = event_names.empty? ? "ALL" : event_names.join(" ")
74
- send_command("event json #{events}")
69
+ send_command("event json #{events}").wait
75
70
  end
76
71
 
77
72
  # Cancel subscriptions. Without arguments cancels all events.
78
73
  def unsubscribe(*event_names)
79
74
  if event_names.empty?
80
- send_command("noevents")
75
+ send_command("noevents").wait
81
76
  else
82
- event_names.each { |e| send_command("nixevent #{e}") }
77
+ event_names.each { |e| send_command("nixevent #{e}").wait }
83
78
  end
84
79
  end
85
80
 
86
81
  # Add an event-header filter so FreeSWITCH only sends matching events.
87
82
  def filter(header, value)
88
- send_command("filter #{header} #{value}")
83
+ send_command("filter #{header} #{value}").wait
89
84
  end
90
85
 
91
86
  # Register a handler block for an event name. Use +"ALL"+ to handle
@@ -100,27 +95,27 @@ module Freeswitch
100
95
  @closed
101
96
  end
102
97
 
98
+ # Stop dispatchers and mark the connection closed. Event handlers are
99
+ # removed together with the dispatcher instances, so a reconnect path must
100
+ # rebuild them through {#initialize_socket}.
103
101
  def close
104
102
  return if @closed
105
103
 
106
104
  @closed = true
107
- begin
108
- @socket.close
109
- rescue StandardError
110
- nil
111
- end
112
- @message_reader&.stop
105
+ @command_dispatcher&.stop
113
106
  @event_dispatcher&.stop
107
+
108
+ send(:remove_instance_variable, :@command_dispatcher)
109
+ send(:remove_instance_variable, :@event_dispatcher)
114
110
  end
115
111
 
116
112
  protected
117
113
 
118
114
  # (Re-)initialise all per-connection state for the given socket.
119
115
  # Called by subclasses on initial connect and on reconnect.
120
- def initialize_socket(socket)
121
- @socket = socket
122
- @send_mutex = Mutex.new
123
- @closed = false
116
+ def initialize_socket(socket, debug: false)
117
+ @socket = socket
118
+ @closed = false
124
119
 
125
120
  # Keep dispatcher data across reconnects so existing handlers still work
126
121
  # after a new socket is created.
@@ -129,38 +124,21 @@ module Freeswitch
129
124
  @event_dispatcher.start
130
125
  end
131
126
 
132
- # Always create new MessageReader for each socket
133
- @message_reader = MessageReader.new(@socket, @event_dispatcher) { |error| on_disconnect(error) }
134
- @message_reader.start
135
- end
136
-
137
- # Authenticate against FreeSWITCH after the initial auth/request message.
138
- def authenticate!(password, timeout: DEFAULT_TIMEOUT)
139
- auth_request = receive_response(timeout: timeout)
140
- unless auth_request.content_type == "auth/request"
141
- raise AuthenticationError, "Expected auth/request, got: #{auth_request.content_type}"
142
- end
143
-
144
- # `auth` is a basic ESL command with a command/reply response, so we
145
- # use send_command (not api/bgapi).
146
- reply = send_command("auth #{password}", timeout: timeout)
147
- return if reply.successful?
148
-
149
- raise AuthenticationError, "Authentication failed: #{reply.reply_text}"
127
+ # Always create a new CommandDispatcher for each socket
128
+ @command_dispatcher = CommandDispatcher.new(@socket, @event_dispatcher, debug:)
129
+ @command_dispatcher.on_disconnect { |error| on_disconnect(error) }
130
+ @command_dispatcher.start
150
131
  end
151
132
 
152
- def receive_response(timeout:)
153
- @message_reader.receive_response(timeout: timeout)
154
- rescue TimeoutError
155
- # If a request times out, the next reply might belong to the old
156
- # command, so we close and start from a clean connection.
157
- close
158
- raise
133
+ # Wait for the initial auth/request bootstrap message before sending any
134
+ # commands that assume FreeSWITCH is ready to accept authenticated input.
135
+ def wait_for_auth_request(timeout: nil)
136
+ @command_dispatcher.wait_for_auth_request(timeout:)
159
137
  end
160
138
 
161
139
  private
162
140
 
163
- # Called by MessageReader when socket is disconnected or closed.
141
+ # Called by the command dispatcher when the current socket disconnects.
164
142
  def on_disconnect(_error)
165
143
  @closed = true
166
144
  end
@@ -13,7 +13,9 @@ module Freeswitch
13
13
  def logger
14
14
  return @logger if defined?(@logger) && @logger
15
15
 
16
- @logger = Freeswitch::ESL.configuration.logger
16
+ @logger = Freeswitch::ESL.configuration.logger.dup
17
+ @logger.progname = self.class.name
18
+ @logger
17
19
  end
18
20
  end
19
21
  end
@@ -7,36 +7,155 @@ module Freeswitch
7
7
  module Protocol
8
8
  # Represents a FreeSWITCH event received in JSON format.
9
9
  class Event
10
+ ERROR_BODY_PREFIX = /\A\s*-(ERR|USAGE)\b/
11
+
10
12
  attr_reader :data
11
13
 
12
14
  def initialize(raw_json)
13
15
  @data = JSON.parse(raw_json).freeze
14
16
  end
15
17
 
18
+ # Returns the raw value stored in the event payload for the given key.
19
+ # @return [Object, nil] Parsed JSON value for the requested field.
16
20
  def [](key)
17
21
  data[key]
18
22
  end
19
23
 
24
+ # Returns the canonical FreeSWITCH event name.
25
+ # @return [String, nil] Event name such as CHANNEL_CREATE, HEARTBEAT or BACKGROUND_JOB.
20
26
  def name
21
27
  data["Event-Name"]
22
28
  end
23
29
 
30
+ # Returns the UUID of the emitting FreeSWITCH core instance.
31
+ # @return [String, nil] Core identifier shared across events generated by the same node.
32
+ def core_uuid
33
+ data["Core-UUID"]
34
+ end
35
+
36
+ # Returns the hostname reported by the FreeSWITCH node.
37
+ # @return [String, nil] Hostname of the server that emitted the event.
38
+ def hostname
39
+ data["FreeSWITCH-Hostname"]
40
+ end
41
+
42
+ # Returns the logical FreeSWITCH switch name.
43
+ # @return [String, nil] Switch name configured on the emitting node.
44
+ def switchname
45
+ data["FreeSWITCH-Switchname"]
46
+ end
47
+
48
+ # Returns the IPv4 address advertised by FreeSWITCH.
49
+ # @return [String, nil] IPv4 address of the emitting node.
50
+ def ipv4
51
+ data["FreeSWITCH-IPv4"]
52
+ end
53
+
54
+ # Returns the IPv6 address advertised by FreeSWITCH.
55
+ # @return [String, nil] IPv6 address of the emitting node.
56
+ def ipv6
57
+ data["FreeSWITCH-IPv6"]
58
+ end
59
+
60
+ # Returns the event subclass when the event provides one.
61
+ # @return [String, nil] Module-specific subclass, often used by CUSTOM events.
24
62
  def subclass
25
63
  data["Event-Subclass"]
26
64
  end
27
65
 
66
+ # Returns the event timestamp formatted in the server local timezone.
67
+ # @return [String, nil] Human-readable local date and time string.
68
+ def date_local
69
+ data["Event-Date-Local"]
70
+ end
71
+
72
+ # Returns the event timestamp formatted in GMT.
73
+ # @return [String, nil] Human-readable GMT date and time string.
74
+ def date_gmt
75
+ data["Event-Date-GMT"]
76
+ end
77
+
78
+ # Returns the raw FreeSWITCH event timestamp.
79
+ # @return [String, nil] Epoch timestamp expressed in microseconds.
80
+ def timestamp
81
+ data["Event-Date-Timestamp"]
82
+ end
83
+
84
+ # Returns the FreeSWITCH source file that emitted the event.
85
+ # @return [String, nil] Internal source filename from the FreeSWITCH runtime.
86
+ def calling_file
87
+ data["Event-Calling-File"]
88
+ end
89
+
90
+ # Returns the FreeSWITCH source function that emitted the event.
91
+ # @return [String, nil] Internal function name from the FreeSWITCH runtime.
92
+ def calling_function
93
+ data["Event-Calling-Function"]
94
+ end
95
+
96
+ # Returns the FreeSWITCH source line number that emitted the event.
97
+ # @return [String, nil] Internal line number from the FreeSWITCH runtime.
98
+ def calling_line_number
99
+ data["Event-Calling-Line-Number"]
100
+ end
101
+
102
+ # Returns the monotonic event sequence assigned by FreeSWITCH.
103
+ # @return [String, nil] Sequence number as exposed by the event payload.
104
+ def sequence
105
+ data["Event-Sequence"]
106
+ end
107
+
108
+ # Returns the channel UUID associated with the event.
109
+ # @return [String, nil] Session identifier when the event refers to a channel.
28
110
  def uuid
29
111
  data["Unique-ID"]
30
112
  end
31
113
 
114
+ # Returns the UUID assigned to a background job.
115
+ # @return [String, nil] Job identifier for BACKGROUND_JOB events.
32
116
  def job_uuid
33
117
  data["Job-UUID"]
34
118
  end
35
119
 
120
+ # Returns the command executed by the background job.
121
+ # @return [String, nil] Original command name reported by a BACKGROUND_JOB event.
122
+ def job_command
123
+ data["Job-Command"]
124
+ end
125
+
126
+ # Returns the command executed by a synchronous API event.
127
+ # @return [String, nil] API command name reported by an API event.
128
+ def api_command
129
+ data["API-Command"]
130
+ end
131
+
132
+ # Returns the human-readable informational text attached to the event.
133
+ # @return [String, nil] Summary text such as the HEARTBEAT status message.
134
+ def info
135
+ data["Event-Info"]
136
+ end
137
+
138
+ # Returns the optional event body payload.
139
+ # @return [String, nil] Body content, commonly used for api/bgapi command output.
36
140
  def body
37
141
  data["_body"]
38
142
  end
39
143
 
144
+ # Returns whether the event represents a bgapi job completion.
145
+ # @return [Boolean] True when the event name is BACKGROUND_JOB.
146
+ def bg_job_event?
147
+ name == "BACKGROUND_JOB"
148
+ end
149
+
150
+ # Returns whether the event body represents an ESL command error.
151
+ # @return [Boolean] True for bodies starting with -ERR or -USAGE,
152
+ # false for non-error bodies, false when the body is missing.
153
+ def error?
154
+ body&.match?(ERROR_BODY_PREFIX) || false
155
+ end
156
+
157
+ # Returns the value of a channel variable exported in the event payload.
158
+ # @return [String, nil] Value stored under the corresponding variable_* event field.
40
159
  def channel_variable(name)
41
160
  data["variable_#{name}"]
42
161
  end
@@ -24,14 +24,19 @@ module Freeswitch
24
24
  headers["Reply-Text"]
25
25
  end
26
26
 
27
+ # Return true when the message payload represents a successful ESL reply.
28
+ # For +api/response+ messages the success marker lives in the body; for
29
+ # command replies it lives in the Reply-Text header.
27
30
  def successful?
28
31
  if content_type == "api/response"
29
- body&.start_with?("+OK")
32
+ body&.start_with?("+OK") || false
30
33
  else
31
- reply_text&.start_with?("+OK")
34
+ reply_text&.start_with?("+OK") || false
32
35
  end
33
36
  end
34
37
 
38
+ # Extract the FreeSWITCH error text without the leading +-ERR+ marker.
39
+ # Returns +nil+ for successful replies or when no error payload is present.
35
40
  def error_message
36
41
  text = content_type == "api/response" ? body : reply_text
37
42
  text&.start_with?("-ERR") ? text.sub(/\A-ERR\s*/, "").strip : nil
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Freeswitch
4
4
  module ESL
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "logger"
4
+ require "ztimer"
4
5
 
5
6
  require "freeswitch/esl/version"
6
7
  require "freeswitch/esl/errors"
@@ -9,6 +10,7 @@ require "freeswitch/esl/configuration"
9
10
  require "freeswitch/esl/protocol/message"
10
11
  require "freeswitch/esl/protocol/event"
11
12
  require "freeswitch/esl/connection"
13
+ require "freeswitch/esl/command"
12
14
  require "freeswitch/esl/client"
13
15
 
14
16
  module Freeswitch
@@ -22,7 +24,7 @@ module Freeswitch
22
24
  end
23
25
 
24
26
  def configuration
25
- @configuration ||= Configuration.build
27
+ @configuration ||= Configuration.new
26
28
  end
27
29
 
28
30
  def reset!
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: freeswitch-esl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Demetra Opinioni.net Srl
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-05 00:00:00.000000000 Z
11
+ date: 2026-06-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: configatron
@@ -38,6 +38,62 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '1.6'
41
+ - !ruby/object:Gem::Dependency
42
+ name: ztimer
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: csv
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.3'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.3'
69
+ - !ruby/object:Gem::Dependency
70
+ name: fiddle
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rdoc
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '7.2'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '7.2'
41
97
  - !ruby/object:Gem::Dependency
42
98
  name: rspec
43
99
  requirement: !ruby/object:Gem::Requirement
@@ -108,10 +164,12 @@ files:
108
164
  - lib/freeswitch-esl.rb
109
165
  - lib/freeswitch/esl.rb
110
166
  - lib/freeswitch/esl/client.rb
167
+ - lib/freeswitch/esl/command.rb
111
168
  - lib/freeswitch/esl/configuration.rb
112
169
  - lib/freeswitch/esl/connection.rb
170
+ - lib/freeswitch/esl/connection/command_dispatcher.rb
171
+ - lib/freeswitch/esl/connection/command_request.rb
113
172
  - lib/freeswitch/esl/connection/event_dispatcher.rb
114
- - lib/freeswitch/esl/connection/message_reader.rb
115
173
  - lib/freeswitch/esl/errors.rb
116
174
  - lib/freeswitch/esl/logger.rb
117
175
  - lib/freeswitch/esl/protocol/event.rb
@@ -1,109 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "timeout"
4
-
5
- module Freeswitch
6
- module ESL
7
- class Connection
8
- # Reads ESL protocol messages from the socket and routes them appropriately.
9
- # Runs in a dedicated thread and handles socket parsing and message dispatch.
10
- class MessageReader
11
- def initialize(socket, event_dispatcher, &on_disconnect_callback)
12
- @socket = socket
13
- @event_dispatcher = event_dispatcher
14
- @on_disconnect_callback = on_disconnect_callback
15
- @response_queue = Queue.new
16
- @reader_thread = nil
17
- end
18
-
19
- # Get the response queue (where command replies are enqueued).
20
- attr_reader :response_queue
21
-
22
- # Start the reader thread.
23
- def start
24
- return if @reader_thread
25
-
26
- @reader_thread = Thread.new do
27
- loop do
28
- msg = read_message
29
- unless msg
30
- on_disconnect(DisconnectedError.new("Connection closed by remote host"))
31
- break
32
- end
33
-
34
- route_message(msg)
35
- rescue IOError, Errno::ECONNRESET, Errno::ENOTCONN => e
36
- on_disconnect(DisconnectedError.new(e.message))
37
- break
38
- end
39
- end
40
- @reader_thread.name = "esl-reader"
41
- @reader_thread.abort_on_exception = false
42
- end
43
-
44
- # Stop the reader thread and join it.
45
- def stop
46
- # The thread exits when socket reads hit EOF/error. `stop` is only a
47
- # synchronisation point to wait for clean shutdown.
48
- @reader_thread&.join
49
- end
50
-
51
- # Wait for and return a response from the response queue with timeout.
52
- def receive_response(timeout:)
53
- result = nil
54
- Timeout.timeout(timeout) { result = @response_queue.pop }
55
- raise result if result.is_a?(Exception)
56
-
57
- result
58
- rescue Timeout::Error
59
- raise TimeoutError, "Command timed out after #{timeout}s — connection closed"
60
- end
61
-
62
- # Enqueue an error to unblock waiting threads (e.g., on disconnect).
63
- def enqueue_error(error)
64
- @response_queue << error
65
- end
66
-
67
- private
68
-
69
- def read_message
70
- headers = {}
71
-
72
- loop do
73
- line = @socket.gets("\n")
74
- return nil unless line
75
-
76
- line = line.chomp
77
- break if line.empty?
78
-
79
- key, value = line.split(": ", 2)
80
- headers[key] = value
81
- end
82
-
83
- return nil if headers.empty?
84
-
85
- body = @socket.read(headers["Content-Length"].to_i) if headers.key?("Content-Length")
86
- Protocol::Message.new(headers, body)
87
- end
88
-
89
- def route_message(message)
90
- # Unknown content types are ignored on purpose.
91
- case message.content_type
92
- when "auth/request", "command/reply", "api/response"
93
- @response_queue << message
94
- when "text/event-json"
95
- @event_dispatcher.enqueue_event(Protocol::Event.new(message.body))
96
- when "text/disconnect-notice"
97
- on_disconnect(DisconnectedError.new("FreeSWITCH sent disconnect notice"))
98
- end
99
- end
100
-
101
- def on_disconnect(error)
102
- # First wake up waiting command calls, then notify upper layers.
103
- @response_queue << error # unblock any waiting receive_response
104
- @on_disconnect_callback&.call(error)
105
- end
106
- end
107
- end
108
- end
109
- end