marvin 0.8.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. data/bin/marvin +33 -0
  2. data/handlers/debug_handler.rb +5 -0
  3. data/handlers/hello_world.rb +9 -0
  4. data/handlers/keiki_thwopper.rb +21 -0
  5. data/handlers/simple_logger.rb +24 -0
  6. data/handlers/tweet_tweet.rb +19 -0
  7. data/lib/marvin.rb +56 -0
  8. data/lib/marvin/abstract_client.rb +146 -0
  9. data/lib/marvin/abstract_parser.rb +29 -0
  10. data/lib/marvin/base.rb +195 -0
  11. data/lib/marvin/client/actions.rb +104 -0
  12. data/lib/marvin/client/default_handlers.rb +97 -0
  13. data/lib/marvin/command_handler.rb +91 -0
  14. data/lib/marvin/console.rb +50 -0
  15. data/lib/marvin/core_commands.rb +49 -0
  16. data/lib/marvin/distributed.rb +8 -0
  17. data/lib/marvin/distributed/client.rb +225 -0
  18. data/lib/marvin/distributed/handler.rb +85 -0
  19. data/lib/marvin/distributed/protocol.rb +88 -0
  20. data/lib/marvin/distributed/server.rb +154 -0
  21. data/lib/marvin/dsl.rb +103 -0
  22. data/lib/marvin/exception_tracker.rb +19 -0
  23. data/lib/marvin/exceptions.rb +11 -0
  24. data/lib/marvin/irc.rb +7 -0
  25. data/lib/marvin/irc/client.rb +168 -0
  26. data/lib/marvin/irc/event.rb +39 -0
  27. data/lib/marvin/irc/replies.rb +154 -0
  28. data/lib/marvin/logging_handler.rb +76 -0
  29. data/lib/marvin/middle_man.rb +103 -0
  30. data/lib/marvin/parsers.rb +9 -0
  31. data/lib/marvin/parsers/command.rb +107 -0
  32. data/lib/marvin/parsers/prefixes.rb +8 -0
  33. data/lib/marvin/parsers/prefixes/host_mask.rb +35 -0
  34. data/lib/marvin/parsers/prefixes/server.rb +24 -0
  35. data/lib/marvin/parsers/ragel_parser.rb +720 -0
  36. data/lib/marvin/parsers/ragel_parser.rl +143 -0
  37. data/lib/marvin/parsers/simple_parser.rb +35 -0
  38. data/lib/marvin/settings.rb +31 -0
  39. data/lib/marvin/test_client.rb +58 -0
  40. data/lib/marvin/util.rb +54 -0
  41. data/templates/boot.erb +3 -0
  42. data/templates/connections.yml.erb +10 -0
  43. data/templates/debug_handler.erb +5 -0
  44. data/templates/hello_world.erb +10 -0
  45. data/templates/rakefile.erb +15 -0
  46. data/templates/settings.yml.erb +8 -0
  47. data/templates/setup.erb +31 -0
  48. data/templates/test_helper.erb +17 -0
  49. data/test/abstract_client_test.rb +63 -0
  50. data/test/parser_comparison.rb +62 -0
  51. data/test/parser_test.rb +266 -0
  52. data/test/test_helper.rb +62 -0
  53. data/test/util_test.rb +57 -0
  54. metadata +136 -0
