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 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 <module>' """
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' )

View File

@ -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

11
main.py
View File

@ -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:

View File

@ -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!'

View File

@ -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.

View File

@ -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 ):