From 646ebb000473d398ce895cd83c8d7a703a3fb785 Mon Sep 17 00:00:00 2001 From: Josh K Date: Sun, 19 Aug 2018 05:08:58 -0400 Subject: [PATCH] Initial commit. --- LICENSE.txt | 8 ++ README.md | 24 ++++++ bot.py | 197 ++++++++++++++++++++++++++++++++++++++++++++ cfg.toml | 42 ++++++++++ main.py | 37 +++++++++ modules/__init__.py | 2 + modules/qdb.py | 105 +++++++++++++++++++++++ protocol.py | 141 +++++++++++++++++++++++++++++++ 8 files changed, 556 insertions(+) create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 bot.py create mode 100644 cfg.toml create mode 100755 main.py create mode 100644 modules/__init__.py create mode 100644 modules/qdb.py create mode 100644 protocol.py diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..a7474f1 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,8 @@ +Copyright (C) 2018 Slipyx (Josh K) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f05fd0 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +Dustbot +======= +--- +A Python IRC bot + +Usage: +------ + +Edit cfg.toml with desired settings. +Execute main.py, specifying a path to a .toml config file if not using default './cfg.toml'. + +Configuration Settings: +----------------------- +TODO + +Requirements: +------------- +Requires Python 3 along with the following libraries: + +* requests +* toml +* BeautifulSoup4 +* uvloop (Only if not using PyPy) + diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..b7f98d6 --- /dev/null +++ b/bot.py @@ -0,0 +1,197 @@ +""" +Bot module +Core functionality for a bot instance +Contains a global module map containing loaded modules and their functions +Includes some builtin administrative commands +""" + +import logging +import importlib +import sys + +from protocol import IrcProtocol + +import modules + +# global module map, maps registered functions for each loaded module +try: _MOD_MAP +except NameError: + _MOD_MAP = {} + +def command( name ): + """ + Command registering decorator. + Passed name is name of command, decorated function's docstring becomes command's help message. + Function's module name is automatically appended to docstring. + Command function gets passed reference to bot instance, array of params, and context info + containing the nick that sent, channel it was sent in, and reference to connection object. + """ + def __deco__( func ): + # "fixed" module name. removes top-level 'module' package name + mod_name = func.__module__[func.__module__.find( '.' ) + 1: ] + logging.info( 'Registering \'%s\' module command: \'%s\' (%s)...', + mod_name, name, func.__name__ ) + + if not func.__doc__: func.__doc__ = 'No help available for command' + func.__doc__ += ' (Module: {})'.format( mod_name ) + + if mod_name not in _MOD_MAP.keys(): + _MOD_MAP[mod_name] = {} + + _MOD_MAP[mod_name][name.lower()] = func + + return func #__inner__ # func + + return __deco__ + +class Bot: + """ Bot class """ + def __init__( self, loop, cfg ): + self._loop = loop + self.cfg = cfg + # list of network connections + self._con = [] + # create and connect all servers in config + for s in self.cfg['server']: + ncon = IrcProtocol( self._loop, self.cfg['user'] ) + # add message callback + ncon.add_message_callback( self._on_irc_message ) + self._con.append( ncon ) + self._loop.create_task( ncon.do_connect( s ) ) + + # called when a connected connection receives a full message + # passes the connection that sent and raw string message + def _on_irc_message( self, con, raw_msg ): + words = raw_msg.split() + if words[1] == 'PRIVMSG': + chan = words[2] + raw_user = words[0] + nick = raw_user[1 : words[0].find( '!' )] + msg = raw_msg[raw_msg.find( ':' , 1 ) + 1 :] + # check for command-like message + # message split into words, first word is command rest are parms list + pfx = self.cfg['bot']['prefix'] + strp_msg = msg.strip() + if strp_msg[0 : len( pfx )] == pfx: + msg_words = strp_msg.split() + cmd = msg_words[0][len( pfx ):].lower() + parms = msg_words[1:] + # check if command is in a registered module + for k, m in _MOD_MAP.items(): + if cmd in m.keys(): + con.log( 'CMD \'{}\' run in ({}) by <{}> with parms: {}'.format( + cmd, chan, nick, parms ) ) + self._loop.create_task( m[cmd]( + self, parms, {'con': con,'dst': chan,'nick': nick} ) ) + +# core commands + +# join / part +@command( 'join' ) +async def cmd_join( bot, parms, ctx ): + """ Joins a channel. """ + + nick = ctx['nick'] + #dst = ctx['dst'] + con = ctx['con'] + if nick == con.cfg['usr']['owner'] and parms \ + and not (parms[0] in con.cfg['sv']['channels']) and parms[0][0] == '#': + con.send( 'JOIN {}'.format( parms[0] ) ) + con.cfg['sv']['channels'].append( parms[0] ) + +@command( 'part' ) +async def cmd_part( bot, parms, ctx ): + """ Leaves a channel. """ + + nick = ctx['nick'] + con = ctx['con'] + if nick == con.cfg['usr']['owner'] and parms \ + and parms[0] in con.cfg['sv']['channels']: + con.send( 'PART {}'.format( parms[0] ) ) + con.cfg['sv']['channels'].remove( parms[0] ) + +# gets the doc string for specified command +@command( 'help' ) +async def cmd_help( b, p, c ): + """ Shows help message for a specified command. """ + + con = c['con'] + dst = c['dst'] + + if not p: + con.say_to( dst, 'No command specified. See list of commands with \";list\".' ) + + helpstr = '' + + for k, m in _MOD_MAP.items(): + if p[0].lower() in m.keys(): + helpstr = '> {}: {}'.format( p[0].lower(), ' '.join( m[p[0].lower()].__doc__.split() ) ) + + if helpstr: + con.say_to( dst, helpstr ) + else: + con.say_to( dst, 'Command not found.' ) + +# list commands +@command( 'list' ) +async def cmd_list( bot, p, ctx ): + """ Lists all available commands. TODO: module filter """ + + con = ctx['con'] + + cmdlist = [list( c.keys() ) for c in _MOD_MAP.values()] + + con.say_to( ctx['dst'], '>: {}'.format( cmdlist ) ) + +# reload a specified module +@command( 'reload' ) +async def cmd_import( bot, p, ctx ): + """ Reloads a previously loaded module. """ + con = ctx['con'] + if ctx['nick'] != con.cfg['usr']['owner']: return + dst = ctx['dst'] + if p and p[0] in _MOD_MAP.keys(): + prevMod = None + try: + prevMod = _MOD_MAP.pop( p[0], None ) + # if not reloading root bot module, re-append the modules. package name + if p[0] != 'bot': p[0] = 'modules.' + p[0] + importlib.reload( sys.modules[p[0]] ) + con.say_to( dst, 'Success!' ) + except Exception as e: + con.say_to( dst, str( e ) ) + _MOD_MAP[p[0]] = prevMod + else: con.say_to( dst, 'Module not found' ) + +# connect to a new server +@command( 'connect' ) +async def cmd_connect( b, p, c ): + """ + Connects to a new network. + Parameters: 'name' 'host' + """ + + con = c['con'] + if c['nick'] != con.cfg['usr']['owner']: return + if not p and len( p ) < 2: return + + new_sv_cfg = {'host':p[1],'port':6697,'name':p[0]} + ncon = IrcProtocol( b._loop, con.cfg['usr'] ) + # add message callback + ncon.add_message_callback( b._on_irc_message ) + b._con.append( ncon ) + b._loop.create_task( ncon.do_connect( new_sv_cfg ) ) + +# disconnect from currently connected server +@command( 'disconnect' ) +async def cmd_discon( b, p, c ): + """ Disconnects from currently connected network """ + con = c['con'] + if c['nick'] != con.cfg['usr']['owner']: return + con._stop = True + con.send( 'QUIT :POOF' ) + b._con.remove( con ) + +# custom modules +importlib.import_module( '.qdb', 'modules' ) + diff --git a/cfg.toml b/cfg.toml new file mode 100644 index 0000000..c6e7f6f --- /dev/null +++ b/cfg.toml @@ -0,0 +1,42 @@ +## +## its a config file! +## + +## +## bot's user settings +## +[user] +# nick of owner, used as a dumb-simple "auth" for the administrative commands +owner = "slipyx" + +# nick, user, and real name of bot +nick = "dustbot" +user = "dust" +name = "Me Mow" + +## +## misc bot settings +## +[bot] +# command prefix +prefix = ";" + +# TBD?? +modules = [] + +## +## networks/servers config +## +[[server]] +# user-friendly name used in logging output +name = "Tilde" + +# host and port of server to connect to +host = "irc.tilde.chat" +port = 6697 + +# optional list of channels to join upon connecting +channels = ["#bots"] + +# add more servers using [[server]] like above + diff --git a/main.py b/main.py new file mode 100755 index 0000000..3448ca0 --- /dev/null +++ b/main.py @@ -0,0 +1,37 @@ +#!/usr/bin/python3 -tt + +# pypy detection +from platform import python_implementation as py_imp + +import sys +import logging +import concurrent.futures +import asyncio + +import toml + +from bot import Bot + +# only use uvloop if not using pypy +if py_imp() != 'PyPy': + import uvloop + asyncio.set_event_loop_policy( uvloop.EventLoopPolicy() ) + +if __name__ == '__main__': + evloop = asyncio.get_event_loop() + evloop.set_default_executor( concurrent.futures.ThreadPoolExecutor( 4 ) ) + + # parse bot configuration + cfg_file = './cfg.toml' + if len( sys.argv ) > 1: cfg_file = sys.argv[1] + cfg = toml.loads( open( cfg_file ).read() ) + + # create a bot instance + bot = Bot( evloop, cfg ) + + try: evloop.run_forever() + except KeyboardInterrupt: + logging.log( logging.INFO, 'POOF!' ) + + logging.log( logging.INFO, 'Event loop stopped, goodbye...' ) + diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..b62684d --- /dev/null +++ b/modules/__init__.py @@ -0,0 +1,2 @@ +pass + diff --git a/modules/qdb.py b/modules/qdb.py new file mode 100644 index 0000000..6b35a52 --- /dev/null +++ b/modules/qdb.py @@ -0,0 +1,105 @@ +""" +QDB module +Grabs quotes from an online QDB database +""" + +import random +import requests +import bs4 + +from bot import command + +# map of supported databases +QDB_URLS = { + 'xkcd':'http://www.xkcdb.com/random', + 'bash':'http://www.bash.org/?random', + 'qdb': 'http://www.qdb.us/random', + 'wg':'https://qdb.worldgenesis.net/?random', + 'tilde': 'https://quotes.tilde.chat/random' +} + +@command( 'qdb' ) +async def get_quote( bot, parms, ctx ): + """ + Grabs a random quote from specified quote database, + default random or database with same name as current network. + Supported databases: xkcd, bash, qdb, tilde, wg. + Does not output quotes with more than 5 lines by default unless 'spam' is first param. + """ + + con = ctx['con'] + dst = ctx['dst'] + + spam = False + db_name = '' + sv_name = con.cfg['sv']['name'].lower() + if sv_name in QDB_URLS.keys(): db_name = sv_name + + if parms: + if parms[0] == 'spam': + spam = True + if len( parms ) > 1: + db_name = parms[1].lower() + else: db_name = parms[0].lower() + + if not db_name: db_name = random.choice( list( QDB_URLS.keys() ) ) + + if db_name not in QDB_URLS.keys(): + con.say_to( dst, 'No QDB known by name \'{}\'!'.format( db_name ) ) + return + + con.say_to( dst, 'Grabbing a quote from QDB \'{}\'...'.format( db_name ) ) + + req = requests.get( QDB_URLS[db_name] ) + + soup = bs4.BeautifulSoup( req.text, 'html.parser' ) + + qts = [] + + # element filter for soup depending on database + # do you even switch bro + if db_name == 'wg': + qts = soup.findAll( 'div', {'class':'quote_quote'} ) + elif db_name == 'xkcd': + qts = soup.findAll( 'span', {'class':'quote'} ) + elif db_name == 'qdb': + qts = soup.findAll( 'span', {'class':'qt'} ) + elif db_name == 'bash': + qts = soup.findAll( 'p', {'class': 'qt'} ) + elif db_name == 'tilde': + qts = soup.findAll( 'pre' ) + + # quote number notes. + # wg: + # bash: first child of

