aboutsummaryrefslogtreecommitdiff
path: root/src/core_modules
diff options
context:
space:
mode:
Diffstat (limited to 'src/core_modules')
-rw-r--r--src/core_modules/admin.py208
-rw-r--r--src/core_modules/channel_access.py91
-rw-r--r--src/core_modules/channel_blacklist.py40
-rw-r--r--src/core_modules/channel_keys.py55
-rw-r--r--src/core_modules/check_mode.py43
-rw-r--r--src/core_modules/commands/__init__.py424
-rw-r--r--src/core_modules/commands/outs.py28
-rw-r--r--src/core_modules/config.py244
-rw-r--r--src/core_modules/ctcp.py29
-rw-r--r--src/core_modules/deferred_read.py23
-rw-r--r--src/core_modules/fake_echo.py13
-rw-r--r--src/core_modules/format_activity.py285
-rw-r--r--src/core_modules/help.py124
-rw-r--r--src/core_modules/ignore.py163
-rw-r--r--src/core_modules/ircv3_chathistory.py36
-rw-r--r--src/core_modules/ircv3_echo_message.py21
-rw-r--r--src/core_modules/ircv3_labeled_responses.py67
-rw-r--r--src/core_modules/ircv3_message_tracking.py17
-rw-r--r--src/core_modules/ircv3_metadata.py16
-rw-r--r--src/core_modules/ircv3_msgid.py31
-rw-r--r--src/core_modules/ircv3_sasl/README.md46
-rw-r--r--src/core_modules/ircv3_sasl/__init__.py195
-rw-r--r--src/core_modules/ircv3_sasl/scram.py130
-rw-r--r--src/core_modules/ircv3_server_time.py12
-rw-r--r--src/core_modules/ircv3_sts.py70
-rw-r--r--src/core_modules/line_handler/__init__.py260
-rw-r--r--src/core_modules/line_handler/channel.py160
-rw-r--r--src/core_modules/line_handler/core.py154
-rw-r--r--src/core_modules/line_handler/ircv3.py138
-rw-r--r--src/core_modules/line_handler/message.py109
-rw-r--r--src/core_modules/line_handler/user.py102
-rw-r--r--src/core_modules/modules.py122
-rw-r--r--src/core_modules/more.py23
-rw-r--r--src/core_modules/nick_regain.py48
-rw-r--r--src/core_modules/perform.py76
-rw-r--r--src/core_modules/permissions/__init__.py357
-rw-r--r--src/core_modules/print_activity.py48
-rw-r--r--src/core_modules/proxy.py38
-rw-r--r--src/core_modules/signals.py65
-rw-r--r--src/core_modules/silence.py70
-rw-r--r--src/core_modules/strip_color.py22
-rw-r--r--src/core_modules/strip_otr.py15
-rw-r--r--src/core_modules/throttle.py17
43 files changed, 4235 insertions, 0 deletions
diff --git a/src/core_modules/admin.py b/src/core_modules/admin.py
new file mode 100644
index 00000000..364380d4
--- /dev/null
+++ b/src/core_modules/admin.py
@@ -0,0 +1,208 @@
+#--depends-on commands
+#--depends-on permissions
+
+from src import IRCLine, ModuleManager, utils
+
+class Module(ModuleManager.BaseModule):
+ @utils.hook("received.command.nick", min_args=1)
+ def change_nickname(self, event):
+ """
+ :help: Change my nickname
+ :usage: <nickname>
+ :permission: changenickname
+ """
+ nickname = event["args_split"][0]
+ event["server"].send_nick(nickname)
+
+ @utils.hook("received.command.raw", min_args=1)
+ def raw(self, event):
+ """
+ :help: Send a line of raw IRC data
+ :usage: <raw line>
+ :permission: raw
+ """
+ if IRCLine.is_human(event["args"]):
+ line = IRCLine.parse_human(event["args"])
+ else:
+ line = IRCLine.parse_line(event["args"])
+ line = event["server"].send(line)
+
+ if not line == None:
+ event["stdout"].write("Sent: %s" % line.parsed_line.format())
+ else:
+ event["stderr"].write("Line was filtered")
+
+ @utils.hook("received.command.part")
+ def part(self, event):
+ """
+ :help: Part from the current or given channel
+ :usage: [channel]
+ :permission: part
+ """
+ if event["args"]:
+ target = event["args_split"][0]
+ elif event["is_channel"]:
+ target = event["target"].name
+ else:
+ event["stderr"].write("No channel provided")
+ event["server"].send_part(target)
+
+ def _id_from_alias(self, alias):
+ return self.bot.database.servers.get_by_alias(alias)
+ def _server_from_alias(self, alias):
+ id, server = self._both_from_alias(alias)
+ return server
+ def _both_from_alias(self, alias):
+ id = self._id_from_alias(alias)
+ if id == None:
+ raise utils.EventError("Unknown server alias")
+ return id, self.bot.get_server_by_id(id)
+
+ @utils.hook("received.command.reconnect")
+ def reconnect(self, event):
+ """
+ :help: Reconnect to the current network
+ :permission: reconnect
+ """
+ server = event["server"]
+ alias = str(event["server"])
+ if event["args"]:
+ alias = event["args_split"][0]
+ server = self._server_from_alias(alias)
+
+ if server:
+ line = server.send_quit("Reconnecting")
+ line.events.on("send").hook(lambda e: self.bot.reconnect(
+ server.id, server.connection_params))
+ if not server == event["server"]:
+ event["stdout"].write("Reconnecting to %s" % alias)
+ else:
+ event["stdout"].write("Not connected to %s" % alias)
+
+ @utils.hook("received.command.connect", min_args=1)
+ def connect(self, event):
+ """
+ :help: Connect to a network
+ :usage: <server id>
+ :permission: connect
+ """
+ alias = event["args_split"][0]
+ server = self._server_from_alias(alias)
+ if server:
+ raise utils.EventError("Already connected to %s" % str(server))
+
+ server = self.bot.add_server(self._id_from_alias(alias))
+ event["stdout"].write("Connecting to %s" % str(server))
+
+ @utils.hook("received.command.disconnect")
+ def disconnect(self, event):
+ """
+ :help: Disconnect from a server
+ :usage: [server id]
+ :permission: disconnect
+ """
+ server = event["server"]
+ id = -1
+ alias = str(event["server"])
+ if event["args"]:
+ alias = event["args_split"][0]
+ id, server = self._both_from_alias(alias)
+
+ if not server == None:
+ alias = str(server)
+ server.disconnect()
+ self.bot.disconnect(server)
+ elif id in self.bot.reconnections:
+ self.bot.reconnections[id].cancel()
+ del self.bot.reconnections[id]
+ else:
+ raise utils.EventError("Server not connected")
+
+ event["stdout"].write("Disconnected from %s" % alias)
+
+ @utils.hook("received.command.shutdown")
+ def shutdown(self, event):
+ """
+ :help: Shutdown bot
+ :usage: [reason]
+ :permission: shutdown
+ """
+ reason = event["args"] or ""
+ for server in self.bot.servers.values():
+ line = server.send_quit(reason)
+ line.events.on("send").hook(self._shutdown_hook(server))
+ def _shutdown_hook(self, server):
+ def shutdown(e):
+ server.disconnect()
+ self.bot.disconnect(server)
+ return shutdown
+
+ @utils.hook("received.command.addserver", min_args=3)
+ def add_server(self, event):
+ """
+ :help: Add a new server
+ :usage: <alias> <hostname>:[+]<port> <nickname>!<username>[@<bindhost>]
+ :permission: addserver
+ """
+ alias = event["args_split"][0]
+ hostname, sep, port = event["args_split"][1].partition(":")
+ tls = port.startswith("+")
+ port = port.lstrip("+")
+
+ if not hostname or not port or not port.isdigit():
+ raise utils.EventError("Please provide <hostname>:[+]<port>")
+ port = int(port)
+
+ hostmask = IRCLine.parse_hostmask(event["args_split"][2])
+ nickname = hostmask.nickname
+ username = hostmask.username or nickname
+ realname = nickname
+ bindhost = hostmask.hostname or None
+
+ try:
+ server_id = self.bot.database.servers.add(alias, hostname, port, "",
+ tls, bindhost, nickname, username, realname)
+ except Exception as e:
+ event["stderr"].write("Failed to add server")
+ self.log.error("failed to add server \"%s\"", [alias],
+ exc_info=True)
+ return
+ event["stdout"].write("Added server '%s'" % alias)
+
+ @utils.hook("received.command.editserver")
+ @utils.kwarg("min_args", 3)
+ @utils.kwarg("help", "Edit server details")
+ @utils.kwarg("usage", "<alias> <option> <value>")
+ @utils.kwarg("permission", "editserver")
+ def edit_server(self, event):
+ alias = event["args_split"][0]
+ server_id = self._id_from_alias(alias)
+ if server_id == None:
+ raise utils.EventError("Unknown server '%s'" % alias)
+
+ option = event["args_split"][1].lower()
+ value = " ".join(event["args_split"][2:])
+ value_parsed = None
+
+ if option == "hostname":
+ value_parsed = value
+ elif option == "port":
+ if not value.isdigit():
+ raise utils.EventError("Invalid port")
+ value_parsed = int(value.lstrip("0"))
+ elif option == "tls":
+ value_lower = value.lower()
+ if not value_lower in ["yes", "no"]:
+ raise utils.EventError("TLS should be either 'yes' or 'no'")
+ value_parsed = value_lower == "yes"
+ elif option == "password":
+ value_parsed = value
+ elif option == "bindhost":
+ value_parsed = value
+ elif option in ["nickname", "username", "realname"]:
+ value_parsed = value
+ else:
+ raise utils.EventError("Unknown option '%s'" % option)
+
+ self.bot.database.servers.edit(server_id, option, value_parsed)
+ event["stdout"].write("Set %s for %s" % (option, alias))
diff --git a/src/core_modules/channel_access.py b/src/core_modules/channel_access.py
new file mode 100644
index 00000000..5502db9f
--- /dev/null
+++ b/src/core_modules/channel_access.py
@@ -0,0 +1,91 @@
+#--depends-on check_mode
+#--depends-on commands
+#--depends-on permissions
+
+from src import ModuleManager, utils
+
+class Module(ModuleManager.BaseModule):
+ _name = "ChanAccess"
+
+ def _has_channel_access(self, target, user, require_access):
+ access = target.get_user_setting(user.get_id(), "access", [])
+ identified = self.exports.get_one("is-identified")(user)
+
+ return (require_access in access or "*" in access) and identified
+
+ def _command_check(self, event, target, require_access):
+ if event["is_channel"]:
+ if require_access:
+ if self._has_channel_access(target, event["user"],
+ require_access):
+ return utils.consts.PERMISSION_FORCE_SUCCESS, None
+ else:
+ return (utils.consts.PERMISSION_ERROR,
+ "You do not have permission to do this")
+
+ @utils.hook("preprocess.command")
+ def preprocess_command(self, event):
+ require_access = event["hook"].get_kwarg("require_access")
+ if require_access:
+ return self._command_check(event, event["target"], require_access)
+
+ @utils.hook("check.command.channel-access")
+ def check_command(self, event):
+ target = event["target"]
+ access = event["request_args"][0]
+ if len(event["request_args"]) > 1:
+ target = event["request_args"][0]
+ access = event["request_args"][1]
+
+ return self._command_check(event, target, access)
+
+ @utils.hook("received.command.access", min_args=1, channel_only=True)
+ def access(self, event):
+ """
+ :help: Show/modify channel access for a user
+ :usage: list <nickname>
+ :usage: add <nickname> <permission1 permission2 ...>
+ :usage: remove <nickname> <permission1 permission2 ...>
+ :usage: set <nickname> <permission1 permission2 ...>
+ :require_mode: high
+ """
+ subcommand = event["args_split"][0].lower()
+ target = event["server"].get_user(event["args_split"][1])
+ access = event["target"].get_user_setting(target.get_id(), "access", [])
+
+ if subcommand == "list":
+ event["stdout"].write("Access for %s: %s" % (target.nickname,
+ " ".join(access)))
+ elif subcommand == "set":
+ if not len(event["args_split"]) > 2:
+ raise utils.EventError("Please provide a list of permissions")
+ event["target"].set_user_setting(target.get_id(), "access",
+ event["args_split"][2:])
+ elif subcommand == "add":
+ if not len(event["args_split"]) > 2:
+ raise utils.EventError("Please provide a list of permissions")
+ for acc in event["args_split"][2:]:
+ if acc in access:
+ raise utils.EventError("%s already has '%s' permission" % (
+ target.nickname, acc))
+ access.append(acc)
+ event["target"].set_user_setting(target.get_id(), "access", access)
+ event["stdout"].write("Added permission to %s: %s" % (
+ target.nickname, " ".join(event["args_split"][2:])))
+ elif subcommand == "remove":
+ if not len(event["args_split"]) > 2:
+ raise utils.EventError("Please provide a list of permissions")
+ for acc in event["args_split"][2:]:
+ if not acc in access:
+ raise utils.EventError("%s does not have '%s' permission" %
+ (target.nickname, acc))
+ access.remove(acc)
+ if access:
+ event["target"].set_user_setting(target.get_id(), "access",
+ access)
+ else:
+ event["target"].del_user_setting(target.get_id(), "access")
+ event["stdout"].write("Removed permission from %s: %s" % (
+ target.nickname, " ".join(event["args_split"][2:])))
+ else:
+ event["stderr"].write("Unknown command '%s'" % subcommand)
diff --git a/src/core_modules/channel_blacklist.py b/src/core_modules/channel_blacklist.py
new file mode 100644
index 00000000..d151bad8
--- /dev/null
+++ b/src/core_modules/channel_blacklist.py
@@ -0,0 +1,40 @@
+from src import EventManager, ModuleManager, utils
+
+@utils.export("channelset", utils.BoolSetting("blacklist",
+ "Refuse to join a given channel"))
+class Module(ModuleManager.BaseModule):
+ @utils.hook("preprocess.send.join")
+ @utils.kwarg("priority", EventManager.PRIORITY_HIGH)
+ def preprocess_send_join(self, event):
+ if event["line"].args:
+ channels = event["line"].args[0].split(",")
+ keys = event["line"].args[1:]
+
+ changed = False
+ channels_out = []
+ for channel_name in filter(None, channels):
+ id = event["server"].channels.get_id(channel_name, create=False)
+ if not id == None and self.bot.database.channel_settings.get(
+ id, "blacklist", False):
+ changed = True
+ if keys:
+ keys.pop(0)
+ else:
+ key = None
+ if keys:
+ key = keys.pop(0)
+ channels_out.append([channel_name, key])
+
+ if changed:
+ if not channels_out:
+ event["line"].invalidate()
+ else:
+ channels = [c[0] for c in channels_out]
+ keys = [c[1] for c in channels_out if c[1]]
+ event["line"].args[0] = ",".join(channels)
+ event["line"].args[1:] = keys
+
+ @utils.hook("received.join")
+ def on_join(self, event):
+ if event["channel"].get_setting("blacklist", False):
+ event["channel"].send_part()
diff --git a/src/core_modules/channel_keys.py b/src/core_modules/channel_keys.py
new file mode 100644
index 00000000..01e3c38f
--- /dev/null
+++ b/src/core_modules/channel_keys.py
@@ -0,0 +1,55 @@
+from src import ModuleManager, utils
+
+@utils.export("channelset", utils.Setting("key", "Channel key (password)",
+ example="hunter2"))
+class Module(ModuleManager.BaseModule):
+ def _get_key(self, server, channel_name):
+ channel_id = server.channels.get_id(channel_name)
+ return self.bot.database.channel_settings.get(channel_id, "key", None)
+ def _set_key(self, channel, key):
+ channel.set_setting("key", key)
+ def _unset_key(self, channel):
+ channel.del_setting("key")
+
+ @utils.hook("preprocess.send.join")
+ def preprocess_send_join(self, event):
+ if event["line"].args:
+ channels = event["line"].args[0].split(",")
+
+ init_keys = False
+ if len(event["line"].args) > 1:
+ init_keys = True
+ keys = event["line"].args[1].split(",")
+ else:
+ keys = []
+
+ with_keys = {}
+ for channel in channels:
+ if keys:
+ with_keys[channel] = keys.pop(0)
+ else:
+ with_keys[channel] = self._get_key(event["server"], channel)
+
+ channels_out = []
+ keys_out = []
+
+ # sort such that channels with keys are at the start
+ for channel_name, key in sorted(with_keys.items(),
+ key=lambda item: not bool(item[1])):
+ channels_out.append(channel_name)
+ if key:
+ keys_out.append(key)
+
+ event["line"].args[0] = ",".join(channels_out)
+ if not init_keys:
+ event["line"].args.append(None)
+ event["line"].args[1] = ",".join(keys_out)
+
+ @utils.hook("received.324")
+ @utils.hook("received.mode.channel")
+ def on_modes(self, event):
+ for mode, arg in event["modes"]:
+ if mode == "+k":
+ self._set_key(event["channel"], arg)
+ elif mode == "-k":
+ self._unset_key(event["channel"])
diff --git a/src/core_modules/check_mode.py b/src/core_modules/check_mode.py
new file mode 100644
index 00000000..9fe3f464
--- /dev/null
+++ b/src/core_modules/check_mode.py
@@ -0,0 +1,43 @@
+#--depends-on commands
+
+from src import ModuleManager, utils
+
+LOWHIGH = {
+ "low": "v",
+ "high": "o"
+}
+
+@utils.export("channelset", utils.Setting("mode-low",
+ "Set which channel mode is considered to be 'low' access", example="v"))
+@utils.export("channelset", utils.Setting("mode-high",
+ "Set which channel mode is considered to be 'high' access", example="o"))
+class Module(ModuleManager.BaseModule):
+ def _check_command(self, event, channel, require_mode):
+ if event["is_channel"] and require_mode:
+ if require_mode.lower() in LOWHIGH:
+ require_mode = event["target"].get_setting(
+ "mode-%s" % require_mode.lower(),
+ LOWHIGH[require_mode.lower()])
+
+ if not event["target"].mode_or_above(event["user"],
+ require_mode):
+ return (utils.consts.PERMISSION_ERROR,
+ "You do not have permission to do this")
+ else:
+ return utils.consts.PERMISSION_FORCE_SUCCESS, None
+
+ @utils.hook("preprocess.command")
+ def preprocess_command(self, event):
+ require_mode = event["hook"].get_kwarg("require_mode")
+ if not require_mode == None:
+ return self._check_command(event, event["target"], require_mode)
+
+ @utils.hook("check.command.channel-mode")
+ def check_command(self, event):
+ target = event["target"]
+ mode = event["request_args"][0]
+ if len(event["request_args"]) > 1:
+ target = event["request_args"][0]
+ mode = event["request_args"][1]
+
+ return self._check_command(event, target, mode)
diff --git a/src/core_modules/commands/__init__.py b/src/core_modules/commands/__init__.py
new file mode 100644
index 00000000..5a750a9b
--- /dev/null
+++ b/src/core_modules/commands/__init__.py
@@ -0,0 +1,424 @@
+#--depends-on config
+#--depends-on permissions
+
+import enum, re, shlex, string, traceback, typing
+from src import EventManager, IRCLine, ModuleManager, utils
+from . import outs
+
+COMMAND_METHOD = "command-method"
+COMMAND_METHODS = ["PRIVMSG", "NOTICE"]
+
+STR_MORE = " (more...)"
+STR_MORE_LEN = len(STR_MORE.encode("utf8"))
+STR_CONTINUED = "(...continued)"
+WORD_BOUNDARIES = [" "]
+
+NON_ALPHANUMERIC = [char for char in string.printable if not char.isalnum()]
+
+class OutType(enum.Enum):
+ OUT = 1
+ ERR = 2
+
+class BadContextException(Exception):
+ def __init__(self, required_context):
+ self.required_context = required_context
+ Exception.__init__(self)
+
+class CommandEvent(object):
+ def __init__(self, command, args):
+ self.command = command
+ self.args = args
+
+SETTING_COMMANDMETHOD = utils.OptionsSetting(COMMAND_METHODS, COMMAND_METHOD,
+ "Set the method used to respond to commands")
+
+@utils.export("channelset", utils.Setting("command-prefix",
+ "Set the command prefix used in this channel", example="!"))
+@utils.export("serverset", utils.Setting("command-prefix",
+ "Set the command prefix used on this server", example="!"))
+@utils.export("serverset", SETTING_COMMANDMETHOD)
+@utils.export("channelset", SETTING_COMMANDMETHOD)
+@utils.export("botset", SETTING_COMMANDMETHOD)
+@utils.export("channelset", utils.BoolSetting("hide-prefix",
+ "Disable/enable hiding prefix in command reponses"))
+@utils.export("channelset", utils.BoolSetting("commands",
+ "Disable/enable responding to commands in-channel"))
+@utils.export("channelset", utils.BoolSetting("prefixed-commands",
+ "Disable/enable responding to prefixed commands in-channel"))
+class Module(ModuleManager.BaseModule):
+ @utils.hook("new.user")
+ @utils.hook("new.channel")
+ def new(self, event):
+ if "user" in event:
+ target = event["user"]
+ else:
+ target = event["channel"]
+
+ def has_command(self, command):
+ return command.lower() in self.events.on("received").on(
+ "command").get_children()
+ def get_hooks(self, command):
+ return self.events.on("received.command").on(command
+ ).get_hooks()
+
+ def is_highlight(self, server, s):
+ if s and s[-1] in [":", ","]:
+ return server.is_own_nickname(s[:-1])
+
+ def _command_method(self, server, target):
+ return target.get_setting(COMMAND_METHOD,
+ server.get_setting(COMMAND_METHOD,
+ self.bot.get_setting(COMMAND_METHOD, "PRIVMSG"))).upper()
+
+ def _find_command_hook(self, server, target, is_channel, command, args):
+ if not self.has_command(command):
+ command_event = CommandEvent(command, args)
+ self.events.on("get.command").call(command=command_event,
+ server=server, target=target, is_channel=is_channel)
+
+ command = command_event.command
+ args = command_event.args
+
+ hook = None
+ args_split = []
+ channel_skip = False
+ private_skip = False
+ if self.has_command(command):
+ for potential_hook in self.get_hooks(command):
+ alias_of = self._get_alias_of(potential_hook)
+ if alias_of:
+ if self.has_command(alias_of):
+ potential_hook = self.get_hooks(alias_of)[0]
+ else:
+ raise ValueError(
+ "'%s' is an alias of unknown command '%s'"
+ % (command.lower(), alias_of.lower()))
+
+ if not is_channel and potential_hook.get_kwarg("channel_only",
+ False):
+ channel_skip = True
+ continue
+ if is_channel and potential_hook.get_kwarg("private_only",
+ False):
+ private_skip = True
+ continue
+
+ hook = potential_hook
+
+ if args:
+ argparse = hook.get_kwarg("argparse", "plain")
+ if argparse == "shlex":
+ args_split = shlex.split(args)
+ elif argparse == "plain":
+ args_split = args.split(" ")
+
+ break
+
+ if not hook and (private_skip or channel_skip):
+ raise BadContextException("channel" if channel_skip else "private")
+
+ return hook, command, args_split
+
+ def _check(self, context, kwargs, requests=[]):
+ event_hook = self.events.on(context).on("command")
+
+ returns = []
+ if requests:
+ for request, request_args in requests:
+ returns.append(event_hook.on(request).call_for_result_unsafe(
+ **kwargs, request_args=request_args))
+ else:
+ returns = event_hook.call_unsafe(**kwargs)
+
+ hard_fail = False
+ force_success = False
+ error = None
+ for returned in returns:
+ if returned:
+ type, message = returned
+ if type == utils.consts.PERMISSION_HARD_FAIL:
+ error = message
+ hard_fail = True
+ break
+ elif type == utils.consts.PERMISSION_FORCE_SUCCESS:
+ force_success = True
+ break
+ elif type == utils.consts.PERMISSION_ERROR:
+ error = message
+
+ if hard_fail:
+ return False, error
+ elif not force_success and error:
+ return False, error
+ else:
+ return True, None
+
+
+ def _check_assert(self, check_kwargs, user,
+ check: typing.Union[utils.Check, utils.MultiCheck]):
+ checks = check.to_multi() # both Check and MultiCheck has this func
+ is_success, message = self._check("check", check_kwargs,
+ checks.requests())
+ if not is_success:
+ raise utils.EventError("%s: %s" % (user.nickname, message))
+
+ def command(self, server, target, target_str, is_channel, user, command,
+ args_split, line, hook, **kwargs):
+ module_name = (self._get_prefix(hook) or
+ self.bot.modules.from_context(hook.context).title)
+
+ stdout = outs.StdOut(module_name)
+ stderr = outs.StdOut(module_name)
+
+ ret = False
+ has_out = False
+
+ if hook.get_kwarg("remove_empty", True):
+ args_split = list(filter(None, args_split))
+
+ event_kwargs = {"hook": hook, "user": user, "server": server,
+ "target": target, "target_str": target_str,
+ "is_channel": is_channel, "line": line, "args_split": args_split,
+ "command": command, "args": " ".join(args_split), "stdout": stdout,
+ "stderr": stderr, "tags": {}}
+ event_kwargs.update(kwargs)
+
+ check_assert = lambda check: self._check_assert(event_kwargs, user,
+ check)
+ event_kwargs["check_assert"] = check_assert
+
+ eaten = False
+
+ check_success, check_message = self._check("preprocess", event_kwargs)
+ if check_success:
+ new_event = self.events.on(hook.event_name).make_event(**event_kwargs)
+ self.log.trace("calling command '%s': %s", [command, new_event.kwargs])
+
+ try:
+ hook.call(new_event)
+ except utils.EventError as e:
+ stderr.write(str(e))
+ eaten = new_event.eaten
+ else:
+ if check_message:
+ stderr.write("%s: %s" % (user.nickname, check_message))
+
+ self._check("postprocess", event_kwargs)
+ # postprocess - send stdout/stderr and typing tag
+
+ return eaten
+
+ @utils.hook("postprocess.command")
+ @utils.kwarg("priority", EventManager.PRIORITY_LOW)
+ def postprocess(self, event):
+ type = None
+ obj = None
+ if event["stdout"].has_text():
+ type = OutType.OUT
+ obj = event["stdout"]
+ elif event["stderr"].has_text():
+ type = OutType.ERR
+ obj = event["stderr"]
+ else:
+ return
+ self._out(event["server"], event["target"], event["target_str"], obj,
+ type, event["tags"])
+
+ def _out(self, server, target, target_str, obj, type, tags):
+ if type == OutType.OUT:
+ color = utils.consts.GREEN
+ else:
+ color = utils.consts.RED
+
+ line_str = obj.pop()
+ if obj.prefix:
+ line_str = "[%s] %s" % (
+ utils.irc.color(obj.prefix, color), line_str)
+ method = self._command_method(server, target)
+
+ if not method in ["PRIVMSG", "NOTICE"]:
+ raise ValueError("Unknown command-method '%s'" % method)
+
+ line = IRCLine.ParsedLine(method, [target_str, line_str],
+ tags=tags)
+ valid, trunc = line.truncate(server.hostmask(),
+ margin=STR_MORE_LEN)
+
+ if trunc:
+ if not trunc[0] in WORD_BOUNDARIES:
+ for boundary in WORD_BOUNDARIES:
+ left, *right = valid.rsplit(boundary, 1)
+ if right:
+ valid = left
+ trunc = right[0]+trunc
+ obj.insert("%s %s" % (STR_CONTINUED, trunc))
+ valid = valid+STR_MORE
+ line = IRCLine.parse_line(valid)
+ server.send(line)
+
+ @utils.hook("preprocess.command")
+ def _check_min_args(self, event):
+ min_args = event["hook"].get_kwarg("min_args")
+ if min_args and len(event["args_split"]) < min_args:
+ usage = self._get_usage(event["hook"], event["command"],
+ event["command_prefix"])
+ error = None
+ if usage:
+ error = "Not enough arguments, usage: %s" % usage
+ else:
+ error = "Not enough arguments (minimum: %d)" % min_args
+ return utils.consts.PERMISSION_HARD_FAIL, error
+
+ def _command_prefix(self, server, channel):
+ return channel.get_setting("command-prefix",
+ server.get_setting("command-prefix", "!"))
+
+ @utils.hook("received.message.channel", priority=EventManager.PRIORITY_LOW)
+ def channel_message(self, event):
+ commands_enabled = event["channel"].get_setting("commands", True)
+ if not commands_enabled:
+ return
+
+ command_prefix = self._command_prefix(event["server"], event["channel"])
+ command = None
+ args = ""
+ if event["message_split"][0].startswith(command_prefix):
+ if not event["channel"].get_setting("prefixed-commands",True):
+ return
+ command = event["message_split"][0].replace(
+ command_prefix, "", 1).lower()
+ if " " in event["message"]:
+ args = event["message"].split(" ", 1)[1]
+ elif len(event["message_split"]) > 1 and self.is_highlight(
+ event["server"], event["message_split"][0]):
+ command = event["message_split"][1].lower()
+ if event["message"].count(" ") > 1:
+ args = event["message"].split(" ", 2)[2]
+
+ hook = None
+ args_split = []
+ if command:
+ try:
+ hook, command, args_split = self._find_command_hook(
+ event["server"], event["channel"], True, command, args)
+ except BadContextException:
+ event["channel"].send_message(
+ "%s: That command is not valid in a channel" %
+ event["user"].nickname)
+ return
+
+ if hook:
+ if event["action"]:
+ return
+
+ if hook:
+ self.command(event["server"], event["channel"],
+ event["target_str"], True, event["user"], command,
+ args_split, event["line"], hook,
+ command_prefix=command_prefix,
+ buffer_line=event["buffer_line"])
+ else:
+ self.events.on("unknown.command").call(server=event["server"],
+ target=event["channel"], user=event["user"],
+ command=command, command_prefix=command_prefix,
+ is_channel=True)
+ else:
+ regex_hooks = self.events.on("command.regex").get_hooks()
+ for hook in regex_hooks:
+ if event["action"] and hook.get_kwarg("ignore_action", True):
+ continue
+
+ pattern = hook.get_kwarg("pattern", None)
+ if pattern:
+ match = re.search(pattern, event["message"])
+ if match:
+ command = hook.get_kwarg("command", "")
+ res = self.command(event["server"], event["channel"],
+ event["target_str"], True, event["user"], command,
+ "", event["line"], hook, match=match,
+ message=event["message"], command_prefix="",
+ action=event["action"],
+ buffer_line=event["buffer_line"])
+
+ if res:
+ break
+
+ @utils.hook("received.message.private", priority=EventManager.PRIORITY_LOW)
+ def private_message(self, event):
+ if event["message_split"] and not event["action"]:
+ command = event["message_split"][0].lower()
+
+ # this should help catch commands when people try to do prefixed
+ # commands ('!help' rather than 'help') in PM
+ command = command.lstrip("".join(NON_ALPHANUMERIC))
+
+ args = ""
+ if " " in event["message"]:
+ args = event["message"].split(" ", 1)[1]
+
+ try:
+ hook, command, args_split = self._find_command_hook(
+ event["server"], event["user"], False, command, args)
+ except BadContextException:
+ event["user"].send_message(
+ "That command is not valid in a PM")
+ return
+
+ if hook:
+ self.command(event["server"], event["user"],
+ event["user"].nickname, False, event["user"], command,
+ args_split, event["line"], hook, command_prefix="",
+ buffer_line=event["buffer_line"])
+ else:
+ self.events.on("unknown.command").call(server=event["server"],
+ target=event["user"], user=event["user"], command=command,
+ command_prefix="", is_channel=False)
+
+ def _get_usage(self, hook, command, command_prefix=""):
+ command = "%s%s" % (command_prefix, command)
+ usages = hook.get_kwargs("usage")
+
+ if usages:
+ return " | ".join(
+ "%s %s" % (command, usage) for usage in usages)
+ return None
+
+ def _get_prefix(self, hook):
+ return hook.get_kwarg("prefix", None)
+ def _get_alias_of(self, hook):
+ return hook.get_kwarg("alias_of", None)
+
+ @utils.hook("send.stdout")
+ def _stdout(self, event):
+ self._send_out(event, OutType.OUT)
+ @utils.hook("send.stderr")
+ def _stderr(self, event):
+ self._send_out(event, OutType.ERR)
+
+ def _send_out(self, event, type):
+ target = event["target"]
+ stdout = outs.StdOut(event["module_name"])
+ stdout.write(event["message"])
+ if event.get("hide_prefix", False):
+ stdout.prefix = None
+
+ target_str = event.get("target_str", target.name)
+ self._out(event["server"], target, target_str, stdout,
+ type, {})
+
+ @utils.hook("check.command.self")
+ def check_command_self(self, event):
+ if event["server"].irc_lower(event["request_args"][0]
+ ) == event["user"].name:
+ return utils.consts.PERMISSION_FORCE_SUCCESS, None
+ else:
+ return (utils.consts.PERMISSION_ERROR,
+ "You do not have permission to do this")
+
+ @utils.hook("check.command.is-channel")
+ def check_command_is_channel(self, event):
+ if event["is_channel"]:
+ return utils.consts.PERMISSION_FORCE_SUCCESS, None
+ else:
+ return (utils.consts.PERMISSION_ERROR,
+ "This command can only be used in-channel")
diff --git a/src/core_modules/commands/outs.py b/src/core_modules/commands/outs.py
new file mode 100644
index 00000000..e82ceefd
--- /dev/null
+++ b/src/core_modules/commands/outs.py
@@ -0,0 +1,28 @@
+import re
+from src import IRCLine, utils
+
+class StdOut(object):
+ def __init__(self, prefix):
+ self.prefix = prefix
+ self._lines = []
+ self._assured = False
+
+ def assure(self):
+ self._assured = True
+
+ def write(self, text):
+ self.write_lines(
+ text.replace("\r", "").replace("\n\n", "\n").split("\n"))
+ def write_lines(self, lines):
+ self._lines += list(filter(None, lines))
+
+ def get_all(self):
+ return self._lines.copy()
+ def pop(self):
+ return self._lines.pop(0)
+ def insert(self, text):
+ self._lines.insert(0, text)
+
+ def has_text(self):
+ return bool(self._lines)
+
diff --git a/src/core_modules/config.py b/src/core_modules/config.py
new file mode 100644
index 00000000..710a5dd6
--- /dev/null
+++ b/src/core_modules/config.py
@@ -0,0 +1,244 @@
+#--depends-on channel_access
+#--depends-on check_mode
+#--depends-on commands
+#--depends-on permissions
+
+import enum
+from src import ModuleManager, utils
+
+class ConfigInvalidValue(Exception):
+ def __init__(self, message: str=None):
+ self.message = message
+class ConfigSettingInexistent(Exception):
+ pass
+
+class ConfigResults(enum.Enum):
+ Changed = 1
+ Retrieved = 2
+ Removed = 3
+ Unchanged = 4
+
+class ConfigResult(object):
+ def __init__(self, result, data=None):
+ self.result = result
+ self.data = data
+
+class ConfigChannelTarget(object):
+ def __init__(self, bot, server, channel_name):
+ self._bot = bot
+ self._server = server
+ self._channel_name = channel_name
+ def _get_id(self):
+ return self._server.channels.get_id(self._channel_name)
+ def set_setting(self, setting, value):
+ channel_id = self._get_id()
+ self._bot.database.channel_settings.set(channel_id, setting, value)
+ def get_setting(self, setting, default=None):
+ channel_id = self._get_id()
+ return self._bot.database.channel_settings.get(channel_id, setting,
+ default)
+ def del_setting(self, setting):
+ channel_id = self._get_id()
+ self._bot.database.channel_settings.delete(channel_id, setting)
+
+ def get_user_setting(self, user_id, setting, default=None):
+ return self._bot.database.user_channel_settings.get(user_id,
+ self._get_id(), setting, default)
+
+class Module(ModuleManager.BaseModule):
+ def _to_context(self, server, channel, user, context_desc):
+ context_desc_lower = context_desc.lower()
+
+ if context_desc == "*":
+ if channel == user:
+ # we're in PM
+ return user, "set", None
+ else:
+ #we're in a channel
+ return channel, "channelset", None
+ elif server.is_channel(context_desc):
+ return context_desc, "channelset", context_desc
+ elif server.irc_lower(context_desc) == user.nickname_lower:
+ return user, "set", None
+ elif "user".startswith(context_desc_lower):
+ return user, "set", None
+ elif "channel".startswith(context_desc_lower):
+ return channel, "channelset", None
+ elif "server".startswith(context_desc_lower):
+ return server, "serverset", None
+ elif "bot".startswith(context_desc_lower):
+ return self.bot, "botset", None
+ else:
+ raise ValueError()
+
+ @utils.hook("preprocess.command")
+ def preprocess_command(self, event):
+ require_setting = event["hook"].get_kwarg("require_setting", None)
+ if not require_setting == None:
+ require_setting_unless = event["hook"].get_kwarg(
+ "require_setting_unless", None)
+ if not require_setting_unless == None:
+ require_setting_unless = int(require_setting_unless)
+ if len(event["args_split"]) >= require_setting_unless:
+ return
+
+ context, _, require_setting = require_setting.rpartition(":")
+ require_setting = require_setting.lower()
+ channel = None
+ if event["is_channel"]:
+ channel = event["target"]
+
+ context = context or "user"
+ target, setting_context, _ = self._to_context(event["server"],
+ channel, event["user"], context)
+
+ export_settings = self._get_export_setting(setting_context)
+ setting_info = export_settings.get(require_setting, None)
+ if setting_info:
+ value = target.get_setting(require_setting, None)
+ if value == None:
+ example = setting_info.example or "<value>"
+ if context == "user":
+ context = event["user"].nickname
+ elif context == "channel" and not channel == None:
+ context = channel.name
+ else:
+ context = context[0]
+
+ error = "Please set %s, e.g.: %sconfig %s %s %s" % (
+ require_setting, event["command_prefix"], context,
+ require_setting, example)
+ return utils.consts.PERMISSION_ERROR, error
+
+ def _get_export_setting(self, context):
+ settings = self.exports.get_all(context)
+ return {setting.name.lower(): setting for setting in settings}
+
+ def _config(self, export_settings, target, setting, value=None):
+ if not value == None:
+ setting_object = export_settings[setting]
+ try:
+ validated_value = setting_object.parse(value)
+ except utils.settings.SettingParseException as e:
+ raise ConfigInvalidValue(str(e))
+
+ if not validated_value == None:
+ existing_value = target.get_setting(setting, None)
+ if existing_value == validated_value:
+ return ConfigResult(ConfigResults.Unchanged)
+ else:
+ target.set_setting(setting, validated_value)
+ formatted_value = setting_object.format(validated_value)
+ return ConfigResult(ConfigResults.Changed, formatted_value)
+ else:
+ raise ConfigInvalidValue()
+ else:
+ unset = False
+ if setting.startswith("-"):
+ setting = setting[1:]
+ unset = True
+
+ existing_value = target.get_setting(setting, None)
+ if not existing_value == None:
+ if unset:
+ target.del_setting(setting)
+ return ConfigResult(ConfigResults.Removed)
+ else:
+ formatted = export_settings[setting].format(existing_value)
+ return ConfigResult(ConfigResults.Retrieved, formatted)
+ else:
+ raise ConfigSettingInexistent()
+
+ @utils.hook("received.command.c", alias_of="config")
+ @utils.hook("received.command.config")
+ @utils.kwarg("min_args", 1)
+ @utils.kwarg("help", "Change config options")
+ @utils.kwarg("usage", "<context>[:name] [-][setting [value]]")
+ def config(self, event):
+ arg_count = len(event["args_split"])
+ context_desc, _, name = event["args_split"][0].partition(":")
+
+ setting = None
+ value = None
+ if arg_count > 1:
+ setting = event["args_split"][1].lower()
+ if arg_count > 2:
+ value = " ".join(event["args_split"][2:])
+
+ try:
+ target, context, name_override = self._to_context(event["server"],
+ event["target"], event["user"], context_desc)
+ except ValueError:
+ raise utils.EventError(
+ "Unknown context '%s'. Please provide "
+ "'user', 'channel', 'server' or 'bot'" % context_desc)
+
+ name = name_override or name
+
+ permission_check = utils.Check("permission", "config")
+
+ if context == "set":
+ if name:
+ event["check_assert"](
+ utils.Check("self", name)|permission_check)
+ target = event["server"].get_user(name)
+ else:
+ target = event["user"]
+ elif context == "channelset":
+ if name:
+ if name in event["server"].channels:
+ target = event["server"].channels.get(name)
+ else:
+ target = ConfigChannelTarget(self.bot, event["server"],
+ name)
+ else:
+ if event["is_channel"]:
+ target = event["target"]
+ else:
+ raise utils.EventError(
+ "Cannot change config for current channel when in "
+ "private message")
+ event["check_assert"](permission_check|
+ utils.Check("channel-access", target, "config")|
+ utils.Check("channel-mode", target, "o"))
+ elif context == "serverset" or context == "botset":
+ event["check_assert"](permission_check)
+
+ export_settings = self._get_export_setting(context)
+ if not setting == None:
+ if not setting.lstrip("-") in export_settings:
+ raise utils.EventError("Setting not found")
+
+ try:
+ result = self._config(export_settings, target, setting, value)
+ except ConfigInvalidValue as e:
+ if not e.message == None:
+ raise utils.EventError("Invalid value: %s" % e.message)
+
+ example = export_settings[setting].get_example()
+ if not example == None:
+ raise utils.EventError("Invalid value. %s" %
+ example)
+ else:
+ raise utils.EventError("Invalid value")
+ except ConfigSettingInexistent:
+ raise utils.EventError("Setting not set")
+
+ for_str = ""
+ if name_override:
+ for_str = " for %s" % name_override
+ if result.result == ConfigResults.Changed:
+ event["stdout"].write("Config '%s'%s set to %s" %
+ (setting, for_str, result.data))
+ elif result.result == ConfigResults.Retrieved:
+ event["stdout"].write("%s%s: %s" % (setting, for_str,
+ result.data))
+ elif result.result == ConfigResults.Removed:
+ event["stdout"].write("Unset setting '%s'%s" %
+ (setting.lstrip("-"), for_str))
+ elif result.result == ConfigResults.Unchanged:
+ event["stdout"].write("Config '%s'%s unchanged" %
+ (setting, for_str))
+ else:
+ event["stdout"].write("Available config: %s" %
+ ", ".join(export_settings.keys()))
diff --git a/src/core_modules/ctcp.py b/src/core_modules/ctcp.py
new file mode 100644
index 00000000..678cf833
--- /dev/null
+++ b/src/core_modules/ctcp.py
@@ -0,0 +1,29 @@
+#--depends-on config
+
+import datetime
+from src import IRCBot, ModuleManager, utils
+
+
+@utils.export("serverset", utils.BoolSetting("ctcp-responses",
+ "Set whether I respond to CTCPs on this server"))
+class Module(ModuleManager.BaseModule):
+ @utils.hook("received.ctcp.request.version")
+ def ctcp_version(self, event):
+ default = "BitBot %s (%s)" % (IRCBot.VERSION, IRCBot.SOURCE)
+
+ event["user"].send_ctcp_response("VERSION",
+ self.bot.config.get("ctcp-version", default))
+
+ @utils.hook("received.ctcp.request.source")
+ def ctcp_source(self, event):
+ event["user"].send_ctcp_response("SOURCE",
+ self.bot.config.get("ctcp-source", IRCBot.SOURCE))
+
+ @utils.hook("received.ctcp.request.ping")
+ def ctcp_ping(self, event):
+ event["user"].send_ctcp_response("PING", event["message"])
+
+ @utils.hook("received.ctcp.request.time")
+ def ctcp_time(self, event):
+ event["user"].send_ctcp_response("TIME",
+ datetime.datetime.now().strftime("%c"))
diff --git a/src/core_modules/deferred_read.py b/src/core_modules/deferred_read.py
new file mode 100644
index 00000000..c891e860
--- /dev/null
+++ b/src/core_modules/deferred_read.py
@@ -0,0 +1,23 @@
+from src import EventManager, ModuleManager, utils
+
+# postpone parsing SOME lines until after 001
+
+class Module(ModuleManager.BaseModule):
+ @utils.hook("new.server")
+ def new_server(self, event):
+ event["server"]._deferred_read = []
+
+ @utils.hook("raw.received.001", priority=EventManager.PRIORITY_LOW)
+ def on_001(self, event):
+ lines = event["server"]._deferred_read[:]
+ event["server"]._deferred_read.clear()
+ for line in lines:
+ self.events.on("raw.received").call(line=line,
+ server=event["server"])
+
+ @utils.hook("raw.received.mode", priority=EventManager.PRIORITY_HIGH)
+ def defer(self, event):
+ if not event["server"].connected:
+ event.eat()
+ event["server"]._deferred_read.append(event["line"])
+
diff --git a/src/core_modules/fake_echo.py b/src/core_modules/fake_echo.py
new file mode 100644
index 00000000..bb7fbf43
--- /dev/null
+++ b/src/core_modules/fake_echo.py
@@ -0,0 +1,13 @@
+from src import EventManager, IRCLine, ModuleManager, utils
+
+class Module(ModuleManager.BaseModule):
+ @utils.hook("raw.send.privmsg", priority=EventManager.PRIORITY_MONITOR)
+ @utils.hook("raw.send.notice", priority=EventManager.PRIORITY_MONITOR)
+ def send_message(self, event):
+ our_hostmask = IRCLine.parse_hostmask(event["server"].hostmask())
+
+ echo = IRCLine.ParsedLine(event["line"].command, event["line"].args,
+ source=our_hostmask, tags=event["line"].tags)
+ echo.id = event["line"].id
+
+ self.events.on("raw.received").call(line=echo, server=event["server"])
diff --git a/src/core_modules/format_activity.py b/src/core_modules/format_activity.py
new file mode 100644
index 00000000..1d93eb94
--- /dev/null
+++ b/src/core_modules/format_activity.py
@@ -0,0 +1,285 @@
+import datetime
+from src import EventManager, ModuleManager, utils
+
+class Module(ModuleManager.BaseModule):
+ def _color(self, nickname):
+ return utils.irc.hash_colorize(nickname)
+
+ def _event(self, type, server, line, context, minimal=None, pretty=None,
+ channel=None, user=None, **kwargs):
+ self.events.on("formatted").on(type).call(server=server,
+ context=context, line=line, channel=channel, user=user,
+ minimal=minimal, pretty=pretty, **kwargs)
+
+ def _mode_symbols(self, user, channel, server):
+ modes = list(channel.get_user_modes(user))
+ if modes:
+ modes = [mode for mode in modes if mode in server.prefix_modes]
+ modes.sort(key=lambda x: list(server.prefix_modes.keys()).index(x))
+ return server.prefix_modes[modes[0]]
+ return ""
+
+ def _privmsg(self, event, channel, user):
+ symbols = ""
+ if channel:
+ symbols = self._mode_symbols(user, channel, event["server"])
+
+ if event["action"]:
+ format = "* %s%s %s"
+ else:
+ format = "<%s%s> %s"
+
+ minimal = format % ("", user.nickname, event["message"])
+ normal = format % (symbols, user.nickname, event["message"])
+ pretty = format % (symbols, self._color(user.nickname),
+ event["message"])
+
+ return minimal, normal, pretty
+
+ @utils.hook("send.message.channel")
+ @utils.hook("received.message.channel")
+ def channel_message(self, event):
+ minimal, normal, pretty = self._privmsg(event, event["channel"],
+ event["user"])
+
+ self._event("message.channel", event["server"], normal,
+ event["channel"].name, channel=event["channel"], user=event["user"],
+ parsed_line=event["line"], minimal=minimal, pretty=pretty)
+
+ def _on_notice(self, event, user, channel):
+ symbols = ""
+ if channel:
+ symbols = self._mode_symbols(user, channel, event["server"])
+
+ format = "-%s%s- %s"
+ minimal = format % ("", user.nickname, event["message"])
+ normal = format % (symbols, user.nickname, event["message"])
+ pretty = format % (symbols, self._color(user.nickname),
+ event["message"])
+
+ return minimal, normal, pretty
+ def _channel_notice(self, event, user, channel):
+ minimal, normal, pretty = self._on_notice(event, user, channel)
+ self._event("notice.channel", event["server"], normal,
+ event["channel"].name, parsed_line=event["line"], channel=channel,
+ user=event["user"], minimal=minimal, pretty=pretty)
+
+ @utils.hook("received.notice.channel")
+ @utils.hook("send.notice.channel")
+ def channel_notice(self, event):
+ self._channel_notice(event, event["user"], event["channel"])
+
+ @utils.hook("received.notice.private")
+ @utils.hook("send.notice.private")
+ def private_notice(self, event):
+ minimal, normal, pretty = self._on_notice(event, event["user"], None)
+ self._event("notice.private", event["server"], normal,
+ event["target"].nickname, parsed_line=event["line"],
+ user=event["user"], minimal=minimal, pretty=pretty)
+
+ def _on_join(self, event, user):
+ channel_name = event["channel"].name
+
+ minimal = "%s joined %s" % (user.nickname, channel_name)
+
+ normal_format = "- %s (%s) joined %s"
+ normal = normal_format % (user.nickname, user.userhost(), channel_name)
+ pretty = normal_format % (self._color(user.nickname), user.userhost(),
+ channel_name)
+
+ self._event("join", event["server"], normal, event["channel"].name,
+ channel=event["channel"], user=user, minimal=minimal,
+ pretty=pretty)
+ @utils.hook("received.join")
+ def join(self, event):
+ self._on_join(event, event["user"])
+ @utils.hook("self.join")
+ def self_join(self, event):
+ self._on_join(event, event["server"].get_user(event["server"].nickname))
+
+ @utils.hook("received.chghost")
+ def _on_chghost(self, event):
+ username = event["username"]
+ hostname = event["hostname"]
+
+ format = "%s changed host to %s@%s"
+ minimal = format % (event["user"].nickname, username, hostname)
+
+ normal_format = "- %s" % format
+ normal = normal_format % (event["user"].nickname, username, hostname)
+ pretty = normal_format % (self._color(event["user"].nickname), username,
+ hostname)
+
+ self._event("chghost", event["server"], normal, None,
+ user=event["user"], minimal=minimal, pretty=pretty)
+
+ def _on_part(self, event, user):
+ channel_name = event["channel"].name
+ reason = event["reason"]
+ reason = "" if not reason else " (%s)" % reason
+
+ format = "%s left %s%s"
+ minimal = format % (user.nickname, channel_name, reason)
+
+ normal_format = "- %s" % format
+ normal = normal_format % (user.nickname, channel_name, reason)
+ pretty = normal_format % (self._color(user.nickname), channel_name,
+ reason)
+
+ self._event("part", event["server"], normal, event["channel"].name,
+ channel=event["channel"], user=user, minimal=minimal, pretty=pretty)
+ @utils.hook("received.part")
+ def part(self, event):
+ self._on_part(event, event["user"])
+ @utils.hook("self.part")
+ def self_part(self, event):
+ self._on_part(event, event["server"].get_user(event["server"].nickname))
+
+ def _on_nick(self, event, user):
+ old_nickname = event["old_nickname"]
+ new_nickname = event["new_nickname"]
+
+ format = "%s changed nickname to %s"
+ minimal = format % (old_nickname, new_nickname)
+
+ normal_format = "- %s" % format
+ normal = normal_format % (old_nickname, new_nickname)
+ pretty = normal_format % (
+ self._color(old_nickname), self._color(new_nickname))
+
+ self._event("nick", event["server"], normal, None, user=user,
+ minimal=minimal, pretty=pretty)
+ @utils.hook("received.nick")
+ def nick(self, event):
+ self._on_nick(event, event["user"])
+ @utils.hook("self.nick")
+ def self_nick(self, event):
+ self._on_nick(event, event["server"].get_user(event["server"].nickname))
+
+ @utils.hook("received.server-notice")
+ def server_notice(self, event):
+ line = "(server notice) %s" % event["message"]
+ self._event("server-notice", event["server"], line, None)
+
+ @utils.hook("received.invite")
+ def invite(self, event):
+ format = "%s invited %s to %s"
+
+ sender = event["user"].nickname
+ target = event["target_user"].nickname
+ channel_name = event["target_channel"]
+
+ minimal = format % (sender, target, channel_name)
+ normal = "- %s" % minimal
+ pretty = format % (self._color(sender), target, channel_name)
+
+ self._event("invite", event["server"], normal, event["target_channel"],
+ minimal=minimal, pretty=pretty)
+
+ @utils.hook("received.mode.channel")
+ def mode(self, event):
+ modes = "".join(event["modes_str"])
+ args = " ".join(event["args_str"])
+ if args:
+ args = " %s" % args
+
+ format = "%s set mode %s%s"
+ minimal = format % (event["user"].nickname, modes, args)
+
+ normal_format = "- %s" % format
+ normal = normal_format % (event["user"].nickname, modes, args)
+ pretty = normal_format % (self._color(event["user"].nickname), modes,
+ args)
+
+ self._event("mode.channel", event["server"], normal,
+ event["channel"].name, channel=event["channel"], user=event["user"],
+ minimal=minimal, pretty=pretty)
+
+ def _on_topic(self, event, nickname, action, topic):
+ format = "topic %s by %s: %s"
+ minimal = format % (action, nickname, topic)
+
+ normal_format = "- %s" % format
+ normal = normal_format % (action, nickname, topic)
+ pretty = normal_format % (action, self._color(nickname), topic)
+
+ self._event("topic", event["server"], normal, event["channel"].name,
+ channel=event["channel"], user=event.get("user", None),
+ minimal=minimal, pretty=pretty)
+ @utils.hook("received.topic")
+ def on_topic(self, event):
+ self._on_topic(event, event["user"].nickname, "changed",
+ event["topic"])
+ @utils.hook("received.333")
+ def on_333(self, event):
+ self._on_topic(event, event["setter"].nickname, "set",
+ event["channel"].topic)
+
+ dt = utils.datetime.iso8601_format(
+ utils.datetime.datetime_timestamp(event["set_at"]))
+
+ minimal = "topic set at %s" % dt
+ normal = "- %s" % minimal
+
+ self._event("topic-timestamp", event["server"], normal,
+ event["channel"].name, channel=event["channel"], minimal=minimal)
+
+ def _on_kick(self, event, kicked_nickname):
+ sender_nickname = event["user"].nickname
+ channel_name = event["channel"].name
+
+ reason = ""
+ if event["reason"]:
+ reason = " (%s)" % event["reason"]
+
+ format = "%s kicked %s from %s%s"
+ minimal = format % (sender_nickname, kicked_nickname, channel_name,
+ reason)
+
+ normal_format = "- %s" % format
+ normal = normal_format % (sender_nickname, kicked_nickname,
+ channel_name, reason)
+ pretty = normal_format % (self._color(sender_nickname),
+ self._color(kicked_nickname), channel_name, reason)
+
+ self._event("kick", event["server"], normal, event["channel"].name,
+ channel=event["channel"], user=event.get("user", None),
+ minimal=minimal, pretty=pretty)
+ @utils.hook("received.kick")
+ def kick(self, event):
+ self._on_kick(event, event["target_user"].nickname)
+ @utils.hook("self.kick")
+ def self_kick(self, event):
+ self._on_kick(event, event["server"].nickname)
+
+ def _quit(self, event, user, reason):
+ reason = "" if not reason else " (%s)" % reason
+
+ format = "%s quit%s"
+ minimal = format % (user.nickname, reason)
+
+ normal_format = "- %s" % format
+ normal = normal_format % (user.nickname, reason)
+ pretty = normal_format % (self._color(user.nickname), reason)
+
+ self._event("quit", event["server"], normal, None, user=user,
+ minimal=minimal, pretty=pretty)
+ @utils.hook("received.quit")
+ def on_quit(self, event):
+ self._quit(event, event["user"], event["reason"])
+ @utils.hook("send.quit")
+ def send_quit(self, event):
+ self._quit(event, event["server"].get_user(event["server"].nickname),
+ event["reason"])
+
+ @utils.hook("received.rename")
+ def rename(self, event):
+ line = "%s was renamed to %s" % (event["old_name"], event["new_name"])
+ self._event("rename", event["server"], line, event["old_name"],
+ channel=event["channel"])
+
+ @utils.hook("received.376")
+ def motd_end(self, event):
+ for line in event["server"].motd_lines:
+ line = "[MOTD] %s" % line
+ self._event("motd", event["server"], line, None)
diff --git a/src/core_modules/help.py b/src/core_modules/help.py
new file mode 100644
index 00000000..58659d9d
--- /dev/null
+++ b/src/core_modules/help.py
@@ -0,0 +1,124 @@
+#--depends-on commands
+from src import IRCBot, ModuleManager, utils
+
+class Module(ModuleManager.BaseModule):
+ def _get_help(self, hook):
+ return hook.get_kwarg("help", None) or hook.docstring.description
+ def _get_usage(self, hook, command, command_prefix=""):
+ command = "%s%s" % (command_prefix, command)
+ usage = hook.get_kwarg("usage", None)
+ if usage:
+ usages = [usage]
+ else:
+ usages = hook.docstring.var_items.get("usage", None)
+
+ if usages:
+ return " | ".join(
+ "%s %s" % (command, usage) for usage in usages)
+ return usage
+
+ def _get_hook(self, command):
+ hooks = self.events.on("received.command").on(command).get_hooks()
+ if hooks:
+ return hooks[0]
+ else:
+ return None
+
+ @utils.hook("received.command.help")
+ def help(self, event):
+ if event["args"]:
+ command = event["args_split"][0].lower()
+ hook = self._get_hook(command)
+
+ if hook == None:
+ raise utils.EventError("Unknown command '%s'" % command)
+ help = self._get_help(hook)
+ usage = self._get_usage(hook, command, event["command_prefix"])
+
+ out = help
+ if usage:
+ out += ". Usage: %s" % usage
+
+ if out:
+ event["stdout"].write("%s: %s" % (command, out))
+ else:
+ event["stderr"].write("No help for %s" % command)
+ else:
+ modules_command = utils.irc.bold(
+ "%smodules" % event["command_prefix"])
+ commands_command = utils.irc.bold(
+ "%scommands <module>" % event["command_prefix"])
+ help_command = utils.irc.bold(
+ "%shelp <command>" % event["command_prefix"])
+
+ event["stdout"].write("I'm %s. use '%s' to list modules, "
+ "'%s' to list commands and "
+ "'%s' to see help text for a command" %
+ (IRCBot.URL, modules_command, commands_command, help_command))
+
+ def _all_command_hooks(self):
+ all_hooks = {}
+ for child_name in self.events.on("received.command").get_children():
+ hooks = self.events.on("received.command").on(child_name
+ ).get_hooks()
+ if hooks:
+ all_hooks[child_name.lower()] = hooks[0]
+ return all_hooks
+
+ @utils.hook("received.command.modules")
+ def modules(self, event):
+ contexts = {}
+ for command, command_hook in self._all_command_hooks().items():
+ if not command_hook.context in contexts:
+ module = self.bot.modules.from_context(command_hook.context)
+ contexts[module.context] = module.name
+
+ modules_available = sorted(contexts.values())
+ event["stdout"].write("Modules: %s" % ", ".join(modules_available))
+
+ @utils.hook("received.command.commands", min_args=1)
+ def commands(self, event):
+ module_name = event["args_split"][0]
+ module = self.bot.modules.from_name(module_name)
+ if module == None:
+ raise utils.EventError("No such module '%s'" % module_name)
+
+ commands = []
+ for command, command_hook in self._all_command_hooks().items():
+ if command_hook.context == module.context:
+ commands.append(command)
+
+ event["stdout"].write("Commands for %s module: %s" % (
+ module.name, ", ".join(commands)))
+
+ @utils.hook("received.command.which")
+ @utils.kwarg("min_args", 1)
+ @utils.kwarg("help", "Find where a command is provided")
+ @utils.kwarg("usage", "<command>")
+ def which(self, event):
+ command = event["args_split"][0].lower()
+ hooks = self.events.on("received.command").on(command).get_hooks()
+ if not hooks:
+ raise utils.EventError("Unknown command '%s'" % command)
+
+ hook = hooks[0]
+ module = self.bot.modules.from_context(hook.context)
+ event["stdout"].write("%s%s is provided by %s.%s" % (
+ event["command_prefix"], command, module.name,
+ hook.function.__name__))
+
+ @utils.hook("received.command.apropos")
+ @utils.kwarg("min_args", 1)
+ @utils.kwarg("help", "Show commands with a given string in them")
+ @utils.kwarg("usage", "<query>")
+ def apropos(self, event):
+ query = event["args_split"][0]
+ query_lower = query.lower()
+
+ commands = []
+ for command, hook in self._all_command_hooks().items():
+ if query_lower in command.lower():
+ commands.append("%s%s" % (event["command_prefix"], command))
+ if commands:
+ event["stdout"].write("Apropos of '%s': %s" %
+ (query, ", ".join(commands)))
diff --git a/src/core_modules/ignore.py b/src/core_modules/ignore.py
new file mode 100644
index 00000000..11ad58f3
--- /dev/null
+++ b/src/core_modules/ignore.py
@@ -0,0 +1,163 @@
+#--depends-on commands
+#--depends-on permissions
+
+from src import EventManager, ModuleManager, utils
+
+class Module(ModuleManager.BaseModule):
+ def _user_ignored(self, user):
+ return user.get_setting("ignore", False)
+ def _user_command_ignored(self, user, command):
+ return user.get_setting("ignore-%s" % command, False)
+ def _user_channel_ignored(self, channel, user):
+ return channel.get_user_setting(user.get_id(), "ignore", False)
+ def _server_command_ignored(self, server, command):
+ return server.get_setting("ignore-%s" % command, False)
+
+ def _is_command_ignored(self, server, user, command):
+ if self._user_command_ignored(user, command):
+ return True
+ elif self._server_command_ignored(server, command):
+ return True
+
+ @utils.hook("received.message.private")
+ @utils.hook("received.message.channel")
+ @utils.hook("received.notice.private")
+ @utils.hook("received.notice.channel")
+ @utils.kwarg("priority", EventManager.PRIORITY_HIGH)
+ def message(self, event):
+ if self._user_ignored(event["user"]):
+ event.eat()
+ elif event["is_channel"] and self._user_channel_ignored(event["target"],
+ event["user"]):
+ event.eat()
+
+ @utils.hook("preprocess.command")
+ def preprocess_command(self, event):
+ if self._user_ignored(event["user"]):
+ return utils.consts.PERMISSION_HARD_FAIL, None
+ elif event["is_channel"] and self._user_channel_ignored(event["target"],
+ event["user"]):
+ return utils.consts.PERMISSION_HARD_FAIL, None
+ elif self._is_command_ignored(event["server"], event["user"],
+ event["command"]):
+ return utils.consts.PERMISSION_HARD_FAIL, None
+
+ @utils.hook("received.command.ignore", min_args=1)
+ def ignore(self, event):
+ """
+ :help: Ignore commands from a given user
+ :usage: <nickname> [command]
+ :permission: ignore
+ """
+ time, args = utils.parse.timed_args(event["args_split"], 1)
+
+ setting = "ignore"
+ for_str = ""
+ if len(args) > 1:
+ command = args[1].lower()
+ setting = "ignore-%s" % command
+ for_str = " for '%s'" % command
+
+ user = event["server"].get_user(args[0])
+ if user.get_setting(setting, False):
+ event["stderr"].write("I'm already ignoring '%s'%s" %
+ (user.nickname, for_str))
+ else:
+ user.set_setting(setting, True)
+ event["stdout"].write("Now ignoring '%s'%s" %
+ (user.nickname, for_str))
+
+ if not time == None:
+ self.timers.add_persistent("unignore", time,
+ user_id=user.get_id(), setting=setting)
+ @utils.hook("timer.unignore")
+ def _timer_unignore(self, event):
+ self.bot.database.user_settings.delete(
+ event["user_id"], event["setting"])
+
+ @utils.hook("received.command.unignore", min_args=1)
+ def unignore(self, event):
+ """
+ :help: Unignore commands from a given user
+ :usage: <nickname> [command]
+ :permission: unignore
+ """
+ setting = "ignore"
+ for_str = ""
+ if len(event["args_split"]) > 1:
+ command = event["args_split"][1].lower()
+ setting = "ignore-%s" % command
+ for_str = " for '%s'" % command
+
+ user = event["server"].get_user(event["args_split"][0])
+ if not user.get_setting(setting, False):
+ event["stderr"].write("I'm not ignoring '%s'%s" %
+ (user.nickname, for_str))
+ else:
+ user.del_setting(setting)
+ event["stdout"].write("Removed ignore for '%s'%s" %
+ (user.nickname, for_str))
+
+ @utils.hook("received.command.cignore",
+ help="Ignore a user in this channel")
+ @utils.hook("received.command.cunignore",
+ help="Unignore a user in this channel")
+ @utils.kwarg("channel_only", True)
+ @utils.kwarg("min_args", 1)
+ @utils.kwarg("usage", "<nickname>")
+ @utils.kwarg("permission", "cignore")
+ @utils.kwarg("require_mode", "o")
+ @utils.kwarg("require_access", "cignore")
+ def cignore(self, event):
+ remove = event["command"] == "cunignore"
+
+ target_user = event["server"].get_user(event["args_split"][0])
+ is_ignored = event["target"].get_user_setting(target_user.get_id(),
+ "ignore", False)
+
+ if remove:
+ if not is_ignored:
+ raise utils.EventError("I'm not ignoring %s in this channel" %
+ target_user.nickname)
+ event["target"].del_user_setting(target_user.get_id(), "ignore")
+ event["stdout"].write("Unignored %s" % target_user.nickname)
+ else:
+ if is_ignored:
+ raise utils.EventError("I'm already ignoring %s in this channel"
+ % target_user.nickname)
+ event["target"].set_user_setting(target_user.get_id(), "ignore",
+ True)
+ event["stdout"].write("Ignoring %s" % target_user.nickname)
+
+ @utils.hook("received.command.serverignore", min_args=1)
+ def server_ignore(self, event):
+ """
+ :permission: server-ignore
+ """
+ command = event["args_split"][0].lower()
+ setting = "ignore-%s" % command
+
+ if event["server"].get_setting(setting, False):
+ event["stderr"].write("I'm already ignoring '%s' for %s" %
+ (command, str(event["server"])))
+ else:
+ event["server"].set_setting(setting, True)
+ event["stdout"].write("Now ignoring '%s' for %s" %
+ (command, str(event["server"])))
+
+ @utils.hook("received.command.serverunignore", min_args=1)
+ def server_unignore(self, event):
+ """
+ :permission: server-unignore
+ """
+ command = event["args_split"][0].lower()
+ setting = "ignore-%s" % command
+
+ if not event["server"].get_setting(setting, False):
+ event["stderr"].write("I'm not ignoring '%s' for %s" %
+ (command, str(event["server"])))
+ else:
+ event["server"].del_setting(setting)
+ event["stdout"].write("No longer ignoring '%s' for %s" %
+ (command, str(event["server"])))
+
diff --git a/src/core_modules/ircv3_chathistory.py b/src/core_modules/ircv3_chathistory.py
new file mode 100644
index 00000000..e540673a
--- /dev/null
+++ b/src/core_modules/ircv3_chathistory.py
@@ -0,0 +1,36 @@
+#--depends-on ircv3_msgid
+
+from src import ModuleManager, utils
+
+TAG = utils.irc.MessageTag("msgid", "draft/msgid")
+CHATHISTORY_BATCH = utils.irc.BatchType("chathistory")
+
+EVENTPLAYBACK_CAP = utils.irc.Capability(None, "draft/event-playback",
+ alias="event-playback")
+HISTORY_BATCH = utils.irc.BatchType("history")
+
+@utils.export("cap", EVENTPLAYBACK_CAP)
+class Module(ModuleManager.BaseModule):
+ @utils.hook("received.batch.end")
+ def batch_end(self, event):
+ if (CHATHISTORY_BATCH.match(event["batch"].type) or
+ HISTORY_BATCH.match(event["batch"].type)):
+ target_name = event["batch"].args[0]
+ if target_name in event["server"].channels:
+ target = event["server"].channels.get(target_name)
+ else:
+ target = event["server"].get_user(target_name)
+
+ last_msgid = target.get_setting("last-msgid", None)
+ if not last_msgid == None:
+ lines = event["batch"].get_lines()
+ stop_index = -1
+
+ for i, line in enumerate(lines):
+ msgid = TAG.get_value(line.tags)
+ if msgid == last_msgid:
+ stop_index = i
+ break
+
+ if not stop_index == -1:
+ return lines[stop_index+1:]
diff --git a/src/core_modules/ircv3_echo_message.py b/src/core_modules/ircv3_echo_message.py
new file mode 100644
index 00000000..276ac554
--- /dev/null
+++ b/src/core_modules/ircv3_echo_message.py
@@ -0,0 +1,21 @@
+from src import EventManager, ModuleManager, utils
+
+CAP = utils.irc.Capability("echo-message", depends_on=["labeled-response"])
+
+@utils.export("cap", CAP)
+class Module(ModuleManager.BaseModule):
+ @utils.hook("raw.send.privmsg", priority=EventManager.PRIORITY_LOW)
+ @utils.hook("raw.send.notice", priority=EventManager.PRIORITY_LOW)
+ def send_message(self, event):
+ if event["server"].has_capability(CAP):
+ event.eat()
+
+ @utils.hook("preprocess.send.privmsg")
+ @utils.hook("preprocess.send.notice")
+ @utils.hook("preprocess.send.tagmsg")
+ def preprocess_send(self, event):
+ if event["server"].has_capability(CAP):
+ event["events"].on("labeled-response").hook(self.on_echo)
+
+ def on_echo(self, event):
+ event["responses"][0].id = event["line"].id
diff --git a/src/core_modules/ircv3_labeled_responses.py b/src/core_modules/ircv3_labeled_responses.py
new file mode 100644
index 00000000..7dd04b5c
--- /dev/null
+++ b/src/core_modules/ircv3_labeled_responses.py
@@ -0,0 +1,67 @@
+import uuid
+from src import ModuleManager, utils
+
+CAP = utils.irc.Capability(None, "draft/labeled-response-0.2",
+ alias="labeled-response", depends_on=["batch"])
+TAG = utils.irc.MessageTag(None, "draft/label")
+BATCH = utils.irc.BatchType(None, "draft/labeled-response")
+
+CAP_TO_TAG = {
+ "draft/labeled-response-0.2": "draft/label"
+}
+
+class WaitingForLabel(object):
+ def __init__(self, line, events):
+ self.line = line
+ self.events = events
+ self.labels_since = 0
+
+@utils.export("cap", CAP)
+class Module(ModuleManager.BaseModule):
+ @utils.hook("new.server")
+ def new_server(self, event):
+ event["server"]._label_cache = {}
+
+ @utils.hook("preprocess.send")
+ def raw_send(self, event):
+ available_cap = event["server"].available_capability(CAP)
+
+ if available_cap:
+ label = TAG.get_value(event["line"].tags)
+ if label == None:
+ tag_key = CAP_TO_TAG[available_cap]
+ label = str(uuid.uuid4())
+ event["line"].tags[tag_key] = label
+
+ event["server"]._label_cache[label] = WaitingForLabel(event["line"],
+ event["events"])
+
+ @utils.hook("raw.received")
+ def raw_recv(self, event):
+ if not event["line"].command == "BATCH":
+ label = TAG.get_value(event["line"].tags)
+ if not label == None:
+ self._recv(event["server"], label, [event["line"]])
+
+ @utils.hook("received.batch.end")
+ def batch_end(self, event):
+ if BATCH.match(event["batch"].type):
+ label = TAG.get_value(event["batch"].tags)
+ self._recv(event["server"], label, event["batch"].get_lines())
+
+ def _recv(self, server, label, lines):
+ if not label in server._label_cache:
+ self.log.debug("unknown label received on %s: %s",
+ [str(server), label])
+ return
+
+ cached = server._label_cache.pop(label)
+ cached.events.on("labeled-response").call(line=cached.line,
+ responses=lines)
+
+ for label, other_cached in server._label_cache.items():
+ other_cached.labels_since += 1
+ if other_cached.labels_since == 10:
+ self.log.debug(
+ "%d labels seen while waiting for response to %s on %s",
+ [other_cached.labels_since, label, str(server)])
diff --git a/src/core_modules/ircv3_message_tracking.py b/src/core_modules/ircv3_message_tracking.py
new file mode 100644
index 00000000..3f4ad88c
--- /dev/null
+++ b/src/core_modules/ircv3_message_tracking.py
@@ -0,0 +1,17 @@
+from src import ModuleManager, utils
+
+MSGID_TAG = "draft/msgid"
+READ_TAG = "+draft/read"
+DELIVERED_TAG = "+draft/delivered"
+MESSAGE_TAG_CAPS = set(["draft/message-tags-0.2", "message-tags"])
+
+class Module(ModuleManager.BaseModule):
+ @utils.hook("received.message.private")
+ @utils.hook("received.notice.private")
+ def privmsg(self, event):
+ if MSGID_TAG in event["tags"] and (
+ event["server"].agreed_capabilities & MESSAGE_TAG_CAPS):
+ target = event.get("channel", event["user"])
+ msgid = event["tags"][MSGID_TAG]
+ tags = {DELIVERED_TAG: msgid, READ_TAG: msgid}
+ target.send_tagmsg(tags)
diff --git a/src/core_modules/ircv3_metadata.py b/src/core_modules/ircv3_metadata.py
new file mode 100644
index 00000000..e0e6d387
--- /dev/null
+++ b/src/core_modules/ircv3_metadata.py
@@ -0,0 +1,16 @@
+from src import IRCBot, ModuleManager, utils
+
+CAP = utils.irc.Capability(None, "draft/metadata", alias="metadata")
+
+class Module(ModuleManager.BaseModule):
+ @utils.hook("received.cap.new")
+ @utils.hook("received.cap.ls")
+ def on_cap(self, event):
+ cap = CAP.copy()
+ cap.on_ack(lambda: self._ack(event["server"]))
+ return cap
+
+ def _ack(self, server):
+ url = self.bot.get_setting("bot-url", IRCBot.SOURCE)
+ server.send_raw("METADATA * SET bot BitBot")
+ server.send_raw("METADATA * SET homepage :%s" % url)
diff --git a/src/core_modules/ircv3_msgid.py b/src/core_modules/ircv3_msgid.py
new file mode 100644
index 00000000..f95f9fd4
--- /dev/null
+++ b/src/core_modules/ircv3_msgid.py
@@ -0,0 +1,31 @@
+from src import ModuleManager, utils
+
+TAG = utils.irc.MessageTag("msgid", "draft/msgid")
+
+class Module(ModuleManager.BaseModule):
+ def _on_channel(self, channel, tags):
+ msgid = TAG.get_value(tags)
+ if not msgid == None:
+ channel.set_setting("last-msgid", msgid)
+
+ @utils.hook("received.message.channel")
+ @utils.hook("send.message.channel")
+ @utils.hook("received.notice.channel")
+ @utils.hook("send.notice.channel")
+ @utils.hook("received.tagmsg.channel")
+ @utils.hook("send.tagmsg.channel")
+ def on_channel(self, event):
+ self._on_channel(event["channel"], event["tags"])
+
+ @utils.hook("received.ctcp.request")
+ @utils.hook("received.ctcp.response")
+ def ctcp(self, event):
+ if event["is_channel"]:
+ self._on_channel(event["target"], event["tags"])
+
+ @utils.hook("postprocess.command")
+ def postprocess_command(self, event):
+ msgid = TAG.get_value(event["line"].tags)
+ if msgid:
+ event["tags"]["+draft/reply"] = msgid
+ event["tags"]["+draft/reply"] = msgid
diff --git a/src/core_modules/ircv3_sasl/README.md b/src/core_modules/ircv3_sasl/README.md
new file mode 100644
index 00000000..30a51e08
--- /dev/null
+++ b/src/core_modules/ircv3_sasl/README.md
@@ -0,0 +1,46 @@
+# Configuring SASL
+
+You can either configure SASL through `!serverset sasl` from an registered and identified admin account or directly through sqlite.
+
+## USERPASS Mechanism
+
+BitBot supports a special SASL mechanism name: `USERPASS`. This internally
+represents "pick the strongest username:password algorithm"
+
+## !serverset sasl
+
+These commands are to be executed from a registered admin account
+
+#### USERPASS
+> !serverset sasl userpass &lt;username>:&lt;password>
+
+#### PLAIN
+> !serverset sasl plain &lt;username>:&lt;password>
+
+#### SCRAM-SHA-1
+> !serverset sasl scram-sha-1 &lt;username>:&lt;password>
+
+#### SCRAM-SHA-256
+> !serverset sasl scram-sha-256 &lt;username>:&lt;password>
+
+#### EXTERNAL
+> !serverset sasl external
+
+## sqlite
+
+Execute these against the current bot database file (e.g. `$ sqlite3 databases/bot.db`)
+
+#### USERPASS
+> INSERT INTO server_settings (&lt;serverid>, 'sasl', '{"mechanism": "userpass", "args": "&lt;username>:&lt;password>"}');
+
+#### PLAIN
+> INSERT INTO server_settings (&lt;serverid>, 'sasl', '{"mechanism": "plain", "args": "&lt;username>:&lt;password>"}');
+
+#### SCRAM-SHA-1
+> INSERT INTO server_settings (&lt;serverid>, 'sasl', '{"mechanism": "scram-sha-1", "args": "&lt;username>:&lt;password>"}');
+
+#### SCRAM-SHA-256
+> INSERT INTO server_settings (&lt;serverid>, 'sasl', '{"mechanism": "scram-sha-256", "args": "&lt;username>:&lt;password>"}');
+
+#### external
+> INSERT INTO server_settings (&lt;serverid>, 'sasl', '{"mechanism": "external"}');
diff --git a/src/core_modules/ircv3_sasl/__init__.py b/src/core_modules/ircv3_sasl/__init__.py
new file mode 100644
index 00000000..9f7fac5f
--- /dev/null
+++ b/src/core_modules/ircv3_sasl/__init__.py
@@ -0,0 +1,195 @@
+#--depends-on config
+
+import base64, hashlib, hmac, typing, uuid
+from src import ModuleManager, utils
+from . import scram
+
+CAP = utils.irc.Capability("sasl")
+
+USERPASS_MECHANISMS = [
+ "SCRAM-SHA-512",
+ "SCRAM-SHA-256",
+ "SCRAM-SHA-1",
+ "PLAIN"
+]
+ALL_MECHANISMS = USERPASS_MECHANISMS+["EXTERNAL"]
+
+def _parse(value):
+ mechanism, _, arguments = value.partition(" ")
+ mechanism = mechanism.upper()
+
+ if mechanism in ALL_MECHANISMS:
+ return {"mechanism": mechanism.upper(), "args": arguments}
+ else:
+ raise utils.settings.SettingParseException(
+ "Unknown SASL mechanism '%s'" % mechanism)
+
+SASL_TIMEOUT = 15 # 15 seconds
+
+HARDFAIL = utils.BoolSetting("sasl-hard-fail",
+ "Set whether a SASL failure should cause a disconnect")
+
+@utils.export("serverset", utils.FunctionSetting(_parse, "sasl",
+ "Set the sasl username/password for this server",
+ example="PLAIN BitBot:hunter2", format=utils.sensitive_format))
+@utils.export("serverset", HARDFAIL)
+@utils.export("botset", HARDFAIL)
+class Module(ModuleManager.BaseModule):
+ @utils.hook("new.server")
+ def new_server(self, event):
+ event["server"]._sasl_timeout = None
+ event["server"]._sasl_retry = False
+
+ def _best_userpass_mechanism(self, mechanisms):
+ for potential_mechanism in USERPASS_MECHANISMS:
+ if potential_mechanism in mechanisms:
+ return potential_mechanism
+
+ def _mech_match(self, server, server_mechanisms):
+ our_sasl = server.get_setting("sasl", None)
+ if not our_sasl:
+ return None
+
+ our_mechanism = our_sasl["mechanism"].upper()
+
+ if not server_mechanisms and our_mechanism in ALL_MECHANISMS:
+ return our_mechanism
+ elif our_mechanism in server_mechanisms:
+ return our_mechanism
+ elif our_mechanism == "USERPASS":
+ if server_mechanisms:
+ return self._best_userpass_mechanism(server_mechanisms)
+ else:
+ return USERPASS_MECHANISMS[0]
+ return None
+
+ @utils.hook("received.cap.new")
+ @utils.hook("received.cap.ls")
+ def on_cap(self, event):
+ has_sasl = "sasl" in event["capabilities"]
+ if has_sasl:
+ server_mechanisms = event["capabilities"]["sasl"]
+ if server_mechanisms:
+ server_mechanisms = server_mechanisms.split(",")
+ else:
+ server_mechanisms = []
+
+ mechanism = self._mech_match(event["server"], server_mechanisms)
+
+ if mechanism:
+ cap = CAP.copy()
+ cap.on_ack(
+ lambda: self._sasl_ack(event["server"], mechanism))
+ return cap
+
+ def _sasl_ack(self, server, mechanism):
+ server.send_authenticate(mechanism)
+ server._sasl_timeout = self.timers.add("sasl-timeout",
+ self._sasl_timeout, SASL_TIMEOUT, server=server)
+ server._sasl_mechanism = mechanism
+
+ server.wait_for_capability("sasl")
+
+ def _sasl_timeout(self, timer):
+ server = timer.kwargs["server"]
+ self._panic(server, "SASL handshake timed out")
+
+ @utils.hook("received.authenticate")
+ def on_authenticate(self, event):
+ sasl = event["server"].get_setting("sasl")
+ mechanism = event["server"]._sasl_mechanism
+
+ auth_text = None
+ if mechanism == "PLAIN":
+ if event["message"] != "+":
+ event["server"].send_authenticate("*")
+ else:
+ sasl_username, sasl_password = sasl["args"].split(":", 1)
+ auth_text = ("%s\0%s\0%s" % (
+ sasl_username, sasl_username, sasl_password)).encode("utf8")
+
+ elif mechanism == "EXTERNAL":
+ if event["message"] != "+":
+ event["server"].send_authenticate("*")
+ else:
+ auth_text = "+"
+
+ elif mechanism.startswith("SCRAM-"):
+
+ if event["message"] == "+":
+ # start SCRAM handshake
+
+ # create SCRAM helper
+ sasl_username, sasl_password = sasl["args"].split(":", 1)
+ algo = mechanism.split("SCRAM-", 1)[1]
+ event["server"]._scram = scram.SCRAM(
+ algo, sasl_username, sasl_password)
+
+ # generate client-first-message
+ auth_text = event["server"]._scram.client_first()
+ else:
+ current_scram = event["server"]._scram
+ data = base64.b64decode(event["message"])
+ if current_scram.state == scram.SCRAMState.ClientFirst:
+ # use server-first-message to generate client-final-message
+ auth_text = current_scram.server_first(data)
+ elif current_scram.state == scram.SCRAMState.ClientFinal:
+ # use server-final-message to check server proof
+ verified = current_scram.server_final(data)
+ del event["server"]._scram
+
+ if verified:
+ auth_text = "+"
+ else:
+ if current_scram.state == scram.SCRAMState.VerifyFailed:
+ # server gave a bad verification so we should panic
+ self._panic(event["server"], "SCRAM VerifyFailed")
+
+ else:
+ raise ValueError("unknown sasl mechanism '%s'" % mechanism)
+
+ if not auth_text == None:
+ if not auth_text == "+":
+ auth_text = base64.b64encode(auth_text)
+ auth_text = auth_text.decode("utf8")
+ event["server"].send_authenticate(auth_text)
+
+ def _end_sasl(self, server):
+ server.capability_done("sasl")
+ if not server._sasl_timeout == None:
+ server._sasl_timeout.cancel()
+ server._sasl_timeout = None
+
+ @utils.hook("received.908")
+ def sasl_mechanisms(self, event):
+ server_mechanisms = event["line"].args[1].split(",")
+ mechanism = self._mech_match(event["server"], server_mechanisms)
+ if mechanism:
+ event["server"]._sasl_mechanism = mechanism
+ event["server"].send_authenticate(mechanism)
+ event["server"]._sasl_retry = True
+
+ @utils.hook("received.903")
+ def sasl_success(self, event):
+ self._end_sasl(event["server"])
+ @utils.hook("received.904")
+ def sasl_failure(self, event):
+ if not event["server"]._sasl_retry:
+ self._panic(event["server"], "ERR_SASLFAIL (%s)" %
+ event["line"].args[1])
+ else:
+ event["server"]._sasl_retry = False
+
+ @utils.hook("received.907")
+ def sasl_already(self, event):
+ self._end_sasl(event["server"])
+
+ def _panic(self, server, message):
+ if server.get_setting("sasl-hard-fail",
+ self.bot.get_setting("sasl-hard-fail", False)):
+ message = "SASL panic for %s: %s" % (str(server), message)
+ self.log.error(message)
+ self.bot.disconnect(server)
+ else:
+ self.log.warn("SASL failure for %s: %s" % (str(server), message))
+ self._end_sasl(server)
diff --git a/src/core_modules/ircv3_sasl/scram.py b/src/core_modules/ircv3_sasl/scram.py
new file mode 100644
index 00000000..f243d1e6
--- /dev/null
+++ b/src/core_modules/ircv3_sasl/scram.py
@@ -0,0 +1,130 @@
+import base64, enum, hashlib, hmac, os, typing
+
+# IANA Hash Function Textual Names
+# https://tools.ietf.org/html/rfc5802#section-4
+# https://www.iana.org/assignments/hash-function-text-names/
+# MD2 has been removed as it's unacceptably weak
+ALGORITHMS = [
+ "MD5", "SHA-1", "SHA-224", "SHA-256", "SHA-384", "SHA-512"]
+
+SCRAM_ERRORS = [
+ "invalid-encoding",
+ "extensions-not-supported", # unrecognized 'm' value
+ "invalid-proof",
+ "channel-bindings-dont-match",
+ "server-does-support-channel-binding",
+ "channel-binding-not-supported",
+ "unsupported-channel-binding-type",
+ "unknown-user",
+ "invalid-username-encoding", # invalid utf8 or bad SASLprep
+ "no-resources"
+]
+
+def _scram_nonce() -> bytes:
+ return base64.b64encode(os.urandom(32))
+def _scram_escape(s: bytes) -> bytes:
+ return s.replace(b"=", b"=3D").replace(b",", b"=2C")
+def _scram_unescape(s: bytes) -> bytes:
+ return s.replace(b"=3D", b"=").replace(b"=2C", b",")
+def _scram_xor(s1: bytes, s2: bytes) -> bytes:
+ return bytes(a ^ b for a, b in zip(s1, s2))
+
+class SCRAMState(enum.Enum):
+ Uninitialised = 0
+ ClientFirst = 1
+ ClientFinal = 2
+ Success = 3
+ Failed = 4
+ VerifyFailed = 5
+
+class SCRAMError(Exception):
+ pass
+
+class SCRAM(object):
+ def __init__(self, algo: str, username: str, password: str):
+ if not algo in ALGORITHMS:
+ raise ValueError("Unknown SCRAM algorithm '%s'" % algo)
+
+ self._algo = algo.replace("-", "") # SHA-1 -> SHA1
+ self._username = username.encode("utf8")
+ self._password = password.encode("utf8")
+
+ self.state = SCRAMState.Uninitialised
+ self.error = ""
+ self.raw_error = ""
+
+ self._client_first = b""
+ self._salted_password = b""
+ self._auth_message = b""
+
+ def _get_pieces(self, data: bytes) -> typing.Dict[bytes, bytes]:
+ pieces = (piece.split(b"=", 1) for piece in data.split(b","))
+ return dict((piece[0], piece[1]) for piece in pieces)
+
+ def _hmac(self, key: bytes, msg: bytes) -> bytes:
+ return hmac.new(key, msg, self._algo).digest()
+ def _hash(self, msg: bytes) -> bytes:
+ return hashlib.new(self._algo, msg).digest()
+
+ def _constant_time_compare(self, b1: bytes, b2: bytes):
+ return hmac.compare_digest(b1, b2)
+
+ def client_first(self) -> bytes:
+ self.state = SCRAMState.ClientFirst
+ self._client_first = b"n=%s,r=%s" % (
+ _scram_escape(self._username), _scram_nonce())
+
+ # n,,n=<username>,r=<nonce>
+ return b"n,,%s" % self._client_first
+
+ def server_first(self, data: bytes) -> bytes:
+ self.state = SCRAMState.ClientFinal
+
+ pieces = self._get_pieces(data)
+ nonce = pieces[b"r"] # server combines your nonce with it's own
+ salt = base64.b64decode(pieces[b"s"]) # salt is b64encoded
+ iterations = int(pieces[b"i"])
+
+ salted_password = hashlib.pbkdf2_hmac(self._algo, self._password,
+ salt, iterations, dklen=None)
+ self._salted_password = salted_password
+
+ client_key = self._hmac(salted_password, b"Client Key")
+ stored_key = self._hash(client_key)
+
+ channel = base64.b64encode(b"n,,")
+ auth_noproof = b"c=%s,r=%s" % (channel, nonce)
+ auth_message = b"%s,%s,%s" % (self._client_first, data, auth_noproof)
+ self._auth_message = auth_message
+
+ client_signature = self._hmac(stored_key, auth_message)
+ client_proof_xor = _scram_xor(client_key, client_signature)
+ client_proof = base64.b64encode(client_proof_xor)
+
+ # c=<b64encode("n,,")>,r=<nonce>,p=<proof>
+ return b"%s,p=%s" % (auth_noproof, client_proof)
+
+ def server_final(self, data: bytes) -> bool:
+ pieces = self._get_pieces(data)
+ if b"e" in pieces:
+ error = pieces[b"e"].decode("utf8")
+ self.raw_error = error
+ if error in SCRAM_ERRORS:
+ self.error = error
+ else:
+ self.error = "other-error"
+
+ self.state = SCRAMState.Failed
+ return False
+
+ verifier = base64.b64decode(pieces[b"v"])
+
+ server_key = self._hmac(self._salted_password, b"Server Key")
+ server_signature = self._hmac(server_key, self._auth_message)
+
+ if server_signature == verifier:
+ self.state = SCRAMState.Success
+ return True
+ else:
+ self.state = SCRAMState.VerifyFailed
+ return False
diff --git a/src/core_modules/ircv3_server_time.py b/src/core_modules/ircv3_server_time.py
new file mode 100644
index 00000000..c9790d95
--- /dev/null
+++ b/src/core_modules/ircv3_server_time.py
@@ -0,0 +1,12 @@
+from src import ModuleManager, utils
+
+CAP = utils.irc.Capability("server-time")
+TAG = utils.irc.MessageTag("time")
+
+@utils.export("cap", CAP)
+class Module(ModuleManager.BaseModule):
+ @utils.hook("raw.received")
+ def raw_recv(self, event):
+ server_time = TAG.get_value(event["line"].tags)
+ if not server_time == None:
+ event["server"].set_setting("last-server-time", server_time)
diff --git a/src/core_modules/ircv3_sts.py b/src/core_modules/ircv3_sts.py
new file mode 100644
index 00000000..aeeac1f1
--- /dev/null
+++ b/src/core_modules/ircv3_sts.py
@@ -0,0 +1,70 @@
+import time
+from src import ModuleManager, utils
+
+CAP = utils.irc.Capability("sts", "draft/sts")
+
+class Module(ModuleManager.BaseModule):
+ def _get_policy(self, server):
+ return server.get_setting("sts-policy", None)
+ def _set_policy(self, server, policy):
+ self.log.info("Setting STS policy for '%s': %s", [str(server), policy])
+ server.set_setting("sts-policy", policy)
+ def _remove_policy(self, server):
+ server.del_setting("sts-policy")
+
+ def set_policy(self, server, port, duration):
+ expiration = None
+ self._set_policy(server, {
+ "port": port,
+ "from": time.time(),
+ "duration": duration})
+ def change_duration(self, server, info):
+ duration = int(info["duration"])
+ if duration == 0:
+ self._remove_policy(server)
+ else:
+ port = server.connection_params.port
+ if "port" in info:
+ port = int(info["port"])
+ self.set_policy(server, port, duration)
+
+ @utils.hook("received.cap.ls")
+ def on_cap_ls(self, event):
+ sts = CAP.available(event["capabilities"])
+ if sts:
+ info = utils.parse.keyvalue(event["capabilities"][sts],
+ delimiter=",")
+ if not event["server"].connection_params.tls:
+ if "port" in info:
+ self.set_policy(event["server"], int(info["port"]), None)
+ event["server"].disconnect()
+ self.bot.reconnect(event["server"].id,
+ event["server"].connection_params)
+ else:
+ self.change_duration(event["server"], info)
+
+ @utils.hook("received.cap.new")
+ def on_cap_new(self, event):
+ if CAP.available(event["capabilities"]
+ ) and event["server"].connection_params.tls:
+ info = utils.parse.keyvalue(sts, delimiter=",")
+ self.change_duration(event["server"], info)
+
+ @utils.hook("new.server")
+ def new_server(self, event):
+ sts_policy = self._get_policy(event["server"])
+ if sts_policy:
+ if not event["server"].connection_params.tls:
+ if not sts_policy["duration"] or time.time() <= (
+ sts_policy["from"]+sts_policy["duration"]):
+ self.log.info("Applying STS policy for '%s'",
+ [str(event["server"])])
+ event["server"].connection_params.tls = True
+ event["server"].connection_params.port = sts_policy["port"]
+
+ @utils.hook("server.disconnect")
+ def on_disconnect(self, event):
+ sts_policy = self._get_policy(event["server"])
+ if sts_policy and sts_policy["duration"]:
+ sts_policy["from"] = time.time()
+ self._set_policy(event["server"], sts_policy)
diff --git a/src/core_modules/line_handler/__init__.py b/src/core_modules/line_handler/__init__.py
new file mode 100644
index 00000000..ddea6fdc
--- /dev/null
+++ b/src/core_modules/line_handler/__init__.py
@@ -0,0 +1,260 @@
+import enum
+from src import EventManager, IRCLine, ModuleManager, utils
+from . import channel, core, ircv3, message, user
+
+class Module(ModuleManager.BaseModule):
+ def _handle(self, server, line):
+ hooks = self.events.on("raw.received").on(line.command).get_hooks()
+ default_events = []
+ for hook in hooks:
+ default_events.append(hook.get_kwarg("default_event", False))
+
+ kwargs = {"server": server, "line": line,
+ "direction": utils.Direction.Recv}
+
+ self.events.on("raw.received").on(line.command).call_unsafe(**kwargs)
+ if any(default_events) or not hooks:
+ self.events.on("received").on(line.command).call(**kwargs)
+
+ @utils.hook("raw.received")
+ def handle_raw(self, event):
+ if ("batch" in event["line"].tags and
+ event["line"].tags["batch"] in event["server"].batches):
+ event["server"].batches[event["line"].tags["batch"]].add_line(
+ event["line"])
+ else:
+ self._handle(event["server"], event["line"])
+
+ @utils.hook("raw.send")
+ def handle_send(self, event):
+ self.events.on("raw.send").on(event["line"].command).call_unsafe(
+ server=event["server"], direction=utils.Direction.Send,
+ line=event["line"])
+
+ # ping from the server
+ @utils.hook("raw.received.ping")
+ def ping(self, event):
+ core.ping(event)
+
+ @utils.hook("raw.received.error")
+ def error(self, event):
+ self.log.error("ERROR received from %s: %s",
+ [str(event["server"]), event["line"].args[0]])
+ @utils.hook("raw.received.fail")
+ def fail(self, event):
+ command = event["line"].args[0]
+ error_code = event["line"].args[1]
+ context = event["line"].args[2:-1]
+ description = event["line"].args[-1]
+
+ self.log.warn("FAIL (%s %s) received on %s: %s",
+ [command, error_code, str(event["server"]), description])
+ self.events.on("received.fail").on(command).call(error_code=error_code,
+ context=context, description=description, server=event["server"])
+
+ # first numeric line the server sends
+ @utils.hook("raw.received.001", default_event=True)
+ def handle_001(self, event):
+ core.handle_001(event)
+
+ # server telling us what it supports
+ @utils.hook("raw.received.005")
+ def handle_005(self, event):
+ core.handle_005(self.events, event)
+
+ # RPL_MYINFO
+ @utils.hook("raw.received.004")
+ def handle_004(self, event):
+ core.handle_004(event)
+
+ # whois respose (nickname, username, realname, hostname)
+ @utils.hook("raw.received.311", default_event=True)
+ def handle_311(self, event):
+ user.handle_311(event)
+
+ # on-join channel topic line
+ @utils.hook("raw.received.332")
+ def handle_332(self, event):
+ channel.handle_332(self.events, event)
+
+ # channel topic changed
+ @utils.hook("raw.received.topic")
+ def topic(self, event):
+ channel.topic(self.events, event)
+
+ # on-join channel topic set by/at
+ @utils.hook("raw.received.333")
+ def handle_333(self, event):
+ channel.handle_333(self.events, event)
+
+ # /names response, also on-join user list
+ @utils.hook("raw.received.353", default_event=True)
+ def handle_353(self, event):
+ channel.handle_353(event)
+
+ # on-join user list has finished
+ @utils.hook("raw.received.366", default_event=True)
+ def handle_366(self, event):
+ channel.handle_366(event)
+
+ @utils.hook("raw.received.375", priority=EventManager.PRIORITY_HIGH)
+ def motd_start(self, event):
+ core.motd_start(event)
+
+ @utils.hook("raw.received.372")
+ @utils.hook("raw.received.375")
+ def motd_line(self, event):
+ core.motd_line(event)
+
+ # on user joining channel
+ @utils.hook("raw.received.join")
+ def join(self, event):
+ channel.join(self.events, event)
+
+ # on user parting channel
+ @utils.hook("raw.received.part")
+ def part(self, event):
+ channel.part(self.events, event)
+
+ # unknown command sent by us, oops!
+ @utils.hook("raw.received.421", default_event=True)
+ def handle_421(self, event):
+ self.bot.log.warn("We sent an unknown command to %s: %s",
+ [str(event["server"]), event["line"].args[1]])
+
+ # a user has disconnected!
+ @utils.hook("raw.received.quit")
+ @utils.hook("raw.send.quit")
+ def quit(self, event):
+ user.quit(self.events, event)
+
+ # the server is telling us about its capabilities!
+ @utils.hook("raw.received.cap")
+ def cap(self, event):
+ ircv3.cap(self.exports, self.events, event)
+
+ # the server is asking for authentication
+ @utils.hook("raw.received.authenticate")
+ def authenticate(self, event):
+ ircv3.authenticate(self.events, event)
+
+ # someone has changed their nickname
+ @utils.hook("raw.received.nick")
+ def nick(self, event):
+ user.nick(self.events, event)
+
+ # something's mode has changed
+ @utils.hook("raw.received.mode")
+ def mode(self, event):
+ core.mode(self.events, event)
+ # server telling us our own modes
+ @utils.hook("raw.received.221")
+ def umodeis(self, event):
+ core.handle_221(event)
+
+ # someone (maybe me!) has been invited somewhere
+ @utils.hook("raw.received.invite")
+ def invite(self, event):
+ core.invite(self.events, event)
+
+ # we've received/sent a PRIVMSG, NOTICE or TAGMSG
+ @utils.hook("raw.received.privmsg")
+ @utils.hook("raw.received.notice")
+ @utils.hook("raw.received.tagmsg")
+ def message(self, event):
+ message.message(self.events, event)
+
+ # IRCv3 AWAY, used to notify us that a client we can see has changed /away
+ @utils.hook("raw.received.away")
+ def away(self, event):
+ user.away(self.events, event)
+
+ @utils.hook("raw.received.batch")
+ def batch(self, event):
+ identifier = event["line"].args[0]
+ modifier, identifier = identifier[0], identifier[1:]
+
+ if modifier == "+":
+ batch_type = event["line"].args[1]
+ args = event["line"].args[2:]
+
+ batch = IRCLine.IRCBatch(identifier, batch_type, args,
+ event["line"].tags, source=event["line"].source)
+ event["server"].batches[identifier] = batch
+
+ self.events.on("received.batch.start").call(batch=batch,
+ server=event["server"])
+ else:
+ batch = event["server"].batches[identifier]
+ del event["server"].batches[identifier]
+
+ lines = batch.get_lines()
+
+ results = self.events.on("received.batch.end").call(batch=batch,
+ server=event["server"])
+
+ for result in results:
+ if not result == None:
+ lines = result
+ break
+
+ for line in lines:
+ self._handle(event["server"], line)
+
+ # IRCv3 CHGHOST, a user's username and/or hostname has changed
+ @utils.hook("raw.received.chghost")
+ def chghost(self, event):
+ user.chghost(self.events, event)
+
+ # IRCv3 SETNAME, to change a user's realname
+ @utils.hook("raw.received.setname")
+ def setname(self, event):
+ user.setname(event)
+
+ @utils.hook("raw.received.account")
+ def account(self, event):
+ user.account(self.events, event)
+
+ # response to a WHO command for user information
+ @utils.hook("raw.received.352", default_event=True)
+ def handle_352(self, event):
+ core.handle_352(self.events, event)
+
+ # response to a WHOX command for user information, including account name
+ @utils.hook("raw.received.354", default_event=True)
+ def handle_354(self, event):
+ core.handle_354(self.events, event)
+
+ # response to an empty mode command
+ @utils.hook("raw.received.324")
+ def handle_324(self, event):
+ channel.handle_324(self.events, event)
+
+ # channel creation unix timestamp
+ @utils.hook("raw.received.329", default_event=True)
+ def handle_329(self, event):
+ channel.handle_329(event)
+
+ # nickname already in use
+ @utils.hook("raw.received.433", default_event=True)
+ def handle_433(self, event):
+ core.handle_433(event)
+ # nickname/channel is temporarily unavailable
+ @utils.hook("raw.received.437")
+ def handle_437(self, event):
+ core.handle_437(event)
+
+ # we need a registered nickname for this channel
+ @utils.hook("raw.received.477", default_event=True)
+ def handle_477(self, event):
+ channel.handle_477(self.timers, event)
+
+ # someone's been kicked from a channel
+ @utils.hook("raw.received.kick")
+ def kick(self, event):
+ channel.kick(self.events, event)
+
+ # a channel has been renamed
+ @utils.hook("raw.received.rename")
+ def rename(self, event):
+ channel.rename(self.events, event)
diff --git a/src/core_modules/line_handler/channel.py b/src/core_modules/line_handler/channel.py
new file mode 100644
index 00000000..91150839
--- /dev/null
+++ b/src/core_modules/line_handler/channel.py
@@ -0,0 +1,160 @@
+from src import IRCLine, utils
+
+def handle_332(events, event):
+ channel = event["server"].channels.get(event["line"].args[1])
+ topic = event["line"].args.get(2)
+ channel.set_topic(topic)
+ events.on("received.332").call(channel=channel, server=event["server"],
+ topic=topic)
+
+def topic(events, event):
+ user = event["server"].get_user(event["line"].source.nickname)
+ channel = event["server"].channels.get(event["line"].args[0])
+ topic = event["line"].args.get(1)
+ channel.set_topic(topic)
+ events.on("received.topic").call(channel=channel, server=event["server"],
+ topic=topic, user=user)
+
+def handle_333(events, event):
+ channel = event["server"].channels.get(event["line"].args[1])
+
+ topic_setter = IRCLine.parse_hostmask(event["line"].args[2])
+ topic_time = int(event["line"].args[3])
+
+ channel.set_topic_setter(topic_setter)
+ channel.set_topic_time(topic_time)
+ events.on("received.333").call(channel=channel,
+ setter=topic_setter, set_at=topic_time, server=event["server"])
+
+def handle_353(event):
+ channel = event["server"].channels.get(event["line"].args[2])
+ nicknames = event["line"].args.get(3).split(" ")
+
+ # there can sometimes be a dangling space at the end of a 353
+ if nicknames and not nicknames[-1]:
+ nicknames.pop(-1)
+
+ for nickname in nicknames:
+ modes = set([])
+
+ while nickname[0] in event["server"].prefix_symbols:
+ modes.add(event["server"].prefix_symbols[nickname[0]])
+ nickname = nickname[1:]
+
+ if event["server"].has_capability_str("userhost-in-names"):
+ hostmask = IRCLine.parse_hostmask(nickname)
+ nickname = hostmask.nickname
+ user = event["server"].get_user(hostmask.nickname,
+ username=hostmask.username, hostname=hostmask.hostname)
+ else:
+ user = event["server"].get_user(nickname)
+ user.join_channel(channel)
+ channel.add_user(user)
+
+ for mode in modes:
+ channel.add_mode(mode, nickname)
+
+def handle_366(event):
+ event["server"].send_whox(event["line"].args[1], "n", "ahnrtu", "111")
+
+def join(events, event):
+ account = None
+ realname = None
+ channel_name = event["line"].args[0]
+
+ if len(event["line"].args) == 3:
+ if not event["line"].args[1] == "*":
+ account = event["line"].args[1]
+ realname = event["line"].args[2]
+
+ user = event["server"].get_user(event["line"].source.nickname,
+ username=event["line"].source.username,
+ hostname=event["line"].source.hostname)
+
+ if account:
+ user.account = account
+ if realname:
+ user.realname = realname
+
+ is_self = event["server"].is_own_nickname(event["line"].source.nickname)
+ if is_self:
+ channel = event["server"].channels.add(channel_name)
+ else:
+ channel = event["server"].channels.get(channel_name)
+
+
+ channel.add_user(user)
+ user.join_channel(channel)
+
+ if is_self:
+ events.on("self.join").call(channel=channel, server=event["server"],
+ account=account, realname=realname)
+ channel.send_mode()
+ else:
+ events.on("received.join").call(channel=channel, user=user,
+ server=event["server"], account=account, realname=realname)
+
+def part(events, event):
+ channel = event["server"].channels.get(event["line"].args[0])
+ user = event["server"].get_user(event["line"].source.nickname)
+ reason = event["line"].args.get(1)
+
+ channel.remove_user(user)
+ user.part_channel(channel)
+ if not len(user.channels):
+ event["server"].remove_user(user)
+
+ if not event["server"].is_own_nickname(event["line"].source.nickname):
+ events.on("received.part").call(channel=channel, reason=reason,
+ user=user, server=event["server"])
+ else:
+ event["server"].channels.remove(channel)
+ events.on("self.part").call(channel=channel, reason=reason,
+ server=event["server"])
+
+def handle_324(events, event):
+ if event["line"].args[1] in event["server"].channels:
+ channel = event["server"].channels.get(event["line"].args[1])
+ modes = event["line"].args[2]
+ args = event["line"].args[3:]
+ new_modes = channel.parse_modes(modes, args[:])
+ events.on("received.324").call(modes=new_modes,
+ channel=channel, server=event["server"], mode_str=modes,
+ args_str=args)
+
+def handle_329(event):
+ channel = event["server"].channels.get(event["line"].args[1])
+ channel.creation_timestamp = int(event["line"].args[2])
+
+def handle_477(timers, event):
+ pass
+
+def kick(events, event):
+ user = event["server"].get_user(event["line"].source.nickname)
+ target = event["line"].args[1]
+ channel = event["server"].channels.get(event["line"].args[0])
+ reason = event["line"].args.get(2)
+ target_user = event["server"].get_user(target)
+
+ if not event["server"].is_own_nickname(target):
+ events.on("received.kick").call(channel=channel, reason=reason,
+ target_user=target_user, user=user, server=event["server"])
+ else:
+ event["server"].channels.remove(channel)
+ events.on("self.kick").call(channel=channel, reason=reason, user=user,
+ server=event["server"])
+
+ channel.remove_user(target_user)
+ target_user.part_channel(channel)
+ if not len(target_user.channels):
+ event["server"].remove_user(target_user)
+
+def rename(events, event):
+ old_name = event["line"].args[0]
+ new_name = event["line"].args[1]
+ channel = event["server"].channels.get(old_name)
+
+ event["server"].channels.rename(old_name, new_name)
+ events.on("received.rename").call(channel=channel, old_name=old_name,
+ new_name=new_name, reason=event["line"].args.get(2),
+ server=event["server"])
diff --git a/src/core_modules/line_handler/core.py b/src/core_modules/line_handler/core.py
new file mode 100644
index 00000000..d72bf223
--- /dev/null
+++ b/src/core_modules/line_handler/core.py
@@ -0,0 +1,154 @@
+import codecs, re
+
+RE_ISUPPORT_ESCAPE = re.compile(r"\\x(\d\d)", re.I)
+RE_MODES = re.compile(r"[-+]\w+")
+
+def ping(event):
+ event["server"].send_pong(event["line"].args[0])
+
+def handle_001(event):
+ event["server"].socket.enable_write_throttle()
+ event["server"].name = event["line"].source.hostmask
+ event["server"].set_own_nickname(event["line"].args[0])
+ event["server"].send_whois(event["server"].nickname)
+ event["server"].send_mode(event["server"].nickname)
+ event["server"].connected = True
+
+def handle_005(events, event):
+ isupport_list = event["line"].args[1:-1]
+ isupport = {}
+
+ for i, item in enumerate(isupport_list):
+ key, sep, value = item.partition("=")
+ if value:
+ for match in RE_ISUPPORT_ESCAPE.finditer(value):
+ char = codecs.decode(match.group(1), "hex").decode("ascii")
+ value.replace(match.group(0), char)
+
+ if sep:
+ isupport[key] = value
+ else:
+ isupport[key] = None
+ event["server"].isupport.update(isupport)
+
+ if "NAMESX" in isupport and not event["server"].has_capability_str(
+ "multi-prefix"):
+ event["server"].send_raw("PROTOCTL NAMESX")
+
+ if "PREFIX" in isupport:
+ modes, symbols = isupport["PREFIX"][1:].split(")", 1)
+ event["server"].prefix_symbols.clear()
+ event["server"].prefix_modes.clear()
+ for symbol, mode in zip(symbols, modes):
+ event["server"].prefix_symbols[symbol] = mode
+ event["server"].prefix_modes[mode] = symbol
+
+ if "CHANMODES" in isupport:
+ modes = isupport["CHANMODES"].split(",", 3)
+ event["server"].channel_list_modes = list(modes[0])
+ event["server"].channel_parametered_modes = list(modes[1])
+ event["server"].channel_setting_modes = list(modes[2])
+ event["server"].channel_modes = list(modes[3])
+ if "CHANTYPES" in isupport:
+ event["server"].channel_types = list(isupport["CHANTYPES"])
+ if "CASEMAPPING" in isupport:
+ event["server"].case_mapping = isupport["CASEMAPPING"]
+ if "STATUSMSG" in isupport:
+ event["server"].statusmsg = list(isupport["STATUSMSG"])
+
+ events.on("received.005").call(isupport=isupport,
+ server=event["server"])
+
+def handle_004(event):
+ event["server"].version = event["line"].args[2]
+
+def motd_start(event):
+ event["server"].motd_lines.clear()
+def motd_line(event):
+ event["server"].motd_lines.append(event["line"].args[1])
+
+def _own_modes(server, modes):
+ mode_chunks = RE_MODES.findall(modes)
+ for chunk in mode_chunks:
+ remove = chunk[0] == "-"
+ for mode in chunk[1:]:
+ server.change_own_mode(remove, mode)
+
+def mode(events, event):
+ user = event["server"].get_user(event["line"].source.nickname)
+ target = event["line"].args[0]
+ is_channel = event["server"].is_channel(target)
+ if is_channel:
+ channel = event["server"].channels.get(target)
+ modes = event["line"].args[1]
+ args = event["line"].args[2:]
+
+ new_modes = channel.parse_modes(modes, args[:])
+
+ events.on("received.mode.channel").call(modes=new_modes,
+ channel=channel, server=event["server"], user=user, modes_str=modes,
+ args_str=args)
+ elif event["server"].is_own_nickname(target):
+ modes = event["line"].args[1]
+ _own_modes(event["server"], modes)
+
+ events.on("self.mode").call(modes=modes, server=event["server"])
+ event["server"].send_who(event["server"].nickname)
+
+def handle_221(event):
+ _own_modes(event["server"], event["line"].args[1])
+
+def invite(events, event):
+ target_channel = event["line"].args[1]
+ user = event["server"].get_user(event["line"].source.nickname)
+ target_user = event["server"].get_user(event["line"].args[0])
+ events.on("received.invite").call(user=user, target_channel=target_channel,
+ server=event["server"], target_user=target_user)
+
+def handle_352(events, event):
+ nickname = event["line"].args[5]
+ username = event["line"].args[2]
+ hostname = event["line"].args[3]
+
+ if event["server"].is_own_nickname(nickname):
+ event["server"].username = username
+ event["server"].hostname = hostname
+
+ target = event["server"].get_user(nickname)
+ target.username = username
+ target.hostname = hostname
+ events.on("received.who").call(server=event["server"],
+ user=target)
+
+def handle_354(events, event):
+ if event["line"].args[1] == "111":
+ nickname = event["line"].args[4]
+ username = event["line"].args[2]
+ hostname = event["line"].args[3]
+ realname = event["line"].args[6]
+ account = event["line"].args[5]
+
+ if event["server"].is_own_nickname(nickname):
+ event["server"].username = username
+ event["server"].hostname = hostname
+ event["server"].realname = realname
+
+ target = event["server"].get_user(nickname)
+ target.username = username
+ target.hostname = hostname
+ target.realname = realname
+ if not account == "0":
+ target.account = account
+ else:
+ target.account = None
+ events.on("received.whox").call(server=event["server"],
+ user=target)
+
+def _nick_in_use(server):
+ new_nick = "%s|" % server.connection_params.nickname
+ server.send_nick(new_nick)
+
+def handle_433(event):
+ _nick_in_use(event["server"])
+def handle_437(event):
+ _nick_in_use(event["server"])
diff --git a/src/core_modules/line_handler/ircv3.py b/src/core_modules/line_handler/ircv3.py
new file mode 100644
index 00000000..a9d740ed
--- /dev/null
+++ b/src/core_modules/line_handler/ircv3.py
@@ -0,0 +1,138 @@
+from src import utils
+
+CAPABILITIES = [
+ utils.irc.Capability("multi-prefix"),
+ utils.irc.Capability("chghost"),
+ utils.irc.Capability("invite-notify"),
+ utils.irc.Capability("account-tag"),
+ utils.irc.Capability("account-notify"),
+ utils.irc.Capability("extended-join"),
+ utils.irc.Capability("away-notify"),
+ utils.irc.Capability("userhost-in-names"),
+ utils.irc.Capability("message-tags", "draft/message-tags-0.2"),
+ utils.irc.Capability("cap-notify"),
+ utils.irc.Capability("batch"),
+ utils.irc.Capability(None, "draft/rename", alias="rename"),
+ utils.irc.Capability(None, "draft/setname", alias="setname")
+]
+
+def _cap_depend_sort(caps, server_caps):
+ sorted_caps = []
+
+ caps_copy = {alias: cap.copy() for alias, cap in caps.items()}
+
+ for cap in caps.values():
+ if not cap.available(server_caps):
+ del caps_copy[cap.alias]
+
+ while True:
+ remove = []
+ for alias, cap in caps_copy.items():
+ for depend_alias in cap.depends_on:
+ if not depend_alias in caps_copy:
+ remove.append(alias)
+ if remove:
+ for alias in remove:
+ del caps_copy[alias]
+ else:
+ break
+
+ while caps_copy:
+ fulfilled = []
+ for cap in caps_copy.values():
+ remove = []
+ for depend_alias in cap.depends_on:
+ if depend_alias in sorted_caps:
+ remove.append(depend_alias)
+ for remove_cap in remove:
+ cap.depends_on.remove(remove_cap)
+
+ if not cap.depends_on:
+ fulfilled.append(cap.alias)
+ for fulfilled_cap in fulfilled:
+ del caps_copy[fulfilled_cap]
+ sorted_caps.append(fulfilled_cap)
+ return [caps[alias] for alias in sorted_caps]
+
+def _cap_match(server, caps):
+ matched_caps = {}
+ blacklist = server.get_setting("blacklisted-caps", [])
+
+ cap_aliases = {}
+ for cap in caps:
+ if not cap.alias in blacklist:
+ cap_aliases[cap.alias] = cap
+
+ sorted_caps = _cap_depend_sort(cap_aliases, server.server_capabilities)
+
+ for cap in sorted_caps:
+ available = cap.available(server.server_capabilities)
+ if available and not server.has_capability(cap):
+ matched_caps[available] = cap
+ return matched_caps
+
+def cap(exports, events, event):
+ capabilities = utils.parse.keyvalue(event["line"].args[-1])
+ subcommand = event["line"].args[1].upper()
+ is_multiline = len(event["line"].args) > 3 and event["line"].args[2] == "*"
+
+ if subcommand == "DEL":
+ for capability in capabilities.keys():
+ event["server"].agreed_capabilities.discard(capability)
+ if capability in event["server"].server_capabilities:
+ del event["server"].server_capabilities[capability]
+
+ events.on("received.cap.del").call(server=event["server"],
+ capabilities=capabilities)
+ elif subcommand == "ACK":
+ for cap_name, cap_args in capabilities.items():
+ if cap_name[0] == "-":
+ event["server"].agreed_capabilities.discard(cap_name[1:])
+ else:
+ event["server"].agreed_capabilities.add(cap_name)
+
+ events.on("received.cap.ack").call(capabilities=capabilities,
+ server=event["server"])
+
+ if subcommand == "LS" or subcommand == "NEW":
+ event["server"].server_capabilities.update(capabilities)
+ if not is_multiline:
+ server_caps = list(event["server"].server_capabilities.keys())
+ all_caps = CAPABILITIES[:]
+
+ export_caps = [cap.copy() for cap in exports.get_all("cap")]
+ all_caps.extend(export_caps)
+
+ module_caps = events.on("received.cap.ls").call(
+ capabilities=event["server"].server_capabilities,
+ server=event["server"])
+ module_caps = list(filter(None, module_caps))
+ all_caps.extend(module_caps)
+
+ matched_caps = _cap_match(event["server"], all_caps)
+ event["server"].capability_queue.update(matched_caps)
+
+ if event["server"].capability_queue:
+ event["server"].send_capability_queue()
+ else:
+ event["server"].send_capability_end()
+
+
+ if subcommand == "ACK" or subcommand == "NAK":
+ ack = subcommand == "ACK"
+ for capability in capabilities:
+ if capability in event["server"].capabilities_requested:
+ cap_obj = event["server"].capabilities_requested[capability]
+ del event["server"].capabilities_requested[capability]
+ if ack:
+ cap_obj.ack()
+ else:
+ cap_obj.nak()
+
+ if (not event["server"].capabilities_requested and
+ not event["server"].waiting_for_capabilities()):
+ event["server"].send_capability_end()
+
+def authenticate(events, event):
+ events.on("received.authenticate").call(message=event["line"].args[0],
+ server=event["server"])
diff --git a/src/core_modules/line_handler/message.py b/src/core_modules/line_handler/message.py
new file mode 100644
index 00000000..fa36dbc2
--- /dev/null
+++ b/src/core_modules/line_handler/message.py
@@ -0,0 +1,109 @@
+from src import IRCBuffer, utils
+
+def _from_self(server, source):
+ if source:
+ return server.is_own_nickname(source.nickname)
+ else:
+ return False
+
+def message(events, event):
+ from_self = _from_self(event["server"], event["line"].source)
+ if from_self == None:
+ return
+
+ direction = "send" if from_self else "received"
+
+ target_str = event["line"].args[0]
+
+ message = None
+ if len(event["line"].args) > 1:
+ message = event["line"].args[1]
+
+ if not from_self and (
+ not event["line"].source or
+ not event["server"].name or
+ event["line"].source.hostmask == event["server"].name or
+ target_str == "*"):
+ if event["line"].source:
+ event["server"].name = event["line"].source.hostmask
+
+ events.on("received.server-notice").call(message=message,
+ message_split=message.split(" "), server=event["server"])
+ return
+
+ if from_self:
+ user = event["server"].get_user(event["server"].nickname)
+ else:
+ user = event["server"].get_user(event["line"].source.nickname,
+ username=event["line"].source.username,
+ hostname=event["line"].source.hostname)
+
+ # strip prefix_symbols from the start of target, for when people use
+ # e.g. 'PRIVMSG +#channel :hi' which would send a message to only
+ # voiced-or-above users
+ target = target_str.lstrip("".join(event["server"].statusmsg))
+
+ is_channel = event["server"].is_channel(target)
+
+ if is_channel:
+ if not target in event["server"].channels:
+ return
+ target_obj = event["server"].channels.get(target)
+ else:
+ target_obj = event["server"].get_user(target)
+
+ kwargs = {"server": event["server"], "target": target_obj,
+ "target_str": target_str, "user": user, "tags": event["line"].tags,
+ "is_channel": is_channel, "from_self": from_self, "line": event["line"]}
+
+ action = False
+
+ if message:
+ ctcp_message = utils.irc.parse_ctcp(message)
+
+ if ctcp_message:
+ if (not ctcp_message.command == "ACTION" or not
+ event["line"].command == "PRIVMSG"):
+ if event["line"].command == "PRIVMSG":
+ ctcp_action = "request"
+ else:
+ ctcp_action = "response"
+ events.on(direction).on("ctcp").on(ctcp_action).call(
+ message=ctcp_message.message, **kwargs)
+ events.on(direction).on("ctcp").on(ctcp_action).on(
+ ctcp_message.command).call(message=ctcp_message.message,
+ **kwargs)
+ return
+ else:
+ message = ctcp_message.message
+ action = True
+
+ if not message == None:
+ kwargs["message"] = message
+ kwargs["message_split"] = message.split(" ")
+ kwargs["action"] = action
+
+ event_type = event["line"].command.lower()
+ if event_type == "privmsg":
+ event_type = "message"
+
+ context = "channel" if is_channel else "private"
+ hook = events.on(direction).on(event_type).on(context)
+
+ buffer_line = None
+ if message:
+ buffer_line = IRCBuffer.BufferLine(user.nickname, message, action,
+ event["line"].tags, from_self, event["line"].command)
+
+ buffer_obj = target_obj
+ if is_channel:
+ hook.call(channel=target_obj, buffer_line=buffer_line, **kwargs)
+ else:
+ buffer_obj = target_obj
+ if not from_self:
+ buffer_obj = user
+
+ hook.call(buffer_line=buffer_line, **kwargs)
+
+ if buffer_line:
+ buffer_obj.buffer.add(buffer_line)
diff --git a/src/core_modules/line_handler/user.py b/src/core_modules/line_handler/user.py
new file mode 100644
index 00000000..d1592cd7
--- /dev/null
+++ b/src/core_modules/line_handler/user.py
@@ -0,0 +1,102 @@
+from src import utils
+
+def handle_311(event):
+ nickname = event["line"].args[1]
+ username = event["line"].args[2]
+ hostname = event["line"].args[3]
+ realname = event["line"].args[4]
+
+ if event["server"].is_own_nickname(nickname):
+ event["server"].username = username
+ event["server"].hostname = hostname
+ event["server"].realname = realname
+
+ target = event["server"].get_user(nickname)
+ target.username = username
+ target.hostname = hostname
+ target.realname = realname
+
+def quit(events, event):
+ nickname = None
+ if event["direction"] == utils.Direction.Recv:
+ nickname = event["line"].source.nickname
+ reason = event["line"].args.get(0)
+
+ if event["direction"] == utils.Direction.Recv:
+ nickname = event["line"].source.nickname
+ if (not event["server"].is_own_nickname(nickname) and
+ not event["line"].source.hostmask == "*"):
+ user = event["server"].get_user(nickname)
+ events.on("received.quit").call(reason=reason, user=user,
+ server=event["server"])
+ event["server"].remove_user(user)
+ else:
+ event["server"].disconnect()
+ else:
+ events.on("send.quit").call(reason=reason, server=event["server"])
+
+def nick(events, event):
+ new_nickname = event["line"].args.get(0)
+ user = event["server"].get_user(event["line"].source.nickname)
+ old_nickname = user.nickname
+ user.set_nickname(new_nickname)
+ event["server"].change_user_nickname(old_nickname, new_nickname)
+
+ if not event["server"].is_own_nickname(event["line"].source.nickname):
+ events.on("received.nick").call(new_nickname=new_nickname,
+ old_nickname=old_nickname, user=user, server=event["server"])
+ else:
+ events.on("self.nick").call(server=event["server"],
+ new_nickname=new_nickname, old_nickname=old_nickname)
+ event["server"].set_own_nickname(new_nickname)
+
+def away(events, event):
+ user = event["server"].get_user(event["line"].source.nickname)
+ message = event["line"].args.get(0)
+ if message:
+ user.away = True
+ user.away_message = message
+ events.on("received.away.on").call(user=user, server=event["server"],
+ message=message)
+ else:
+ user.away = False
+ user.away_message = None
+ events.on("received.away.off").call(user=user, server=event["server"])
+
+def chghost(events, event):
+ nickname = event["line"].source.nickname
+ username = event["line"].args[0]
+ hostname = event["line"].args[1]
+
+ if event["server"].is_own_nickname(nickname):
+ event["server"].username = username
+ event["server"].hostname = hostname
+
+ target = event["server"].get_user(nickname)
+ events.on("received.chghost").call(user=target, server=event["server"],
+ username=username, hostname=hostname)
+
+ target.username = username
+ target.hostname = hostname
+
+def setname(event):
+ nickname = event["line"].source.nickname
+ realname = event["line"].args[0]
+
+ user = event["server"].get_user(nickname)
+ user.realname = realname
+
+ if event["server"].is_own_nickname(nickname):
+ event["server"].realname = realname
+
+def account(events, event):
+ user = event["server"].get_user(event["line"].source.nickname)
+
+ if not event["line"].args[0] == "*":
+ user.account = event["line"].args[0]
+ events.on("received.account.login").call(user=user,
+ server=event["server"], account=event["line"].args[0])
+ else:
+ user.account = None
+ events.on("received.account.logout").call(user=user,
+ server=event["server"])
diff --git a/src/core_modules/modules.py b/src/core_modules/modules.py
new file mode 100644
index 00000000..a93afaea
--- /dev/null
+++ b/src/core_modules/modules.py
@@ -0,0 +1,122 @@
+#--depends-on commands
+#--depends-on permissions
+
+from src import ModuleManager, utils
+
+class Module(ModuleManager.BaseModule):
+ def _catch(self, name, func):
+ try:
+ return func()
+ except ModuleManager.ModuleNotFoundException:
+ raise utils.EventError("Module '%s' not found" % name)
+ except ModuleManager.ModuleNotLoadedException:
+ raise utils.EventError("Module '%s' isn't loaded" % name)
+ except ModuleManager.ModuleWarning as warning:
+ raise utils.EventError("Module '%s' not loaded: %s" % (
+ name, str(warning)))
+ except Exception as e:
+ raise utils.EventError("Failed to reload module '%s': %s" % (
+ name, str(e)))
+
+ @utils.hook("received.command.loadmodule", min_args=1)
+ def load(self, event):
+ """
+ :help: Load a module
+ :usage: <module name>
+ :permission: load-module
+ """
+ name = event["args_split"][0].lower()
+ if name in self.bot.modules.modules:
+ raise utils.EventError("Module '%s' is already loaded" % name)
+ definition = self._catch(name,
+ lambda: self.bot.modules.find_module(name))
+
+ self._catch(name, lambda: self.bot.modules.load_module(self.bot, definition))
+ event["stdout"].write("Loaded '%s'" % name)
+
+ @utils.hook("received.command.unloadmodule", min_args=1)
+ def unload(self, event):
+ """
+ :help: Unload a module
+ :usage: <module name>
+ :permission: unload-module
+ """
+ name = event["args_split"][0].lower()
+ if not name in self.bot.modules.modules:
+ raise utils.EventError("Module '%s' isn't loaded" % name)
+
+ self._catch(name, lambda: self.bot.modules.unload_module(name))
+ event["stdout"].write("Unloaded '%s'" % name)
+
+ def _reload(self, name):
+ self.bot.modules.unload_module(name)
+ definition = self._catch(name,
+ lambda: self.bot.modules.find_module(name))
+ self.bot.modules.load_module(self.bot, definition)
+ @utils.hook("received.command.reloadmodule", min_args=1)
+ def reload(self, event):
+ """
+ :help: Reload a module
+ :usage: <module name>
+ :permission: reload-module
+ """
+ name = event["args_split"][0].lower()
+
+ self._catch(name, lambda: self._reload(name))
+ event["stdout"].write("Reloaded '%s'" % name)
+
+ @utils.hook("received.command.reloadallmodules")
+ def reload_all(self, event):
+ """
+ :help: Reload all modules
+ :permission: reload-all-modules
+ """
+ result = self.bot.try_reload_modules()
+ if result.success:
+ event["stdout"].write(result.message)
+ else:
+ event["stderr"].write(result.message)
+
+ def _get_blacklist(self):
+ return self.bot.config.get_list("module-blacklist")
+ def _save_blacklist(self, modules):
+ self.bot.config.set_list("module-blacklist", modules)
+ self.bot.config.save()
+
+ @utils.hook("received.command.enablemodule")
+ @utils.kwarg("min_args", 1)
+ @utils.kwarg("help", "Remove a module from the module blacklist")
+ @utils.kwarg("usage", "<module>")
+ @utils.kwarg("permission", "enable-module")
+ def enable(self, event):
+ name = event["args_split"][0].lower()
+
+ blacklist = self._get_blacklist()
+ if not name in blacklist:
+ raise utils.EventError("Module '%s' isn't disabled" % name)
+
+ blacklist.remove(name)
+ self._save_blacklist(blacklist)
+ event["stdout"].write("Module '%s' has been enabled and can now "
+ "be loaded" % name)
+
+ @utils.hook("received.command.disablemodule")
+ @utils.kwarg("min_args", 1)
+ @utils.kwarg("help", "Add a module to the module blacklist")
+ @utils.kwarg("usage", "<module>")
+ @utils.kwarg("permission", "disable-module")
+ def disable(self, event):
+ name = event["args_split"][0].lower()
+ and_unloaded = ""
+ if name in self.bot.modules.modules:
+ self.bot.modules.unload_module(name)
+ and_unloaded = " and unloaded"
+
+ blacklist = self._get_blacklist()
+ if name in blacklist:
+ raise utils.EventError("Module '%s' is already disabled" % name)
+
+ blacklist.append(name)
+ self._save_blacklist(blacklist)
+ event["stdout"].write("Module '%s' has been disabled%s" % (
+ name, and_unloaded))
diff --git a/src/core_modules/more.py b/src/core_modules/more.py
new file mode 100644
index 00000000..52849938
--- /dev/null
+++ b/src/core_modules/more.py
@@ -0,0 +1,23 @@
+from src import EventManager, ModuleManager, utils
+
+class Module(ModuleManager.BaseModule):
+ @utils.hook("new.user")
+ @utils.hook("new.channel")
+ def new(self, event):
+ obj = event.get("user", event.get("channel", None))
+ obj._last_stdout = None
+ obj._last_stderr = None
+
+ @utils.hook("postprocess.command")
+ @utils.kwarg("priority", EventManager.PRIORITY_MONITOR)
+ def postprocess(self, event):
+ if event["stdout"].has_text():
+ event["target"]._last_stdout = event["stdout"]
+ if event["stderr"].has_text():
+ event["target"]._last_stderr = event["stderr"]
+
+ @utils.hook("received.command.more")
+ def more(self, event):
+ last_stdout = event["target"]._last_stdout
+ if last_stdout and last_stdout.has_text():
+ event["stdout"].write_lines(last_stdout.get_all())
diff --git a/src/core_modules/nick_regain.py b/src/core_modules/nick_regain.py
new file mode 100644
index 00000000..cf1dfa48
--- /dev/null
+++ b/src/core_modules/nick_regain.py
@@ -0,0 +1,48 @@
+from src import ModuleManager, utils
+
+class Module(ModuleManager.BaseModule):
+ def _done_connecting(self, server):
+ target_nick = server.connection_params.nickname
+ if not server.irc_equals(server.nickname, target_nick):
+ if "MONITOR" in server.isupport:
+ server.send_raw("MONITOR + %s" % target_nick)
+ else:
+ self.timers.add("ison-check", self._ison_check, 30,
+ server=server)
+
+ @utils.hook("received.376")
+ def end_of_motd(self, event):
+ self._done_connecting(event["server"])
+ @utils.hook("received.422")
+ def no_motd(self, event):
+ self._done_connecting(event["server"])
+
+ @utils.hook("self.nick")
+ def self_nick(self, event):
+ target_nick = event["server"].connection_params.nickname
+ if event["server"].irc_equals(event["new_nickname"], target_nick):
+ if "MONITOR" in event["server"].isupport:
+ event["server"].send_raw("MONITOR - %s " % target_nick)
+
+ @utils.hook("received.731")
+ def mon_offline(self, event):
+ target_nick = event["server"].connection_params.nickname
+ nicks = event["line"].args[1].split(",")
+ nicks = [event["server"].irc_lower(n) for n in nicks]
+ if event["server"].irc_lower(target_nick) in nicks:
+ event["server"].send_nick(target_nick)
+
+ def _ison_check(self, timer):
+ server = timer.kwargs["server"]
+ target_nick = server.connection_params.nickname
+ if not server.irc_equals(server.nickname, target_nick):
+ server.send_raw("ISON %s" % target_nick)
+ timer.redo()
+
+ @utils.hook("received.303")
+ def ison_response(self, event):
+ target_nick = event["server"].connection_params.nickname
+ if not event["line"].args[1] and not event["server"].irc_equals(
+ event["server"].nickname, target_nick):
+ event["server"].send_nick(target_nick)
+
diff --git a/src/core_modules/perform.py b/src/core_modules/perform.py
new file mode 100644
index 00000000..832cab54
--- /dev/null
+++ b/src/core_modules/perform.py
@@ -0,0 +1,76 @@
+#--depends-on commands
+#--depends-on permissions
+
+from src import EventManager, IRCLine, ModuleManager, utils
+
+class Module(ModuleManager.BaseModule):
+ def _execute(self, server, commands, **kwargs):
+ for command in commands:
+ line = command.format(**kwargs)
+ if IRCLine.is_human(line):
+ line = IRCLine.parse_human(line)
+ else:
+ line = IRCLine.parse_line(line)
+ server.send(line)
+
+ @utils.hook("received.001", priority=EventManager.PRIORITY_URGENT)
+ def on_connect(self, event):
+ commands = event["server"].get_setting("perform", [])
+ self._execute(event["server"], commands, NICK=event["server"].nickname)
+
+ @utils.hook("self.join", priority=EventManager.PRIORITY_URGENT)
+ def on_join(self, event):
+ commands = event["channel"].get_setting("perform", [])
+ self._execute(event["server"], commands, NICK=event["server"].nickname,
+ CHAN=event["channel"].name)
+
+ def _perform(self, target, args_split):
+ subcommand = args_split[0].lower()
+ current_perform = target.get_setting("perform", [])
+ if subcommand == "list":
+ return "Configured commands: %s" % ", ".join(current_perform)
+
+ message = None
+ if subcommand == "add":
+ if not len(args_split) > 1:
+ raise utils.EventError("Please provide a raw command to add")
+ current_perform.append(" ".join(args_split[1:]))
+ message = "Added command"
+ elif subcommand == "remove":
+ if not len(args_split) > 1:
+ raise utils.EventError("Please provide an index to remove")
+ if not args_split[1].isdigit():
+ raise utils.EventError("Please provide a number")
+ index = int(args_split[1])
+ if not index < len(current_perform):
+ raise utils.EventError("Index out of bounds")
+ current_perform.pop(index)
+ message = "Removed command"
+ else:
+ raise utils.EventError("Unknown subcommand '%s'" % subcommand)
+
+ target.set_setting("perform", current_perform)
+ return message
+
+ @utils.hook("received.command.perform", min_args=1)
+ @utils.kwarg("min_args", 1)
+ @utils.kwarg("help", "Edit on-connect command configuration")
+ @utils.kwarg("usage", "list")
+ @utils.kwarg("usage", "add <raw command>")
+ @utils.kwarg("usage", "remove <index>")
+ @utils.kwarg("permission", "perform")
+ def perform(self, event):
+ event["stdout"].write(self._perform(event["server"],
+ event["args_split"]))
+
+ @utils.hook("received.command.cperform", min_args=1)
+ @utils.kwarg("min_args", 1)
+ @utils.kwarg("channel_only", True)
+ @utils.kwarg("help", "Edit channel on-join command configuration")
+ @utils.kwarg("usage", "list")
+ @utils.kwarg("usage", "add <raw command>")
+ @utils.kwarg("usage", "remove <index>")
+ @utils.kwarg("permission", "cperform")
+ def cperform(self, event):
+ event["stdout"].write(self._perform(event["target"],
+ event["args_split"]))
diff --git a/src/core_modules/permissions/__init__.py b/src/core_modules/permissions/__init__.py
new file mode 100644
index 00000000..0559774c
--- /dev/null
+++ b/src/core_modules/permissions/__init__.py
@@ -0,0 +1,357 @@
+#--depends-on commands
+
+import base64, binascii, os
+import scrypt
+from src import ModuleManager, utils
+
+HOSTMASKS_SETTING = "hostmask-account"
+NO_PERMISSION = "You do not have permission to do that"
+
+class Module(ModuleManager.BaseModule):
+ def on_load(self):
+ self.exports.add("is-identified", self._is_identified)
+ self.exports.add("account-name", self._account_name)
+
+ @utils.hook("new.server")
+ def new_server(self, event):
+ event["server"]._hostmasks = {}
+
+ for account, user_hostmasks in event["server"].get_all_user_settings(
+ HOSTMASKS_SETTING):
+ for hostmask in user_hostmasks:
+ self._add_hostmask(event["server"],
+ utils.irc.hostmask_parse(hostmask), account)
+
+ def _add_hostmask(self, server, hostmask, account):
+ server._hostmasks[hostmask.original] = (hostmask, account)
+ def _remove_hostmask(self, server, hostmask):
+ if hostmask in server._hostmasks:
+ del server._hostmasks[hostmask]
+
+ def _make_salt(self):
+ return base64.b64encode(os.urandom(64)).decode("utf8")
+
+ def _random_password(self):
+ return binascii.hexlify(os.urandom(32)).decode("utf8")
+
+ def _make_hash(self, password, salt=None):
+ salt = salt or self._make_salt()
+ hash = base64.b64encode(scrypt.hash(password, salt)).decode("utf8")
+ return hash, salt
+
+ def _get_hash(self, server, account):
+ hash, salt = server.get_user(account).get_setting("authentication",
+ (None, None))
+ return hash, salt
+
+ def _master_password(self):
+ master_password = self._random_password()
+ hash, salt = self._make_hash(master_password)
+ self.bot.set_setting("master-password", [hash, salt])
+ return master_password
+ @utils.hook("control.master-password")
+ def command_line(self, event):
+ master_password = self._master_password()
+ return "One-time master password: %s" % master_password
+
+ def _has_identified(self, server, user, account):
+ user._id_override = server.get_user_id(account)
+ def _is_identified(self, user):
+ return not user._id_override == None
+ def _signout(self, user):
+ user._id_override = None
+
+ def _find_hostmask(self, server, user):
+ user_hostmask = user.hostmask()
+ for hostmask, (hostmask_pattern, account) in server._hostmasks.items():
+ if utils.irc.hostmask_match(user_hostmask, hostmask_pattern):
+ return (hostmask, account)
+ def _specific_hostmask(self, server, hostmask, account):
+ for user in server.users.values():
+ if utils.irc.hostmask_match(user.hostmask(), hostmask):
+ if account == None:
+ user._hostmask_account = None
+ self._signout(user)
+ else:
+ user._hostmask_account = (hostmask, account)
+ self._has_identified(server, user, account)
+
+ def _account_name(self, user):
+ if not user.account == None:
+ return user.account
+ elif not user._account_override == None:
+ return user._account_override
+ elif not user._hostmask_account == None:
+ return user._hostmask_account[1]
+
+ @utils.hook("new.user")
+ def new_user(self, event):
+ event["user"]._hostmask_account = None
+ event["user"]._account_override = None
+ event["user"]._master_admin = False
+
+ def _set_hostmask(self, server, user):
+ account = self._find_hostmask(server, user)
+ if not account == None:
+ hostmask, account = account
+ user._hostmask_account = (hostmask, account)
+ self._has_identified(server, user, account)
+
+ @utils.hook("received.chghost")
+ @utils.hook("received.nick")
+ @utils.hook("received.who")
+ @utils.hook("received.whox")
+ @utils.hook("received.message.private")
+ def chghost(self, event):
+ if not self._is_identified(event["user"]):
+ self._set_hostmask(event["server"], event["user"])
+ @utils.hook("received.whox")
+ @utils.hook("received.account")
+ @utils.hook("received.account.login")
+ @utils.hook("received.account.logout")
+ @utils.hook("received.join")
+ def check_account(self, event):
+ if not self._is_identified(event["user"]):
+ if event["user"].account:
+ self._has_identified(event["server"], event["user"],
+ event["user"].account)
+ else:
+ self._set_hostmask(event["server"], event["user"])
+
+ def _get_permissions(self, user):
+ if self._is_identified(user):
+ return user.get_setting("permissions", [])
+ return []
+
+ def _has_permission(self, user, permission):
+ if user._master_admin:
+ return True
+
+ permissions = self._get_permissions(user)
+ if permission in permissions:
+ return True
+ else:
+ permission_parts = permission.split(".")
+ for user_permission in permissions:
+ user_permission_parts = user_permission.split(".")
+ for i, part in enumerate(permission_parts):
+ last = i==(len(permission_parts)-1)
+ user_last = i==(len(user_permission_parts)-1)
+ if not permission_parts[i] == user_permission_parts[i]:
+ if user_permission_parts[i] == "*" and user_last:
+ return True
+ else:
+ break
+ else:
+ if last and user_last:
+ return True
+ return False
+
+ @utils.hook("received.command.masterlogin")
+ @utils.kwarg("min_args", 1)
+ @utils.kwarg("private_only", True)
+ def master_login(self, event):
+ saved_hash, saved_salt = self.bot.get_setting("master-password",
+ (None, None))
+ if saved_hash and saved_salt:
+ given_hash, _ = self._make_hash(event["args"], saved_salt)
+ if utils.security.constant_time_compare(given_hash, saved_hash):
+ self.bot.del_setting("master-password")
+ event["user"]._master_admin = True
+ event["stdout"].write("Master login successful")
+ return
+ event["stderr"].write("Master login failed")
+
+ @utils.hook("received.command.mypermissions")
+ @utils.kwarg("authenticated", True)
+ def my_permissions(self, event):
+ """
+ :help: Show your permissions
+ """
+ permissions = event["user"].get_setting("permissions", [])
+ event["stdout"].write("Your permissions: %s" % ", ".join(permissions))
+
+
+ @utils.hook("received.command.register", private_only=True, min_args=1)
+ @utils.kwarg("min_args", 1)
+ @utils.kwarg("private_only", True)
+ @utils.kwarg("help", "Register your nickname")
+ @utils.kwarg("usage", "<password>")
+ def register(self, event):
+ hash, salt = self._get_hash(event["server"], event["user"].nickname)
+ if not hash and not salt:
+ password = event["args"]
+ hash, salt = self._make_hash(password)
+ event["user"].set_setting("authentication", [hash, salt])
+
+ event["user"]._account_override = event["user"].nickname
+ self._has_identified(event["server"], event["user"],
+ event["user"].nickname)
+
+ event["stdout"].write("Nickname registered successfully")
+ else:
+ event["stderr"].write("This nickname is already registered")
+
+ @utils.hook("received.command.identify", private_only=True, min_args=1)
+ @utils.kwarg("min_args", 1)
+ @utils.kwarg("private_only", True)
+ @utils.kwarg("help", "Identify for your current nickname")
+ @utils.kwarg("usage", "[account] <password>")
+ def identify(self, event):
+ if not event["user"].channels:
+ raise utils.EventError("You must share at least one channel "
+ "with me before you can identify")
+
+ if not self._is_identified(event["user"]):
+ if len(event["args_split"]) > 1:
+ account = event["args_split"][0]
+ password = " ".join(event["args_split"][1:])
+ else:
+ account = event["user"].nickname
+ password = event["args"]
+
+ hash, salt = self._get_hash(event["server"], account)
+ if hash and salt:
+ attempt, _ = self._make_hash(password, salt)
+ if utils.security.constant_time_compare(attempt, hash):
+ event["user"]._account_override = account
+ self._has_identified(event["server"], event["user"], account)
+
+ event["stdout"].write("Correct password, you have "
+ "been identified as %s." % account)
+ self.events.on("internal.identified").call(
+ user=event["user"])
+ else:
+ event["stderr"].write("Incorrect password for '%s'" %
+ account)
+ else:
+ event["stderr"].write("Account '%s' is not registered" %
+ account)
+ else:
+ event["stderr"].write("You are already identified as %s" %
+ self._account_name(event["user"]))
+
+ @utils.hook("received.command.permission")
+ @utils.kwarg("min_args", 2)
+ @utils.kwarg("usage", "list <account>")
+ @utils.kwarg("usage", "clear <account>")
+ @utils.kwarg("usage", "add <account> <permission>")
+ @utils.kwarg("usage", "remove <account> <permission>")
+ @utils.kwarg("permission", "permissions.change")
+ def permission(self, event):
+ subcommand = event["args_split"][0].lower()
+ account = event["args_split"][1]
+ target_user = event["server"].get_user(account)
+
+ if subcommand == "list":
+ event["stdout"].write("Permissions for %s: %s" % (
+ account, ", ".join(self._get_permissions(target_user))))
+ elif subcommand == "clear":
+ if not self._get_permissions(target_user):
+ raise utils.EventError("%s has no permissions" % account)
+ target_user.del_setting("permissions")
+ event["stdout"].write("Cleared permissions for %s" % account)
+ else:
+ permissions = event["args_split"][2:]
+ if not permissions:
+ raise utils.EventError("Please provide at least 1 permission")
+ user_permissions = self._get_permissions(target_user)
+
+ if subcommand == "add":
+ new = list(set(permissions)-set(user_permissions))
+ if not new:
+ raise utils.EventError("No new permissions to give")
+ target_user.set_setting("permissions", user_permissions+new)
+ event["stdout"].write("Gave %s new permissions: %s" %
+ (account, ", ".join(new)))
+ elif subcommand == "remove":
+ permissions_set = set(permissions)
+ user_permissions_set = set(user_permissions)
+ removed = list(user_permissions_set&permissions_set)
+ if not (user_permissions_set & permissions_set):
+ raise utils.EventError("New permissions to remove")
+ change = list(user_permissions_set - permissions_set)
+
+ if not change:
+ target_user.del_setting("permissions")
+ else:
+ target_user.set_setting("permissions", change)
+ event["stdout"].write("Removed permissions from %s: %s" %
+ (account, ", ".join(change)))
+ else:
+ raise utils.EventError("Unknown subcommand %s" % subcommand)
+
+ @utils.hook("received.command.hostmask")
+ @utils.kwarg("min_args", 1)
+ @utils.kwarg("authenticated", True)
+ @utils.kwarg("usage", "list")
+ @utils.kwarg("usage", "add [hostmask]")
+ @utils.kwarg("usage", "remove [hostmask]")
+ def hostmask(self, event):
+ subcommand = event["args_split"][0].lower()
+ hostmasks = event["user"].get_setting(HOSTMASKS_SETTING, [])
+
+ if subcommand == "list":
+ event["stdout"].write("Your hostmasks: %s" % ", ".join(hostmasks))
+ else:
+ if event["args_split"][1:]:
+ hostmask = event["args_split"][1]
+ else:
+ hostmask = "*!%s" % event["user"].userhost()
+ account = self._account_name(event["user"])
+
+ if subcommand == "add":
+ if hostmask in hostmasks:
+ raise utils.EventError(
+ "Hostmask %s is already on your account" % hostmask)
+ hostmasks.append(hostmask)
+ event["user"].set_setting(HOSTMASKS_SETTING, hostmasks)
+
+ hostmask_obj = utils.irc.hostmask_parse(hostmaks)
+ self._specific_hostmask(event["server"], hostmask_obj, account)
+ self._add_hostmask(event["server"], hostmask_obj, account)
+
+ event["stdout"].write("Added %s to your hostmasks" % hostmask)
+ elif subcommand == "remove":
+ if not hostmask in hostmasks:
+ raise utils.EventError("Hostmask %s is not on your account"
+ % hostmask)
+ while hostmask in hostmasks:
+ hostmasks.remove(hostmask)
+ event["user"].set_setting(HOSTMASKS_SETTING, hostmasks)
+
+ self._specific_hostmask(event["server"], hostmask, None)
+ self._remove_hostmask(event["server"], hostmask)
+
+ event["stdout"].write("Removed %s from your hostmasks"
+ % hostmask)
+ else:
+ raise utils.EventError("Unknown subcommand %s" % subcommand)
+
+ def _assert(self, allowed):
+ if allowed:
+ return utils.consts.PERMISSION_FORCE_SUCCESS, None
+ else:
+ return utils.consts.PERMISSION_ERROR, NO_PERMISSION
+
+ @utils.hook("preprocess.command")
+ def preprocess_command(self, event):
+ allowed = None
+ permission = event["hook"].get_kwarg("permission", None)
+ authenticated = event["hook"].get_kwarg("authenticated", False)
+ if not permission == None:
+ allowed = self._has_permission(event["user"], permission)
+ elif authenticated:
+ allowed = self._is_identified(event["user"])
+ else:
+ return
+
+ return self._assert(allowed)
+
+ @utils.hook("check.command.permission")
+ def check_permission(self, event):
+ return self._assert(
+ self._has_permission(event["user"], event["request_args"][0]))
+ @utils.hook("check.command.authenticated")
+ def check_authenticated(self, event):
+ return self._assert(self._is_identified(event["user"]))
diff --git a/src/core_modules/print_activity.py b/src/core_modules/print_activity.py
new file mode 100644
index 00000000..e6a34992
--- /dev/null
+++ b/src/core_modules/print_activity.py
@@ -0,0 +1,48 @@
+#--depends-on config
+#--depends-on format_activity
+
+import datetime
+from src import EventManager, ModuleManager, utils
+
+@utils.export("botset",
+ utils.BoolSetting("print-motd", "Set whether I print /motd"))
+@utils.export("botset", utils.BoolSetting("pretty-activity",
+ "Whether or not to pretty print activity"))
+@utils.export("channelset", utils.BoolSetting("print",
+ "Whether or not to print activity a channel to logs"))
+class Module(ModuleManager.BaseModule):
+ def _print(self, event):
+ if (event["channel"] and
+ not event["channel"].get_setting("print", True)):
+ return
+
+ line = event["line"]
+ if event["pretty"] and self.bot.get_setting("pretty-activity", False):
+ line = event["pretty"]
+
+ context = (":%s" % event["context"]) if event["context"] else ""
+ self.bot.log.info("%s%s | %s", [
+ str(event["server"]), context, utils.irc.parse_format(line)])
+
+ @utils.hook("formatted.message.channel")
+ @utils.hook("formatted.notice.channel")
+ @utils.hook("formatted.notice.private")
+ @utils.hook("formatted.join")
+ @utils.hook("formatted.part")
+ @utils.hook("formatted.nick")
+ @utils.hook("formatted.server-notice")
+ @utils.hook("formatted.invite")
+ @utils.hook("formatted.mode.channel")
+ @utils.hook("formatted.topic")
+ @utils.hook("formatted.topic-timestamp")
+ @utils.hook("formatted.kick")
+ @utils.hook("formatted.quit")
+ @utils.hook("formatted.rename")
+ @utils.hook("formatted.chghost")
+ def formatted(self, event):
+ self._print(event)
+
+ @utils.hook("formatted.motd")
+ def motd(self, event):
+ if self.bot.get_setting("print-motd", True):
+ self._print(event)
diff --git a/src/core_modules/proxy.py b/src/core_modules/proxy.py
new file mode 100644
index 00000000..1bcaebd1
--- /dev/null
+++ b/src/core_modules/proxy.py
@@ -0,0 +1,38 @@
+import typing, urllib.parse
+import socks
+from src import ModuleManager, utils
+
+TYPES = {
+ "socks4": socks.SOCKS4,
+ "socks5": socks.SOCKS5,
+ "http": socks.HTTP
+}
+
+def _parse(value):
+ parsed = urllib.parse.urlparse(value)
+ if parsed.scheme in TYPES and parsed.hostname:
+ return value
+
+@utils.export("serverset", utils.FunctionSetting(_parse, "proxy",
+ "Proxy configuration for the current server",
+ example="socks5://localhost:9050"))
+class Module(ModuleManager.BaseModule):
+ @utils.hook("preprocess.connect")
+ def new_server(self, event):
+ proxy = event["server"].get_setting("proxy", None)
+ if proxy:
+ proxy_parsed = urllib.parse.urlparse(proxy)
+ type = TYPES.get(proxy_parsed.scheme)
+
+ if type == None:
+ raise ValueError("Invalid proxy type '%s' for '%s'" %
+ (proxy_parsed.scheme, str(event["server"])))
+
+ event["server"].socket._make_socket = self._socket_factory(
+ type, proxy_parsed.hostname, proxy_parsed.port)
+
+ def _socket_factory(self, ptype, phost, pport):
+ def _(host, port, bind, timeout):
+ return socks.create_connection((host, port), timeout, bind,
+ ptype, phost, pport)
+ return _
diff --git a/src/core_modules/signals.py b/src/core_modules/signals.py
new file mode 100644
index 00000000..921b483c
--- /dev/null
+++ b/src/core_modules/signals.py
@@ -0,0 +1,65 @@
+import signal, sys
+from src import Config, IRCLine, ModuleManager, utils
+
+class Module(ModuleManager.BaseModule):
+ def on_load(self):
+ self._exited = False
+ signal.signal(signal.SIGINT, self.SIGINT)
+ signal.signal(signal.SIGUSR1, self.SIGUSR1)
+ signal.signal(signal.SIGHUP, self.SIGHUP)
+
+ def SIGINT(self, signum, frame):
+ print()
+ self.bot.trigger(lambda: self._kill(signum))
+
+ def _kill(self, signum):
+ if self._exited:
+ return
+ self._exited = True
+
+ self.events.on("signal.interrupt").call(signum=signum)
+
+ written = False
+ for server in list(self.bot.servers.values()):
+ if server.connected:
+ server.socket.clear_send_buffer()
+
+ line = IRCLine.ParsedLine("QUIT", ["Shutting down"])
+ sent_line = server.send(line, immediate=True)
+ sent_line.events.on("send").hook(self._make_hook(server))
+
+ server.send_enabled = False
+ written = True
+
+ if not written:
+ sys.exit()
+
+ def _make_hook(self, server):
+ return lambda e: self._disconnect_hook(server)
+ def _disconnect_hook(self, server):
+ self.bot.disconnect(server)
+ if not self.bot.servers:
+ sys.exit()
+
+ def SIGUSR1(self, signum, frame):
+ self.bot.trigger(self._reload_config)
+
+ def SIGHUP(self, signum, frame):
+ self.bot.trigger(self._SIGHUP)
+ def _SIGHUP(self):
+ self._reload_config()
+ self._reload_modules()
+
+ def _reload_config(self):
+ self.bot.log.info("Reloading config file")
+ self.bot.config.load()
+ self.bot.log.info("Reloaded config file")
+
+ def _reload_modules(self):
+ self.bot.log.info("Reloading modules")
+
+ result = self.bot.try_reload_modules()
+ if result.success:
+ self.bot.log.info(result.message)
+ else:
+ self.bot.log.warn(result.message)
diff --git a/src/core_modules/silence.py b/src/core_modules/silence.py
new file mode 100644
index 00000000..42990921
--- /dev/null
+++ b/src/core_modules/silence.py
@@ -0,0 +1,70 @@
+#--depends-on commands
+#--depends-on permissions
+
+import time
+from src import EventManager, ModuleManager, utils
+
+SILENCE_TIME = 60*5 # 5 minutes
+
+class Module(ModuleManager.BaseModule):
+ def on_load(self):
+ self.exports.add("is-silenced", self._is_silenced)
+
+ def _is_silenced(self, target):
+ silence_until = target.get_setting("silence-until", None)
+ if not silence_until == None:
+ if time.time()<silence_until:
+ return True
+ else:
+ target.del_setting("silence-until")
+ return False
+
+ @utils.hook("received.command.silence")
+ @utils.kwarg("channel_only", True)
+ @utils.kwarg("help", "Prevent me saying anything for a period of time "
+ "(default: 5 minutes)")
+ @utils.kwarg("usage", "[+time]")
+ @utils.kwarg("require_mode", "high")
+ @utils.kwarg("require_access", "silence")
+ @utils.kwarg("permission", "silence")
+ def silence(self, event):
+ duration = SILENCE_TIME
+ if event["args"] and event["args_split"][0].startswith("+"):
+ duration = utils.datetime.from_pretty_time(
+ event["args_split"][0][1:])
+ if duration == None:
+ raise utils.EventError("Invalid duration provided")
+
+ silence_until = time.time()+duration
+ event["target"].set_setting("silence-until", silence_until)
+ event["stdout"].write("Ok, I'll be back")
+
+ @utils.hook("received.command.unsilence")
+ @utils.kwarg("help", "Unsilence me")
+ @utils.kwarg("unsilence", True)
+ @utils.kwarg("channel_only", True)
+ @utils.kwarg("require_mode", "high")
+ @utils.kwarg("require_access", "unsilence")
+ @utils.kwarg("permission", "unsilence")
+ def unsiltence(self, event):
+ silence_until = event["target"].get_setting("silence-until", None)
+ if not silence_until == None:
+ event["target"].del_setting("silence-until")
+ event["stdout"].write("Ok. I've been unsilenced")
+ else:
+ event["stderr"].write("I am not silenced")
+
+ @utils.hook("preprocess.command", priority=EventManager.PRIORITY_HIGH)
+ def preprocess_command(self, event):
+ if event["is_channel"] and not event["hook"].get_kwarg(
+ "unsilence", False):
+ silence_until = event["target"].get_setting("silence-until", None)
+ if silence_until:
+ if self._is_silenced(event["target"]):
+ return utils.consts.PERMISSION_HARD_FAIL, None
+
+ @utils.hook("unknown.command")
+ @utils.kwarg("priority", EventManager.PRIORITY_HIGH)
+ def unknown_command(self, event):
+ if event["is_channel"] and self._is_silenced(event["target"]):
+ event.eat()
diff --git a/src/core_modules/strip_color.py b/src/core_modules/strip_color.py
new file mode 100644
index 00000000..736d066e
--- /dev/null
+++ b/src/core_modules/strip_color.py
@@ -0,0 +1,22 @@
+#--depends-on config
+
+from src import ModuleManager, utils
+
+@utils.export("serverset", utils.BoolSetting("strip-color",
+ "Set whether I strip colors from my messages on this server"))
+@utils.export("channelset", utils.BoolSetting("strip-color",
+ "Set whether I strip colors from my messages on in this channel"))
+class Module(ModuleManager.BaseModule):
+ @utils.hook("preprocess.send.privmsg")
+ @utils.hook("preprocess.send.notice")
+ def preprocess(self, event):
+ if len(event["line"].args) > 1:
+ strip_color = event["server"].get_setting("strip-color", False)
+ target = event["line"].args[0]
+ if not strip_color and target in event["server"].channels:
+ channel = event["server"].channels.get(target)
+ strip_color = channel.get_setting("strip-color", False)
+
+ if strip_color:
+ message = event["line"].args[1]
+ event["line"].args[1] = utils.irc.strip_font(message)
diff --git a/src/core_modules/strip_otr.py b/src/core_modules/strip_otr.py
new file mode 100644
index 00000000..bdb273a5
--- /dev/null
+++ b/src/core_modules/strip_otr.py
@@ -0,0 +1,15 @@
+from src import EventManager, ModuleManager, utils
+
+# Strip magic whitespace string from the end of messages.
+# OTR uses this string to advertise, over plaintext, that the sending user
+# supports OTR.
+
+MAGIC = " \t \t\t\t\t \t \t \t \t\t \t \t"
+
+class Module(ModuleManager.BaseModule):
+ @utils.hook("raw.received.privmsg")
+ @utils.kwarg("priority", EventManager.PRIORITY_HIGH)
+ def on_message(self, event):
+ message = event["line"].args.get(1)
+ if message.endswith(MAGIC):
+ event["line"].args[1] = message.rsplit(MAGIC, 1)[0]
diff --git a/src/core_modules/throttle.py b/src/core_modules/throttle.py
new file mode 100644
index 00000000..e204cc34
--- /dev/null
+++ b/src/core_modules/throttle.py
@@ -0,0 +1,17 @@
+from src import ModuleManager, utils
+
+def _parse(value):
+ lines, _, seconds = value.partition(":")
+ if lines.isdigit() and seconds.isdigit():
+ return [int(lines), int(seconds)]
+ return None
+
+@utils.export("serverset", utils.FunctionSetting(_parse, "throttle",
+ "Configure lines:seconds throttle for the current server", example="4:2"))
+class Module(ModuleManager.BaseModule):
+ @utils.hook("received.001")
+ def connect(self, event):
+ throttle = event["server"].get_setting("throttle", None)
+ if throttle:
+ lines, seconds = throttle
+ event["server"].socket.set_throttle(lines, seconds)