Advertisement
Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- '''
- Automated Python IRC bot structure.
- This is a threaded bot class designed to make simple the creation of IRC
- bots with Python. It is inspired by the cmd standard library module and
- is very similar to it in many respects, including its setup.
- Various security features are implemented, including a levels system,
- which allows individual commands to restrict access to groups of users,
- and an access system, which allows groups of commands to provide access
- to individual users.
- An extension system allows new commands to be added. Extensions may be
- loaded with the bot or dynamically. Preprocessors and postprocessors can
- also be created.
- '''
- from __future__ import unicode_literals, print_function
- import sys
- import os
- # The threading module is not available on all platforms.
- try:
- import threading
- except ImportError:
- import dummy_threading as threading
- import socket
- import time
- import re
- import itertools
- # Compatibility stuff to make Python 2 behave more like py3k.
- if sys.version_info.major == 3:
- import queue
- import configparser
- deutf = lambda s: str(s, 'utf-8')
- basestring = (str, bytes)
- else:
- import Queue as queue
- import ConfigParser as configparser
- deutf = lambda s: unicode(s, 'utf-8')
- __all__ = ['Bot', 'User', 'Channel', 'deutf', 'message_re']
- __version__ = '0.0.0a1'
- class User(object):
- '''User object for IRC bots.
- This helps keep track of user information.
- '''
- def __init__(self, nick, user, real, host, mode, channels):
- '''Frobnicate the bizbang.
- nick, user, real, and host correspond to the nickname, username,
- realname, and hostmask of the user, respectively.
- mode is the user's mode, including op/voice/ban/&c status.
- channels is a list of Channel objects for each channel in which
- both the user and this bot are.
- '''
- # target is the target of Bot.message() calls.
- self.target = nick
- self.user = user
- self.real = real
- self.host = host
- self.mode = mode
- self.channels = channels
- self.level = 0
- def __str__(self):
- return self.target
- __slots__ = ('target', 'user', 'real', 'host', 'mode', 'channels', 'level')
- class Channel(object):
- '''Channel object for IRC bots.
- This helps keep track of users, modes, and topics of channels.
- '''
- def __init__(self, name, users, mode, topic):
- '''Catenifrob the bangbiz.
- name is the channel's name, including the initial # (or &).
- users is a list of User objects for each user in the channel.
- mode is the channel's mode, not including modes applying
- specifically to individual users (op, voice, ban, &c).
- topic is the channel's topic.
- '''
- # target is the target of Bot.message() calls.
- self.target = name
- self.users = users
- self.mode = mode
- self.topic = topic
- __slots__ = ('target', 'users', 'mode', 'topic')
- class Bot(object):
- '''IRC bot class.
- New bots may subclass this or use only extension modules. The
- default implementation provides the commands join, to have the bot
- join a channel; part, to have it leave a channel; quit, to have it
- disconnect from the server; load, to load an extension; unload, to
- disable an extension; levels, to set a user's security level;
- access, to modify a command's access list; identify, to give a user
- owner-level priveleges; raw, to have the bot send a raw string to
- the server (not run through any postprocessors); and eval, to have
- the bot execute an arbitrary Python expression.
- To define a new command named, for example, `spam', define a method
- named `do_spam(self, channel, sender, line, time)'. channel will be
- a Channel object representing the originating channel or the sending
- user if it was sent in a personal message; the sender argument will
- be a User object corresponding to the user who sent the message;
- line will be a string containing the line accompanying the command
- call; and time will be the struct_time for the time when the
- message was sent as returned by time.localtime(). For example, if a
- user with nick `spam' attempts to call a command called `say' in
- #foobar as follows:
- <spam> !say Ni.
- the bot will call
- do_say(<Channel '#foobar'>, <User 'spam'>, 'Ni.', time.localtime()).
- The return value for a command must be a string which will be sent
- to the server, unless it is empty. It will be run through any
- postprocessors which have been defined.
- To specify security levels for a command named, for example, `eggs',
- create an attribute named `level_eggs' with the numeric level. If
- the level is None, only identified users will be allowed to use it;
- if it is not defined, it will be treated as 0.
- '''
- def __init__(self, nick, user=None, realname=None, server=None, port=None,
- pass_=None, channel=None, idpass=None, debug=None,
- eval_allowed=None, raw_allowed=None, configfile=None):
- '''Spawn more overlords.
- nick, user, and realname are the bot's nickname, username, and
- realname.
- server is the server to which the bot should connect.
- If port is not given, 6667 will be used.
- pass_ is the password with which to connect. If not given, none
- will be sent.
- channel may be a single channel name or a sequence of them.
- idpass should be a string used as a password for giving users
- owner privileges via the identify command.
- If debug is True, a lot of information you probably don't need
- to know will be printed. self.debug will be set to True, too.
- Setting eval_allowed to False will disable the eval command. Use
- this to prevent the crazy people to whom you have given your
- idpass from rm -rf /ing you.
- raw_allowed is the same as eval_allowed but for raw instead of
- eval.
- configfile may be the path to a file containing these options in
- the format used by the configfile standard library module. The
- option names are the same, with the nick specifying the section
- name. Options specified both here and in the config file use the
- former. If the config file does not specify an option, the same
- default is used as here.
- This method calls pre_init_hook() before doing anything and
- post_init_hook() after it has finished. The former does nothing
- by default; the latter calls self.check_init().
- '''
- self.pre_init_hook()
- if configfile is not None:
- # Note to self: Bo Burnham is an excellent comedian.
- # Note to self: Sources are not the best places for notes to self.
- cfg = configparser.SafeConfigParser(
- {
- 'user': None, 'realname': None, 'server': None,
- 'port': 6667, 'pass_': None, 'channel': None,
- 'idpass': None, 'debug': False, 'eval_allowed': True,
- 'raw_allowed': True
- }, dict)
- with open(configfile) as f:
- cfg.readfp(f)
- self.user = cfg.get(nick, 'user')
- self.realname = cfg.get(nick, 'realname')
- self.server = cfg.get(nick, 'server')
- self.port = cfg.getint(nick, 'port')
- self.pass_ = cfg.get(nick, 'pass')
- c = cfg.get(nick, 'channel')
- if c and ',' in c:
- self.channels = dict(zip(eval(c, {}, {}),
- itertools.repeat(None)))
- else:
- self.channels = c
- self.idpass = cfg.get(nick, 'idpass')
- self.debug = cfg.getboolean(nick, 'debug')
- self.eval_allowed = cfg.getboolean(nick, 'eval_allowed')
- self.raw_allowed = cfg.getboolean(nick, 'raw_allowed')
- if user is not None:
- self.user = user
- if realname is not None:
- self.realname = realname
- if server is not None:
- self.server = server
- if port is not None:
- self.port = port
- if pass_ is not None:
- self.pass_ = pass_
- if channel is not None:
- if isinstance(channel, basestring):
- self.channels = {channel: None}
- else:
- self.channels = dict(zip(channel, itertools.repeat(None)))
- if idpass is not None:
- self.idpass = idpass
- if debug is not None:
- self.debug = debug
- if eval_allowed is not None:
- self.eval_allowed = eval_allowed
- self.modules = ['builtins']
- if self.debug:
- self.realtimes = {'builtins': [self.debug_print]}
- else:
- self.realtimes = {'builtins': []}
- self.preprocessors = {'builtins': []}
- self.commands = {'builtins': ['join', 'part', 'quit', 'load', 'unload',
- 'levels', 'access', 'raw', 'eval']}
- self.postprocessors = {'builtins': []}
- self.post_init_hook()
- def connect(self):
- '''Connect to the server and begin processing.
- Once this is called, the bot becomes autonomous.
- '''
- self.pre_connect_hook()
- if not getattr(self, 'params_checked', False):
- raise AttributeError('parameters not checked')
- # The send thread manages all messages to the server.
- self.send_thread = threading.Thread(target=self.send_mgr, args=(self,))
- # The recv thread delegates (or responds to) messages from the server.
- self.recv_thread = threading.Thread(target=self.recv_mgr, args=(self,))
- self.recv_thread.daemon = True #TODO: remember why this was a bad idea
- # The work thread runs commands.
- self.work_thread = threading.Thread(target=self.work_mgr, args=(self,))
- self.work_thread.daemon = True
- # The send queue is used by the send thread for unknown purposes.
- self.send_queue = queue.Queue()
- # The send timer is used by the send thread to prevent flooding.
- self.send_timer = time.time()
- # The work queue is used by the work thread for well-known purposes.
- self.work_queue = queue.Queue()
- # Initialize the send queue.
- if self.pass_ is not None:
- self.rawmsg('PASS ' + self.pass_)
- self.rawmsg('NICK ' + self.nick)
- self.rawmsg('USER ' + self.user + ' * * :' + self.realname)
- # More hooks, please.
- self.pre_connect_start_hook()
- self.running = True # I'll just leave this here.
- # Connection begins with YOU!!
- self.socket = socket.create_connection((self.server, self.port), 180)
- # Start the threads.
- self.send_thread.start()
- self.recv_thread.start()
- self.work_thread.start()
- self.post_connect_hook()
- # Optionally block.
- if getattr(self, 'join_send_thread', True):
- self.send_thread.join()
- return self.exit_hook()
- def reconnect(self):
- '''Reconnect to the server.
- This is chiefly aimed at reconnecting after a ping timeout or
- some other unexpected disconnection.
- '''
- self.pre_reconnect_hook()
- # Close the connection in case it really is still active.
- self.socket.close()
- self.socket = socket.create_connection((self.server, self.port), 180)
- if self.pass_ is not None:
- self.rawmsg('PASS ' + self.pass_)
- self.rawmsg('NICK ' + self.nick)
- self.rawmsg('USER ' + self.user + ' * * :' + self.realname)
- self.post_reconnect_hook()
- def send_wait(self):
- '''Block until it is safe to send the next message.'''
- if time.time() > self.send_timer:
- self.send_timer = time.time()
- elif time.time() < self.send_timer - 7:
- time.sleep(2)
- self.send_timer += 2
- def send_mgr(self):
- '''Manage messages sent to the server.
- This should prevent the bot from flooding.
- '''
- while self.running:
- m = self.send_queue.get().rstrip('\r\n\0').encode('utf-8')
- if m:
- # If the message is too long, it must be split.
- if len(m) > 510: # This is the maximum in RFC 1459.
- # PRIVMSGs and NOTICEs need to be split such that they all
- # go where they are intended to go.
- s = m.split(' ', 2)
- # It is assumed that no message will contain no spaces yet
- # still be over the limit.
- #TODO: make this more general.
- if len(s) == 2:
- t, r = s
- else:
- t = ' '.join(s[:2])
- r = s[2]
- if r.startswith(':'):
- t += ' :'
- r = r[1:]
- else:
- t += ' '
- while len(r) > 510 - len(t):
- self.send_wait()
- #TODO: prevent any chance of paramaters being split
- n = self.socket.send(t + r[:511] + '\r\n')
- if n == 0:
- # The connection has been broken by this point.
- self.reconnect()
- continue
- #TODO: I don't think this is right.
- r = r[n:]
- self.send_wait()
- n = self.socket.send(t + r + '\r\n')
- while not n:
- self.reconnect()
- self.send_wait()
- n = self.socket.send(t + r + '\r\n')
- else:
- # The message may be sent in its entirety.
- while True:
- self.send_wait()
- n = self.socket.send(t + r + '\r\n')
- if n > 0:
- break
- self.reconnect()
- if m.startswith('QUIT'):
- # The bot has quit; end this thread.
- self.running = False
- self.socket.close()
- def recv_mgr(self):
- '''Manage messages received from the server.
- This responds to PINGs immediately and delegates other tasks to
- the work thread.
- '''
- while self.running:
- messages = self.socket.recv(4096).split('\r\n')
- for message in messages:
- message = message.encode('utf-8')
- m = message_re.match(message)
- if m is None:
- if self.debug:
- print('XXX invalid message from server:\n', message)
- continue
- args = m.groupdict()
- if args['command'] is None:
- #TODO: handle numeric replies
- if args['command'] == 'PING':
- self.rawmsg('PONG ' + args['middle'])
- continue
- stop = False
- for group in self.realtimes:
- if self.debug:
- print('+ realtime group {0!r}'.format(group))
- for f in group:
- if f(self, args):
- stop = True
- break
- if stop:
- break
- if stop:
- continue
- if args['command'] == 'PRIVMSG':
- if args['message'].startswith(self.prefix):
- try:
- command, params = \
- args[len(self.prefix):].split(None, 1)
- except IndexError:
- command, params = args[len(self.prefix):], ''
- s = 'do_' + command
- if hasattr(self, s):
- level = getattr(self, 'level_' + command, 0)
- if args['nick'] in self.users:
- u = self.users[args['nick']]
- else:
- # The user is unknown; create a dummy and ask
- # for the user's information later.
- u = User(args['nick'], '', '', '', '', [])
- self.rawmsg('WHOIS ' + args['nick'])
- if (level is None or
- (level > u.level and
- args['nick'] not in self.accesses[command]) and
- args['nick'] not in self.identified):
- self.disallowed()
- continue
- if args['middle'] not in self.channels:
- # The message was most likely sent in a PM.
- target = u
- self.work_queue.put(
- (getattr(self, s),
- (target, u, params, time.time())))
- def rawmsg(self, msg):
- '''Post a message to be sent to the server with no postprocessing.'''
- self.send_queue.put(msg)
- def send(self, target, msg, ctcp=False, as_notice=False):
- r'''Post a PRIVMSG to be sent to the server.
- target should be a User or Channel object to which the message
- should be sent.
- msg should be the string to be sent.
- If ctcp is True, CTCP dequoting will be performed and the
- message will be wrapped in \001 characters.
- If as_notice is True, a NOTICE will be sent instead of a
- PRIVMSG.
- A message is run through all loaded postprocessors before
- anything else happens, unless ctcp is True and the first word is
- not ACTION.
- '''
- pp = True
- if ctcp:
- t = msg.split(' ', 1)
- if len(t) > 1:
- query, msg = t
- else:
- query, msg = t[0], ''
- if query != 'ACTION':
- pp = False
- if pp:
- for group in self.postprocessors:
- if self.debug:
- print('+ postprocessor group {0!r}'.format(group))
- for f in group:
- msg = f(self, msg)
- if not msg:
- return
- if ctcp:
- msg = self.ctcpquote('{0} {1}'.format(query, msg))
- if as_notice:
- for i in msg.split('\n'):
- if i:
- self.send_queue.put('NOTICE {0} :{1}'.format(
- target.target, msg))
- else:
- for i in msg.split('\n'):
- if i:
- self.send_queue.put('PRIVMSG {0} :{1}'.format(
- target.target, msg))
- def ctcpquote(self, msg):
- return '\x01' + msg.replace(
- '\x10', '\x10\x10').replace(
- '\0', '\x100').replace(
- '\r', '\x10r').replace(
- '\n', '\x10n').replace(
- '\x01', '\\a').replace(
- '\\', r'\\') + '\x01'
- def ctcpdequote(self, msg):
- if ctcp_re.match(msg) is None:
- return msg
- return msg.strip('\x01').replace(
- '\x10\x10', '\x10').replace(
- '\x100', '\0').replace(
- '\x10r', '\r').replace(
- '\x10n', '\n').replace(
- '\\a', '\x01').replace(
- r'\\', '\\')
- def debug_print(self, args):
- '''Print each message received by the bot.'''
- print(args['message'])
- # This should be (mostly) RFC-compliant and is designed to allow for quick and
- # easy extraction of parameters. It does not allow an @ in the username, which
- # is allowed by the RFC (but probably no IRCds).
- # This pattern consumed several hours of my time. I had fun.
- message_re = re.compile(
- r'^(?P<message>' # This is really just to have it there.
- r'(?::(?P<sender>(?:(?P<nick>[][a-zA-Z0-9_\\`{}^-]+)' # nick
- r'(?:!(?P<user>[^\0\n\r @]+))?' # user
- r'(?:@(?P<host>[a-zA-Z0-9.-]+))?)|' # host
- r'(?P<server>[a-zA-Z0-9.-]+)) +)?' # server
- r'(?:(?P<command>[a-zA-Z]+)|' # command
- r'(?P<response>\d{3})' # response
- r'(?: +(?P<params>(?P<middle>(?:(?!:)[^\0\n\r ]+ *?)*) *' # middle
- r'(?::(?P<trailing>[^\0\n\r]*))?))?)$' # trailing
- )
- # This matches any valid CTCP request.
- ctcp_re = re.compile(
- r'^\x01' # initial thingy
- r'(?:|(?P<query>[^\0\x01\n\r ]+)' # query
- r'(?: (?P<params>[^\0\x01\n\r]+))?)' # params
- r'\x01$' # ending thingy
- )
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement