Browse Source

Add a !stalk/!watch command and a bunch of framework to support it.

tags/v0.2
Ben Kurtovic 8 years ago
parent
commit
b9a315cf1a
4 changed files with 301 additions and 2 deletions
  1. +5
    -1
      CHANGELOG
  2. +290
    -0
      earwigbot/commands/stalk.py
  3. +1
    -0
      earwigbot/irc/watcher.py
  4. +5
    -1
      earwigbot/managers.py

+ 5
- 1
CHANGELOG View File

@@ -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;


+ 290
- 0
earwigbot/commands/stalk.py View File

@@ -0,0 +1,290 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2009-2015 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
# 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 "")

+ 1
- 0
earwigbot/irc/watcher.py View File

@@ -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":


+ 5
- 1
earwigbot/managers.py View File

@@ -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,


Loading…
Cancel
Save