| @@ -6,3 +6,4 @@ __pycache__ | |||
| build | |||
| dist | |||
| docs/_build | |||
| venv | |||
| @@ -1,6 +1,6 @@ | |||
| # -*- 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 | |||
| # 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 | |||
| other channels.""" | |||
| name = "chanops" | |||
| commands = ["chanops", "voice", "devoice", "op", "deop", "join", "part"] | |||
| commands = ["chanops", "voice", "devoice", "op", "deop", "join", "part", "listchans"] | |||
| def process(self, data): | |||
| 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 | |||
| de_escalate = data.command in ["devoice", "deop"] | |||
| if de_escalate and (not data.args or data.args[0] == data.nick): | |||
| @@ -44,6 +45,8 @@ class ChanOps(Command): | |||
| self.do_join(data) | |||
| elif data.command == "part": | |||
| self.do_part(data) | |||
| elif data.command == "listchans": | |||
| self.do_list(data) | |||
| else: | |||
| # If it is just !op/!devoice/whatever without arguments, assume | |||
| # they want to do this to themselves: | |||
| @@ -89,3 +92,11 @@ class ChanOps(Command): | |||
| log += ' ("{0}")'.format(reason) | |||
| self.part(channel, msg) | |||
| 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 -*- | |||
| # | |||
| # 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 | |||
| # 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", | |||
| "allstalks", "allwatches", "unstalkall", "unwatchall"] | |||
| hooks = ["msg", "rc"] | |||
| MAX_STALKS_PER_USER = 10 | |||
| MAX_STALKS_PER_USER = 5 | |||
| def setup(self): | |||
| self._users = {} | |||
| @@ -49,7 +49,8 @@ class Stalk(Command): | |||
| def process(self, data): | |||
| if isinstance(data, RC): | |||
| return self._process_rc(data) | |||
| self._process_rc(data) | |||
| return | |||
| 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)) | |||
| 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("_", " ") | |||
| if target.startswith("[[") and target.endswith("]]"): | |||
| target = target[2:-2] | |||
| @@ -89,14 +96,14 @@ class Stalk(Command): | |||
| if data.command in ["stalk", "watch"]: | |||
| if data.is_private: | |||
| stalkinfo = (data.nick, None) | |||
| stalkinfo = (data.nick, None, modifiers) | |||
| elif not data.is_admin: | |||
| self.reply(data, "You must be a bot admin to stalk users or " | |||
| "watch pages publicly. Retry this command in " | |||
| "a private message.") | |||
| return | |||
| else: | |||
| stalkinfo = (data.nick, data.chan) | |||
| stalkinfo = (data.nick, data.chan, modifiers) | |||
| if data.command == "stalk": | |||
| self._add_stalk("user", data, target, stalkinfo) | |||
| @@ -113,38 +120,53 @@ class Stalk(Command): | |||
| def _process_rc(self, rc): | |||
| """Process a watcher event.""" | |||
| def _update_chans(items): | |||
| def _update_chans(items, flags): | |||
| 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] 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]) | |||
| else: | |||
| chans[item[1]] = {item[0]} | |||
| if modifiers.get("nocolor"): | |||
| nocolor.add(item[1]) | |||
| else: | |||
| chans[item[0]] = None | |||
| if modifiers.get("nocolor"): | |||
| nocolor.add(item[0]) | |||
| def _regex_match(target, 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(): | |||
| if target == tag or _regex_match(target, tag): | |||
| _update_chans(stalks) | |||
| _update_chans(stalks, flags) | |||
| chans = {} | |||
| _process(self._users, rc.user) | |||
| nocolor = set() | |||
| _process(self._users, rc.user, rc.flags) | |||
| if rc.is_edit: | |||
| _process(self._pages, rc.page) | |||
| _process(self._pages, rc.page, rc.flags) | |||
| if not chans: | |||
| return | |||
| with self.bot.component_lock: | |||
| frontend = self.bot.frontend | |||
| 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) | |||
| else: | |||
| msg = pretty | |||
| @@ -181,6 +203,10 @@ class Stalk(Command): | |||
| "for non-bot admins.") | |||
| self.reply(data, msg.format(verb, nstalks, stalktype)) | |||
| 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 stalkinfo in table[target]: | |||
| @@ -285,8 +311,13 @@ class Stalk(Command): | |||
| """Return all existing stalks, for bot admins.""" | |||
| def _format_info(info): | |||
| 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): | |||
| return ", ".join(_format_info(info) for info in data) | |||
| @@ -1,6 +1,6 @@ | |||
| # -*- 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 | |||
| # of this software and associated documentation files (the "Software"), to deal | |||
| @@ -48,6 +48,7 @@ class Frontend(IRCConnection): | |||
| cf["realname"], bot.logger.getChild("frontend")) | |||
| self._auth_wait = False | |||
| self._channels = set() | |||
| self._connect() | |||
| def __repr__(self): | |||
| @@ -62,18 +63,45 @@ class Frontend(IRCConnection): | |||
| return res.format(self.nick, self.ident, self.host, self.port) | |||
| 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) | |||
| 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): | |||
| """Process a single message from IRC.""" | |||
| if line[1] == "JOIN": | |||
| data = Data(self.nick, line, msgtype="JOIN") | |||
| if data.nick == self.nick: | |||
| self._add_channel(data.chan) | |||
| self.bot.commands.call("join", data) | |||
| elif line[1] == "PART": | |||
| data = Data(self.nick, line, msgtype="PART") | |||
| if data.nick == self.nick: | |||
| self._remove_channel(data.chan) | |||
| self.bot.commands.call("part", data) | |||
| elif line[1] == "PRIVMSG": | |||
| @@ -93,6 +121,10 @@ class Frontend(IRCConnection): | |||
| sleep(2) # Wait for hostname change to propagate | |||
| 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 | |||
| # If we're supposed to auth to NickServ, do that: | |||
| try: | |||
| @@ -111,3 +143,8 @@ class Frontend(IRCConnection): | |||
| # Services is down, or something...? | |||
| self._auth_wait = False | |||
| 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 -*- | |||
| # | |||
| # 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 | |||
| # 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_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): | |||
| self.chan = chan | |||
| @@ -57,7 +59,7 @@ class RC(object): | |||
| try: | |||
| page, self.flags, url, user, comment = self.re_edit.findall(msg)[0] | |||
| 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: | |||
| page, flags, user, comment = self.re_log.findall(msg)[0] | |||
| 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 | |||
| def prettify(self): | |||
| def prettify(self, color=True): | |||
| """Make a nice, colorful message to send back to the IRC front-end.""" | |||
| flags = self.flags | |||
| if self.is_edit: | |||
| @@ -82,8 +84,8 @@ class RC(object): | |||
| event = "bot edit" # "New bot edit:" | |||
| if "M" in flags: | |||
| 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": | |||
| event = "deletion" # "New deletion:" | |||
| @@ -93,4 +95,5 @@ class RC(object): | |||
| event = "user" # "New user:" | |||
| else: | |||
| 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) | |||