Advertisement
zephyrtronium

Untitled

May 1st, 2011
120
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 21.03 KB | None | 0 0
  1.  
  2. '''
  3. Automated Python IRC bot structure.
  4.  
  5. This is a threaded bot class designed to make simple the creation of IRC
  6. bots with Python. It is inspired by the cmd standard library module and
  7. is very similar to it in many respects, including its setup.
  8.  
  9. Various security features are implemented, including a levels system,
  10. which allows individual commands to restrict access to groups of users,
  11. and an access system, which allows groups of commands to provide access
  12. to individual users.
  13.  
  14. An extension system allows new commands to be added. Extensions may be
  15. loaded with the bot or dynamically. Preprocessors and postprocessors can
  16. also be created.
  17.  
  18. '''
  19.  
  20. from __future__ import unicode_literals, print_function
  21. import sys
  22. import os
  23. # The threading module is not available on all platforms.
  24. try:
  25.     import threading
  26. except ImportError:
  27.     import dummy_threading as threading
  28. import socket
  29. import time
  30. import re
  31. import itertools
  32. # Compatibility stuff to make Python 2 behave more like py3k.
  33. if sys.version_info.major == 3:
  34.     import queue
  35.     import configparser
  36.     deutf = lambda s: str(s, 'utf-8')
  37.     basestring = (str, bytes)
  38. else:
  39.     import Queue as queue
  40.     import ConfigParser as configparser
  41.     deutf = lambda s: unicode(s, 'utf-8')
  42.  
  43. __all__ = ['Bot', 'User', 'Channel', 'deutf', 'message_re']
  44. __version__ = '0.0.0a1'
  45.  
  46.  
  47. class User(object):
  48.    
  49.     '''User object for IRC bots.
  50.  
  51.    This helps keep track of user information.
  52.    '''
  53.  
  54.     def __init__(self, nick, user, real, host, mode, channels):
  55.         '''Frobnicate the bizbang.
  56.  
  57.        nick, user, real, and host correspond to the nickname, username,
  58.        realname, and hostmask of the user, respectively.
  59.        mode is the user's mode, including op/voice/ban/&c status.
  60.        channels is a list of Channel objects for each channel in which
  61.        both the user and this bot are.
  62.        '''
  63.         # target is the target of Bot.message() calls.
  64.         self.target = nick
  65.         self.user = user
  66.         self.real = real
  67.         self.host = host
  68.         self.mode = mode
  69.         self.channels = channels
  70.         self.level = 0
  71.  
  72.     def __str__(self):
  73.         return self.target
  74.  
  75.     __slots__ = ('target', 'user', 'real', 'host', 'mode', 'channels', 'level')
  76.  
  77. class Channel(object):
  78.  
  79.     '''Channel object for IRC bots.
  80.  
  81.    This helps keep track of users, modes, and topics of channels.
  82.    '''
  83.  
  84.     def __init__(self, name, users, mode, topic):
  85.         '''Catenifrob the bangbiz.
  86.  
  87.        name is the channel's name, including the initial # (or &).
  88.        users is a list of User objects for each user in the channel.
  89.        mode is the channel's mode, not including modes applying
  90.        specifically to individual users (op, voice, ban, &c).
  91.        topic is the channel's topic.
  92.        '''
  93.         # target is the target of Bot.message() calls.
  94.         self.target = name
  95.         self.users = users
  96.         self.mode = mode
  97.         self.topic = topic
  98.  
  99.     __slots__ = ('target', 'users', 'mode', 'topic')
  100.  
  101. class Bot(object):
  102.  
  103.     '''IRC bot class.
  104.  
  105.    New bots may subclass this or use only extension modules. The
  106.    default implementation provides the commands join, to have the bot
  107.    join a channel; part, to have it leave a channel; quit, to have it
  108.    disconnect from the server; load, to load an extension; unload, to
  109.    disable an extension; levels, to set a user's security level;
  110.    access, to modify a command's access list; identify, to give a user
  111.    owner-level priveleges; raw, to have the bot send a raw string to
  112.    the server (not run through any postprocessors); and eval, to have
  113.    the bot execute an arbitrary Python expression.
  114.  
  115.    To define a new command named, for example, `spam', define a method
  116.    named `do_spam(self, channel, sender, line, time)'. channel will be
  117.    a Channel object representing the originating channel or the sending
  118.    user if it was sent in a personal message; the sender argument will
  119.    be a User object corresponding to the user who sent the message;
  120.    line will be a string containing the line accompanying the command
  121.    call; and time will be the struct_time for the time when the
  122.    message was sent as returned by time.localtime(). For example, if a
  123.    user with nick `spam' attempts to call a command called `say' in
  124.    #foobar as follows:
  125.  
  126.        <spam> !say Ni.
  127.  
  128.    the bot will call
  129.    do_say(<Channel '#foobar'>, <User 'spam'>, 'Ni.', time.localtime()).
  130.  
  131.    The return value for a command must be a string which will be sent
  132.    to the server, unless it is empty. It will be run through any
  133.    postprocessors which have been defined.
  134.  
  135.    To specify security levels for a command named, for example, `eggs',
  136.    create an attribute named `level_eggs' with the numeric level. If
  137.    the level is None, only identified users will be allowed to use it;
  138.    if it is not defined, it will be treated as 0.
  139.    '''
  140.  
  141.     def __init__(self, nick, user=None, realname=None, server=None, port=None,
  142.                  pass_=None, channel=None, idpass=None, debug=None,
  143.                  eval_allowed=None, raw_allowed=None, configfile=None):
  144.         '''Spawn more overlords.
  145.  
  146.        nick, user, and realname are the bot's nickname, username, and
  147.        realname.
  148.        server is the server to which the bot should connect.
  149.        If port is not given, 6667 will be used.
  150.        pass_ is the password with which to connect. If not given, none
  151.        will be sent.
  152.        channel may be a single channel name or a sequence of them.
  153.        idpass should be a string used as a password for giving users
  154.        owner privileges via the identify command.
  155.        If debug is True, a lot of information you probably don't need
  156.        to know will be printed. self.debug will be set to True, too.
  157.        Setting eval_allowed to False will disable the eval command. Use
  158.        this to prevent the crazy people to whom you have given your
  159.        idpass from rm -rf /ing you.
  160.        raw_allowed is the same as eval_allowed but for raw instead of
  161.        eval.
  162.        configfile may be the path to a file containing these options in
  163.        the format used by the configfile standard library module. The
  164.        option names are the same, with the nick specifying the section
  165.        name. Options specified both here and in the config file use the
  166.        former. If the config file does not specify an option, the same
  167.        default is used as here.
  168.  
  169.        This method calls pre_init_hook() before doing anything and
  170.        post_init_hook() after it has finished. The former does nothing
  171.        by default; the latter calls self.check_init().
  172.        '''
  173.         self.pre_init_hook()
  174.         if configfile is not None:
  175.             # Note to self: Bo Burnham is an excellent comedian.
  176.             # Note to self: Sources are not the best places for notes to self.
  177.             cfg = configparser.SafeConfigParser(
  178.                 {
  179.                     'user': None, 'realname': None, 'server': None,
  180.                     'port': 6667, 'pass_': None, 'channel': None,
  181.                     'idpass': None, 'debug': False, 'eval_allowed': True,
  182.                     'raw_allowed': True
  183.                 }, dict)
  184.             with open(configfile) as f:
  185.                 cfg.readfp(f)
  186.             self.user = cfg.get(nick, 'user')
  187.             self.realname = cfg.get(nick, 'realname')
  188.             self.server = cfg.get(nick, 'server')
  189.             self.port = cfg.getint(nick, 'port')
  190.             self.pass_ = cfg.get(nick, 'pass')
  191.             c = cfg.get(nick, 'channel')
  192.             if c and ',' in c:
  193.                 self.channels = dict(zip(eval(c, {}, {}),
  194.                                          itertools.repeat(None)))
  195.             else:
  196.                 self.channels = c
  197.             self.idpass = cfg.get(nick, 'idpass')
  198.             self.debug = cfg.getboolean(nick, 'debug')
  199.             self.eval_allowed = cfg.getboolean(nick, 'eval_allowed')
  200.             self.raw_allowed = cfg.getboolean(nick, 'raw_allowed')
  201.         if user is not None:
  202.             self.user = user
  203.         if realname is not None:
  204.             self.realname = realname
  205.         if server is not None:
  206.             self.server = server
  207.         if port is not None:
  208.             self.port = port
  209.         if pass_ is not None:
  210.             self.pass_ = pass_
  211.         if channel is not None:
  212.             if isinstance(channel, basestring):
  213.                 self.channels = {channel: None}
  214.             else:
  215.                 self.channels = dict(zip(channel, itertools.repeat(None)))
  216.         if idpass is not None:
  217.             self.idpass = idpass
  218.         if debug is not None:
  219.             self.debug = debug
  220.         if eval_allowed is not None:
  221.             self.eval_allowed = eval_allowed
  222.         self.modules = ['builtins']
  223.         if self.debug:
  224.             self.realtimes = {'builtins': [self.debug_print]}
  225.         else:
  226.             self.realtimes = {'builtins': []}
  227.         self.preprocessors = {'builtins': []}
  228.         self.commands = {'builtins': ['join', 'part', 'quit', 'load', 'unload',
  229.                                       'levels', 'access', 'raw', 'eval']}
  230.         self.postprocessors = {'builtins': []}
  231.         self.post_init_hook()
  232.  
  233.     def connect(self):
  234.         '''Connect to the server and begin processing.
  235.  
  236.        Once this is called, the bot becomes autonomous.
  237.        '''
  238.         self.pre_connect_hook()
  239.         if not getattr(self, 'params_checked', False):
  240.             raise AttributeError('parameters not checked')
  241.         # The send thread manages all messages to the server.
  242.         self.send_thread = threading.Thread(target=self.send_mgr, args=(self,))
  243.         # The recv thread delegates (or responds to) messages from the server.
  244.         self.recv_thread = threading.Thread(target=self.recv_mgr, args=(self,))
  245.         self.recv_thread.daemon = True #TODO: remember why this was a bad idea
  246.         # The work thread runs commands.
  247.         self.work_thread = threading.Thread(target=self.work_mgr, args=(self,))
  248.         self.work_thread.daemon = True
  249.         # The send queue is used by the send thread for unknown purposes.
  250.         self.send_queue = queue.Queue()
  251.         # The send timer is used by the send thread to prevent flooding.
  252.         self.send_timer = time.time()
  253.         # The work queue is used by the work thread for well-known purposes.
  254.         self.work_queue = queue.Queue()
  255.         # Initialize the send queue.
  256.         if self.pass_ is not None:
  257.             self.rawmsg('PASS ' + self.pass_)
  258.         self.rawmsg('NICK ' + self.nick)
  259.         self.rawmsg('USER ' + self.user + ' * * :' + self.realname)
  260.         # More hooks, please.
  261.         self.pre_connect_start_hook()
  262.         self.running = True # I'll just leave this here.
  263.         # Connection begins with YOU!!
  264.         self.socket = socket.create_connection((self.server, self.port), 180)
  265.         # Start the threads.
  266.         self.send_thread.start()
  267.         self.recv_thread.start()
  268.         self.work_thread.start()
  269.         self.post_connect_hook()
  270.         # Optionally block.
  271.         if getattr(self, 'join_send_thread', True):
  272.             self.send_thread.join()
  273.         return self.exit_hook()
  274.  
  275.     def reconnect(self):
  276.         '''Reconnect to the server.
  277.  
  278.        This is chiefly aimed at reconnecting after a ping timeout or
  279.        some other unexpected disconnection.
  280.        '''
  281.         self.pre_reconnect_hook()
  282.         # Close the connection in case it really is still active.
  283.         self.socket.close()
  284.         self.socket = socket.create_connection((self.server, self.port), 180)
  285.         if self.pass_ is not None:
  286.             self.rawmsg('PASS ' + self.pass_)
  287.         self.rawmsg('NICK ' + self.nick)
  288.         self.rawmsg('USER ' + self.user + ' * * :' + self.realname)
  289.         self.post_reconnect_hook()
  290.  
  291.     def send_wait(self):
  292.         '''Block until it is safe to send the next message.'''
  293.         if time.time() > self.send_timer:
  294.             self.send_timer = time.time()
  295.         elif time.time() < self.send_timer - 7:
  296.             time.sleep(2)
  297.         self.send_timer += 2
  298.  
  299.     def send_mgr(self):
  300.         '''Manage messages sent to the server.
  301.  
  302.        This should prevent the bot from flooding.
  303.        '''
  304.         while self.running:
  305.             m = self.send_queue.get().rstrip('\r\n\0').encode('utf-8')
  306.             if m:
  307.                 # If the message is too long, it must be split.
  308.                 if len(m) > 510: # This is the maximum in RFC 1459.
  309.                     # PRIVMSGs and NOTICEs need to be split such that they all
  310.                     # go where they are intended to go.
  311.                     s = m.split(' ', 2)
  312.                     # It is assumed that no message will contain no spaces yet
  313.                     # still be over the limit.
  314.                     #TODO: make this more general.
  315.                     if len(s) == 2:
  316.                         t, r = s
  317.                     else:
  318.                         t = ' '.join(s[:2])
  319.                         r = s[2]
  320.                     if r.startswith(':'):
  321.                         t += ' :'
  322.                         r = r[1:]
  323.                     else:
  324.                         t += ' '
  325.                     while len(r) > 510 - len(t):
  326.                         self.send_wait()
  327.                         #TODO: prevent any chance of paramaters being split
  328.                         n = self.socket.send(t + r[:511] + '\r\n')
  329.                         if n == 0:
  330.                             # The connection has been broken by this point.
  331.                             self.reconnect()
  332.                             continue
  333.                         #TODO: I don't think this is right.
  334.                         r = r[n:]
  335.                     self.send_wait()
  336.                     n = self.socket.send(t + r + '\r\n')
  337.                     while not n:
  338.                         self.reconnect()
  339.                         self.send_wait()
  340.                         n = self.socket.send(t + r + '\r\n')
  341.                 else:
  342.                     # The message may be sent in its entirety.
  343.                     while True:
  344.                         self.send_wait()
  345.                         n = self.socket.send(t + r + '\r\n')
  346.                         if n > 0:
  347.                             break
  348.                         self.reconnect()
  349.                 if m.startswith('QUIT'):
  350.                     # The bot has quit; end this thread.
  351.                     self.running = False
  352.                     self.socket.close()
  353.  
  354.     def recv_mgr(self):
  355.         '''Manage messages received from the server.
  356.  
  357.        This responds to PINGs immediately and delegates other tasks to
  358.        the work thread.
  359.        '''
  360.         while self.running:
  361.             messages = self.socket.recv(4096).split('\r\n')
  362.             for message in messages:
  363.                 message = message.encode('utf-8')
  364.                 m = message_re.match(message)
  365.                 if m is None:
  366.                     if self.debug:
  367.                         print('XXX invalid message from server:\n', message)
  368.                     continue
  369.                 args = m.groupdict()
  370.                 if args['command'] is None:
  371.                     #TODO: handle numeric replies
  372.                 if args['command'] == 'PING':
  373.                     self.rawmsg('PONG ' + args['middle'])
  374.                     continue
  375.                 stop = False
  376.                 for group in self.realtimes:
  377.                     if self.debug:
  378.                         print('+ realtime group {0!r}'.format(group))
  379.                     for f in group:
  380.                         if f(self, args):
  381.                             stop = True
  382.                             break
  383.                     if stop:
  384.                         break
  385.                 if stop:
  386.                     continue
  387.                 if args['command'] == 'PRIVMSG':
  388.                     if args['message'].startswith(self.prefix):
  389.                         try:
  390.                             command, params = \
  391.                                      args[len(self.prefix):].split(None, 1)
  392.                         except IndexError:
  393.                             command, params = args[len(self.prefix):], ''
  394.                         s = 'do_' + command
  395.                         if hasattr(self, s):
  396.                             level = getattr(self, 'level_' + command, 0)
  397.                             if args['nick'] in self.users:
  398.                                 u = self.users[args['nick']]
  399.                             else:
  400.                                 # The user is unknown; create a dummy and ask
  401.                                 # for the user's information later.
  402.                                 u = User(args['nick'], '', '', '', '', [])
  403.                                 self.rawmsg('WHOIS ' + args['nick'])
  404.                             if (level is None or
  405.                                (level > u.level and
  406.                                 args['nick'] not in self.accesses[command]) and
  407.                                 args['nick'] not in self.identified):
  408.                                 self.disallowed()
  409.                                 continue
  410.                             if args['middle'] not in self.channels:
  411.                                 # The message was most likely sent in a PM.
  412.                                 target = u
  413.                             self.work_queue.put(
  414.                                 (getattr(self, s),
  415.                                  (target, u, params, time.time())))
  416.  
  417.     def rawmsg(self, msg):
  418.         '''Post a message to be sent to the server with no postprocessing.'''
  419.         self.send_queue.put(msg)
  420.  
  421.     def send(self, target, msg, ctcp=False, as_notice=False):
  422.         r'''Post a PRIVMSG to be sent to the server.
  423.  
  424.        target should be a User or Channel object to which the message
  425.        should be sent.
  426.        msg should be the string to be sent.
  427.        If ctcp is True, CTCP dequoting will be performed and the
  428.        message will be wrapped in \001 characters.
  429.        If as_notice is True, a NOTICE will be sent instead of a
  430.        PRIVMSG.
  431.  
  432.        A message is run through all loaded postprocessors before
  433.        anything else happens, unless ctcp is True and the first word is
  434.        not ACTION.
  435.        '''
  436.         pp = True
  437.         if ctcp:
  438.             t = msg.split(' ', 1)
  439.             if len(t) > 1:
  440.                 query, msg = t
  441.             else:
  442.                 query, msg = t[0], ''
  443.             if query != 'ACTION':
  444.                 pp = False
  445.         if pp:
  446.             for group in self.postprocessors:
  447.                 if self.debug:
  448.                     print('+ postprocessor group {0!r}'.format(group))
  449.                 for f in group:
  450.                     msg = f(self, msg)
  451.                     if not msg:
  452.                         return
  453.         if ctcp:
  454.             msg = self.ctcpquote('{0} {1}'.format(query, msg))
  455.         if as_notice:
  456.             for i in msg.split('\n'):
  457.                 if i:
  458.                     self.send_queue.put('NOTICE {0} :{1}'.format(
  459.                         target.target, msg))
  460.         else:
  461.             for i in msg.split('\n'):
  462.                 if i:
  463.                     self.send_queue.put('PRIVMSG {0} :{1}'.format(
  464.                             target.target, msg))
  465.  
  466.     def ctcpquote(self, msg):
  467.         return '\x01' + msg.replace(
  468.             '\x10', '\x10\x10').replace(
  469.                 '\0', '\x100').replace(
  470.                     '\r', '\x10r').replace(
  471.                         '\n', '\x10n').replace(
  472.                             '\x01', '\\a').replace(
  473.                                 '\\', r'\\') + '\x01'
  474.  
  475.     def ctcpdequote(self, msg):
  476.         if ctcp_re.match(msg) is None:
  477.             return msg
  478.         return msg.strip('\x01').replace(
  479.             '\x10\x10', '\x10').replace(
  480.                 '\x100', '\0').replace(
  481.                     '\x10r', '\r').replace(
  482.                         '\x10n', '\n').replace(
  483.                             '\\a', '\x01').replace(
  484.                                 r'\\', '\\')
  485.  
  486.     def debug_print(self, args):
  487.         '''Print each message received by the bot.'''
  488.         print(args['message'])
  489.  
  490.  
  491. # This should be (mostly) RFC-compliant and is designed to allow for quick and
  492. # easy extraction of parameters. It does not allow an @ in the username, which
  493. # is allowed by the RFC (but probably no IRCds).
  494. # This pattern consumed several hours of my time. I had fun.
  495. message_re = re.compile(
  496.     r'^(?P<message>' # This is really just to have it there.
  497.     r'(?::(?P<sender>(?:(?P<nick>[][a-zA-Z0-9_\\`{}^-]+)' # nick
  498.     r'(?:!(?P<user>[^\0\n\r @]+))?' # user
  499.     r'(?:@(?P<host>[a-zA-Z0-9.-]+))?)|' # host
  500.     r'(?P<server>[a-zA-Z0-9.-]+)) +)?' # server
  501.     r'(?:(?P<command>[a-zA-Z]+)|' # command
  502.     r'(?P<response>\d{3})' # response
  503.     r'(?: +(?P<params>(?P<middle>(?:(?!:)[^\0\n\r ]+ *?)*) *' # middle
  504.     r'(?::(?P<trailing>[^\0\n\r]*))?))?)$' # trailing
  505.     )
  506.  
  507. # This matches any valid CTCP request.
  508. ctcp_re = re.compile(
  509.     r'^\x01' # initial thingy
  510.     r'(?:|(?P<query>[^\0\x01\n\r ]+)' # query
  511.     r'(?: (?P<params>[^\0\x01\n\r]+))?)' # params
  512.     r'\x01$' # ending thingy
  513.     )
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement