# Simple chanserv helper script for Xchat # (c) 2006 Dennis Kaarsemaker # # Latest version can be found on http://www.kaarsemaker.net/files/Software/ # # This script is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # version 2, as published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # Usage instructions: # Place in ~/.xchat2 for it to be autoloaded # # It adds one command to xchat: /cs # /cs understands the following arguments # o or op - Let chanserv op you/others (/cs op, /cs op somenick) # v or voice - Let hanserv give you/others voice # d or deop - Let chanserv deop you/others (/cs deop, /cs deop somenick) # dv or devoice - Let chanserv decoice you/others (/cs deop, /cs deop somenick) # k or kick - Op, kick, deop (/cs kick somenick [comment]) # b or ban - Op, ban, deop (/cs ban somenick) # f or forward - Ban a user with a forward (/cs forward nick chan) # n or nameban - GECOS ban (/cs nameban nick) # m or mute - Op, mute, deop (/cs mute somenick) # kb or kickban - Op, kickban, deop (/cs kb somenick [comment]) # kf or kickforward - Same as kb, but with a forward (/cs kf somenick channel [comment]) # kn or kicknameban - Same as kb, but with a GECOS ban (/cs kn somenick [comment]) # l or lart - A combination of kick, ban, nameban, ident ban and nick ban. # u or unban - Op, unban, deop (/cs u somenick) # t or topic - Op, set topic, deop (/cs t New topic here) # m or mode - Op, change channel mode, deop (/cs mode modes here) # i or invite - With nick as argument: op, invite, deop. Without nick: invite to a channel # # * For (kick)ban and mute, it will use the ip-address or hostname instead of # the nickname, unless you give a complete mask as argument. This works even # after a person left by using /whowas. /whowas generally works up to a few # hours after someone left. # # * Unban will remove all bans matching the nick or mask you give as argument # (* and ? wildcards work) # * It won't actually kick, but use the /remove command # * Script is made to work on Freenode, may need changes to work on other # networks # * The -n argument before any of the commands (eg: /cs -n m foo) will make you # stay opped after the act # # Apart from the /cs command it also adds automatic rejoining magic. When you # are /remove'd from a channel, it will automatically rejoin (X-chat already can # do that for you if kicked). When attempting to (re)join a channel which is # invite-only, has a key set or where you are banned, the script will poke # chanserv to let you in and will automatically let you in if ChanServ helps # # Changelog of latest versions # 0.5.0: - Add some lower() calls to make checking case-insensitive where needed # - Make sure not to /whois twice. # - Unban also removes matching realname bans. # - Unmute oneself if muted. # 0.5.1: - Tuples are not changeable, fool! Mr. T pities you. # - Add /cs update which will download the latest version # 0.5.2: - Turn the download into a thread and dont overwrite if unchanged # 0.6.0: - Use mode tracking to prevent useless /op /deop around mode or topic # changes # - Don't try to remove from the pending array twice # - Remove invalid bans instead of raising an error # 0.6.1: - Add /cs lart. PWNED! # 1.0: - Use xchat.get_info for getting the xchat dir # - If a nick in /cs u starts with 2 asterisks, a non-looked up nameban # removal will be tried. This was the last item in the todo, so this is # 1.0 # 1.0.1: - Add voice/devoice # 1.0.2: - Fix complete mask detection # - Fixed downloader __module_name__ = "chanserv" __module_version__ = "1.0.2" __module_description__ = "Chanserv helper" import xchat import time import re # Event queue pending = [] # /whois cache users = {} # /mode =bd 'cache' bans = {} _bans = {} # channel modes modes = {} intercept_akick = False KICK, BAN, MUTE, KICKBAN, UNBAN, TOPIC, MODE, NAMEBAN, KICKNAMEBAN, LART, INVITE = range(11) # Main /cs command def cs(word, word_eol, userdata): chan = xchat.get_info('channel') me = xchat.get_info('nick') ctx = xchat.get_context() deop = True if len(word) == 1: return xchat.EAT_ALL if word[1] == '-n': deop = False word.remove('-n') for w in word_eol: if w.strip().startswith('-n'): word_eol.remove(w) break comm = word[1].lower() if comm in ['o','op']: word_eol.append('') if me in word_eol[2] or word_eol[2] == '': for p in pending: if p.channel == chan: p.deop = False xchat.command('chanserv OP %s %s' % (chan, word_eol[2])) return xchat.EAT_ALL if comm in ['d','deop']: if len(word) < 3: word.append(me) if me in word[2:]: for p in pending: if p.channel == chan: p.deop = True xchat.command('chanserv OP %s %s' % (chan, ' '.join(map(lambda x: '-'+x, word[2:])))) return xchat.EAT_ALL if comm in ['v','voice']: word_eol.append('') xchat.command('chanserv VOICE %s %s' % (chan, word_eol[2])) return xchat.EAT_ALL if comm in ['dv','devoice']: if len(word) < 3: word.append(me) xchat.command('chanserv VOICE %s %s' % (chan, ' '.join(map(lambda x: '-'+x, word[2:])))) return xchat.EAT_ALL if comm in ['k','kick']: if len(word) < 3: return xchat.EAT_ALL if len(word) < 4: word_eol.append('') if word[2].lower() not in [x.nick.lower() for x in ctx.get_list('users')]: xchat.emit_print("Server Error", "%s is not in %s" % (word[2],ctx.get_info('channel'))) return xchat.EAT_ALL schedule(Action(ctx, KICK, word[2], word_eol[3]), deop) return xchat.EAT_ALL if comm in ['b','ban']: if len(word) < 3: return xchat.EAT_ALL schedule(Action(ctx, BAN, word[2]), deop) return xchat.EAT_ALL if comm in ['n','nameban']: if len(word) < 3: return xchat.EAT_ALL schedule(Action(ctx, NAMEBAN, word[2]), deop) return xchat.EAT_ALL if comm in ['f','forward']: if len(word) < 4: return xchat.EAT_ALL if word[3][0] != '#': xchat.emit_print("Server Error", "You can only forward to a channel"); return xchat.EAT_ALL schedule(Action(ctx, BAN, word[2], forward_channel=word[3]), deop) return xchat.EAT_ALL if comm in ['m','mute']: if len(word) < 3: return xchat.EAT_ALL if word[2][0] not in "+-=": schedule(Action(ctx, MUTE, word[2]), deop) return xchat.EAT_ALL if comm in ['kb','kickban']: if len(word) < 3: return xchat.EAT_ALL if len(word) < 4: word_eol.append('') schedule(Action(ctx, KICKBAN, word[2], word_eol[3]), deop) return xchat.EAT_ALL if comm in ['kn','kicknameban']: if len(word) < 3: return xchat.EAT_ALL if len(word) < 4: word_eol.append('') schedule(Action(ctx, KICKNAMEBAN, word[2], word_eol[3]), deop) return xchat.EAT_ALL if comm in ['kf','kickforward']: if len(word) < 4: return xchat.EAT_ALL if word[3][0] != '#': xchat.emit_print("Server Error", "You can only forward to a channel"); return xchat.EAT_ALL if len(word) < 5: word_eol.append('') schedule(Action(ctx, KICKBAN, word[2], word_eol[4], word[3]), deop) return xchat.EAT_ALL if comm in ['u','unban']: if len(word) < 3: return xchat.EAT_ALL if word[2].startswith('**'): schedule(Action(ctx, UNBAN, word_eol[2]), deop) else: schedule(Action(ctx, UNBAN, word[2]), deop) return xchat.EAT_ALL if comm in ['l','lart']: if len(word) < 3: return xchat.EAT_ALL schedule(Action(ctx, LART, word[2]), deop) return xchat.EAT_ALL if comm in ['t','topic']: if len(word) < 3: return xchat.EAT_ALL schedule(Action(ctx, TOPIC, word_eol[2]), deop) return xchat.EAT_ALL if comm in ['m','mode']: if len(word) < 3: return xchat.EAT_ALL schedule(Action(ctx, MODE, word_eol[2]), deop) return xchat.EAT_ALL if comm in ['i','invite']: if len(word) < 3: return xchat.EAT_ALL if word[2][0] == '#': xchat.command('chanserv INVITE %s' % (word[2])) else: if word[2].lower() in [x.nick.lower() for x in ctx.get_list('users')]: xchat.emit_print("Server Error", "%s is already in %s" % (word[2],ctx.get_info('channel'))) return xchat.EAT_ALL schedule(Action(ctx, INVITE, word_eol[2]), deop) return xchat.EAT_ALL if comm in ['update']: import thread thread.start_new_thread(download,tuple([])) return xchat.EAT_ALL # /cs is an alias for chanserv too, so don't eat anything if we're not able # to fulfill the request return xchat.EAT_NONE xchat.hook_command('cs',cs,"For help with /cs, please read the comments in the script") # Action class, quite powerful and extendable class Action: def __init__(self, ctx, typ, arg, comment='', forward_channel=''): self.ctx = ctx self.typ = typ self.arg = arg self.nick = arg.lower() self.comment = comment self.forward_channel = forward_channel self.completemask = False self.realname = '' self.mask = None if typ in [MUTE, BAN, KICKBAN, UNBAN]: if self.nick.startswith('**'): self.realname = self.nick[2:] self.nick = '' self.mask = ('','') if '!' in self.nick and '@' in self.nick and self.nick.find('!') < self.nick.find('@'): self.nick, self.mask = self.nick.split('!',1) self.completemask = True if '@' in self.mask: self.mask = list(self.mask.split('@',1)) self.channel = ctx.get_info('channel') self.stamp = time.time() def run(self): # Now perform actions if self.typ == TOPIC: self.ctx.command("TOPIC %s" % self.arg) if self.typ == MODE: self.ctx.command("MODE %s %s" % (self.channel, self.arg)) if self.typ == INVITE: self.ctx.command("INVITE %s" % (self.arg)) if self.typ == UNBAN: for b in bans[self.channel]: if self.match(b): if '!' in b and '@' in b: self.ctx.command("MODE %s -b %s" % (self.channel, b)) else: self.ctx.command("MODE %s -d :%s" % (self.channel, b)) if self.typ in [KICK, KICKBAN, KICKNAMEBAN, LART]: self.ctx.command("REMOVE %s %s :%s" % (self.channel, self.nick, self.comment)) if self.typ in [BAN, KICKBAN, MUTE, LART]: mode = 'b' if self.forward_channel: self.mask[1] += '!' + self.forward_channel if self.typ == MUTE: mode = 'q' if self.completemask: self.ctx.command("MODE %s +%s %s!%s@%s" % (self.channel, mode, self.nick, self.mask[0], self.mask[1])) else: self.ctx.command("MODE %s +%s *!*@%s" % (self.channel, mode, self.mask[1])) if self.typ in [NAMEBAN, KICKNAMEBAN, LART]: self.ctx.command("MODE %s +d %s" % (self.channel, self.realname.replace(' ','?'))) if self.typ == LART: # Still todo: ident ban and nick ban self.ctx.command("MODE %s +b %s!*@*" % (self.channel, self.nick)) self.ctx.command("MODE %s +b *!%s@*" % (self.channel, self.mask[0])) def match(self, ban): if '!' in ban and '@' in ban: # Not 100% reliable but it'll do try: nick, host = ban.split('!')[:2] # Trim !#foo channel forward ident, host = host.split('@') except: # If this happens, the ban is invalid and we should remove it anyway return True if nick[0] == '%': nick = nick[1:] for mtch, me in [(nick, self.nick), (ident, self.mask[0]), (host, self.mask[1])]: mtch = '^%s$' % re.escape(mtch).replace(r'\*','.*').replace(r'\?','.') if not re.match(mtch,me,re.I): return False return True mtch = '^%s$' % re.escape(ban).replace(r'\*','.*').replace(r'\?','.') if not re.match(mtch,self.realname,re.I): return False return True def n2a(self,request=False): if self.nick in users: if users[self.nick][0]: if users[self.nick][3] > time.time() - 10: self.mask = list(users[self.nick][0:2]) self.realname = users[self.nick][2] return if request: self.ctx.command('whois %s' % self.nick) self.mask = None def schedule(event, deop): # Add event to the pending queue and make sure all neccessary commands are # issued. Don't op if not sure the nick is there pending.append(event) # Am I op? for user in event.ctx.get_list('users'): if user.nick == event.ctx.get_info('nick') and user.prefix == '@': event.am_op = True break else: event.am_op = False # Deop afterwards? event.deop = deop if event.deop: event.deop = not event.am_op for p in pending: if p.channel == event.channel and p.deop: event.deop = True # Do I know the nick if event.typ in (BAN, KICKBAN, MUTE, UNBAN) and not event.mask: event.n2a(request=True) if event.typ in (NAMEBAN, KICKNAMEBAN) and not event.realname: event.n2a(request=True) if event.typ == LART and (not event.mask or not event.realname): event.n2a(request=True) # Do I have all bans if event.typ == UNBAN and event.channel not in bans: _bans[event.channel] = [] event.ctx.command("MODE %s =bd" % event.channel) # Do I have all modes if event.typ in [TOPIC, MODE] and event.channel not in modes: event.ctx.command("MODE %s" % event.channel) run_pending() def run_pending(just_opped = None): for p in pending: # Timeout? if p.stamp < time.time() - 10: if p.deop and len([x for x in pending if x.channel == p.channel]) == 0: p.ctx.command('chanserv OP %s -%s' % (p.channel, p.ctx.get_info('nick'))) pending.remove(p) continue if p.channel == just_opped: p.am_op = True if p.typ in (BAN, KICKBAN, MUTE, UNBAN, LART) and not p.mask: p.n2a() if p.typ in (NAMEBAN, KICKNAMEBAN, LART) and not p.realname: p.n2a() # Run! # Mode check here! TODO if p.typ == MODE and p.channel in modes: if not modes[p.channel].would_change([x for x in p.arg.split() if x]): pending.remove(p) return if (p.typ in (BAN, KICKBAN, MUTE) and p.mask) or \ (p.typ in (NAMEBAN, KICKNAMEBAN) and p.realname) or \ (p.typ == UNBAN and p.channel in bans and p.mask) or \ (p.typ in (MODE, TOPIC) and p.channel in modes) or \ (p.typ == LART and p.realname and p.mask) or \ p.typ in (KICK,INVITE): if p.am_op or (p.typ == TOPIC and 't' not in modes[p.channel].modeset): p.run() pending.remove(p) if p.typ == UNBAN and len([x for x in pending if x.channel == p.channel and x.typ == UNBAN]) == 0: bans.pop(p.channel) if p.deop and len([x for x in pending if x.channel == p.channel]) == 0: p.ctx.command('chanserv OP %s -%s' % (p.channel, p.ctx.get_info('nick'))) else: p.ctx.command('chanserv OP %s %s' % (p.channel, p.ctx.get_info('nick'))) # Run commands after chanserv ops def do_mode(word, word_eol, userdata): ctx = xchat.get_context() if 'chanserv!' in word[0].lower() and '+o' in word[3] and ctx.get_info('nick') in word: run_pending(just_opped = ctx.get_info('channel')) xchat.hook_server('MODE', do_mode) # Run commands after /whois returns data def do_whois(word, word_eol, userdata): users[word[3].lower()] = (word[4], word[5], word_eol[7][1:], time.time()) run_pending() xchat.hook_server('311', do_whois) xchat.hook_server('314', do_whois) # This actually is a /whowas reply # Do /whowas is /whois fails def do_missing(word, word_eol, userdata): for p in pending: if p.nick == word[3]: p.ctx.command('whowas %s' % word[3]) break xchat.hook_server('401', do_missing) # Display an error if /whowas also fails def do_endwas(word, word_eol, userdata): for p in pending: if p.nick == word[3]: xchat.emit_print("Server Error", "%s could not be found" % p.nick) pending.remove(p) xchat.hook_server('406', do_endwas) # Add ban data tot cache (reply of /mode =b) def do_ban(word, word_eol, userdata): if word[3] in _bans: _bans[word[3]].append(word[4]) return xchat.EAT_ALL xchat.hook_server('367', do_ban) # Run commands after all bans are shown # It does mode =bd, so 2 368 have to be received before any action is taken MARKER = '@@@@@@' # This is invalid as ban, so is safe, the +b bans come first MARKER2 = '!!!!!!' def do_endban(word, word_eol, userdata): if word[3] in _bans: if MARKER in _bans[word[3]]: _bans[word[3]].remove(MARKER) bans[word[3]] = _bans[word[3]] del(_bans[word[3]]) run_pending() else: _bans[word[3]].append(MARKER) return xchat.EAT_ALL xchat.hook_server('368', do_endban) # Autorejoin on /remove and /kick xchat.command('SET -quiet irc_auto_rejoin ON') def rejoin(word, word_eol, userdata): if word[0][1:word[0].find('!')] == xchat.get_info('nick') and word[3][1:].lower() == 'requested': xchat.command('join %s' % word[2]) xchat.hook_server('PART', rejoin) # Try to convince chanserv to let me in def letmein(word, word_eol, userdata): if word[1] == '473': xchat.command('quote cs invite %s' % word[3]) elif word[1] == '474': xchat.command('quote cs unban %s' % word[3]) elif word[1] == '475': xchat.command('quote cs getkey %s' % word[3]) xchat.hook_server('473', letmein) # +i xchat.hook_server('474', letmein) # +b xchat.hook_server('475', letmein) # +k def unmute(word, word_eol, userdata): xchat.command('cs unban %s' % xchat.get_info('nick')) xchat.hook_server('404', unmute) class ModeSet: def __init__(self, raw_modes=[], modeset={}, new=False): self.modeset = dict(modeset) # Always copy if raw_modes: self.merge(raw_modes) self.new = new def merge(self, raw_modes): newmodes = raw_modes[0]; args = raw_modes[1:] num_plusmin = newmodes.count('+') + newmodes.count('-') do_set = True if len(args) > len(newmodes) - num_plusmin or newmodes[0] not in '+-': xchat.emit_print('Server Error', 'Woah mitzy, be careful with your modes :)') else: for i in range(len(newmodes)): m = newmodes[i] if m == '+': do_set = True; num_plusmin -= 1 elif m == '-': do_set = False; num_plusmin -= 1 elif m.isalnum(): if m in 'vohbdqa': continue argp = len(newmodes) - i - len(args) - num_plusmin if argp == 0: self.modeset[m] = args[0] args = args[1:] else: if do_set: self.modeset[m] = True elif m in self.modeset: self.modeset.pop(m) else: xchat.emit_print('Server Error', 'Whoah mitzy, be careful with your modes :)') def would_change(self, raw_modes): # Always return true for operator/voice etc... changes for m in raw_modes[0]: if m in 'vohbdqa': return True return str(ModeSet(raw_modes, self.modeset)) != str(self) def __str__(self): return '{' + ','.join(['%s:%s' % (x, self.modeset[x]) for x in sorted(self.modeset.keys())]) + '}' def do_mode2(word, word_eol, userdata): if userdata: ret = xchat.EAT_NONE c = word[3] if c[0] != '#': return if c not in modes: ret = xchat.EAT_ALL modes[c] = ModeSet(word[4:],new=c not in modes) run_pending() return ret else: # Let's be lazy here c = word[2] if c[0] != '#': return if c not in modes: xchat.command("MODE %s" % c) else: modes[c].merge(word[3:]) xchat.hook_server('324',do_mode2, True) xchat.hook_server('MODE',do_mode2, False) def do_time(word, word_eol, userdata): if word[3] in modes and modes[word[3]].new: modes[word[3]].new = False return xchat.EAT_ALL xchat.hook_server('329',do_time, True) def joincb(word, word_eol, userdata): modes[word[1]] = ModeSet() xchat.hook_command('join', joincb, priority=xchat.PRI_HIGHEST) # Did chanserv let me in? - This function is now misnamed as it's used for akick as well def join(word, word_eol, userdata): global intercept_akick if word[0] == ':ChanServ!ChanServ@services.': if word[1] == 'INVITE': xchat.command('JOIN %s' % word[-1][1:]) if 'have been cleared' in word_eol[0]: xchat.command('JOIN %s' %word[-1]) if 'key is' in word_eol[0]: xchat.command('JOIN %s %s' % (word[4][2:-2], word[-1][2:-2])) # Work around xchat stupidness by always writing chanserv notices to the # current context # Intercept akick lists if needed if intercept_akick: if 'AutoRemove' in word_eol[0] or 'Num Hostmask' in word_eol[0] or '--- --------' in word_eol[0]: return xchat.EAT_ALL if '-- End of list --' in word_eol[0]: intercept_akick=False # FIXME Remove marker run_pending() else: # FIXME Parse hostmask and add to bans pass if word[1] == 'NOTICE': xchat.emit_print("Notice", 'ChanServ', word_eol[3][2:]) return xchat.EAT_ALL xchat.hook_server('NOTICE', join) xchat.hook_server('INVITE', join) def download(): import urllib2, os xchat.emit_print('Server Text','Trying to download chanserv.py from kaarsemaker.net') try: fd = open(os.path.join(xchat.get_info('xchatdir'),'chanserv.py')) old_cs = fd.read() fd.close() fd = urllib2.urlopen('http://kaarsemaker.net/files/Software/chanserv.py') new_cs = fd.read() fd.close() if old_cs == new_cs: xchat.emit_print('Server Text','No new version of chanserv.py is available') return # Basic sanity check if 'Seveas' in new_cs and 'chanserv.py' in new_cs and 'xchat.hook_server' in new_cs: fd2 = open(os.path.join(xchat.get_info('xchatdir'),'chanserv.py'),'w') fd2.write(new_cs) fd2.close() xchat.emit_print('Server Text','chanserv.py updated -- reload with /py reload chanserv.py') else: xchat.emit_print('Server Error','Downloading chanserv.py failed - downloaded file not correct') except: xchat.emit_print('Server Error','Failed to update chanserv.py') # Spam! xchat.emit_print('Server Text',"Loaded %s %s by Seveas " % (__module_description__, __module_version__))