| @@ -6,3 +6,4 @@ __pycache__ | |||||
| build | build | ||||
| dist | dist | ||||
| docs/_build | docs/_build | ||||
| venv | |||||
| @@ -1,6 +1,6 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| # | # | ||||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # Copyright (C) 2009-2021 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # | # | ||||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | # Permission is hereby granted, free of charge, to any person obtaining a copy | ||||
| # of this software and associated documentation files (the "Software"), to deal | # of this software and associated documentation files (the "Software"), to deal | ||||
| @@ -26,12 +26,13 @@ class ChanOps(Command): | |||||
| """Voice, devoice, op, or deop users in the channel, or join or part from | """Voice, devoice, op, or deop users in the channel, or join or part from | ||||
| other channels.""" | other channels.""" | ||||
| name = "chanops" | name = "chanops" | ||||
| commands = ["chanops", "voice", "devoice", "op", "deop", "join", "part"] | |||||
| commands = ["chanops", "voice", "devoice", "op", "deop", "join", "part", "listchans"] | |||||
| def process(self, data): | def process(self, data): | ||||
| if data.command == "chanops": | if data.command == "chanops": | ||||
| msg = "Available commands are !voice, !devoice, !op, !deop, !join, and !part." | |||||
| self.reply(data, msg) | |||||
| msg = "Available commands are {0}." | |||||
| self.reply(data, msg.format(", ".join( | |||||
| "!" + cmd for cmd in self.commands if cmd != data.command))) | |||||
| return | return | ||||
| de_escalate = data.command in ["devoice", "deop"] | de_escalate = data.command in ["devoice", "deop"] | ||||
| if de_escalate and (not data.args or data.args[0] == data.nick): | if de_escalate and (not data.args or data.args[0] == data.nick): | ||||
| @@ -44,6 +45,8 @@ class ChanOps(Command): | |||||
| self.do_join(data) | self.do_join(data) | ||||
| elif data.command == "part": | elif data.command == "part": | ||||
| self.do_part(data) | self.do_part(data) | ||||
| elif data.command == "listchans": | |||||
| self.do_list(data) | |||||
| else: | else: | ||||
| # If it is just !op/!devoice/whatever without arguments, assume | # If it is just !op/!devoice/whatever without arguments, assume | ||||
| # they want to do this to themselves: | # they want to do this to themselves: | ||||
| @@ -89,3 +92,11 @@ class ChanOps(Command): | |||||
| log += ' ("{0}")'.format(reason) | log += ' ("{0}")'.format(reason) | ||||
| self.part(channel, msg) | self.part(channel, msg) | ||||
| self.logger.info(log) | self.logger.info(log) | ||||
| def do_list(self, data): | |||||
| chans = self.bot.frontend.channels | |||||
| if not chans: | |||||
| self.reply(data, "I am currently in no channels.") | |||||
| return | |||||
| self.reply(data, "I am currently in \x02{0}\x0F channel{1}: {2}.".format( | |||||
| len(chans), "" if len(chans) == 1 else "s", ", ".join(chans))) | |||||
| @@ -1,6 +1,6 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| # | # | ||||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # Copyright (C) 2009-2021 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # | # | ||||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | # Permission is hereby granted, free of charge, to any person obtaining a copy | ||||
| # of this software and associated documentation files (the "Software"), to deal | # of this software and associated documentation files (the "Software"), to deal | ||||
| @@ -33,7 +33,7 @@ class Stalk(Command): | |||||
| commands = ["stalk", "watch", "unstalk", "unwatch", "stalks", "watches", | commands = ["stalk", "watch", "unstalk", "unwatch", "stalks", "watches", | ||||
| "allstalks", "allwatches", "unstalkall", "unwatchall"] | "allstalks", "allwatches", "unstalkall", "unwatchall"] | ||||
| hooks = ["msg", "rc"] | hooks = ["msg", "rc"] | ||||
| MAX_STALKS_PER_USER = 10 | |||||
| MAX_STALKS_PER_USER = 5 | |||||
| def setup(self): | def setup(self): | ||||
| self._users = {} | self._users = {} | ||||
| @@ -49,7 +49,8 @@ class Stalk(Command): | |||||
| def process(self, data): | def process(self, data): | ||||
| if isinstance(data, RC): | if isinstance(data, RC): | ||||
| return self._process_rc(data) | |||||
| self._process_rc(data) | |||||
| return | |||||
| data.is_admin = self.config.irc["permissions"].is_admin(data) | data.is_admin = self.config.irc["permissions"].is_admin(data) | ||||
| @@ -77,6 +78,12 @@ class Stalk(Command): | |||||
| self.reply(data, self._current_stalks(data.nick)) | self.reply(data, self._current_stalks(data.nick)) | ||||
| return | return | ||||
| modifiers = {} | |||||
| for modifier in ["noping", "nobots", "nominor", "nocolor"]: | |||||
| if "!" + modifier in data.args: | |||||
| modifiers[modifier] = True | |||||
| data.args.remove("!" + modifier) | |||||
| target = " ".join(data.args).replace("_", " ") | target = " ".join(data.args).replace("_", " ") | ||||
| if target.startswith("[[") and target.endswith("]]"): | if target.startswith("[[") and target.endswith("]]"): | ||||
| target = target[2:-2] | target = target[2:-2] | ||||
| @@ -89,14 +96,14 @@ class Stalk(Command): | |||||
| if data.command in ["stalk", "watch"]: | if data.command in ["stalk", "watch"]: | ||||
| if data.is_private: | if data.is_private: | ||||
| stalkinfo = (data.nick, None) | |||||
| stalkinfo = (data.nick, None, modifiers) | |||||
| elif not data.is_admin: | elif not data.is_admin: | ||||
| self.reply(data, "You must be a bot admin to stalk users or " | self.reply(data, "You must be a bot admin to stalk users or " | ||||
| "watch pages publicly. Retry this command in " | "watch pages publicly. Retry this command in " | ||||
| "a private message.") | "a private message.") | ||||
| return | return | ||||
| else: | else: | ||||
| stalkinfo = (data.nick, data.chan) | |||||
| stalkinfo = (data.nick, data.chan, modifiers) | |||||
| if data.command == "stalk": | if data.command == "stalk": | ||||
| self._add_stalk("user", data, target, stalkinfo) | self._add_stalk("user", data, target, stalkinfo) | ||||
| @@ -113,38 +120,53 @@ class Stalk(Command): | |||||
| def _process_rc(self, rc): | def _process_rc(self, rc): | ||||
| """Process a watcher event.""" | """Process a watcher event.""" | ||||
| def _update_chans(items): | |||||
| def _update_chans(items, flags): | |||||
| for item in items: | for item in items: | ||||
| modifiers = item[2] if len(item) > 2 else {} | |||||
| if modifiers.get("nobots") and "B" in flags: | |||||
| continue | |||||
| if modifiers.get("nominor") and "M" in flags: | |||||
| continue | |||||
| if item[1]: | if item[1]: | ||||
| if item[1] in chans: | |||||
| if modifiers.get("noping"): | |||||
| if item[1] not in chans: | |||||
| chans[item[1]] = set() | |||||
| elif item[1] in chans: | |||||
| chans[item[1]].add(item[0]) | chans[item[1]].add(item[0]) | ||||
| else: | else: | ||||
| chans[item[1]] = {item[0]} | chans[item[1]] = {item[0]} | ||||
| if modifiers.get("nocolor"): | |||||
| nocolor.add(item[1]) | |||||
| else: | else: | ||||
| chans[item[0]] = None | chans[item[0]] = None | ||||
| if modifiers.get("nocolor"): | |||||
| nocolor.add(item[0]) | |||||
| def _regex_match(target, tag): | def _regex_match(target, tag): | ||||
| return target.startswith("re:") and re.match(target[3:], tag) | return target.startswith("re:") and re.match(target[3:], tag) | ||||
| def _process(table, tag): | |||||
| def _process(table, tag, flags): | |||||
| for target, stalks in table.iteritems(): | for target, stalks in table.iteritems(): | ||||
| if target == tag or _regex_match(target, tag): | if target == tag or _regex_match(target, tag): | ||||
| _update_chans(stalks) | |||||
| _update_chans(stalks, flags) | |||||
| chans = {} | chans = {} | ||||
| _process(self._users, rc.user) | |||||
| nocolor = set() | |||||
| _process(self._users, rc.user, rc.flags) | |||||
| if rc.is_edit: | if rc.is_edit: | ||||
| _process(self._pages, rc.page) | |||||
| _process(self._pages, rc.page, rc.flags) | |||||
| if not chans: | if not chans: | ||||
| return | return | ||||
| with self.bot.component_lock: | with self.bot.component_lock: | ||||
| frontend = self.bot.frontend | frontend = self.bot.frontend | ||||
| if frontend and not frontend.is_stopped(): | if frontend and not frontend.is_stopped(): | ||||
| pretty = rc.prettify() | |||||
| for chan in chans: | |||||
| if chans[chan]: | |||||
| nicks = ", ".join(sorted(chans[chan])) | |||||
| for chan, users in chans.iteritems(): | |||||
| if chan.startswith("#") and chan not in frontend.channels: | |||||
| continue | |||||
| pretty = rc.prettify(color=chan not in nocolor) | |||||
| if users: | |||||
| nicks = ", ".join(sorted(users)) | |||||
| msg = "\x02{0}\x0F: {1}".format(nicks, pretty) | msg = "\x02{0}\x0F: {1}".format(nicks, pretty) | ||||
| else: | else: | ||||
| msg = pretty | msg = pretty | ||||
| @@ -181,6 +203,10 @@ class Stalk(Command): | |||||
| "for non-bot admins.") | "for non-bot admins.") | ||||
| self.reply(data, msg.format(verb, nstalks, stalktype)) | self.reply(data, msg.format(verb, nstalks, stalktype)) | ||||
| return | return | ||||
| if stalkinfo[1] and not stalkinfo[1].startswith("##"): | |||||
| msg = "You must be a bot admin to {0} {1}s in public channels." | |||||
| self.reply(data, msg.format(verb, stalktype)) | |||||
| return | |||||
| if target in table: | if target in table: | ||||
| if stalkinfo in table[target]: | if stalkinfo in table[target]: | ||||
| @@ -285,8 +311,13 @@ class Stalk(Command): | |||||
| """Return all existing stalks, for bot admins.""" | """Return all existing stalks, for bot admins.""" | ||||
| def _format_info(info): | def _format_info(info): | ||||
| if info[1]: | if info[1]: | ||||
| return "for {0} in {1}".format(info[0], info[1]) | |||||
| return "for {0} privately".format(info[0]) | |||||
| result = "for {0} in {1}".format(info[0], info[1]) | |||||
| else: | |||||
| result = "for {0} privately".format(info[0]) | |||||
| modifiers = ", ".join(info[2]) if len(info) > 2 else "" | |||||
| if modifiers: | |||||
| result += " ({0})".format(modifiers) | |||||
| return result | |||||
| def _format_data(data): | def _format_data(data): | ||||
| return ", ".join(_format_info(info) for info in data) | return ", ".join(_format_info(info) for info in data) | ||||
| @@ -1,6 +1,6 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| # | # | ||||
| # Copyright (C) 2009-2016 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # Copyright (C) 2009-2021 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # | # | ||||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | # Permission is hereby granted, free of charge, to any person obtaining a copy | ||||
| # of this software and associated documentation files (the "Software"), to deal | # of this software and associated documentation files (the "Software"), to deal | ||||
| @@ -48,6 +48,7 @@ class Frontend(IRCConnection): | |||||
| cf["realname"], bot.logger.getChild("frontend")) | cf["realname"], bot.logger.getChild("frontend")) | ||||
| self._auth_wait = False | self._auth_wait = False | ||||
| self._channels = set() | |||||
| self._connect() | self._connect() | ||||
| def __repr__(self): | def __repr__(self): | ||||
| @@ -62,18 +63,45 @@ class Frontend(IRCConnection): | |||||
| return res.format(self.nick, self.ident, self.host, self.port) | return res.format(self.nick, self.ident, self.host, self.port) | ||||
| def _join_channels(self): | def _join_channels(self): | ||||
| """Join all startup channels as specified by the config file.""" | |||||
| for chan in self.bot.config.irc["frontend"]["channels"]: | |||||
| """Join all startup channels.""" | |||||
| permdb = self.bot.config.irc["permissions"] | |||||
| try: | |||||
| # Try channels previously joined: | |||||
| chans = permdb.get_attr("meta:frontend", "channels").split(",") | |||||
| except KeyError: | |||||
| # Channels specified in the config file: | |||||
| chans = self.bot.config.irc["frontend"]["channels"] | |||||
| for chan in chans: | |||||
| self.join(chan) | self.join(chan) | ||||
| def _save_channels(self): | |||||
| """Save the channel list persistently.""" | |||||
| permdb = self.bot.config.irc["permissions"] | |||||
| permdb.set_attr("meta:frontend", "channels", ",".join(sorted(self._channels))) | |||||
| def _add_channel(self, chan): | |||||
| """Add a channel to the list of our channels.""" | |||||
| self._channels.add(chan) | |||||
| self._save_channels() | |||||
| def _remove_channel(self, chan): | |||||
| """Remove a channel from the list of our channels.""" | |||||
| self._channels.discard(chan) | |||||
| self._save_channels() | |||||
| def _process_message(self, line): | def _process_message(self, line): | ||||
| """Process a single message from IRC.""" | """Process a single message from IRC.""" | ||||
| if line[1] == "JOIN": | if line[1] == "JOIN": | ||||
| data = Data(self.nick, line, msgtype="JOIN") | data = Data(self.nick, line, msgtype="JOIN") | ||||
| if data.nick == self.nick: | |||||
| self._add_channel(data.chan) | |||||
| self.bot.commands.call("join", data) | self.bot.commands.call("join", data) | ||||
| elif line[1] == "PART": | elif line[1] == "PART": | ||||
| data = Data(self.nick, line, msgtype="PART") | data = Data(self.nick, line, msgtype="PART") | ||||
| if data.nick == self.nick: | |||||
| self._remove_channel(data.chan) | |||||
| self.bot.commands.call("part", data) | self.bot.commands.call("part", data) | ||||
| elif line[1] == "PRIVMSG": | elif line[1] == "PRIVMSG": | ||||
| @@ -93,6 +121,10 @@ class Frontend(IRCConnection): | |||||
| sleep(2) # Wait for hostname change to propagate | sleep(2) # Wait for hostname change to propagate | ||||
| self._join_channels() | self._join_channels() | ||||
| elif line[1] == "KICK": | |||||
| if line[3] == self.nick: | |||||
| self._remove_channel(line[2]) | |||||
| elif line[1] == "376": # On successful connection to the server | elif line[1] == "376": # On successful connection to the server | ||||
| # If we're supposed to auth to NickServ, do that: | # If we're supposed to auth to NickServ, do that: | ||||
| try: | try: | ||||
| @@ -111,3 +143,8 @@ class Frontend(IRCConnection): | |||||
| # Services is down, or something...? | # Services is down, or something...? | ||||
| self._auth_wait = False | self._auth_wait = False | ||||
| self._join_channels() | self._join_channels() | ||||
| @property | |||||
| def channels(self): | |||||
| """A set containing all channels the bot is in.""" | |||||
| return self._channels | |||||
| @@ -1,6 +1,6 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| # | # | ||||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # Copyright (C) 2009-2021 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # | # | ||||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | # Permission is hereby granted, free of charge, to any person obtaining a copy | ||||
| # of this software and associated documentation files (the "Software"), to deal | # of this software and associated documentation files (the "Software"), to deal | ||||
| @@ -32,6 +32,8 @@ class RC(object): | |||||
| pretty_edit = "\x02New {0}\x0F: \x0314[[\x0307{1}\x0314]]\x0306 * \x0303{2}\x0306 * \x0302{3}\x0306 * \x0310{4}" | pretty_edit = "\x02New {0}\x0F: \x0314[[\x0307{1}\x0314]]\x0306 * \x0303{2}\x0306 * \x0302{3}\x0306 * \x0310{4}" | ||||
| pretty_log = "\x02New {0}\x0F: \x0303{1}\x0306 * \x0302{2}\x0306 * \x0310{3}" | pretty_log = "\x02New {0}\x0F: \x0303{1}\x0306 * \x0302{2}\x0306 * \x0310{3}" | ||||
| plain_edit = "New {0}: [[{1}]] * {2} * {3} * {4}" | |||||
| plain_log = "New {0}: {1} * {2} * {3}" | |||||
| def __init__(self, chan, msg): | def __init__(self, chan, msg): | ||||
| self.chan = chan | self.chan = chan | ||||
| @@ -57,7 +59,7 @@ class RC(object): | |||||
| try: | try: | ||||
| page, self.flags, url, user, comment = self.re_edit.findall(msg)[0] | page, self.flags, url, user, comment = self.re_edit.findall(msg)[0] | ||||
| except IndexError: | except IndexError: | ||||
| # We're probably missing the http:// part, because it's a log | |||||
| # We're probably missing the https:// part, because it's a log | |||||
| # entry, which lacks a URL: | # entry, which lacks a URL: | ||||
| page, flags, user, comment = self.re_log.findall(msg)[0] | page, flags, user, comment = self.re_log.findall(msg)[0] | ||||
| url = "https://{0}.org/wiki/{1}".format(self.chan[1:], page) | url = "https://{0}.org/wiki/{1}".format(self.chan[1:], page) | ||||
| @@ -70,7 +72,7 @@ class RC(object): | |||||
| self.page, self.url, self.user, self.comment = page, url, user, comment | self.page, self.url, self.user, self.comment = page, url, user, comment | ||||
| def prettify(self): | |||||
| def prettify(self, color=True): | |||||
| """Make a nice, colorful message to send back to the IRC front-end.""" | """Make a nice, colorful message to send back to the IRC front-end.""" | ||||
| flags = self.flags | flags = self.flags | ||||
| if self.is_edit: | if self.is_edit: | ||||
| @@ -82,8 +84,8 @@ class RC(object): | |||||
| event = "bot edit" # "New bot edit:" | event = "bot edit" # "New bot edit:" | ||||
| if "M" in flags: | if "M" in flags: | ||||
| event = "minor " + event # "New minor (bot)? edit:" | event = "minor " + event # "New minor (bot)? edit:" | ||||
| return self.pretty_edit.format(event, self.page, self.user, | |||||
| self.url, self.comment) | |||||
| tmpl = self.pretty_edit if color else self.plain_edit | |||||
| return tmpl.format(event, self.page, self.user, self.url, self.comment) | |||||
| if flags == "delete": | if flags == "delete": | ||||
| event = "deletion" # "New deletion:" | event = "deletion" # "New deletion:" | ||||
| @@ -93,4 +95,5 @@ class RC(object): | |||||
| event = "user" # "New user:" | event = "user" # "New user:" | ||||
| else: | else: | ||||
| event = flags # Works for "move", "block", etc | event = flags # Works for "move", "block", etc | ||||
| return self.pretty_log.format(event, self.user, self.url, self.comment) | |||||
| tmpl = self.pretty_log if color else self.plain_log | |||||
| return tmpl.format(event, self.user, self.url, self.comment) | |||||