2020-10-09 10:40:37 +02:00
|
|
|
from dataclasses import dataclass
|
2020-10-09 16:03:09 +02:00
|
|
|
from pathlib import Path
|
2020-10-09 10:40:37 +02:00
|
|
|
|
|
|
|
|
|
|
|
class Plugin:
|
|
|
|
|
2020-10-09 12:49:35 +02:00
|
|
|
REQUIRED_CONFIGS = []
|
|
|
|
|
2020-10-09 10:40:37 +02:00
|
|
|
def __init__(self, bot):
|
|
|
|
self.bot = bot
|
2020-10-09 12:19:58 +02:00
|
|
|
self.name = self.__class__.__name__.lower()[:-6] # Remove "Plugin".
|
2020-11-02 12:51:26 +01:00
|
|
|
self.config = self.__get_config()
|
|
|
|
self.is_ready = self.__check_config()
|
2020-10-09 10:40:37 +02:00
|
|
|
|
|
|
|
@property
|
|
|
|
def callbacks(self):
|
|
|
|
"""List of callback types available for this plugin."""
|
|
|
|
return {
|
|
|
|
cb[3:]: getattr(self, cb)
|
|
|
|
for cb in dir(self)
|
|
|
|
if cb.startswith("on_") and callable(getattr(self, cb))
|
|
|
|
}
|
|
|
|
|
2020-11-02 12:51:26 +01:00
|
|
|
def __get_config(self):
|
2020-10-09 12:49:35 +02:00
|
|
|
"""Return the plugin section from the bot config, plus common values."""
|
2020-10-09 10:40:37 +02:00
|
|
|
plugins_configs = self.bot.config["plugins"]
|
2020-10-09 13:30:52 +02:00
|
|
|
config = plugins_configs["common"].copy()
|
2020-10-09 12:49:35 +02:00
|
|
|
config.update(plugins_configs.get(self.name, {}))
|
2020-10-09 16:03:09 +02:00
|
|
|
# Load resources as config values.
|
|
|
|
resources_dir = self.bot.config["resources_dir"]
|
|
|
|
for key, resource_path in config.get("resources", {}).items():
|
|
|
|
resource_path = Path(resources_dir) / resource_path
|
2020-10-09 21:59:21 +02:00
|
|
|
try:
|
|
|
|
with open(resource_path, "rt") as resource_file:
|
|
|
|
config[key] = [l.strip() for l in resource_file.readlines()]
|
|
|
|
except OSError:
|
|
|
|
self.bot.log_e(f"Could not load resource at {resource_path}.")
|
2020-10-09 12:49:35 +02:00
|
|
|
return config
|
|
|
|
|
2020-11-02 12:51:26 +01:00
|
|
|
def __check_config(self):
|
2020-10-09 12:49:35 +02:00
|
|
|
"""Return True if the plugin config is properly setup."""
|
|
|
|
missing = False
|
|
|
|
for key in self.REQUIRED_CONFIGS:
|
|
|
|
if key not in self.config:
|
2020-10-09 21:59:21 +02:00
|
|
|
self.bot.log_e(f"Missing '{key}' in {self.name} configuration.")
|
2020-10-09 12:49:35 +02:00
|
|
|
missing = True
|
2020-10-09 13:30:52 +02:00
|
|
|
return not missing
|
2020-10-09 12:19:58 +02:00
|
|
|
|
2020-10-12 15:45:31 +02:00
|
|
|
def get_runtime_value(self, key, default=None, ns=None):
|
2020-10-09 23:37:35 +02:00
|
|
|
"""Get a value from the plugin runtime dict.
|
|
|
|
|
|
|
|
This will get the value from the plugin namespace, but it is possible to
|
|
|
|
get runtime values from other plugins using their name as `ns`.
|
|
|
|
"""
|
2020-10-09 12:49:35 +02:00
|
|
|
if ns is None:
|
|
|
|
ns = self.name
|
2020-10-12 15:45:31 +02:00
|
|
|
return self.bot.values[ns].get(key, default)
|
2020-10-09 12:19:58 +02:00
|
|
|
|
|
|
|
def set_runtime_value(self, key, value):
|
|
|
|
"""Set a value in the plugin runtime dict."""
|
|
|
|
self.bot.values[self.name][key] = value
|
|
|
|
|
2020-10-12 15:45:31 +02:00
|
|
|
def get_storage_value(self, key, default=None):
|
2020-10-09 23:37:35 +02:00
|
|
|
"""Get a value from the plugin persistent storage."""
|
2020-10-12 15:45:31 +02:00
|
|
|
return self.bot.storage.get(self.name, {}).get(key, default)
|
2020-10-09 23:37:35 +02:00
|
|
|
|
|
|
|
def set_storage_value(self, key, value):
|
|
|
|
"""Set a value in the plugin persistent storage."""
|
|
|
|
if self.name not in self.bot.storage:
|
|
|
|
self.bot.storage[self.name] = {key: value}
|
|
|
|
else:
|
|
|
|
self.bot.storage[self.name][key] = value
|
|
|
|
|
|
|
|
def append_storage_list_value(self, key, value):
|
|
|
|
"""Append a value to a list in the plugin persistent storage."""
|
|
|
|
if self.name not in self.bot.storage:
|
|
|
|
self.bot.storage[self.name] = {key: [value]}
|
|
|
|
elif key not in self.bot.storage[self.name]:
|
|
|
|
self.bot.storage[self.name][key] = [value]
|
|
|
|
else:
|
|
|
|
self.bot.storage[self.name][key].append(value)
|
|
|
|
|
2020-10-12 15:45:31 +02:00
|
|
|
def remove_storage_list_value(self, key, value):
|
2020-11-01 19:29:51 +01:00
|
|
|
"""Remove a value from a persistent storage list."""
|
2020-10-12 15:45:31 +02:00
|
|
|
if self.name in self.bot.storage and key in self.bot.storage[self.name]:
|
|
|
|
self.bot.storage[self.name][key].remove(value)
|
|
|
|
|
2020-10-09 12:19:58 +02:00
|
|
|
def should_answer_question(self, message):
|
|
|
|
"""Store Question in object and return True if it should answer it."""
|
|
|
|
words = message.split()
|
2020-11-02 12:51:26 +01:00
|
|
|
# Is the message addressed to me?
|
2020-10-09 12:19:58 +02:00
|
|
|
if words[0].lower() not in self.bot.names:
|
|
|
|
return False
|
|
|
|
|
2020-11-02 12:51:26 +01:00
|
|
|
# Are the config conditions to answer a question valid?
|
|
|
|
if not self.__respects_handling_conditions():
|
|
|
|
return False
|
|
|
|
|
|
|
|
# Is it a question I can answer?
|
|
|
|
question = message[len(words[0]):].strip()
|
2020-10-09 12:49:35 +02:00
|
|
|
for q in self.config.get("questions", []):
|
2020-10-09 12:19:58 +02:00
|
|
|
if question.startswith(q):
|
|
|
|
self.question = Question(q, question[len(q):].strip())
|
2020-10-09 13:30:52 +02:00
|
|
|
self.bot.log_d(f"Answering question from plugin {self.name}.")
|
2020-10-09 12:19:58 +02:00
|
|
|
return True
|
|
|
|
return False
|
2020-10-09 10:40:37 +02:00
|
|
|
|
2020-11-02 13:23:19 +01:00
|
|
|
def should_handle_command(
|
|
|
|
self,
|
|
|
|
message,
|
|
|
|
no_content=False,
|
|
|
|
exclude_conditions=None,
|
|
|
|
):
|
|
|
|
"""Store Command in object and return True if it should handle it.
|
|
|
|
|
|
|
|
If no_content is True, the command does not parse command contents and
|
|
|
|
put all the command message (without suffix) to the command identifier.
|
|
|
|
This is useful for commands that have multiple words in their identifier
|
|
|
|
and do not have any content, or when the content parsing should be done
|
|
|
|
by the plugin itself.
|
|
|
|
|
|
|
|
If exclude_conditions is a collection of plugin namespaces, the
|
|
|
|
conditions of these plugins are not tested. A basic scenario is the
|
|
|
|
wakeup command from sleep plugin that requires excluding its own
|
|
|
|
condition (i.e. do not handle commands when sleeping).
|
|
|
|
"""
|
2020-11-02 12:51:26 +01:00
|
|
|
# Are the config conditions to handle a command valid?
|
2020-11-02 13:23:19 +01:00
|
|
|
if not self.__respects_handling_conditions(exclude_conditions):
|
2020-11-02 12:51:26 +01:00
|
|
|
return False
|
|
|
|
|
|
|
|
# Is it a valid command?
|
2020-11-02 17:41:47 +01:00
|
|
|
parsed_command = self.__parse_command(message, no_content=no_content)
|
|
|
|
if not parsed_command:
|
2020-10-31 18:12:08 +01:00
|
|
|
return False
|
2020-11-02 12:51:26 +01:00
|
|
|
|
|
|
|
# Is it a command I can handle?
|
2020-11-02 17:41:47 +01:00
|
|
|
available_commands = self.config.get("commands", [])
|
|
|
|
aliases = self.config.get("aliases", {})
|
|
|
|
for ident in available_commands:
|
|
|
|
# Match commands differently according to no_content. If no_content
|
|
|
|
# is True, check the parsed command (pc) raw data as a string that
|
|
|
|
# may contain the available identifier (ai) at its beginning.
|
|
|
|
# If no_content is False (default), simply compare the parsed
|
|
|
|
# identifier with available identifiers.
|
|
|
|
if no_content:
|
|
|
|
match = lambda pc, ai: (
|
|
|
|
pc.raw == ai or pc.raw.startswith(ai + " ")
|
|
|
|
)
|
|
|
|
else:
|
|
|
|
match = lambda pc, ai: pc.ident == ai
|
|
|
|
# First case: the command identifier has been used.
|
|
|
|
if match(parsed_command, ident):
|
|
|
|
parsed_command.ident = ident
|
|
|
|
parsed_command.match = ident
|
|
|
|
self.__save_command(parsed_command)
|
|
|
|
return True
|
|
|
|
# Second case: an alias of the identifier has been used.
|
|
|
|
ident_aliases = aliases.get(ident, [])
|
|
|
|
for alias in ident_aliases:
|
|
|
|
if match(parsed_command, alias):
|
|
|
|
parsed_command.ident = ident
|
|
|
|
parsed_command.match = alias
|
|
|
|
self.__save_command(parsed_command)
|
|
|
|
return True
|
2020-10-09 10:40:37 +02:00
|
|
|
return False
|
|
|
|
|
2020-11-02 17:41:47 +01:00
|
|
|
def __parse_command(self, message, no_content=False):
|
|
|
|
"""Return a command ID if this message is a command.
|
|
|
|
|
|
|
|
The command raw field is always set. The ident and content fields are
|
|
|
|
not set when no_content is True. The match field is never set by this
|
|
|
|
method.
|
|
|
|
"""
|
2020-10-09 10:40:37 +02:00
|
|
|
words = message.split()
|
2020-10-09 12:49:35 +02:00
|
|
|
command_suffix = self.config["command_suffix"]
|
|
|
|
if words[0].lower() in self.bot.names and words[-1] == command_suffix:
|
2020-11-02 17:41:47 +01:00
|
|
|
raw = " ".join(words[1:-1])
|
2020-10-09 22:32:36 +02:00
|
|
|
if no_content:
|
2020-11-02 17:41:47 +01:00
|
|
|
ident = ""
|
2020-10-09 22:32:36 +02:00
|
|
|
content = ""
|
|
|
|
else:
|
|
|
|
ident = words[1]
|
|
|
|
content = " ".join(words[2:-1])
|
2020-11-02 17:41:47 +01:00
|
|
|
return Command(ident, content, raw)
|
2020-10-09 10:40:37 +02:00
|
|
|
|
2020-11-02 13:23:19 +01:00
|
|
|
def __respects_handling_conditions(self, exclude_conditions=None):
|
2020-11-02 12:51:26 +01:00
|
|
|
"""Check if question conditions are valid."""
|
|
|
|
conditions = self.config.get("handling_conditions", {})
|
|
|
|
for plugin_ns, plugin_conditions in conditions.items():
|
2020-11-02 13:23:19 +01:00
|
|
|
if exclude_conditions and plugin_ns in exclude_conditions:
|
|
|
|
continue
|
2020-11-02 12:51:26 +01:00
|
|
|
for condition_key, condition_value in plugin_conditions.items():
|
|
|
|
value = self.get_runtime_value(condition_key, ns=plugin_ns)
|
|
|
|
if condition_value != value:
|
|
|
|
self.bot.log_d(
|
|
|
|
f"Handling condition {plugin_ns}.{condition_key} false."
|
|
|
|
)
|
|
|
|
return False
|
|
|
|
return True
|
|
|
|
|
2020-11-02 17:41:47 +01:00
|
|
|
def __save_command(self, command):
|
|
|
|
"""Save command in instance for further processing by the plugin."""
|
|
|
|
self.command = command
|
|
|
|
self.bot.log_d(f"Processing command from plugin {self.name}: {command}")
|
|
|
|
|
2020-10-09 22:34:47 +02:00
|
|
|
def signal_failure(self, target):
|
|
|
|
"""Signal a plugin failure to target."""
|
|
|
|
self.bot.say(target, self.bot.config["error_message"])
|
|
|
|
|
2020-10-09 10:40:37 +02:00
|
|
|
|
2020-10-09 12:19:58 +02:00
|
|
|
@dataclass
|
|
|
|
class Question:
|
|
|
|
preambule: str
|
|
|
|
content: str
|
|
|
|
|
|
|
|
|
2020-10-09 10:40:37 +02:00
|
|
|
@dataclass
|
|
|
|
class Command:
|
2020-11-02 17:41:47 +01:00
|
|
|
# Identifier as set in config. Set even when an alias has been used.
|
2020-10-09 10:40:37 +02:00
|
|
|
ident: str
|
2020-11-02 17:41:47 +01:00
|
|
|
# Content of the command when it has been parsed, empty str otherwise.
|
2020-10-09 10:40:37 +02:00
|
|
|
content: str
|
2020-11-02 17:41:47 +01:00
|
|
|
# Raw command content (minus name and suffix), always set.
|
|
|
|
raw: str
|
|
|
|
# Identifier matched, possibly an alias. Set only when matched.
|
|
|
|
match: str = ""
|