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:
parent
508dc04eac
commit
cceb2515eb
141
bot.py
141
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,28 +83,64 @@ def parser():
|
|||
|
||||
return __deco__
|
||||
|
||||
class Bot:
|
||||
""" Bot class """
|
||||
def __init__( self, loop, cfg ):
|
||||
self._loop = loop
|
||||
self.cfg = cfg
|
||||
# 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
|
||||
self._con = []
|
||||
_BOT['cons'] = []
|
||||
# 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 ) )
|
||||
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 ):
|
||||
# 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 = self.cfg['bot']['prefix']
|
||||
pfx = bcfg['bot']['prefix']
|
||||
strp_msg = msg.lstrip()
|
||||
if strp_msg[0 : len( pfx )] == pfx:
|
||||
msg_words = strp_msg.split()
|
||||
|
@ -104,17 +154,17 @@ class Bot:
|
|||
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 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
|
||||
self._loop.create_task( cmd_func(
|
||||
self, parms, {'con':con,'dst':dst,'nick':nick} ) )
|
||||
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']:
|
||||
if nick != bcfg['user']['nick']:
|
||||
# call each registered parser
|
||||
for v in _MOD_MAP.values():
|
||||
for p in v['parsers']:
|
||||
|
@ -124,28 +174,28 @@ class Bot:
|
|||
|
||||
# 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' )
|
||||
|
||||
|
|
5
cfg.toml
5
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
|
||||
|
|
11
main.py
11
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:
|
||||
|
|
|
@ -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 len(m.split()) > 0:
|
||||
if m.split()[0].lower() == '!botlist' or m.split()[0].lower() == '!rollcall':
|
||||
pfx = ';' # HAX
|
||||
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!'
|
||||
|
|
|
@ -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.
|
||||
|
|
36
protocol.py
36
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 ):
|
||||
|
|
Loading…
Reference in New Issue