2020-10-09 10:40:37 +02:00
|
|
|
import importlib
|
2020-10-09 23:37:35 +02:00
|
|
|
import json
|
2020-10-09 10:40:37 +02:00
|
|
|
import os
|
|
|
|
import time
|
|
|
|
from pathlib import Path
|
|
|
|
|
2020-10-08 18:46:45 +02:00
|
|
|
import irc.client
|
|
|
|
from irc.client import NickMask
|
|
|
|
|
|
|
|
from edmond.log import Logger
|
|
|
|
|
|
|
|
|
|
|
|
class Bot(irc.client.SimpleIRCClient, Logger):
|
|
|
|
|
2020-11-01 19:23:42 +01:00
|
|
|
CHANNELS_RUNTIME_KEY = "_channels"
|
|
|
|
|
2020-10-08 18:46:45 +02:00
|
|
|
def __init__(self, config, logger):
|
|
|
|
super().__init__()
|
|
|
|
self.config = config
|
|
|
|
self.logger = logger
|
2020-10-09 10:40:37 +02:00
|
|
|
self.plugins = []
|
2020-10-09 12:19:58 +02:00
|
|
|
self.values = {}
|
2020-11-03 17:28:40 +01:00
|
|
|
self.storage = self.__get_storage()
|
2020-10-08 18:46:45 +02:00
|
|
|
|
|
|
|
@property
|
|
|
|
def nick(self):
|
2020-11-01 19:29:51 +01:00
|
|
|
"""Nickname validated by the server, or the configured nick."""
|
2020-10-12 15:45:31 +02:00
|
|
|
if self.connection.is_connected():
|
|
|
|
return self.connection.get_nickname()
|
2020-10-08 18:46:45 +02:00
|
|
|
return self.config["nick"]
|
|
|
|
|
2020-10-09 10:40:37 +02:00
|
|
|
@property
|
|
|
|
def names(self):
|
2020-11-01 19:29:51 +01:00
|
|
|
"""Collection of names the bot should identify with."""
|
2020-10-12 15:45:31 +02:00
|
|
|
return (self.nick, *self.config["alternative_nicks"])
|
2020-10-09 10:40:37 +02:00
|
|
|
|
2020-11-01 19:23:42 +01:00
|
|
|
@property
|
|
|
|
def channels(self):
|
|
|
|
"""List of joined channels."""
|
|
|
|
if self.CHANNELS_RUNTIME_KEY not in self.values:
|
|
|
|
self.values[self.CHANNELS_RUNTIME_KEY] = []
|
|
|
|
return self.values[self.CHANNELS_RUNTIME_KEY]
|
|
|
|
|
2020-11-03 17:28:40 +01:00
|
|
|
def __get_storage(self):
|
2020-10-09 23:37:35 +02:00
|
|
|
"""Load data from storage."""
|
|
|
|
try:
|
|
|
|
with open(self.config["storage_file"], "rt") as storage_file:
|
|
|
|
storage = json.load(storage_file)
|
|
|
|
self.log_d("Loaded storage file.")
|
|
|
|
return storage
|
|
|
|
except (OSError, json.decoder.JSONDecodeError) as exc:
|
|
|
|
self.log_e(f"Could not load storage file: {exc}")
|
|
|
|
self.log_w("If it's not the first time Edm0nd is run, you may lose"
|
|
|
|
" data when closing the program.")
|
|
|
|
return {}
|
|
|
|
|
2020-11-03 17:28:40 +01:00
|
|
|
def __save_storage(self):
|
2020-10-09 23:37:35 +02:00
|
|
|
"""Save storage data to disk."""
|
|
|
|
try:
|
|
|
|
with open(self.config["storage_file"], "wt") as storage_file:
|
|
|
|
json.dump(self.storage, storage_file, indent=2, sort_keys=True)
|
|
|
|
self.log_d("Saved storage file.")
|
|
|
|
except (OSError, json.decoder.JSONEncodeError) as exc:
|
|
|
|
self.log_e(f"Could not save storage file: {exc}")
|
|
|
|
|
2020-10-08 18:46:45 +02:00
|
|
|
def on_welcome(self, connection, event):
|
2020-11-01 19:29:51 +01:00
|
|
|
"""Handle a successful connection to a server."""
|
2020-10-08 18:46:45 +02:00
|
|
|
self.log_i(f"Connected to server {event.source}.")
|
2020-10-09 12:19:58 +02:00
|
|
|
self.run_plugin_callbacks(event)
|
2020-10-08 18:46:45 +02:00
|
|
|
for channel in self.config["channels"]:
|
|
|
|
connection.join(channel)
|
|
|
|
|
|
|
|
def on_join(self, connection, event):
|
2020-11-01 19:29:51 +01:00
|
|
|
"""Handle someone, possibly the bot, joining a channel."""
|
2020-10-12 15:45:31 +02:00
|
|
|
if event.source.nick == self.nick:
|
|
|
|
self.log_i(f"Joined {event.target}.")
|
2020-11-01 19:23:42 +01:00
|
|
|
self.channels.append(event.target)
|
2020-10-09 10:40:37 +02:00
|
|
|
self.run_plugin_callbacks(event)
|
2020-10-08 18:46:45 +02:00
|
|
|
|
|
|
|
def on_part(self, connection, event):
|
2020-11-01 19:29:51 +01:00
|
|
|
"""Handle someone, possibly the bot, leaving a channel."""
|
2020-10-12 15:45:31 +02:00
|
|
|
if event.source.nick == self.nick:
|
|
|
|
self.log_i(f"Left {event.target} (args: {event.arguments[0]}).")
|
2020-11-01 19:23:42 +01:00
|
|
|
self.channels.remove(event.target)
|
2020-10-09 10:40:37 +02:00
|
|
|
self.run_plugin_callbacks(event)
|
2020-10-08 18:46:45 +02:00
|
|
|
|
|
|
|
def on_pubmsg(self, connection, event):
|
2020-11-01 19:29:51 +01:00
|
|
|
"""Handle a message received in a channel."""
|
2020-10-08 18:46:45 +02:00
|
|
|
channel = event.target
|
|
|
|
nick = NickMask(event.source).nick
|
|
|
|
message = event.arguments[0]
|
|
|
|
self.log_d(f"Message in {channel} from {nick}: {message}")
|
2020-10-09 10:40:37 +02:00
|
|
|
self.run_plugin_callbacks(event)
|
2020-10-08 18:46:45 +02:00
|
|
|
|
|
|
|
def on_privmsg(self, connection, event):
|
2020-11-01 19:29:51 +01:00
|
|
|
"""Handle a message received privately."""
|
2020-10-08 18:46:45 +02:00
|
|
|
nick = NickMask(event.source).nick
|
|
|
|
target = event.target
|
|
|
|
message = event.arguments[0]
|
|
|
|
self.log_d(f"Private message from {nick} to {target}: {message}")
|
2020-10-09 10:40:37 +02:00
|
|
|
self.run_plugin_callbacks(event)
|
2020-10-08 18:46:45 +02:00
|
|
|
|
2020-11-01 19:27:12 +01:00
|
|
|
def on_ping(self, connection, event):
|
|
|
|
"""Handle a ping; can be used as a random event timer."""
|
|
|
|
self.log_d(f"Received ping from {event.target}.")
|
|
|
|
self.run_plugin_callbacks(event)
|
|
|
|
|
2020-10-08 18:46:45 +02:00
|
|
|
def run(self):
|
2020-10-09 10:40:37 +02:00
|
|
|
"""Connect the bot to server, join channels and start responding."""
|
2020-10-08 18:46:45 +02:00
|
|
|
self.log_i("Starting Edmond.")
|
2020-10-09 10:40:37 +02:00
|
|
|
self.load_plugins()
|
2020-11-06 12:24:16 +01:00
|
|
|
self.log_i("Connecting to server.")
|
2020-10-08 18:46:45 +02:00
|
|
|
self.connect(self.config["host"], self.config["port"], self.nick)
|
|
|
|
try:
|
|
|
|
self.start()
|
|
|
|
except KeyboardInterrupt:
|
2021-06-11 11:28:22 +02:00
|
|
|
self.log_i("Caught keyboard interrupt.")
|
|
|
|
finally:
|
2020-10-08 18:46:45 +02:00
|
|
|
self.log_i("Stopping Edmond.")
|
2020-11-03 17:28:40 +01:00
|
|
|
self.__save_storage()
|
2020-10-09 10:40:37 +02:00
|
|
|
|
|
|
|
def load_plugins(self):
|
|
|
|
"""Load all installed plugins."""
|
2020-11-06 12:24:16 +01:00
|
|
|
self.log_i("Loading plugins.")
|
2020-10-09 10:40:37 +02:00
|
|
|
plugin_files = os.listdir(Path(__file__).parent / "plugins")
|
|
|
|
plugin_names = map(
|
|
|
|
lambda f: os.path.splitext(f)[0],
|
|
|
|
filter(lambda f: f.endswith(".py"), plugin_files)
|
|
|
|
)
|
|
|
|
for plugin_name in plugin_names:
|
|
|
|
module = importlib.import_module(f"edmond.plugins.{plugin_name}")
|
2020-10-09 13:30:28 +02:00
|
|
|
are_dependencies_ok = getattr(module, "DEPENDENCIES_FOUND", True)
|
|
|
|
if not are_dependencies_ok:
|
|
|
|
self.log_e(f"Dependencies not found for plugin {plugin_name}.")
|
|
|
|
continue
|
2020-10-09 22:35:04 +02:00
|
|
|
# Get plugin class name from its module name.
|
|
|
|
class_name = "".join(map(
|
|
|
|
lambda w: w.capitalize(),
|
|
|
|
plugin_name.split("_")
|
|
|
|
)) + "Plugin"
|
2020-10-09 10:40:37 +02:00
|
|
|
plugin_class = getattr(module, class_name)
|
|
|
|
self.plugins.append(plugin_class(self))
|
2020-10-09 12:19:58 +02:00
|
|
|
self.values[plugin_name] = {}
|
2020-10-09 10:40:37 +02:00
|
|
|
self.log_d(f"Loaded {class_name}.")
|
|
|
|
|
2020-11-03 17:28:26 +01:00
|
|
|
def get_plugin(self, name):
|
|
|
|
"""Get a loaded plugin by its name (e.g. 'mood'), or None."""
|
|
|
|
return next(filter(lambda p: p.name == name, self.plugins), None)
|
|
|
|
|
2020-10-09 10:40:37 +02:00
|
|
|
def say(self, target, message):
|
|
|
|
"""Send message to target after a slight delay."""
|
2021-12-02 11:35:02 +01:00
|
|
|
message = message.replace("\n", " ").replace("\r", " ")
|
2020-10-09 10:40:37 +02:00
|
|
|
time.sleep(self.config["speak_delay"])
|
|
|
|
self.log_d(f"Sending to {target}: {message}")
|
2021-09-13 09:04:18 +02:00
|
|
|
try:
|
|
|
|
if message.startswith("/me "):
|
|
|
|
self.connection.action(target, message[4:])
|
|
|
|
else:
|
|
|
|
self.connection.privmsg(target, message)
|
|
|
|
except irc.client.MessageTooLong:
|
|
|
|
self.log_e("Could not send, message is too long.")
|
2020-10-09 10:40:37 +02:00
|
|
|
|
|
|
|
def run_plugin_callbacks(self, event):
|
2020-10-09 23:37:35 +02:00
|
|
|
"""Run appropriate callbacks for each plugin."""
|
2020-10-09 10:40:37 +02:00
|
|
|
etype = event.type
|
2020-11-03 16:22:57 +01:00
|
|
|
plugins = filter(lambda p: p.is_ready, self.plugins)
|
|
|
|
plugins = sorted(plugins, key=lambda p: p.priority, reverse=True)
|
|
|
|
for plugin in plugins:
|
2020-10-09 10:40:37 +02:00
|
|
|
callbacks = plugin.callbacks
|
|
|
|
if etype not in callbacks:
|
|
|
|
continue
|
2020-11-02 16:44:35 +01:00
|
|
|
if callbacks[etype](event):
|
|
|
|
break
|