Source code for cslbot.helpers.handler

# -*- coding: utf-8 -*-
# Copyright (C) 2013-2015 Samuel Damashek, Peter Foley, James Forcier, Srijay Kasturi, Reed Koser, Christopher Reffett, and Fox Wilson
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# 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.

import base64
import collections
import copy
import functools
import logging
import random
import re
import threading
import time
from datetime import datetime, timedelta

from irc import modes

from . import (admin, arguments, command, control, hook, identity, misc, orm,
               sql, textutils, tokens, workers)


[docs]class BotHandler(): def __init__(self, config, connection, channels, confdir): """ Set everything up. | kick_enabled controls whether the bot will kick people or not. | caps is a array of the nicks who have abused capslock. | abuselist is a dict keeping track of how many times nicks have used | rate-limited commands. | modules is a dict containing the commands the bot supports. | confdir is the path to the directory where the bot's config is stored. | db - Is a db wrapper for data storage. """ self.connection = connection self.channels = channels self.config = config self.db = sql.Sql(config, confdir) self.workers = workers.Workers(self) self.caps = [] self.guarded = [] self.admins = {nick.strip(): None for nick in config['auth']['admins'].split(',')} self.voiced = collections.defaultdict(dict) self.opers = collections.defaultdict(dict) self.features = {'account-notify': False, 'extended-join': False, 'whox': False} start = datetime.now() self.uptime = {'start': start, 'reloaded': start} self.abuselist = {} self.ping_map = {} self.outputfilter = collections.defaultdict(list) self.kick_enabled = True self.who_map = {} self.flood_lock = threading.Lock() self.data_lock = threading.RLock() self.last_msg_time = datetime.now() self.confdir = confdir self.log_to_ctrlchan = False
[docs] def get_data(self): """Saves the handler's data for :func:`bot.IrcBot.do_reload`""" data = {} data['caps'] = self.caps[:] data['guarded'] = self.guarded[:] data['admins'] = self.admins.copy() data['voiced'] = copy.deepcopy(self.voiced) data['opers'] = copy.deepcopy(self.opers) data['features'] = self.features.copy() data['uptime'] = self.uptime.copy() data['abuselist'] = self.abuselist.copy() return data
[docs] def set_data(self, data): """Called from :func:`bot.IrcBot.do_reload` to restore the handler's data.""" for key, val in data.items(): setattr(self, key, val) self.uptime['reloaded'] = datetime.now()
[docs] def update_authstatus(self, nick): if self.features['whox']: tag = random.randint(0, 999) # This just maps it off into nothingness, but that's okay, we just care about the auth self.who_map[tag] = nick self.connection.who('%s %%naft,%d' % (nick, tag)) elif self.config['feature']['servicestype'] == "ircservices": self.connection.privmsg('NickServ', 'STATUS %s' % nick) elif self.config['feature']['servicestype'] == "atheme": self.connection.privmsg('NickServ', 'ACC %s' % nick)
[docs] def is_admin(self, send, nick): """Checks if a nick is a admin. | If the nick is not in self.admins then it's not a admin. | If NickServ hasn't responded yet, then the admin is unverified, | so assume they aren't a admin. """ if nick not in [x.strip() for x in self.config['auth']['admins'].split(',')]: return False # no nickserv support, assume people are who they say they are. if not self.config['feature'].getboolean('nickserv'): return True # unauthed if nick not in self.admins: self.admins[nick] = None if self.admins[nick] is None: self.update_authstatus(nick) # We don't necessarily want to complain in all cases. if send is not None: send("Unverified admin: %s" % nick, target=self.config['core']['channel']) return False else: if not self.features['account-notify']: # reverify every 5min if datetime.now() - self.admins[nick] > timedelta(minutes=5): self.update_authstatus(nick) return True
[docs] def get_admins(self): """Check verification for all admins.""" # no nickserv support, assume people are who they say they are. if not self.config['feature'].getboolean('nickserv'): return for i, a in enumerate(self.admins): if a is None: self.workers.defer(i, False, self.update_authstatus, a)
[docs] def abusecheck(self, send, nick, target, limit, cmd): """ Rate-limits commands. | If a nick uses commands with the limit attr set, record the time | at which they were used. | If the command is used more than `limit` times in a | minute, ignore the nick. """ if nick not in self.abuselist: self.abuselist[nick] = {} if cmd not in self.abuselist[nick]: self.abuselist[nick][cmd] = [datetime.now()] else: self.abuselist[nick][cmd].append(datetime.now()) count = 0 for x in self.abuselist[nick][cmd]: # 60 seconds - arbitrary cuttoff if datetime.now() - x < timedelta(seconds=60): count = count + 1 if count > limit: msg = "%s: don't abuse scores!" if cmd == 'scores' else "%s: stop abusing the bot!" send(msg % nick, target=target) with self.db.session_scope() as session: send(misc.ignore(session, nick)) return True
@staticmethod
[docs] def get_max_length(target, msgtype): overhead = r"PRIVMSG %s: \r\n" % target # FIXME: what the hell is up w/ message length limits? if msgtype == 'action': overhead += "\001ACTION \001" max_len = 454 # 512 else: max_len = 453 # 512 return max_len - len(overhead.encode())
[docs] def send(self, target, nick, msg, msgtype, ignore_length=False, filters=None): """ Send a message. Records the message in the log. """ if not isinstance(msg, str): raise Exception("Trying to send a %s to irc, only strings allowed." % type(msg).__name__) msgs = [] if filters is None: filters = self.outputfilter[target] for i in filters: if target != self.config['core']['ctrlchan']: msg = i(msg) # Avoid spam from commands that produce excessive output. max_len = 650 msg = [x.encode() for x in msg] if functools.reduce(lambda x, y: x + len(y), msg, 0) > max_len and not ignore_length: msg, _ = misc.split_msg(msg, max_len) msg += "..." msg = [x.encode() for x in msg] max_len = self.get_max_length(target, msgtype) # We can't send messages > 512 bytes to irc. while functools.reduce(lambda x, y: x + len(y), msg, 0) > max_len: split, msg = misc.split_msg(msg, max_len) msgs.append(split) msgs.append(''.join([x.decode() for x in msg]).strip()) for i in msgs: self.do_log(target, nick, i, msgtype) if msgtype == 'action': self.rate_limited_send('action', target, i) else: self.rate_limited_send('privmsg', target, i)
[docs] def rate_limited_send(self, mtype, target, msg): with self.flood_lock: elapsed = datetime.now() - self.last_msg_time # Don't send messages more then once every 0.5 sec. time.sleep(max(0, 0.5 - elapsed.total_seconds())) getattr(self.connection, mtype)(target, msg) self.last_msg_time = datetime.now()
[docs] def do_log(self, target, nick, msg, msgtype): """ Handles logging. | Logs to a sql db. """ if not isinstance(msg, str): raise Exception("IRC doesn't like it when you send it a %s" % type(msg).__name__) target = target.lower() flags = 0 # Properly handle /msg +#channel if target.startswith(('+', '@')): target = target[1:] with self.data_lock: if target in self.channels: if nick in self.opers[target]: flags |= 1 if nick in self.voiced[target]: flags |= 2 else: target = 'private' # FIXME: should we special-case this? # strip ctrl chars from !creffett msg = msg.replace('\x02\x038,4', '<rage>') self.db.log(nick, target, flags, msg, msgtype) if self.log_to_ctrlchan: ctrlchan = self.config['core']['ctrlchan'] if target != ctrlchan: ctrlmsg = "%s:%s:%s:%s" % (target, msgtype, nick, msg) # If we call self.send, we'll get a infinite loop. self.connection.privmsg(ctrlchan, ctrlmsg.strip())
[docs] def do_part(self, cmdargs, nick, target, msgtype, send, c): """ Leaves a channel. Prevent user from leaving the primary channel. """ channel = self.config['core']['channel'] botnick = self.config['core']['nick'] if not cmdargs: # don't leave the primary channel if target == channel: send("%s must have a home." % botnick) return else: cmdargs = target if not cmdargs.startswith(('#', '+', '@')): cmdargs = '#' + cmdargs # don't leave the primary channel if cmdargs == channel: send("%s must have a home." % botnick) return # don't leave the control channel if cmdargs == self.config['core']['ctrlchan']: send("%s must remain under control, or bad things will happen." % botnick) return self.send(cmdargs, nick, "Leaving at the request of %s" % nick, msgtype) c.part(cmdargs)
[docs] def do_join(self, cmdargs, nick, msgtype, send, c): """ Join a channel. | Checks if bot is already joined to channel. """ if not cmdargs: send("Join what?") return if cmdargs == '0': send("I'm sorry, Dave. I'm afraid I can't do that.") return if not cmdargs.startswith(('#', '+', '@')): cmdargs = '#' + cmdargs cmd = cmdargs.split() # FIXME: use argparse if cmd[0] in self.channels and not (len(cmd) > 1 and cmd[1] == "force"): send("%s is already a member of %s" % (self.config['core']['nick'], cmd[0])) return c.join(cmd[0]) self.send(cmd[0], nick, "Joined at the request of " + nick, msgtype)
[docs] def check_mode(self, mode): if mode[2] != self.connection.real_nickname: return False if (mode[0], mode[1]) == ('-', 'o'): return True elif (mode[0], mode[1]) == ('+', 'b'): return True return False
[docs] def do_mode(self, target, msg, nick, send): """ reop and handle guard violations """ mode_changes = modes.parse_channel_modes(msg) with self.data_lock: for change in mode_changes: if change[1] == 'v': self.voiced[target][change[2]] = True if change[0] == '+' else False if change[1] == 'o': self.opers[target][change[2]] = True if change[0] == '+' else False # reop # FIXME: handle -o+o msbobBot msbobBot if [x for x in mode_changes if self.check_mode(x)]: send("%s: :(" % nick, target=target) # Assume bot admins know what they're doing. if not self.is_admin(None, nick): send("OP %s" % target, target='ChanServ') send("UNBAN %s" % target, target='ChanServ') if len(self.guarded) > 0: # if user is guarded and quieted, devoiced, or deopped, fix that regex = r"(.*(-v|-o|\+q|\+b)[^ ]*) (%s)" % "|".join(self.guarded) match = re.search(regex, msg) if match and nick not in [match.group(3), self.connection.real_nickname]: modestring = "+voe-qb %s" % (" ".join([match.group(3)] * 5)) self.connection.mode(target, modestring) send('Mode %s on %s by the guard system' % (modestring, target), target=self.config['core']['ctrlchan'])
[docs] def do_kick(self, send, target, nick, msg, slogan=True): """ Kick users. | If kick is disabled, don't do anything. | If the bot is not a op, rage at a op. | Kick the user. """ if not self.kick_enabled: return if target not in self.channels: send("%s: you're lucky, private message kicking hasn't been implemented yet." % nick) return with self.data_lock: ops = [k for k, v in self.opers[target].items() if v] botnick = self.config['core']['nick'] if botnick not in ops: ops = ['someone'] if not ops else ops send(textutils.gen_creffett("%s: /op the bot" % random.choice(ops)), target=target) elif random.random() < 0.01 and msg == "shutting caps lock off": if nick in ops: send("%s: HUEHUEHUE GIBE CAPSLOCK PLS I REPORT U" % nick, target=target) else: self.connection.kick(target, nick, "HUEHUEHUE GIBE CAPSLOCK PLS I REPORT U") else: msg = textutils.gen_slogan(msg).upper() if slogan else msg if nick in ops: send("%s: %s" % (nick, msg), target=target) else: self.connection.kick(target, nick, msg)
[docs] def do_args(self, modargs, send, nick, target, source, name, msgtype): """ Handle the various args that modules need.""" realargs = {} args = {'nick': nick, 'handler': self, 'db': None, 'config': self.config, 'source': source, 'name': name, 'type': msgtype, 'botnick': self.connection.real_nickname, 'target': target if target[0] == "#" else "private", 'do_kick': lambda target, nick, msg: self.do_kick(send, target, nick, msg), 'is_admin': lambda nick: self.is_admin(send, nick), 'abuse': lambda nick, limit, cmd: self.abusecheck(send, nick, target, limit, cmd)} for arg in modargs: if arg in args: realargs[arg] = args[arg] else: raise Exception("Invalid Argument: %s" % arg) return realargs
[docs] def do_welcome(self): """Do setup when connected to server. | Join the primary channel. | Join the control channel. """ tokens.update_all_tokens(self.config) self.connection.join(self.config['core']['channel']) self.connection.join(self.config['core']['ctrlchan'], self.config['auth']['ctrlkey']) # We use this to pick up info on admins who aren't currently in a channel. self.workers.defer(5, False, self.get_admins) extrachans = self.config['core']['extrachans'] if extrachans: extrachans = [x.strip() for x in extrachans.split(',')] # Delay joining extra channels to prevent excess flood. for i, chan in enumerate(extrachans): self.workers.defer(i, False, self.connection.join, chan)
[docs] def is_ignored(self, nick): with self.db.session_scope() as session: return session.query(orm.Ignore).filter(orm.Ignore.nick == nick).count()
[docs] def get_filtered_send(self, cmdargs, send, target): """Parse out any filters.""" parser = arguments.ArgParser(self.config) parser.add_argument('--filter') try: filterargs, remainder = parser.parse_known_args(cmdargs) except arguments.ArgumentException as ex: return str(ex), None cmdargs = ' '.join(remainder) if filterargs.filter is None: return cmdargs, send filter_list, output = textutils.append_filters(filterargs.filter) if filter_list is None: return output, None # define a new send to handle filter chaining def filtersend(msg, mtype='privmsg', target=target, ignore_length=False): self.send(target, self.connection.real_nickname, msg, mtype, ignore_length, filters=filter_list) return cmdargs, filtersend
[docs] def do_rejoin(self, c, e): # If we're still banned, this will trigger a bannedfromchan event so we'll try again. if e.arguments[0] not in self.channels: c.join(e.arguments[0])
[docs] def handle_event(self, msg, send, c, e): if e.type == 'whospcrpl': self.handle_who(e) elif e.type == 'account': self.handle_account(e) elif e.type == 'authenticate': self.handle_authenticate(e) elif e.type == 'bannedfromchan': self.workers.defer(5, False, self.do_rejoin, c, e) elif e.type == 'cap': self.handle_cap(e) elif e.type in ['ctcpreply', 'nosuchnick']: misc.ping(self.ping_map, c, e, datetime.now()) elif e.type == 'error': logging.error(e.target) elif e.type == 'featurelist': if 'WHOX' in e.arguments: self.features['whox'] = True elif e.type == 'nick': self.handle_nick(send, e) elif e.type == 'nicknameinuse': self.connection.nick('Guest%d' % random.getrandbits(20)) elif e.type == 'privnotice': if e.source.nick == 'NickServ': admin.set_admin(msg, self) elif e.type == 'welcome': self.handle_welcome()
[docs] def handle_authenticate(self, e): passwd = self.config['auth']['serverpass'] user = self.config['core']['nick'] if e.target == '+': token = base64.b64encode('\0'.join([user, user, passwd]).encode()) self.connection.send_raw('AUTHENTICATE %s' % token.decode()) self.connection.cap('END')
[docs] def handle_account(self, e): if e.source.nick in self.admins: if e.target == '*': self.admins[e.source.nick] = None else: self.admins[e.source.nick] = datetime.now()
[docs] def handle_welcome(self): passwd = self.config['auth']['serverpass'] user = self.config['core']['nick'] logging.info("Connected to server %s", self.config['core']['host']) if self.config.getboolean('feature', 'nickserv') and self.connection.real_nickname != self.config['core']['nick']: self.connection.privmsg('NickServ', 'REGAIN %s %s' % (user, passwd)) self.do_welcome()
[docs] def handle_who(self, e): # arguments: type,nick,modes,account # properly track voiced status. location = self.who_map[int(e.arguments[0])] self.voiced[location][e.arguments[1]] = '+' in e.arguments[2] self.opers[location][e.arguments[1]] = '@' in e.arguments[2] if e.arguments[1] in self.admins: if e.arguments[3] != '0': self.admins[e.arguments[1]] = datetime.now()
[docs] def handle_cap(self, e): if e.arguments[0] == 'ACK': if e.arguments[1].strip() == 'sasl': self.connection.send_raw('AUTHENTICATE PLAIN') elif e.arguments[1].strip() == 'account-notify': self.features['account-notify'] = True elif e.arguments[1].strip() == 'extended-join': self.features['extended-join'] = True
[docs] def handle_nick(self, send, e): with self.data_lock: for channel in misc.get_channels(self.channels, e.target): self.do_log(channel, e.source.nick, e.target, 'nick') # Move the voice+op status to the new nick if e.source.nick in self.voiced[channel].keys(): # In case we somehow didn't set the voice state on the old nick self.voiced[channel][e.target] = self.voiced[channel].pop(e.source.nick) if e.source.nick in self.opers[channel].keys(): # As above, for ops self.opers[channel][e.target] = self.opers[channel].pop(e.source.nick) if identity.handle_nick(self, e): for x in misc.get_channels(self.channels, e.target): self.do_kick(send, x, e.target, "identity crisis")
[docs] def handle_join(self, c, e, target, send): if self.features['whox']: tag = random.randint(0, 999) self.who_map[tag] = target if e.source.nick == c.real_nickname: # http://faerion.sourceforge.net/doc/irc/whox.var c.who('%s %%naft,%d' % (target, tag)) else: c.who('%s %%naft,%d' % (e.source.nick, tag)) if e.source.nick == c.real_nickname: send("Joined channel %s" % target, target=self.config['core']['ctrlchan']) elif self.features['extended-join']: if e.source.nick in self.admins: if e.arguments[0] == '*': self.admins[e.source.nick] = None else: self.admins[e.source.nick] = datetime.now()
[docs] def get_cmd(self, msg): cmd = msg.split()[0] cmdchar = self.config['core']['cmdchar'] cmdlen = len(cmd) + 1 # FIXME: figure out a better way to handle !s if cmd.startswith('%ss' % cmdchar): # escape special regex chars raw_cmdchar = '\\' + cmdchar if re.match(r'[\[\].^$*+?]', cmdchar) else cmdchar match = re.match(r'%ss(\W)' % raw_cmdchar, cmd) if match: cmd = cmd.split(match.group(1))[0] cmdlen = len(cmd) cmdargs = msg[cmdlen:] cmd_name = cmd[len(cmdchar):].lower() if cmd.startswith(cmdchar) else None return cmd_name, cmdargs
[docs] def run_cmd(self, send, nick, target, cmd_name, cmdargs, e): cmdargs, filtersend = self.get_filtered_send(cmdargs, send, target) if filtersend is None: send(cmdargs) return cmd_obj = command.registry.get_command(cmd_name) if cmd_obj.is_limited() and self.abusecheck(send, nick, target, cmd_obj.limit, cmd_name): return if cmd_obj.requires_admin() and not self.is_admin(send, nick): send("This command requires admin privileges.") return args = self.do_args(cmd_obj.args, send, nick, target, e.source, cmd_name, e.type) cmd_obj.run(filtersend, cmdargs, args, cmd_name, nick, target, self)
[docs] def handle_kick(self, c, e, target, send): if e.arguments[0] == c.real_nickname: send("Kicked from channel %s" % target, target=self.config['core']['ctrlchan']) # Auto-rejoin after 5 seconds. self.workers.defer(5, False, self.connection.join, target)
[docs] def handle_hooks(self, send, nick, target, e, msg): if self.config['feature'].getboolean('hooks'): for h in hook.registry.get_hook_objects(): realargs = self.do_args(h.args, send, nick, target, e.source, h, e.type) h.run(send, msg, e.type, self, target, realargs)
[docs] def handle_msg(self, c, e): """The Heart and Soul of IrcBot.""" if e.type not in ['authenticate', 'error', 'join', 'part', 'quit']: nick = e.source.nick else: nick = e.source if e.arguments is None: msg = "" else: msg = " ".join(e.arguments).strip() # Send the response to private messages to the sending nick. target = nick if e.type == 'privmsg' else e.target def send(msg, mtype='privmsg', target=target, ignore_length=False): self.send(target, self.connection.real_nickname, msg, mtype, ignore_length) if e.type in ['account', 'authenticate', 'bannedfromchan', 'cap', 'ctcpreply', 'error', 'featurelist', 'nosuchnick', 'nick', 'nicknameinuse', 'privnotice', 'welcome', 'whospcrpl']: self.handle_event(msg, send, c, e) return # ignore empty messages if not msg and e.type != 'join': return self.do_log(target, nick, msg, e.type) if e.type == 'mode': self.do_mode(target, msg, nick, send) return if e.type == 'join': self.handle_join(c, e, target, send) return if e.type == 'part': if nick == c.real_nickname: send("Parted channel %s" % target, target=self.config['core']['ctrlchan']) return if e.type == 'kick': self.handle_kick(c, e, target, send) return if e.target == self.config['core']['ctrlchan'] and self.is_admin(None, nick): control.handle_ctrlchan(self, msg, send) if self.is_ignored(nick) and not self.is_admin(None, nick): return self.handle_hooks(send, nick, target, e, msg) msg = misc.get_cmdchar(self.config, c, msg, e.type) admins = [x.strip() for x in self.config['auth']['admins'].split(',')] cmd_name, cmdargs = self.get_cmd(msg) if command.registry.is_registered(cmd_name): self.run_cmd(send, nick, target, cmd_name, cmdargs, e) # special commands elif cmd_name == 'reload' and nick in admins: send("Aye Aye Capt'n")