diff options
| author | 2019-12-10 05:27:35 +0000 | |
|---|---|---|
| committer | 2019-12-10 05:27:35 +0000 | |
| commit | 638eee0d685c06d258cb55287204ca97bca7c344 (patch) | |
| tree | 33442439317ae2846f1efb7674b7a3758c8990a1 /src/core_modules | |
| parent | move sys.exit() codes to an enum in utils.consts (diff) | |
| signature | ||
move core modules to src/core_modules, make them uneffected by white/black list
Diffstat (limited to 'src/core_modules')
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 <username>:<password> + +#### PLAIN +> !serverset sasl plain <username>:<password> + +#### SCRAM-SHA-1 +> !serverset sasl scram-sha-1 <username>:<password> + +#### SCRAM-SHA-256 +> !serverset sasl scram-sha-256 <username>:<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 (<serverid>, 'sasl', '{"mechanism": "userpass", "args": "<username>:<password>"}'); + +#### PLAIN +> INSERT INTO server_settings (<serverid>, 'sasl', '{"mechanism": "plain", "args": "<username>:<password>"}'); + +#### SCRAM-SHA-1 +> INSERT INTO server_settings (<serverid>, 'sasl', '{"mechanism": "scram-sha-1", "args": "<username>:<password>"}'); + +#### SCRAM-SHA-256 +> INSERT INTO server_settings (<serverid>, 'sasl', '{"mechanism": "scram-sha-256", "args": "<username>:<password>"}'); + +#### external +> INSERT INTO server_settings (<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) |
