From f11423ba31414f41d04870c8092be6944d9163ea Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Sat, 12 May 2012 18:55:28 -0400 Subject: [PATCH 01/14] Ported !langcode command (#6) --- earwigbot/commands/_old.py | 18 --------------- earwigbot/commands/langcode.py | 52 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 18 deletions(-) create mode 100644 earwigbot/commands/langcode.py diff --git a/earwigbot/commands/_old.py b/earwigbot/commands/_old.py index 6cbb7c5..b49577e 100644 --- a/earwigbot/commands/_old.py +++ b/earwigbot/commands/_old.py @@ -367,24 +367,6 @@ def parse(command, line, line2, nick, chan, host, auth, notice, say, reply, s): reply("NotImplementedError", chan, nick) elif action == "report": reply("NotImplementedError", chan, nick) - if command == "langcode" or command == "lang" or command == "language": - try: - lang = line2[4] - except Exception: - reply("Please specify an ISO code.", chan, nick) - return - data = urllib.urlopen("http://toolserver.org/~earwig/cgi-bin/swmt.py?action=iso").read() - data = string.split(data, "\n") - result = False - for datum in data: - if datum.startswith(lang): - result = re.findall(".*? (.*)", datum)[0] - break - if result: - reply(result, chan, nick) - return - reply("Not found.", chan, nick) - return if command == "lookup" or command == "ip": try: hexIP = line2[4] diff --git a/earwigbot/commands/langcode.py b/earwigbot/commands/langcode.py new file mode 100644 index 0000000..4630780 --- /dev/null +++ b/earwigbot/commands/langcode.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 by 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 BaseCommand + +class Command(BaseCommand): + """Convert a language code into its name and a list of WMF sites in that + language.""" + name = "langcode" + + def check(self, data): + commands = ["langcode", "lang", "language"] + return data.is_command and data.command in commands + + def process(self, data): + if not data.args: + self.reply(data, "please specify a language code.") + return + + code = data.args[0] + site = self.bot.wiki.get_site() + matrix = site.api_query(action="sitematrix")["sitematrix"] + del matrix["specials"] + + for site in matrix.itervalues(): + if site["code"] == code: + name = site["name"] + sites = ", ".join([s["url"] for s in site["site"]]) + msg = "\x0302{0}\x0302 is {1} ({2})".format(code, name, sites) + self.reply(data, msg) + return + + self.reply(data, "site \x0302{0}\x0301 not found.".format(code)) From 526151e0314a10364413a41b034c522c3fa5e27d Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Sun, 13 May 2012 01:15:55 -0400 Subject: [PATCH 02/14] !geolocate command, plus some cleanup to other commands --- earwigbot/commands/_old.py | 14 -------- earwigbot/commands/crypt.py | 4 +-- earwigbot/commands/editcount.py | 4 +-- earwigbot/commands/geolocate.py | 68 ++++++++++++++++++++++++++++++++++++++ earwigbot/commands/langcode.py | 2 +- earwigbot/commands/link.py | 8 ----- earwigbot/commands/registration.py | 4 +-- earwigbot/commands/remind.py | 4 +-- earwigbot/commands/replag.py | 3 +- earwigbot/commands/rights.py | 4 +-- earwigbot/commands/threads.py | 4 +-- earwigbot/config.py | 5 ++- 12 files changed, 81 insertions(+), 43 deletions(-) create mode 100644 earwigbot/commands/geolocate.py diff --git a/earwigbot/commands/_old.py b/earwigbot/commands/_old.py index b49577e..64fcd74 100644 --- a/earwigbot/commands/_old.py +++ b/earwigbot/commands/_old.py @@ -367,17 +367,3 @@ def parse(command, line, line2, nick, chan, host, auth, notice, say, reply, s): reply("NotImplementedError", chan, nick) elif action == "report": reply("NotImplementedError", chan, nick) - if command == "lookup" or command == "ip": - try: - hexIP = line2[4] - except Exception: - reply("Please specify a hex IP address.", chan, nick) - return - hexes = [hexIP[:2], hexIP[2:4], hexIP[4:6], hexIP[6:8]] - hashes = [] - for hexHash in hexes: - newHex = int(hexHash, 16) - hashes.append(newHex) - normalizedIP = "%s.%s.%s.%s" % (hashes[0], hashes[1], hashes[2], hashes[3]) - reply(normalizedIP, chan, nick) - return diff --git a/earwigbot/commands/crypt.py b/earwigbot/commands/crypt.py index 6a57c8c..ccb5300 100644 --- a/earwigbot/commands/crypt.py +++ b/earwigbot/commands/crypt.py @@ -33,9 +33,7 @@ class Command(BaseCommand): def check(self, data): commands = ["crypt", "hash", "encrypt", "decrypt"] - if data.is_command and data.command in commands: - return True - return False + return data.is_command and data.command in commands def process(self, data): if data.command == "crypt": diff --git a/earwigbot/commands/editcount.py b/earwigbot/commands/editcount.py index 9182877..517a8e3 100644 --- a/earwigbot/commands/editcount.py +++ b/earwigbot/commands/editcount.py @@ -31,9 +31,7 @@ class Command(BaseCommand): def check(self, data): commands = ["ec", "editcount"] - if data.is_command and data.command in commands: - return True - return False + return data.is_command and data.command in commands def process(self, data): if not data.args: diff --git a/earwigbot/commands/geolocate.py b/earwigbot/commands/geolocate.py new file mode 100644 index 0000000..73dee91 --- /dev/null +++ b/earwigbot/commands/geolocate.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 by 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. + +import json +import urllib2 + +from earwigbot.commands import BaseCommand + +class Command(BaseCommand): + """Geolocate an IP address (via http://ipinfodb.com/).""" + name = "geolocate" + + def check(self, data): + commands = ["geolocate", "locate", "geo", "ip"] + return data.is_command and data.command in commands + + def process(self, data): + if not data.args: + self.reply(data, "please specify an IP to lookup.") + return + + try: + key = config.tasks[self.name]["apiKey"] + except KeyError: + msg = 'I need an API key for http://ipinfodb.com/ stored as \x0303config.tasks["{0}"]["apiKey"]\x0301.' + log = 'Need an API key for http://ipinfodb.com/ stored as config.tasks["{0}"]["apiKey"]' + self.reply(data, msg.format(self.name) + ".") + self.logger.error(log.format(self.name)) + return + + address = data.args[0] + url = "http://api.ipinfodb.com/v3/ip-city/?key={0}&ip={1}&format=json" + query = urllib2.urlopen(url.format(key, address)).read() + res = json.loads(query) + + try: + country = res["countryName"] + region = res["regionName"] + city = res["cityName"] + latitude = res["latitude"] + longitude = res["longitude"] + utcoffset = res["timeZone"] + except KeyError: + self.reply(data, "IP \x0302{0}\x0301 not found.".format(address)) + return + + msg = "{0}, {1}, {2} ({3}, {4}), UTC {5}" + geo = msg.format(country, region, city, latitude, longitude, utcoffset) + self.reply(data, geo) diff --git a/earwigbot/commands/langcode.py b/earwigbot/commands/langcode.py index 4630780..6695980 100644 --- a/earwigbot/commands/langcode.py +++ b/earwigbot/commands/langcode.py @@ -45,7 +45,7 @@ class Command(BaseCommand): if site["code"] == code: name = site["name"] sites = ", ".join([s["url"] for s in site["site"]]) - msg = "\x0302{0}\x0302 is {1} ({2})".format(code, name, sites) + msg = "\x0302{0}\x0301 is {1} ({2})".format(code, name, sites) self.reply(data, msg) return diff --git a/earwigbot/commands/link.py b/earwigbot/commands/link.py index cb2e154..152fa08 100644 --- a/earwigbot/commands/link.py +++ b/earwigbot/commands/link.py @@ -29,14 +29,6 @@ class Command(BaseCommand): """Convert a Wikipedia page name into a URL.""" name = "link" - def check(self, data): - # if ((data.is_command and data.command == "link") or - # (("[[" in data.msg and "]]" in data.msg) or - # ("{{" in data.msg and "}}" in data.msg))): - if data.is_command and data.command == "link": - return True - return False - def process(self, data): msg = data.msg diff --git a/earwigbot/commands/registration.py b/earwigbot/commands/registration.py index 44592ef..3ef641c 100644 --- a/earwigbot/commands/registration.py +++ b/earwigbot/commands/registration.py @@ -31,9 +31,7 @@ class Command(BaseCommand): def check(self, data): commands = ["registration", "reg", "age"] - if data.is_command and data.command in commands: - return True - return False + return data.is_command and data.command in commands def process(self, data): if not data.args: diff --git a/earwigbot/commands/remind.py b/earwigbot/commands/remind.py index 05360e6..4a7d6ed 100644 --- a/earwigbot/commands/remind.py +++ b/earwigbot/commands/remind.py @@ -30,9 +30,7 @@ class Command(BaseCommand): name = "remind" def check(self, data): - if data.is_command and data.command in ["remind", "reminder"]: - return True - return False + return data.is_command and data.command in ["remind", "reminder"] def process(self, data): if not data.args: diff --git a/earwigbot/commands/replag.py b/earwigbot/commands/replag.py index fce0240..ab81319 100644 --- a/earwigbot/commands/replag.py +++ b/earwigbot/commands/replag.py @@ -41,7 +41,8 @@ class Command(BaseCommand): conn = oursql.connect(**args) with conn.cursor() as cursor: - query = "SELECT UNIX_TIMESTAMP() - UNIX_TIMESTAMP(rc_timestamp) FROM recentchanges ORDER BY rc_timestamp DESC LIMIT 1" + query = """SELECT UNIX_TIMESTAMP() - UNIX_TIMESTAMP(rc_timestamp) + FROM recentchanges ORDER BY rc_timestamp DESC LIMIT 1""" cursor.execute(query) replag = int(cursor.fetchall()[0][0]) conn.close() diff --git a/earwigbot/commands/rights.py b/earwigbot/commands/rights.py index a2ad76d..63357c7 100644 --- a/earwigbot/commands/rights.py +++ b/earwigbot/commands/rights.py @@ -29,9 +29,7 @@ class Command(BaseCommand): def check(self, data): commands = ["rights", "groups", "permissions", "privileges"] - if data.is_command and data.command in commands: - return True - return False + return data.is_command and data.command in commands def process(self, data): if not data.args: diff --git a/earwigbot/commands/threads.py b/earwigbot/commands/threads.py index 8d1ed17..e1bbb22 100644 --- a/earwigbot/commands/threads.py +++ b/earwigbot/commands/threads.py @@ -32,9 +32,7 @@ class Command(BaseCommand): def check(self, data): commands = ["tasks", "task", "threads", "tasklist"] - if data.is_command and data.command in commands: - return True - return False + return data.is_command and data.command in commands def process(self, data): self.data = data diff --git a/earwigbot/config.py b/earwigbot/config.py index ab9eecd..cee3570 100644 --- a/earwigbot/config.py +++ b/earwigbot/config.py @@ -273,7 +273,10 @@ class BotConfig(object): >>> config.decrypt(config.irc, "frontend", "nickservPassword") # decrypts config.irc["frontend"]["nickservPassword"] """ - self._decryptable_nodes.append((node, nodes)) + signature = (node, nodes) + if signature in self._decryptable_nodes: + return # Already decrypted + self._decryptable_nodes.append(signature) if self.is_encrypted(): self._decrypt(node, nodes) From cfdfc49d789f605b0dd33e9f113ca5a8446cc946 Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Sun, 13 May 2012 01:59:51 -0400 Subject: [PATCH 03/14] Command.setup() like Task.setup(); config.commands like config.tasks --- README.rst | 23 +++++++++++++++++----- docs/customizing.rst | 27 ++++++++++++++++++++++---- earwigbot/commands/__init__.py | 16 +++++++++++++--- earwigbot/commands/geolocate.py | 19 +++++++++++++------ earwigbot/config.py | 42 ++++++++++++++++++++++++----------------- 5 files changed, 92 insertions(+), 35 deletions(-) diff --git a/README.rst b/README.rst index 65f5d37..f14c1d5 100644 --- a/README.rst +++ b/README.rst @@ -138,9 +138,9 @@ The most useful attributes are: `earwigbot.config.BotConfig`_ stores configuration information for the bot. Its docstring explains what each attribute is used for, but essentially each "node" -(one of ``config.components``, ``wiki``, ``tasks``, ``irc``, and ``metadata``) -maps to a section of the bot's ``config.yml`` file. For example, if -``config.yml`` includes something like:: +(one of ``config.components``, ``wiki``, ``irc``, ``commands``, ``tasks``, and +``metadata``) maps to a section of the bot's ``config.yml`` file. For example, +if ``config.yml`` includes something like:: irc: frontend: @@ -158,7 +158,8 @@ Custom IRC commands ~~~~~~~~~~~~~~~~~~~ Custom commands are subclasses of `earwigbot.commands.BaseCommand`_ that -override ``BaseCommand``'s ``process()`` (and optionally ``check()``) methods. +override ``BaseCommand``'s ``process()`` (and optionally ``check()`` or +``setup()``) methods. ``BaseCommand``'s docstrings should explain what each attribute and method is for and what they should be overridden with, but these are the basics: @@ -171,6 +172,12 @@ for and what they should be overridden with, but these are the basics: messages only), and ``"join"`` (for when a user joins a channel). See the afc_status_ plugin for a command that responds to other hook types. +- Method ``setup()`` is called *once* with no arguments immediately after the + command is first loaded. Does nothing by default; treat it like an + ``__init__()`` if you want (``__init__()`` does things by default and a + dedicated setup method is often easier than overriding ``__init__()`` and + using ``super``). + - Method ``check()`` is passed a ``Data`` [2]_ object, and should return ``True`` if you want to respond to this message, or ``False`` otherwise. The default behavior is to return ``True`` only if ``data.is_command`` is @@ -191,6 +198,12 @@ for and what they should be overridden with, but these are the basics: ``self.action(chan_or_user, msg)``, ``self.notice(chan_or_user, msg)``, ``self.join(chan)``, and ``self.part(chan)``. +Commands have access to ``config.commands[command_name]`` for config +information, which is a node in ``config.yml`` like every other attribute of +``bot.config``. This can be used to store, for example, API keys or SQL +connection info, so that these can be easily changed without modifying the +command itself. + It's important to name the command class ``Command`` within the file, or else the bot might not recognize it as a command. The name of the file doesn't really matter and need not match the command's name, but this is recommended @@ -243,7 +256,7 @@ and what they should be overridden with, but these are the basics: Tasks have access to ``config.tasks[task_name]`` for config information, which is a node in ``config.yml`` like every other attribute of ``bot.config``. This -can be used to store, for example, edit summaries, or templates to append to +can be used to store, for example, edit summaries or templates to append to user talk pages, so that these can be easily changed without modifying the task itself. diff --git a/docs/customizing.rst b/docs/customizing.rst index fa4be59..f5b27f4 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -58,8 +58,13 @@ The most useful attributes are: :py:class:`earwigbot.config.BotConfig` stores configuration information for the bot. Its docstrings explains what each attribute is used for, but essentially -each "node" (one of :py:attr:`config.components`, :py:attr:`wiki`, -:py:attr:`tasks`, :py:attr:`tasks`, or :py:attr:`metadata`) maps to a section +each "node" (one of :py:attr:`config.components +`, +:py:attr:`~earwigbot.config.BotConfig.wiki`, +:py:attr:`~earwigbot.config.BotConfig.irc`, +:py:attr:`~earwigbot.config.BotConfig.commands`, +:py:attr:`~earwigbot.config.BotConfig.tasks`, or +:py:attr:`~earwigbot.config.BotConfig.metadata`) maps to a section of the bot's :file:`config.yml` file. For example, if :file:`config.yml` includes something like:: @@ -81,7 +86,8 @@ Custom IRC commands Custom commands are subclasses of :py:class:`earwigbot.commands.BaseCommand` that override :py:class:`~earwigbot.commands.BaseCommand`'s :py:meth:`~earwigbot.commands.BaseCommand.process` (and optionally -:py:meth:`~earwigbot.commands.BaseCommand.check`) methods. +:py:meth:`~earwigbot.commands.BaseCommand.check` or +:py:meth:`~earwigbot.commands.BaseCommand.setup`) methods. :py:class:`~earwigbot.commands.BaseCommand`'s docstrings should explain what each attribute and method is for and what they should be overridden with, but @@ -97,6 +103,13 @@ these are the basics: a user joins a channel). See the afc_status_ plugin for a command that responds to other hook types. +- Method :py:meth:`~earwigbot.commands.BaseCommand.setup` is called *once* with + no arguments immediately after the command is first loaded. Does nothing by + default; treat it like an :py:meth:`__init__` if you want + (:py:meth:`~earwigbot.tasks.BaseCommand.__init__` does things by default and + a dedicated setup method is often easier than overriding + :py:meth:`~earwigbot.tasks.BaseCommand.__init__` and using :py:obj:`super`). + - Method :py:meth:`~earwigbot.commands.BaseCommand.check` is passed a :py:class:`~earwigbot.irc.data.Data` [1]_ object, and should return ``True`` if you want to respond to this message, or ``False`` otherwise. The default @@ -128,6 +141,12 @@ these are the basics: `, and :py:meth:`part(chan) `. +Commands have access to :py:attr:`config.commands[command_name]` for config +information, which is a node in :file:`config.yml` like every other attribute +of :py:attr:`bot.config`. This can be used to store, for example, API keys or +SQL connection info, so that these can be easily changed without modifying the +command itself. + It's important to name the command class :py:class:`Command` within the file, or else the bot might not recognize it as a command. The name of the file doesn't really matter and need not match the command's name, but this is @@ -190,7 +209,7 @@ are the basics: Tasks have access to :py:attr:`config.tasks[task_name]` for config information, which is a node in :file:`config.yml` like every other attribute of -:py:attr:`bot.config`. This can be used to store, for example, edit summaries, +:py:attr:`bot.config`. This can be used to store, for example, edit summaries or templates to append to user talk pages, so that these can be easily changed without modifying the task itself. diff --git a/earwigbot/commands/__init__.py b/earwigbot/commands/__init__.py index 7a70244..e3ba449 100644 --- a/earwigbot/commands/__init__.py +++ b/earwigbot/commands/__init__.py @@ -49,9 +49,10 @@ class BaseCommand(object): This is called once when the command is loaded (from :py:meth:`commands.load() `). - *bot* is out base :py:class:`~earwigbot.bot.Bot` object. Generally you - shouldn't need to override this; if you do, call - ``super(Command, self).__init__()`` first. + *bot* is out base :py:class:`~earwigbot.bot.Bot` object. Don't override + this directly; if you do, remember to place + ``super(Command, self).__init()`` first. Use :py:meth:`setup` for + typical command-init/setup needs. """ self.bot = bot self.config = bot.config @@ -67,6 +68,15 @@ class BaseCommand(object): self.mode = lambda t, level, msg: self.bot.frontend.mode(t, level, msg) self.pong = lambda target: self.bot.frontend.pong(target) + self.setup() + + def setup(self): + """Hook called immediately after the command is loaded. + + Does nothing by default; feel free to override. + """ + pass + def check(self, data): """Return whether this command should be called in response to *data*. diff --git a/earwigbot/commands/geolocate.py b/earwigbot/commands/geolocate.py index 73dee91..67ba7c8 100644 --- a/earwigbot/commands/geolocate.py +++ b/earwigbot/commands/geolocate.py @@ -29,6 +29,15 @@ class Command(BaseCommand): """Geolocate an IP address (via http://ipinfodb.com/).""" name = "geolocate" + def setup(self): + self.config.decrypt(self.config.commands, (self.name, "apiKey")) + try: + self.key = self.config.commands[self.name]["apiKey"] + except KeyError: + self.key = None + log = 'Cannot use without an API key for http://ipinfodb.com/ stored as config.commands["{0}"]["apiKey"]' + self.logger.warn(log.format(self.name)) + def check(self, data): commands = ["geolocate", "locate", "geo", "ip"] return data.is_command and data.command in commands @@ -38,18 +47,16 @@ class Command(BaseCommand): self.reply(data, "please specify an IP to lookup.") return - try: - key = config.tasks[self.name]["apiKey"] - except KeyError: - msg = 'I need an API key for http://ipinfodb.com/ stored as \x0303config.tasks["{0}"]["apiKey"]\x0301.' - log = 'Need an API key for http://ipinfodb.com/ stored as config.tasks["{0}"]["apiKey"]' + if not self.key: + msg = 'I need an API key for http://ipinfodb.com/ stored as \x0303config.commands["{0}"]["apiKey"]\x0301.' + log = 'Need an API key for http://ipinfodb.com/ stored as config.commands["{0}"]["apiKey"]' self.reply(data, msg.format(self.name) + ".") self.logger.error(log.format(self.name)) return address = data.args[0] url = "http://api.ipinfodb.com/v3/ip-city/?key={0}&ip={1}&format=json" - query = urllib2.urlopen(url.format(key, address)).read() + query = urllib2.urlopen(url.format(self.key, address)).read() res = json.loads(query) try: diff --git a/earwigbot/config.py b/earwigbot/config.py index cee3570..6e7387f 100644 --- a/earwigbot/config.py +++ b/earwigbot/config.py @@ -48,8 +48,9 @@ class BotConfig(object): - :py:attr:`path`: path to the bot's config file - :py:attr:`components`: enabled components - :py:attr:`wiki`: information about wiki-editing - - :py:attr:`tasks`: information for bot tasks - :py:attr:`irc`: information about IRC + - :py:attr:`commands`: information about IRC commands + - :py:attr:`tasks`: information for bot tasks - :py:attr:`metadata`: miscellaneous information - :py:meth:`schedule`: tasks scheduled to run at a given time @@ -69,12 +70,13 @@ class BotConfig(object): self._components = _ConfigNode() self._wiki = _ConfigNode() - self._tasks = _ConfigNode() self._irc = _ConfigNode() + self._commands = _ConfigNode() + self._tasks = _ConfigNode() self._metadata = _ConfigNode() - self._nodes = [self._components, self._wiki, self._tasks, self._irc, - self._metadata] + self._nodes = [self._components, self._wiki, self._irc, self._commands, + self._tasks, self._metadata] self._decryptable_nodes = [ # Default nodes to decrypt (self._wiki, ("password",)), @@ -196,16 +198,21 @@ class BotConfig(object): return self._wiki @property - def tasks(self): - """A dict of information for bot tasks.""" - return self._tasks - - @property def irc(self): """A dict of information about IRC.""" return self._irc @property + def commands(self): + """A dict of information for IRC commands.""" + return self._commands + + @property + def tasks(self): + """A dict of information for bot tasks.""" + return self._tasks + + @property def metadata(self): """A dict of miscellaneous information.""" return self._metadata @@ -225,14 +232,14 @@ class BotConfig(object): user. If there is no config file at all, offer to make one, otherwise exit. - Data from the config file is stored in five + Data from the config file is stored in six :py:class:`~earwigbot.config._ConfigNode`\ s (:py:attr:`components`, - :py:attr:`wiki`, :py:attr:`tasks`, :py:attr:`irc`, :py:attr:`metadata`) - for easy access (as well as the lower-level :py:attr:`data` attribute). - If passwords are encrypted, we'll use :py:func:`~getpass.getpass` for - the key and then decrypt them. If the config is being reloaded, - encrypted items will be automatically decrypted if they were decrypted - earlier. + :py:attr:`wiki`, :py:attr:`irc`, :py:attr:`commands`, :py:attr:`tasks`, + :py:attr:`metadata`) for easy access (as well as the lower-level + :py:attr:`data` attribute). If passwords are encrypted, we'll use + :py:func:`~getpass.getpass` for the key and then decrypt them. If the + config is being reloaded, encrypted items will be automatically + decrypted if they were decrypted earlier. """ if not path.exists(self._config_path): print "Config file not found:", self._config_path @@ -246,8 +253,9 @@ class BotConfig(object): data = self._data self.components._load(data.get("components", {})) self.wiki._load(data.get("wiki", {})) - self.tasks._load(data.get("tasks", {})) self.irc._load(data.get("irc", {})) + self.commands._load(data.get("commands", {})) + self.tasks._load(data.get("tasks", {})) self.metadata._load(data.get("metadata", {})) self._setup_logging() From bc75594704fdeedf32232c027d79e038d96da494 Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Sun, 13 May 2012 02:12:37 -0400 Subject: [PATCH 04/14] !pending --- earwigbot/commands/_old.py | 47 --------------------------------------- earwigbot/commands/afc_pending.py | 37 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 47 deletions(-) create mode 100644 earwigbot/commands/afc_pending.py diff --git a/earwigbot/commands/_old.py b/earwigbot/commands/_old.py index 64fcd74..a4e8fc1 100644 --- a/earwigbot/commands/_old.py +++ b/earwigbot/commands/_old.py @@ -131,10 +131,6 @@ def parse(command, line, line2, nick, chan, host, auth, notice, say, reply, s): msg = 'Can\'t find the etymology for "%s". Try %s' % (word, uri) reply(msg, chan, nick) return - if command == "pend" or command == "pending": - say("Pending submissions status page: .", chan) - say("Pending submissions category: .", chan) - return if command == "sub" or command == "submissions": try: number = int(line2[4]) @@ -191,49 +187,6 @@ def parse(command, line, line2, nick, chan, host, auth, notice, say, reply, s): else: reply("I refuse to hurt anything with \"Earwig\" in its name :P", chan, nick) return - if command == "mysql": - if authy != "owner": - reply("You aren't authorized to use this command.", chan, nick) - return - import MySQLdb - try: - strings = line2[4] - strings = ' '.join(line2[4:]) - if "db:" in strings: - database = re.findall("db\:(.*?)\s", strings)[0] - else: - database = "enwiki_p" - if "time:" in strings: - times = int(re.findall("time\:(.*?)\s", strings)[0]) - else: - times = 60 - file = re.findall("file\:(.*?)\s", strings)[0] - sqlquery = re.findall("query\:(.*?)\Z", strings)[0] - except Exception: - reply("You did not specify enough data for the bot to continue.", chan, nick) - return - database2 = database[:-2] + "-p" - db = MySQLdb.connect(db=database, host="%s.rrdb.toolserver.org" % database2, read_default_file="/home/earwig/.my.cnf") - db.query(sqlquery) - r = db.use_result() - data = r.fetch_row(0) - try: - f = codecs.open("/home/earwig/public_html/reports/%s/%s" % (database[:-2], file), 'r') - reply("A file already exists with that name.", chan, nick) - return - except Exception: - pass - f = codecs.open("/home/earwig/public_html/reports/%s/%s" % (database[:-2], file), 'a', 'utf-8') - for line in data: - new_line = [] - for l in line: - new_line.append(str(l)) - f.write(' '.join(new_line) + "\n") - f.close() - reply("Query completed successfully. See http://toolserver.org/~earwig/reports/%s/%s. I will delete the report in %s seconds." % (database[:-2], file, times), chan, nick) - time.sleep(times) - os.remove("/home/earwig/public_html/reports/%s/%s" % (database[:-2], file)) - return if command == "notes" or command == "note" or command == "about" or command == "data" or command == "database": try: action = line2[4] diff --git a/earwigbot/commands/afc_pending.py b/earwigbot/commands/afc_pending.py new file mode 100644 index 0000000..d90b25f --- /dev/null +++ b/earwigbot/commands/afc_pending.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 by 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 BaseCommand + +class Command(BaseCommand): + """Links the user to the pending AFC submissions page and category.""" + name = "pending" + + def check(self, data): + commands = ["pending", "pend"] + return data.is_command and data.command in commands + + def process(self, data): + msg1 = "pending submissions status page: http://enwp.org/WP:AFC/ST" + msg2 = "pending submissions category: http://enwp.org/CAT:PEND" + self.reply(data, msg1) + self.reply(data, msg2) From dcf912b65b57f0fa68ca2ac419bdec0b09cce0ab Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Mon, 14 May 2012 23:25:21 -0400 Subject: [PATCH 05/14] Make cat.get_members() an iterator; make page.exists output nicer; cleanup --- docs/toolset.rst | 15 ++++--- earwigbot/commands/afc_pending.py | 2 +- earwigbot/commands/afc_report.py | 2 +- earwigbot/wiki/category.py | 82 +++++++++++++++++++++++------------- earwigbot/wiki/page.py | 89 ++++++++++++++++++++------------------- earwigbot/wiki/site.py | 22 +++++++--- earwigbot/wiki/user.py | 6 +++ 7 files changed, 132 insertions(+), 86 deletions(-) diff --git a/docs/toolset.rst b/docs/toolset.rst index b8a4124..812b0c8 100644 --- a/docs/toolset.rst +++ b/docs/toolset.rst @@ -97,11 +97,11 @@ and the following methods: - :py:meth:`namespace_name_to_id(name) `: given a namespace name, returns the associated namespace ID -- :py:meth:`get_page(title, follow_redirects=False) +- :py:meth:`get_page(title, follow_redirects=False, ...) `: returns a ``Page`` object for the given title (or a :py:class:`~earwigbot.wiki.category.Category` object if the page's namespace is "``Category:``") -- :py:meth:`get_category(catname, follow_redirects=False) +- :py:meth:`get_category(catname, follow_redirects=False, ...) `: returns a ``Category`` object for the given title (sans namespace) - :py:meth:`get_user(username) `: returns a @@ -120,7 +120,7 @@ provide the following attributes: - :py:attr:`~earwigbot.wiki.page.Page.site`: the page's corresponding :py:class:`~earwigbot.wiki.site.Site` object - :py:attr:`~earwigbot.wiki.page.Page.title`: the page's title, or pagename -- :py:attr:`~earwigbot.wiki.page.Page.exists`: whether the page exists +- :py:attr:`~earwigbot.wiki.page.Page.exists`: whether or not the page exists - :py:attr:`~earwigbot.wiki.page.Page.pageid`: an integer ID representing the page - :py:attr:`~earwigbot.wiki.page.Page.url`: the page's URL @@ -166,9 +166,10 @@ or :py:meth:`site.get_page(title) ` where ``title`` is in the ``Category:`` namespace) provide the following additional method: -- :py:meth:`get_members(use_sql=False, limit=None) - `: returns a list of page - titles in the category (limit is ``50`` by default if using the API) +- :py:meth:`get_members(use_sql=False, limit=None, ...) + `: iterates over + :py:class:`~earwigbot.wiki.page.Page`\ s in the category, until either the + category is exhausted or (if given) ``limit`` is reached Users ~~~~~ @@ -178,6 +179,8 @@ Create :py:class:`earwigbot.wiki.User ` objects with :py:meth:`page.get_creator() `. They provide the following attributes: +- :py:attr:`~earwigbot.wiki.user.User.site`: the user's corresponding + :py:class:`~earwigbot.wiki.site.Site` object - :py:attr:`~earwigbot.wiki.user.User.name`: the user's username - :py:attr:`~earwigbot.wiki.user.User.exists`: ``True`` if the user exists, or ``False`` if they do not diff --git a/earwigbot/commands/afc_pending.py b/earwigbot/commands/afc_pending.py index d90b25f..1a43786 100644 --- a/earwigbot/commands/afc_pending.py +++ b/earwigbot/commands/afc_pending.py @@ -23,7 +23,7 @@ from earwigbot.commands import BaseCommand class Command(BaseCommand): - """Links the user to the pending AFC submissions page and category.""" + """Link the user to the pending AFC submissions page and category.""" name = "pending" def check(self, data): diff --git a/earwigbot/commands/afc_report.py b/earwigbot/commands/afc_report.py index 6129e11..c41e3bc 100644 --- a/earwigbot/commands/afc_report.py +++ b/earwigbot/commands/afc_report.py @@ -70,7 +70,7 @@ class Command(BaseCommand): def get_page(self, title): page = self.site.get_page(title, follow_redirects=False) - if page.exists[0]: + if page.exists == page.PAGE_EXISTS: return page def report(self, page): diff --git a/earwigbot/wiki/category.py b/earwigbot/wiki/category.py index e953e70..2df9a0e 100644 --- a/earwigbot/wiki/category.py +++ b/earwigbot/wiki/category.py @@ -39,7 +39,7 @@ class Category(Page): *Public methods:* - - :py:meth:`get_members`: returns a list of page titles in the category + - :py:meth:`get_members`: iterates over Pages in the category """ def __repr__(self): @@ -51,8 +51,8 @@ class Category(Page): """Return a nice string representation of the Category.""" return ''.format(self.title, str(self._site)) - def _get_members_via_sql(self, limit): - """Return a list of tuples of (title, pageid) in the category.""" + def _get_members_via_sql(self, limit, follow): + """Iterate over Pages in the category using SQL.""" query = """SELECT page_title, page_namespace, page_id FROM page JOIN categorylinks ON page_id = cl_from WHERE cl_to = ?""" @@ -64,42 +64,66 @@ class Category(Page): else: result = self._site.sql_query(query, (title,)) - members = [] - for row in result: + members = list(result) + for row in members: base = row[0].replace("_", " ").decode("utf8") namespace = self._site.namespace_id_to_name(row[1]) if namespace: title = u":".join((namespace, base)) else: # Avoid doing a silly (albeit valid) ":Pagename" thing title = base - members.append((title, row[2])) - return members + yield self._site.get_page(title, follow_redirects=follow, + pageid=row[2]) - def _get_members_via_api(self, limit): - """Return a list of page titles in the category using the API.""" + def _get_members_via_api(self, limit, follow): + """Iterate over Pages in the category using the API.""" params = {"action": "query", "list": "categorymembers", - "cmlimit": limit, "cmtitle": self._title} - if not limit: - params["cmlimit"] = 50 # Default value - - result = self._site.api_query(**params) - members = result['query']['categorymembers'] - return [member["title"] for member in members] - - def get_members(self, use_sql=False, limit=None): - """Return a list of page titles in the category. + "cmtitle": self._title} + + while 1: + params["cmlimit"] = limit if limit else "max" + result = self._site.api_query(**params) + for member in result["query"]["categorymembers"]: + title = member["title"] + yield self._site.get_page(title, follow_redirects=follow) + + if "query-continue" in result: + qcontinue = result["query-continue"]["categorymembers"] + params["cmcontinue"] = qcontinue["cmcontinue"] + if limit: + limit -= len(result["query"]["categorymembers"]) + else: + break + + def get_members(self, use_sql=False, limit=None, follow_redirects=None): + """Iterate over Pages in the category. If *use_sql* is ``True``, we will use a SQL query instead of the API. - Pages will be returned as tuples of ``(title, pageid)`` instead of just - titles. - - If *limit* is provided, we will provide this many titles, or less if - the category is smaller. It defaults to 50 for API queries; normal - users can go up to 500, and bots can go up to 5,000 on a single API - query. If we're using SQL, the limit is ``None`` by default (returning - all pages in the category), but an arbitrary limit can still be chosen. + Note that pages are retrieved from the API in chunks (by default, in + 500-page chunks for normal users and 5000-page chunks for bots and + admins), so queries may be made as we go along. If *limit* is given, we + will provide this many pages, or less if the category is smaller. By + default, *limit* is ``None``, meaning we will keep iterating over + members until the category is exhausted. *follow_redirects* is passed + directly to :py:meth:`site.get_page() + `; it defaults to ``None``, which + will use the value passed to our :py:meth:`__init__`. + + .. note:: + Be careful when iterating over very large categories with no limit. + If using the API, at best, you will make one query per 5000 pages, + which can add up significantly for categories with hundreds of + thousands of members. As for SQL, note that *all page titles are + stored internally* as soon as the query is made, so the site-wide + SQL lock can be freed and unrelated queries can be made without + requiring a separate connection to be opened. This is generally not + an issue unless your category's size approaches several hundred + thousand, in which case the sheer number of titles in memory becomes + problematic. """ + if follow_redirects is None: + follow_redirects = self._follow_redirects if use_sql: - return self._get_members_via_sql(limit) + return self._get_members_via_sql(limit, follow_redirects) else: - return self._get_members_via_api(limit) + return self._get_members_via_api(limit, follow_redirects) diff --git a/earwigbot/wiki/page.py b/earwigbot/wiki/page.py index 21acd6d..98f11dd 100644 --- a/earwigbot/wiki/page.py +++ b/earwigbot/wiki/page.py @@ -43,7 +43,7 @@ class Page(CopyrightMixin): - :py:attr:`site`: the page's corresponding Site object - :py:attr:`title`: the page's title, or pagename - - :py:attr:`exists`: whether the page exists + - :py:attr:`exists`: whether or not the page exists - :py:attr:`pageid`: an integer ID representing the page - :py:attr:`url`: the page's URL - :py:attr:`namespace`: the page's namespace as an integer @@ -70,17 +70,20 @@ class Page(CopyrightMixin): URL """ - re_redirect = "^\s*\#\s*redirect\s*\[\[(.*?)\]\]" + PAGE_UNKNOWN = 0 + PAGE_INVALID = 1 + PAGE_MISSING = 2 + PAGE_EXISTS = 3 - def __init__(self, site, title, follow_redirects=False): + def __init__(self, site, title, follow_redirects=False, pageid=None): """Constructor for new Page instances. - Takes three arguments: a Site object, the Page's title (or pagename), - and whether or not to follow redirects (optional, defaults to False). + Takes four arguments: a Site object, the Page's title (or pagename), + whether or not to follow redirects (optional, defaults to False), and + a page ID to supplement the title (optional, defaults to None - i.e., + we will have to query the API to get it). - As with User, site.get_page() is preferred. Site's method has support - for a default *follow_redirects* value in our config, while __init__() - always defaults to False. + As with User, site.get_page() is preferred. __init__() will not do any API queries, but it will use basic namespace logic to determine our namespace ID and if we are a talkpage. @@ -89,9 +92,9 @@ class Page(CopyrightMixin): self._site = site self._title = title.strip() self._follow_redirects = self._keep_following = follow_redirects + self._pageid = pageid - self._exists = 0 - self._pageid = None + self._exists = self.PAGE_UNKNOWN self._is_redirect = None self._lastrevid = None self._protection = None @@ -140,7 +143,7 @@ class Page(CopyrightMixin): Note that validity != existence. If a page's title is invalid (e.g, it contains "[") it will always be invalid, and cannot be edited. """ - if self._exists == 1: + if self._exists == self.PAGE_INVALID: e = "Page '{0}' is invalid.".format(self._title) raise exceptions.InvalidPageError(e) @@ -152,7 +155,7 @@ class Page(CopyrightMixin): It will also call _assert_validity() beforehand. """ self._assert_validity() - if self._exists == 2: + if self._exists == self.PAGE_MISSING: e = "Page '{0}' does not exist.".format(self._title) raise exceptions.PageNotFoundError(e) @@ -213,14 +216,14 @@ class Page(CopyrightMixin): if "missing" in res: # If it has a negative ID and it's missing; we can still get # data like the namespace, protection, and URL: - self._exists = 2 + self._exists = self.PAGE_MISSING else: # If it has a negative ID and it's invalid, then break here, # because there's no other data for us to get: - self._exists = 1 + self._exists = self.PAGE_INVALID return else: - self._exists = 3 + self._exists = self.PAGE_EXISTS self._fullurl = res["fullurl"] self._protection = res["protection"] @@ -312,7 +315,7 @@ class Page(CopyrightMixin): if result["edit"]["result"] == "Success": self._content = None self._basetimestamp = None - self._exists = 0 + self._exists = self.PAGE_UNKNOWN return # If we're here, then the edit failed. If it's because of AssertEdit, @@ -346,7 +349,7 @@ class Page(CopyrightMixin): params["starttimestamp"] = self._starttimestamp if self._basetimestamp: params["basetimestamp"] = self._basetimestamp - if self._exists == 2: + if self._exists == self.PAGE_MISSING: # Page does not exist; don't edit if it already exists: params["createonly"] = "true" else: @@ -384,7 +387,7 @@ class Page(CopyrightMixin): # These attributes are now invalidated: self._content = None self._basetimestamp = None - self._exists = 0 + self._exists = self.PAGE_UNKNOWN raise exceptions.EditConflictError(error.info) elif error.code in ["emptypage", "emptynewsection"]: @@ -432,12 +435,12 @@ class Page(CopyrightMixin): @property def site(self): - """The Page's corresponding Site object.""" + """The page's corresponding Site object.""" return self._site @property def title(self): - """The Page's title, or "pagename". + """The page's title, or "pagename". This won't do any API queries on its own. Any other attributes or methods that do API queries will reload the title, however, like @@ -448,37 +451,36 @@ class Page(CopyrightMixin): @property def exists(self): - """Information about whether the Page exists or not. + """Whether or not the page exists. - The "information" is a tuple with two items. The first is a bool, - either ``True`` if the page exists or ``False`` if it does not. The - second is a string giving more information, either ``"invalid"``, - (title is invalid, e.g. it contains ``"["``), ``"missing"``, or - ``"exists"``. + This will be a number; its value does not matter, but it will equal + one of :py:attr:`self.PAGE_INVALID `, + :py:attr:`self.PAGE_MISSING `, or + :py:attr:`self.PAGE_EXISTS `. Makes an API query only if we haven't already made one. """ - cases = { - 0: (None, "unknown"), - 1: (False, "invalid"), - 2: (False, "missing"), - 3: (True, "exists"), - } - if self._exists == 0: + if self._exists == self.PAGE_UNKNOWN: self._load() - return cases[self._exists] + return self._exists @property def pageid(self): - """An integer ID representing the Page. + """An integer ID representing the page. - Makes an API query only if we haven't already made one. + Makes an API query only if we haven't already made one and the *pageid* + parameter to :py:meth:`__init__` was left as ``None``, which should be + true for all cases except when pages are returned by an SQL generator + (like :py:meth:`category.get_members(use_sql=True) + `). Raises :py:exc:`~earwigbot.exceptions.InvalidPageError` or :py:exc:`~earwigbot.exceptions.PageNotFoundError` if the page name is invalid or the page does not exist, respectively. """ - if self._exists == 0: + if self._pageid: + return self._pageid + if self._exists == self.PAGE_UNKNOWN: self._load() self._assert_existence() # Missing pages do not have IDs return self._pageid @@ -518,7 +520,7 @@ class Page(CopyrightMixin): name is invalid. Won't raise an error if the page is missing because those can still be create-protected. """ - if self._exists == 0: + if self._exists == self.PAGE_UNKNOWN: self._load() self._assert_validity() # Invalid pages cannot be protected return self._protection @@ -541,7 +543,7 @@ class Page(CopyrightMixin): We will return ``False`` even if the page does not exist or is invalid. """ - if self._exists == 0: + if self._exists == self.PAGE_UNKNOWN: self._load() return self._is_redirect @@ -606,7 +608,7 @@ class Page(CopyrightMixin): Raises InvalidPageError or PageNotFoundError if the page name is invalid or the page does not exist, respectively. """ - if self._exists == 0: + if self._exists == self.PAGE_UNKNOWN: # Kill two birds with one stone by doing an API query for both our # attributes and our page content: query = self._site.api_query @@ -621,7 +623,7 @@ class Page(CopyrightMixin): if self._keep_following and self._is_redirect: self._title = self.get_redirect_target() self._keep_following = False # Don't follow double redirects - self._exists = 0 # Force another API query + self._exists = self.PAGE_UNKNOWN # Force another API query self.get() return self._content @@ -645,9 +647,10 @@ class Page(CopyrightMixin): :py:exc:`~earwigbot.exceptions.RedirectError` if the page is not a redirect. """ + re_redirect = "^\s*\#\s*redirect\s*\[\[(.*?)\]\]" content = self.get() try: - return re.findall(self.re_redirect, content, flags=re.I)[0] + return re.findall(re_redirect, content, flags=re.I)[0] except IndexError: e = "The page does not appear to have a redirect target." raise exceptions.RedirectError(e) @@ -666,7 +669,7 @@ class Page(CopyrightMixin): :py:exc:`~earwigbot.exceptions.PageNotFoundError` if the page name is invalid or the page does not exist, respectively. """ - if self._exists == 0: + if self._exists == self.PAGE_UNKNOWN: self._load() self._assert_existence() if not self._creator: diff --git a/earwigbot/wiki/site.py b/earwigbot/wiki/site.py index 3c4babc..eb818c4 100644 --- a/earwigbot/wiki/site.py +++ b/earwigbot/wiki/site.py @@ -184,6 +184,12 @@ class Site(object): res = "" return res.format(self.name, self.project, self.lang, self.domain) + def _unicodeify(self, value, encoding="utf8"): + """Return input as unicode if it's not unicode to begin with.""" + if isinstance(value, unicode): + return value + return unicode(value, encoding) + def _urlencode_utf8(self, params): """Implement urllib.urlencode() with support for unicode input.""" enc = lambda s: s.encode("utf8") if isinstance(s, unicode) else str(s) @@ -682,7 +688,7 @@ class Site(object): e = "There is no namespace with name '{0}'.".format(name) raise exceptions.NamespaceNotFoundError(e) - def get_page(self, title, follow_redirects=False): + def get_page(self, title, follow_redirects=False, pageid=None): """Return a :py:class:`Page` object for the given title. *follow_redirects* is passed directly to @@ -696,23 +702,26 @@ class Site(object): redirect-following: :py:class:`~earwigbot.wiki.page.Page`'s methods provide that. """ + title = self._unicodeify(title) prefixes = self.namespace_id_to_name(constants.NS_CATEGORY, all=True) prefix = title.split(":", 1)[0] if prefix != title: # Avoid a page that is simply "Category" if prefix in prefixes: - return Category(self, title, follow_redirects) - return Page(self, title, follow_redirects) + return Category(self, title, follow_redirects, pageid) + return Page(self, title, follow_redirects, pageid) - def get_category(self, catname, follow_redirects=False): + def get_category(self, catname, follow_redirects=False, pageid=None): """Return a :py:class:`Category` object for the given category name. *catname* should be given *without* a namespace prefix. This method is really just shorthand for :py:meth:`get_page("Category:" + catname) `. """ + catname = self._unicodeify(catname) + name = name if isinstance(name, unicode) else name.decode("utf8") prefix = self.namespace_id_to_name(constants.NS_CATEGORY) - pagename = ':'.join((prefix, catname)) - return Category(self, pagename, follow_redirects) + pagename = u':'.join((prefix, catname)) + return Category(self, pagename, follow_redirects, pageid) def get_user(self, username=None): """Return a :py:class:`User` object for the given username. @@ -721,6 +730,7 @@ class Site(object): :py:class:`~earwigbot.wiki.user.User` object representing the currently logged-in (or anonymous!) user is returned. """ + username = self._unicodeify(username) if not username: username = self._get_username() return User(self, username) diff --git a/earwigbot/wiki/user.py b/earwigbot/wiki/user.py index 619e6ad..9256a52 100644 --- a/earwigbot/wiki/user.py +++ b/earwigbot/wiki/user.py @@ -39,6 +39,7 @@ class User(object): *Attributes:* + - :py:attr:`site`: the user's corresponding Site object - :py:attr:`name`: the user's username - :py:attr:`exists`: ``True`` if the user exists, else ``False`` - :py:attr:`userid`: an integer ID representing the user @@ -155,6 +156,11 @@ class User(object): self._gender = res["gender"] @property + def site(self): + """The user's corresponding Site object.""" + return self._site + + @property def name(self): """The user's username. From b34dd94f0d1eb94c1fc9b9d2e832b50a26b9f46a Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Sun, 20 May 2012 20:29:21 -0400 Subject: [PATCH 06/14] Command.commands; small change in managers; !time --- README.rst | 20 ++++++++--- docs/customizing.rst | 27 +++++++++++---- earwigbot/commands/__init__.py | 16 ++++++--- earwigbot/commands/_old.py | 5 --- earwigbot/commands/afc_pending.py | 5 +-- earwigbot/commands/afc_status.py | 5 ++- earwigbot/commands/chanops.py | 5 +-- earwigbot/commands/crypt.py | 5 +-- earwigbot/commands/editcount.py | 5 +-- earwigbot/commands/geolocate.py | 5 +-- earwigbot/commands/help.py | 33 ++++++------------ earwigbot/commands/langcode.py | 5 +-- earwigbot/commands/praise.py | 7 ++-- earwigbot/commands/quit.py | 5 +-- earwigbot/commands/registration.py | 5 +-- earwigbot/commands/remind.py | 13 ++----- earwigbot/commands/rights.py | 5 +-- earwigbot/commands/threads.py | 9 ++--- earwigbot/commands/time.py | 70 ++++++++++++++++++++++++++++++++++++++ earwigbot/managers.py | 33 ++++++++---------- 20 files changed, 161 insertions(+), 122 deletions(-) create mode 100644 earwigbot/commands/time.py diff --git a/README.rst b/README.rst index f14c1d5..2513903 100644 --- a/README.rst +++ b/README.rst @@ -166,6 +166,13 @@ for and what they should be overridden with, but these are the basics: - Class attribute ``name`` is the name of the command. This must be specified. +- Class attribute ``commands`` is a list of names that will trigger this + command. It defaults to the command's ``name``, but you can override it with + multiple names to serve as aliases. This is handled by the default + ``check()`` implementation (see below), so if ``check()`` is overridden, this + is ignored by everything except the help_ command (so ``!help alias`` will + trigger help for the actual command). + - Class attribute ``hooks`` is a list of the "IRC events" that this command might respond to. It defaults to ``["msg"]``, but options include ``"msg_private"`` (for private messages only), ``"msg_public"`` (for channel @@ -181,10 +188,12 @@ for and what they should be overridden with, but these are the basics: - Method ``check()`` is passed a ``Data`` [2]_ object, and should return ``True`` if you want to respond to this message, or ``False`` otherwise. The default behavior is to return ``True`` only if ``data.is_command`` is - ``True`` and ``data.command == self.name``, which is suitable for most cases. - A common, straightforward reason for overriding is if a command has aliases - (see chanops_ for an example). Note that by returning ``True``, you prevent - any other commands from responding to this message. + ``True`` and ``data.command`` ``==`` ``self.name`` (or ``data.command`` is in + ``self.commands`` if that list is overriden; see above), which is suitable + for most cases. A possible reason for overriding is if you want to do + something in response to events from a specific channel only. Note that by + returning ``True``, you prevent any other commands from responding to this + message. - Method ``process()`` is passed the same ``Data`` object as ``check()``, but only if ``check()`` returned ``True``. This is where the bulk of your command @@ -230,7 +239,7 @@ and what they should be overridden with, but these are the basics: ``"([[WP:BOT|Bot]]; [[User:EarwigBot#Task $1|Task $1]]): $2"``, which the task class's ``make_summary(comment)`` method will take and replace ``$1`` with the task number and ``$2`` with the details of the edit. - + Additionally, ``shutoff_enabled()`` (which checks whether the bot has been told to stop on-wiki by checking the content of a particular page) can check a different page for each task using similar variables. EarwigBot's @@ -510,6 +519,7 @@ Footnotes .. _earwigbot.bot.Bot: https://github.com/earwig/earwigbot/blob/develop/earwigbot/bot.py .. _earwigbot.config.BotConfig: https://github.com/earwig/earwigbot/blob/develop/earwigbot/config.py .. _earwigbot.commands.BaseCommand: https://github.com/earwig/earwigbot/blob/develop/earwigbot/commands/__init__.py +.. _help: https://github.com/earwig/earwigbot/blob/develop/earwigbot/commands/help.py .. _afc_status: https://github.com/earwig/earwigbot-plugins/blob/develop/commands/afc_status.py .. _chanops: https://github.com/earwig/earwigbot/blob/develop/earwigbot/commands/chanops.py .. _test: https://github.com/earwig/earwigbot/blob/develop/earwigbot/commands/test.py diff --git a/docs/customizing.rst b/docs/customizing.rst index f5b27f4..4891a22 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -96,6 +96,15 @@ these are the basics: - Class attribute :py:attr:`~earwigbot.commands.BaseCommand.name` is the name of the command. This must be specified. +- Class attribute :py:attr:`~earwigbot.commands.BaseCommand.commands` is a list + of names that will trigger this command. It defaults to the command's + :py:attr:`~earwigbot.commands.BaseCommand.name`, but you can override it with + multiple names to serve as aliases. This is handled by the default + :py:meth:`~earwigbot.commands.BaseCommand.check` implementation (see below), + so if :py:meth:`~earwigbot.commands.BaseCommand.check` is overridden, this is + ignored by everything except the help_ command (so ``!help alias`` will + trigger help for the actual command). + - Class attribute :py:attr:`~earwigbot.commands.BaseCommand.hooks` is a list of the "IRC events" that this command might respond to. It defaults to ``["msg"]``, but options include ``"msg_private"`` (for private messages @@ -113,12 +122,15 @@ these are the basics: - Method :py:meth:`~earwigbot.commands.BaseCommand.check` is passed a :py:class:`~earwigbot.irc.data.Data` [1]_ object, and should return ``True`` if you want to respond to this message, or ``False`` otherwise. The default - behavior is to return ``True`` only if - :py:attr:`data.is_command` is ``True`` and :py:attr:`data.command` == - :py:attr:`~earwigbot.commands.BaseCommand.name`, which is suitable for most - cases. A common, straightforward reason for overriding is if a command has - aliases (see chanops_ for an example). Note that by returning ``True``, you - prevent any other commands from responding to this message. + behavior is to return ``True`` only if :py:attr:`data.is_command` is ``True`` + and :py:attr:`data.command` ``==`` + :py:attr:`~earwigbot.commands.BaseCommand.name` (or :py:attr:`data.command + ` is in + :py:attr:`~earwigbot.commands.BaseCommand.commands` if that list is + overriden; see above), which is suitable for most cases. A possible reason + for overriding is if you want to do something in response to events from a + specific channel only. Note that by returning ``True``, you prevent any other + commands from responding to this message. - Method :py:meth:`~earwigbot.commands.BaseCommand.process` is passed the same :py:class:`~earwigbot.irc.data.Data` object as @@ -179,7 +191,7 @@ are the basics: task class's :py:meth:`make_summary(comment) ` method will take and replace ``$1`` with the task number and ``$2`` with the details of the edit. - + Additionally, :py:meth:`~earwigbot.tasks.BaseTask.shutoff_enabled` (which checks whether the bot has been told to stop on-wiki by checking the content of a particular page) can check a different page for each task using similar @@ -249,6 +261,7 @@ task, or the afc_statistics_ plugin for a more complicated one. :py:attr:`~earwigbot.irc.data.Data.ident`, and :py:attr:`~earwigbot.irc.data.Data.host`. +.. _help: https://github.com/earwig/earwigbot/blob/develop/earwigbot/commands/help.py .. _afc_status: https://github.com/earwig/earwigbot-plugins/blob/develop/commands/afc_status.py .. _chanops: https://github.com/earwig/earwigbot/blob/develop/earwigbot/commands/chanops.py .. _test: https://github.com/earwig/earwigbot/blob/develop/earwigbot/commands/test.py diff --git a/earwigbot/commands/__init__.py b/earwigbot/commands/__init__.py index e3ba449..c072ca7 100644 --- a/earwigbot/commands/__init__.py +++ b/earwigbot/commands/__init__.py @@ -36,9 +36,13 @@ class BaseCommand(object): This docstring is reported to the user when they type ``"!help "``. """ - # This is the command's name, as reported to the user when they use !help: + # The command's name, as reported to the user when they use !help: name = None + # A list of names that will trigger this command. If left empty, it will + # be triggered by the command's name and its name only: + commands = [] + # Hooks are "msg", "msg_private", "msg_public", and "join". "msg" is the # default behavior; if you wish to override that, change the value in your # command subclass: @@ -86,11 +90,15 @@ class BaseCommand(object): sent on IRC, it should be cheap to execute and unlikely to throw exceptions. - Most commands return ``True`` if :py:attr:`data.command + Most commands return ``True`` only if :py:attr:`data.command ` ``==`` :py:attr:`self.name `, - otherwise they return ``False``. This is the default behavior of - :py:meth:`check`; you need only override it if you wish to change that. + or :py:attr:`data.command ` is in + :py:attr:`self.commands ` if that list is overriden. This is + the default behavior; you should only override it if you wish to change + that. """ + if self.commands: + return data.is_command and data.command in self.commands return data.is_command and data.command == self.name def process(self, data): diff --git a/earwigbot/commands/_old.py b/earwigbot/commands/_old.py index a4e8fc1..e22d681 100644 --- a/earwigbot/commands/_old.py +++ b/earwigbot/commands/_old.py @@ -21,11 +21,6 @@ def parse(command, line, line2, nick, chan, host, auth, notice, say, reply, s): u.close() say('"' + info['Date'] + '" - tycho.usno.navy.mil', chan) return - if command == "beats": - beats = ((time.time() + 3600) % 86400) / 86.4 - beats = int(math.floor(beats)) - say('@%03i' % beats, chan) - return if command == "dict" or command == "dictionary": def trim(thing): if thing.endswith(' '): diff --git a/earwigbot/commands/afc_pending.py b/earwigbot/commands/afc_pending.py index 1a43786..98b4dfb 100644 --- a/earwigbot/commands/afc_pending.py +++ b/earwigbot/commands/afc_pending.py @@ -25,10 +25,7 @@ from earwigbot.commands import BaseCommand class Command(BaseCommand): """Link the user to the pending AFC submissions page and category.""" name = "pending" - - def check(self, data): - commands = ["pending", "pend"] - return data.is_command and data.command in commands + commands = ["pending", "pend"] def process(self, data): msg1 = "pending submissions status page: http://enwp.org/WP:AFC/ST" diff --git a/earwigbot/commands/afc_status.py b/earwigbot/commands/afc_status.py index 635d6cc..a5aa228 100644 --- a/earwigbot/commands/afc_status.py +++ b/earwigbot/commands/afc_status.py @@ -28,13 +28,12 @@ class Command(BaseCommand): """Get the number of pending AfC submissions, open redirect requests, and open file upload requests.""" name = "status" + commands = ["status", "count", "num", "number"] hooks = ["join", "msg"] def check(self, data): - commands = ["status", "count", "num", "number"] - if data.is_command and data.command in commands: + if data.is_command and data.command in self.commands: return True - try: if data.line[1] == "JOIN" and data.chan == "#wikipedia-en-afc": if data.nick != self.config.irc["frontend"]["nick"]: diff --git a/earwigbot/commands/chanops.py b/earwigbot/commands/chanops.py index 1083a45..b2bcbca 100644 --- a/earwigbot/commands/chanops.py +++ b/earwigbot/commands/chanops.py @@ -26,10 +26,7 @@ class Command(BaseCommand): """Voice, devoice, op, or deop users in the channel, or join or part from other channels.""" name = "chanops" - - def check(self, data): - cmnds = ["chanops", "voice", "devoice", "op", "deop", "join", "part"] - return data.is_command and data.command in cmnds + commands = ["chanops", "voice", "devoice", "op", "deop", "join", "part"] def process(self, data): if data.command == "chanops": diff --git a/earwigbot/commands/crypt.py b/earwigbot/commands/crypt.py index ccb5300..611cde1 100644 --- a/earwigbot/commands/crypt.py +++ b/earwigbot/commands/crypt.py @@ -30,10 +30,7 @@ class Command(BaseCommand): """Provides hash functions with !hash (!hash list for supported algorithms) and blowfish encryption with !encrypt and !decrypt.""" name = "crypt" - - def check(self, data): - commands = ["crypt", "hash", "encrypt", "decrypt"] - return data.is_command and data.command in commands + commands = ["crypt", "hash", "encrypt", "decrypt"] def process(self, data): if data.command == "crypt": diff --git a/earwigbot/commands/editcount.py b/earwigbot/commands/editcount.py index 517a8e3..60f225d 100644 --- a/earwigbot/commands/editcount.py +++ b/earwigbot/commands/editcount.py @@ -28,10 +28,7 @@ from earwigbot.commands import BaseCommand class Command(BaseCommand): """Return a user's edit count.""" name = "editcount" - - def check(self, data): - commands = ["ec", "editcount"] - return data.is_command and data.command in commands + commands = ["ec", "editcount"] def process(self, data): if not data.args: diff --git a/earwigbot/commands/geolocate.py b/earwigbot/commands/geolocate.py index 67ba7c8..371e73c 100644 --- a/earwigbot/commands/geolocate.py +++ b/earwigbot/commands/geolocate.py @@ -28,6 +28,7 @@ from earwigbot.commands import BaseCommand class Command(BaseCommand): """Geolocate an IP address (via http://ipinfodb.com/).""" name = "geolocate" + commands = ["geolocate", "locate", "geo", "ip"] def setup(self): self.config.decrypt(self.config.commands, (self.name, "apiKey")) @@ -38,10 +39,6 @@ class Command(BaseCommand): log = 'Cannot use without an API key for http://ipinfodb.com/ stored as config.commands["{0}"]["apiKey"]' self.logger.warn(log.format(self.name)) - def check(self, data): - commands = ["geolocate", "locate", "geo", "ip"] - return data.is_command and data.command in commands - def process(self, data): if not data.args: self.reply(data, "please specify an IP to lookup.") diff --git a/earwigbot/commands/help.py b/earwigbot/commands/help.py index 35f97fc..e44cd51 100644 --- a/earwigbot/commands/help.py +++ b/earwigbot/commands/help.py @@ -23,7 +23,6 @@ import re from earwigbot.commands import BaseCommand -from earwigbot.irc import Data class Command(BaseCommand): """Displays help information.""" @@ -48,34 +47,24 @@ class Command(BaseCommand): def do_main_help(self, data): """Give the user a general help message with a list of all commands.""" msg = "Hi, I'm a bot! I have {0} commands loaded: {1}. You can get help for any command with '!help '." - cmnds = sorted(self.bot.commands) + cmnds = sorted([cmnd.name for cmnd in self.bot.commands]) msg = msg.format(len(cmnds), ', '.join(cmnds)) self.reply(data, msg) def do_command_help(self, data): """Give the user help for a specific command.""" - command = data.args[0] + target = data.args[0] - # Create a dummy message to test which commands pick up the user's - # input: - msg = ":foo!bar@example.com PRIVMSG #channel :msg".split() - dummy = Data(self.bot, msg) - dummy.command = command.lower() - dummy.is_command = True + for command in self.bot.commands: + if command.name == target or target in command.commands: + if command.__doc__: + doc = command.__doc__.replace("\n", "") + doc = re.sub("\s\s+", " ", doc) + msg = "help for command \x0303{0}\x0301: \"{1}\"" + self.reply(data, msg.format(target, doc)) + return - for cmnd_name in self.bot.commands: - cmnd = self.bot.commands.get(cmnd_name) - if not cmnd.check(dummy): - continue - if cmnd.__doc__: - doc = cmnd.__doc__.replace("\n", "") - doc = re.sub("\s\s+", " ", doc) - msg = "help for command \x0303{0}\x0301: \"{1}\"" - self.reply(data, msg.format(command, doc)) - return - break - - msg = "sorry, no help for \x0303{0}\x0301.".format(command) + msg = "sorry, no help for \x0303{0}\x0301.".format(target) self.reply(data, msg) def do_hello(self, data): diff --git a/earwigbot/commands/langcode.py b/earwigbot/commands/langcode.py index 6695980..434e72b 100644 --- a/earwigbot/commands/langcode.py +++ b/earwigbot/commands/langcode.py @@ -26,10 +26,7 @@ class Command(BaseCommand): """Convert a language code into its name and a list of WMF sites in that language.""" name = "langcode" - - def check(self, data): - commands = ["langcode", "lang", "language"] - return data.is_command and data.command in commands + commands = ["langcode", "lang", "language"] def process(self, data): if not data.args: diff --git a/earwigbot/commands/praise.py b/earwigbot/commands/praise.py index e04fd3e..bb60801 100644 --- a/earwigbot/commands/praise.py +++ b/earwigbot/commands/praise.py @@ -25,11 +25,8 @@ from earwigbot.commands import BaseCommand class Command(BaseCommand): """Praise people!""" name = "praise" - - def check(self, data): - commands = ["praise", "earwig", "leonard", "leonard^bloom", "groove", - "groovedog"] - return data.is_command and data.command in commands + commands = ["praise", "earwig", "leonard", "leonard^bloom", "groove", + "groovedog"] def process(self, data): if data.command == "earwig": diff --git a/earwigbot/commands/quit.py b/earwigbot/commands/quit.py index 044f7ff..1bf538d 100644 --- a/earwigbot/commands/quit.py +++ b/earwigbot/commands/quit.py @@ -26,10 +26,7 @@ class Command(BaseCommand): """Quit, restart, or reload components from the bot. Only the owners can run this command.""" name = "quit" - - def check(self, data): - commands = ["quit", "restart", "reload"] - return data.is_command and data.command in commands + commands = ["quit", "restart", "reload"] def process(self, data): if data.host not in self.config.irc["permissions"]["owners"]: diff --git a/earwigbot/commands/registration.py b/earwigbot/commands/registration.py index 3ef641c..88c4119 100644 --- a/earwigbot/commands/registration.py +++ b/earwigbot/commands/registration.py @@ -28,10 +28,7 @@ from earwigbot.commands import BaseCommand class Command(BaseCommand): """Return when a user registered.""" name = "registration" - - def check(self, data): - commands = ["registration", "reg", "age"] - return data.is_command and data.command in commands + commands = ["registration", "reg", "age"] def process(self, data): if not data.args: diff --git a/earwigbot/commands/remind.py b/earwigbot/commands/remind.py index 4a7d6ed..b82faf8 100644 --- a/earwigbot/commands/remind.py +++ b/earwigbot/commands/remind.py @@ -20,7 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -import threading +from threading import Timer import time from earwigbot.commands import BaseCommand @@ -28,9 +28,7 @@ from earwigbot.commands import BaseCommand class Command(BaseCommand): """Set a message to be repeated to you in a certain amount of time.""" name = "remind" - - def check(self, data): - return data.is_command and data.command in ["remind", "reminder"] + commands = ["remind", "reminder"] def process(self, data): if not data.args: @@ -58,12 +56,7 @@ class Command(BaseCommand): msg = msg.format(message, wait, end_time_with_timezone) self.reply(data, msg) - t_reminder = threading.Thread(target=self.reminder, - args=(data, message, wait)) + t_reminder = Timer(wait, self.reply, args=(data, message)) t_reminder.name = "reminder " + end_time t_reminder.daemon = True t_reminder.start() - - def reminder(self, data, message, wait): - time.sleep(wait) - self.reply(data, message) diff --git a/earwigbot/commands/rights.py b/earwigbot/commands/rights.py index 63357c7..bcd1c10 100644 --- a/earwigbot/commands/rights.py +++ b/earwigbot/commands/rights.py @@ -26,10 +26,7 @@ from earwigbot.commands import BaseCommand class Command(BaseCommand): """Retrieve a list of rights for a given username.""" name = "rights" - - def check(self, data): - commands = ["rights", "groups", "permissions", "privileges"] - return data.is_command and data.command in commands + commands = ["rights", "groups", "permissions", "privileges"] def process(self, data): if not data.args: diff --git a/earwigbot/commands/threads.py b/earwigbot/commands/threads.py index e1bbb22..62cb60c 100644 --- a/earwigbot/commands/threads.py +++ b/earwigbot/commands/threads.py @@ -29,10 +29,7 @@ from earwigbot.exceptions import KwargParseError class Command(BaseCommand): """Manage wiki tasks from IRC, and check on thread status.""" name = "threads" - - def check(self, data): - commands = ["tasks", "task", "threads", "tasklist"] - return data.is_command and data.command in commands + commands = ["tasks", "task", "threads", "tasklist"] def process(self, data): self.data = data @@ -103,7 +100,7 @@ class Command(BaseCommand): whether they are currently running or idle.""" threads = threading.enumerate() tasklist = [] - for task in sorted(self.bot.tasks): + for task in sorted([task.name for task in self.bot.tasks]): threadlist = [t for t in threads if t.name.startswith(task)] ids = [str(t.ident) for t in threadlist] if not ids: @@ -138,7 +135,7 @@ class Command(BaseCommand): self.reply(data, msg) return - if task_name not in self.bot.tasks: + if task_name not in [task.name for task in self.bot.tasks]: # This task does not exist or hasn't been loaded: msg = "task could not be found; either it doesn't exist, or it wasn't loaded correctly." self.reply(data, msg.format(task_name)) diff --git a/earwigbot/commands/time.py b/earwigbot/commands/time.py new file mode 100644 index 0000000..dae1822 --- /dev/null +++ b/earwigbot/commands/time.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 by 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 datetime import datetime, timedelta +from math import floor +from time import time + +from earwigbot.commands import BaseCommand + +class Command(BaseCommand): + """Report the current time in any timezone (UTC default), or in beats.""" + name = "time" + commands = ["time", "beats", "swatch"] + timezones = [ + "UTC": 0, + "EST": -5, + "EDT": -4, + "CST": -6, + "CDT": -5, + "MST": -7, + "MDT": -6, + "PST": -8, + "PDT": -7, + ] + + def process(self, data): + if data.command in ["beats", "swatch"]: + self.do_beats(data) + return + if data.args: + timezone = data.args[0] + else: + timezone = "UTC" + if timezone in ["beats", "swatch"]: + self.do_beats(data) + else: + self.do_time(data, timezone) + + def do_beats(self, data): + beats = ((time() + 3600) % 86400) / 86.4 + beats = int(floor(beats)) + self.reply(data, "@{0:0>3}".format(beats)) + + def do_time(self, data, timezone): + now = datetime.utcnow() + try: + now += timedelta(hours=self.timezones[timezone]) # Timezone offset + except KeyError: + self.reply(data, "unknown timezone: {0}.".format(timezone)) + return + self.reply(data, now.strftime("%Y-%m-%d %H:%M:%S") + " " + timezone) diff --git a/earwigbot/managers.py b/earwigbot/managers.py index e7882f3..dd18b63 100644 --- a/earwigbot/managers.py +++ b/earwigbot/managers.py @@ -24,7 +24,7 @@ import imp from os import listdir, path from re import sub -from threading import Lock, Thread +from threading import RLock, Thread from time import gmtime, strftime from earwigbot.commands import BaseCommand @@ -46,11 +46,7 @@ class _ResourceManager(object): This class handles the low-level tasks of (re)loading resources via :py:meth:`load`, retrieving specific resources via :py:meth:`get`, and - iterating over all resources via :py:meth:`__iter__`. If iterating over - resources, it is recommended to acquire :py:attr:`self.lock ` - beforehand and release it afterwards (alternatively, wrap your code in a - ``with`` statement) so an attempt at reloading resources in another thread - won't disrupt your iteration. + iterating over all resources via :py:meth:`__iter__`. """ def __init__(self, bot, name, attribute, base): self.bot = bot @@ -60,16 +56,12 @@ class _ResourceManager(object): self._resource_name = name # e.g. "commands" or "tasks" self._resource_attribute = attribute # e.g. "Command" or "Task" self._resource_base = base # e.g. BaseCommand or BaseTask - self._resource_access_lock = Lock() - - @property - def lock(self): - """The resource access/modify lock.""" - return self._resource_access_lock + self._resource_access_lock = RLock() def __iter__(self): - for name in self._resources: - yield name + with self.lock: + for resource in self._resources.itervalues(): + yield resource def _load_resource(self, name, path): """Load a specific resource from a module, identified by name and path. @@ -118,6 +110,11 @@ class _ResourceManager(object): self._load_resource(modname, dir) processed.append(modname) + @property + def lock(self): + """The resource access/modify lock.""" + return self._resource_access_lock + def load(self): """Load (or reload) all valid resources into :py:attr:`_resources`.""" name = self._resource_name # e.g. "commands" or "tasks" @@ -138,7 +135,8 @@ class _ResourceManager(object): Will raise :py:exc:`KeyError` if the resource (a command or task) is not found. """ - return self._resources[key] + with self.lock: + return self._resources[key] class CommandManager(_ResourceManager): @@ -167,13 +165,10 @@ class CommandManager(_ResourceManager): def call(self, hook, data): """Respond to a hook type and a :py:class:`Data` object.""" - self.lock.acquire() - for command in self._resources.itervalues(): + for command in self: if hook in command.hooks and self._wrap_check(command, data): - self.lock.release() self._wrap_process(command, data) return - self.lock.release() class TaskManager(_ResourceManager): From e11ce3f901d802c1e1cd1a6a0bdc9dde7c1e1250 Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Wed, 4 Jul 2012 00:35:17 -0400 Subject: [PATCH 07/14] Adding afc_submissions command. --- earwigbot/commands/afc_submissions.py | 59 +++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 earwigbot/commands/afc_submissions.py diff --git a/earwigbot/commands/afc_submissions.py b/earwigbot/commands/afc_submissions.py new file mode 100644 index 0000000..2a2f6a2 --- /dev/null +++ b/earwigbot/commands/afc_submissions.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 by 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 + +__all__ = ["AFCSubmissions"] + +class AFCSubmissions(Command): + """Link the user directly to some pending AFC submissions.""" + name = "submissions" + commands = ["submissions", "subs"] + + def setup(self): + try: + self.ignore_list = self.config.commands[self.name]["ignoreList"] + except KeyError: + try: + ignores = self.config.tasks["afc_statistics"]["ignoreList"] + self.ignore_list = ignores + except KeyError: + self.ignore_list = [] + + def process(self, data): + if data.args: + try: + number = int(data.args[0]) + except ValueError: + self.reply(data, "argument must be a number.") + return + if number > 5: + msg = "cannot get more than five submissions at a time." + self.reply(data, msg) + return + else: + number = 3 + + site = self.bot.wiki.get_site() + category = site.get_category("Pending AfC submissions") + pages = ", ".join(category.get_members(use_sql=True, limit=number)) + self.reply(data, "{0} pending AfC subs: {1}".format(number, pages)) From 729aa04cc1f6eeea1c17af66e237702ab899dad4 Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Wed, 4 Jul 2012 01:11:18 -0400 Subject: [PATCH 08/14] Site.url; some refactoring and cleanup --- docs/toolset.rst | 2 ++ earwigbot/commands/_old.py | 18 ++++++++++++------ earwigbot/commands/afc_submissions.py | 3 ++- earwigbot/wiki/page.py | 2 +- earwigbot/wiki/site.py | 21 +++++++++++++-------- 5 files changed, 30 insertions(+), 16 deletions(-) diff --git a/docs/toolset.rst b/docs/toolset.rst index 812b0c8..75f4a2f 100644 --- a/docs/toolset.rst +++ b/docs/toolset.rst @@ -80,6 +80,8 @@ following attributes: ``"en"`` - :py:attr:`~earwigbot.wiki.site.Site.domain`: the site's web domain, like ``"en.wikipedia.org"`` +- :py:attr:`~earwigbot.wiki.site.Site.url`: the site's full base URL, like + ``"https://en.wikipedia.org"`` and the following methods: diff --git a/earwigbot/commands/_old.py b/earwigbot/commands/_old.py index e22d681..3767fa9 100644 --- a/earwigbot/commands/_old.py +++ b/earwigbot/commands/_old.py @@ -9,18 +9,16 @@ def parse(command, line, line2, nick, chan, host, auth, notice, say, reply, s): authy = auth(host) + + if command == "access": a = 'The bot\'s owner is "%s".' % OWNER b = 'The bot\'s admins are "%s".' % ', '.join(ADMINS_R) reply(a, chan, nick) reply(b, chan, nick) return - if command == "tock": - u = urllib.urlopen('http://tycho.usno.navy.mil/cgi-bin/timer.pl') - info = u.info() - u.close() - say('"' + info['Date'] + '" - tycho.usno.navy.mil', chan) - return + + if command == "dict" or command == "dictionary": def trim(thing): if thing.endswith(' '): @@ -58,6 +56,8 @@ def parse(command, line, line2, nick, chan, host, auth, notice, say, reply, s): reply('Sorry, no definition found.', chan, nick) else: say(result, chan) return + + if command == "ety" or command == "etymology": etyuri = 'http://etymonline.com/?term=%s' etysearch = 'http://etymonline.com/?search=%s' @@ -126,6 +126,8 @@ def parse(command, line, line2, nick, chan, host, auth, notice, say, reply, s): msg = 'Can\'t find the etymology for "%s". Try %s' % (word, uri) reply(msg, chan, nick) return + + if command == "sub" or command == "submissions": try: number = int(line2[4]) @@ -162,6 +164,8 @@ def parse(command, line, line2, nick, chan, host, auth, notice, say, reply, s): report = "\x02First %s pending AfC submissions:\x0F %s" % (number, s) say(report, chan) return + + if command == "trout": try: user = line2[4] @@ -182,6 +186,8 @@ def parse(command, line, line2, nick, chan, host, auth, notice, say, reply, s): else: reply("I refuse to hurt anything with \"Earwig\" in its name :P", chan, nick) return + + if command == "notes" or command == "note" or command == "about" or command == "data" or command == "database": try: action = line2[4] diff --git a/earwigbot/commands/afc_submissions.py b/earwigbot/commands/afc_submissions.py index 2a2f6a2..2c8ce9f 100644 --- a/earwigbot/commands/afc_submissions.py +++ b/earwigbot/commands/afc_submissions.py @@ -55,5 +55,6 @@ class AFCSubmissions(Command): site = self.bot.wiki.get_site() category = site.get_category("Pending AfC submissions") - pages = ", ".join(category.get_members(use_sql=True, limit=number)) + members = category.get_members(use_sql=True, limit=number) + pages = ", ".join([member.url for member in members]) self.reply(data, "{0} pending AfC subs: {1}".format(number, pages)) diff --git a/earwigbot/wiki/page.py b/earwigbot/wiki/page.py index 98f11dd..310edad 100644 --- a/earwigbot/wiki/page.py +++ b/earwigbot/wiki/page.py @@ -498,7 +498,7 @@ class Page(CopyrightMixin): else: slug = quote(self._title.replace(" ", "_"), safe="/:") path = self._site._article_path.replace("$1", slug) - return ''.join((self._site._base_url, path)) + return ''.join((self._site.url, path)) @property def namespace(self): diff --git a/earwigbot/wiki/site.py b/earwigbot/wiki/site.py index eb818c4..a47c839 100644 --- a/earwigbot/wiki/site.py +++ b/earwigbot/wiki/site.py @@ -69,6 +69,7 @@ class Site(object): - :py:attr:`project`: the site's project name, like ``"wikipedia"`` - :py:attr:`lang`: the site's language code, like ``"en"`` - :py:attr:`domain`: the site's web domain, like ``"en.wikipedia.org"`` + - :py:attr:`url`: the site's URL, like ``"https://en.wikipedia.org"`` *Public methods:* @@ -243,14 +244,7 @@ class Site(object): e = "Tried to do an API query, but no API URL is known." raise exceptions.SiteAPIError(e) - base_url = self._base_url - if base_url.startswith("//"): # Protocol-relative URLs from 1.18 - if self._use_https: - base_url = "https:" + base_url - else: - base_url = "http:" + base_url - url = ''.join((base_url, self._script_path, "/api.php")) - + url = ''.join((self.url, self._script_path, "/api.php")) params["format"] = "json" # This is the only format we understand if self._assert_edit: # If requested, ensure that we're logged in params["assert"] = self._assert_edit @@ -548,6 +542,17 @@ class Site(object): """The Site's web domain, like ``"en.wikipedia.org"``.""" return urlparse(self._base_url).netloc + @property + def url(self): + """The Site's full base URL, like ``"https://en.wikipedia.org"``.""" + url = self._base_url + if url.startswith("//"): # Protocol-relative URLs from 1.18 + if self._use_https: + url = "https:" + url + else: + url = "http:" + url + return url + def api_query(self, **kwargs): """Do an API query with `kwargs` as the parameters. From 4b1d745e2c89ca93cefaafeb12f7e0981e0ffe48 Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Wed, 4 Jul 2012 01:37:30 -0400 Subject: [PATCH 09/14] Handle timezones correctly with pytz. --- earwigbot/commands/time.py | 30 ++++++++++++++---------------- setup.py | 1 + 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/earwigbot/commands/time.py b/earwigbot/commands/time.py index dae1822..53dafe8 100644 --- a/earwigbot/commands/time.py +++ b/earwigbot/commands/time.py @@ -20,27 +20,21 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from datetime import datetime, timedelta +from datetime import datetime from math import floor from time import time +try: + import pytz +except ImportError: + pytz = None + from earwigbot.commands import BaseCommand class Command(BaseCommand): """Report the current time in any timezone (UTC default), or in beats.""" name = "time" commands = ["time", "beats", "swatch"] - timezones = [ - "UTC": 0, - "EST": -5, - "EDT": -4, - "CST": -6, - "CDT": -5, - "MST": -7, - "MDT": -6, - "PST": -8, - "PDT": -7, - ] def process(self, data): if data.command in ["beats", "swatch"]: @@ -61,10 +55,14 @@ class Command(BaseCommand): self.reply(data, "@{0:0>3}".format(beats)) def do_time(self, data, timezone): - now = datetime.utcnow() + if not pytz: + msg = "this command requires the 'pytz' module: http://pytz.sourceforge.net/" + self.reply(data, msg) + return try: - now += timedelta(hours=self.timezones[timezone]) # Timezone offset - except KeyError: + tzinfo = pytz.timezone(timezone) + except pytz.exceptions.UnknownTimeZoneError: self.reply(data, "unknown timezone: {0}.".format(timezone)) return - self.reply(data, now.strftime("%Y-%m-%d %H:%M:%S") + " " + timezone) + now = pytz.utc.localize(datetime.utcnow()).astimezone(tzinfo) + self.reply(data, now.strftime("%Y-%m-%d %H:%M:%S %Z")) diff --git a/setup.py b/setup.py index 0e32bbd..762bda8 100644 --- a/setup.py +++ b/setup.py @@ -37,6 +37,7 @@ setup( "pycrypto >= 2.5", # Storing bot passwords and keys "GitPython >= 0.3.2.RC1", # Interfacing with git "PyYAML >= 3.10", # Config parsing + "pytz >= 2012c", # Timezone handling ], test_suite = "tests", version = __version__, From 09bfc441b0d75d32e9c5d635f333d0a5c79c6d87 Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Wed, 4 Jul 2012 02:02:33 -0400 Subject: [PATCH 10/14] Some anti-hardcoding refactor work and other fixes. --- earwigbot/commands/_old.py | 38 -------------------------------------- earwigbot/commands/editcount.py | 5 +++-- earwigbot/commands/git.py | 17 +++++++++++------ earwigbot/commands/registration.py | 2 +- earwigbot/commands/replag.py | 10 ++++++++-- 5 files changed, 23 insertions(+), 49 deletions(-) diff --git a/earwigbot/commands/_old.py b/earwigbot/commands/_old.py index 3767fa9..8681b8b 100644 --- a/earwigbot/commands/_old.py +++ b/earwigbot/commands/_old.py @@ -128,44 +128,6 @@ def parse(command, line, line2, nick, chan, host, auth, notice, say, reply, s): return - if command == "sub" or command == "submissions": - try: - number = int(line2[4]) - except Exception: - reply("Please enter a number.", chan, nick) - return - do_url = False - try: - if "url" in line2[5:]: do_url = True - except Exception: - pass - url = "http://en.wikipedia.org/w/api.php?action=query&list=categorymembers&cmtitle=Category:Pending_AfC_submissions&cmlimit=500&cmsort=timestamp" - query = urllib.urlopen(url) - data = query.read() - pages = re.findall("title="(.*?)"", data) - try: - pages.remove("Wikipedia:Articles for creation/Redirects") - except Exception: - pass - try: - pages.remove("Wikipedia:Files for upload") - except Exception: - pass - pages.reverse() - pages = pages[:number] - if not do_url: - s = string.join(pages, "]], [[") - s = "[[%s]]" % s - else: - s = string.join(pages, ">, ,_<", ">, <", s) - report = "\x02First %s pending AfC submissions:\x0F %s" % (number, s) - say(report, chan) - return - - if command == "trout": try: user = line2[4] diff --git a/earwigbot/commands/editcount.py b/earwigbot/commands/editcount.py index 60f225d..b25269f 100644 --- a/earwigbot/commands/editcount.py +++ b/earwigbot/commands/editcount.py @@ -47,6 +47,7 @@ class Command(BaseCommand): return safe = quote_plus(user.name) - url = "http://toolserver.org/~tparis/pcount/index.php?name={0}&lang=en&wiki=wikipedia" + url = "http://toolserver.org/~tparis/pcount/index.php?name={0}&lang={1}&wiki={2}" + fullurl = url.format(safe, site.lang, site.project) msg = "\x0302{0}\x0301 has {1} edits ({2})." - self.reply(data, msg.format(name, count, url.format(safe))) + self.reply(data, msg.format(name, count, fullurl)) diff --git a/earwigbot/commands/git.py b/earwigbot/commands/git.py index 8ee3f22..07c681a 100644 --- a/earwigbot/commands/git.py +++ b/earwigbot/commands/git.py @@ -30,10 +30,12 @@ class Command(BaseCommand): """Commands to interface with the bot's git repository; use '!git' for a sub-command list.""" name = "git" - repos = { - "core": "/home/earwig/git/earwigbot", - "plugins": "/home/earwig/git/earwigbot-plugins", - } + + def setup(self): + try: + self.repos = self.config.commands[self.name]["repos"] + except KeyError: + self.repos = None def process(self, data): self.data = data @@ -44,6 +46,9 @@ class Command(BaseCommand): if not data.args or data.args[0] == "help": self.do_help() return + if not self.repos: + self.reply(data, "no repos are specified in the config file.") + return command = data.args[0] try: @@ -55,7 +60,7 @@ class Command(BaseCommand): return if repo_name not in self.repos: repos = self.get_repos() - msg = "repository must be one of the following: {0}" + msg = "repository must be one of the following: {0}." self.reply(data, msg.format(repos)) return self.repo = git.Repo(self.repos[repo_name]) @@ -89,7 +94,7 @@ class Command(BaseCommand): try: return getattr(self.repo.remotes, remote_name) except AttributeError: - msg = "unknown remote: \x0302{0}\x0301".format(remote_name) + msg = "unknown remote: \x0302{0}\x0301.".format(remote_name) self.reply(self.data, msg) def get_time_since(self, date): diff --git a/earwigbot/commands/registration.py b/earwigbot/commands/registration.py index 88c4119..4c8b099 100644 --- a/earwigbot/commands/registration.py +++ b/earwigbot/commands/registration.py @@ -54,7 +54,7 @@ class Command(BaseCommand): elif user.gender == "female": gender = "She's" else: - gender = "They're" + gender = "They're" # Singluar they? msg = "\x0302{0}\x0301 registered on {1}. {2} {3} old." self.reply(data, msg.format(name, date, gender, age)) diff --git a/earwigbot/commands/replag.py b/earwigbot/commands/replag.py index ab81319..b8899bc 100644 --- a/earwigbot/commands/replag.py +++ b/earwigbot/commands/replag.py @@ -30,10 +30,16 @@ class Command(BaseCommand): """Return the replag for a specific database on the Toolserver.""" name = "replag" + def setup(self): + try: + self.key = self.config.commands[self.name]["default"] + except KeyError: + self.default = None + def process(self, data): args = {} if not data.args: - args["db"] = "enwiki_p" + args["db"] = self.default or self.bot.wiki.get_site().name + "_p" else: args["db"] = data.args[0] args["host"] = args["db"].replace("_", "-") + ".rrdb.toolserver.org" @@ -47,5 +53,5 @@ class Command(BaseCommand): replag = int(cursor.fetchall()[0][0]) conn.close() - msg = "Replag on \x0302{0}\x0301 is \x02{1}\x0F seconds." + msg = "replag on \x0302{0}\x0301 is \x02{1}\x0F seconds." self.reply(data, msg.format(args["db"], replag)) From 1afa10cbc5b14d3ecb265a943eb0c17d539db3c4 Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Wed, 4 Jul 2012 02:12:44 -0400 Subject: [PATCH 11/14] Refactor actual praises out of praise; fix replag --- earwigbot/commands/praise.py | 34 ++++++++++++++++++---------------- earwigbot/commands/replag.py | 2 +- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/earwigbot/commands/praise.py b/earwigbot/commands/praise.py index bb60801..86ece33 100644 --- a/earwigbot/commands/praise.py +++ b/earwigbot/commands/praise.py @@ -25,22 +25,24 @@ from earwigbot.commands import BaseCommand class Command(BaseCommand): """Praise people!""" name = "praise" - commands = ["praise", "earwig", "leonard", "leonard^bloom", "groove", - "groovedog"] + + def setup(self): + try: + self.praises = self.config.commands[self.name]["praises"] + except KeyError: + self.praises = [] + + def check(self, data): + check = data.command == "praise" or data.command in self.praises + return data.is_command and check def process(self, data): - if data.command == "earwig": - msg = "\x02Earwig\x0F is the bestest Python programmer ever!" - elif data.command in ["leonard", "leonard^bloom"]: - msg = "\x02Leonard^Bloom\x0F is the biggest slacker ever!" - elif data.command in ["groove", "groovedog"]: - msg = "\x02GrooveDog\x0F is the bestest heh evar!" - else: - if not data.args: - msg = "You use this command to praise certain people. Who they are is a secret." - else: - msg = "You're doing it wrong." - self.reply(data, msg) + if data.command in self.praises: + msg = self.praises[data.command] + self.say(data.chan, msg) return - - self.say(data.chan, msg) + if not data.args: + msg = "You use this command to praise certain people. Who they are is a secret." + else: + msg = "you're doing it wrong." + self.reply(data, msg) diff --git a/earwigbot/commands/replag.py b/earwigbot/commands/replag.py index b8899bc..91e0f9f 100644 --- a/earwigbot/commands/replag.py +++ b/earwigbot/commands/replag.py @@ -32,7 +32,7 @@ class Command(BaseCommand): def setup(self): try: - self.key = self.config.commands[self.name]["default"] + self.default = self.config.commands[self.name]["default"] except KeyError: self.default = None From 17c99248ce36ab881bb11fffff7918d1e81cb08f Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Wed, 4 Jul 2012 13:22:14 -0400 Subject: [PATCH 12/14] Rename tasks, remove feed_dailycats, add image_display_resize. --- earwigbot/tasks/{blptag.py => blp_tag.py} | 2 +- earwigbot/tasks/{feed_dailycats.py => image_display_resize.py} | 10 +++++----- earwigbot/tasks/{wrongmime.py => wrong_mime.py} | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) rename earwigbot/tasks/{blptag.py => blp_tag.py} (98%) rename earwigbot/tasks/{feed_dailycats.py => image_display_resize.py} (86%) rename earwigbot/tasks/{wrongmime.py => wrong_mime.py} (98%) diff --git a/earwigbot/tasks/blptag.py b/earwigbot/tasks/blp_tag.py similarity index 98% rename from earwigbot/tasks/blptag.py rename to earwigbot/tasks/blp_tag.py index 2d9c7e4..f4f1011 100644 --- a/earwigbot/tasks/blptag.py +++ b/earwigbot/tasks/blp_tag.py @@ -27,7 +27,7 @@ __all__ = ["Task"] class Task(BaseTask): """A task to add |blp=yes to ``{{WPB}}`` or ``{{WPBS}}`` when it is used along with ``{{WP Biography}}``.""" - name = "blptag" + name = "blp_tag" def setup(self): pass diff --git a/earwigbot/tasks/feed_dailycats.py b/earwigbot/tasks/image_display_resize.py similarity index 86% rename from earwigbot/tasks/feed_dailycats.py rename to earwigbot/tasks/image_display_resize.py index 2d08540..ebf2bdf 100644 --- a/earwigbot/tasks/feed_dailycats.py +++ b/earwigbot/tasks/image_display_resize.py @@ -20,13 +20,13 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from earwigbot.tasks import BaseTask +from earwigbot.tasks import Task -__all__ = ["Task"] +__all__ = ["ImageDisplayResize"] -class Task(BaseTask): - """A task to create daily categories for [[WP:FEED]].""" - name = "feed_dailycats" +class ImageDisplayResize(Task): + """A task to resize upscaled portraits in infoboxes.""" + name = "image_display_resize" def setup(self): pass diff --git a/earwigbot/tasks/wrongmime.py b/earwigbot/tasks/wrong_mime.py similarity index 98% rename from earwigbot/tasks/wrongmime.py rename to earwigbot/tasks/wrong_mime.py index 2c8813e..bec0caf 100644 --- a/earwigbot/tasks/wrongmime.py +++ b/earwigbot/tasks/wrong_mime.py @@ -27,7 +27,7 @@ __all__ = ["Task"] class Task(BaseTask): """A task to tag files whose extensions do not agree with their MIME type.""" - name = "wrongmime" + name = "wrong_mime" def setup(self): pass From 71ac38ea89d6bb49f1af0432d5b3b060ed5f24da Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Wed, 4 Jul 2012 16:22:31 -0400 Subject: [PATCH 13/14] !trout command, plus an indent fix --- earwigbot/commands/_old.py | 22 ------------------ earwigbot/commands/afc_pending.py | 2 +- earwigbot/commands/trout.py | 48 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 23 deletions(-) create mode 100644 earwigbot/commands/trout.py diff --git a/earwigbot/commands/_old.py b/earwigbot/commands/_old.py index 8681b8b..bc58ee2 100644 --- a/earwigbot/commands/_old.py +++ b/earwigbot/commands/_old.py @@ -128,28 +128,6 @@ def parse(command, line, line2, nick, chan, host, auth, notice, say, reply, s): return - if command == "trout": - try: - user = line2[4] - user = ' '.join(line2[4:]) - except Exception: - reply("Hahahahahahahaha...", chan, nick) - return - normal = unicodedata.normalize('NFKD', unicode(string.lower(user))) - if "itself" in normal: - reply("I'm not that stupid ;)", chan, nick) - return - elif "earwigbot" in normal: - reply("I'm not that stupid ;)", chan, nick) - elif "earwig" not in normal and "ear wig" not in normal: - text = 'slaps %s around a bit with a large trout.' % user - msg = '\x01ACTION %s\x01' % text - say(msg, chan) - else: - reply("I refuse to hurt anything with \"Earwig\" in its name :P", chan, nick) - return - - if command == "notes" or command == "note" or command == "about" or command == "data" or command == "database": try: action = line2[4] diff --git a/earwigbot/commands/afc_pending.py b/earwigbot/commands/afc_pending.py index 98b4dfb..6a2fec0 100644 --- a/earwigbot/commands/afc_pending.py +++ b/earwigbot/commands/afc_pending.py @@ -31,4 +31,4 @@ class Command(BaseCommand): msg1 = "pending submissions status page: http://enwp.org/WP:AFC/ST" msg2 = "pending submissions category: http://enwp.org/CAT:PEND" self.reply(data, msg1) - self.reply(data, msg2) + self.reply(data, msg2) diff --git a/earwigbot/commands/trout.py b/earwigbot/commands/trout.py new file mode 100644 index 0000000..6449d62 --- /dev/null +++ b/earwigbot/commands/trout.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 by 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 unicodedata import normalize + +from earwigbot.commands import Command + +__all__ = ["Trout"] + +class Trout(Command): + """Slap someone with a trout, or related fish.""" + name = "trout" + commands = ["trout", "whale"] + + def setup(self): + try: + self.exceptions = self.config.commands[self.name]["exceptions"] + except KeyError: + self.exceptions = {} + + def process(self, data): + animal = data.command + target = " ".join(data.args) or data.nick + normal = normalize("NFKD", target.decode("utf8")).lower() + if normal in self.exceptions: + self.reply(data, self.exceptions["normal"]) + else: + msg = "slaps {0} around a bit with a large {1}." + self.action(data.chan, msg.format(target, animal)) From 9f7e5ea7bc5ce4f258eaaa6ebb17152be7840b3c Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Wed, 4 Jul 2012 16:28:08 -0400 Subject: [PATCH 14/14] Move final bits of _old to notes.py --- earwigbot/commands/_old.py | 263 -------------------------------------------- earwigbot/commands/notes.py | 169 ++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 263 deletions(-) delete mode 100644 earwigbot/commands/_old.py create mode 100644 earwigbot/commands/notes.py diff --git a/earwigbot/commands/_old.py b/earwigbot/commands/_old.py deleted file mode 100644 index bc58ee2..0000000 --- a/earwigbot/commands/_old.py +++ /dev/null @@ -1,263 +0,0 @@ -# -*- coding: utf-8 -*- -###### -###### NOTE: -###### This is an old commands file from the previous version of EarwigBot. -###### It is not used by the new EarwigBot and is simply here for reference -###### when developing new commands. -###### -### EarwigBot - -def parse(command, line, line2, nick, chan, host, auth, notice, say, reply, s): - authy = auth(host) - - - if command == "access": - a = 'The bot\'s owner is "%s".' % OWNER - b = 'The bot\'s admins are "%s".' % ', '.join(ADMINS_R) - reply(a, chan, nick) - reply(b, chan, nick) - return - - - if command == "dict" or command == "dictionary": - def trim(thing): - if thing.endswith(' '): - thing = thing[:-6] - return thing.strip(' :.') - r_li = re.compile(r'(?ims)
  • .*?
  • ') - r_tag = re.compile(r'<[^>]+>') - r_parens = re.compile(r'(?<=\()(?:[^()]+|\([^)]+\))*(?=\))') - r_word = re.compile(r'^[A-Za-z0-9\' -]+$') - uri = 'http://encarta.msn.com/dictionary_/%s.html' - r_info = re.compile(r'(?:ResultBody">

    (.*?) )|(?:(.*?))') - try: - word = line2[4] - except Exception: - reply("Please enter a word.", chan, nick) - return - word = urllib.quote(word.encode('utf-8')) - bytes = web.get(uri % word) - results = {} - wordkind = None - for kind, sense in r_info.findall(bytes): - kind, sense = trim(kind), trim(sense) - if kind: wordkind = kind - elif sense: - results.setdefault(wordkind, []).append(sense) - result = word.encode('utf-8') + ' - ' - for key in sorted(results.keys()): - if results[key]: - result += (key or '') + ' 1. ' + results[key][0] - if len(results[key]) > 1: - result += ', 2. ' + results[key][1] - result += '; ' - result = result.rstrip('; ') - if result.endswith('-') and (len(result) < 30): - reply('Sorry, no definition found.', chan, nick) - else: say(result, chan) - return - - - if command == "ety" or command == "etymology": - etyuri = 'http://etymonline.com/?term=%s' - etysearch = 'http://etymonline.com/?search=%s' - r_definition = re.compile(r'(?ims)]*>.*?') - r_tag = re.compile(r'<(?!!)[^>]+>') - r_whitespace = re.compile(r'[\t\r\n ]+') - abbrs = [ - 'cf', 'lit', 'etc', 'Ger', 'Du', 'Skt', 'Rus', 'Eng', 'Amer.Eng', 'Sp', - 'Fr', 'N', 'E', 'S', 'W', 'L', 'Gen', 'J.C', 'dial', 'Gk', - '19c', '18c', '17c', '16c', 'St', 'Capt', 'obs', 'Jan', 'Feb', 'Mar', - 'Apr', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', 'c', 'tr', 'e', 'g' - ] - t_sentence = r'^.*?(?') - s = s.replace('<', '<') - s = s.replace('&', '&') - return s - def text(html): - html = r_tag.sub('', html) - html = r_whitespace.sub(' ', html) - return unescape(html).strip() - try: - word = line2[4] - except Exception: - reply("Please enter a word.", chan, nick) - return - def ety(word): - if len(word) > 25: - raise ValueError("Word too long: %s[...]" % word[:10]) - word = {'axe': 'ax/axe'}.get(word, word) - bytes = web.get(etyuri % word) - definitions = r_definition.findall(bytes) - if not definitions: - return None - defn = text(definitions[0]) - m = r_sentence.match(defn) - if not m: - return None - sentence = m.group(0) - try: - sentence = unicode(sentence, 'iso-8859-1') - sentence = sentence.encode('utf-8') - except: pass - maxlength = 275 - if len(sentence) > maxlength: - sentence = sentence[:maxlength] - words = sentence[:-5].split(' ') - words.pop() - sentence = ' '.join(words) + ' [...]' - sentence = '"' + sentence.replace('"', "'") + '"' - return sentence + ' - ' + (etyuri % word) - try: - result = ety(word.encode('utf-8')) - except IOError: - msg = "Can't connect to etymonline.com (%s)" % (etyuri % word) - reply(msg, chan, nick) - return - except AttributeError: - result = None - if result is not None: - reply(result, chan, nick) - else: - uri = etysearch % word - msg = 'Can\'t find the etymology for "%s". Try %s' % (word, uri) - reply(msg, chan, nick) - return - - - if command == "notes" or command == "note" or command == "about" or command == "data" or command == "database": - try: - action = line2[4] - except BaseException: - reply("What do you want me to do? Type \"!notes help\" for more information.", chan, nick) - return - import MySQLdb - db = MySQLdb.connect(db="u_earwig_ircbot", host="sql", read_default_file="/home/earwig/.my.cnf") - specify = ' '.join(line2[5:]) - if action == "help" or action == "manual": - shortCommandList = "read, write, change, undo, delete, move, author, category, list, report, developer" - if specify == "read": - say("To read an entry, type \"!notes read \".", chan) - elif specify == "write": - say("To write a new entry, type \"!notes write \". This will create a new entry only if one does not exist, see the below command...", chan) - elif specify == "change": - say("To change an entry, type \"!notes change \". The old entry will be stored in the database, so it can be undone later.", chan) - elif specify == "undo": - say("To undo a change, type \"!notes undo \".", chan) - elif specify == "delete": - say("To delete an entry, type \"!notes delete \". For security reasons, only bot admins can do this.", chan) - elif specify == "move": - say("To move an entry, type \"!notes move \".", chan) - elif specify == "author": - say("To return the author of an entry, type \"!notes author \".", chan) - elif specify == "category" or specify == "cat": - say("To change an entry's category, type \"!notes category \".", chan) - elif specify == "list": - say("To list all categories in the database, type \"!notes list\". Type \"!notes list \" to get all entries in a certain category.", chan) - elif specify == "report": - say("To give some statistics about the mini-wiki, including some debugging information, type \"!notes report\" in a PM.", chan) - elif specify == "developer": - say("To do developer work, such as writing to the database directly, type \"!notes developer \". This can only be done by the bot owner.", chan) - else: - db.query("SELECT * FROM version;") - r = db.use_result() - data = r.fetch_row(0) - version = data[0] - reply("The Earwig Mini-Wiki: running v%s." % version, chan, nick) - reply("The full list of commands, for reference, are: %s." % shortCommandList, chan, nick) - reply("For an explaination of a certain command, type \"!notes help \".", chan, nick) - reply("You can also access the database from the Toolserver: http://toolserver.org/~earwig/cgi-bin/irc_database.py", chan, nick) - time.sleep(0.4) - return - elif action == "read": - specify = string.lower(specify) - if " " in specify: specify = string.split(specify, " ")[0] - if not specify or "\"" in specify: - reply("Please include the name of the entry you would like to read after the command, e.g. !notes read earwig", chan, nick) - return - try: - db.query("SELECT entry_content FROM entries WHERE entry_title = \"%s\";" % specify) - r = db.use_result() - data = r.fetch_row(0) - entry = data[0][0] - say("Entry \"\x02%s\x0F\": %s" % (specify, entry), chan) - except Exception: - reply("There is no entry titled \"\x02%s\x0F\"." % specify, chan, nick) - return - elif action == "delete" or action == "remove": - specify = string.lower(specify) - if " " in specify: specify = string.split(specify, " ")[0] - if not specify or "\"" in specify: - reply("Please include the name of the entry you would like to delete after the command, e.g. !notes delete earwig", chan, nick) - return - if authy == "owner" or authy == "admin": - try: - db.query("DELETE from entries where entry_title = \"%s\";" % specify) - r = db.use_result() - db.commit() - reply("The entry on \"\x02%s\x0F\" has been removed." % specify, chan, nick) - except Exception: - phenny.reply("Unable to remove the entry on \"\x02%s\x0F\", because it doesn't exist." % specify, chan, nick) - else: - reply("Only bot admins can remove entries.", chan, nick) - return - elif action == "developer": - if authy == "owner": - db.query(specify) - r = db.use_result() - try: - print r.fetch_row(0) - except Exception: - pass - db.commit() - reply("Done.", chan, nick) - else: - reply("Only the bot owner can modify the raw database.", chan, nick) - return - elif action == "write": - try: - write = line2[5] - content = ' '.join(line2[6:]) - except Exception: - reply("Please include some content in your entry.", chan, nick) - return - db.query("SELECT * from entries WHERE entry_title = \"%s\";" % write) - r = db.use_result() - data = r.fetch_row(0) - if data: - reply("An entry on %s already exists; please use \"!notes change %s %s\"." % (write, write, content), chan, nick) - return - content2 = content.replace('"', '\\' + '"') - db.query("INSERT INTO entries (entry_title, entry_author, entry_category, entry_content, entry_content_old) VALUES (\"%s\", \"%s\", \"uncategorized\", \"%s\", NULL);" % (write, nick, content2)) - db.commit() - reply("You have written an entry titled \"\x02%s\x0F\", with the following content: \"%s\"" % (write, content), chan, nick) - return - elif action == "change": - reply("NotImplementedError", chan, nick) - elif action == "undo": - reply("NotImplementedError", chan, nick) - elif action == "move": - reply("NotImplementedError", chan, nick) - elif action == "author": - try: - entry = line2[5] - except Exception: - reply("Please include the name of the entry you would like to get information for after the command, e.g. !notes author earwig", chan, nick) - return - db.query("SELECT entry_author from entries WHERE entry_title = \"%s\";" % entry) - r = db.use_result() - data = r.fetch_row(0) - if data: - say("The author of \"\x02%s\x0F\" is \x02%s\x0F." % (entry, data[0][0]), chan) - return - reply("There is no entry titled \"\x02%s\x0F\"." % entry, chan, nick) - return - elif action == "cat" or action == "category": - reply("NotImplementedError", chan, nick) - elif action == "list": - reply("NotImplementedError", chan, nick) - elif action == "report": - reply("NotImplementedError", chan, nick) diff --git a/earwigbot/commands/notes.py b/earwigbot/commands/notes.py new file mode 100644 index 0000000..29d387f --- /dev/null +++ b/earwigbot/commands/notes.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 by 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 + +__all__ = ["Notes"] + +class Notes(Command): + """A mini IRC-based wiki for storing notes, tips, and reminders.""" + name = "notes" + + def process(self, data): + pass + + +class OldCommand(object): + def parse(self): + if command == "notes" or command == "note" or command == "about" or command == "data" or command == "database": + try: + action = line2[4] + except BaseException: + reply("What do you want me to do? Type \"!notes help\" for more information.", chan, nick) + return + import MySQLdb + db = MySQLdb.connect(db="u_earwig_ircbot", host="sql", read_default_file="/home/earwig/.my.cnf") + specify = ' '.join(line2[5:]) + if action == "help" or action == "manual": + shortCommandList = "read, write, change, undo, delete, move, author, category, list, report, developer" + if specify == "read": + say("To read an entry, type \"!notes read \".", chan) + elif specify == "write": + say("To write a new entry, type \"!notes write \". This will create a new entry only if one does not exist, see the below command...", chan) + elif specify == "change": + say("To change an entry, type \"!notes change \". The old entry will be stored in the database, so it can be undone later.", chan) + elif specify == "undo": + say("To undo a change, type \"!notes undo \".", chan) + elif specify == "delete": + say("To delete an entry, type \"!notes delete \". For security reasons, only bot admins can do this.", chan) + elif specify == "move": + say("To move an entry, type \"!notes move \".", chan) + elif specify == "author": + say("To return the author of an entry, type \"!notes author \".", chan) + elif specify == "category" or specify == "cat": + say("To change an entry's category, type \"!notes category \".", chan) + elif specify == "list": + say("To list all categories in the database, type \"!notes list\". Type \"!notes list \" to get all entries in a certain category.", chan) + elif specify == "report": + say("To give some statistics about the mini-wiki, including some debugging information, type \"!notes report\" in a PM.", chan) + elif specify == "developer": + say("To do developer work, such as writing to the database directly, type \"!notes developer \". This can only be done by the bot owner.", chan) + else: + db.query("SELECT * FROM version;") + r = db.use_result() + data = r.fetch_row(0) + version = data[0] + reply("The Earwig Mini-Wiki: running v%s." % version, chan, nick) + reply("The full list of commands, for reference, are: %s." % shortCommandList, chan, nick) + reply("For an explaination of a certain command, type \"!notes help \".", chan, nick) + reply("You can also access the database from the Toolserver: http://toolserver.org/~earwig/cgi-bin/irc_database.py", chan, nick) + time.sleep(0.4) + return + elif action == "read": + specify = string.lower(specify) + if " " in specify: specify = string.split(specify, " ")[0] + if not specify or "\"" in specify: + reply("Please include the name of the entry you would like to read after the command, e.g. !notes read earwig", chan, nick) + return + try: + db.query("SELECT entry_content FROM entries WHERE entry_title = \"%s\";" % specify) + r = db.use_result() + data = r.fetch_row(0) + entry = data[0][0] + say("Entry \"\x02%s\x0F\": %s" % (specify, entry), chan) + except Exception: + reply("There is no entry titled \"\x02%s\x0F\"." % specify, chan, nick) + return + elif action == "delete" or action == "remove": + specify = string.lower(specify) + if " " in specify: specify = string.split(specify, " ")[0] + if not specify or "\"" in specify: + reply("Please include the name of the entry you would like to delete after the command, e.g. !notes delete earwig", chan, nick) + return + if authy == "owner" or authy == "admin": + try: + db.query("DELETE from entries where entry_title = \"%s\";" % specify) + r = db.use_result() + db.commit() + reply("The entry on \"\x02%s\x0F\" has been removed." % specify, chan, nick) + except Exception: + phenny.reply("Unable to remove the entry on \"\x02%s\x0F\", because it doesn't exist." % specify, chan, nick) + else: + reply("Only bot admins can remove entries.", chan, nick) + return + elif action == "developer": + if authy == "owner": + db.query(specify) + r = db.use_result() + try: + print r.fetch_row(0) + except Exception: + pass + db.commit() + reply("Done.", chan, nick) + else: + reply("Only the bot owner can modify the raw database.", chan, nick) + return + elif action == "write": + try: + write = line2[5] + content = ' '.join(line2[6:]) + except Exception: + reply("Please include some content in your entry.", chan, nick) + return + db.query("SELECT * from entries WHERE entry_title = \"%s\";" % write) + r = db.use_result() + data = r.fetch_row(0) + if data: + reply("An entry on %s already exists; please use \"!notes change %s %s\"." % (write, write, content), chan, nick) + return + content2 = content.replace('"', '\\' + '"') + db.query("INSERT INTO entries (entry_title, entry_author, entry_category, entry_content, entry_content_old) VALUES (\"%s\", \"%s\", \"uncategorized\", \"%s\", NULL);" % (write, nick, content2)) + db.commit() + reply("You have written an entry titled \"\x02%s\x0F\", with the following content: \"%s\"" % (write, content), chan, nick) + return + elif action == "change": + reply("NotImplementedError", chan, nick) + elif action == "undo": + reply("NotImplementedError", chan, nick) + elif action == "move": + reply("NotImplementedError", chan, nick) + elif action == "author": + try: + entry = line2[5] + except Exception: + reply("Please include the name of the entry you would like to get information for after the command, e.g. !notes author earwig", chan, nick) + return + db.query("SELECT entry_author from entries WHERE entry_title = \"%s\";" % entry) + r = db.use_result() + data = r.fetch_row(0) + if data: + say("The author of \"\x02%s\x0F\" is \x02%s\x0F." % (entry, data[0][0]), chan) + return + reply("There is no entry titled \"\x02%s\x0F\"." % entry, chan, nick) + return + elif action == "cat" or action == "category": + reply("NotImplementedError", chan, nick) + elif action == "list": + reply("NotImplementedError", chan, nick) + elif action == "report": + reply("NotImplementedError", chan, nick)