@@ -0,0 +1,104 @@
1
+ module Marvin
2
+ class AbstractClient
3
+
4
+ ## General IRC Functions / Actions
5
+
6
+ # Sends a specified command to the server.
7
+ # Takes name (e.g. :privmsg) and all of the args.
8
+ # Very simply formats them as a string correctly
9
+ # and calls send_data with the results.
10
+ def command(name, *args)
11
+ # First, get the appropriate command
12
+ name = name.to_s.upcase
13
+ args = args.flatten
14
+ args << util.last_param(args.pop)
15
+ send_line "#{name} #{args.compact.join(" ").strip}\r\n"
16
+ end
17
+
18
+ # Join one or more channels on the current server
19
+ # e.g.
20
+ # client.join "#marvin-testing"
21
+ # client.join ["#marvin-testing", "#rubyonrails"]
22
+ # client.join "#marvin-testing", "#rubyonrails"
23
+ def join(*channels_to_join)
24
+ channels_to_join = channels_to_join.flatten.map { |c| util.channel_name(c) }
25
+ # If you're joining multiple channels at once, we join them together
26
+ command :JOIN, channels_to_join.join(",")
27
+ channels_to_join.each { |channel| dispatch :outgoing_join, :target => channel }
28
+ logger.info "Sent JOIN for channels #{channels_to_join.join(", ")}"
29
+ end
30
+
31
+ # Parts a channel, with an optional reason
32
+ # e.g.
33
+ # part "#marvin-testing"
34
+ # part "#marvin-testing", "Ninjas stole by felafel"
35
+ def part(channel, reason = nil)
36
+ channel = util.channel_name(channel)
37
+ # Send the command anyway, even if we're not a
38
+ # a recorded member something might of happened.
39
+ command :part, channel, reason
40
+ if channels.include?(channel)
41
+ dispatch :outgoing_part, :target => channel, :reason => reason
42
+ logger.info "Parted channel #{channel} - #{reason.present? ? reason : "Non given"}"
43
+ else
44
+ logger.warn "Parted channel #{channel} but wasn't recorded as member of channel"
45
+ end
46
+ end
47
+
48
+ # Quites from a server, first parting all channels if a second
49
+ # argument is passed as true
50
+ # e.g.
51
+ # quit
52
+ # quit "Going to grab some z's"
53
+ def quit(reason = nil, part_before_quit = false)
54
+ @disconnect_expected = true
55
+ # If the user wants to part before quitting, they should
56
+ # pass a second, true, parameter
57
+ if part_before_quit
58
+ logger.info "Preparing to part from channels before quitting"
59
+ channels.to_a.each { |chan| part(chan, reason) }
60
+ logger.info "Parted from all channels, quitting"
61
+ end
62
+ command :quit, reason
63
+ dispatch :outgoing_quit
64
+ # Remove the connections from the pool
65
+ connections.delete(self)
66
+ logger.info "Quit from #{host_with_port}"
67
+ end
68
+
69
+ # Sends a message to a target (either a channel or a user)
70
+ # e.g.
71
+ # msg "#marvin-testing", "Hello there!"
72
+ # msg "SuttoL", "Hey, I'm playing with marvin!"
73
+ def msg(target, message)
74
+ command :privmsg, target, message
75
+ dispatch :outgoing_message, :target => target, :message => message
76
+ logger.info "Message #{target} - #{message}"
77
+ end
78
+
79
+ # Does a CTCP action in a channel (equiv. to doing /me in most IRC clients)
80
+ # e.g.
81
+ # action "#marvin-testing", "is about to sleep"
82
+ # action "SuttoL", "is about to sleep"
83
+ def action(target, message)
84
+ command :privmsg, target, "\01ACTION #{message.strip}\01"
85
+ dispatch :outgoing_action, :target => target, :message => message
86
+ logger.info "Action sent to #{target} - #{message}"
87
+ end
88
+
89
+ def pong(data)
90
+ command :pong, data
91
+ dispatch :outgoing_pong
92
+ logger.info "PONG sent to #{host_with_port} w/ data - #{data}"
93
+ end
94
+
95
+ def nick(new_nick)
96
+ logger.info "Changing nick to #{new_nick}"
97
+ command :nick, new_nick
98
+ @nickname = new_nick
99
+ dispatch :outgoing_nick, :new_nick => new_nick
100
+ logger.info "Nick changed to #{new_nick}"
101
+ end
102
+
103
+ end
104
+ end
@@ -0,0 +1,97 @@
1
+ module Marvin
2
+ class AbstractClient
3
+
4
+ # Default handlers
5
+
6
+ # The default handler for all things initialization-related
7
+ # on the client. Usually, this will send the user command,
8
+ # set out nick, join all of the channels / rooms we wish
9
+ # to be in and if a password is specified in the configuration,
10
+ # it will also attempt to identify us.
11
+ def handle_client_connected(opts = {})
12
+ logger.info "About to handle client connected"
13
+ # If the pass is set
14
+ unless pass.blank?
15
+ logger.info "Sending pass for connection"
16
+ command :pass, pass
17
+ end
18
+ # IRC Connection is establish so we send all the required commands to the server.
19
+ logger.info "Setting default nickname"
20
+ nick nicks.shift
21
+ logger.info "Sending user command"
22
+ command :user, configuration.user, "0", "*", configuration.name
23
+ rescue Exception => e
24
+ Marvin::ExceptionTracker.log(e)
25
+ end
26
+
27
+ # handle a bunch of default events that happen at a connection
28
+ # level instead of a per-app level.
29
+
30
+ # The default response for PING's - it simply replies
31
+ # with a PONG.
32
+ def handle_incoming_ping(opts = {})
33
+ logger.info "Received Incoming Ping - Handling with a PONG"
34
+ pong(opts[:data])
35
+ end
36
+
37
+ # TODO: Get the correct mapping for a given
38
+ # Code.
39
+ def handle_incoming_numeric(opts = {})
40
+ case opts[:code]
41
+ when Marvin::IRC::Replies[:RPL_WELCOME]
42
+ handle_welcome
43
+ when Marvin::IRC::Replies[:ERR_NICKNAMEINUSE]
44
+ handle_nick_taken
45
+ when Marvin::IRC::Replies[:RPL_TOPIC]
46
+ handle_channel_topic
47
+ end
48
+ code = opts[:code].to_i
49
+ args = Marvin::Util.arguments(opts[:data])
50
+ dispatch :incoming_numeric_processed, :code => code, :data => args
51
+ end
52
+
53
+ def handle_welcome
54
+ logger.info "Welcome received from server"
55
+ # If a password is specified, we will attempt to message
56
+ # NickServ to identify ourselves.
57
+ say ":IDENTIFY #{self.configuration.password}", "NickServ" if configuration.password.present?
58
+ # Join the default channels IF they're already set
59
+ # Note that Marvin::IRC::Client.connect will set them AFTER this stuff is run.
60
+ join default_channels
61
+ end
62
+
63
+ # The default handler for when a users nickname is taken on
64
+ # on the server. It will attempt to get the nicknickname from
65
+ # the nicknames part of the configuration (if available) and
66
+ # will then call #nick to change the nickname.
67
+ def handle_nick_taken
68
+ logger.info "Nickname '#{nickname}' on #{server} taken, trying next."
69
+ logger.info "Available Nicknames: #{nicks.empty? ? "None" : nicks.join(", ")}"
70
+ if !nicks.empty?
71
+ logger.info "Getting next nickname to switch"
72
+ next_nick = nicks.shift # Get the next nickname
73
+ logger.info "Attemping to set nickname to '#{next_nick}'"
74
+ nick next_nick
75
+ else
76
+ logger.fatal "No Nicknames available - QUITTING"
77
+ quit
78
+ end
79
+ end
80
+
81
+ # Only record joins when you've successfully joined the channel.
82
+ def handle_incoming_join(opts = {})
83
+ if opts[:nick] == @nickname
84
+ channels << opts[:target]
85
+ logger.info "Successfully joined channel #{opts[:target]}"
86
+ end
87
+ end
88
+
89
+ # Make sure we show user server errors
90
+ def handle_incoming_error(opts = {})
91
+ if opts[:message].present?
92
+ logger.info "Got ERROR Message: #{opts[:message]}"
93
+ end
94
+ end
95
+
96
+ end
97
+ end
@@ -0,0 +1,91 @@
1
+ require 'set'
2
+
3
+ module Marvin
4
+ class CommandHandler < Base
5
+
6
+ class_inheritable_accessor :command_prefix
7
+ self.command_prefix = ""
8
+
9
+ @@exposed_method_mapping = Hash.new { |h,k| h[k] = [] }
10
+ @@method_descriptions = Hash.new { |h,k| h[k] = {} }
11
+ @@registered_classes = Set.new
12
+
13
+ class << self
14
+
15
+ def command(name, method_desc = nil, &blk)
16
+ exposes name
17
+ desc method_desc unless method_desc.blank?
18
+ define_method(name, &blk)
19
+ end
20
+
21
+ def prefix_is(p)
22
+ self.command_prefix = p
23
+ end
24
+
25
+ def exposes(*args)
26
+ args.each { |name| @@exposed_method_mapping[self] << name.to_sym }
27
+ end
28
+
29
+ def exposed_methods
30
+ methods = []
31
+ klass = self
32
+ while klass != Object
33
+ methods += @@exposed_method_mapping[klass]
34
+ klass = klass.superclass
35
+ end
36
+ return methods.uniq.compact
37
+ end
38
+
39
+ def prefix_regexp
40
+ /^#{command_prefix}/
41
+ end
42
+
43
+ def desc(description)
44
+ @last_description = description
45
+ end
46
+
47
+ def exposed_name(method)
48
+ "#{command_prefix}#{method}"
49
+ end
50
+
51
+ end
52
+
53
+ on_event :incoming_message, :check_for_commands
54
+
55
+ def check_for_commands
56
+ message = options.message.to_s.strip
57
+ data, command = nil, nil
58
+ if from_channel?
59
+ name, command, data = message.split(/\s+/, 2)
60
+ return if name !~ /^#{client.nickname}:/i
61
+ else
62
+ command, data = message.splt(/\s+/, 2)
63
+ end
64
+ data ||= ""
65
+ if (command_name = extract_command_name(command)).present?
66
+ logger.info "Processing command '#{command_name}' for #{from}"
67
+ send(command_name, data.to_s) if respond_to?(command_name)
68
+ end
69
+ end
70
+
71
+ def extract_command_name(command)
72
+ re = self.class.prefix_regexp
73
+ if command =~ re
74
+ method_name = command.gsub(re, "").underscore.to_sym
75
+ return method_name if self.class.exposed_methods.include?(method_name)
76
+ end
77
+ end
78
+
79
+ def exposed_name(name)
80
+ self.class.exposed_name(name)
81
+ end
82
+
83
+ def self.method_added(name)
84
+ if @last_description.present?
85
+ @@method_descriptions[self][name.to_sym] = @last_description
86
+ @last_description = nil
87
+ end
88
+ end
89
+
90
+ end
91
+ end
@@ -0,0 +1,50 @@
1
+ require 'irb'
2
+
3
+ module Marvin
4
+ class Console
5
+
6
+ module BaseExtensions
7
+ def parse(line)
8
+ Marvin::Settings.parser.parse(line)
9
+ end
10
+
11
+ def logger
12
+ Marvin::Logger.logger
13
+ end
14
+
15
+ def client
16
+ $client ||= Marvin::Settings.client.new(:port => 6667, :server => "irc.freenode.net")
17
+ end
18
+
19
+ def user(reset = false)
20
+ unless @user_created || reset
21
+ server.receive_line "NICK SuttoL"
22
+ server.receive_line "USER SuttoL 0 * :SuttoL"
23
+ @user_created = true
24
+ end
25
+ return server.connection_implementation
26
+ end
27
+ end
28
+
29
+ def initialize(file = $0)
30
+ @file = file
31
+ setup_irb
32
+ end
33
+
34
+ def setup_irb
35
+ # This is a bit hacky, surely there is a better way?
36
+ # e.g. some way to specify which scope irb runs in.
37
+ eval("include Marvin::Console::BaseExtensions", TOPLEVEL_BINDING)
38
+ end
39
+
40
+ def run
41
+ ARGV.replace []
42
+ IRB.start
43
+ end
44
+
45
+ def self.run
46
+ self.new.run
47
+ end
48
+
49
+ end
50
+ end
@@ -0,0 +1,49 @@
1
+ module Marvin
2
+ class CoreCommands < CommandHandler
3
+
4
+ # Returns a hash of doccumented method names
5
+ def self.method_documentation
6
+ documented = Hash.new { |h,k| h[k] = [] }
7
+ @@method_descriptions.each_key do |klass|
8
+ next unless klass.registered?
9
+ @@exposed_method_mapping[klass].each do |m|
10
+ desc = @@method_descriptions[klass][m]
11
+ documented[m.to_s] << desc if desc.present?
12
+ end
13
+ end
14
+ return documented
15
+ end
16
+
17
+ def registered_and_exposed_handlers
18
+ end
19
+
20
+ exposes :help
21
+ desc "Generates this usage statement"
22
+ def help(method)
23
+ method = method.strip
24
+ documentation = self.class.method_documentation
25
+ names = documentation.keys.sort
26
+ if method.blank?
27
+ display_names = names.map { |n| exposed_name(n) }
28
+ width = display_names.map { |d| d.length }.max
29
+ say "Hello there, I know the following documented commands:"
30
+ names.each_with_index do |name, index|
31
+ say "#{display_names[index].ljust(width)} - #{documentation[name].join("; ")}"
32
+ end
33
+ else
34
+ if names.include? method
35
+ reply "#{exposed_name(method)} - #{documentation[method].join("; ")}"
36
+ else
37
+ reply "I'm sorry, I can't help with #{m} - it seems to be undocumented."
38
+ end
39
+ end
40
+ end
41
+
42
+ exposes :about
43
+ desc "Displays the current marvin and ruby versions."
44
+ def about(*args)
45
+ reply "Marvin v#{Marvin::VERSION} running on Ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})"
46
+ end
47
+
48
+ end
49
+ end
@@ -0,0 +1,8 @@
1
+ module Marvin
2
+ module Distributed
3
+ autoload :Protocol, 'marvin/distributed/protocol'
4
+ autoload :Server, 'marvin/distributed/server'
5
+ autoload :Handler, 'marvin/distributed/handler'
6
+ autoload :Client, 'marvin/distributed/client'
7
+ end
8
+ end
@@ -0,0 +1,225 @@
1
+ require 'json'
2
+ require 'digest/sha2'
3
+ require 'eventmachine'
4
+ require 'socket'
5
+
6
+ module Marvin
7
+ module Distributed
8
+ class Client < Marvin::AbstractClient
9
+
10
+ attr_accessor :em_connection, :remote_client_host, :remote_client_nick
11
+
12
+ class RemoteClientProxy
13
+ is :loggable
14
+
15
+ def initialize(conn, host_with_port, nickname)
16
+ @connection = conn
17
+ @host_with_port = host_with_port
18
+ @nickname = nickname
19
+ end
20
+
21
+ def nickname
22
+ @nickname
23
+ end
24
+
25
+ def host_with_port
26
+ @host_with_port
27
+ end
28
+
29
+ def method_missing(name, *args)
30
+ logger.debug "Proxying #{name}(#{args.inspect[1..-2]}) to #{@host_with_port}"
31
+ @connection.send_message(:action, {
32
+ "action" => name.to_s,
33
+ "arguments" => args,
34
+ "client-host" => @host_with_port
35
+ })
36
+ end
37
+
38
+ end
39
+
40
+ class EMConnection < Marvin::Distributed::Protocol
41
+
42
+ register_handler_method :event
43
+ register_handler_method :authentication_failed
44
+ register_handler_method :authenticated
45
+ register_handler_method :unauthorized
46
+
47
+ cattr_accessor :stopping
48
+ self.stopping = false
49
+
50
+ attr_accessor :client, :port, :connection_host, :connection_port, :configuration
51
+
52
+ def initialize(*args)
53
+ @configuration = args.last.is_a?(Marvin::Nash) ? args.pop : Marvin::Nash.new
54
+ super(*args)
55
+ @callbacks = {}
56
+ @client = Marvin::Distributed::Client.new(self)
57
+ @authenticated = false
58
+ end
59
+
60
+ def post_init
61
+ super
62
+ logger.info "Connected to distributed server"
63
+ if should_use_tls?
64
+ logger.info "Attempting to initialize tls"
65
+ start_tls
66
+ else
67
+ process_authentication
68
+ end
69
+ end
70
+
71
+ def ssl_handshake_completed
72
+ logger.info "tls handshake completed"
73
+ process_authentication if should_use_tls?
74
+ end
75
+
76
+
77
+ def unbind
78
+ if self.stopping
79
+ logger.info "Stopping distributed client"
80
+ else
81
+ logger.info "Lost connection to distributed client - Scheduling reconnect"
82
+ EventMachine.add_timer(15) { EMConnection.connect(connection_host, connection_port, @configuration) }
83
+ end
84
+ super
85
+ end
86
+
87
+ def process_authentication
88
+ if configuration.token?
89
+ logger.info "Attempting to authenticate..."
90
+ send_message(:authenticate, {:token => configuration.token})
91
+ end
92
+ end
93
+
94
+ def handle_event(options = {})
95
+ event = options["event-name"]
96
+ client_host = options["client-host"]
97
+ client_nick = options["client-nick"]
98
+ options = options["event-options"]
99
+ options = {} unless options.is_a?(Hash)
100
+ return if event.blank?
101
+ begin
102
+ logger.debug "Handling #{event}"
103
+ @client.remote_client_host = client_host
104
+ @client.remote_client_nick = client_nick
105
+ @client.setup_handlers
106
+ @client.dispatch(event.to_sym, options)
107
+ rescue Exception => e
108
+ logger.warn "Got Exception - Forwarding to Remote"
109
+ Marvin::ExceptionTracker.log(e)
110
+ send_message(:exception, {
111
+ "name" => e.class.name,
112
+ "message" => e.message,
113
+ "backtrace" => e.backtrace
114
+ })
115
+ ensure
116
+ logger.debug "Sending completed message"
117
+ send_message(:completed)
118
+ @client.reset!
119
+ end
120
+ end
121
+
122
+ def handle_unauthorized(options = {})
123
+ logger.warn "Attempted action when unauthorized. Stopping client."
124
+ Marvin::Distributed::Client.stop
125
+ end
126
+
127
+ def handle_authenticated(options = {})
128
+ @authenticated = true
129
+ logger.info "Successfully authenticated with #{host_with_port}"
130
+ end
131
+
132
+ def handle_authentication_failed(options = {})
133
+ logger.info "Authentication with #{host_with_port} failed. Stopping."
134
+ Marvin::Distributed::Client.stop
135
+ end
136
+
137
+ def self.connect(host, port, config = Marvin::Nash.new)
138
+ logger.info "Attempting to connect to #{host}:#{port}"
139
+ EventMachine.connect(host, port, self, config) do |c|
140
+ c.connection_host = host
141
+ c.connection_port = port
142
+ end
143
+ end
144
+
145
+ protected
146
+
147
+ def options_for_callback(blk)
148
+ return {} if blk.blank?
149
+ cb_id = "callback-#{seld.object_id}-#{Time.now.to_f}"
150
+ count = 0
151
+ count += 1 while @callbacks.has_key?(Digest::SHA256.hexdigest("#{cb_id}-#{count}"))
152
+ final_id = Digest::SHA256.hexdigest("#{cb_id}-#{count}")
153
+ @callbacks[final_id] = blk
154
+ {"callback-id" => final_id}
155
+ end
156
+
157
+ def process_callback(hash)
158
+ if hash.is_a?(Hash) && hash.has_key?("callback-id")
159
+ callback = @callbacks.delete(hash["callback-id"])
160
+ callback.call(self, hash)
161
+ end
162
+ end
163
+
164
+ def host_with_port
165
+ @host_with_port ||= begin
166
+ port, ip = Socket.unpack_sockaddr_in(get_peername)
167
+ "#{ip}:#{port}"
168
+ end
169
+ end
170
+
171
+ def should_use_tls?
172
+ @using_tls ||= configuration.encrypted?
173
+ end
174
+
175
+ end
176
+
177
+ def initialize(em_connection)
178
+ @em_connection = em_connection
179
+ end
180
+
181
+ def remote_client
182
+ @remote_client ||= RemoteClientProxy.new(@em_connection, @remote_client_host, @remote_client_nick)
183
+ end
184
+
185
+ def reset!
186
+ @remote_client = nil
187
+ @remote_client_nick = nil
188
+ @remote_client_host = nil
189
+ reset_handlers
190
+ end
191
+
192
+ def setup_handlers
193
+ self.class.handlers.each { |h| h.client = remote_client if h.respond_to?(:client=) }
194
+ end
195
+
196
+ def reset_handlers
197
+ self.class.handlers.each { |h| h.client = nil if h.respond_to?(:client=) }
198
+ end
199
+
200
+ class << self
201
+
202
+ def run
203
+ logger.info "Preparing to start distributed client"
204
+ EventMachine.kqueue
205
+ EventMachine.epoll
206
+ EventMachine.run do
207
+ opts = Marvin::Settings.distributed || Marvin::Nash.new
208
+ opts = opts.client || Marvin::Nash.new
209
+ host = opts.host || "0.0.0.0"
210
+ port = (opts.port || 8943).to_i
211
+ EMConnection.connect(host, port, opts)
212
+ end
213
+ end
214
+
215
+ def stop
216
+ logger.info "Stopping distributed client..."
217
+ EMConnection.stopping = true
218
+ EventMachine.stop_event_loop
219
+ end
220
+
221
+ end
222
+
223
+ end
224
+ end
225
+ end