|
|
|
@ -1,6 +1,8 @@
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from irc.client import NickMask
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Plugin:
|
|
|
|
|
"""Base class for the bot plugins.
|
|
|
|
@ -17,15 +19,15 @@ class Plugin:
|
|
|
|
|
available after a restart.
|
|
|
|
|
|
|
|
|
|
Initalisation should be very fast, no network connections or anything. They
|
|
|
|
|
are initialised before connecting to the server, so their `is_ready` flag is
|
|
|
|
|
set at that point. The loading order is more or less random, so a plugin
|
|
|
|
|
are initialised before connecting to the server, so their `is_ready` flag
|
|
|
|
|
is set at that point. The loading order is more or less random, so a plugin
|
|
|
|
|
cannot assume another has been loaded during initialisation. If it wants to
|
|
|
|
|
interact with another plugin, the earliest point to do that is in the
|
|
|
|
|
on_welcome callback which is called after connecting to a server, and can
|
|
|
|
|
disable itself by setting its own `is_ready` flag to false.
|
|
|
|
|
|
|
|
|
|
A plugin can access its config once the base `__init__` has been called. The
|
|
|
|
|
configuration is valid only is `is_ready` is True, else accessing its
|
|
|
|
|
A plugin can access its config once the base `__init__` has been called.
|
|
|
|
|
The configuration is valid only is `is_ready` is True, else accessing its
|
|
|
|
|
content is undefined behaviour.
|
|
|
|
|
|
|
|
|
|
Plugins can have priorities and calling their callbacks will respect it.
|
|
|
|
@ -57,19 +59,19 @@ class Plugin:
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def __get_config(self):
|
|
|
|
|
"""Return the plugin section from the bot config, plus common values."""
|
|
|
|
|
"""Return the plugin section from the bot config plus common values."""
|
|
|
|
|
plugins_configs = self.bot.config["plugins"]
|
|
|
|
|
config = plugins_configs["common"].copy()
|
|
|
|
|
config.update(plugins_configs.get(self.name, {}))
|
|
|
|
|
# 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
|
|
|
|
|
res_dir = self.bot.config["resources_dir"]
|
|
|
|
|
for key, res_path in config.get("resources", {}).items():
|
|
|
|
|
res_path = Path(res_dir) / res_path
|
|
|
|
|
try:
|
|
|
|
|
with open(resource_path, "rt") as resource_file:
|
|
|
|
|
config[key] = [l.strip() for l in resource_file.readlines()]
|
|
|
|
|
with open(res_path, "rt") as res_file:
|
|
|
|
|
config[key] = [l.strip() for l in res_file.readlines()]
|
|
|
|
|
except OSError:
|
|
|
|
|
self.bot.log_e(f"Could not load resource at {resource_path}.")
|
|
|
|
|
self.bot.log_e(f"Could not load resource at {res_path}.")
|
|
|
|
|
return config
|
|
|
|
|
|
|
|
|
|
def __check_config(self):
|
|
|
|
@ -77,15 +79,15 @@ class Plugin:
|
|
|
|
|
missing = False
|
|
|
|
|
for key in self.REQUIRED_CONFIGS:
|
|
|
|
|
if key not in self.config:
|
|
|
|
|
self.bot.log_e(f"Missing '{key}' in {self.name} configuration.")
|
|
|
|
|
self.bot.log_e(f"Missing '{key}' in {self.name} config.")
|
|
|
|
|
missing = True
|
|
|
|
|
return not missing
|
|
|
|
|
|
|
|
|
|
def get_runtime_value(self, key, default=None, ns=None):
|
|
|
|
|
"""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`.
|
|
|
|
|
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`.
|
|
|
|
|
"""
|
|
|
|
|
if ns is None:
|
|
|
|
|
ns = self.name
|
|
|
|
@ -98,8 +100,8 @@ class Plugin:
|
|
|
|
|
def get_storage_value(self, key, default=None, ns=None):
|
|
|
|
|
"""Get a value from the plugin persistent storage.
|
|
|
|
|
|
|
|
|
|
This will get the value from the plugin namespace, but it is possible to
|
|
|
|
|
get storage values from other plugins using their name as `ns`.
|
|
|
|
|
This will get the value from the plugin namespace, but it is possible
|
|
|
|
|
to get storage values from other plugins using their name as `ns`.
|
|
|
|
|
"""
|
|
|
|
|
if ns is None:
|
|
|
|
|
ns = self.name
|
|
|
|
@ -124,7 +126,10 @@ class Plugin:
|
|
|
|
|
|
|
|
|
|
def remove_storage_list_value(self, key, value):
|
|
|
|
|
"""Remove a value from a persistent storage list."""
|
|
|
|
|
if self.name in self.bot.storage and key in self.bot.storage[self.name]:
|
|
|
|
|
if (
|
|
|
|
|
self.name in self.bot.storage
|
|
|
|
|
and key in self.bot.storage[self.name]
|
|
|
|
|
):
|
|
|
|
|
self.bot.storage[self.name][key].remove(value)
|
|
|
|
|
|
|
|
|
|
def should_read_message(self, message):
|
|
|
|
@ -136,8 +141,8 @@ class Plugin:
|
|
|
|
|
|
|
|
|
|
Note that you do not need to use this method for command/question
|
|
|
|
|
plugins, the next methods already check for this and do more precise
|
|
|
|
|
parsing of the message. This is rather for plugins reacting to a message
|
|
|
|
|
addressed to the bot that are neither commands or questions.
|
|
|
|
|
parsing of the message. This is rather for plugins reacting to a
|
|
|
|
|
message addressed to the bot that are neither commands or questions.
|
|
|
|
|
"""
|
|
|
|
|
first_word_and_rest = message.split(maxsplit=1)
|
|
|
|
|
num_parts = len(first_word_and_rest)
|
|
|
|
@ -158,11 +163,11 @@ class Plugin:
|
|
|
|
|
def should_answer_question(self, message):
|
|
|
|
|
"""Store Question in object and return True if I should answer it.
|
|
|
|
|
|
|
|
|
|
To answer a question, the message must start with one of the bot's names
|
|
|
|
|
and optionally end with one or more question marks (they are discarded).
|
|
|
|
|
The bot checks that answering conditions are respected, and they cannot
|
|
|
|
|
be bypassed as with commands. A Question created is checked from the
|
|
|
|
|
plugin's registered questions and stored in the instance.
|
|
|
|
|
To answer a question, the message must start with one of the bot's
|
|
|
|
|
names and optionally end with one or more question marks (they are
|
|
|
|
|
discarded). The bot checks that answering conditions are respected, and
|
|
|
|
|
they cannot be bypassed as with commands. A Question created is checked
|
|
|
|
|
from the plugin's registered questions and stored in the instance.
|
|
|
|
|
"""
|
|
|
|
|
words = message.split()
|
|
|
|
|
# Is the message addressed to me?
|
|
|
|
@ -200,9 +205,9 @@ class Plugin:
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
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
|
|
|
|
@ -250,7 +255,7 @@ class Plugin:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def __parse_command(self, message, no_content=False):
|
|
|
|
|
"""Return a parsed Command if this message is a command, None otherwise.
|
|
|
|
|
"""Return a parsed Command if this message is a command, else None.
|
|
|
|
|
|
|
|
|
|
The command raw field is always set. The ident and content fields are
|
|
|
|
|
empty when no_content is True. The match field is never set by this
|
|
|
|
@ -273,7 +278,7 @@ class Plugin:
|
|
|
|
|
def __save_command(self, command):
|
|
|
|
|
"""Save command in instance for further processing by the plugin."""
|
|
|
|
|
self.command = command
|
|
|
|
|
self.bot.log_i(f"Processing command from plugin {self.name}: {command}")
|
|
|
|
|
self.bot.log_i(f"Processing command from p. {self.name}: {command}")
|
|
|
|
|
|
|
|
|
|
def respects_handling_conditions(self, exclude_conditions=None):
|
|
|
|
|
"""Check if handling conditions are valid.
|
|
|
|
@ -297,6 +302,19 @@ class Plugin:
|
|
|
|
|
"""Signal a plugin failure to target."""
|
|
|
|
|
self.bot.say(target, self.bot.config["error_message"])
|
|
|
|
|
|
|
|
|
|
def on_privmsg(self, event):
|
|
|
|
|
"""Default behaviour on privmsg is to answer like a pubmsg.
|
|
|
|
|
|
|
|
|
|
Because most plugins assume messages are sent publicly, they send their
|
|
|
|
|
replies to `event.target` so we replace it with the event source nick.
|
|
|
|
|
If it causes an issue with a plugin requiring the original target even
|
|
|
|
|
on private message, override this method.
|
|
|
|
|
"""
|
|
|
|
|
if (on_pubmsg := getattr(self, "on_pubmsg", None)):
|
|
|
|
|
event.target = NickMask(event.source).nick
|
|
|
|
|
return on_pubmsg(event)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class Question:
|
|
|
|
|