You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
345 lines
12 KiB
345 lines
12 KiB
""" |
|
.. module:: PyIRCBot |
|
:synopsis: Main IRC bot class |
|
|
|
.. moduleauthor:: Dave Pedu <dave@davepedu.com> |
|
|
|
""" |
|
|
|
import logging |
|
import sys |
|
from pyircbot.rpc import BotRPC |
|
from pyircbot.irccore import IRCCore |
|
from socket import AF_INET, AF_INET6 |
|
import os.path |
|
import asyncio |
|
import traceback |
|
|
|
|
|
class ModuleLoader(object): |
|
def __init__(self): |
|
"""storage of imported modules""" |
|
self.modules = {} |
|
|
|
"""instances of modules""" |
|
self.moduleInstances = {} |
|
|
|
self.log = logging.getLogger('ModuleLoader') |
|
|
|
def importmodule(self, name): |
|
"""Import a module |
|
|
|
:param moduleName: Name of the module to import |
|
:type moduleName: str""" |
|
" check if already exists " |
|
if name not in self.modules: |
|
self.log.info("Importing %s" % name) |
|
" attempt to load " |
|
try: |
|
moduleref = __import__(name) |
|
self.modules[name] = moduleref |
|
return (True, None) |
|
except Exception as e: |
|
" on failure (usually syntax error in Module code) print an error " |
|
self.log.error("Module %s failed to load: " % name) |
|
self.log.error("Module load failure reason: " + str(e)) |
|
traceback.print_exc() |
|
return (False, str(e)) |
|
else: |
|
self.log.warning("Module %s already imported" % name) |
|
return (False, "Module already imported") |
|
|
|
def deportmodule(self, name): |
|
"""Remove a module's code from memory. If the module is loaded it will be unloaded silently. |
|
|
|
:param moduleName: Name of the module to import |
|
:type moduleName: str""" |
|
self.log.info("Deporting %s" % name) |
|
" unload if necessary " |
|
if name in self.moduleInstances: |
|
self.unloadmodule(name) |
|
" delete all references to the module" |
|
if name in self.modules: |
|
del self.modules[name] |
|
" delete copy that python stores in sys.modules " |
|
if name in sys.modules: |
|
del sys.modules[name] |
|
|
|
def loadmodule(self, name): |
|
"""Activate a module. |
|
|
|
:param moduleName: Name of the module to activate |
|
:type moduleName: str""" |
|
" check if already loaded " |
|
if name in self.moduleInstances: |
|
self.log.warning("Module %s already loaded" % name) |
|
return False |
|
" check if needs to be imported, and verify it was " |
|
if name not in self.modules: |
|
importResult = self.importmodule(name) |
|
if not importResult[0]: |
|
return importResult |
|
" init the module " |
|
self.moduleInstances[name] = getattr(self.modules[name], name)(self, name) |
|
" load hooks " |
|
# self.loadModuleHooks(self.moduleInstances[name]) |
|
self.moduleInstances[name].onenable() |
|
|
|
def unloadmodule(self, name): |
|
"""Deactivate a module. |
|
|
|
:param moduleName: Name of the module to deactivate |
|
:type moduleName: str""" |
|
if name in self.moduleInstances: |
|
" notify the module of disabling " |
|
self.moduleInstances[name].ondisable() |
|
" unload all hooks " |
|
# self.unloadModuleHooks(self.moduleInstances[name]) |
|
" remove & delete the instance " |
|
self.moduleInstances.pop(name) |
|
self.log.info("Module %s unloaded" % name) |
|
return (True, None) |
|
else: |
|
self.log.info("Module %s not loaded" % name) |
|
return (False, "Module not loaded") |
|
|
|
def reloadmodule(self, name): |
|
"""Deactivate and activate a module. |
|
|
|
:param moduleName: Name of the target module |
|
:type moduleName: str""" |
|
" make sure it's imporeted" |
|
if name in self.modules: |
|
" remember if it was loaded before" |
|
loadedbefore = name in self.moduleInstances |
|
self.log.info("Reloading %s" % self.modules[name]) |
|
" unload " |
|
self.unloadmodule(name) |
|
" load " |
|
if loadedbefore: |
|
self.loadmodule(name) |
|
return (True, None) |
|
return (False, "Module is not loaded") |
|
|
|
def redomodule(self, name): |
|
"""Reload a running module from disk |
|
|
|
:param moduleName: Name of the target module |
|
:type moduleName: str""" |
|
" remember if it was loaded before " |
|
loadedbefore = name in self.moduleInstances |
|
" unload/deport " |
|
self.deportmodule(name) |
|
" import " |
|
importResult = self.importmodule(name) |
|
if not importResult[0]: |
|
return importResult |
|
" load " |
|
if loadedbefore: |
|
self.loadmodule(name) |
|
return (True, None) |
|
|
|
def getmodulebyname(self, name): |
|
"""Get a module object by name |
|
|
|
:param name: name of the module to return |
|
:type name: str |
|
:returns: object -- the module object""" |
|
if name not in self.moduleInstances: |
|
return None |
|
return self.moduleInstances[name] |
|
|
|
def getmodulesbyservice(self, service): |
|
"""Get a list of modules that provide the specified service |
|
|
|
:param service: name of the service searched for |
|
:type service: str |
|
:returns: list -- a list of module objects""" |
|
validModules = [] |
|
for module in self.moduleInstances: |
|
if service in self.moduleInstances[module].services: |
|
validModules.append(self.moduleInstances[module]) |
|
return validModules |
|
|
|
def getBestModuleForService(self, service): |
|
"""Get the first module that provides the specified service |
|
|
|
:param service: name of the service searched for |
|
:type service: str |
|
:returns: object -- the module object, if found. None if not found.""" |
|
m = self.getmodulesbyservice(service) |
|
if len(m) > 0: |
|
return m[0] |
|
return None |
|
|
|
|
|
class PrimitiveBot(ModuleLoader): |
|
def __init__(self, botconfig): |
|
super().__init__() |
|
self.botconfig = botconfig |
|
|
|
def closeAllModules(self): |
|
""" |
|
Deport all modules (for shutdown). Modules are unloaded in the opposite order listed in the config. |
|
""" |
|
loaded = list(self.moduleInstances.keys()) |
|
loadOrder = self.botconfig["modules"] |
|
loadOrder.reverse() |
|
for key in loadOrder: |
|
if key in loaded: |
|
loaded.remove(key) |
|
self.deportmodule(key) |
|
for key in loaded: |
|
self.deportmodule(key) |
|
|
|
" Filesystem Methods " |
|
def getConfigPath(self, moduleName): |
|
"""Return the absolute path for a module's config file |
|
|
|
:param moduleName: the module who's config file we want |
|
:type moduleName: str""" |
|
|
|
basepath = "%s/config/%s" % (self.botconfig["bot"]["datadir"], moduleName) |
|
|
|
if os.path.exists("%s.json" % basepath): |
|
return "%s.json" % basepath |
|
return None |
|
|
|
def getDataPath(self, moduleName): |
|
"""Return the absolute path for a module's data dir |
|
|
|
:param moduleName: the module who's data dir we want |
|
:type moduleName: str""" |
|
module_dir = os.path.join(self.botconfig["bot"]["datadir"], "data", moduleName) |
|
if not os.path.exists(module_dir): |
|
os.mkdir(module_dir) |
|
return module_dir |
|
|
|
|
|
class PyIRCBot(PrimitiveBot): |
|
""":param botconfig: The configuration of this instance of the bot. Passed by main.py. |
|
:type botconfig: dict |
|
""" |
|
version = "5.0.0" |
|
|
|
def __init__(self, botconfig): |
|
super().__init__(botconfig) |
|
|
|
self.log = logging.getLogger('PyIRCBot') |
|
"""Reference to logger object""" |
|
|
|
self.loop = asyncio.get_event_loop() |
|
|
|
"""Reference to BotRPC thread""" |
|
if self.botconfig["bot"]["rpcport"] >= 0: |
|
self.rpc = BotRPC(self) |
|
|
|
ratelimit = self.botconfig["connection"].get("rate_limit", None) or dict(rate_max=5.0, rate_int=1.1) |
|
|
|
"""IRC protocol handler""" |
|
self.irc = IRCCore(servers=self.botconfig["connection"]["servers"], |
|
loop=self.loop, |
|
rate_limit=True if ratelimit else False, |
|
rate_max=ratelimit["rate_max"], |
|
rate_int=ratelimit["rate_int"]) |
|
if self.botconfig.get("connection").get("force_ipv6", False): |
|
self.irc.connection_family = AF_INET6 |
|
elif self.botconfig.get("connection").get("force_ipv4", False): |
|
self.irc.connection_family = AF_INET |
|
self.irc.bind_addr = self.botconfig.get("connection").get("bind", None) |
|
|
|
self.act_PONG = self.irc.act_PONG |
|
self.act_USER = self.irc.act_USER |
|
self.act_NICK = self.irc.act_NICK |
|
self.act_JOIN = self.irc.act_JOIN |
|
self.act_PRIVMSG = self.irc.act_PRIVMSG |
|
self.act_MODE = self.irc.act_MODE |
|
self.act_ACTION = self.irc.act_ACTION |
|
self.act_KICK = self.irc.act_KICK |
|
self.act_QUIT = self.irc.act_QUIT |
|
self.act_PASS = self.irc.act_PASS |
|
self.get_nick = self.irc.get_nick |
|
self.decodePrefix = IRCCore.decodePrefix |
|
|
|
# Load modules |
|
self.initModules() |
|
|
|
# Internal usage hook |
|
self.irc.addHook("_ALL", self._irchook_internal) |
|
|
|
def initModules(self): |
|
"""load modules specified in instance config""" |
|
" append module location to path " |
|
sys.path.append(os.path.dirname(__file__) + "/modules/") |
|
|
|
" append usermodule dir to beginning of path" |
|
for path in self.botconfig["bot"]["usermodules"]: |
|
sys.path.insert(0, path + "/") |
|
|
|
for modulename in self.botconfig["modules"]: |
|
self.loadmodule(modulename) |
|
|
|
def run(self): |
|
self.client = asyncio.ensure_future(self.irc.loop(self.loop), loop=self.loop) |
|
try: |
|
self.loop.set_debug(True) |
|
self.loop.run_until_complete(self.client) |
|
finally: |
|
logging.debug("Escaped main loop") |
|
|
|
def disconnect(self, message): |
|
"""Send quit message and disconnect from IRC. |
|
|
|
:param message: Quit message |
|
:type message: str |
|
:param reconnect: True causes a reconnection attempt to be made after the disconnect |
|
:type reconnect: bool |
|
""" |
|
self.log.info("disconnect") |
|
self.kill(message=message) |
|
|
|
def kill(self, message="Help! Another thread is killing me :(", forever=True): |
|
"""Close the connection violently |
|
|
|
:param sys_exit: True causes sys.exit(0) to be called |
|
:type sys_exit: bool |
|
:param message: Quit message |
|
:type message: str |
|
""" |
|
if forever: |
|
self.closeAllModules() |
|
asyncio.run_coroutine_threadsafe(self.irc.kill(message=message, forever=forever), self.loop) |
|
|
|
def _irchook_internal(self, msg): |
|
""" |
|
IRC hook handler. Calling point for IRCHook based module hooks. This method is called when any message is |
|
received. It tests all hooks in all modules against the message can calls the hooked function on hits. |
|
""" |
|
for module_name, module in self.moduleInstances.items(): |
|
for hook in module.irchooks: |
|
validation = hook.validator(msg, self) |
|
if validation: |
|
hook.method(msg, validation) |
|
|
|
" Filesystem Methods " |
|
def getConfigPath(self, moduleName): |
|
"""Return the absolute path for a module's config file |
|
|
|
:param moduleName: the module who's config file we want |
|
:type moduleName: str""" |
|
|
|
basepath = "%s/config/%s" % (self.botconfig["bot"]["datadir"], moduleName) |
|
|
|
if os.path.exists("%s.json" % basepath): |
|
return "%s.json" % basepath |
|
return None |
|
|
|
def getDataPath(self, moduleName): |
|
"""Return the absolute path for a module's data dir |
|
|
|
:param moduleName: the module who's data dir we want |
|
:type moduleName: str""" |
|
module_dir = os.path.join(self.botconfig["bot"]["datadir"], "data", moduleName) |
|
if not os.path.exists(module_dir): |
|
os.mkdir(module_dir) |
|
return module_dir
|
|
|