From b9a315cf1ae0e15e40281066ae75b606ed28ffb5 Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Sat, 18 Jul 2015 04:19:31 -0400 Subject: [PATCH] Add a !stalk/!watch command and a bunch of framework to support it. --- CHANGELOG | 6 +- earwigbot/commands/stalk.py | 290 ++++++++++++++++++++++++++++++++++++++++++++ earwigbot/irc/watcher.py | 1 + earwigbot/managers.py | 6 +- 4 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 earwigbot/commands/stalk.py diff --git a/CHANGELOG b/CHANGELOG index 23005a4..7e63d8d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,10 @@ v0.2 (unreleased): -- Added !epoch command; expanded !remind; improved !access and !threads. +- Added 'rc' hook type to allow IRC commands to respond to RC watcher events. +- Added !stalk/!watch. +- Added !epoch as a subcommand of !time. +- Expanded and improved !remind. +- Improved general behavior of !access and !threads. - Fixed API behavior when blocked, when using AssertEdit, and under other circumstances. - Added copyvio detector functionality: specifying a max time for checks; diff --git a/earwigbot/commands/stalk.py b/earwigbot/commands/stalk.py new file mode 100644 index 0000000..0fb4b81 --- /dev/null +++ b/earwigbot/commands/stalk.py @@ -0,0 +1,290 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2015 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 +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from earwigbot.commands import Command +from earwigbot.irc import RC + +class Stalk(Command): + """Stalk a particular user (!stalk/!unstalk) or page (!watch/!unwatch) for + edits. Applies to the current bot session only.""" + name = "stalk" + commands = ["stalk", "watch", "unstalk", "unwatch", "stalks", "watches", + "allstalks", "allwatches", "unstalkall", "unwatchall"] + hooks = ["msg", "rc"] + MAX_STALKS_PER_USER = 10 + + def setup(self): + self._users = {} + self._pages = {} + + def check(self, data): + if isinstance(data, RC): + return True + if data.is_command and data.command in self.commands: + return True + return False + + def process(self, data): + if isinstance(data, RC): + return self._process_rc(data) + + data.is_admin = self.config.irc["permissions"].is_admin(data) + + if data.command.startswith("all"): + if data.is_admin: + self.reply(data, self._all_stalks()) + else: + self.reply(data, "You must be a bot admin to view all stalked " + "users or watched pages. View your own with " + "\x0306!stalks\x0F.") + return + + if data.command.endswith("all"): + if not data.is_admin: + self.reply(data, "You must be a bot admin to unstalk a user " + "or unwatch a page for all users.") + return + if not data.args: + self.reply(data, "You must give a user to unstalk or a page " + "to unwatch. View all active with " + "\x0306!allstalks\x0F.") + return + + if not data.args or data.command in ["stalks", "watches"]: + self.reply(data, self._current_stalks(data.nick)) + return + + target = " ".join(data.args) + if target.startswith("[[") and target.endswith("]]"): + target = target[2:-2] + if target.startswith("User:") and "stalk" in data.command: + target = target[5:] + + if data.command in ["stalk", "watch"]: + if data.is_private: + stalkinfo = (data.nick, None) + 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) + + if data.command == "stalk": + self._add_stalk("user", data, target, stalkinfo) + elif data.command == "watch": + self._add_stalk("page", data, target, stalkinfo) + elif data.command == "unstalk": + self._remove_stalk("user", data, target) + elif data.command == "unwatch": + self._remove_stalk("page", data, target) + elif data.command == "unstalkall": + self._remove_all_stalks("user", data, target) + elif data.command == "unwatchall": + self._remove_all_stalks("page", data, target) + + def _process_rc(self, rc): + """Process a watcher event.""" + def _update_chans(items): + for item in items: + if item[1]: + if item[1] in chans: + chans[item[1]].add(item[0]) + else: + chans[item[1]] = {item[0]} + else: + chans[item[0]] = None + + chans = {} + if rc.user in self._users: + _update_chans(self._users[rc.user]) + if rc.is_edit and rc.page in self._pages: + _update_chans(self._pages[rc.page]) + 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])) + msg = "\x02{0}\x0F: {1}".format(nicks, pretty) + else: + msg = pretty + if len(msg) > 400: + msg = msg[:397] + "..." + frontend.say(chan, msg) + + @staticmethod + def _get_stalks_by_nick(nick, table): + """Return a dictionary of stalklist entries by the given nick.""" + entries = {} + for target, stalks in table.iteritems(): + for info in stalks: + if info[0] == nick: + if target in entries: + entries[target].append(info[1]) + else: + entries[target] = [info[1]] + return entries + + def _add_stalk(self, stalktype, data, target, stalkinfo): + """Add a stalk entry to the given table.""" + if stalktype == "user": + table = self._users + verb = "stalk" + else: + table = self._pages + verb = "watch" + + if not data.is_admin: + nstalks = len(self._get_stalks_by_nick(data.nick, table)) + if nstalks >= self.MAX_STALKS_PER_USER: + msg = ("Already {0}ing {1} {2}s for you, which is the limit " + "for non-bot admins.") + self.reply(data, msg.format(verb, nstalks, stalktype)) + return + + if target in table: + if stalkinfo in table[target]: + msg = "Already {0}ing that {1} in here for you." + self.reply(data, msg.format(verb, stalktype)) + return + else: + table[target].append(stalkinfo) + else: + table[target] = [stalkinfo] + + msg = "Now {0}ing {1} \x0302{2}\x0F. Remove with \x0306!un{0} {2}\x0F." + self.reply(data, msg.format(verb, stalktype, target)) + + def _remove_stalk(self, stalktype, data, target): + """Remove a stalk entry from the given table.""" + if stalktype == "user": + table = self._users + verb = "stalk" + plural = "stalks" + else: + table = self._pages + verb = "watch" + plural = "watches" + + to_remove = [] + if target in table: + for info in table[target]: + if info[0] == data.nick: + to_remove.append(info) + + if to_remove: + for info in to_remove: + table[target].remove(info) + if not table[target]: + del table[target] + msg = "No longer {0}ing {1} \x0302{2}\x0F for you." + self.reply(data, msg.format(verb, stalktype, target)) + return + + msg = ("I haven't been {0}ing that {1} for you in the first place. " + "View your active {2} with \x0306!{2}\x0F.") + if data.is_admin: + msg += (" As a bot admin, you can clear all active {2} on that " + "{1} with \x0306!un{0}all {2}\x0F.") + self.reply(data, msg.format(verb, stalktype, plural)) + + def _remove_all_stalks(self, stalktype, data, target): + """Remove all entries for a particular target from the given table.""" + if stalktype == "user": + table = self._users + verb = "stalk" + plural = "stalks" + else: + table = self._pages + verb = "watch" + plural = "watches" + + try: + del table[target] + except KeyError: + msg = ("I haven't been {0}ing that {1} for anyone in the first " + "place. View all active {2} with \x0306!all{2}\x0F.") + self.reply(data, msg.format(verb, stalktype, plural)) + else: + msg = "No longer {0}ing {1} \x0302{2}\x0F for anyone." + self.reply(data, msg.format(verb, stalktype, target)) + + def _current_stalks(self, nick): + """Return the given user's current stalks.""" + def _format_chans(chans): + if None in chans: + chans.remove(None) + if not chans: + return "privately" + if len(chans) == 1: + return "in {0} and privately".format(chans[0]) + return "in " + ", ".join(chans) + ", and privately" + return "in " + ", ".join(chans) + + def _format_stalks(stalks): + return ", ".join( + "\x0302{0}\x0F ({1})".format(target, _format_chans(chans)) + for target, chans in stalks.iteritems()) + + users = self._get_stalks_by_nick(nick, self._users) + pages = self._get_stalks_by_nick(nick, self._pages) + if users: + uinfo = " Users: {0}.".format(_format_stalks(users)) + if pages: + pinfo = " Pages: {0}.".format(_format_stalks(pages)) + + msg = "Currently stalking {0} user{1} and watching {2} page{3} for you.{4}{5}" + return msg.format(len(users), "s" if len(users) != 1 else "", + len(pages), "s" if len(pages) != 1 else "", + uinfo if users else "", pinfo if pages else "") + + def _all_stalks(self): + """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]) + + def _format_data(data): + return ", ".join(_format_info(info) for info in data) + + def _format_stalks(stalks): + return ", ".join( + "\x0302{0}\x0F ({1})".format(target, _format_data(data)) + for target, data in stalks.iteritems()) + + users, pages = self._users, self._pages + if users: + uinfo = " Users: {0}.".format(_format_stalks(users)) + if pages: + pinfo = " Pages: {0}.".format(_format_stalks(pages)) + + msg = "Currently stalking {0} user{1} and watching {2} page{3}.{4}{5}" + return msg.format(len(users), "s" if len(users) != 1 else "", + len(pages), "s" if len(pages) != 1 else "", + uinfo if users else "", pinfo if pages else "") diff --git a/earwigbot/irc/watcher.py b/earwigbot/irc/watcher.py index 0ce5b63..82b0091 100644 --- a/earwigbot/irc/watcher.py +++ b/earwigbot/irc/watcher.py @@ -72,6 +72,7 @@ class Watcher(IRCConnection): rc = RC(chan, msg) # New RC object to store this event's data rc.parse() # Parse a message into pagenames, usernames, etc. self._process_rc_event(rc) + self.bot.commands.call("rc", rc) # When we've finished starting up, join all watcher channels: elif line[1] == "376": diff --git a/earwigbot/managers.py b/earwigbot/managers.py index cb5ebbf..72fad74 100644 --- a/earwigbot/managers.py +++ b/earwigbot/managers.py @@ -200,7 +200,11 @@ class CommandManager(_ResourceManager): self.logger.exception(e.format(command.name)) def call(self, hook, data): - """Respond to a hook type and a :py:class:`Data` object.""" + """Respond to a hook type and a :py:class:`~.Data` object. + + .. note:: + The special ``rc`` hook actually passes a :class:`~.RC` object. + """ for command in self: if hook in command.hooks and self._wrap_check(command, data): thread = Thread(target=self._wrap_process,