Conflicts: README.rst docs/customizing.rst earwigbot/commands/help.py earwigbot/commands/threads.py earwigbot/managers.py earwigbot/tasks/image_display_resize.py setup.pytags/v0.1^2
| @@ -113,9 +113,9 @@ because it is the main way to communicate with other parts of the bot. A | |||||
| `earwigbot.config.BotConfig`_ stores configuration information for the bot. Its | `earwigbot.config.BotConfig`_ stores configuration information for the bot. Its | ||||
| docstring explains what each attribute is used for, but essentially each "node" | 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: | irc: | ||||
| frontend: | frontend: | ||||
| @@ -133,7 +133,8 @@ Custom IRC commands | |||||
| ~~~~~~~~~~~~~~~~~~~ | ~~~~~~~~~~~~~~~~~~~ | ||||
| Custom commands are subclasses of `earwigbot.commands.Command`_ that override | Custom commands are subclasses of `earwigbot.commands.Command`_ that override | ||||
| ``Command``'s ``process()`` (and optionally ``check()``) methods. | |||||
| ``Command``'s ``process()`` (and optionally ``check()`` or ``setup()``) | |||||
| methods. | |||||
| The bot has a wide selection of built-in commands and plugins to act as sample | The bot has a wide selection of built-in commands and plugins to act as sample | ||||
| code and/or to give ideas. Start with test_, and then check out chanops_ and | code and/or to give ideas. Start with test_, and then check out chanops_ and | ||||
| @@ -58,8 +58,13 @@ The most useful attributes are: | |||||
| :py:class:`earwigbot.config.BotConfig` stores configuration information for the | :py:class:`earwigbot.config.BotConfig` stores configuration information for the | ||||
| bot. Its docstrings explains what each attribute is used for, but essentially | 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 | |||||
| <earwigbot.config.BotConfig.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` | of the bot's :file:`config.yml` file. For example, if :file:`config.yml` | ||||
| includes something like:: | includes something like:: | ||||
| @@ -81,7 +86,8 @@ Custom IRC commands | |||||
| Custom commands are subclasses of :py:class:`earwigbot.commands.Command` that | Custom commands are subclasses of :py:class:`earwigbot.commands.Command` that | ||||
| override :py:class:`~earwigbot.commands.Command`'s | override :py:class:`~earwigbot.commands.Command`'s | ||||
| :py:meth:`~earwigbot.commands.Command.process` (and optionally | :py:meth:`~earwigbot.commands.Command.process` (and optionally | ||||
| :py:meth:`~earwigbot.commands.Command.check`) methods. | |||||
| :py:meth:`~earwigbot.commands.Command.check` or | |||||
| :py:meth:`~earwigbot.commands.Command.setup`) methods. | |||||
| :py:class:`~earwigbot.commands.Command`'s docstrings should explain what each | :py:class:`~earwigbot.commands.Command`'s docstrings should explain what each | ||||
| attribute and method is for and what they should be overridden with, but these | attribute and method is for and what they should be overridden with, but these | ||||
| @@ -90,6 +96,15 @@ are the basics: | |||||
| - Class attribute :py:attr:`~earwigbot.commands.Command.name` is the name of | - Class attribute :py:attr:`~earwigbot.commands.Command.name` is the name of | ||||
| the command. This must be specified. | the command. This must be specified. | ||||
| - Class attribute :py:attr:`~earwigbot.commands.Command.commands` is a list of | |||||
| names that will trigger this command. It defaults to the command's | |||||
| :py:attr:`~earwigbot.commands.Command.name`, but you can override it with | |||||
| multiple names to serve as aliases. This is handled by the default | |||||
| :py:meth:`~earwigbot.commands.Command.check` implementation (see below), so | |||||
| if :py:meth:`~earwigbot.commands.Command.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.Command.hooks` is a list of the | - Class attribute :py:attr:`~earwigbot.commands.Command.hooks` is a list of the | ||||
| "IRC events" that this command might respond to. It defaults to ``["msg"]``, | "IRC events" that this command might respond to. It defaults to ``["msg"]``, | ||||
| but options include ``"msg_private"`` (for private messages only), | but options include ``"msg_private"`` (for private messages only), | ||||
| @@ -97,15 +112,25 @@ are the basics: | |||||
| joins a channel). See the afc_status_ plugin for a command that responds to | joins a channel). See the afc_status_ plugin for a command that responds to | ||||
| other hook types. | other hook types. | ||||
| - Method :py:meth:`~earwigbot.commands.Command.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.Command.__init__` does things by default and a | |||||
| dedicated setup method is often easier than overriding | |||||
| :py:meth:`~earwigbot.tasks.Command.__init__` and using :py:obj:`super`). | |||||
| - Method :py:meth:`~earwigbot.commands.Command.check` is passed a | - Method :py:meth:`~earwigbot.commands.Command.check` is passed a | ||||
| :py:class:`~earwigbot.irc.data.Data` object, and should return ``True`` if | :py:class:`~earwigbot.irc.data.Data` object, and should return ``True`` if | ||||
| you want to respond to this message, or ``False`` otherwise. The default | 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`` | behavior is to return ``True`` only if :py:attr:`data.is_command` is ``True`` | ||||
| and :py:attr:`data.command` == :py:attr:`~earwigbot.commands.Command.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. | |||||
| and :py:attr:`data.command` ``==`` | |||||
| :py:attr:`~earwigbot.commands.Command.name` (or :py:attr:`data.command | |||||
| <earwigbot.irc.data.Data.command>` is in | |||||
| :py:attr:`~earwigbot.commands.Command.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.Command.process` is passed the same | - Method :py:meth:`~earwigbot.commands.Command.process` is passed the same | ||||
| :py:class:`~earwigbot.irc.data.Data` object as | :py:class:`~earwigbot.irc.data.Data` object as | ||||
| @@ -128,6 +153,12 @@ are the basics: | |||||
| <earwigbot.irc.connection.IRCConnection.join>`, and | <earwigbot.irc.connection.IRCConnection.join>`, and | ||||
| :py:meth:`part(chan) <earwigbot.irc.connection.IRCConnection.part>`. | :py:meth:`part(chan) <earwigbot.irc.connection.IRCConnection.part>`. | ||||
| 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. | |||||
| The command *class* doesn't need a specific name, but it should logically | The command *class* doesn't need a specific name, but it should logically | ||||
| follow the command's name. The filename doesn't matter, but it is recommended | follow the command's name. The filename doesn't matter, but it is recommended | ||||
| to match the command name for readability. Multiple command classes are allowed | to match the command name for readability. Multiple command classes are allowed | ||||
| @@ -190,7 +221,7 @@ are the basics: | |||||
| Tasks have access to :py:attr:`config.tasks[task_name]` for config information, | 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 | 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 | or templates to append to user talk pages, so that these can be easily changed | ||||
| without modifying the task itself. | without modifying the task itself. | ||||
| @@ -201,8 +232,9 @@ the task name for readability. Multiple tasks classes are allowed in one file. | |||||
| See the built-in wikiproject_tagger_ task for a relatively straightforward | See the built-in wikiproject_tagger_ task for a relatively straightforward | ||||
| task, or the afc_statistics_ plugin for a more complicated one. | task, or the afc_statistics_ plugin for a more complicated one. | ||||
| .. _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 | .. _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 | .. _test: https://github.com/earwig/earwigbot/blob/develop/earwigbot/commands/test.py | ||||
| .. _chanops: https://github.com/earwig/earwigbot/blob/develop/earwigbot/commands/chanops.py | |||||
| .. _wikiproject_tagger: https://github.com/earwig/earwigbot/blob/develop/earwigbot/tasks/wikiproject_tagger.py | .. _wikiproject_tagger: https://github.com/earwig/earwigbot/blob/develop/earwigbot/tasks/wikiproject_tagger.py | ||||
| .. _afc_statistics: https://github.com/earwig/earwigbot-plugins/blob/develop/tasks/afc_statistics.py | .. _afc_statistics: https://github.com/earwig/earwigbot-plugins/blob/develop/tasks/afc_statistics.py | ||||
| @@ -80,6 +80,8 @@ following attributes: | |||||
| ``"en"`` | ``"en"`` | ||||
| - :py:attr:`~earwigbot.wiki.site.Site.domain`: the site's web domain, like | - :py:attr:`~earwigbot.wiki.site.Site.domain`: the site's web domain, like | ||||
| ``"en.wikipedia.org"`` | ``"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: | and the following methods: | ||||
| @@ -97,11 +99,11 @@ and the following methods: | |||||
| - :py:meth:`namespace_name_to_id(name) | - :py:meth:`namespace_name_to_id(name) | ||||
| <earwigbot.wiki.site.Site.namespace_name_to_id>`: given a namespace name, | <earwigbot.wiki.site.Site.namespace_name_to_id>`: given a namespace name, | ||||
| returns the associated namespace ID | returns the associated namespace ID | ||||
| - :py:meth:`get_page(title, follow_redirects=False) | |||||
| - :py:meth:`get_page(title, follow_redirects=False, ...) | |||||
| <earwigbot.wiki.site.Site.get_page>`: returns a ``Page`` object for the given | <earwigbot.wiki.site.Site.get_page>`: returns a ``Page`` object for the given | ||||
| title (or a :py:class:`~earwigbot.wiki.category.Category` object if the | title (or a :py:class:`~earwigbot.wiki.category.Category` object if the | ||||
| page's namespace is "``Category:``") | page's namespace is "``Category:``") | ||||
| - :py:meth:`get_category(catname, follow_redirects=False) | |||||
| - :py:meth:`get_category(catname, follow_redirects=False, ...) | |||||
| <earwigbot.wiki.site.Site.get_category>`: returns a ``Category`` object for | <earwigbot.wiki.site.Site.get_category>`: returns a ``Category`` object for | ||||
| the given title (sans namespace) | the given title (sans namespace) | ||||
| - :py:meth:`get_user(username) <earwigbot.wiki.site.Site.get_user>`: returns a | - :py:meth:`get_user(username) <earwigbot.wiki.site.Site.get_user>`: returns a | ||||
| @@ -120,7 +122,7 @@ provide the following attributes: | |||||
| - :py:attr:`~earwigbot.wiki.page.Page.site`: the page's corresponding | - :py:attr:`~earwigbot.wiki.page.Page.site`: the page's corresponding | ||||
| :py:class:`~earwigbot.wiki.site.Site` object | :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.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 | - :py:attr:`~earwigbot.wiki.page.Page.pageid`: an integer ID representing the | ||||
| page | page | ||||
| - :py:attr:`~earwigbot.wiki.page.Page.url`: the page's URL | - :py:attr:`~earwigbot.wiki.page.Page.url`: the page's URL | ||||
| @@ -166,9 +168,10 @@ or :py:meth:`site.get_page(title) <earwigbot.wiki.site.Site.get_page>` where | |||||
| ``title`` is in the ``Category:`` namespace) provide the following additional | ``title`` is in the ``Category:`` namespace) provide the following additional | ||||
| method: | method: | ||||
| - :py:meth:`get_members(use_sql=False, limit=None) | |||||
| <earwigbot.wiki.category.Category.get_members>`: 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, ...) | |||||
| <earwigbot.wiki.category.Category.get_members>`: 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 | Users | ||||
| ~~~~~ | ~~~~~ | ||||
| @@ -178,6 +181,8 @@ Create :py:class:`earwigbot.wiki.User <earwigbot.wiki.user.User>` objects with | |||||
| :py:meth:`page.get_creator() <earwigbot.wiki.page.Page.get_creator>`. They | :py:meth:`page.get_creator() <earwigbot.wiki.page.Page.get_creator>`. They | ||||
| provide the following attributes: | 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.name`: the user's username | ||||
| - :py:attr:`~earwigbot.wiki.user.User.exists`: ``True`` if the user exists, or | - :py:attr:`~earwigbot.wiki.user.User.exists`: ``True`` if the user exists, or | ||||
| ``False`` if they do not | ``False`` if they do not | ||||
| @@ -36,9 +36,13 @@ class Command(object): | |||||
| This docstring is reported to the user when they type ``"!help | This docstring is reported to the user when they type ``"!help | ||||
| <command>"``. | <command>"``. | ||||
| """ | """ | ||||
| # 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 | 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 | # 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 | # default behavior; if you wish to override that, change the value in your | ||||
| # command subclass: | # command subclass: | ||||
| @@ -49,9 +53,10 @@ class Command(object): | |||||
| This is called once when the command is loaded (from | This is called once when the command is loaded (from | ||||
| :py:meth:`commands.load() <earwigbot.managers._ResourceManager.load>`). | :py:meth:`commands.load() <earwigbot.managers._ResourceManager.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.bot = bot | ||||
| self.config = bot.config | self.config = bot.config | ||||
| @@ -67,6 +72,15 @@ class Command(object): | |||||
| self.mode = lambda t, level, msg: self.bot.frontend.mode(t, level, msg) | self.mode = lambda t, level, msg: self.bot.frontend.mode(t, level, msg) | ||||
| self.pong = lambda target: self.bot.frontend.pong(target) | 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): | def check(self, data): | ||||
| """Return whether this command should be called in response to *data*. | """Return whether this command should be called in response to *data*. | ||||
| @@ -76,11 +90,15 @@ class Command(object): | |||||
| sent on IRC, it should be cheap to execute and unlikely to throw | sent on IRC, it should be cheap to execute and unlikely to throw | ||||
| exceptions. | exceptions. | ||||
| Most commands return ``True`` if :py:attr:`data.command | |||||
| Most commands return ``True`` only if :py:attr:`data.command | |||||
| <earwigbot.irc.data.Data.command>` ``==`` :py:attr:`self.name <name>`, | <earwigbot.irc.data.Data.command>` ``==`` :py:attr:`self.name <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 <earwigbot.irc.data.Data.command>` is in | |||||
| :py:attr:`self.commands <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 | return data.is_command and data.command == self.name | ||||
| def process(self, data): | def process(self, data): | ||||
| @@ -1,401 +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 == "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 == "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(' '): | |||||
| thing = thing[:-6] | |||||
| return thing.strip(' :.') | |||||
| r_li = re.compile(r'(?ims)<li>.*?</li>') | |||||
| 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"><br /><br />(.*?) )|(?:<b>(.*?)</b>)') | |||||
| 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)<dd[^>]*>.*?</dd>') | |||||
| 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)(?:\.(?= [A-Z0-9]|\Z)|\Z)' | |||||
| r_sentence = re.compile(t_sentence % ')(?<!'.join(abbrs)) | |||||
| def unescape(s): | |||||
| s = s.replace('>', '>') | |||||
| 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 == "pend" or command == "pending": | |||||
| say("Pending submissions status page: <http://en.wikipedia.org/wiki/WP:AFC/S>.", chan) | |||||
| say("Pending submissions category: <http://en.wikipedia.org/wiki/Category:Pending_AfC_submissions>.", chan) | |||||
| 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, ">, <http://en.wikipedia.org/wiki/") | |||||
| s = "<http://en.wikipedia.org/wiki/%s>" % s | |||||
| s = re.sub(" ", "_", s) | |||||
| s = re.sub(">,_<", ">, <", s) | |||||
| report = "\x02First %s pending AfC submissions:\x0F %s" % (number, s) | |||||
| say(report, chan) | |||||
| 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 == "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] | |||||
| 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 <entry>\".", chan) | |||||
| elif specify == "write": | |||||
| say("To write a new entry, type \"!notes write <entry> <content>\". 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 <entry> <new content>\". 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 <entry>\".", chan) | |||||
| elif specify == "delete": | |||||
| say("To delete an entry, type \"!notes delete <entry>\". For security reasons, only bot admins can do this.", chan) | |||||
| elif specify == "move": | |||||
| say("To move an entry, type \"!notes move <old_title> <new_title>\".", chan) | |||||
| elif specify == "author": | |||||
| say("To return the author of an entry, type \"!notes author <entry>\".", chan) | |||||
| elif specify == "category" or specify == "cat": | |||||
| say("To change an entry's category, type \"!notes category <entry> <category>\".", chan) | |||||
| elif specify == "list": | |||||
| say("To list all categories in the database, type \"!notes list\". Type \"!notes list <category>\" 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 <command>\". 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 <command>\".", 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) | |||||
| 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] | |||||
| 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 | |||||
| @@ -0,0 +1,34 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # | |||||
| # Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
| # | |||||
| # 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): | |||||
| """Link the user to the pending AFC submissions page and category.""" | |||||
| name = "pending" | |||||
| commands = ["pending", "pend"] | |||||
| 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) | |||||
| @@ -72,7 +72,7 @@ class AFCReport(Command): | |||||
| def get_page(self, title): | def get_page(self, title): | ||||
| page = self.site.get_page(title, follow_redirects=False) | page = self.site.get_page(title, follow_redirects=False) | ||||
| if page.exists[0]: | |||||
| if page.exists == page.PAGE_EXISTS: | |||||
| return page | return page | ||||
| def report(self, page): | def report(self, page): | ||||
| @@ -30,13 +30,12 @@ class AFCStatus(Command): | |||||
| """Get the number of pending AfC submissions, open redirect requests, and | """Get the number of pending AfC submissions, open redirect requests, and | ||||
| open file upload requests.""" | open file upload requests.""" | ||||
| name = "status" | name = "status" | ||||
| commands = ["status", "count", "num", "number"] | |||||
| hooks = ["join", "msg"] | hooks = ["join", "msg"] | ||||
| def check(self, data): | 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 | return True | ||||
| try: | try: | ||||
| if data.line[1] == "JOIN" and data.chan == "#wikipedia-en-afc": | if data.line[1] == "JOIN" and data.chan == "#wikipedia-en-afc": | ||||
| if data.nick != self.config.irc["frontend"]["nick"]: | if data.nick != self.config.irc["frontend"]["nick"]: | ||||
| @@ -0,0 +1,60 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # | |||||
| # Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
| # | |||||
| # 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") | |||||
| 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)) | |||||
| @@ -28,10 +28,7 @@ class ChanOps(Command): | |||||
| """Voice, devoice, op, or deop users in the channel, or join or part from | """Voice, devoice, op, or deop users in the channel, or join or part from | ||||
| other channels.""" | other channels.""" | ||||
| name = "chanops" | 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): | def process(self, data): | ||||
| if data.command == "chanops": | if data.command == "chanops": | ||||
| @@ -32,12 +32,7 @@ class Crypt(Command): | |||||
| """Provides hash functions with !hash (!hash list for supported algorithms) | """Provides hash functions with !hash (!hash list for supported algorithms) | ||||
| and blowfish encryption with !encrypt and !decrypt.""" | and blowfish encryption with !encrypt and !decrypt.""" | ||||
| name = "crypt" | name = "crypt" | ||||
| def check(self, data): | |||||
| commands = ["crypt", "hash", "encrypt", "decrypt"] | |||||
| if data.is_command and data.command in commands: | |||||
| return True | |||||
| return False | |||||
| commands = ["crypt", "hash", "encrypt", "decrypt"] | |||||
| def process(self, data): | def process(self, data): | ||||
| if data.command == "crypt": | if data.command == "crypt": | ||||
| @@ -30,12 +30,7 @@ __all__ = ["Editcount"] | |||||
| class Editcount(Command): | class Editcount(Command): | ||||
| """Return a user's edit count.""" | """Return a user's edit count.""" | ||||
| name = "editcount" | name = "editcount" | ||||
| def check(self, data): | |||||
| commands = ["ec", "editcount"] | |||||
| if data.is_command and data.command in commands: | |||||
| return True | |||||
| return False | |||||
| commands = ["ec", "editcount"] | |||||
| def process(self, data): | def process(self, data): | ||||
| if not data.args: | if not data.args: | ||||
| @@ -54,6 +49,7 @@ class Editcount(Command): | |||||
| return | return | ||||
| safe = quote_plus(user.name) | 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})." | 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)) | |||||
| @@ -0,0 +1,72 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # | |||||
| # Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
| # | |||||
| # 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" | |||||
| commands = ["geolocate", "locate", "geo", "ip"] | |||||
| 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 process(self, data): | |||||
| if not data.args: | |||||
| self.reply(data, "please specify an IP to lookup.") | |||||
| return | |||||
| 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(self.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) | |||||
| @@ -32,10 +32,12 @@ class Git(Command): | |||||
| """Commands to interface with the bot's git repository; use '!git' for a | """Commands to interface with the bot's git repository; use '!git' for a | ||||
| sub-command list.""" | sub-command list.""" | ||||
| name = "git" | 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): | def process(self, data): | ||||
| self.data = data | self.data = data | ||||
| @@ -46,6 +48,9 @@ class Git(Command): | |||||
| if not data.args or data.args[0] == "help": | if not data.args or data.args[0] == "help": | ||||
| self.do_help() | self.do_help() | ||||
| return | return | ||||
| if not self.repos: | |||||
| self.reply(data, "no repos are specified in the config file.") | |||||
| return | |||||
| command = data.args[0] | command = data.args[0] | ||||
| try: | try: | ||||
| @@ -57,7 +62,7 @@ class Git(Command): | |||||
| return | return | ||||
| if repo_name not in self.repos: | if repo_name not in self.repos: | ||||
| repos = self.get_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)) | self.reply(data, msg.format(repos)) | ||||
| return | return | ||||
| self.repo = git.Repo(self.repos[repo_name]) | self.repo = git.Repo(self.repos[repo_name]) | ||||
| @@ -91,7 +96,7 @@ class Git(Command): | |||||
| try: | try: | ||||
| return getattr(self.repo.remotes, remote_name) | return getattr(self.repo.remotes, remote_name) | ||||
| except AttributeError: | 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) | self.reply(self.data, msg) | ||||
| def get_time_since(self, date): | def get_time_since(self, date): | ||||
| @@ -23,7 +23,6 @@ | |||||
| import re | import re | ||||
| from earwigbot.commands import Command | from earwigbot.commands import Command | ||||
| from earwigbot.irc import Data | |||||
| __all__ = ["Help"] | __all__ = ["Help"] | ||||
| @@ -50,34 +49,24 @@ class Help(Command): | |||||
| def do_main_help(self, data): | def do_main_help(self, data): | ||||
| """Give the user a general help message with a list of all commands.""" | """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 <command>'." | msg = "Hi, I'm a bot! I have {0} commands loaded: {1}. You can get help for any command with '!help <command>'." | ||||
| cmnds = sorted(self.bot.commands) | |||||
| cmnds = sorted([cmnd.name for cmnd in self.bot.commands]) | |||||
| msg = msg.format(len(cmnds), ', '.join(cmnds)) | msg = msg.format(len(cmnds), ', '.join(cmnds)) | ||||
| self.reply(data, msg) | self.reply(data, msg) | ||||
| def do_command_help(self, data): | def do_command_help(self, data): | ||||
| """Give the user help for a specific command.""" | """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) | self.reply(data, msg) | ||||
| def do_hello(self, data): | def do_hello(self, data): | ||||
| @@ -0,0 +1,49 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # | |||||
| # Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
| # | |||||
| # 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" | |||||
| commands = ["langcode", "lang", "language"] | |||||
| 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}\x0301 is {1} ({2})".format(code, name, sites) | |||||
| self.reply(data, msg) | |||||
| return | |||||
| self.reply(data, "site \x0302{0}\x0301 not found.".format(code)) | |||||
| @@ -31,14 +31,6 @@ class Link(Command): | |||||
| """Convert a Wikipedia page name into a URL.""" | """Convert a Wikipedia page name into a URL.""" | ||||
| name = "link" | 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): | def process(self, data): | ||||
| msg = data.msg | msg = data.msg | ||||
| @@ -0,0 +1,169 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # | |||||
| # Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
| # | |||||
| # 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 <entry>\".", chan) | |||||
| elif specify == "write": | |||||
| say("To write a new entry, type \"!notes write <entry> <content>\". 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 <entry> <new content>\". 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 <entry>\".", chan) | |||||
| elif specify == "delete": | |||||
| say("To delete an entry, type \"!notes delete <entry>\". For security reasons, only bot admins can do this.", chan) | |||||
| elif specify == "move": | |||||
| say("To move an entry, type \"!notes move <old_title> <new_title>\".", chan) | |||||
| elif specify == "author": | |||||
| say("To return the author of an entry, type \"!notes author <entry>\".", chan) | |||||
| elif specify == "category" or specify == "cat": | |||||
| say("To change an entry's category, type \"!notes category <entry> <category>\".", chan) | |||||
| elif specify == "list": | |||||
| say("To list all categories in the database, type \"!notes list\". Type \"!notes list <category>\" 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 <command>\". 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 <command>\".", 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) | |||||
| @@ -28,24 +28,23 @@ class Praise(Command): | |||||
| """Praise people!""" | """Praise people!""" | ||||
| name = "praise" | name = "praise" | ||||
| def setup(self): | |||||
| try: | |||||
| self.praises = self.config.commands[self.name]["praises"] | |||||
| except KeyError: | |||||
| self.praises = [] | |||||
| def check(self, data): | def check(self, data): | ||||
| commands = ["praise", "earwig", "leonard", "leonard^bloom", "groove", | |||||
| "groovedog"] | |||||
| return data.is_command and data.command in commands | |||||
| check = data.command == "praise" or data.command in self.praises | |||||
| return data.is_command and check | |||||
| def process(self, data): | 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 | 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) | |||||
| @@ -28,10 +28,7 @@ class Quit(Command): | |||||
| """Quit, restart, or reload components from the bot. Only the owners can | """Quit, restart, or reload components from the bot. Only the owners can | ||||
| run this command.""" | run this command.""" | ||||
| name = "quit" | 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): | def process(self, data): | ||||
| if data.host not in self.config.irc["permissions"]["owners"]: | if data.host not in self.config.irc["permissions"]["owners"]: | ||||
| @@ -30,12 +30,7 @@ __all__ = ["Registration"] | |||||
| class Registration(Command): | class Registration(Command): | ||||
| """Return when a user registered.""" | """Return when a user registered.""" | ||||
| name = "registration" | name = "registration" | ||||
| def check(self, data): | |||||
| commands = ["registration", "reg", "age"] | |||||
| if data.is_command and data.command in commands: | |||||
| return True | |||||
| return False | |||||
| commands = ["registration", "reg", "age"] | |||||
| def process(self, data): | def process(self, data): | ||||
| if not data.args: | if not data.args: | ||||
| @@ -61,7 +56,7 @@ class Registration(Command): | |||||
| elif user.gender == "female": | elif user.gender == "female": | ||||
| gender = "She's" | gender = "She's" | ||||
| else: | else: | ||||
| gender = "They're" | |||||
| gender = "They're" # Singluar they? | |||||
| msg = "\x0302{0}\x0301 registered on {1}. {2} {3} old." | msg = "\x0302{0}\x0301 registered on {1}. {2} {3} old." | ||||
| self.reply(data, msg.format(name, date, gender, age)) | self.reply(data, msg.format(name, date, gender, age)) | ||||
| @@ -20,7 +20,7 @@ | |||||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||
| # SOFTWARE. | # SOFTWARE. | ||||
| import threading | |||||
| from threading import Timer | |||||
| import time | import time | ||||
| from earwigbot.commands import Command | from earwigbot.commands import Command | ||||
| @@ -30,11 +30,7 @@ __all__ = ["Remind"] | |||||
| class Remind(Command): | class Remind(Command): | ||||
| """Set a message to be repeated to you in a certain amount of time.""" | """Set a message to be repeated to you in a certain amount of time.""" | ||||
| name = "remind" | name = "remind" | ||||
| def check(self, data): | |||||
| if data.is_command and data.command in ["remind", "reminder"]: | |||||
| return True | |||||
| return False | |||||
| commands = ["remind", "reminder"] | |||||
| def process(self, data): | def process(self, data): | ||||
| if not data.args: | if not data.args: | ||||
| @@ -62,12 +58,7 @@ class Remind(Command): | |||||
| msg = msg.format(message, wait, end_time_with_timezone) | msg = msg.format(message, wait, end_time_with_timezone) | ||||
| self.reply(data, msg) | 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.name = "reminder " + end_time | ||||
| t_reminder.daemon = True | t_reminder.daemon = True | ||||
| t_reminder.start() | t_reminder.start() | ||||
| def reminder(self, data, message, wait): | |||||
| time.sleep(wait) | |||||
| self.reply(data, message) | |||||
| @@ -32,10 +32,16 @@ class Replag(Command): | |||||
| """Return the replag for a specific database on the Toolserver.""" | """Return the replag for a specific database on the Toolserver.""" | ||||
| name = "replag" | name = "replag" | ||||
| def setup(self): | |||||
| try: | |||||
| self.default = self.config.commands[self.name]["default"] | |||||
| except KeyError: | |||||
| self.default = None | |||||
| def process(self, data): | def process(self, data): | ||||
| args = {} | args = {} | ||||
| if not data.args: | if not data.args: | ||||
| args["db"] = "enwiki_p" | |||||
| args["db"] = self.default or self.bot.wiki.get_site().name + "_p" | |||||
| else: | else: | ||||
| args["db"] = data.args[0] | args["db"] = data.args[0] | ||||
| args["host"] = args["db"].replace("_", "-") + ".rrdb.toolserver.org" | args["host"] = args["db"].replace("_", "-") + ".rrdb.toolserver.org" | ||||
| @@ -43,10 +49,11 @@ class Replag(Command): | |||||
| conn = oursql.connect(**args) | conn = oursql.connect(**args) | ||||
| with conn.cursor() as cursor: | 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) | cursor.execute(query) | ||||
| replag = int(cursor.fetchall()[0][0]) | replag = int(cursor.fetchall()[0][0]) | ||||
| conn.close() | 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)) | self.reply(data, msg.format(args["db"], replag)) | ||||
| @@ -28,12 +28,7 @@ __all__ = ["Rights"] | |||||
| class Rights(Command): | class Rights(Command): | ||||
| """Retrieve a list of rights for a given username.""" | """Retrieve a list of rights for a given username.""" | ||||
| name = "rights" | name = "rights" | ||||
| def check(self, data): | |||||
| commands = ["rights", "groups", "permissions", "privileges"] | |||||
| if data.is_command and data.command in commands: | |||||
| return True | |||||
| return False | |||||
| commands = ["rights", "groups", "permissions", "privileges"] | |||||
| def process(self, data): | def process(self, data): | ||||
| if not data.args: | if not data.args: | ||||
| @@ -30,12 +30,7 @@ __all__ = ["Threads"] | |||||
| class Threads(Command): | class Threads(Command): | ||||
| """Manage wiki tasks from IRC, and check on thread status.""" | """Manage wiki tasks from IRC, and check on thread status.""" | ||||
| name = "threads" | name = "threads" | ||||
| def check(self, data): | |||||
| commands = ["tasks", "task", "threads", "tasklist"] | |||||
| if data.is_command and data.command in commands: | |||||
| return True | |||||
| return False | |||||
| commands = ["tasks", "task", "threads", "tasklist"] | |||||
| def process(self, data): | def process(self, data): | ||||
| self.data = data | self.data = data | ||||
| @@ -106,7 +101,7 @@ class Threads(Command): | |||||
| whether they are currently running or idle.""" | whether they are currently running or idle.""" | ||||
| threads = threading.enumerate() | threads = threading.enumerate() | ||||
| tasklist = [] | 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)] | threadlist = [t for t in threads if t.name.startswith(task)] | ||||
| ids = [str(t.ident) for t in threadlist] | ids = [str(t.ident) for t in threadlist] | ||||
| if not ids: | if not ids: | ||||
| @@ -134,7 +129,7 @@ class Threads(Command): | |||||
| self.reply(data, "what task do you want me to start?") | self.reply(data, "what task do you want me to start?") | ||||
| return | 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: | # 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." | msg = "task could not be found; either it doesn't exist, or it wasn't loaded correctly." | ||||
| self.reply(data, msg.format(task_name)) | self.reply(data, msg.format(task_name)) | ||||
| @@ -0,0 +1,68 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # | |||||
| # Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
| # | |||||
| # 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 | |||||
| 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"] | |||||
| 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): | |||||
| if not pytz: | |||||
| msg = "this command requires the 'pytz' module: http://pytz.sourceforge.net/" | |||||
| self.reply(data, msg) | |||||
| return | |||||
| try: | |||||
| tzinfo = pytz.timezone(timezone) | |||||
| except pytz.exceptions.UnknownTimeZoneError: | |||||
| self.reply(data, "unknown timezone: {0}.".format(timezone)) | |||||
| return | |||||
| now = pytz.utc.localize(datetime.utcnow()).astimezone(tzinfo) | |||||
| self.reply(data, now.strftime("%Y-%m-%d %H:%M:%S %Z")) | |||||
| @@ -0,0 +1,48 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # | |||||
| # Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||||
| # | |||||
| # 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)) | |||||
| @@ -48,8 +48,9 @@ class BotConfig(object): | |||||
| - :py:attr:`path`: path to the bot's config file | - :py:attr:`path`: path to the bot's config file | ||||
| - :py:attr:`components`: enabled components | - :py:attr:`components`: enabled components | ||||
| - :py:attr:`wiki`: information about wiki-editing | - :py:attr:`wiki`: information about wiki-editing | ||||
| - :py:attr:`tasks`: information for bot tasks | |||||
| - :py:attr:`irc`: information about IRC | - :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:attr:`metadata`: miscellaneous information | ||||
| - :py:meth:`schedule`: tasks scheduled to run at a given time | - :py:meth:`schedule`: tasks scheduled to run at a given time | ||||
| @@ -69,12 +70,13 @@ class BotConfig(object): | |||||
| self._components = _ConfigNode() | self._components = _ConfigNode() | ||||
| self._wiki = _ConfigNode() | self._wiki = _ConfigNode() | ||||
| self._tasks = _ConfigNode() | |||||
| self._irc = _ConfigNode() | self._irc = _ConfigNode() | ||||
| self._commands = _ConfigNode() | |||||
| self._tasks = _ConfigNode() | |||||
| self._metadata = _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._decryptable_nodes = [ # Default nodes to decrypt | ||||
| (self._wiki, ("password",)), | (self._wiki, ("password",)), | ||||
| @@ -196,16 +198,21 @@ class BotConfig(object): | |||||
| return self._wiki | return self._wiki | ||||
| @property | @property | ||||
| def tasks(self): | |||||
| """A dict of information for bot tasks.""" | |||||
| return self._tasks | |||||
| @property | |||||
| def irc(self): | def irc(self): | ||||
| """A dict of information about IRC.""" | """A dict of information about IRC.""" | ||||
| return self._irc | return self._irc | ||||
| @property | @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): | def metadata(self): | ||||
| """A dict of miscellaneous information.""" | """A dict of miscellaneous information.""" | ||||
| return self._metadata | return self._metadata | ||||
| @@ -225,14 +232,14 @@ class BotConfig(object): | |||||
| user. If there is no config file at all, offer to make one, otherwise | user. If there is no config file at all, offer to make one, otherwise | ||||
| exit. | 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: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): | if not path.exists(self._config_path): | ||||
| print "Config file not found:", self._config_path | print "Config file not found:", self._config_path | ||||
| @@ -246,8 +253,9 @@ class BotConfig(object): | |||||
| data = self._data | data = self._data | ||||
| self.components._load(data.get("components", {})) | self.components._load(data.get("components", {})) | ||||
| self.wiki._load(data.get("wiki", {})) | self.wiki._load(data.get("wiki", {})) | ||||
| self.tasks._load(data.get("tasks", {})) | |||||
| self.irc._load(data.get("irc", {})) | 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.metadata._load(data.get("metadata", {})) | ||||
| self._setup_logging() | self._setup_logging() | ||||
| @@ -273,7 +281,10 @@ class BotConfig(object): | |||||
| >>> config.decrypt(config.irc, "frontend", "nickservPassword") | >>> config.decrypt(config.irc, "frontend", "nickservPassword") | ||||
| # decrypts 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(): | if self.is_encrypted(): | ||||
| self._decrypt(node, nodes) | self._decrypt(node, nodes) | ||||
| @@ -24,7 +24,7 @@ | |||||
| import imp | import imp | ||||
| from os import listdir, path | from os import listdir, path | ||||
| from re import sub | from re import sub | ||||
| from threading import Lock, Thread | |||||
| from threading import RLock, Thread | |||||
| from time import gmtime, strftime | from time import gmtime, strftime | ||||
| from earwigbot.commands import Command | from earwigbot.commands import Command | ||||
| @@ -46,11 +46,7 @@ class _ResourceManager(object): | |||||
| This class handles the low-level tasks of (re)loading resources via | This class handles the low-level tasks of (re)loading resources via | ||||
| :py:meth:`load`, retrieving specific resources via :py:meth:`get`, and | :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 <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, base): | def __init__(self, bot, name, base): | ||||
| self.bot = bot | self.bot = bot | ||||
| @@ -62,8 +58,9 @@ class _ResourceManager(object): | |||||
| self._resource_access_lock = Lock() | self._resource_access_lock = Lock() | ||||
| def __iter__(self): | 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, klass): | def _load_resource(self, name, path, klass): | ||||
| """Instantiate a resource class and add it to the dictionary.""" | """Instantiate a resource class and add it to the dictionary.""" | ||||
| @@ -140,7 +137,8 @@ class _ResourceManager(object): | |||||
| Will raise :py:exc:`KeyError` if the resource (a command or task) is | Will raise :py:exc:`KeyError` if the resource (a command or task) is | ||||
| not found. | not found. | ||||
| """ | """ | ||||
| return self._resources[key] | |||||
| with self.lock: | |||||
| return self._resources[key] | |||||
| class CommandManager(_ResourceManager): | class CommandManager(_ResourceManager): | ||||
| @@ -168,13 +166,10 @@ class CommandManager(_ResourceManager): | |||||
| def call(self, hook, data): | def call(self, hook, data): | ||||
| """Respond to a hook type and a :py:class:`Data` object.""" | """Respond to a hook type and a :py:class:`Data` object.""" | ||||
| self.lock.acquire() | |||||
| for command in self._resources.itervalues(): | |||||
| for command in self: | |||||
| if hook in command.hooks and self._wrap_check(command, data): | if hook in command.hooks and self._wrap_check(command, data): | ||||
| self.lock.release() | |||||
| self._wrap_process(command, data) | self._wrap_process(command, data) | ||||
| return | return | ||||
| self.lock.release() | |||||
| class TaskManager(_ResourceManager): | class TaskManager(_ResourceManager): | ||||
| @@ -27,7 +27,7 @@ __all__ = ["BLPTag"] | |||||
| class BLPTag(Task): | class BLPTag(Task): | ||||
| """A task to add |blp=yes to ``{{WPB}}`` or ``{{WPBS}}`` when it is used | """A task to add |blp=yes to ``{{WPB}}`` or ``{{WPBS}}`` when it is used | ||||
| along with ``{{WP Biography}}``.""" | along with ``{{WP Biography}}``.""" | ||||
| name = "blptag" | |||||
| name = "blp_tag" | |||||
| def setup(self): | def setup(self): | ||||
| pass | pass | ||||
| @@ -22,11 +22,11 @@ | |||||
| from earwigbot.tasks import Task | from earwigbot.tasks import Task | ||||
| __all__ = ["FeedDailyCats"] | |||||
| __all__ = ["ImageDisplayResize"] | |||||
| class FeedDailyCats(Task): | |||||
| """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): | def setup(self): | ||||
| pass | pass | ||||
| @@ -27,7 +27,7 @@ __all__ = ["WrongMIME"] | |||||
| class WrongMIME(Task): | class WrongMIME(Task): | ||||
| """A task to tag files whose extensions do not agree with their MIME | """A task to tag files whose extensions do not agree with their MIME | ||||
| type.""" | type.""" | ||||
| name = "wrongmime" | |||||
| name = "wrong_mime" | |||||
| def setup(self): | def setup(self): | ||||
| pass | pass | ||||
| @@ -39,7 +39,7 @@ class Category(Page): | |||||
| *Public methods:* | *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): | def __repr__(self): | ||||
| @@ -51,8 +51,8 @@ class Category(Page): | |||||
| """Return a nice string representation of the Category.""" | """Return a nice string representation of the Category.""" | ||||
| return '<Category "{0}" of {1}>'.format(self.title, str(self._site)) | return '<Category "{0}" of {1}>'.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 | query = """SELECT page_title, page_namespace, page_id FROM page | ||||
| JOIN categorylinks ON page_id = cl_from | JOIN categorylinks ON page_id = cl_from | ||||
| WHERE cl_to = ?""" | WHERE cl_to = ?""" | ||||
| @@ -64,42 +64,66 @@ class Category(Page): | |||||
| else: | else: | ||||
| result = self._site.sql_query(query, (title,)) | 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") | base = row[0].replace("_", " ").decode("utf8") | ||||
| namespace = self._site.namespace_id_to_name(row[1]) | namespace = self._site.namespace_id_to_name(row[1]) | ||||
| if namespace: | if namespace: | ||||
| title = u":".join((namespace, base)) | title = u":".join((namespace, base)) | ||||
| else: # Avoid doing a silly (albeit valid) ":Pagename" thing | else: # Avoid doing a silly (albeit valid) ":Pagename" thing | ||||
| title = base | 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", | 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. | 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() | |||||
| <earwigbot.wiki.site.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: | if use_sql: | ||||
| return self._get_members_via_sql(limit) | |||||
| return self._get_members_via_sql(limit, follow_redirects) | |||||
| else: | else: | ||||
| return self._get_members_via_api(limit) | |||||
| return self._get_members_via_api(limit, follow_redirects) | |||||
| @@ -48,7 +48,7 @@ class Page(CopyrightMixIn): | |||||
| - :py:attr:`site`: the page's corresponding Site object | - :py:attr:`site`: the page's corresponding Site object | ||||
| - :py:attr:`title`: the page's title, or pagename | - :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:`pageid`: an integer ID representing the page | ||||
| - :py:attr:`url`: the page's URL | - :py:attr:`url`: the page's URL | ||||
| - :py:attr:`namespace`: the page's namespace as an integer | - :py:attr:`namespace`: the page's namespace as an integer | ||||
| @@ -75,17 +75,20 @@ class Page(CopyrightMixIn): | |||||
| checks the page like :py:meth:`copyvio_check`, but against a specific URL | checks the page like :py:meth:`copyvio_check`, but against a specific 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. | """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 | __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. | logic to determine our namespace ID and if we are a talkpage. | ||||
| @@ -94,9 +97,9 @@ class Page(CopyrightMixIn): | |||||
| self._site = site | self._site = site | ||||
| self._title = title.strip() | self._title = title.strip() | ||||
| self._follow_redirects = self._keep_following = follow_redirects | 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._is_redirect = None | ||||
| self._lastrevid = None | self._lastrevid = None | ||||
| self._protection = None | self._protection = None | ||||
| @@ -145,7 +148,7 @@ class Page(CopyrightMixIn): | |||||
| Note that validity != existence. If a page's title is invalid (e.g, it | Note that validity != existence. If a page's title is invalid (e.g, it | ||||
| contains "[") it will always be invalid, and cannot be edited. | 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) | e = "Page '{0}' is invalid.".format(self._title) | ||||
| raise exceptions.InvalidPageError(e) | raise exceptions.InvalidPageError(e) | ||||
| @@ -157,7 +160,7 @@ class Page(CopyrightMixIn): | |||||
| It will also call _assert_validity() beforehand. | It will also call _assert_validity() beforehand. | ||||
| """ | """ | ||||
| self._assert_validity() | self._assert_validity() | ||||
| if self._exists == 2: | |||||
| if self._exists == self.PAGE_MISSING: | |||||
| e = "Page '{0}' does not exist.".format(self._title) | e = "Page '{0}' does not exist.".format(self._title) | ||||
| raise exceptions.PageNotFoundError(e) | raise exceptions.PageNotFoundError(e) | ||||
| @@ -218,14 +221,14 @@ class Page(CopyrightMixIn): | |||||
| if "missing" in res: | if "missing" in res: | ||||
| # If it has a negative ID and it's missing; we can still get | # If it has a negative ID and it's missing; we can still get | ||||
| # data like the namespace, protection, and URL: | # data like the namespace, protection, and URL: | ||||
| self._exists = 2 | |||||
| self._exists = self.PAGE_MISSING | |||||
| else: | else: | ||||
| # If it has a negative ID and it's invalid, then break here, | # If it has a negative ID and it's invalid, then break here, | ||||
| # because there's no other data for us to get: | # because there's no other data for us to get: | ||||
| self._exists = 1 | |||||
| self._exists = self.PAGE_INVALID | |||||
| return | return | ||||
| else: | else: | ||||
| self._exists = 3 | |||||
| self._exists = self.PAGE_EXISTS | |||||
| self._fullurl = res["fullurl"] | self._fullurl = res["fullurl"] | ||||
| self._protection = res["protection"] | self._protection = res["protection"] | ||||
| @@ -317,7 +320,7 @@ class Page(CopyrightMixIn): | |||||
| if result["edit"]["result"] == "Success": | if result["edit"]["result"] == "Success": | ||||
| self._content = None | self._content = None | ||||
| self._basetimestamp = None | self._basetimestamp = None | ||||
| self._exists = 0 | |||||
| self._exists = self.PAGE_UNKNOWN | |||||
| return | return | ||||
| # If we're here, then the edit failed. If it's because of AssertEdit, | # If we're here, then the edit failed. If it's because of AssertEdit, | ||||
| @@ -351,7 +354,7 @@ class Page(CopyrightMixIn): | |||||
| params["starttimestamp"] = self._starttimestamp | params["starttimestamp"] = self._starttimestamp | ||||
| if self._basetimestamp: | if self._basetimestamp: | ||||
| params["basetimestamp"] = 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: | # Page does not exist; don't edit if it already exists: | ||||
| params["createonly"] = "true" | params["createonly"] = "true" | ||||
| else: | else: | ||||
| @@ -389,7 +392,7 @@ class Page(CopyrightMixIn): | |||||
| # These attributes are now invalidated: | # These attributes are now invalidated: | ||||
| self._content = None | self._content = None | ||||
| self._basetimestamp = None | self._basetimestamp = None | ||||
| self._exists = 0 | |||||
| self._exists = self.PAGE_UNKNOWN | |||||
| raise exceptions.EditConflictError(error.info) | raise exceptions.EditConflictError(error.info) | ||||
| elif error.code in ["emptypage", "emptynewsection"]: | elif error.code in ["emptypage", "emptynewsection"]: | ||||
| @@ -437,12 +440,12 @@ class Page(CopyrightMixIn): | |||||
| @property | @property | ||||
| def site(self): | def site(self): | ||||
| """The Page's corresponding Site object.""" | |||||
| """The page's corresponding Site object.""" | |||||
| return self._site | return self._site | ||||
| @property | @property | ||||
| def title(self): | 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 | 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 | methods that do API queries will reload the title, however, like | ||||
| @@ -453,37 +456,36 @@ class Page(CopyrightMixIn): | |||||
| @property | @property | ||||
| def exists(self): | 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 <PAGE_INVALID>`, | |||||
| :py:attr:`self.PAGE_MISSING <PAGE_MISSING>`, or | |||||
| :py:attr:`self.PAGE_EXISTS <PAGE_EXISTS>`. | |||||
| Makes an API query only if we haven't already made one. | 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() | self._load() | ||||
| return cases[self._exists] | |||||
| return self._exists | |||||
| @property | @property | ||||
| def pageid(self): | 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) | |||||
| <earwigbot.wiki.category.Category.get_members>`). | |||||
| Raises :py:exc:`~earwigbot.exceptions.InvalidPageError` or | Raises :py:exc:`~earwigbot.exceptions.InvalidPageError` or | ||||
| :py:exc:`~earwigbot.exceptions.PageNotFoundError` if the page name is | :py:exc:`~earwigbot.exceptions.PageNotFoundError` if the page name is | ||||
| invalid or the page does not exist, respectively. | 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._load() | ||||
| self._assert_existence() # Missing pages do not have IDs | self._assert_existence() # Missing pages do not have IDs | ||||
| return self._pageid | return self._pageid | ||||
| @@ -501,7 +503,7 @@ class Page(CopyrightMixIn): | |||||
| else: | else: | ||||
| slug = quote(self._title.replace(" ", "_"), safe="/:") | slug = quote(self._title.replace(" ", "_"), safe="/:") | ||||
| path = self._site._article_path.replace("$1", slug) | path = self._site._article_path.replace("$1", slug) | ||||
| return ''.join((self._site._base_url, path)) | |||||
| return ''.join((self._site.url, path)) | |||||
| @property | @property | ||||
| def namespace(self): | def namespace(self): | ||||
| @@ -523,7 +525,7 @@ class Page(CopyrightMixIn): | |||||
| name is invalid. Won't raise an error if the page is missing because | name is invalid. Won't raise an error if the page is missing because | ||||
| those can still be create-protected. | those can still be create-protected. | ||||
| """ | """ | ||||
| if self._exists == 0: | |||||
| if self._exists == self.PAGE_UNKNOWN: | |||||
| self._load() | self._load() | ||||
| self._assert_validity() # Invalid pages cannot be protected | self._assert_validity() # Invalid pages cannot be protected | ||||
| return self._protection | return self._protection | ||||
| @@ -546,7 +548,7 @@ class Page(CopyrightMixIn): | |||||
| We will return ``False`` even if the page does not exist or is invalid. | 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() | self._load() | ||||
| return self._is_redirect | return self._is_redirect | ||||
| @@ -611,7 +613,7 @@ class Page(CopyrightMixIn): | |||||
| Raises InvalidPageError or PageNotFoundError if the page name is | Raises InvalidPageError or PageNotFoundError if the page name is | ||||
| invalid or the page does not exist, respectively. | 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 | # Kill two birds with one stone by doing an API query for both our | ||||
| # attributes and our page content: | # attributes and our page content: | ||||
| query = self._site.api_query | query = self._site.api_query | ||||
| @@ -626,7 +628,7 @@ class Page(CopyrightMixIn): | |||||
| if self._keep_following and self._is_redirect: | if self._keep_following and self._is_redirect: | ||||
| self._title = self.get_redirect_target() | self._title = self.get_redirect_target() | ||||
| self._keep_following = False # Don't follow double redirects | 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() | self.get() | ||||
| return self._content | return self._content | ||||
| @@ -650,9 +652,10 @@ class Page(CopyrightMixIn): | |||||
| :py:exc:`~earwigbot.exceptions.RedirectError` if the page is not a | :py:exc:`~earwigbot.exceptions.RedirectError` if the page is not a | ||||
| redirect. | redirect. | ||||
| """ | """ | ||||
| re_redirect = "^\s*\#\s*redirect\s*\[\[(.*?)\]\]" | |||||
| content = self.get() | content = self.get() | ||||
| try: | try: | ||||
| return re.findall(self.re_redirect, content, flags=re.I)[0] | |||||
| return re.findall(re_redirect, content, flags=re.I)[0] | |||||
| except IndexError: | except IndexError: | ||||
| e = "The page does not appear to have a redirect target." | e = "The page does not appear to have a redirect target." | ||||
| raise exceptions.RedirectError(e) | raise exceptions.RedirectError(e) | ||||
| @@ -671,7 +674,7 @@ class Page(CopyrightMixIn): | |||||
| :py:exc:`~earwigbot.exceptions.PageNotFoundError` if the page name is | :py:exc:`~earwigbot.exceptions.PageNotFoundError` if the page name is | ||||
| invalid or the page does not exist, respectively. | invalid or the page does not exist, respectively. | ||||
| """ | """ | ||||
| if self._exists == 0: | |||||
| if self._exists == self.PAGE_UNKNOWN: | |||||
| self._load() | self._load() | ||||
| self._assert_existence() | self._assert_existence() | ||||
| if not self._creator: | if not self._creator: | ||||
| @@ -69,6 +69,7 @@ class Site(object): | |||||
| - :py:attr:`project`: the site's project name, like ``"wikipedia"`` | - :py:attr:`project`: the site's project name, like ``"wikipedia"`` | ||||
| - :py:attr:`lang`: the site's language code, like ``"en"`` | - :py:attr:`lang`: the site's language code, like ``"en"`` | ||||
| - :py:attr:`domain`: the site's web domain, like ``"en.wikipedia.org"`` | - :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:* | *Public methods:* | ||||
| @@ -184,6 +185,12 @@ class Site(object): | |||||
| res = "<Site {0} ({1}:{2}) at {3}>" | res = "<Site {0} ({1}:{2}) at {3}>" | ||||
| return res.format(self.name, self.project, self.lang, self.domain) | 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): | def _urlencode_utf8(self, params): | ||||
| """Implement urllib.urlencode() with support for unicode input.""" | """Implement urllib.urlencode() with support for unicode input.""" | ||||
| enc = lambda s: s.encode("utf8") if isinstance(s, unicode) else str(s) | enc = lambda s: s.encode("utf8") if isinstance(s, unicode) else str(s) | ||||
| @@ -237,14 +244,7 @@ class Site(object): | |||||
| e = "Tried to do an API query, but no API URL is known." | e = "Tried to do an API query, but no API URL is known." | ||||
| raise exceptions.SiteAPIError(e) | 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 | params["format"] = "json" # This is the only format we understand | ||||
| if self._assert_edit: # If requested, ensure that we're logged in | if self._assert_edit: # If requested, ensure that we're logged in | ||||
| params["assert"] = self._assert_edit | params["assert"] = self._assert_edit | ||||
| @@ -542,6 +542,17 @@ class Site(object): | |||||
| """The Site's web domain, like ``"en.wikipedia.org"``.""" | """The Site's web domain, like ``"en.wikipedia.org"``.""" | ||||
| return urlparse(self._base_url).netloc | 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): | def api_query(self, **kwargs): | ||||
| """Do an API query with `kwargs` as the parameters. | """Do an API query with `kwargs` as the parameters. | ||||
| @@ -682,7 +693,7 @@ class Site(object): | |||||
| e = "There is no namespace with name '{0}'.".format(name) | e = "There is no namespace with name '{0}'.".format(name) | ||||
| raise exceptions.NamespaceNotFoundError(e) | 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. | """Return a :py:class:`Page` object for the given title. | ||||
| *follow_redirects* is passed directly to | *follow_redirects* is passed directly to | ||||
| @@ -696,23 +707,26 @@ class Site(object): | |||||
| redirect-following: :py:class:`~earwigbot.wiki.page.Page`'s methods | redirect-following: :py:class:`~earwigbot.wiki.page.Page`'s methods | ||||
| provide that. | provide that. | ||||
| """ | """ | ||||
| title = self._unicodeify(title) | |||||
| prefixes = self.namespace_id_to_name(constants.NS_CATEGORY, all=True) | prefixes = self.namespace_id_to_name(constants.NS_CATEGORY, all=True) | ||||
| prefix = title.split(":", 1)[0] | prefix = title.split(":", 1)[0] | ||||
| if prefix != title: # Avoid a page that is simply "Category" | if prefix != title: # Avoid a page that is simply "Category" | ||||
| if prefix in prefixes: | 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. | """Return a :py:class:`Category` object for the given category name. | ||||
| *catname* should be given *without* a namespace prefix. This method is | *catname* should be given *without* a namespace prefix. This method is | ||||
| really just shorthand for :py:meth:`get_page("Category:" + catname) | really just shorthand for :py:meth:`get_page("Category:" + catname) | ||||
| <get_page>`. | <get_page>`. | ||||
| """ | """ | ||||
| catname = self._unicodeify(catname) | |||||
| name = name if isinstance(name, unicode) else name.decode("utf8") | |||||
| prefix = self.namespace_id_to_name(constants.NS_CATEGORY) | 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): | def get_user(self, username=None): | ||||
| """Return a :py:class:`User` object for the given username. | """Return a :py:class:`User` object for the given username. | ||||
| @@ -721,6 +735,7 @@ class Site(object): | |||||
| :py:class:`~earwigbot.wiki.user.User` object representing the currently | :py:class:`~earwigbot.wiki.user.User` object representing the currently | ||||
| logged-in (or anonymous!) user is returned. | logged-in (or anonymous!) user is returned. | ||||
| """ | """ | ||||
| username = self._unicodeify(username) | |||||
| if not username: | if not username: | ||||
| username = self._get_username() | username = self._get_username() | ||||
| return User(self, username) | return User(self, username) | ||||
| @@ -39,6 +39,7 @@ class User(object): | |||||
| *Attributes:* | *Attributes:* | ||||
| - :py:attr:`site`: the user's corresponding Site object | |||||
| - :py:attr:`name`: the user's username | - :py:attr:`name`: the user's username | ||||
| - :py:attr:`exists`: ``True`` if the user exists, else ``False`` | - :py:attr:`exists`: ``True`` if the user exists, else ``False`` | ||||
| - :py:attr:`userid`: an integer ID representing the user | - :py:attr:`userid`: an integer ID representing the user | ||||
| @@ -155,6 +156,11 @@ class User(object): | |||||
| self._gender = res["gender"] | self._gender = res["gender"] | ||||
| @property | @property | ||||
| def site(self): | |||||
| """The user's corresponding Site object.""" | |||||
| return self._site | |||||
| @property | |||||
| def name(self): | def name(self): | ||||
| """The user's username. | """The user's username. | ||||
| @@ -38,6 +38,7 @@ setup( | |||||
| "oursql >= 0.9.3", # Talking with MediaWiki databases | "oursql >= 0.9.3", # Talking with MediaWiki databases | ||||
| "oauth2 >= 1.5.211", # Talking with Yahoo BOSS Search | "oauth2 >= 1.5.211", # Talking with Yahoo BOSS Search | ||||
| "pycrypto >= 2.5", # Storing bot passwords and keys | "pycrypto >= 2.5", # Storing bot passwords and keys | ||||
| "pytz >= 2012c", # Timezone handling | |||||
| ], | ], | ||||
| test_suite = "tests", | test_suite = "tests", | ||||
| version = __version__, | version = __version__, | ||||