em-irc 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +2 -1
- data/.rvmrc +1 -1
- data/.travis.yml +6 -2
- data/Gemfile +5 -1
- data/Guardfile +8 -1
- data/README.md +33 -25
- data/Rakefile +9 -1
- data/lib/em-irc.rb +5 -0
- data/lib/em-irc/client.rb +62 -98
- data/lib/em-irc/commands.rb +358 -0
- data/lib/em-irc/dispatcher.rb +2 -6
- data/lib/em-irc/responses.rb +88 -0
- data/lib/em-irc/version.rb +1 -1
- data/lib/support/dsl_accessor.rb +22 -0
- data/spec/integration/integration_spec.rb +31 -11
- data/spec/lib/em-irc/client_spec.rb +44 -36
- data/spec/lib/em-irc/commands_spec.rb +166 -0
- data/spec/lib/em-irc/dispatcher_spec.rb +16 -16
- data/spec/lib/em-irc/responses_spec.rb +46 -0
- metadata +13 -6
@@ -0,0 +1,358 @@
|
|
1
|
+
module EventMachine
|
2
|
+
module IRC
|
3
|
+
# Client commands
|
4
|
+
# @see http://tools.ietf.org/html/rfc2812 RFC 2812
|
5
|
+
module Commands
|
6
|
+
# Set connection password
|
7
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.1.1 3.1.1 Password message
|
8
|
+
def pass(password)
|
9
|
+
send_data("PASS #{password}")
|
10
|
+
end
|
11
|
+
|
12
|
+
# Set/get user nick
|
13
|
+
# @return [String] nick if no param
|
14
|
+
# @return nil otherwise
|
15
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.1.2 3.1.2 Nick Message
|
16
|
+
def nick(nick = nil)
|
17
|
+
if nick
|
18
|
+
send_data("NICK #{nick}")
|
19
|
+
else
|
20
|
+
@nick
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# Set username, hostname, and realname
|
25
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.1.3 3.1.3 User Message
|
26
|
+
def user(username, mode, realname)
|
27
|
+
send_data("USER #{username} #{mode} * :#{realname}")
|
28
|
+
end
|
29
|
+
|
30
|
+
# Gain operator privledges
|
31
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.1.4 3.1.4 Oper Message
|
32
|
+
def oper(name, password)
|
33
|
+
send_data("OPER #{name} #{password}")
|
34
|
+
end
|
35
|
+
|
36
|
+
# Set user mode
|
37
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.1.5 3.1.5 Mode Message
|
38
|
+
def mode(nickname, setting)
|
39
|
+
raise NotImplementedError.new
|
40
|
+
end
|
41
|
+
|
42
|
+
# Register a new service
|
43
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.1.6 3.1.6 Service Message
|
44
|
+
def service(nickname, reserved, distribution, type)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Terminate connection
|
48
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.1.7 3.1.7 Quit
|
49
|
+
def quit(message = 'leaving')
|
50
|
+
send_data("QUIT :#{message}")
|
51
|
+
end
|
52
|
+
|
53
|
+
# Disconnect server links
|
54
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.1.8 3.1.8 Squit
|
55
|
+
def squit(server, message = "quiting")
|
56
|
+
raise NotImplementedError.new
|
57
|
+
end
|
58
|
+
|
59
|
+
# Join a channel
|
60
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.2.1 3.2.1 Join message
|
61
|
+
# @example
|
62
|
+
# client.join("#general")
|
63
|
+
# client.join("#general", "fubar") # join #general with fubar key
|
64
|
+
# client.join(['#general', 'fubar'], "#foo") # join multiple channels
|
65
|
+
def join(*args)
|
66
|
+
raise ArgumentError.new("Not enough arguments") unless args.size > 0
|
67
|
+
channels, keys = [], []
|
68
|
+
args.map! {|arg| arg.is_a?(Array) ? arg : [arg, '']}
|
69
|
+
args.sort! {|a,b| b[1].length <=> a[1].length} # key channels first
|
70
|
+
args.each {|arg|
|
71
|
+
channels << arg[0]
|
72
|
+
keys << arg[1] if arg[1].length > 0
|
73
|
+
}
|
74
|
+
send_data("JOIN #{channels.join(',')} #{keys.join(',')}".strip)
|
75
|
+
end
|
76
|
+
|
77
|
+
# Part all channels
|
78
|
+
def part_all
|
79
|
+
join('0')
|
80
|
+
end
|
81
|
+
|
82
|
+
# Leave a channel
|
83
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.2.2 3.2.2 Part message
|
84
|
+
# @example
|
85
|
+
# client.part('#general')
|
86
|
+
# client.part('#general', '#foo')
|
87
|
+
# client.part('#general', 'Bye all!')
|
88
|
+
# client.part('#general', '#foo', 'Bye all!')
|
89
|
+
def part(*args)
|
90
|
+
raise ArgumentError.new("Not enough arguments") unless args.size > 0
|
91
|
+
message = channel?(args.last) ? "Leaving..." : args.pop
|
92
|
+
send_data("PART #{args.join(',')} :#{message}")
|
93
|
+
end
|
94
|
+
|
95
|
+
# Set channel mode
|
96
|
+
# @todo name conflict with user MODE message
|
97
|
+
def channel_mode
|
98
|
+
raise NotImplementedError.new
|
99
|
+
end
|
100
|
+
|
101
|
+
# Set/get topic
|
102
|
+
# @param topic [Mixed] String, nil
|
103
|
+
# non-blank string sets the topic
|
104
|
+
# blank string unsets the topic
|
105
|
+
# nil returns the current topic (default)
|
106
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.2.4 3.2.4 Topic message
|
107
|
+
def topic(channel, message = nil)
|
108
|
+
message = message.nil? ? "" : ":#{message}"
|
109
|
+
send_data("TOPIC #{channel} #{message}".strip)
|
110
|
+
end
|
111
|
+
|
112
|
+
# List all nicknames visible to user
|
113
|
+
# @param args [Multiple] list of channels to list nicks
|
114
|
+
# if last argument is a hash, then :target can request
|
115
|
+
# which server generates response
|
116
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.2.5 3.2.5 Names message
|
117
|
+
def names(*args)
|
118
|
+
options = args.extract_options!
|
119
|
+
send_data("NAMES #{args.join(',')} #{options[:target]}".strip)
|
120
|
+
end
|
121
|
+
|
122
|
+
# List channels and topics
|
123
|
+
# @param args [Multiple] list of channels
|
124
|
+
# if last argument is a hash, then :target can request
|
125
|
+
# which server generates response
|
126
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.2.6 3.2.6 List message
|
127
|
+
def list(*args)
|
128
|
+
options = args.extract_options!
|
129
|
+
send_data("LIST #{args.join(',')} #{options[:target]}".strip)
|
130
|
+
end
|
131
|
+
|
132
|
+
|
133
|
+
# Invite a user to a channel
|
134
|
+
# @param nickname [String] to invite
|
135
|
+
# @param channel [String] to invite to
|
136
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.2.7 3.2.7 Invite message
|
137
|
+
def invite(nickname, channel)
|
138
|
+
send_data("INVITE #{nickname} #{channel}")
|
139
|
+
end
|
140
|
+
|
141
|
+
# Kick a user from a channel
|
142
|
+
# @param args [Multiple]
|
143
|
+
# @example
|
144
|
+
# client.kick('#general', 'jch')
|
145
|
+
# client.kick('#general', '&bar', 'jch', 'wcc')
|
146
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.2.8 3.2.8 Kick message
|
147
|
+
def kick(*args)
|
148
|
+
channels = args.select {|arg| channel?(arg)}
|
149
|
+
nicks = args.select {|arg| !channel?(arg)}
|
150
|
+
raise ArgumentError.new("Missing channels") if channels.empty?
|
151
|
+
send_data("KICK #{channels.join(',')} #{nicks.join(',')}".strip)
|
152
|
+
end
|
153
|
+
|
154
|
+
# Send message to user or channel
|
155
|
+
# @param target [String] nick or channel name
|
156
|
+
# @param message [String]
|
157
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.3.1 3.3.1 Private message
|
158
|
+
def privmsg(target, message)
|
159
|
+
send_data("PRIVMSG #{target} :#{message}")
|
160
|
+
end
|
161
|
+
alias_method :message, :privmsg
|
162
|
+
|
163
|
+
# Send message to user or channel
|
164
|
+
# @param target [String] nick or channel name
|
165
|
+
# @param message [String]
|
166
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.3.2 3.3.2 Notice message
|
167
|
+
def notice(target, message)
|
168
|
+
send_data("NOTICE #{target} :#{message}")
|
169
|
+
end
|
170
|
+
|
171
|
+
# Get message of the day for a server or current server
|
172
|
+
# @param target [String] server name or current server if nil
|
173
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.4.1 3.4.1 Motd message
|
174
|
+
def motd(target = nil)
|
175
|
+
send_data("MOTD #{target}".strip)
|
176
|
+
end
|
177
|
+
|
178
|
+
# List users
|
179
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.4.2 3.4.2 Lusers message
|
180
|
+
def lusers(mask = nil, target = nil)
|
181
|
+
send_data("LUSERS #{mask} #{target}".strip)
|
182
|
+
end
|
183
|
+
|
184
|
+
# Get server version
|
185
|
+
# @param target [String] server or current server if nil
|
186
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.4.3 3.4.3 Version message
|
187
|
+
def version(target = nil)
|
188
|
+
send_data("VERSION #{target}".strip)
|
189
|
+
end
|
190
|
+
|
191
|
+
# Get stats for a server
|
192
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.4.4 3.4.4 Stats message
|
193
|
+
def stats(query = nil, target = nil)
|
194
|
+
send_data("STATS #{query} #{target}".strip)
|
195
|
+
end
|
196
|
+
|
197
|
+
# List all servernames
|
198
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.4.5 3.4.5 Links message
|
199
|
+
def links(remote_server = nil, server_mask = nil)
|
200
|
+
send_data("LINKS #{remote_server} #{server_mask}".strip)
|
201
|
+
end
|
202
|
+
|
203
|
+
# Get server local time
|
204
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.4.6 3.4.6 Time message
|
205
|
+
def time(target = nil)
|
206
|
+
send_data("TIME #{target}".strip)
|
207
|
+
end
|
208
|
+
|
209
|
+
# Connect to another server
|
210
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.4.7 3.4.7 Connect message
|
211
|
+
def server_connect(target, port, remote = nil)
|
212
|
+
send_data("CONNECT #{target} #{port} #{remote}".strip)
|
213
|
+
end
|
214
|
+
|
215
|
+
# Find the route to a specific server
|
216
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.4.8 3.4.8 Trace message
|
217
|
+
def trace(target = nil)
|
218
|
+
send_data("TRACE #{target}".strip)
|
219
|
+
end
|
220
|
+
|
221
|
+
# Find info about admin of a given server
|
222
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.4.9 3.4.9 Admin message
|
223
|
+
def admin(target = nil)
|
224
|
+
send_data("ADMIN #{target}".strip)
|
225
|
+
end
|
226
|
+
|
227
|
+
# Describe server information
|
228
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.4.10 3.4.10 Info message
|
229
|
+
def info(target = nil)
|
230
|
+
send_data("INFO #{target}".strip)
|
231
|
+
end
|
232
|
+
|
233
|
+
# List services connected to network
|
234
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.5.1 3.5.1 Servlist message
|
235
|
+
def servlist(mask = nil, type = nil)
|
236
|
+
send_data("SERVLIST #{mask} #{type}".strip)
|
237
|
+
end
|
238
|
+
alias_method :server_list, :servlist
|
239
|
+
|
240
|
+
# Send a message to a service
|
241
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.5.2 3.5.2 Squery message
|
242
|
+
def squery(service, text)
|
243
|
+
send_data("SQUERY #{service} :#{text}")
|
244
|
+
end
|
245
|
+
|
246
|
+
# Get info about a user
|
247
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.6.1 3.6.1 Who message
|
248
|
+
def who(mask, mode = 'o')
|
249
|
+
send_data("WHO #{mask} #{mode}")
|
250
|
+
end
|
251
|
+
|
252
|
+
# Get user information about a list of users
|
253
|
+
# @param args [Multiple]
|
254
|
+
# if last arg is a hash, :target specifies which server to query
|
255
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.6.2 3.6.2 Whois message
|
256
|
+
def whois(*args)
|
257
|
+
options = args.extract_options!
|
258
|
+
target = options[:target] ? "#{options[:target]} " : ''
|
259
|
+
send_data("WHOIS #{target}#{args.join(',')}")
|
260
|
+
end
|
261
|
+
|
262
|
+
# Get user information that no longer exists (nick changed, etc)
|
263
|
+
# @param args [Multiple] list of nicknames
|
264
|
+
# @param options [Hash]
|
265
|
+
# @option options [Integer] :count number of history entries to list
|
266
|
+
# @option options [Integer] :target
|
267
|
+
# @example
|
268
|
+
# client.whowas('jch')
|
269
|
+
# client.whowas('jch', 'foo')
|
270
|
+
# client.whowas('jch', 'foo', :count => 5, :target => 'irc.net')
|
271
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.6.3 3.6.3 Whowas message
|
272
|
+
def whowas(*args)
|
273
|
+
options = args.extract_options!
|
274
|
+
send_data("WHOWAS #{args.join(',')} #{options[:count]} #{options[:target]}".strip)
|
275
|
+
end
|
276
|
+
|
277
|
+
# Terminate a connection by nickname
|
278
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.7.1 3.7.1 Kill message
|
279
|
+
def kill(nickname, comment = "Connection killed")
|
280
|
+
send_data("KILL #{nickname} :#{comment}".strip)
|
281
|
+
end
|
282
|
+
|
283
|
+
# Test connection is alive
|
284
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.7.2 3.7.2 Ping message
|
285
|
+
def ping(server, target = '')
|
286
|
+
send_data("PING #{server} #{target}".strip)
|
287
|
+
end
|
288
|
+
|
289
|
+
# Respond to a server ping
|
290
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.7.3 3.7.3 Pong message
|
291
|
+
def pong(*servers)
|
292
|
+
send_data("PONG #{servers.join(' ')}")
|
293
|
+
end
|
294
|
+
|
295
|
+
# Server serious or fatal error, or to terminate a connection on quit
|
296
|
+
# @see http://tools.ietf.org/html/rfc2812#section-3.7.4 3.7.4 Error message
|
297
|
+
def error(message)
|
298
|
+
send_data("ERROR :#{message}")
|
299
|
+
end
|
300
|
+
|
301
|
+
# Set user as away with optional message
|
302
|
+
# @see http://tools.ietf.org/html/rfc2812#section-4.1 4.1 Away
|
303
|
+
def away(message = nil)
|
304
|
+
send_data("AWAY" + (message ? ":#{message}" : ""))
|
305
|
+
end
|
306
|
+
|
307
|
+
# Force user to re-read config
|
308
|
+
# @see http://tools.ietf.org/html/rfc2812#section-4.2 4.2 Rehash message
|
309
|
+
def rehash
|
310
|
+
send_data("REHASH")
|
311
|
+
end
|
312
|
+
|
313
|
+
# Shutdown server
|
314
|
+
# @see http://tools.ietf.org/html/rfc2812#section-4.3 4.3 Die message
|
315
|
+
def die
|
316
|
+
send_data("DIE")
|
317
|
+
end
|
318
|
+
|
319
|
+
# Restart server
|
320
|
+
# @see http://tools.ietf.org/html/rfc2812#section-4.4 4.4 Restart
|
321
|
+
def restart
|
322
|
+
send_data("RESTART")
|
323
|
+
end
|
324
|
+
|
325
|
+
# Ask user to join IRC
|
326
|
+
# @see http://tools.ietf.org/html/rfc2812#section-4.5 4.5 Summon
|
327
|
+
def summon(user, target = nil, channel = nil)
|
328
|
+
send_data("SUMMON #{user} #{target} #{channel}".strip)
|
329
|
+
end
|
330
|
+
|
331
|
+
# List logged in users
|
332
|
+
# @see http://tools.ietf.org/html/rfc2812#section-4.6 4.6 Users
|
333
|
+
def users(target = nil)
|
334
|
+
send_data("USERS #{target}".strip)
|
335
|
+
end
|
336
|
+
|
337
|
+
# Broadcast to all logged in users
|
338
|
+
# @see http://tools.ietf.org/html/rfc2812#section-4.7 4.7 Operwall
|
339
|
+
def wallops(message)
|
340
|
+
send_data("WALLOPS :#{message}")
|
341
|
+
end
|
342
|
+
alias_method :broadcast, :wallops
|
343
|
+
|
344
|
+
# Returns information about up to 5 nicknames
|
345
|
+
# @see http://tools.ietf.org/html/rfc2812#section-4.9 4.9 Userhost
|
346
|
+
def userhost(*nicks)
|
347
|
+
raise ArgumentError.new("Wrong number of arguments") unless nicks.size > 0 && nicks.size <= 5
|
348
|
+
send_data("USERHOST #{nicks.join(' ')}")
|
349
|
+
end
|
350
|
+
|
351
|
+
# Efficient way to check if nicks are currently on
|
352
|
+
# @see http://tools.ietf.org/html/rfc2812#section-4.9 4.9 Ison
|
353
|
+
def ison(*nicks)
|
354
|
+
send_data("ISON #{nicks.join(' ')}")
|
355
|
+
end
|
356
|
+
end
|
357
|
+
end
|
358
|
+
end
|
data/lib/em-irc/dispatcher.rb
CHANGED
@@ -9,20 +9,16 @@ module EventMachine
|
|
9
9
|
raise ArgumentError.new(":parent parameter is required for EM#connect") unless options[:parent]
|
10
10
|
# TODO: if parent doesn't respond to a above methods, do a no-op
|
11
11
|
@parent = options[:parent]
|
12
|
+
@ssl = options[:ssl] || false
|
12
13
|
end
|
13
14
|
|
14
15
|
# @parent.conn is set back to nil when this is created
|
15
16
|
def post_init
|
16
17
|
@parent.conn = self
|
17
|
-
@parent.ready unless @parent.ssl
|
18
18
|
end
|
19
19
|
|
20
20
|
def connection_completed
|
21
|
-
|
22
|
-
start_tls
|
23
|
-
else
|
24
|
-
@parent.ready
|
25
|
-
end
|
21
|
+
@ssl ? start_tls : @parent.ready
|
26
22
|
end
|
27
23
|
|
28
24
|
def ssl_handshake_completed
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module EventMachine
|
2
|
+
module IRC
|
3
|
+
# This module defines callbacks for IRC server responses
|
4
|
+
module Responses
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
included do
|
8
|
+
class_attribute :server_callbacks
|
9
|
+
|
10
|
+
server_reply 'PRIVMSG' do |m|
|
11
|
+
who = sender_nick(m[:prefix])
|
12
|
+
channel = m[:params].first
|
13
|
+
message = m[:params].slice(1..-1).join(' ').gsub(/^:/, '')
|
14
|
+
trigger(:message, who, channel, message)
|
15
|
+
end
|
16
|
+
|
17
|
+
server_reply '001', 'RPL_WELCOME' do |m|
|
18
|
+
@nick = m[:params].first
|
19
|
+
trigger(:nick, @nick)
|
20
|
+
end
|
21
|
+
|
22
|
+
server_reply 'PING' do |m|
|
23
|
+
pong(m[:params].first)
|
24
|
+
trigger(:ping, *m[:params])
|
25
|
+
end
|
26
|
+
|
27
|
+
server_reply 'JOIN' do |m|
|
28
|
+
trigger(:join, sender_nick(m[:prefix]), m[:params].first)
|
29
|
+
end
|
30
|
+
|
31
|
+
server_reply '433', 'ERR_NICKNAMEINUSE' do |m|
|
32
|
+
@nick = nil
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
module ClassMethods
|
37
|
+
def server_reply(*cmds, &blk)
|
38
|
+
cmds << cmds.first if cmds.size == 1
|
39
|
+
self.server_callbacks ||= {}
|
40
|
+
self.server_callbacks[cmds.first] = {
|
41
|
+
:name => cmds.last,
|
42
|
+
:callback => block_given? ? blk : lambda {|m|
|
43
|
+
trigger(cmd.last.downcase.to_sym, *m[:params])
|
44
|
+
}
|
45
|
+
}
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# @return [Hash] h
|
50
|
+
# @option h [String] :prefix
|
51
|
+
# @option h [String] :command
|
52
|
+
# @option h [Array] :params
|
53
|
+
# @private
|
54
|
+
def parse_message(message)
|
55
|
+
# TODO: error handling
|
56
|
+
result = {}
|
57
|
+
|
58
|
+
parts = message.split(' ')
|
59
|
+
result[:prefix] = parts.shift.gsub(/^:/, '') if parts[0] =~ /^:/
|
60
|
+
result[:command] = parts.shift
|
61
|
+
result[:params] = parts.take_while {|e| e[0] != ':'}
|
62
|
+
if result[:params].size < parts.size
|
63
|
+
full_string = parts.slice(result[:params].size..-1).join(" ")
|
64
|
+
full_string.gsub!(/^:/, '')
|
65
|
+
result[:params] << full_string
|
66
|
+
end
|
67
|
+
result
|
68
|
+
end
|
69
|
+
|
70
|
+
# @private
|
71
|
+
def handle_parsed_message(m)
|
72
|
+
if handler = self.class.server_callbacks[m[:command]]
|
73
|
+
instance_exec(m, &handler[:callback])
|
74
|
+
# error codes 400 to 599
|
75
|
+
trigger(:error, handler[:name]) if (m[:command].to_i / 100) > 3
|
76
|
+
else
|
77
|
+
log Logger::ERROR, "Unimplemented command: #{m[:prefix]} #{m[:command]} #{m[:params].join(' ')}"
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
protected
|
82
|
+
# @private
|
83
|
+
def sender_nick(prefix)
|
84
|
+
prefix.split('!').first
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|