quote + # xkcd: first child of + # qdb: + # tilde: ??? + + if qts: + # number of quotes per page + #print( len( qts ) ) + # one random quote from page + rqt = random.choice( qts ) + # grab raw quote text and split to lines + qt_lines = rqt.text.strip().split('\n') + # if too many lines for spam filter, try to find a shorter one + while not spam and len( qt_lines ) > 5: + #for q in qts: + qt_lines = qts.pop().text.strip().split('\n') + + if not qt_lines: + con.say_to( dst, 'Quote had too many lines!' ); return + for l in qt_lines: + # find some kind of resemblence of a nick. doesnt account for timestamps + nsep = l.find( '>' )+1 + if nsep <= 0: nsep = l.find( ':' )+1 + if nsep <= 0: nsep = l.find( ' ' ) + nn = l[: nsep] + nnc = 0 + for c in nn: nnc += ord( c ) + nnc %= 16 + con.say_to( dst, '> \002\003{}{}\003\002{}'.format( nnc, nn, l[nsep:] ) ) + else: + con.say_to( dst, 'No quotes found! :(' ) + diff --git a/protocol.py b/protocol.py new file mode 100644 index 0000000..f593165 --- /dev/null +++ b/protocol.py @@ -0,0 +1,141 @@ +""" +IRC protocol class and associated logging config and a shared ssl context +""" + +import asyncio +import ssl +import logging + +logging.basicConfig( level=logging.INFO, + format='[%(asctime)s]%(levelname)s%(message)s', + datefmt='%Y-%m-%d %H:%M:%S' ) +logging.addLevelName( 20, ' ' ) +logging.addLevelName( 30, ' ::WARNING:: ' ) + +# shared ssl context for connections +SSL_CTX = ssl.create_default_context() +SSL_CTX.check_hostname = False +SSL_CTX.verify_mode = ssl.CERT_NONE + +class IrcProtocol( asyncio.Protocol ): + """ + IRC protocol class + Represents a connection to an IRC server + + Built on top of asyncio's protocol class + + Accepts a user config on creation and a server config when connecting + + Can add message callbacks that get called when connection + receives a new message + + Attempts to reconnect if connection lost. + + Custom logging and warning functions. + """ + def __init__( self, evloop, user_cfg ): + """ Creates a new protocol instance. Pass an asyncio event loop and a bot user config dict + containing 'nick', 'user', 'name', and 'owner' fields. + """ + self._loop = evloop + self._recnt = 0 # reconnect attempts + self._dat = b'' # buffered message data + self._trans = None # protocol's transport + self._stop = False # True if doing a manual stop and dont try to reconnect + self.cfg = {'usr':user_cfg,'sv':{}} # server config set on first connect + self._msg_cb = [] # list of message callbacks + + def connection_made( self, transport ): + self.log( 'Connection made! {}'.format( transport ) ) + self._trans = transport + self.send( 'NICK {}'.format( self.cfg['usr']['nick'] ) ) + self.send( 'USER {} 0 * :{}'.format( self.cfg['usr']['user'], self.cfg['usr']['name'] ) ) + + def connection_lost( self, exc ): + self._trans.close() + if exc is None: exc = 'EOF' + self.warning( 'Connection lost to server \'{}\'! ({})'.format( self.cfg['sv']['host'], exc ) ) + # if was a manual stop, dont attempt reconnect + if self._stop: + self.on_stop() + return + self.warning( 'Attempting reconnect...' ) + self._loop.create_task( self.do_connect( self.cfg['sv'] ) ) + + def data_received( self, data ): + # buffer data, then strip off and parse each line within + self._dat += data + while b'\r\n' in self._dat: + raw_msg = self._dat[:self._dat.index( b'\r\n' )] + self._dat = self._dat[self._dat.index( b'\r\n' ) + 2:] + words = raw_msg.decode().split() + + if words[0] == 'PING': self.send( 'PONG ' + words[1] ) + # register success, join initial channels + if words[1] == '001': + self._recnt = 0 # reset reconnect count + if 'channels' in self.cfg['sv']: + for c in self.cfg['sv']['channels']: + self.send( 'JOIN {}'.format( c ) ) + else: self.cfg['sv']['channels'] = [] + # send raw message to all added callbacks + for c in self._msg_cb: c( self, raw_msg.decode() ) + # manual stop + #self._stop = True + #self.send('QUIT :stopped' ) + + def add_message_callback( self, cb ): + """ + Adds a function to callback when a raw message is received. + Passes in self and the message + """ + self._msg_cb.append( cb ) + + # logging + def log( self, msg, level=logging.INFO ): + logging.log( level, '(%s) %s', self.cfg['sv']['name'], msg ) + def warning( self, msg ): + self.log( msg, logging.WARNING ) + + # helper func for writing a trimmed string message with newline + def send( self, msg ): + #if 'PONG' not in msg: self.log( '>> {}'.format( msg ) ) + self._trans.write( bytes( msg[:510] + '\r\n', 'utf-8' ) ) + # helper func for sending a privmsg to specified destination + def say_to( self, dst, msg ): + msg = msg.replace( '\n', ' ' ).replace( '\r', '' ) + self.send( 'PRIVMSG {} :{}'.format( dst, msg ) ) + + # create connection task + async def do_connect( self, sv_cfg ): + """ + Attempts a connection using specified server config dict. + Fields: 'name', 'host', 'port', and optional 'channels' array to join on connect + The 'name' field is custom name for this connection + mainly used to label logging output for easier readability. + Called using an event loop's create_task function + """ + # set initial server config + if not self.cfg['sv']: self.cfg['sv'] = sv_cfg + + self.log( 'Connecting to server \'{}\'...'.format( self.cfg['sv']['host'] ) ) + try: await asyncio.wait_for( self._loop.create_connection( + lambda: self, self.cfg['sv']['host'], self.cfg['sv']['port'], ssl=SSL_CTX ), 10, loop=self._loop ) + except Exception as e: + if e is None: e = 'EOF' + self.warning( 'Connection exception! {}'.format( e ) ) + self._recnt += 1 + # if enough reconnect attempts were made, stop trying + if self._recnt > 60: + self.warning( 'Reconnect attempt limit reached, stopping connection...' ) + self.on_stop() + return + self.warning( 'Reconnect attempt #{} in {}s...'.format( self._recnt, self._recnt * 10 ) ) + await asyncio.sleep( self._recnt * 10 ) + self._loop.create_task( self.do_connect( self.cfg['sv'] ) ) + + # after a connection has been stopped + def on_stop( self ): + self.warning( 'Stopped connection to server \'{}\'.'.format( self.cfg['sv']['host'] ) ) + #db.ps.close() +