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.

This commit is contained in:
Josh K 2018-11-18 09:01:50 -05:00
parent 508dc04eac
commit cceb2515eb
6 changed files with 166 additions and 100 deletions

191
bot.py
View File

@ -9,6 +9,8 @@ import logging
import importlib import importlib
import sys import sys
import toml
from protocol import IrcProtocol from protocol import IrcProtocol
import modules import modules
@ -19,6 +21,18 @@ try: _MOD_MAP
except NameError: except NameError:
_MOD_MAP = {} _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 ): def add_module( name ):
""" adds a new named empty module definition to the global module map """ adds a new named empty module definition to the global module map
'fixes' passed module name by removing top-level package name 'fixes' passed module name by removing top-level package name
@ -69,83 +83,119 @@ def parser():
return __deco__ return __deco__
class Bot: # initialize the bot instance, takes a config filename
""" Bot class """ def init( loop, cfg_file ):
def __init__( self, loop, cfg ): _BOT['loop'] = loop
self._loop = loop _BOT['_cfg_file'] = cfg_file
self.cfg = cfg # load config file
# list of network connections cfg = toml.loads( open( cfg_file ).read() )
self._con = [] _BOT['cfg'] = cfg
# create and connect all servers in config # import initial module list from config
for s in self.cfg['server']: _load_modules( cfg['bot'].get( 'modules', [] ) )
ncon = IrcProtocol( self._loop, self.cfg['user'] ) # list of network connections
# add priv message callback to the connections _BOT['cons'] = []
ncon.add_message_callback( self._on_priv_message ) # create and connect all servers in config
self._con.append( ncon ) for s in cfg.get( 'server', [] ):
self._loop.create_task( ncon.do_connect( s ) ) add_connection( s )
# called when a connected connection receives a priv message # public getter functions for bot
# passes the connection that sent, destination of message, def get_loop():
# the nick that sent, and the message return _BOT.get( 'loop', None )
def _on_priv_message( self, con, dst, nick, msg ): def get_cfg():
# check for command-like message return _BOT.get( 'cfg', {} )
# message split into words, first word is command rest are parms list def get_cons():
pfx = self.cfg['bot']['prefix'] return _BOT.get( 'cons', [] )
strp_msg = msg.lstrip()
if strp_msg[0 : len( pfx )] == pfx: # add and connect to a new server
msg_words = strp_msg.split() def add_connection( sv_cfg ):
cmd = msg_words[0][len( pfx ):].lower() loop = get_loop()
parms = msg_words[1:] ncon = IrcProtocol( loop, get_cfg().get( 'user' ) )
# check if command is in a registered module # add connection callbacks
for k, m in _MOD_MAP.items(): ncon.add_message_callback( _on_priv_message )
if cmd in m['cmds'].keys(): ncon.add_connect_callback( _on_connect )
cmd_func = m['cmds'][cmd] ncon.add_disconnect_callback( _on_disconnect )
con.log( '::CMD:: \'{}\' run by <{}> in ({}) with parms: {}'.format( loop.create_task( ncon.do_connect( sv_cfg ) )
cmd, nick, dst, parms ) )
# if msg was sent as pm, def _on_connect( con ):
# change dst to nick so say_to's can respond in pm con.warning( 'ON CONNECT!' )
if dst == self.cfg['user']['nick']: dst = nick cons = get_cons()
# check for admin # add priv msg callback here since this could be a reconnect as well
if 'admin' in cmd_func.__dict__.keys() and nick != con.cfg['usr']['owner']: # and it was removed on disconnect
con.say_to( dst, '> You do not have permission to execute this command.' ) #con.add_message_callback( _on_priv_message )
break if con not in cons:
self._loop.create_task( cmd_func( cons.append( con )
self, parms, {'con':con,'dst':dst,'nick':nick} ) )
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 break
get_loop().create_task( cmd_func(
parms, {'con':con,'dst':dst,'nick':nick} ) )
break
# callback registered parsers, not for self messages though # callback registered parsers, not for self messages though
if nick != self.cfg['user']['nick']: if nick != bcfg['user']['nick']:
# call each registered parser # call each registered parser
for v in _MOD_MAP.values(): for v in _MOD_MAP.values():
for p in v['parsers']: for p in v['parsers']:
p( con, dst, nick, msg ) p( con, dst, nick, msg )
# core commands # core commands
# join / part # join / part
@command( 'join', True ) @command( 'join', True )
async def cmd_join( bot, parms, ctx ): async def cmd_join( parms, ctx ):
""" Joins a channel. """ """ Joins a channel. """
con = ctx['con'] con = ctx['con']
if parms \ 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.send( 'JOIN {}'.format( parms[0] ) )
con.cfg['sv']['channels'].append( parms[0] ) con.cfg['sv']['channels'].append( parms[0].lower() )
@command( 'part', True ) @command( 'part', True )
async def cmd_part( bot, parms, ctx ): async def cmd_part( parms, ctx ):
""" Leaves a channel. """ """ Leaves a channel. """
con = ctx['con'] con = ctx['con']
if parms \ 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.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 # gets the doc string for specified command
@command( 'help' ) @command( 'help' )
async def cmd_help( b, p, c ): async def cmd_help( p, c ):
""" Shows help message for a specified command. """ """ Shows help message for a specified command. """
con = c['con'] con = c['con']
@ -168,7 +218,7 @@ async def cmd_help( b, p, c ):
# list commands # list commands
@command( 'list' ) @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. """ """ Lists available commands. Specify a module name from 'modules' command to filter. """
cn = c['con'] cn = c['con']
@ -186,7 +236,7 @@ async def cmd_list( b, p, c ):
# list loaded modules # list loaded modules
@command( '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 <module>' """ """ List all currently loaded modules. List commands within using 'list <module>' """
cn = c['con'] cn = c['con']
modlist = list(_MOD_MAP.keys()) modlist = list(_MOD_MAP.keys())
@ -194,14 +244,14 @@ async def cmd_modules( b, p, c ):
# list active connections # list active connections
@command( 'connections', True ) @command( 'connections', True )
async def cmd_connections( b, p, c ): async def cmd_connections( p, c ):
""" List currently connected connections """ """ 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 ) ) c['con'].say_to( c['dst'], '> {}'.format( conlist ) )
# reload a specified module # reload a specified module
@command( 'reload', True ) @command( 'reload', True )
async def cmd_import( bot, p, ctx ): async def cmd_import( p, ctx ):
""" Reloads a previously loaded module. """ """ Reloads a previously loaded module. """
con = ctx['con'] con = ctx['con']
dst = ctx['dst'] dst = ctx['dst']
@ -221,7 +271,7 @@ async def cmd_import( bot, p, ctx ):
# connect to a new server # connect to a new server
@command( 'connect', True ) @command( 'connect', True )
async def cmd_connect( b, p, c ): async def cmd_connect( p, c ):
""" """
Connects to a new network. Connects to a new network.
Parameters: 'name' 'host' Parameters: 'name' 'host'
@ -231,25 +281,21 @@ async def cmd_connect( b, p, c ):
if not p and len( p ) < 2: return if not p and len( p ) < 2: return
new_sv_cfg = {'host':p[1],'port':6697,'name':p[0]} new_sv_cfg = {'host':p[1],'port':6697,'name':p[0]}
ncon = IrcProtocol( b._loop, con.cfg['usr'] ) add_connection( new_sv_cfg )
# 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 ) )
con.say_to( c['dst'], 'OMW...' ) con.say_to( c['dst'], 'OMW...' )
# disconnect from currently connected server # disconnect from currently connected server
@command( 'disconnect', True ) @command( 'disconnect', True )
async def cmd_discon( b, p, c ): async def cmd_discon( p, c ):
""" Disconnects from currently connected network """ """ Disconnects from currently connected network """
con = c['con'] con = c['con']
con._stop = True con._stop = True
con.send( 'QUIT :POOF' ) con.send( 'QUIT :POOF' )
b._con.remove( con ) #b._con.remove( con )
# list of names on chan # list of names on chan
@command( 'names', True ) @command( 'names', True )
async def cmd_names( b, p, c ): async def cmd_names( p, c ):
cn = c['con'] cn = c['con']
nmstr = '' nmstr = ''
for k,v in cn.names.items(): 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.say_to( c['dst'], '> {}'.format( nmstr ) )
cn.log( str(cn.names) ) #cn.log( str(cn.names) )
# custom modules # imports a list of module names from the modules package
importlib.import_module( '.misc', 'modules' ) def _load_modules( mod_list ):
importlib.import_module( '.qdb', 'modules' ) for n in mod_list:
importlib.import_module( '.{}'.format( n ), 'modules' )

View File

@ -21,8 +21,9 @@ name = "Me Mow"
# command prefix # command prefix
prefix = ";" prefix = ";"
# TBD?? # list of module names to import from modules package
modules = [] # additions are imported on bot reload
modules = ['misc','qdb']
## ##
## networks/servers config ## networks/servers config

11
main.py
View File

@ -8,9 +8,7 @@ import logging
import concurrent.futures import concurrent.futures
import asyncio import asyncio
import toml import bot
from bot import Bot
# only use uvloop if not using pypy # only use uvloop if not using pypy
if py_imp() != 'PyPy': if py_imp() != 'PyPy':
@ -21,13 +19,12 @@ if __name__ == '__main__':
evloop = asyncio.get_event_loop() evloop = asyncio.get_event_loop()
evloop.set_default_executor( concurrent.futures.ThreadPoolExecutor( 4 ) ) evloop.set_default_executor( concurrent.futures.ThreadPoolExecutor( 4 ) )
# parse bot configuration # determine config filename
cfg_file = './cfg.toml' cfg_file = './cfg.toml'
if len( sys.argv ) > 1: cfg_file = sys.argv[1] if len( sys.argv ) > 1: cfg_file = sys.argv[1]
cfg = toml.loads( open( cfg_file ).read() )
# create a bot instance # initialize bot instance, takes name of a config file
bot = Bot( evloop, cfg ) bot.init( evloop, cfg_file )
try: evloop.run_forever() try: evloop.run_forever()
except KeyboardInterrupt: except KeyboardInterrupt:

View File

@ -18,12 +18,12 @@ PY_APP_URL = 'https://tumbolia-sopel.appspot.com/py/{}'
#req_ses = requests.Session() #req_ses = requests.Session()
@bot.command( 'eval' ) @bot.command( 'eval' )
async def cmd_eval( b, p, c ): async def cmd_eval( p, c ):
""" Evaluates Python code. Trust me, it's safe. """ """ Evaluates Python code. Trust me, it's safe. """
evstr = urllib.parse.quote( ' '.join( p ) ) evstr = urllib.parse.quote( ' '.join( p ) )
try: 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 ) ) functools.partial( requests.get, PY_APP_URL.format( evstr ), timeout=3.0 ) )
rstr = res.text.rstrip().lstrip() rstr = res.text.rstrip().lstrip()
@ -42,14 +42,15 @@ async def cmd_eval( b, p, c ):
# parser test # parser test
@bot.parser() @bot.parser()
def parse_misc( c, d, n, m ): def parse_misc( c, d, n, m ):
if m.split()[0].lower() == '!botlist' or m.split()[0].lower() == '!rollcall': if len(m.split()) > 0:
pfx = ';' # HAX if m.split()[0].lower() == '!botlist' or m.split()[0].lower() == '!rollcall':
c.say_to( d, ('> dustbot | Owner: {} | ' pfx = bot.get_cfg()['bot'].get( 'prefix' )
'Source: https://tildegit.org/slipyx/dustbot | Prefix: \'{}\'. | ' c.say_to( d, ('> dustbot | Owner: {} | '
'Commands: See \'{}list\'.').format( c.cfg['usr']['owner'],pfx,pfx ) ) 'Source: https://tildegit.org/slipyx/dustbot | Prefix: \'{}\'. | '
'Commands: See \'{}list\'.').format( c.cfg['usr']['owner'],pfx,pfx ) )
@bot.command( 'rfk' ) @bot.command( 'rfk' )
async def cmd_rfk( b, p, c ): async def cmd_rfk( p, c ):
""" Try to find kitten! """ """ Try to find kitten! """
nki = '' nki = ''
if random.randint(1,20) == random.randint(1,20): nki = 'YOU FOUND KITTEN! CONGRATULATIONS!' if random.randint(1,20) == random.randint(1,20): nki = 'YOU FOUND KITTEN! CONGRATULATIONS!'

View File

@ -7,7 +7,7 @@ import random
import requests import requests
import bs4 import bs4
from bot import command import bot
# map of supported databases # map of supported databases
QDB_URLS = { QDB_URLS = {
@ -18,8 +18,8 @@ QDB_URLS = {
'tilde': 'https://quotes.tilde.chat/random' 'tilde': 'https://quotes.tilde.chat/random'
} }
@command( 'qdb' ) @bot.command( 'qdb' )
async def get_quote( bot, parms, ctx ): async def get_quote( parms, ctx ):
""" """
Grabs a random quote from specified quote database, Grabs a random quote from specified quote database,
default random or database with same name as current network. default random or database with same name as current network.

View File

@ -43,7 +43,8 @@ class IrcProtocol( asyncio.Protocol ):
self._trans = None # protocol's transport self._trans = None # protocol's transport
self._stop = False # True if doing a manual stop and dont try to reconnect 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.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 self.names = {} # map of names on server to list of channels they're visible in
def connection_made( self, transport ): def connection_made( self, transport ):
@ -51,12 +52,18 @@ class IrcProtocol( asyncio.Protocol ):
self._trans = transport self._trans = transport
self.send( 'NICK {}'.format( self.cfg['usr']['nick'] ) ) self.send( 'NICK {}'.format( self.cfg['usr']['nick'] ) )
self.send( 'USER {} 0 * :{}'.format( self.cfg['usr']['user'], self.cfg['usr']['name'] ) ) 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 ): def connection_lost( self, exc ):
self._trans.close() self._trans.close()
self.names.clear() # clear names self.names.clear() # clear names
if exc is None: exc = 'EOF' if exc is None: exc = 'EOF'
self.warning( 'Connection lost to server \'{}\'! ({})'.format( self.cfg['sv']['host'], exc ) ) 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 was a manual stop, dont attempt reconnect
if self._stop: if self._stop:
self.on_stop() self.on_stop()
@ -86,7 +93,7 @@ class IrcProtocol( asyncio.Protocol ):
raw_user = words[0] raw_user = words[0]
nick = raw_user[1 : words[0].find( '!' )] nick = raw_user[1 : words[0].find( '!' )]
msg = raw_msg[raw_msg.find( ':' , 1 ) + 1 :] 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 # on names
elif words[1] == '353': elif words[1] == '353':
#self.log( '::NAMES:: {}'.format( raw_msg.decode() ) ) #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.log( '::QUIT:: {} has quit the server.'.format( nick ) )
self.quit_name( nick ) self.quit_name( nick )
elif 'NICK' in words[1]: elif 'NICK' in words[1]:
self.log( raw_msg )
nick = words[0][1:words[0].find('!')] nick = words[0][1:words[0].find('!')]
#self.log( '::NICK:: {} is now known as {}.'.format( nick, words[2][1:] ) ) #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]: elif 'KICK' in words[1]:
if words[3] == self.cfg['usr']['nick']: if words[3] == self.cfg['usr']['nick']:
self.log( '::KICK:: {} has been kicked from {}.'.format( words[3], words[2] ) ) 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 ): def part_name( self, name, chan ):
""" Removes chan from name """ """ 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 chans are empty, remove name completely
if not self.names[name]: self.quit_name( name ) 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']: if name == self.cfg['usr']['nick']:
nms = [] nms = []
for k,v in self.names.items(): for k,v in self.names.items():
@ -150,11 +160,14 @@ class IrcProtocol( asyncio.Protocol ):
def quit_name( self, name ): def quit_name( self, name ):
""" Removes name from connection """ """ Removes name from connection """
self.names.pop( name ) self.names.pop( name, [] )
def change_name( self, name, new ): def change_name( self, name, new ):
""" Renames an existing name """ """ 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 # callbacks
def add_message_callback( self, cb ): 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. Adds a function to callback when a priv message is received.
Passes in self, dst, nick, and the message 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 # logging
def log( self, msg, level=logging.INFO ): def log( self, msg, level=logging.INFO ):