# 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., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
#
# Usage instructions:
# Place in ~/.xchat 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)
# d  or deop    - Let chanserv deop you/others (/cs deop, /cs deop somenick)
# k  or kick    - Op, kick, deop (/cs kick somenick [reason])
# b  or ban     - Op, ban, deop (/cs ban somenick)
# m  or mute    - Op, mute, deop (/cs mute somenick)
# kb or kickban - Op, kickban, deop (/cs kb somenick)
# 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)
#
# * 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 to any of the commands will make you stay opped
#
# 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
# 0.1:   First version
# 0.1.2: Added /whois trick to always ban *!*@ip
#        Added timeouts for commands
# 0.2.0: Since the actions are getting complicated: make a class of them. Also
#        get rid of multi-nick handling and don't bother unhooking...
# 0.2.1: Add unban functionality
# 0.2.2: Fixed said functionality
# 0.2.3: Make /cs o work in case of failed unbanning, put a timeout before
#        deoping.
# 0.2.4: Remove timeout after correct fix
# 0.3.0: Make it case-insensitive and add evil /whowas hook to ban users who quit
# 0.3.1: Make complete-masks-as-arguments work, add /cs t(opic) and /cs m(ode)
# 0.4.0: Move all checks to the same place, simplifying the logic, also don't op
#        for undoable kick/ban/mute actions. Add autorejoin helpers.
# 0.4.1: Fix the ban cache to make sure it updates
# 0.4.2: add -n switch to make sure not to deop afterwards. Always write
#        chanserv notices to current context
# 0.4.3: Automatically enable autorejoin for /kick too, REALLY fix the caching
#
# Maybe todo (patches appreciated):
# - Don't op for /cs t if the channel is on -t
# - Don't op if mode (/cs m) already (un)set
#   (Both require extra messages to retrieve mode, or mode tracking)
# - Don't display /who{i,wa}s replies for scrip-initiated /who{i,wa}s
#   (Requires some extra hooks and logic)

__module_name__        = "chanserv"
__module_version__     = "0.4.3"
__module_description__ = "Chanserv helper"

import xchat
import time
import re

# Event queue
pending = []
# /whois cache
users = {}
# /mode =b 'cache'
bans = {}
_bans = {}

KICK, BAN, MUTE, KICKBAN, UNBAN, TOPIC, MODE = range(7)

# 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

    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 '-n' in word:
        deop = False
        word.remove('-n')
        for w in word_eol:
            if w.strip().startswith('-n'):
                word_eol.remove(w)
                break

    if comm in ['k','kick']:
        if len(word) < 3: return xchat.EAT_ALL
        if len(word) < 4: word_eol.append('')
        if word[2] not in [x.nick for x in ctx.get_list('users')]:
            xchat.emit_print("Server Error", "%s is not in %s" % (word[2],ctx.get_info('channel')))
            return
        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 ['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 ['u','unban']:
        if len(word) < 3: return xchat.EAT_ALL
        schedule(action(ctx, UNBAN, 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
    
    # /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=''):
        self.ctx = ctx
        self.typ = typ
        self.arg = arg
        self.nick = arg.lower()
        self.comment = comment
        self.completemask = False
        if typ in [MUTE, BAN, KICKBAN, UNBAN]:
            if '!' in self.nick and '@' in self.nick:
                self.nick, self.mask = self.nick.split('!',1)
                self.completemask = True
                if '@' in self.mask:
                    self.mask = self.mask.split('@',1)
            else:
                 self.mask = None
            
        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" % self.arg)
            
        if self.typ == UNBAN:
            for b in bans[self.channel]:
                if self.match(b):
                    self.ctx.command("MODE %s -b %s" % (self.channel, b))
                    
        if self.typ in [KICK, KICKBAN]:
            self.ctx.command("REMOVE %s %s :%s" % (self.channel, self.nick, self.comment))
            
        if self.typ in [BAN, KICKBAN, MUTE]:
            mode = 'b'
            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]))

    def match(self, ban):
        nick, host =  ban.split('!')
        ident, host = host.split('@')
        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):
                return False
        return True
    
    def n2a(self,request=True):
        # First check internal data
        ctxusers = self.ctx.get_list('users')
        for u in ctxusers:
            if u.nick == self.nick:
                if not u.host:
                    break
                self.mask = u.host.split('@')
                return

        # If not available: whois and cache for 10 seconds max
        if self.nick in users:
            if users[self.nick][0]:
                if users[self.nick][1] > time.time() - 10:
                    self.mask = users[self.nick][0]
                    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)

    # Do I have all bans
    if event.typ == UNBAN and event.channel not in bans:
        _bans[event.channel] = []
        event.ctx.command("MODE %s =b" % 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)

        if p.channel == just_opped:
            p.am_op = True
        if p.typ in (BAN, KICKBAN, MUTE, UNBAN) and not p.mask:
            p.n2a()

        # Run!
        if (p.typ in (BAN, KICKBAN, MUTE) and p.mask) or \
           (p.typ == UNBAN and p.channel in bans and p.mask) or \
           (p.typ in (MODE, TOPIC, KICK)):
            if not p.am_op:
                p.ctx.command('chanserv OP %s %s' % (p.channel, p.ctx.get_info('nick')))
            else:
                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')))
        
# 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:6], 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
def do_endban(word, word_eol, userdata):
    if word[3] in _bans:
        bans[word[3]] = _bans[word[3]]
        del(_bans[word[3]])
        run_pending()
        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

# Did chanserv let me in?
def join(word, word_eol, userdata):
    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
        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)

# Spam!
print "Loaded %s %s by Seveas <dennis@kaarsemaker.net>" % (__module_description__, __module_version__)

