diff --git a/.gitignore b/.gitignore index d70b37d..be3378f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ __pycache__ build dist docs/_build +venv diff --git a/earwigbot/commands/chanops.py b/earwigbot/commands/chanops.py index 731f52b..32b3996 100644 --- a/earwigbot/commands/chanops.py +++ b/earwigbot/commands/chanops.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2009-2015 Ben Kurtovic +# Copyright (C) 2009-2021 Ben Kurtovic # # 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))) diff --git a/earwigbot/commands/stalk.py b/earwigbot/commands/stalk.py index 29db53f..55841c8 100644 --- a/earwigbot/commands/stalk.py +++ b/earwigbot/commands/stalk.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2009-2015 Ben Kurtovic +# Copyright (C) 2009-2021 Ben Kurtovic # # 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) diff --git a/earwigbot/irc/frontend.py b/earwigbot/irc/frontend.py index 3a4154a..3503ba7 100644 --- a/earwigbot/irc/frontend.py +++ b/earwigbot/irc/frontend.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2009-2016 Ben Kurtovic +# Copyright (C) 2009-2021 Ben Kurtovic # # 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 diff --git a/earwigbot/irc/rc.py b/earwigbot/irc/rc.py index 3e71ba9..557d1b0 100644 --- a/earwigbot/irc/rc.py +++ b/earwigbot/irc/rc.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2009-2015 Ben Kurtovic +# Copyright (C) 2009-2021 Ben Kurtovic # # 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)