em-irc 0.0.1 → 0.0.2
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.
- 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
|