import glob
import importlib
from pathlib import Path
from typing import Any, Callable, Dict, List, NoReturn, Union
import requests
import yaml
from slack.web.classes.messages import Message
from slack.web.classes.objects import MarkdownTextObject, PlainTextObject, TextObject
from glados import (
BOT_ROUTES,
EventRoutes,
GladosBot,
GladosBotNotFoundError,
GladosError,
GladosPathExistsError,
GladosRequest,
GladosRoute,
PyJSON,
RouteType,
VERIFY_ROUTES,
logging,
)
SLACK_MESSAGE_TYPES = [Message, MarkdownTextObject, TextObject, PlainTextObject]
[docs]class PluginBotConfig:
def __init__(self, name="NOT SET"):
self.name = name
[docs] def to_dict(self):
return dict(name=self.name)
[docs]class PluginConfig:
def __init__(
self,
name: str,
config_file: str,
module=None,
enabled=False,
bot=None,
**kwargs,
):
"""Plugin Config Object.
Parameters
----------
name
Plugin Name
config_file
Path to config file for plugin
module
plugin python module name
enabled
enable this plugin
bot
what bot does this plugin use
kwargs
"""
if not bot:
bot = dict()
self.name = name
self.module = module
self.enabled = enabled
self.bot = PluginBotConfig(**bot)
self.config_file = config_file
self.config = PyJSON(kwargs)
package = config_file.replace("/", ".")
package = package.replace(".config.yaml", "")
self.package = package
[docs] def update(self, config: "PluginConfig", use_base_module: bool = True) -> NoReturn:
"""Update a config object using the default values from the config object passed in.
Parameters
----------
config : PluginConfig
the config object to use as the base. By default the module property will be set from
the base config object only
use_base_module: bool
if set true use the value of module and package from the base config object only.
"""
config = config.__dict__.copy()
self_config = self.__dict__
if use_base_module:
self_config.pop("module")
self_config.pop("package")
config.update(self_config)
self.__dict__ = config
[docs] def to_dict(self, user_config_only=True) -> dict:
"""Return config as dict
Parameters
----------
user_config_only
if True only get get waht is in the config file and not the running config.
"""
config = dict(enabled=self.enabled, bot=self.bot.to_dict())
if not user_config_only:
config["module"] = self.module
return {self.name: config}
[docs] def to_yaml(self, user_config_only=True):
return yaml.dump(self.to_dict(user_config_only))
[docs]class PluginImporter:
def __init__(self, plugins_folder: str, plugins_config_folder: str):
"""Create the PluginImporter object.
Parameters
----------
plugins_folder
plugin folder
plugins_config_folder
plugin config folder
"""
self.plugins = dict()
self.plugins_folder = plugins_folder
self.plugins_config_folder = plugins_config_folder
self.config_files = list()
self.plugin_configs = dict() # type: Dict[str, PluginConfig]
[docs] def discover_plugins(self) -> NoReturn:
"""Discover all plugin config files in the plugins folder"""
config_files = glob.glob(f"{self.plugins_folder}/**/*.yaml", recursive=True)
self.config_files = config_files
[docs] def load_discovered_plugins_config(
self, write_to_user_config: bool = True
) -> NoReturn:
"""Load all the yaml configs for the plugins"""
plugin_package_config = None
plugin_user_config = None
logging.debug("starting import of plugins")
for config_file in self.config_files:
# Read the plugin package config
plugin_name = None
with open(config_file) as file:
c = yaml.load(file, yaml.FullLoader)
if len(c.keys()) != 1:
logging.critical(
f"zero or more than one object in config file: {config_file}"
)
continue
plugin_name = list(c.keys())[0]
c[plugin_name]["config_file"] = config_file
plugin_package_config = PluginConfig(plugin_name, **c[plugin_name])
if plugin_name is None:
logging.critical(
f"invalid or missing plugin name. config file: {config_file}"
)
continue
user_config_path = Path(self.plugins_config_folder, f"{plugin_name}.yaml")
# Write defaults to user file
if not user_config_path.is_file() and write_to_user_config:
with open(user_config_path, "w") as file:
plugin_package_config.enabled = False
yaml.dump(plugin_package_config.to_dict(), file)
elif not user_config_path.is_file() and not write_to_user_config:
logging.warning(f"no user plugin config for {plugin_name}. skipping.")
continue
with open(user_config_path) as file:
c = yaml.load(file, yaml.FullLoader)
if len(c.keys()) != 1:
logging.critical(
f"zero or more than one object in config file: {config_file}"
)
continue
c[plugin_name]["config_file"] = str(user_config_path)
plugin_user_config = PluginConfig(plugin_name, **c[plugin_name])
plugin_user_config.update(plugin_package_config)
self.plugin_configs[plugin_name] = plugin_user_config
# TODO(zpriddy): Filter out warnings and errors if importing plugins in a limited way.
[docs] def import_discovered_plugins(self, bots: Dict[str, GladosBot]) -> NoReturn:
"""Import all discovered plugins and store them in self.plugins.
Parameters
----------
bots
dict of all the imported bots
Returns
-------
:obj: `NoReturn`:
the results are updated in self.plugins
"""
for plugin_name, plugin_config in self.plugin_configs.items():
if not plugin_config.enabled:
logging.warning(f"plugin {plugin_name} is disabled")
continue
plugin_config.name = plugin_name
logging.info(f"importing plugin: {plugin_name}")
module = importlib.import_module(plugin_config.package)
# Check if required bot is imported
def get_required_bot(
bot_name: str, bots: Dict[str, GladosBot]
) -> Union[None, GladosBot]:
if not bot_name:
raise GladosError(f"no bot name set for plugin: {plugin_name}")
bot = bots.get(bot_name)
if not bot:
logging.error(
f"bot: {bot_name} is not found. disabling plugin: {plugin_name}"
)
raise GladosBotNotFoundError(
f"bot: {bot_name} is not found as required for {plugin_name}"
)
return bot
try:
bot = get_required_bot(plugin_config.bot.name, bots)
except GladosError as e:
logging.error(f"{e} :: disabling plugin: {plugin_name}")
self.plugin_configs[plugin_name].enabled = False
continue
plugin = getattr(module, plugin_config.module)(plugin_config, bot)
self.plugins[plugin_name] = plugin
[docs]class GladosPlugin:
"""Parent class for a GLaDOS Plugin
Parameters
----------
config
PluginConfig object for the plugin.
bot
the GLaDOS bot that this plugin will use
"""
def __init__(self, config: PluginConfig, bot: GladosBot, **kwargs):
self.name = config.name
self._config = config
self.config = config.config
self.bot = bot
self._routes = dict() # type: Dict[int, Dict[str, GladosRoute]]
for route in RouteType._member_names_:
self._routes[
RouteType[route].value
] = dict() # type: Dict[str, GladosRoute]
[docs] def add_route(
self, route_type: RouteType, route: Union[EventRoutes, str], function: Callable
) -> NoReturn:
"""Add a new route to the plugin
Parameters
----------
route_type
what type of route this is this
route
what is the route to be added
function
the function to be executed when this route runs
"""
if type(route) is EventRoutes:
route = route.name
new_route = GladosRoute(route_type, route, function)
if route_type in BOT_ROUTES:
new_route.route = f"{self.bot.name}_{route}"
if new_route.route in self._routes[new_route.route_type.value]:
raise GladosPathExistsError(
f"a route with the name of {new_route.route} already exists in the route type: {new_route.route_type.name}"
)
self._routes[new_route.route_type.value][new_route.route] = new_route
[docs] def send_request(self, request: GladosRequest, **kwargs) -> Any:
"""This is the function to be called when sending a request to a plugin.
This function is responsible for validating the slack signature if needed. It also returns
and empty string if the function called returns None.
Parameters
----------
request
the request object to be sent
kwargs
"""
if request.route_type in VERIFY_ROUTES:
self.bot.validate_slack_signature(request)
response = self._routes[request.route_type.value][request.route].function(
request, **kwargs
)
if response is None:
# TODO(zpriddy): add logging.
return ""
if request.route_type is RouteType.Interaction and request.response_url:
if type(response) is str:
self.respond_to_url(request, response)
if type(response) in SLACK_MESSAGE_TYPES:
response = response.to_dict()
if type(response) is dict:
self.respond_to_url(request, **response)
return response
[docs] def respond_to_url(self, request: GladosRequest, text: str, **kwargs):
"""When you click on a link that was sent via slack it sends a callback, This is to handle that"""
if not request.response_url:
logging.error("no response_url provided in request.")
return
kwargs["text"] = text
r = requests.post(request.response_url, json=kwargs)
logging.info(f"slack response: {r}")
[docs] def has_route(self, route: str) -> bool:
"""See if route exists.
Parameters
----------
route : route to check
Returns
-------
True if route exists else false
"""
route_extended = f"{self.bot.name}_{route}"
for r_type, route_entry in self._routes.items():
try:
if list(route_entry.keys())[0] in [route, route_extended]:
return True
except IndexError:
continue
return False
@property
def routes(self) -> List[GladosRoute]:
"""List all routes for the plugin."""
routes = list()
[
routes.extend(route_object)
for route_object in [
list(route.values())
for route in [route_type for route_type in self._routes.values()]
]
]
return routes