1 # Simple chanserv helper script for Xchat 2 # (c) 2006-2010 Dennis Kaarsemaker 3 # 4 # Latest version can be found on http://www.kaarsemaker.net/software/ 5 # 6 # This script is free software; you can redistribute it and/or 7 # modify it under the terms of the GNU General Public License 8 # version 3, as published by the Free Software Foundation. 9 # 10 # This program is distributed in the hope that it will be useful, 11 # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 # GNU General Public License for more details. 14 # 15 # You should have received a copy of the GNU General Public License along
1 # Simple chanserv helper script for Xchat
2 # (c) 2006-2010 Dennis Kaarsemaker
3 #
4 # Latest version can be found on http://www.kaarsemaker.net/software/
5 #
6 # This script is free software; you can redistribute it and/or
7 # modify it under the terms of the GNU General Public License
8 # version 3, as published by the Free Software Foundation.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License along
16 # with this program; if not, write to the Free Software Foundation, Inc.,
17 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
18
19 # Usage instructions:
20 # - Place in ~/.xchat2 for it to be autoloaded
21 # - Use /py load chanserv.py if you already started X-chat
22 # - Connect to freenode, if not connected (other networks will not work)
23 #
24 # It adds one command to xchat: /cs
25 # /cs understands the following arguments
26 #
27 # To give/take ops/voice:
28 #
29 # o or op - Let chanserv op you/others (/cs op, /cs op nick)
30 # v or voice - Let chanserv give you/others voice
31 # d or deop - Let chanserv deop you/others (/cs deop, /cs deop nick)
32 # dv or devoice - Let chanserv decoice you/others (/cs devoice, /cs devoice nick)
33 #
34 # To op yourself, perform an action, and deop:
35 #
36 # k or kick - Kick a user, possibly with comment (/cs kick nick [comment])
37 # b or ban - Ban a user (/cs ban [-nihar] nick)
38 # kb or kickban - Kick and ban a user (/cs ban [-nihar] nick)
39 # f or forward - Ban a user with a forward (/cs forward [-nihar] nick chan)
40 # kf or kickforward - Kickban a user with a forward (/cs forward [-nihar] nick chan)
41 # m or mute - Mute a user (/cs mute [-nihar] nick)
42 # l or lart - A combination of kick and ban on all fields
43 # u or unban - Remove all bans for a user (/cs u nick)
44 # t or topic - Set channel topic (/cs t New topic here)
45 # m or mode - Change channel mode (/cs mode modes here)
46 # i or invite - Invite yourself or someone else (/cs invite [nick])
47 # bans - Show bans that apply to someone without removing them (/cs bans nick)
48 #
49 # * Bans, forwards and mute take an extra optional argument that specifies
50 # what should be banned: nickname, ident, host, account and/or realname.
51 # /cs ban -nah nick -- Ban nick, account and host
52 # /cs forward -nihra nick #somewhere -- Forward all
53 #
54 # * Unban will remove all bans matching the nick or mask you give as argument
55 # (* and ? wildcards work)
56 # * It won't actually kick, but use the /remove command
57 #
58 # The following additional features are implemented
59 # - Autorejoin for /remove
60 # - Auto-unmute when muted
61 # - Auto-unban via chanserv
62 # - Auto-invite via chanserv
63 # - Auto-getkey via chanserv
64
65 __module_name__ = "chanserv"
66 __module_version__ = "2.0.4"
67 __module_description__ = "Chanserv helper"
68
69 import collections
70 import xchat
71 import time
72 import re
73
74 # Event queue
75 pending = []
76 # /whois cache
77 users = {}
78 # /mode bq 'cache'
79 bans = collections.defaultdict(list)
80 quiets = collections.defaultdict(list)
81 bq_switch = {}
82
83 abbreviations = {'kick': 'k', 'ban': 'b', 'kickban': 'kb', 'forward': 'f',
84 'kickforward': 'kf', 'mute': 'm', 'topic': 't', 'unban': 'u',
85 'mode': 'm', 'invite': 'i', 'op': 'o', 'deop': 'd', 'lart': 'l',
86 'voice': 'v', 'devoice': 'dv', 'bans': 'bans'}
87 expansions = dict([x[::-1] for x in abbreviations.items()])
88 simple_commands = ['op', 'deop', 'voice', 'devoice']
89 kick_commands = ['kick', 'kickforward', 'kickban', 'lart']
90 forward_commands = ['kickforward', 'forward']
91 ban_commands = ['ban', 'forward', 'mute', 'lart', 'kickban', 'kickforward']
92 simple_commands += [abbreviations[x] for x in simple_commands]
93 kick_commands += [abbreviations[x] for x in kick_commands]
94 ban_commands += [abbreviations[x] for x in ban_commands]
95 forward_commands += [abbreviations[x] for x in forward_commands]
96 all_commands = abbreviations.keys() + abbreviations.values()
97 ban_sentinel = '!'
98
99 def cs(word, word_eol, userdata):
100 """Main command dispatcher"""
101 if len(word) == 1:
102 return xchat.EAT_ALL
103 command = word[1].lower()
104
105 if command not in all_commands:
106 return xchat.EAT_NONE
107
108 args = dict(enumerate(word_eol[2:]))
109 me = xchat.get_info('nick')
110
111 action = Action(channel = xchat.get_info('channel'),
112 me = me,
113 context = xchat.get_context())
114
115 # The simple ones: op/voice
116 if command in simple_commands:
117 action.target = args.get(0, me)
118 action.deop = (action.target != me)
119 action.needs_op = False
120 command = expansions.get(command,command)
121 action.actions.append('chanserv %s %%(channel)s %%(target_nick)s' % command)
122 return action.schedule()
123
124 # Usage check
125 if len(word) < 3:
126 if command in all_commands:
127 xchat.emit_print("Server Error", "Not enough arguments for %s" % command)
128 return xchat.EAT_ALL
129 return xchat.EAT_NONE
130
131 if command in ('t','topic'):
132 action.actions.append('chanserv TOPIC %%(channel)s %s' % args[0])
133 action.needs_op = False
134 return action.schedule()
135
136 if command in ('m','mode') and args[0][0] in '+=-':
137 action.actions.append('MODE %%(channel)s %s' % args[0])
138 return action.schedule()
139
140 if command in ('i','invite'):
141 target = args[0]
142 if target.startswith('#'):
143 action.needs_op = False
144 action.actions.append('chanserv INVITE %s' % target)
145 else:
146 if target.lower() in [x.nick.lower() for x in action.context.get_list('users')]:
147 xchat.emit_print("Server Error", "%s is already in %s" % (target, action.channel))
148 return xchat.EAT_ALL
149 action.actions.append('INVITE %s %%(channel)s' % target)
150 return action.schedule()
151
152 # Kick/ban/forward/mute handling
153 if len(word) < 4 and command in forward_commands:
154 xchat.emit_print("Server Error", "Not enough arguments for %s" % command)
155 return xchat.EAT_ALL
156
157 # Command dispatch
158 # Check for -nihra argument
159 if command in ban_commands:
160 if args[0].startswith('-'):
161 action.bans = args[0][1:].split(None, 1)[0]
162 args = dict(enumerate(word_eol[3:]))
163 if command in ('lart','l'):
164 action.bans = 'nihra'
165
166 # Set target
167 action.target = args[0].split(None,1)[0]
168
169 if not valid_nickname(action.target) and not valid_mask(action.target):
170 xchat.emit_print("Server Error", "Invalid target: %s" % action.target)
171 return xchat.EAT_ALL
172
173 if action.bans and not valid_nickname(action.target):
174 xchat.emit_print("Server Error", "Ban types and lart can only be used with nicks, not with complete masks")
175 return xchat.EAT_ALL
176
177 if valid_mask(action.target):
178 action.bans = 'f'
179
180 if not action.bans:
181 action.bans = 'h'
182
183 # Find forward channel
184 if command in forward_commands:
185 action.forward_to = '$' + args[1].split(None,1)[0] # Kludge
186 if not valid_channel(action.forward_to[1:]):
187 xchat.emit_print("Server Error", "Invalid channel: %s" % action.forward_to[1:])
188 return xchat.EAT_ALL
189
190 # Check if target is there and schedule kick
191 if command in kick_commands:
192 if action.target.lower() not in [x.nick.lower() for x in action.context.get_list('users')]:
193 xchat.emit_print("Server Error", "%s is not in %s" % (action.target, action.channel))
194 return xchat.EAT_ALL
195 action.reason = args.get(1, 'Goodbye')
196 action.actions.append('remove %(channel)s %(target_nick)s :%(reason)s')
197
198 if command in ('m','mute'):
199 action.banmode = 'q'
200
201 if command in ban_commands:
202 action.do_ban = True
203 if 'n' in action.bans: action.actions.append('mode %(channel)s +%(banmode)s %(target_nick)s!*@*%(forward_to)s')
204 if 'i' in action.bans: action.actions.append('mode %(channel)s +%(banmode)s *!%(target_ident)s@*%(forward_to)s')
205 if 'h' in action.bans: action.actions.append('mode %(channel)s +%(banmode)s *!*@%(target_host)s%(forward_to)s')
206 if 'r' in action.bans: action.actions.append('mode %(channel)s +%(banmode)s $r:%(target_name_bannable)s%(forward_to)s')
207 if 'a' in action.bans: action.actions.append('mode %(channel)s +%(banmode)s $a:%(target_account)s%(forward_to)s')
208 if 'f' in action.bans: action.actions.append('mode %(channel)s +%(banmode)s %(target)s%(forward_to)s')
209
210 if command in ('u','unban'):
211 action.do_unban = True
212
213 if command == 'bans':
214 action.do_bans = True
215 action.needs_op = False
216
217 return action.schedule()
218 xchat.hook_command('cs',cs,"For help with /cs, please read the comments in the script")
219
220 class Action:
221 """A list of actions to do, and information needed for them"""
222 def __init__(self, channel, me, context):
223 self.channel = channel
224 self.me = me
225 self.context = context
226 self.stamp = time.time()
227
228 # Defaults
229 self.deop = True
230 self.needs_op = True
231 self.do_ban = self.do_unban = self.do_bans = False
232 self.banmode = 'b'
233 self.reason = ''
234 self.bans = ''
235 self.actions = []
236 self.resolved = True
237 self.target = ''
238 self.forward_to = ''
239
240 def schedule(self):
241 """Request information and add ourselves to the queue"""
242 pending.append(self)
243 # Am I opped?
244 self.am_op = False
245 for user in self.context.get_list('users'):
246 if user.nick == self.me and user.prefix == '@':
247 self.am_op = True
248 self.deop = False
249
250 if self.needs_op and not self.am_op:
251 self.context.command("chanserv op %s" % self.channel)
252
253 # Find needed information
254 if ('a' in self.bans or 'r' in self.bans) and valid_mask(self.target) and not self.target.startswith('$'):
255 xchat.emit_print('Server Error', "Invalid argument %s for account/realname ban" % self.target)
256 return xchat.EAT_ALL
257 if self.do_ban or self.do_unban or self.do_bans:
258 self.resolve_nick()
259 else:
260 self.target_nick = self.target
261
262 if self.do_unban or self.do_bans:
263 self.fetch_bans()
264
265 run_pending()
266 return xchat.EAT_ALL
267
268 def resolve_nick(self, request=True):
269 """Try to find nickname, ident and host"""
270 self.target_nick = None
271 self.target_ident = None
272 self.target_host = None
273 self.target_name = None
274 self.target_account = None
275 self.resolved = False
276
277 if valid_mask(self.target):
278 if self.target.startswith('$a:'):
279 self.target_account = self.target[3:]
280 elif self.target.startswith('$r:'):
281 self.target_name = self.target[3:]
282 else:
283 self.target_nick, self.target_mask, self.target_host = re.split('[!@]', self.target)
284 self.resolved = True
285 return
286
287 self.target_nick = self.target.lower()
288 if self.target_nick in users:
289 if users[self.target_nick].time < time.time() - 10:
290 del users[self.target_nick]
291 if request:
292 self.context.command('whois %s' % self.target_nick)
293 else:
294 self.target_ident = users[self.target_nick].ident
295 self.target_host = users[self.target_nick].host
296 self.target_name = users[self.target_nick].name
297 self.target_name_bannable = re.sub('[^a-zA-Z0-9]', '?', self.target_name)
298 self.target_account = users[self.target_nick].account
299 self.resolved = True
300 if 'gateway/' in self.target_host and self.bans == 'h' and self.do_ban:
301 # For gateway/* users, default to ident ban
302 self.actions.append('mode %(channel)s +%(banmode)s *!%(target_ident)s@gateway/*%(forward_to)s')
303 self.actions.remove('mode %(channel)s +%(banmode)s *!*@%(target_host)s%(forward_to)s')
304 else:
305 if request:
306 self.context.command('whois %s' % self.target_nick)
307
308 def fetch_bans(self):
309 """Read bans for a channel"""
310 bq_switch[self.channel] = 'b'
311 bans[self.channel] = []
312 quiets[self.channel] = []
313 self.context.command("mode %s +bq" % self.channel)
314
315 def run(self):
316 """Perform our actions"""
317 kwargs = dict(self.__dict__.items())
318
319 if self.do_bans:
320 xchat.emit_print('Server Text', "Bans matching %s!%s@%s (r:%s, a:%s)" %
321 (self.target_nick, self.target_ident, self.target_host, self.target_name, self.target_account))
322
323 if self.do_unban or self.do_bans:
324
325 for b in bans[self.channel]:
326 if self.match(b):
327 if self.do_bans:
328 xchat.emit_print('Server Text', b)
329 else:
330 self.actions.append('mode %s -b %s' % (self.channel, b))
331
332 for b in quiets[self.channel]:
333 if self.match(b):
334 if self.do_bans:
335 xchat.emit_print('Server Text', b + ' (quiet)')
336 else:
337 self.actions.append('mode %s -q %s' % (self.channel, b))
338
339 # Perform all registered actions
340 for action in self.actions:
341 self.context.command(action % kwargs)
342
343 self.done()
344
345 def done(self):
346 """Finaliazation and cleanup"""
347 # Done!
348 pending.remove(self)
349
350 # Deop?
351 if not self.am_op or not self.needs_op:
352 return
353
354 for p in pending:
355 if p.channel == self.channel and p.needs_op or not p.deop:
356 self.deop = False
357 break
358
359 if self.deop:
360 self.context.command("chanserv deop %s" % self.channel)
361
362 def match(self, ban):
363 """Does a ban match this action"""
364 if ban.startswith('$r:') and self.target_name:
365 return ban2re(ban[3:]).match(self.target_name)
366 elif ban.startswith('$a:') and self.target_account:
367 return ban2re(ban[3:]).match(self.target_account)
368 else:
369 if '#' in ban:
370 ban = ban[:ban.find('$#')]
371 return ban2re(ban).match('%s!%s@%s' % (self.target_nick, self.target_ident, self.target_host))
372
373 def run_pending(just_opped = None):
374 """Check all actions and run them if all information is there"""
375 now = time.time()
376
377 for p in pending:
378 if p.channel == just_opped:
379 p.am_op = True
380
381 if p.target_nick in users and not p.resolved:
382 p.resolve_nick(request = False)
383
384 # Timeout?
385 if p.stamp < now - 10:
386 p.done()
387 continue
388
389 can_run = (bans[p.channel] and quiets[p.channel]) or not (p.do_unban or p.do_bans)
390 if can_run and p.resolved and (p.am_op or not p.needs_op):
391 p.run()
392
393 # Helper functions
394 def ban2re(data):
395 return re.compile('^' + re.escape(data).replace(r'\*','.*').replace(r'\?','.') + '$')
396
397 _valid_nickname = re.compile(r'^[-a-zA-Z0-9\[\]{}`|_]{0,30}$')
398 valid_nickname = lambda data: _valid_nickname.match(data)
399 _valid_channel = re.compile(r'^[#~].*') # OK, this is cheating
400 valid_channel = lambda data: _valid_channel.match(data)
401 _valid_mask = re.compile(r'^([-a-zA-Z0-9\[\]{}`|_*?]{0,30}!.*?@.*?|\$[ar]:.*)$')
402 valid_mask = lambda data: _valid_mask.match(data)
403
404 # Data processing
405 def do_mode(word, word_eol, userdata):
406 """Run pending actions when chanserv opped us"""
407 ctx = xchat.get_context()
408 if 'chanserv!' in word[0].lower() and '+o' in word[3] and ctx.get_info('nick') in word:
409 run_pending(just_opped = ctx.get_info('channel'))
410 xchat.hook_server('MODE', do_mode)
411
412 class User(object):
413 def __init__(self, nick, ident, host, name):
414 self.nick = nick; self.ident = ident; self.host = host; self.name = name
415 self.account = None
416 self.time = time.time()
417 def do_whois(word, word_eol, userdata):
418 """Store whois replies in global cache"""
419 nick = word[3].lower()
420 if word[1] == '330':
421 users[nick].account = word[4]
422 else:
423 users[nick] = User(nick, word[4], word[5], word_eol[7][1:])
424 xchat.hook_server('311', do_whois)
425 xchat.hook_server('330', do_whois)
426 xchat.hook_server('314', do_whois) # This actually is a /whowas reply
427
428 def do_missing(word, word_eol, userdata):
429 """Fall back to whowas if whois fails"""
430 for p in pending:
431 if p.target == word[3]:
432 p.context.command('whowas %s' % word[3])
433 break
434 xchat.hook_server('401', do_missing)
435
436 def do_endwas(word, word_eol, userdata):
437 """Display error if nickname cannot be resolved"""
438 for p in pending:
439 if p.target == word[3]:
440 xchat.emit_print("Server Error", "%s could not be found" % p.target)
441 pending.remove(p)
442 xchat.hook_server('406', do_endwas)
443
444 def endofwhois(word, word_eol, userdata):
445 """Process the queue after nickname resolution"""
446 run_pending()
447 xchat.hook_server('318', endofwhois)
448 xchat.hook_server('369', endofwhois)
449
450 xchat.hook_server('482', lambda word, word_eol, userdata: xchat.emit_print('Server Error', '%s in %s' % (word_eol[4][1:], word[3])))
451
452 def do_ban(word, word_eol, userdata):
453 """Process banlists"""
454 channel, ban = word[3:5]
455 if channel in bq_switch:
456 if bq_switch[channel] == 'b':
457 bans[channel].append(ban)
458 else:
459 quiets[channel].append(ban)
460 return xchat.EAT_ALL
461 return xchat.EAT_NONE
462 xchat.hook_server('367', do_ban)
463
464 def do_endban(word, word_eol, userdata):
465 """Process end-of-ban markers"""
466 channel = word[3]
467 if channel in bq_switch:
468 if 'Ban' in word:
469 bans[channel].append(ban_sentinel)
470 bq_switch[channel] = 'q'
471 elif 'Quiet' in word:
472 quiets[channel].append(ban_sentinel)
473 del bq_switch[channel]
474 run_pending()
475 return xchat.EAT_ALL
476 return xchat.EAT_NONE
477 xchat.hook_server('368', do_endban)
478
479 # Turn on autorejoin
480 xchat.command('SET -quiet irc_auto_rejoin ON')
481
482 def rejoin(word, word_eol, userdata):
483 """Rejoin when /remove'd"""
484 if word[0][1:word[0].find('!')] == xchat.get_info('nick') and len(word) > 3 and word[3][1:].lower() == 'requested':
485 xchat.command('join %s' % word[2])
486 xchat.hook_server('PART', rejoin)
487
488 # Unban when muted
489 xchat.hook_server('404', lambda word, word_eol, userdata: xchat.command('quote cs unban %s' % word[3]))
490
491 # Convince chanserv to let me in when key/unban/invite is needed
492 xchat.hook_server('471', lambda word, word_eol, userdata: xchat.command('quote cs invite %s' % word[3])) # 471 = limit reached
493 xchat.hook_server('473', lambda word, word_eol, userdata: xchat.command('quote cs invite %s' % word[3]))
494 xchat.hook_server('474', lambda word, word_eol, userdata: xchat.command('quote cs unban %s' % word[3]))
495 xchat.hook_server('475', lambda word, word_eol, userdata: xchat.command('quote cs getkey %s' % word[3]))
496
497 def on_invite(word, word_eol, userdata):
498 """Autojoin when chanserv invites us"""
499 if word[0] == ':ChanServ!ChanServ@services.':
500 xchat.command('join %s' % word[-1][1:])
501 xchat.hook_server('INVITE', on_invite)
502
503 def on_notice(word, word_eol, userdata):
504 """Autojoin when chanserv unbans us or sent us a key"""
505 if word[0] != ':ChanServ!ChanServ@services.':
506 return
507 if 'Unbanned' in word_eol[0]:
508 xchat.command('JOIN %s' % word[6].strip()[1:-1])
509 if 'key is' in word_eol[0]:
510 xchat.command('JOIN %s %s' % (word[4][1:-1], word[-1]))
511 xchat.hook_server('NOTICE', on_notice)
512
513 xchat.emit_print('Server Text',"Loaded %s %s by Seveas <dennis@kaarsemaker.net>" % (__module_description__, __module_version__))
Show all