Initial commit.

This commit is contained in:
Josh K 2018-08-19 05:08:58 -04:00
commit 646ebb0004
8 changed files with 556 additions and 0 deletions

8
LICENSE.txt Normal file
View File

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

24
README.md Normal file
View File

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

197
bot.py Normal file
View File

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

42
cfg.toml Normal file
View File

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

37
main.py Executable file
View File

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

2
modules/__init__.py Normal file
View File

@ -0,0 +1,2 @@
pass

105
modules/qdb.py Normal file
View File

@ -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: <a class="quote_number">
# bash: first <b> child of <p> quote
# xkcd: first <a> child of <span class="quotehead">
# qdb: <a class="ql">
# 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! :(' )

141
protocol.py Normal file
View File

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