ircsupport 0.1.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.
@@ -0,0 +1,101 @@
1
+ module IRCSupport
2
+ module Modes
3
+ # @param [Array] modes The modes you want to parse.
4
+ # @return [Array] Each element will be a hash with two keys: `:set`,
5
+ # a boolean indicating whether the mode is being set (instead of unset);
6
+ # and `:mode`, the mode character.
7
+ def parse_modes(modes)
8
+ mode_changes = []
9
+ modes.scan(/[-+]\w+/).each do |modegroup|
10
+ set, modegroup = modegroup.split '', 2
11
+ set = set == '+' ? true : false
12
+ modegroup.split('').each do |mode|
13
+ mode_changes << { set: set, mode: mode }
14
+ end
15
+ end
16
+ return mode_changes
17
+ end
18
+
19
+ # @param [Array] modes The modes you want to parse.
20
+ # @option opts [Hash] :chanmodes The channel modes which are allowed. This is
21
+ # the same as the "CHANMODES" isupport option.
22
+ # @option opts [Hash] :statmodes The channel modes which are allowed. This is
23
+ # the same as the keys of the "PREFIX" isupport option.
24
+ # @return [Array] Each element will be a hash with three keys: `:set`,
25
+ # a boolean indicating whether the mode is being set (instead of unset);
26
+ # `:mode`, the mode character; and `:argument`, the argument to the mode,
27
+ # if any.
28
+ def parse_channel_modes(modeparts, opts = {})
29
+ chanmodes = opts[:chanmodes] || {
30
+ 'A' => %w{b e I},
31
+ 'B' => %w{k},
32
+ 'C' => %w{l},
33
+ 'D' => %w{i m n p s t a q r},
34
+ }
35
+ statmodes = opts[:statmodes] || %w{o h v}
36
+
37
+ mode_changes = []
38
+ modes, *args = modeparts
39
+ parse_modes(modes).each do |mode_change|
40
+ set, mode = mode_change[:set], mode_change[:mode]
41
+ case
42
+ when chanmodes["A"].include?(mode) || chanmodes["B"].include?(mode)
43
+ mode_changes << {
44
+ mode: mode,
45
+ set: set,
46
+ argument: args.shift
47
+ }
48
+ when chanmodes["C"].include?(mode)
49
+ mode_changes << {
50
+ mode: mode,
51
+ set: set,
52
+ argument: args.shift.to_i
53
+ }
54
+ when chanmodes["D"].include?(mode)
55
+ mode_changes << {
56
+ mode: mode,
57
+ set: set,
58
+ }
59
+ else
60
+ raise ArgumentError, "Unknown mode: #{mode}"
61
+ end
62
+ end
63
+
64
+ return mode_changes
65
+ end
66
+
67
+ # @param [String] modes A string of modes you want to condense
68
+ # (remove duplicates).
69
+ # @return [Strings] A condensed mode string.
70
+ def condense_modes(modes)
71
+ action = nil
72
+ result = ''
73
+ modes.split(//).each do |mode|
74
+ if mode =~ /[+-]/ and (!action or mode != action)
75
+ result += mode
76
+ action = mode
77
+ next
78
+ end
79
+ result += mode if mode =~ /[^+-]/
80
+ end
81
+ result.sub!(/[+-]\z/, '')
82
+ return result
83
+ end
84
+
85
+ # @param [String] before The "before" mode string.
86
+ # @param [String] after The "after" mode string.
87
+ # @return [String] A modestring representing the difference between the
88
+ # two mode strings.
89
+ def diff_modes(before, after)
90
+ before_modes = before.split(//)
91
+ after_modes = after.split(//)
92
+ removed = before_modes - after_modes
93
+ added = after_modes - before_modes
94
+ result = removed.map { |m| '-' + m }.join
95
+ result << added.map { |m| '+' + m }.join
96
+ return condense_modes(result)
97
+ end
98
+
99
+ module_function :parse_modes, :parse_channel_modes, :condense_modes, :diff_modes
100
+ end
101
+ end
@@ -0,0 +1,242 @@
1
+ module IRCSupport
2
+ module Numerics
3
+ # @private
4
+ @@numeric_to_name_map = {
5
+ '001' => 'RPL_WELCOME', # RFC2812
6
+ '002' => 'RPL_YOURHOST', # RFC2812
7
+ '003' => 'RPL_CREATED', # RFC2812
8
+ '004' => 'RPL_MYINFO', # RFC2812
9
+ '005' => 'RPL_ISUPPORT', # draft-brocklesby-irc-isupport-03
10
+ '008' => 'RPL_SNOMASK', # Undernet
11
+ '009' => 'RPL_STATMEMTOT', # Undernet
12
+ '010' => 'RPL_STATMEM', # Undernet
13
+ '020' => 'RPL_CONNECTING', # IRCnet
14
+ '014' => 'RPL_YOURCOOKIE', # IRCnet
15
+ '042' => 'RPL_YOURID', # IRCnet
16
+ '043' => 'RPL_SAVENICK', # IRCnet
17
+ '050' => 'RPL_ATTEMPTINGJUNC', # aircd
18
+ '051' => 'RPL_ATTEMPTINGREROUTE', # aircd
19
+ '200' => 'RPL_TRACELINK', # RFC1459
20
+ '201' => 'RPL_TRACECONNECTING', # RFC1459
21
+ '202' => 'RPL_TRACEHANDSHAKE', # RFC1459
22
+ '203' => 'RPL_TRACEUNKNOWN', # RFC1459
23
+ '204' => 'RPL_TRACEOPERATOR', # RFC1459
24
+ '205' => 'RPL_TRACEUSER', # RFC1459
25
+ '206' => 'RPL_TRACESERVER', # RFC1459
26
+ '207' => 'RPL_TRACESERVICE', # RFC2812
27
+ '208' => 'RPL_TRACENEWTYPE', # RFC1459
28
+ '209' => 'RPL_TRACECLASS', # RFC2812
29
+ '210' => 'RPL_STATS', # aircd
30
+ '211' => 'RPL_STATSLINKINFO', # RFC1459
31
+ '212' => 'RPL_STATSCOMMANDS', # RFC1459
32
+ '213' => 'RPL_STATSCLINE', # RFC1459
33
+ '214' => 'RPL_STATSNLINE', # RFC1459
34
+ '215' => 'RPL_STATSILINE', # RFC1459
35
+ '216' => 'RPL_STATSKLINE', # RFC1459
36
+ '217' => 'RPL_STATSQLINE', # RFC1459
37
+ '218' => 'RPL_STATSYLINE', # RFC1459
38
+ '219' => 'RPL_ENDOFSTATS', # RFC1459
39
+ '221' => 'RPL_UMODEIS', # RFC1459
40
+ '231' => 'RPL_SERVICEINFO', # RFC1459
41
+ '233' => 'RPL_SERVICE', # RFC1459
42
+ '234' => 'RPL_SERVLIST', # RFC1459
43
+ '235' => 'RPL_SERVLISTEND', # RFC1459
44
+ '239' => 'RPL_STATSIAUTH', # IRCnet
45
+ '241' => 'RPL_STATSLLINE', # RFC1459
46
+ '242' => 'RPL_STATSUPTIME', # RFC1459
47
+ '243' => 'RPL_STATSOLINE', # RFC1459
48
+ '244' => 'RPL_STATSHLINE', # RFC1459
49
+ '245' => 'RPL_STATSSLINE', # Bahamut, IRCnet, Hybrid
50
+ '250' => 'RPL_STATSCONN', # ircu, Unreal
51
+ '251' => 'RPL_LUSERCLIENT', # RFC1459
52
+ '252' => 'RPL_LUSEROP', # RFC1459
53
+ '253' => 'RPL_LUSERUNKNOWN', # RFC1459
54
+ '254' => 'RPL_LUSERCHANNELS', # RFC1459
55
+ '255' => 'RPL_LUSERME', # RFC1459
56
+ '256' => 'RPL_ADMINME', # RFC1459
57
+ '257' => 'RPL_ADMINLOC1', # RFC1459
58
+ '258' => 'RPL_ADMINLOC2', # RFC1459
59
+ '259' => 'RPL_ADMINEMAIL', # RFC1459
60
+ '261' => 'RPL_TRACELOG', # RFC1459
61
+ '262' => 'RPL_TRACEEND', # RFC2812
62
+ '263' => 'RPL_TRYAGAIN', # RFC2812
63
+ '265' => 'RPL_LOCALUSERS', # aircd, Bahamut, Hybrid
64
+ '266' => 'RPL_GLOBALUSERS', # aircd, Bahamut, Hybrid
65
+ '267' => 'RPL_START_NETSTAT', # aircd
66
+ '268' => 'RPL_NETSTAT', # aircd
67
+ '269' => 'RPL_END_NETSTAT', # aircd
68
+ '270' => 'RPL_PRIVS', # ircu
69
+ '271' => 'RPL_SILELIST', # ircu
70
+ '272' => 'RPL_ENDOFSILELIST', # ircu
71
+ '300' => 'RPL_NONE', # RFC1459
72
+ '301' => 'RPL_AWAY', # RFC1459
73
+ '302' => 'RPL_USERHOST', # RFC1459
74
+ '303' => 'RPL_ISON', # RFC1459
75
+ '305' => 'RPL_UNAWAY', # RFC1459
76
+ '306' => 'RPL_NOWAWAY', # RFC1459
77
+ '307' => 'RPL_WHOISREGNICK', # Bahamut, Unreal, Plexus
78
+ '310' => 'RPL_WHOISMODES', # Plexus
79
+ '311' => 'RPL_WHOISUSER', # RFC1459
80
+ '312' => 'RPL_WHOISSERVER', # RFC1459
81
+ '313' => 'RPL_WHOISOPERATOR', # RFC1459
82
+ '314' => 'RPL_WHOWASUSER', # RFC1459
83
+ '315' => 'RPL_ENDOFWHO', # RFC1459
84
+ '317' => 'RPL_WHOISIDLE', # RFC1459
85
+ '318' => 'RPL_ENDOFWHOIS', # RFC1459
86
+ '319' => 'RPL_WHOISCHANNELS', # RFC1459
87
+ '321' => 'RPL_LISTSTART', # RFC1459
88
+ '322' => 'RPL_LIST', # RFC1459
89
+ '323' => 'RPL_LISTEND', # RFC1459
90
+ '324' => 'RPL_CHANNELMODEIS', # RFC1459
91
+ '325' => 'RPL_UNIQOPIS', # RFC2812
92
+ '328' => 'RPL_CHANNEL_URL', # Bahamut, AustHex
93
+ '329' => 'RPL_CREATIONTIME', # Bahamut
94
+ '330' => 'RPL_WHOISACCOUNT', # ircu
95
+ '331' => 'RPL_NOTOPIC', # RFC1459
96
+ '332' => 'RPL_TOPIC', # RFC1459
97
+ '333' => 'RPL_TOPICWHOTIME', # ircu
98
+ '338' => 'RPL_WHOISACTUALLY', # Bahamut, ircu
99
+ '340' => 'RPL_USERIP', # ircu
100
+ '341' => 'RPL_INVITING', # RFC1459
101
+ '342' => 'RPL_SUMMONING', # RFC1459
102
+ '345' => 'RPL_INVITED', # GameSurge
103
+ '346' => 'RPL_INVITELIST', # RFC2812
104
+ '347' => 'RPL_ENDOFINVITELIST', # RFC2812
105
+ '348' => 'RPL_EXCEPTLIST', # RFC2812
106
+ '349' => 'RPL_ENDOFEXCEPTLIST', # RFC2812
107
+ '351' => 'RPL_VERSION', # RFC1459
108
+ '352' => 'RPL_WHOREPLY', # RFC1459
109
+ '353' => 'RPL_NAMREPLY', # RFC1459
110
+ '354' => 'RPL_WHOSPCRPL', # ircu
111
+ '355' => 'RPL_NAMREPLY_', # QuakeNet
112
+ '361' => 'RPL_KILLDONE', # RFC1459
113
+ '362' => 'RPL_CLOSING', # RFC1459
114
+ '363' => 'RPL_CLOSEEND', # RFC1459
115
+ '364' => 'RPL_LINKS', # RFC1459
116
+ '365' => 'RPL_ENDOFLINKS', # RFC1459
117
+ '366' => 'RPL_ENDOFNAMES', # RFC1459
118
+ '367' => 'RPL_BANLIST', # RFC1459
119
+ '368' => 'RPL_ENDOFBANLIST', # RFC1459
120
+ '369' => 'RPL_ENDOFWHOWAS', # RFC1459
121
+ '371' => 'RPL_INFO', # RFC1459
122
+ '372' => 'RPL_MOTD', # RFC1459
123
+ '373' => 'RPL_INFOSTART', # RFC1459
124
+ '374' => 'RPL_ENDOFINFO', # RFC1459
125
+ '375' => 'RPL_MOTDSTART', # RFC1459
126
+ '376' => 'RPL_ENDOFMOTD', # RFC1459
127
+ '381' => 'RPL_YOUREOPER', # RFC1459
128
+ '382' => 'RPL_REHASHING', # RFC1459
129
+ '383' => 'RPL_YOURESERVICE', # RFC2812
130
+ '384' => 'RPL_MYPORTIS', # RFC1459
131
+ '385' => 'RPL_NOTOPERANYMORE', # AustHex, Hybrid, Unreal
132
+ '386' => 'RPL_QLIST', # Unreal
133
+ '387' => 'RPL_ENDOFQLIST', # Unreal
134
+ '391' => 'RPL_TIME', # RFC1459
135
+ '392' => 'RPL_USERSSTART', # RFC1459
136
+ '393' => 'RPL_USERS', # RFC1459
137
+ '394' => 'RPL_ENDOFUSERS', # RFC1459
138
+ '395' => 'RPL_NOUSERS', # RFC1459
139
+ '396' => 'RPL_HOSTHIDDEN', # Undernet
140
+ '401' => 'ERR_NOSUCHNICK', # RFC1459
141
+ '402' => 'ERR_NOSUCHSERVER', # RFC1459
142
+ '403' => 'ERR_NOSUCHCHANNEL', # RFC1459
143
+ '404' => 'ERR_CANNOTSENDTOCHAN', # RFC1459
144
+ '405' => 'ERR_TOOMANYCHANNELS', # RFC1459
145
+ '406' => 'ERR_WASNOSUCHNICK', # RFC1459
146
+ '407' => 'ERR_TOOMANYTARGETS', # RFC1459
147
+ '408' => 'ERR_NOSUCHSERVICE', # RFC2812
148
+ '409' => 'ERR_NOORIGIN', # RFC1459
149
+ '411' => 'ERR_NORECIPIENT', # RFC1459
150
+ '412' => 'ERR_NOTEXTTOSEND', # RFC1459
151
+ '413' => 'ERR_NOTOPLEVEL', # RFC1459
152
+ '414' => 'ERR_WILDTOPLEVEL', # RFC1459
153
+ '415' => 'ERR_BADMASK', # RFC2812
154
+ '421' => 'ERR_UNKNOWNCOMMAND', # RFC1459
155
+ '422' => 'ERR_NOMOTD', # RFC1459
156
+ '423' => 'ERR_NOADMININFO', # RFC1459
157
+ '424' => 'ERR_FILEERROR', # RFC1459
158
+ '425' => 'ERR_NOOPERMOTD', # Unreal
159
+ '429' => 'ERR_TOOMANYAWAY', # Bahamut
160
+ '430' => 'ERR_EVENTNICKCHANGE', # AustHex
161
+ '431' => 'ERR_NONICKNAMEGIVEN', # RFC1459
162
+ '432' => 'ERR_ERRONEUSNICKNAME', # RFC1459
163
+ '433' => 'ERR_NICKNAMEINUSE', # RFC1459
164
+ '436' => 'ERR_NICKCOLLISION', # RFC1459
165
+ '439' => 'ERR_TARGETTOOFAST', # ircu
166
+ '440' => 'ERR_SERCVICESDOWN', # Bahamut, Unreal
167
+ '441' => 'ERR_USERNOTINCHANNEL', # RFC1459
168
+ '442' => 'ERR_NOTONCHANNEL', # RFC1459
169
+ '443' => 'ERR_USERONCHANNEL', # RFC1459
170
+ '444' => 'ERR_NOLOGIN', # RFC1459
171
+ '445' => 'ERR_SUMMONDISABLED', # RFC1459
172
+ '446' => 'ERR_USERSDISABLED', # RFC1459
173
+ '447' => 'ERR_NONICKCHANGE', # Unreal
174
+ '449' => 'ERR_NOTIMPLEMENTED', # Undernet
175
+ '451' => 'ERR_NOTREGISTERED', # RFC1459
176
+ '455' => 'ERR_HOSTILENAME', # Unreal
177
+ '459' => 'ERR_NOHIDING', # Unreal
178
+ '460' => 'ERR_NOTFORHALFOPS', # Unreal
179
+ '461' => 'ERR_NEEDMOREPARAMS', # RFC1459
180
+ '462' => 'ERR_ALREADYREGISTRED', # RFC1459
181
+ '463' => 'ERR_NOPERMFORHOST', # RFC1459
182
+ '464' => 'ERR_PASSWDMISMATCH', # RFC1459
183
+ '465' => 'ERR_YOUREBANNEDCREEP', # RFC1459
184
+ '466' => 'ERR_YOUWILLBEBANNED', # RFC1459
185
+ '467' => 'ERR_KEYSET', # RFC1459
186
+ '469' => 'ERR_LINKSET', # Unreal
187
+ '471' => 'ERR_CHANNELISFULL', # RFC1459
188
+ '472' => 'ERR_UNKNOWNMODE', # RFC1459
189
+ '473' => 'ERR_INVITEONLYCHAN', # RFC1459
190
+ '474' => 'ERR_BANNEDFROMCHAN', # RFC1459
191
+ '475' => 'ERR_BADCHANNELKEY', # RFC1459
192
+ '476' => 'ERR_BADCHANMASK', # RFC2812
193
+ '477' => 'ERR_NOCHANMODES', # RFC2812
194
+ '478' => 'ERR_BANLISTFULL', # RFC2812
195
+ '481' => 'ERR_NOPRIVILEGES', # RFC1459
196
+ '482' => 'ERR_CHANOPRIVSNEEDED', # RFC1459
197
+ '483' => 'ERR_CANTKILLSERVER', # RFC1459
198
+ '484' => 'ERR_RESTRICTED', # RFC2812
199
+ '485' => 'ERR_UNIQOPPRIVSNEEDED', # RFC2812
200
+ '488' => 'ERR_TSLESSCHAN', # IRCnet
201
+ '491' => 'ERR_NOOPERHOST', # RFC1459
202
+ '492' => 'ERR_NOSERVICEHOST', # RFC1459
203
+ '493' => 'ERR_NOFEATURE', # ircu
204
+ '494' => 'ERR_BADFEATURE', # ircu
205
+ '495' => 'ERR_BADLOGTYPE', # ircu
206
+ '496' => 'ERR_BADLOGSYS', # ircu
207
+ '497' => 'ERR_BADLOGVALUE', # ircu
208
+ '498' => 'ERR_ISOPERLCHAN', # ircu
209
+ '501' => 'ERR_UMODEUNKNOWNFLAG', # RFC1459
210
+ '502' => 'ERR_USERSDONTMATCH', # RFC1459
211
+ '503' => 'ERR_GHOSTEDCLIENT', # Hybrid
212
+ '730' => 'RPL_MONONLINE', # ratbox
213
+ '731' => 'RPL_MONOFFLINE', # ratbox
214
+ '732' => 'RPL_MONLIST', # ratbox
215
+ '733' => 'RPL_ENDOFMONLIST', # ratbox
216
+ '732' => 'ERR_MONLISTFULL', # ratbox
217
+ '900' => 'RPL_SASLLOGIN', # charybdis, ircd-seven
218
+ '903' => 'RPL_SASLSUCCESS', # charybdis, ircd-seven
219
+ '904' => 'RPL_SASLFAILED', # charybdis, ircd-seven
220
+ '905' => 'RPL_SASLERROR', # charybdis, ircd-seven
221
+ '906' => 'RPL_SASLABORT', # charybdis, ircd-seven
222
+ '907' => 'RPL_SASLREADYAUTH', # charybdis, ircd-seven
223
+ }
224
+
225
+ # @private
226
+ @@name_to_numeric_map = @@numeric_to_name_map.invert
227
+
228
+ # @param [String] numeric A numeric to look up.
229
+ # @return [String] The name of the numeric.
230
+ def numeric_to_name(numeric)
231
+ return @@numeric_to_name_map[numeric]
232
+ end
233
+
234
+ # @param [String] name A name to look up.
235
+ # @return [String] The numeric corresponding to the name.
236
+ def name_to_numeric(name)
237
+ return @@name_to_numeric_map[name]
238
+ end
239
+
240
+ module_function :numeric_to_name, :name_to_numeric
241
+ end
242
+ end
@@ -0,0 +1,340 @@
1
+ require 'ircsupport/message'
2
+
3
+ module IRCSupport
4
+ class Parser
5
+ # @private
6
+ @@eol = '\x00\x0a\x0d'
7
+ # @private
8
+ @@space = / +/
9
+ # @private
10
+ @@maybe_space = / */
11
+ # @private
12
+ @@non_space = /[^ ]+/
13
+ # @private
14
+ @@numeric = /[0-9]{3}/
15
+ # @private
16
+ @@command = /[a-zA-Z]+/
17
+ # @private
18
+ @@irc_name = /[^#@@eol :][^#@@eol ]*/
19
+ # @private
20
+ @@irc_line = /
21
+ \A
22
+ (?: : (?<prefix> #@@non_space ) #@@space )?
23
+ (?<command> #@@numeric | #@@command )
24
+ (?: #@@space (?<args> #@@irc_name (?: #@@space #@@irc_name )* ) )?
25
+ (?: #@@space : (?<trailing_arg> [^#@@eol]* ) | #@@maybe_space )?
26
+ \z
27
+ /x
28
+ # @private
29
+ @@low_quote_from = /[\x00\x0a\x0d\x10]/
30
+ # @private
31
+ @@low_quote_to = {
32
+ "\x00" => "\x100",
33
+ "\x0a" => "\x10n",
34
+ "\x0d" => "\x10r",
35
+ "\x10" => "\x10\x10",
36
+ }
37
+ # @private
38
+ @@low_dequote_from = /\x10[0nr\x10]/
39
+ # @private
40
+ @@low_dequote_to = {
41
+ "\x100" => "\x00",
42
+ "\x10n" => "\x0a",
43
+ "\x10r" => "\x0d",
44
+ "\x10\x10" => "\x10",
45
+ }
46
+ # @private
47
+ @@default_isupport = {
48
+ "PREFIX" => {"o" => "@", "v" => "+"},
49
+ "CHANTYPES" => ["#"],
50
+ "CHANMODES" => {
51
+ "A" => ["b"],
52
+ "B" => ["k"],
53
+ "C" => ["l"],
54
+ "D" => %w[i m n p s t r]
55
+ },
56
+ "MODES" => 1,
57
+ "NICKLEN" => 999,
58
+ "MAXBANS" => 999,
59
+ "TOPICLEN" => 999,
60
+ "KICKLEN" => 999,
61
+ "CHANNELLEN" => 999,
62
+ "CHIDLEN" => 5,
63
+ "AWAYLEN" => 999,
64
+ "MAXTARGETS" => 1,
65
+ "MAXCHANNELS" => 999,
66
+ "CHANLIMIT" => {"#" => 999},
67
+ "STATUSMSG" => ["@", "+"],
68
+ "CASEMAPPING" => :rfc1459,
69
+ "ELIST" => [],
70
+ "MONITOR" => 0,
71
+ }
72
+
73
+ # The isupport configuration of the IRC server.
74
+ # The configuration will be seeded with sane defaults, and updated in
75
+ # response to parsed {IRCSupport::Message::Numeric005 `005`} messages.
76
+ # @return [Hash]
77
+ attr_reader :isupport
78
+
79
+ # A list of currently enabled capabilities.
80
+ # It will be updated in response to parsed {IRCSupport::Message::CAP::ACK `CAP ACK`} messages.
81
+ # @return [Array]
82
+ attr_reader :capabilities
83
+
84
+ # @return [IRCSupport::Parser]
85
+ def initialize
86
+ @isupport = @@default_isupport
87
+ @capabilities = []
88
+ end
89
+
90
+ # @param [String] line An IRC protocol line you wish to decompose.
91
+ # @return [Hash] A decomposed IRC protocol line with 3 keys:
92
+ # `command`, the IRC command; `prefix`, the prefix to the
93
+ # command, if any; `args`, an array of any arguments to the command
94
+ def decompose_line(line)
95
+ if line =~ @@irc_line
96
+ c = $~
97
+ elems = {}
98
+ elems[:prefix] = c[:prefix] if c[:prefix]
99
+ elems[:command] = c[:command].upcase
100
+ elems[:args] = []
101
+ elems[:args].concat c[:args].split(@@space) if c[:args]
102
+ elems[:args] << c[:trailing_arg] if c[:trailing_arg]
103
+ else
104
+ raise ArgumentError, "Line is not IRC protocol: #{line}"
105
+ end
106
+
107
+ return elems
108
+ end
109
+
110
+ # @param [Hash] elems The attributes of the message (as returned
111
+ # by {#decompose_line}).
112
+ # @return [String] An IRC protocol line.
113
+ def compose_line(elems)
114
+ line = ''
115
+ line << ":#{elems[:prefix]} " if elems[:prefix]
116
+ if !elems[:command]
117
+ raise ArgumentError, "You must specify a command"
118
+ end
119
+ line << elems[:command]
120
+
121
+ if elems[:args]
122
+ elems[:args].each_with_index do |arg, idx|
123
+ line << ' '
124
+ if idx != elems[:args].count-1 and arg.match(@@space)
125
+ raise ArgumentError, "Only the last argument may contain spaces"
126
+ end
127
+ if idx == elems[:args].count-1
128
+ line << ':' if arg.match(@@space)
129
+ end
130
+ line << arg
131
+ end
132
+ end
133
+
134
+ return line
135
+ end
136
+
137
+ # @param [String] line An IRC protocol line.
138
+ # @return [IRCSupport::Message] A parsed message object.
139
+ def parse(line)
140
+ elems = decompose_line(line)
141
+ elems[:isupport] = @isupport
142
+ elems[:capabilities] = @capabilities
143
+
144
+ if elems[:command] =~ /^(PRIVMSG|NOTICE)$/ && elems[:args][1] =~ /\x01/
145
+ return handle_ctcp_message(elems)
146
+ end
147
+
148
+ if elems[:command] =~ /^\d{3}$/
149
+ msg_class = "Numeric"
150
+ elsif elems[:command] == "MODE"
151
+ if @isupport['CHANTYPES'].include? elems[:args][0][0]
152
+ msg_class = "ChannelModeChange"
153
+ else
154
+ msg_class = "UserModeChange"
155
+ end
156
+ elsif elems[:command] == "NOTICE" && (!elems[:prefix] || elems[:prefix] !~ /!/)
157
+ msg_class = "ServerNotice"
158
+ elsif elems[:command] =~ /^(PRIVMSG|NOTICE)$/
159
+ msg_class = "Message"
160
+ elems[:is_notice] = true if elems[:command] == "NOTICE"
161
+ if @isupport['CHANTYPES'].include? elems[:args][0][0]
162
+ elems[:is_public] = true
163
+ end
164
+ if @capabilities.include?('identify-msg')
165
+ elems[:args][1], elems[:identified] = split_idmsg(elems[:args][1])
166
+ end
167
+ elsif elems[:command] == "CAP" && %w{LS LIST ACK}.include?(elems[:args][0])
168
+ msg_class = "CAP::#{elems[:args][0]}"
169
+ else
170
+ msg_class = elems[:command]
171
+ end
172
+
173
+ begin
174
+ if msg_class == "Numeric"
175
+ begin
176
+ msg_const = constantize("IRCSupport::Message::Numeric#{elems[:command]}")
177
+ rescue
178
+ msg_const = constantize("IRCSupport::Message::#{msg_class}")
179
+ end
180
+ else
181
+ begin
182
+ msg_const = constantize("IRCSupport::Message::#{msg_class}")
183
+ rescue
184
+ msg_const = constantize("IRCSupport::Message::#{msg_class.capitalize}")
185
+ end
186
+ end
187
+ rescue
188
+ msg_const = constantize("IRCSupport::Message")
189
+ end
190
+
191
+ message = msg_const.new(elems)
192
+
193
+ if message.type == '005'
194
+ @isupport.merge! message.isupport
195
+ elsif message.type == 'cap_ack'
196
+ message.capabilities.each do |capability, options|
197
+ if options.include?(:disable)
198
+ @capabilities = @capabilities - [capability]
199
+ elsif options.include?(:enable)
200
+ @capabilities = @capabilities + [capability]
201
+ end
202
+ end
203
+ end
204
+
205
+ return message
206
+ end
207
+
208
+ # @param [String] type The CTCP type.
209
+ # @param [String] message The text of the CTCP message.
210
+ # @return [String] A CTCP-quoted message.
211
+ def ctcp_quote(type, message)
212
+ line = low_quote(message)
213
+ line.gsub!(/\x01/, '\a')
214
+ return "\x01#{type} #{line}\x01"
215
+ end
216
+
217
+ private
218
+
219
+ # from ActiveSupport
220
+ def constantize(camel_cased_word)
221
+ names = camel_cased_word.split('::')
222
+ names.shift if names.empty? || names.first.empty?
223
+
224
+ constant = Object
225
+ names.each do |name|
226
+ constant = constant.const_defined?(name) ? constant.const_get(name) : constant.const_missing(name)
227
+ end
228
+ constant
229
+ end
230
+
231
+ def split_idmsg(line)
232
+ identified, line = line.split(//, 2)
233
+ identified = identified == '+' ? true : false
234
+ return line, identified
235
+ end
236
+
237
+ def handle_ctcp_message(elems)
238
+ ctcp_type = elems[:command] == 'PRIVMSG' ? 'CTCP' : 'CTCPReply'
239
+ ctcps, texts = ctcp_dequote(elems[:args][1])
240
+
241
+ # We only process the first CTCP, ignoring extra CTCPs and any
242
+ # non-CTCPs. Those who send anything in addition to that first CTCP
243
+ # are probably up to no good (e.g. trying to flood a bot by having it
244
+ # reply to 20 CTCP VERSIONs at a time).
245
+ ctcp = ctcps.first
246
+
247
+ if @capabilities.include?('identify-msg') && ctcp =~ /^.ACTION/
248
+ ctcp, elems[:identified] = split_idmsg(ctcp)
249
+ end
250
+
251
+ if ctcp !~ /^(\w+)(?: (.*))?/
252
+ warn "Received malformed CTCP from #{elems[:prefix]}: #{ctcp}"
253
+ return
254
+ end
255
+ ctcp_name, ctcp_args = $~.captures
256
+
257
+ if ctcp_name == 'DCC'
258
+ if ctcp_args !~ /^(\w+) +(.+)/
259
+ warn "Received malformed DCC request from #{elems[:prefix]}: #{ctcp}"
260
+ return
261
+ end
262
+ dcc_name, dcc_args = $~.captures
263
+ elems[:args][1] = dcc_args
264
+ elems[:dcc_type] = dcc_name
265
+
266
+ begin
267
+ message_class = constantize("IRCSupport::Message::DCC::" + dcc_name.capitalize)
268
+ rescue
269
+ message_class = constantize("IRCSupport::Message::DCC")
270
+ end
271
+
272
+ return message_class.new(elems)
273
+ else
274
+ elems[:args][1] = ctcp_args || ''
275
+
276
+ if @isupport['CHANTYPES'].include? elems[:args][0][0]
277
+ elems[:is_public] = true
278
+ end
279
+
280
+ # treat CTCP ACTIONs as normal messages with a special attribute
281
+ if ctcp_name == 'ACTION'
282
+ elems[:is_action] = true
283
+ return IRCSupport::Message::Message.new(elems)
284
+ end
285
+
286
+ begin
287
+ message_class = constantize("IRCSupport::Message::#{ctcp_type}_" + ctcp_name.capitalize)
288
+ rescue
289
+ message_class = constantize("IRCSupport::Message::#{ctcp_type}")
290
+ end
291
+
292
+ elems[:ctcp_type] = ctcp_name
293
+ return message_class.new(elems)
294
+ end
295
+ end
296
+
297
+ def ctcp_dequote(line)
298
+ line = low_dequote(line)
299
+
300
+ # filter misplaced \x01 before processing
301
+ if line.count("\x01") % 2 != 0
302
+ line[line.rindex("\x01")] = '\a'
303
+ end
304
+
305
+ return if line !~ /\x01/
306
+
307
+ chunks = line.split(/\x01/)
308
+ chunks.shift if chunks.first.empty?
309
+
310
+ chunks.each do |chunk|
311
+ # Dequote unnecessarily quoted chars, and convert escaped \'s and ^A's.
312
+ chunk.gsub!(/\\([^\\a])/, "\\1")
313
+ chunk.gsub!(/\\\\/, "\\")
314
+ chunk.gsub!(/\\a/, "\x01")
315
+ end
316
+
317
+ ctcp, text = [], []
318
+
319
+ # If the line begins with a control-A, the first chunk is a CTCP
320
+ # line. Otherwise, it starts with text and alternates with CTCP
321
+ # lines. Really stupid protocol.
322
+ ctcp << chunks.shift if line =~ /^\x01/
323
+
324
+ while not chunks.empty?
325
+ text << chunks.shift
326
+ ctcp << chunks.shift if not chunks.empty?
327
+ end
328
+
329
+ return ctcp, text
330
+ end
331
+
332
+ def low_quote(line)
333
+ return line.sub(@@low_quote_from, @@low_quote_to)
334
+ end
335
+
336
+ def low_dequote(line)
337
+ return line.sub(@@low_dequote_from, @@low_dequote_to)
338
+ end
339
+ end
340
+ end