From cceb2515ebe437823b15b220dbd3f5e9aaa841ee Mon Sep 17 00:00:00 2001 From: Josh K Date: Sun, 18 Nov 2018 09:01:50 -0500 Subject: [PATCH] Removed Bot class in favor of a single instance Bot 'module'. Bot imports modules specified in config file. Fixed NICK change parsing. Protocol supports connect and disconnect callbacks. --- bot.py | 191 ++++++++++++++++++++++++++++++------------------ cfg.toml | 5 +- main.py | 11 +-- modules/misc.py | 17 +++-- modules/qdb.py | 6 +- protocol.py | 36 +++++++-- 6 files changed, 166 insertions(+), 100 deletions(-) diff --git a/bot.py b/bot.py index bae8943..82b4649 100644 --- a/bot.py +++ b/bot.py @@ -9,6 +9,8 @@ import logging import importlib import sys +import toml + from protocol import IrcProtocol import modules @@ -19,6 +21,18 @@ try: _MOD_MAP except NameError: _MOD_MAP = {} +# single global bot object +try: + _BOT + + # on a reload, reimport current module list from config + with open( _BOT['_cfg_file'] ) as cfg_file: + cfg = toml.loads( cfg_file.read() ) + _load_modules( cfg['bot'].get( 'modules', [] ) ) + +except NameError: + _BOT = {} + def add_module( name ): """ adds a new named empty module definition to the global module map 'fixes' passed module name by removing top-level package name @@ -69,83 +83,119 @@ def parser(): 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 priv message callback to the connections - ncon.add_message_callback( self._on_priv_message ) - self._con.append( ncon ) - self._loop.create_task( ncon.do_connect( s ) ) +# initialize the bot instance, takes a config filename +def init( loop, cfg_file ): + _BOT['loop'] = loop + _BOT['_cfg_file'] = cfg_file + # load config file + cfg = toml.loads( open( cfg_file ).read() ) + _BOT['cfg'] = cfg + # import initial module list from config + _load_modules( cfg['bot'].get( 'modules', [] ) ) + # list of network connections + _BOT['cons'] = [] + # create and connect all servers in config + for s in cfg.get( 'server', [] ): + add_connection( s ) - # called when a connected connection receives a priv message - # passes the connection that sent, destination of message, - # the nick that sent, and the message - def _on_priv_message( self, con, dst, nick, msg ): - # 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.lstrip() - 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['cmds'].keys(): - cmd_func = m['cmds'][cmd] - con.log( '::CMD:: \'{}\' run by <{}> in ({}) with parms: {}'.format( - cmd, nick, dst, parms ) ) - # if msg was sent as pm, - # change dst to nick so say_to's can respond in pm - if dst == self.cfg['user']['nick']: dst = nick - # check for admin - if 'admin' in cmd_func.__dict__.keys() and nick != con.cfg['usr']['owner']: - con.say_to( dst, '> You do not have permission to execute this command.' ) - break - self._loop.create_task( cmd_func( - self, parms, {'con':con,'dst':dst,'nick':nick} ) ) +# public getter functions for bot +def get_loop(): + return _BOT.get( 'loop', None ) +def get_cfg(): + return _BOT.get( 'cfg', {} ) +def get_cons(): + return _BOT.get( 'cons', [] ) + +# add and connect to a new server +def add_connection( sv_cfg ): + loop = get_loop() + ncon = IrcProtocol( loop, get_cfg().get( 'user' ) ) + # add connection callbacks + ncon.add_message_callback( _on_priv_message ) + ncon.add_connect_callback( _on_connect ) + ncon.add_disconnect_callback( _on_disconnect ) + loop.create_task( ncon.do_connect( sv_cfg ) ) + +def _on_connect( con ): + con.warning( 'ON CONNECT!' ) + cons = get_cons() + # add priv msg callback here since this could be a reconnect as well + # and it was removed on disconnect + #con.add_message_callback( _on_priv_message ) + if con not in cons: + cons.append( con ) + +def _on_disconnect( con ): + con.warning( 'ON DISCONNECT!' ) + cons = get_cons() + if con in cons: + cons.remove( con ) + +# called when a connected connection receives a priv message +# passes the connection that sent, destination of message, +# the nick that sent, and the message +def _on_priv_message( con, dst, nick, msg ): + #con.log( '{} {} {} {}'.format( con, dst, nick, msg ) ) + + bcfg = get_cfg() + # check for command-like message + # message split into words, first word is command rest are parms list + pfx = bcfg['bot']['prefix'] + strp_msg = msg.lstrip() + 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['cmds'].keys(): + cmd_func = m['cmds'][cmd] + con.log( '::CMD:: \'{}\' run by <{}> in ({}) with parms: {}'.format( + cmd, nick, dst, parms ) ) + # if msg was sent as pm, + # change dst to nick so say_to's can respond in pm + if dst == bcfg['user']['nick']: dst = nick + # check for admin flag + if 'admin' in cmd_func.__dict__.keys() and nick != con.cfg['usr']['owner']: + con.say_to( dst, '> You do not have permission to execute this command.' ) break + get_loop().create_task( cmd_func( + parms, {'con':con,'dst':dst,'nick':nick} ) ) + break - # callback registered parsers, not for self messages though - if nick != self.cfg['user']['nick']: - # call each registered parser - for v in _MOD_MAP.values(): - for p in v['parsers']: - p( con, dst, nick, msg ) + # callback registered parsers, not for self messages though + if nick != bcfg['user']['nick']: + # call each registered parser + for v in _MOD_MAP.values(): + for p in v['parsers']: + p( con, dst, nick, msg ) # core commands # join / part @command( 'join', True ) -async def cmd_join( bot, parms, ctx ): +async def cmd_join( parms, ctx ): """ Joins a channel. """ con = ctx['con'] if parms \ - and not (parms[0] in con.cfg['sv']['channels']) and parms[0][0] == '#': + and not (parms[0].lower() in con.cfg['sv']['channels']) and parms[0][0] == '#': con.send( 'JOIN {}'.format( parms[0] ) ) - con.cfg['sv']['channels'].append( parms[0] ) + con.cfg['sv']['channels'].append( parms[0].lower() ) @command( 'part', True ) -async def cmd_part( bot, parms, ctx ): +async def cmd_part( parms, ctx ): """ Leaves a channel. """ con = ctx['con'] if parms \ - and parms[0] in con.cfg['sv']['channels']: + and parms[0].lower() in con.cfg['sv']['channels']: con.send( 'PART {}'.format( parms[0] ) ) - con.cfg['sv']['channels'].remove( parms[0] ) + con.cfg['sv']['channels'].remove( parms[0].lower() ) # gets the doc string for specified command @command( 'help' ) -async def cmd_help( b, p, c ): +async def cmd_help( p, c ): """ Shows help message for a specified command. """ con = c['con'] @@ -168,7 +218,7 @@ async def cmd_help( b, p, c ): # list commands @command( 'list' ) -async def cmd_list( b, p, c ): +async def cmd_list( p, c ): """ Lists available commands. Specify a module name from 'modules' command to filter. """ cn = c['con'] @@ -186,7 +236,7 @@ async def cmd_list( b, p, c ): # list loaded modules @command( 'modules' ) -async def cmd_modules( b, p, c ): +async def cmd_modules( p, c ): """ List all currently loaded modules. List commands within using 'list ' """ cn = c['con'] modlist = list(_MOD_MAP.keys()) @@ -194,14 +244,14 @@ async def cmd_modules( b, p, c ): # list active connections @command( 'connections', True ) -async def cmd_connections( b, p, c ): +async def cmd_connections( p, c ): """ List currently connected connections """ - conlist = [c.cfg['sv']['name'] for c in b._con] + conlist = [c.cfg['sv']['name'] for c in get_cons()] c['con'].say_to( c['dst'], '> {}'.format( conlist ) ) # reload a specified module @command( 'reload', True ) -async def cmd_import( bot, p, ctx ): +async def cmd_import( p, ctx ): """ Reloads a previously loaded module. """ con = ctx['con'] dst = ctx['dst'] @@ -221,7 +271,7 @@ async def cmd_import( bot, p, ctx ): # connect to a new server @command( 'connect', True ) -async def cmd_connect( b, p, c ): +async def cmd_connect( p, c ): """ Connects to a new network. Parameters: 'name' 'host' @@ -231,25 +281,21 @@ async def cmd_connect( b, p, c ): 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_priv_message ) - b._con.append( ncon ) - b._loop.create_task( ncon.do_connect( new_sv_cfg ) ) + add_connection( new_sv_cfg ) con.say_to( c['dst'], 'OMW...' ) # disconnect from currently connected server @command( 'disconnect', True ) -async def cmd_discon( b, p, c ): +async def cmd_discon( p, c ): """ Disconnects from currently connected network """ con = c['con'] con._stop = True con.send( 'QUIT :POOF' ) - b._con.remove( con ) + #b._con.remove( con ) # list of names on chan @command( 'names', True ) -async def cmd_names( b, p, c ): +async def cmd_names( p, c ): cn = c['con'] nmstr = '' for k,v in cn.names.items(): @@ -258,9 +304,10 @@ async def cmd_names( b, p, c ): cn.say_to( c['dst'], '> {}'.format( nmstr ) ) - cn.log( str(cn.names) ) + #cn.log( str(cn.names) ) -# custom modules -importlib.import_module( '.misc', 'modules' ) -importlib.import_module( '.qdb', 'modules' ) +# imports a list of module names from the modules package +def _load_modules( mod_list ): + for n in mod_list: + importlib.import_module( '.{}'.format( n ), 'modules' ) diff --git a/cfg.toml b/cfg.toml index c6e7f6f..4c7c2fd 100644 --- a/cfg.toml +++ b/cfg.toml @@ -21,8 +21,9 @@ name = "Me Mow" # command prefix prefix = ";" -# TBD?? -modules = [] +# list of module names to import from modules package +# additions are imported on bot reload +modules = ['misc','qdb'] ## ## networks/servers config diff --git a/main.py b/main.py index 3448ca0..ffa5bdc 100755 --- a/main.py +++ b/main.py @@ -8,9 +8,7 @@ import logging import concurrent.futures import asyncio -import toml - -from bot import Bot +import bot # only use uvloop if not using pypy if py_imp() != 'PyPy': @@ -21,13 +19,12 @@ if __name__ == '__main__': evloop = asyncio.get_event_loop() evloop.set_default_executor( concurrent.futures.ThreadPoolExecutor( 4 ) ) - # parse bot configuration + # determine config filename 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 ) + # initialize bot instance, takes name of a config file + bot.init( evloop, cfg_file ) try: evloop.run_forever() except KeyboardInterrupt: diff --git a/modules/misc.py b/modules/misc.py index d9a04ed..b16b258 100644 --- a/modules/misc.py +++ b/modules/misc.py @@ -18,12 +18,12 @@ PY_APP_URL = 'https://tumbolia-sopel.appspot.com/py/{}' #req_ses = requests.Session() @bot.command( 'eval' ) -async def cmd_eval( b, p, c ): +async def cmd_eval( p, c ): """ Evaluates Python code. Trust me, it's safe. """ evstr = urllib.parse.quote( ' '.join( p ) ) try: - res = await b._loop.run_in_executor( None, + res = await bot.get_loop().run_in_executor( None, functools.partial( requests.get, PY_APP_URL.format( evstr ), timeout=3.0 ) ) rstr = res.text.rstrip().lstrip() @@ -42,14 +42,15 @@ async def cmd_eval( b, p, c ): # parser test @bot.parser() def parse_misc( c, d, n, m ): - if m.split()[0].lower() == '!botlist' or m.split()[0].lower() == '!rollcall': - pfx = ';' # HAX - c.say_to( d, ('> dustbot | Owner: {} | ' - 'Source: https://tildegit.org/slipyx/dustbot | Prefix: \'{}\'. | ' - 'Commands: See \'{}list\'.').format( c.cfg['usr']['owner'],pfx,pfx ) ) + if len(m.split()) > 0: + if m.split()[0].lower() == '!botlist' or m.split()[0].lower() == '!rollcall': + pfx = bot.get_cfg()['bot'].get( 'prefix' ) + c.say_to( d, ('> dustbot | Owner: {} | ' + 'Source: https://tildegit.org/slipyx/dustbot | Prefix: \'{}\'. | ' + 'Commands: See \'{}list\'.').format( c.cfg['usr']['owner'],pfx,pfx ) ) @bot.command( 'rfk' ) -async def cmd_rfk( b, p, c ): +async def cmd_rfk( p, c ): """ Try to find kitten! """ nki = '' if random.randint(1,20) == random.randint(1,20): nki = 'YOU FOUND KITTEN! CONGRATULATIONS!' diff --git a/modules/qdb.py b/modules/qdb.py index 9e2de84..16c160f 100644 --- a/modules/qdb.py +++ b/modules/qdb.py @@ -7,7 +7,7 @@ import random import requests import bs4 -from bot import command +import bot # map of supported databases QDB_URLS = { @@ -18,8 +18,8 @@ QDB_URLS = { 'tilde': 'https://quotes.tilde.chat/random' } -@command( 'qdb' ) -async def get_quote( bot, parms, ctx ): +@bot.command( 'qdb' ) +async def get_quote( parms, ctx ): """ Grabs a random quote from specified quote database, default random or database with same name as current network. diff --git a/protocol.py b/protocol.py index 5e42d90..c3410ff 100644 --- a/protocol.py +++ b/protocol.py @@ -43,7 +43,8 @@ class IrcProtocol( asyncio.Protocol ): 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 priv message callbacks + # callbacks + self._cb = {'message':[],'connect':[],'disconnect':[]} self.names = {} # map of names on server to list of channels they're visible in def connection_made( self, transport ): @@ -51,12 +52,18 @@ class IrcProtocol( asyncio.Protocol ): self._trans = transport self.send( 'NICK {}'.format( self.cfg['usr']['nick'] ) ) self.send( 'USER {} 0 * :{}'.format( self.cfg['usr']['user'], self.cfg['usr']['name'] ) ) + for c in self._cb['connect']: c( self ) def connection_lost( self, exc ): self._trans.close() self.names.clear() # clear names if exc is None: exc = 'EOF' self.warning( 'Connection lost to server \'{}\'! ({})'.format( self.cfg['sv']['host'], exc ) ) + # callbacks + for c in self._cb['disconnect']: c( self ) + # remove all callbacks + #for cb in self._cb.values(): + #cb.clear() # if was a manual stop, dont attempt reconnect if self._stop: self.on_stop() @@ -86,7 +93,7 @@ class IrcProtocol( asyncio.Protocol ): raw_user = words[0] nick = raw_user[1 : words[0].find( '!' )] msg = raw_msg[raw_msg.find( ':' , 1 ) + 1 :] - for cb in self._msg_cb: cb( self, dst, nick, msg ) + for cb in self._cb['message']: cb( self, dst, nick, msg ) # on names elif words[1] == '353': #self.log( '::NAMES:: {}'.format( raw_msg.decode() ) ) @@ -114,9 +121,12 @@ class IrcProtocol( asyncio.Protocol ): self.log( '::QUIT:: {} has quit the server.'.format( nick ) ) self.quit_name( nick ) elif 'NICK' in words[1]: + self.log( raw_msg ) nick = words[0][1:words[0].find('!')] #self.log( '::NICK:: {} is now known as {}.'.format( nick, words[2][1:] ) ) - self.change_name( nick, words[2][1:] ) + newnick = words[2] + if newnick[0] == ':': newnick = newnick[1:] + self.change_name( nick, newnick ) elif 'KICK' in words[1]: if words[3] == self.cfg['usr']['nick']: self.log( '::KICK:: {} has been kicked from {}.'.format( words[3], words[2] ) ) @@ -135,10 +145,10 @@ class IrcProtocol( asyncio.Protocol ): def part_name( self, name, chan ): """ Removes chan from name """ - self.names[name].remove( chan ) + if chan in self.names[name]: self.names[name].remove( chan ) # if chans are empty, remove name completely if not self.names[name]: self.quit_name( name ) - # when self leaves a chan, remove chan from all existing names + # when bot leaves a chan, remove chan from all existing names if name == self.cfg['usr']['nick']: nms = [] for k,v in self.names.items(): @@ -150,11 +160,14 @@ class IrcProtocol( asyncio.Protocol ): def quit_name( self, name ): """ Removes name from connection """ - self.names.pop( name ) + self.names.pop( name, [] ) def change_name( self, name, new ): """ Renames an existing name """ - self.names[new] = self.names.pop( name ) + #if new in self.names: + self.names[new] = self.names.pop( name, [] ) + #else: self.names[new] = [] + #self.log( 'NICK {} changed to {} w/now: {}'.format( name, new, self.names[new] ) ) # callbacks def add_message_callback( self, cb ): @@ -162,7 +175,14 @@ class IrcProtocol( asyncio.Protocol ): Adds a function to callback when a priv message is received. Passes in self, dst, nick, and the message """ - self._msg_cb.append( cb ) + if cb not in self._cb['message']: + self._cb['message'].append( cb ) + def add_connect_callback( self, cb ): + if cb not in self._cb['connect']: + self._cb['connect'].append( cb ) + def add_disconnect_callback( self, cb ): + if cb not in self._cb['disconnect']: + self._cb['disconnect'].append( cb ) # logging def log( self, msg, level=logging.INFO ):