* _ResourceManager: allow resources to be named anything as long as they inherit from the base resource class; gave resources proper names. * Renamed BaseCommand to Command and BaseTask to Task; applied renames throughout earwigbot.commands and earwigbot.tasks. * Data: refactored argument and command parsing to be completely internal. Added docstrings to attributes. Applied changes to Frontend. * IRCConnection: improved such that we accurately detect disconnects with server pings; timeout support. Applied changes to Bot. * Updated documentation and other minor fixes.tags/v0.1^2
| @@ -78,44 +78,44 @@ and :py:attr:`config.irc["frontend"]["channels"]` will be | |||||
| Custom IRC commands | Custom IRC commands | ||||
| ------------------- | ------------------- | ||||
| Custom commands are subclasses of :py:class:`earwigbot.commands.BaseCommand` | |||||
| that override :py:class:`~earwigbot.commands.BaseCommand`'s | |||||
| :py:meth:`~earwigbot.commands.BaseCommand.process` (and optionally | |||||
| :py:meth:`~earwigbot.commands.BaseCommand.check`) methods. | |||||
| :py:class:`~earwigbot.commands.BaseCommand`'s docstrings should explain what | |||||
| each attribute and method is for and what they should be overridden with, but | |||||
| these are the basics: | |||||
| - Class attribute :py:attr:`~earwigbot.commands.BaseCommand.name` is the name | |||||
| of the command. This must be specified. | |||||
| - Class attribute :py:attr:`~earwigbot.commands.BaseCommand.hooks` is a list of | |||||
| the "IRC events" that this command might respond to. It defaults to | |||||
| ``["msg"]``, but options include ``"msg_private"`` (for private messages | |||||
| only), ``"msg_public"`` (for channel messages only), and ``"join"`` (for when | |||||
| a user joins a channel). See the afc_status_ plugin for a command that | |||||
| responds to other hook types. | |||||
| - Method :py:meth:`~earwigbot.commands.BaseCommand.check` is passed a | |||||
| :py:class:`~earwigbot.irc.data.Data` [1]_ object, and should return ``True`` | |||||
| if you want to respond to this message, or ``False`` otherwise. The default | |||||
| behavior is to return ``True`` only if | |||||
| :py:attr:`data.is_command` is ``True`` and :py:attr:`data.command` == | |||||
| :py:attr:`~earwigbot.commands.BaseCommand.name`, which is suitable for most | |||||
| cases. A common, straightforward reason for overriding is if a command has | |||||
| aliases (see chanops_ for an example). Note that by returning ``True``, you | |||||
| prevent any other commands from responding to this message. | |||||
| - Method :py:meth:`~earwigbot.commands.BaseCommand.process` is passed the same | |||||
| Custom commands are subclasses of :py:class:`earwigbot.commands.Command` that | |||||
| override :py:class:`~earwigbot.commands.Command`'s | |||||
| :py:meth:`~earwigbot.commands.Command.process` (and optionally | |||||
| :py:meth:`~earwigbot.commands.Command.check`) methods. | |||||
| :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 | |||||
| are the basics: | |||||
| - Class attribute :py:attr:`~earwigbot.commands.Command.name` is the name of | |||||
| the command. This must be specified. | |||||
| - 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"]``, | |||||
| but options include ``"msg_private"`` (for private messages only), | |||||
| ``"msg_public"`` (for channel messages only), and ``"join"`` (for when a user | |||||
| joins a channel). See the afc_status_ plugin for a command that responds to | |||||
| other hook types. | |||||
| - Method :py:meth:`~earwigbot.commands.Command.check` is passed a | |||||
| :py:class:`~earwigbot.irc.data.Data` object, and should return ``True`` if | |||||
| you want to respond to this message, or ``False`` otherwise. The default | |||||
| behavior is to return ``True`` only if :py:attr:`data.is_command` is ``True`` | |||||
| and :py:attr:`data.command` == :py:attr:`~earwigbot.commands.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. | |||||
| - 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 | ||||
| :py:meth:`~earwigbot.commands.BaseCommand.check`, but only if | |||||
| :py:meth:`~earwigbot.commands.BaseCommand.check` returned ``True``. This is | |||||
| where the bulk of your command goes. To respond to IRC messages, there are a | |||||
| number of methods of :py:class:`~earwigbot.commands.BaseCommand` at your | |||||
| disposal. See the the test_ command for a simple example, or look in | |||||
| :py:class:`~earwigbot.commands.BaseCommand`'s | |||||
| :py:meth:`~earwigbot.commands.BaseCommand.__init__` method for the full list. | |||||
| :py:meth:`~earwigbot.commands.Command.check`, but only if | |||||
| :py:meth:`~earwigbot.commands.Command.check` returned ``True``. This is where | |||||
| the bulk of your command goes. To respond to IRC messages, there are a number | |||||
| of methods of :py:class:`~earwigbot.commands.Command` at your disposal. See | |||||
| the test_ command for a simple example, or look in | |||||
| :py:class:`~earwigbot.commands.Command`'s | |||||
| :py:meth:`~earwigbot.commands.Command.__init__` method for the full list. | |||||
| The most common ones are :py:meth:`say(chan_or_user, msg) | The most common ones are :py:meth:`say(chan_or_user, msg) | ||||
| <earwigbot.irc.connection.IRCConnection.say>`, :py:meth:`reply(data, msg) | <earwigbot.irc.connection.IRCConnection.say>`, :py:meth:`reply(data, msg) | ||||
| @@ -128,10 +128,10 @@ these 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>`. | ||||
| It's important to name the command class :py:class:`Command` within the file, | |||||
| or else the bot might not recognize it as a command. The name of the file | |||||
| doesn't really matter and need not match the command's name, but this is | |||||
| recommended for readability. | |||||
| 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 | |||||
| to match the command name for readability. Multiple command classes are allowed | |||||
| in one file. | |||||
| 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 | ||||
| @@ -140,48 +140,48 @@ afc_status_ for some more complicated scripts. | |||||
| Custom bot tasks | Custom bot tasks | ||||
| ---------------- | ---------------- | ||||
| Custom tasks are subclasses of :py:class:`earwigbot.tasks.BaseTask` that | |||||
| override :py:class:`~earwigbot.tasks.BaseTask`'s | |||||
| :py:meth:`~earwigbot.tasks.BaseTask.run` (and optionally | |||||
| :py:meth:`~earwigbot.tasks.BaseTask.setup`) methods. | |||||
| Custom tasks are subclasses of :py:class:`earwigbot.tasks.Task` that | |||||
| override :py:class:`~earwigbot.tasks.Task`'s | |||||
| :py:meth:`~earwigbot.tasks.Task.run` (and optionally | |||||
| :py:meth:`~earwigbot.tasks.Task.setup`) methods. | |||||
| :py:class:`~earwigbot.tasks.BaseTask`'s docstrings should explain what each | |||||
| :py:class:`~earwigbot.tasks.Task`'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 | ||||
| are the basics: | are the basics: | ||||
| - Class attribute :py:attr:`~earwigbot.tasks.BaseTask.name` is the name of the | |||||
| - Class attribute :py:attr:`~earwigbot.tasks.Task.name` is the name of the | |||||
| task. This must be specified. | task. This must be specified. | ||||
| - Class attribute :py:attr:`~earwigbot.tasks.BaseTask.number` can be used to | |||||
| store an optional "task number", possibly for use in edit summaries (to be | |||||
| generated with :py:meth:`~earwigbot.tasks.BaseTask.make_summary`). For | |||||
| - Class attribute :py:attr:`~earwigbot.tasks.Task.number` can be used to store | |||||
| an optional "task number", possibly for use in edit summaries (to be | |||||
| generated with :py:meth:`~earwigbot.tasks.Task.make_summary`). For | |||||
| example, EarwigBot's :py:attr:`config.wiki["summary"]` is | example, EarwigBot's :py:attr:`config.wiki["summary"]` is | ||||
| ``"([[WP:BOT|Bot]]; [[User:EarwigBot#Task $1|Task $1]]): $2"``, which the | ``"([[WP:BOT|Bot]]; [[User:EarwigBot#Task $1|Task $1]]): $2"``, which the | ||||
| task class's :py:meth:`make_summary(comment) | task class's :py:meth:`make_summary(comment) | ||||
| <earwigbot.tasks.BaseTask.make_summary>` method will take and replace | |||||
| <earwigbot.tasks.Task.make_summary>` method will take and replace | |||||
| ``$1`` with the task number and ``$2`` with the details of the edit. | ``$1`` with the task number and ``$2`` with the details of the edit. | ||||
| Additionally, :py:meth:`~earwigbot.tasks.BaseTask.shutoff_enabled` (which | |||||
| checks whether the bot has been told to stop on-wiki by checking the content | |||||
| of a particular page) can check a different page for each task using similar | |||||
| Additionally, :py:meth:`~earwigbot.tasks.Task.shutoff_enabled` (which checks | |||||
| whether the bot has been told to stop on-wiki by checking the content of a | |||||
| particular page) can check a different page for each task using similar | |||||
| variables. EarwigBot's :py:attr:`config.wiki["shutoff"]["page"]` is | variables. EarwigBot's :py:attr:`config.wiki["shutoff"]["page"]` is | ||||
| ``"User:$1/Shutoff/Task $2"``; ``$1`` is substituted with the bot's username, | ``"User:$1/Shutoff/Task $2"``; ``$1`` is substituted with the bot's username, | ||||
| and ``$2`` is substituted with the task number, so, e.g., task #14 checks the | and ``$2`` is substituted with the task number, so, e.g., task #14 checks the | ||||
| page ``[[User:EarwigBot/Shutoff/Task 14]].`` If the page's content does *not* | page ``[[User:EarwigBot/Shutoff/Task 14]].`` If the page's content does *not* | ||||
| match :py:attr:`config.wiki["shutoff"]["disabled"]` (``"run"`` by default), | match :py:attr:`config.wiki["shutoff"]["disabled"]` (``"run"`` by default), | ||||
| then shutoff is considered to be *enabled* and | then shutoff is considered to be *enabled* and | ||||
| :py:meth:`~earwigbot.tasks.BaseTask.shutoff_enabled` will return ``True``, | |||||
| :py:meth:`~earwigbot.tasks.Task.shutoff_enabled` will return ``True``, | |||||
| indicating the task should not run. If you don't intend to use either of | indicating the task should not run. If you don't intend to use either of | ||||
| these methods, feel free to leave this attribute blank. | these methods, feel free to leave this attribute blank. | ||||
| - Method :py:meth:`~earwigbot.tasks.BaseTask.setup` is called *once* with no | |||||
| - Method :py:meth:`~earwigbot.tasks.Task.setup` is called *once* with no | |||||
| arguments immediately after the task is first loaded. Does nothing by | arguments immediately after the task is first loaded. Does nothing by | ||||
| default; treat it like an :py:meth:`__init__` if you want | default; treat it like an :py:meth:`__init__` if you want | ||||
| (:py:meth:`~earwigbot.tasks.BaseTask.__init__` does things by default and a | |||||
| (:py:meth:`~earwigbot.tasks.Task.__init__` does things by default and a | |||||
| dedicated setup method is often easier than overriding | dedicated setup method is often easier than overriding | ||||
| :py:meth:`~earwigbot.tasks.BaseTask.__init__` and using :py:obj:`super`). | |||||
| :py:meth:`~earwigbot.tasks.Task.__init__` and using :py:obj:`super`). | |||||
| - Method :py:meth:`~earwigbot.tasks.BaseTask.run` is called with any number of | |||||
| - Method :py:meth:`~earwigbot.tasks.Task.run` is called with any number of | |||||
| keyword arguments every time the task is executed (by | keyword arguments every time the task is executed (by | ||||
| :py:meth:`tasks.start(task_name, **kwargs) | :py:meth:`tasks.start(task_name, **kwargs) | ||||
| <earwigbot.managers.TaskManager.start>`, usually). This is where the bulk of | <earwigbot.managers.TaskManager.start>`, usually). This is where the bulk of | ||||
| @@ -194,45 +194,15 @@ which is a node in :file:`config.yml` like every other attribute of | |||||
| 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. | ||||
| It's important to name the task class :py:class:`Task` within the file, or else | |||||
| the bot might not recognize it as a task. The name of the file doesn't really | |||||
| matter and need not match the task's name, but this is recommended for | |||||
| readability. | |||||
| The task *class* doesn't need a specific name, but it should logically follow | |||||
| the task's name. The filename doesn't matter, but it is recommended to match | |||||
| 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. | ||||
| .. rubric:: Footnotes | |||||
| .. [1] :py:class:`~earwigbot.irc.data.Data` objects are instances of | |||||
| :py:class:`earwigbot.irc.Data <earwigbot.irc.data.Data>` that contain | |||||
| information about a single message sent on IRC. Their useful attributes | |||||
| are :py:attr:`~earwigbot.irc.data.Data.chan` (channel the message was | |||||
| sent from, equal to :py:attr:`~earwigbot.irc.data.Data.nick` if it's a | |||||
| private message), :py:attr:`~earwigbot.irc.data.Data.nick` (nickname of | |||||
| the sender), :py:attr:`~earwigbot.irc.data.Data.ident` (ident_ of the | |||||
| sender), :py:attr:`~earwigbot.irc.data.Data.host` (hostname of the | |||||
| sender), :py:attr:`~earwigbot.irc.data.Data.msg` (text of the sent | |||||
| message), :py:attr:`~earwigbot.irc.data.Data.is_command` (boolean | |||||
| telling whether or not this message is a bot command, e.g., whether it | |||||
| is prefixed by ``!``), :py:attr:`~earwigbot.irc.data.Data.command` (if | |||||
| the message is a command, this is the name of the command used), and | |||||
| :py:attr:`~earwigbot.irc.data.Data.args` (if the message is a command, | |||||
| this is a list of the command arguments - for example, if issuing | |||||
| "``!part ##earwig Goodbye guys``", | |||||
| :py:attr:`~earwigbot.irc.data.Data.args` will equal | |||||
| ``["##earwig", "Goodbye", "guys"]``). Note that not all | |||||
| :py:class:`~earwigbot.irc.data.Data` objects will have all of these | |||||
| attributes: :py:class:`~earwigbot.irc.data.Data` objects generated by | |||||
| private messages will, but ones generated by joins will only have | |||||
| :py:attr:`~earwigbot.irc.data.Data.chan`, | |||||
| :py:attr:`~earwigbot.irc.data.Data.nick`, | |||||
| :py:attr:`~earwigbot.irc.data.Data.ident`, | |||||
| and :py:attr:`~earwigbot.irc.data.Data.host`. | |||||
| .. _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 | .. _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 | ||||
| .. _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 | ||||
| .. _ident: http://en.wikipedia.org/wiki/Ident | |||||
| @@ -75,17 +75,20 @@ class Bot(object): | |||||
| self.commands.load() | self.commands.load() | ||||
| self.tasks.load() | self.tasks.load() | ||||
| def _dispatch_irc_component(self, name, klass): | |||||
| """Create a new IRC component, record it internally, and start it.""" | |||||
| component = klass(self) | |||||
| setattr(self, name, component) | |||||
| Thread(name="irc_" + name, target=component.loop).start() | |||||
| def _start_irc_components(self): | def _start_irc_components(self): | ||||
| """Start the IRC frontend/watcher in separate threads if enabled.""" | """Start the IRC frontend/watcher in separate threads if enabled.""" | ||||
| if self.config.components.get("irc_frontend"): | if self.config.components.get("irc_frontend"): | ||||
| self.logger.info("Starting IRC frontend") | self.logger.info("Starting IRC frontend") | ||||
| self.frontend = Frontend(self) | |||||
| Thread(name="irc_frontend", target=self.frontend.loop).start() | |||||
| self._dispatch_irc_component("frontend", Frontend) | |||||
| if self.config.components.get("irc_watcher"): | if self.config.components.get("irc_watcher"): | ||||
| self.logger.info("Starting IRC watcher") | self.logger.info("Starting IRC watcher") | ||||
| self.watcher = Watcher(self) | |||||
| Thread(name="irc_watcher", target=self.watcher.loop).start() | |||||
| self._dispatch_irc_component("watcher", Watcher) | |||||
| def _start_wiki_scheduler(self): | def _start_wiki_scheduler(self): | ||||
| """Start the wiki scheduler in a separate thread if enabled.""" | """Start the wiki scheduler in a separate thread if enabled.""" | ||||
| @@ -104,6 +107,16 @@ class Bot(object): | |||||
| thread.daemon = True # Stop if other threads stop | thread.daemon = True # Stop if other threads stop | ||||
| thread.start() | thread.start() | ||||
| def _keep_irc_component_alive(self, name, klass): | |||||
| """Ensure that IRC components stay connected, else restart them.""" | |||||
| component = getattr(self, name) | |||||
| if component: | |||||
| component.keep_alive() | |||||
| if component.is_stopped(): | |||||
| log = "IRC {0} has stopped; restarting".format(name) | |||||
| self.logger.warn(log) | |||||
| self._dispatch_irc_component(name, klass) | |||||
| def _stop_irc_components(self, msg): | def _stop_irc_components(self, msg): | ||||
| """Request the IRC frontend and watcher to stop if enabled.""" | """Request the IRC frontend and watcher to stop if enabled.""" | ||||
| if self.frontend: | if self.frontend: | ||||
| @@ -148,16 +161,8 @@ class Bot(object): | |||||
| self._start_wiki_scheduler() | self._start_wiki_scheduler() | ||||
| while self._keep_looping: | while self._keep_looping: | ||||
| with self.component_lock: | with self.component_lock: | ||||
| if self.frontend and self.frontend.is_stopped(): | |||||
| name = "irc_frontend" | |||||
| self.logger.warn("IRC frontend has stopped; restarting") | |||||
| self.frontend = Frontend(self) | |||||
| Thread(name=name, target=self.frontend.loop).start() | |||||
| if self.watcher and self.watcher.is_stopped(): | |||||
| name = "irc_watcher" | |||||
| self.logger.warn("IRC watcher has stopped; restarting") | |||||
| self.watcher = Watcher(self) | |||||
| Thread(name=name, target=self.watcher.loop).start() | |||||
| self._keep_irc_component_alive("frontend", Frontend) | |||||
| self._keep_irc_component_alive("watcher", Watcher) | |||||
| sleep(2) | sleep(2) | ||||
| def restart(self, msg=None): | def restart(self, msg=None): | ||||
| @@ -20,9 +20,9 @@ | |||||
| # 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. | ||||
| __all__ = ["BaseCommand"] | |||||
| __all__ = ["Command"] | |||||
| class BaseCommand(object): | |||||
| class Command(object): | |||||
| """ | """ | ||||
| **EarwigBot: Base IRC Command** | **EarwigBot: Base IRC Command** | ||||
| @@ -30,8 +30,8 @@ class BaseCommand(object): | |||||
| component. Additional commands can be installed as plugins in the bot's | component. Additional commands can be installed as plugins in the bot's | ||||
| working directory. | working directory. | ||||
| This class (import with ``from earwigbot.commands import BaseCommand``), | |||||
| can be subclassed to create custom IRC commands. | |||||
| This class (import with ``from earwigbot.commands import Command``), can be | |||||
| subclassed to create custom IRC commands. | |||||
| This docstring is reported to the user when they type ``"!help | This docstring is reported to the user when they type ``"!help | ||||
| <command>"``. | <command>"``. | ||||
| @@ -21,9 +21,11 @@ | |||||
| # SOFTWARE. | # SOFTWARE. | ||||
| from earwigbot import wiki | from earwigbot import wiki | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.commands import Command | |||||
| class Command(BaseCommand): | |||||
| __all__ = ["AFCReport"] | |||||
| class AFCReport(Command): | |||||
| """Get information about an AFC submission by name.""" | """Get information about an AFC submission by name.""" | ||||
| name = "report" | name = "report" | ||||
| @@ -22,9 +22,11 @@ | |||||
| import re | import re | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.commands import Command | |||||
| class Command(BaseCommand): | |||||
| __all__ = ["AFCStatus"] | |||||
| 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" | ||||
| @@ -23,9 +23,11 @@ | |||||
| import re | import re | ||||
| import urllib | import urllib | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.commands import Command | |||||
| class Command(BaseCommand): | |||||
| __all__ = ["Calc"] | |||||
| class Calc(Command): | |||||
| """A somewhat advanced calculator: see http://futureboy.us/fsp/frink.fsp | """A somewhat advanced calculator: see http://futureboy.us/fsp/frink.fsp | ||||
| for details.""" | for details.""" | ||||
| name = "calc" | name = "calc" | ||||
| @@ -20,9 +20,11 @@ | |||||
| # 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. | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.commands import Command | |||||
| class Command(BaseCommand): | |||||
| __all__ = ["ChanOps"] | |||||
| 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" | ||||
| @@ -24,9 +24,11 @@ import hashlib | |||||
| from Crypto.Cipher import Blowfish | from Crypto.Cipher import Blowfish | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.commands import Command | |||||
| class Command(BaseCommand): | |||||
| __all__ = ["Crypt"] | |||||
| 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" | ||||
| @@ -24,9 +24,11 @@ import platform | |||||
| import time | import time | ||||
| from earwigbot import __version__ | from earwigbot import __version__ | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.commands import Command | |||||
| class Command(BaseCommand): | |||||
| __all__ = ["CTCP"] | |||||
| class CTCP(Command): | |||||
| """Not an actual command; this module implements responses to the CTCP | """Not an actual command; this module implements responses to the CTCP | ||||
| requests PING, TIME, and VERSION.""" | requests PING, TIME, and VERSION.""" | ||||
| name = "ctcp" | name = "ctcp" | ||||
| @@ -23,9 +23,11 @@ | |||||
| from urllib import quote_plus | from urllib import quote_plus | ||||
| from earwigbot import exceptions | from earwigbot import exceptions | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.commands import Command | |||||
| class Command(BaseCommand): | |||||
| __all__ = ["Editcount"] | |||||
| class Editcount(Command): | |||||
| """Return a user's edit count.""" | """Return a user's edit count.""" | ||||
| name = "editcount" | name = "editcount" | ||||
| @@ -24,9 +24,11 @@ import time | |||||
| import git | import git | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.commands import Command | |||||
| class Command(BaseCommand): | |||||
| __all__ = ["Git"] | |||||
| 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" | ||||
| @@ -22,10 +22,12 @@ | |||||
| import re | import re | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.commands import Command | |||||
| from earwigbot.irc import Data | from earwigbot.irc import Data | ||||
| class Command(BaseCommand): | |||||
| __all__ = ["Help"] | |||||
| class Help(Command): | |||||
| """Displays help information.""" | """Displays help information.""" | ||||
| name = "help" | name = "help" | ||||
| @@ -23,9 +23,11 @@ | |||||
| import re | import re | ||||
| from urllib import quote | from urllib import quote | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.commands import Command | |||||
| class Command(BaseCommand): | |||||
| __all__ = ["Link"] | |||||
| class Link(Command): | |||||
| """Convert a Wikipedia page name into a URL.""" | """Convert a Wikipedia page name into a URL.""" | ||||
| name = "link" | name = "link" | ||||
| @@ -20,9 +20,11 @@ | |||||
| # 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. | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.commands import Command | |||||
| class Command(BaseCommand): | |||||
| __all__ = ["Praise"] | |||||
| class Praise(Command): | |||||
| """Praise people!""" | """Praise people!""" | ||||
| name = "praise" | name = "praise" | ||||
| @@ -20,9 +20,11 @@ | |||||
| # 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. | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.commands import Command | |||||
| class Command(BaseCommand): | |||||
| __all__ = ["Quit"] | |||||
| 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" | ||||
| @@ -23,9 +23,11 @@ | |||||
| import time | import time | ||||
| from earwigbot import exceptions | from earwigbot import exceptions | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.commands import Command | |||||
| class Command(BaseCommand): | |||||
| __all__ = ["Registration"] | |||||
| class Registration(Command): | |||||
| """Return when a user registered.""" | """Return when a user registered.""" | ||||
| name = "registration" | name = "registration" | ||||
| @@ -23,9 +23,11 @@ | |||||
| import threading | import threading | ||||
| import time | import time | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.commands import Command | |||||
| class Command(BaseCommand): | |||||
| __all__ = ["Remind"] | |||||
| 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" | ||||
| @@ -24,9 +24,11 @@ from os.path import expanduser | |||||
| import oursql | import oursql | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.commands import Command | |||||
| class Command(BaseCommand): | |||||
| __all__ = ["Replag"] | |||||
| 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" | ||||
| @@ -21,9 +21,11 @@ | |||||
| # SOFTWARE. | # SOFTWARE. | ||||
| from earwigbot import exceptions | from earwigbot import exceptions | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.commands import Command | |||||
| class Command(BaseCommand): | |||||
| __all__ = ["Rights"] | |||||
| 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" | ||||
| @@ -22,14 +22,16 @@ | |||||
| import random | import random | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.commands import Command | |||||
| class Command(BaseCommand): | |||||
| __all__ = ["Test"] | |||||
| class Test(Command): | |||||
| """Test the bot!""" | """Test the bot!""" | ||||
| name = "test" | name = "test" | ||||
| def process(self, data): | def process(self, data): | ||||
| user = "\x02{0}\x0F".format(data.nick) | |||||
| user = "\x02" + data.nick + "\x0F" # Wrap nick in bold | |||||
| hey = random.randint(0, 1) | hey = random.randint(0, 1) | ||||
| if hey: | if hey: | ||||
| self.say(data.chan, "Hey {0}!".format(user)) | self.say(data.chan, "Hey {0}!".format(user)) | ||||
| @@ -23,10 +23,11 @@ | |||||
| import threading | import threading | ||||
| import re | import re | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.exceptions import KwargParseError | |||||
| from earwigbot.commands import Command | |||||
| class Command(BaseCommand): | |||||
| __all__ = ["Threads"] | |||||
| 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" | ||||
| @@ -133,13 +134,6 @@ class Command(BaseCommand): | |||||
| self.reply(data, "what task do you want me to start?") | self.reply(data, "what task do you want me to start?") | ||||
| return | return | ||||
| try: | |||||
| data.parse_kwargs() | |||||
| except KwargParseError, arg: | |||||
| msg = "error parsing argument: \x0303{0}\x0301.".format(arg) | |||||
| self.reply(data, msg) | |||||
| return | |||||
| if task_name not in self.bot.tasks: | if task_name not 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." | ||||
| @@ -29,7 +29,6 @@ This module contains all exceptions used by EarwigBot:: | |||||
| +-- NoConfigError | +-- NoConfigError | ||||
| +-- IRCError | +-- IRCError | ||||
| | +-- BrokenSocketError | | +-- BrokenSocketError | ||||
| | +-- KwargParseError | |||||
| +-- WikiToolsetError | +-- WikiToolsetError | ||||
| +-- SiteNotFoundError | +-- SiteNotFoundError | ||||
| +-- SiteAPIError | +-- SiteAPIError | ||||
| @@ -73,17 +72,6 @@ class BrokenSocketError(IRCError): | |||||
| <earwigbot.irc.connection.IRCConnection._get>`. | <earwigbot.irc.connection.IRCConnection._get>`. | ||||
| """ | """ | ||||
| class KwargParseError(IRCError): | |||||
| """Couldn't parse a certain keyword argument in an IRC message. | |||||
| This is usually caused by it being given incorrectly: e.g., no value (abc), | |||||
| just a value (=xyz), just an equal sign (=), instead of the correct form | |||||
| (abc=xyz). | |||||
| Raised by :py:meth:`Data.parse_kwargs | |||||
| <earwigbot.irc.data.Data.parse_kwargs>`. | |||||
| """ | |||||
| class WikiToolsetError(EarwigBotError): | class WikiToolsetError(EarwigBotError): | ||||
| """Base exception class for errors in the Wiki Toolset.""" | """Base exception class for errors in the Wiki Toolset.""" | ||||
| @@ -22,7 +22,7 @@ | |||||
| import socket | import socket | ||||
| from threading import Lock | from threading import Lock | ||||
| from time import sleep | |||||
| from time import sleep, time | |||||
| from earwigbot.exceptions import BrokenSocketError | from earwigbot.exceptions import BrokenSocketError | ||||
| @@ -32,16 +32,18 @@ class IRCConnection(object): | |||||
| """Interface with an IRC server.""" | """Interface with an IRC server.""" | ||||
| def __init__(self, host, port, nick, ident, realname): | def __init__(self, host, port, nick, ident, realname): | ||||
| self.host = host | |||||
| self.port = port | |||||
| self.nick = nick | |||||
| self.ident = ident | |||||
| self.realname = realname | |||||
| self._is_running = False | |||||
| self._host = host | |||||
| self._port = port | |||||
| self._nick = nick | |||||
| self._ident = ident | |||||
| self._realname = realname | |||||
| # A lock to prevent us from sending two messages at once: | |||||
| self._is_running = False | |||||
| self._send_lock = Lock() | self._send_lock = Lock() | ||||
| self._last_recv = time() | |||||
| self._last_ping = 0 | |||||
| def _connect(self): | def _connect(self): | ||||
| """Connect to our IRC server.""" | """Connect to our IRC server.""" | ||||
| self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | ||||
| @@ -55,7 +57,7 @@ class IRCConnection(object): | |||||
| self._send("USER {0} {1} * :{2}".format(self.ident, self.host, self.realname)) | self._send("USER {0} {1} * :{2}".format(self.ident, self.host, self.realname)) | ||||
| def _close(self): | def _close(self): | ||||
| """Close our connection with the IRC server.""" | |||||
| """Completely close our connection with the IRC server.""" | |||||
| try: | try: | ||||
| self._sock.shutdown(socket.SHUT_RDWR) # Shut down connection first | self._sock.shutdown(socket.SHUT_RDWR) # Shut down connection first | ||||
| except socket.error: | except socket.error: | ||||
| @@ -73,16 +75,48 @@ class IRCConnection(object): | |||||
| def _send(self, msg): | def _send(self, msg): | ||||
| """Send data to the server.""" | """Send data to the server.""" | ||||
| with self._send_lock: | with self._send_lock: | ||||
| self._sock.sendall(msg + "\r\n") | |||||
| self.logger.debug(msg) | |||||
| try: | |||||
| self._sock.sendall(msg + "\r\n") | |||||
| except socket.error: | |||||
| self._is_running = False | |||||
| else: | |||||
| self.logger.debug(msg) | |||||
| def _quit(self, msg=None): | def _quit(self, msg=None): | ||||
| """Issue a quit message to the server.""" | |||||
| """Issue a quit message to the server. Doesn't close the connection.""" | |||||
| if msg: | if msg: | ||||
| self._send("QUIT :{0}".format(msg)) | self._send("QUIT :{0}".format(msg)) | ||||
| else: | else: | ||||
| self._send("QUIT") | self._send("QUIT") | ||||
| @property | |||||
| def host(self): | |||||
| """The hostname of the IRC server, like ``"irc.freenode.net"``.""" | |||||
| return self._host | |||||
| @property | |||||
| def port(self): | |||||
| """The port of the IRC server, like ``6667``.""" | |||||
| return self._port | |||||
| @property | |||||
| def nick(self): | |||||
| """Our nickname on the server, like ``"EarwigBot"``.""" | |||||
| return self._nick | |||||
| @property | |||||
| def ident(self): | |||||
| """Our ident on the server, like ``"earwig"``. | |||||
| See `http://en.wikipedia.org/wiki/Ident`_. | |||||
| """ | |||||
| return self._ident | |||||
| @property | |||||
| def realname(self): | |||||
| """Our realname (gecos field) on the server.""" | |||||
| return self._realname | |||||
| def say(self, target, msg): | def say(self, target, msg): | ||||
| """Send a private message to a target on the server.""" | """Send a private message to a target on the server.""" | ||||
| msg = "PRIVMSG {0} :{1}".format(target, msg) | msg = "PRIVMSG {0} :{1}".format(target, msg) | ||||
| @@ -120,6 +154,11 @@ class IRCConnection(object): | |||||
| msg = "MODE {0} {1} {2}".format(target, level, msg) | msg = "MODE {0} {1} {2}".format(target, level, msg) | ||||
| self._send(msg) | self._send(msg) | ||||
| def ping(self, target): | |||||
| """Ping another entity on the server.""" | |||||
| msg = "PING {0} {0}".format(target) | |||||
| self._send(msg) | |||||
| def pong(self, target): | def pong(self, target): | ||||
| """Pong another entity on the server.""" | """Pong another entity on the server.""" | ||||
| msg = "PONG {0}".format(target) | msg = "PONG {0}".format(target) | ||||
| @@ -136,14 +175,26 @@ class IRCConnection(object): | |||||
| self._is_running = False | self._is_running = False | ||||
| break | break | ||||
| self._last_recv = time() | |||||
| lines = read_buffer.split("\n") | lines = read_buffer.split("\n") | ||||
| read_buffer = lines.pop() | read_buffer = lines.pop() | ||||
| for line in lines: | for line in lines: | ||||
| self._process_message(line) | self._process_message(line) | ||||
| if self.is_stopped(): | if self.is_stopped(): | ||||
| self._close() | |||||
| break | break | ||||
| self._close() | |||||
| def keep_alive(self): | |||||
| """Ensure that we stay connected, stopping if the connection breaks.""" | |||||
| now = time() | |||||
| if now - self._last_recv > 60: | |||||
| if self._last_ping < self._last_recv: | |||||
| self.ping(self.host) | |||||
| self._last_ping = now | |||||
| elif now - self._last_ping > 60: | |||||
| self.stop() | |||||
| def stop(self, msg=None): | def stop(self, msg=None): | ||||
| """Request the IRC connection to close at earliest convenience.""" | """Request the IRC connection to close at earliest convenience.""" | ||||
| if self._is_running: | if self._is_running: | ||||
| @@ -22,72 +22,172 @@ | |||||
| import re | import re | ||||
| from earwigbot.exceptions import KwargParseError | |||||
| __all__ = ["Data"] | __all__ = ["Data"] | ||||
| class Data(object): | class Data(object): | ||||
| """Store data from an individual line received on IRC.""" | """Store data from an individual line received on IRC.""" | ||||
| def __init__(self, bot, line): | |||||
| self.line = line | |||||
| self.my_nick = bot.config.irc["frontend"]["nick"].lower() | |||||
| self.chan = self.nick = self.ident = self.host = self.msg = "" | |||||
| def __init__(self, bot, my_nick, line, msgtype): | |||||
| self._bot = bot | |||||
| self._my_nick = my_nick | |||||
| self._line = line | |||||
| def parse_args(self): | |||||
| """Parse command arguments from the message. | |||||
| self._is_private = self._is_command = False | |||||
| self._msg = self._command = self._trigger = None | |||||
| self._args = [] | |||||
| self._kwargs = {} | |||||
| self._parse(msgtype) | |||||
| def _parse(self, msgtype): | |||||
| """Parse a line from IRC into its components as instance attributes.""" | |||||
| sender = re.findall(":(.*?)!(.*?)@(.*?)\Z", line[0])[0] | |||||
| self._nick, self._ident, self._host = sender | |||||
| self._chan = self.line[2] | |||||
| :py:attr:`self.msg <msg>` is converted into the string | |||||
| :py:attr:`self.command <command>` and the argument list | |||||
| :py:attr:`self.args <args>` if the message starts with a "trigger" | |||||
| (``"!"``, ``"."``, or the bot's name); :py:attr:`self.is_command | |||||
| <is_command>` will be set to ``True``, and :py:attr:`self.trigger | |||||
| <trigger>` will store the trigger string. Otherwise, | |||||
| :py:attr:`is_command` will be set to ``False``.""" | |||||
| args = self.msg.strip().split() | |||||
| if msgtype == "PRIVMSG": | |||||
| if self.chan == self.my_nick: | |||||
| # This is a privmsg to us, so set 'chan' as the nick of the | |||||
| # sender instead of the 'channel', which is ourselves: | |||||
| self._chan = self._nick | |||||
| self._is_private = True | |||||
| self._msg = " ".join(line[3:])[1:] | |||||
| self._parse_args() | |||||
| self._parse_kwargs() | |||||
| while "" in args: | |||||
| args.remove("") | |||||
| def _parse_args(self): | |||||
| """Parse command arguments from the message. | |||||
| # Isolate command arguments: | |||||
| self.args = args[1:] | |||||
| self.is_command = False # Is this message a command? | |||||
| self.trigger = None # What triggered this command? (!, ., or our nick) | |||||
| self.msg is converted into the string self.command and the argument | |||||
| list self.args if the message starts with a "trigger" ("!", ".", or the | |||||
| bot's name); self.is_command will be set to True, and self.trigger will | |||||
| store the trigger string. Otherwise, is_command will be set to False. | |||||
| """ | |||||
| self._args = self.msg.strip().split()[1:] | |||||
| try: | try: | ||||
| self.command = args[0].lower() | |||||
| self._command = args[0].lower() | |||||
| except IndexError: | except IndexError: | ||||
| self.command = None | |||||
| return | return | ||||
| if self.command.startswith("!") or self.command.startswith("."): | if self.command.startswith("!") or self.command.startswith("."): | ||||
| # e.g. "!command arg1 arg2" | # e.g. "!command arg1 arg2" | ||||
| self.is_command = True | |||||
| self.trigger = self.command[0] | |||||
| self.command = self.command[1:] # Strip the "!" or "." | |||||
| self._is_command = True | |||||
| self._trigger = self.command[0] | |||||
| self._command = self.command[1:] # Strip the "!" or "." | |||||
| elif self.command.startswith(self.my_nick): | elif self.command.startswith(self.my_nick): | ||||
| # e.g. "EarwigBot, command arg1 arg2" | # e.g. "EarwigBot, command arg1 arg2" | ||||
| self.is_command = True | |||||
| self.trigger = self.my_nick | |||||
| self._is_command = True | |||||
| self._trigger = self.my_nick | |||||
| try: | try: | ||||
| self.command = self.args.pop(0).lower() | |||||
| self._command = self.args.pop(0).lower() | |||||
| except IndexError: | except IndexError: | ||||
| self.command = "" | |||||
| self._command = "" | |||||
| def parse_kwargs(self): | |||||
| """Parse keyword arguments embedded in :py:attr:`self.args <args>`. | |||||
| def _parse_kwargs(self): | |||||
| """Parse keyword arguments embedded in self.args. | |||||
| Parse a command given as ``"!command key1=value1 key2=value2..."`` | |||||
| into a dict, :py:attr:`self.kwargs <kwargs>`, like | |||||
| ``{'key1': 'value2', 'key2': 'value2'...}``. | |||||
| Parse a command given as "!command key1=value1 key2=value2..." into a | |||||
| dict, self.kwargs, like {'key1': 'value2', 'key2': 'value2'...}. | |||||
| """ | """ | ||||
| self.kwargs = {} | |||||
| for arg in self.args[2:]: | for arg in self.args[2:]: | ||||
| try: | try: | ||||
| key, value = re.findall("^(.*?)\=(.*?)$", arg)[0] | key, value = re.findall("^(.*?)\=(.*?)$", arg)[0] | ||||
| except IndexError: | except IndexError: | ||||
| raise KwargParseError(arg) | |||||
| pass | |||||
| if key and value: | if key and value: | ||||
| self.kwargs[key] = value | self.kwargs[key] = value | ||||
| else: | |||||
| raise KwargParseError(arg) | |||||
| @property | |||||
| def my_nick(self): | |||||
| """Our nickname, *not* the nickname of the sender.""" | |||||
| return self._my_nick | |||||
| @property | |||||
| def line(self): | |||||
| """The full message received on IRC, including escape characters.""" | |||||
| return self._line | |||||
| @property | |||||
| def chan(self): | |||||
| """Channel the message was sent from. | |||||
| This will be equal to :py:attr:`nick` if the message is a private | |||||
| message. | |||||
| """ | |||||
| return self._chan | |||||
| @property | |||||
| def nick(self): | |||||
| """Nickname of the sender.""" | |||||
| return self._nick | |||||
| @property | |||||
| def ident(self): | |||||
| """`Ident <http://en.wikipedia.org/wiki/Ident>`_ of the sender.""" | |||||
| return self._ident | |||||
| @property | |||||
| def host(self): | |||||
| """Hostname of the sender.""" | |||||
| return self._host | |||||
| @property | |||||
| def msg(self): | |||||
| """Text of the sent message, if it is a message, else ``None``.""" | |||||
| return self._msg | |||||
| @property | |||||
| def is_private(self): | |||||
| """``True`` if this message was sent to us *only*, else ``False``.""" | |||||
| return self._is_private | |||||
| @property | |||||
| def is_command(self): | |||||
| """Boolean telling whether or not this message is a bot command. | |||||
| A message is considered a command if and only if it begins with the | |||||
| character ``"!"``, ``"."``, or the bot's name followed by optional | |||||
| punctuation and a space (so ``EarwigBot: do something``, ``EarwigBot, | |||||
| do something``, and ``EarwigBot do something`` are all valid). | |||||
| """ | |||||
| return self._is_command | |||||
| @property | |||||
| def command(self): | |||||
| """If the message is a command, this is the name of the command used. | |||||
| See :py:attr:`is_command <self.is_command>` for when a message is | |||||
| considered a command. If it's not a command, this will be set to | |||||
| ``None``. | |||||
| """ | |||||
| return self._command | |||||
| @property | |||||
| def trigger(self): | |||||
| """If this message is a command, this is what triggered it. | |||||
| It can be either "!" (``"!help"``), "." (``".help"``), or the bot's | |||||
| name (``"EarwigBot: help"``). Otherwise, it will be ``None``.""" | |||||
| return self._trigger | |||||
| @property | |||||
| def args(self): | |||||
| """List of all arguments given to this command. | |||||
| For example, the message ``"!command arg1 arg2 arg3=val3"`` will | |||||
| produce the args ``["arg1", "arg2", "arg3=val3"]``. This is empty if | |||||
| the message was not a command or if it doesn't have arguments. | |||||
| """ | |||||
| return self._args | |||||
| @property | |||||
| def kwargs(self): | |||||
| """Dictionary of keyword arguments given to this command. | |||||
| For example, the message ``"!command arg1=val1 arg2=val2"`` will | |||||
| produce the kwargs ``{"arg1": "val1", "arg2": "val2"}``. This is empty | |||||
| if the message was not a command or if it doesn't have keyword | |||||
| arguments. | |||||
| """ | |||||
| return self._kwargs | |||||
| @@ -20,8 +20,6 @@ | |||||
| # 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 re | |||||
| from earwigbot.irc import IRCConnection, Data | from earwigbot.irc import IRCConnection, Data | ||||
| __all__ = ["Frontend"] | __all__ = ["Frontend"] | ||||
| @@ -32,13 +30,12 @@ class Frontend(IRCConnection): | |||||
| The IRC frontend runs on a normal IRC server and expects users to interact | The IRC frontend runs on a normal IRC server and expects users to interact | ||||
| with it and give it commands. Commands are stored as "command classes", | with it and give it commands. Commands are stored as "command classes", | ||||
| subclasses of :py:class:`~earwigbot.commands.BaseCommand`. All command | |||||
| classes are automatically imported by :py:meth:`commands.load() | |||||
| subclasses of :py:class:`~earwigbot.commands.Command`. All command classes | |||||
| are automatically imported by :py:meth:`commands.load() | |||||
| <earwigbot.managers._ResourceManager.load>` if they are in | <earwigbot.managers._ResourceManager.load>` if they are in | ||||
| :py:mod:`earwigbot.commands` or the bot's custom command directory | :py:mod:`earwigbot.commands` or the bot's custom command directory | ||||
| (explained in the :doc:`documentation </customizing>`). | (explained in the :doc:`documentation </customizing>`). | ||||
| """ | """ | ||||
| sender_regex = re.compile(":(.*?)!(.*?)@(.*?)\Z") | |||||
| def __init__(self, bot): | def __init__(self, bot): | ||||
| self.bot = bot | self.bot = bot | ||||
| @@ -53,30 +50,17 @@ class Frontend(IRCConnection): | |||||
| def _process_message(self, line): | def _process_message(self, line): | ||||
| """Process a single message from IRC.""" | """Process a single message from IRC.""" | ||||
| line = line.strip().split() | line = line.strip().split() | ||||
| data = Data(self.bot, line) | |||||
| if line[1] == "JOIN": | if line[1] == "JOIN": | ||||
| data.nick, data.ident, data.host = self.sender_regex.findall(line[0])[0] | |||||
| data.chan = line[2] | |||||
| data.parse_args() | |||||
| data = Data(self.bot, self.nick, line, msgtype="JOIN") | |||||
| self.bot.commands.call("join", data) | self.bot.commands.call("join", data) | ||||
| elif line[1] == "PRIVMSG": | elif line[1] == "PRIVMSG": | ||||
| data.nick, data.ident, data.host = self.sender_regex.findall(line[0])[0] | |||||
| data.msg = " ".join(line[3:])[1:] | |||||
| data.chan = line[2] | |||||
| data.parse_args() | |||||
| if data.chan == self.bot.config.irc["frontend"]["nick"]: | |||||
| # This is a privmsg to us, so set 'chan' as the nick of the | |||||
| # sender, then check for private-only command hooks: | |||||
| data.chan = data.nick | |||||
| data = Data(self.bot, self.nick, line, msgtype="PRIVMSG") | |||||
| if data.is_private: | |||||
| self.bot.commands.call("msg_private", data) | self.bot.commands.call("msg_private", data) | ||||
| else: | else: | ||||
| # Check for public-only command hooks: | |||||
| self.bot.commands.call("msg_public", data) | self.bot.commands.call("msg_public", data) | ||||
| # Check for command hooks that apply to all messages: | |||||
| self.bot.commands.call("msg", data) | self.bot.commands.call("msg", data) | ||||
| elif line[0] == "PING": # If we are pinged, pong back | elif line[0] == "PING": # If we are pinged, pong back | ||||
| @@ -27,8 +27,8 @@ from re import sub | |||||
| from threading import Lock, Thread | from threading import Lock, Thread | ||||
| from time import gmtime, strftime | from time import gmtime, strftime | ||||
| from earwigbot.commands import BaseCommand | |||||
| from earwigbot.tasks import BaseTask | |||||
| from earwigbot.commands import Command | |||||
| from earwigbot.tasks import Task | |||||
| __all__ = ["CommandManager", "TaskManager"] | __all__ = ["CommandManager", "TaskManager"] | ||||
| @@ -52,32 +52,40 @@ class _ResourceManager(object): | |||||
| ``with`` statement) so an attempt at reloading resources in another thread | ``with`` statement) so an attempt at reloading resources in another thread | ||||
| won't disrupt your iteration. | won't disrupt your iteration. | ||||
| """ | """ | ||||
| def __init__(self, bot, name, attribute, base): | |||||
| def __init__(self, bot, name, base): | |||||
| self.bot = bot | self.bot = bot | ||||
| self.logger = bot.logger.getChild(name) | self.logger = bot.logger.getChild(name) | ||||
| self._resources = {} | self._resources = {} | ||||
| self._resource_name = name # e.g. "commands" or "tasks" | self._resource_name = name # e.g. "commands" or "tasks" | ||||
| self._resource_attribute = attribute # e.g. "Command" or "Task" | |||||
| self._resource_base = base # e.g. BaseCommand or BaseTask | |||||
| self._resource_base = base # e.g. Command or Task | |||||
| self._resource_access_lock = Lock() | self._resource_access_lock = Lock() | ||||
| @property | |||||
| def lock(self): | |||||
| """The resource access/modify lock.""" | |||||
| return self._resource_access_lock | |||||
| def __iter__(self): | def __iter__(self): | ||||
| for name in self._resources: | for name in self._resources: | ||||
| yield name | yield name | ||||
| def _load_resource(self, name, path): | |||||
| def _load_resource(self, name, path, klass): | |||||
| """Instantiate a resource class and add it to the dictionary.""" | |||||
| res_type = self._resource_name[:-1] # e.g. "command" or "task" | |||||
| try: | |||||
| resource = klass(self.bot) # Create instance of resource | |||||
| except Exception: | |||||
| e = "Error instantiating {0} class in {1} (from {2})" | |||||
| self.logger.exception(e.format(res_type, name, path)) | |||||
| else: | |||||
| self._resources[resource.name] = resource | |||||
| self.logger.debug("Loaded {0} {1}".format(res_type, resource.name)) | |||||
| def _load_module(self, name, path): | |||||
| """Load a specific resource from a module, identified by name and path. | """Load a specific resource from a module, identified by name and path. | ||||
| We'll first try to import it using imp magic, and if that works, make | We'll first try to import it using imp magic, and if that works, make | ||||
| an instance of the 'Command' class inside (assuming it is an instance | |||||
| of BaseCommand), add it to self._commands, and log the addition. Any | |||||
| problems along the way will either be ignored or logged. | |||||
| instances of any classes inside that are subclasses of the base | |||||
| (:py:attr:`self._resource_base <_resource_base>`), add them to the | |||||
| resources dictionary with :py:meth:`self._load_resource() | |||||
| <_load_resource>`, and finally log the addition. Any problems along | |||||
| the way will either be ignored or logged. | |||||
| """ | """ | ||||
| f, path, desc = imp.find_module(name, [path]) | f, path, desc = imp.find_module(name, [path]) | ||||
| try: | try: | ||||
| @@ -89,24 +97,13 @@ class _ResourceManager(object): | |||||
| finally: | finally: | ||||
| f.close() | f.close() | ||||
| attr = self._resource_attribute | |||||
| if not hasattr(module, attr): | |||||
| return # No resources in this module | |||||
| resource_class = getattr(module, attr) | |||||
| try: | |||||
| resource = resource_class(self.bot) # Create instance of resource | |||||
| except Exception: | |||||
| e = "Error instantiating {0} class in {1} (from {2})" | |||||
| self.logger.exception(e.format(attr, name, path)) | |||||
| return | |||||
| if not isinstance(resource, self._resource_base): | |||||
| return | |||||
| self._resources[resource.name] = resource | |||||
| self.logger.debug("Loaded {0} {1}".format(attr.lower(), resource.name)) | |||||
| for obj in vars(module).values(): | |||||
| if type(obj) is type and isinstance(obj, self._resource_base): | |||||
| self._load_resource(name, path, obj) | |||||
| def _load_directory(self, dir): | def _load_directory(self, dir): | ||||
| """Load all valid resources in a given directory.""" | """Load all valid resources in a given directory.""" | ||||
| self.logger.debug("Loading directory {0}".format(dir)) | |||||
| processed = [] | processed = [] | ||||
| for name in listdir(dir): | for name in listdir(dir): | ||||
| if not name.endswith(".py") and not name.endswith(".pyc"): | if not name.endswith(".py") and not name.endswith(".pyc"): | ||||
| @@ -115,9 +112,14 @@ class _ResourceManager(object): | |||||
| continue | continue | ||||
| modname = sub("\.pyc?$", "", name) # Remove extension | modname = sub("\.pyc?$", "", name) # Remove extension | ||||
| if modname not in processed: | if modname not in processed: | ||||
| self._load_resource(modname, dir) | |||||
| self._load_module(modname, dir) | |||||
| processed.append(modname) | processed.append(modname) | ||||
| @property | |||||
| def lock(self): | |||||
| """The resource access/modify lock.""" | |||||
| return self._resource_access_lock | |||||
| def load(self): | def load(self): | ||||
| """Load (or reload) all valid resources into :py:attr:`_resources`.""" | """Load (or reload) all valid resources into :py:attr:`_resources`.""" | ||||
| name = self._resource_name # e.g. "commands" or "tasks" | name = self._resource_name # e.g. "commands" or "tasks" | ||||
| @@ -146,8 +148,7 @@ class CommandManager(_ResourceManager): | |||||
| Manages (i.e., loads, reloads, and calls) IRC commands. | Manages (i.e., loads, reloads, and calls) IRC commands. | ||||
| """ | """ | ||||
| def __init__(self, bot): | def __init__(self, bot): | ||||
| base = super(CommandManager, self) | |||||
| base.__init__(bot, "commands", "Command", BaseCommand) | |||||
| super(CommandManager, self).__init__(bot, "commands", Command) | |||||
| def _wrap_check(self, command, data): | def _wrap_check(self, command, data): | ||||
| """Check whether a command should be called, catching errors.""" | """Check whether a command should be called, catching errors.""" | ||||
| @@ -181,7 +182,7 @@ class TaskManager(_ResourceManager): | |||||
| Manages (i.e., loads, reloads, schedules, and runs) wiki bot tasks. | Manages (i.e., loads, reloads, schedules, and runs) wiki bot tasks. | ||||
| """ | """ | ||||
| def __init__(self, bot): | def __init__(self, bot): | ||||
| super(TaskManager, self).__init__(bot, "tasks", "Task", BaseTask) | |||||
| super(TaskManager, self).__init__(bot, "tasks", Task) | |||||
| def _wrapper(self, task, **kwargs): | def _wrapper(self, task, **kwargs): | ||||
| """Wrapper for task classes: run the task and catch any errors.""" | """Wrapper for task classes: run the task and catch any errors.""" | ||||
| @@ -197,9 +198,8 @@ class TaskManager(_ResourceManager): | |||||
| def start(self, task_name, **kwargs): | def start(self, task_name, **kwargs): | ||||
| """Start a given task in a new daemon thread, and return the thread. | """Start a given task in a new daemon thread, and return the thread. | ||||
| kwargs are passed to :py:meth:`task.run() <earwigbot.tasks.BaseTask>`. | |||||
| If the task is not found, ``None`` will be returned an an error is | |||||
| logged. | |||||
| kwargs are passed to :py:meth:`task.run() <earwigbot.tasks.Task>`. If | |||||
| the task is not found, ``None`` will be returned an an error is logged. | |||||
| """ | """ | ||||
| msg = "Starting task '{0}' in a new thread" | msg = "Starting task '{0}' in a new thread" | ||||
| self.logger.info(msg.format(task_name)) | self.logger.info(msg.format(task_name)) | ||||
| @@ -23,16 +23,16 @@ | |||||
| from earwigbot import exceptions | from earwigbot import exceptions | ||||
| from earwigbot import wiki | from earwigbot import wiki | ||||
| __all__ = ["BaseTask"] | |||||
| __all__ = ["Task"] | |||||
| class BaseTask(object): | |||||
| class Task(object): | |||||
| """ | """ | ||||
| **EarwigBot: Base Bot Task** | **EarwigBot: Base Bot Task** | ||||
| This package provides built-in wiki bot "tasks" EarwigBot runs. Additional | This package provides built-in wiki bot "tasks" EarwigBot runs. Additional | ||||
| tasks can be installed as plugins in the bot's working directory. | tasks can be installed as plugins in the bot's working directory. | ||||
| This class (import with ``from earwigbot.tasks import BaseTask``) can be | |||||
| This class (import with ``from earwigbot.tasks import Task``) can be | |||||
| subclassed to create custom bot tasks. | subclassed to create custom bot tasks. | ||||
| To run a task, use :py:meth:`bot.tasks.start(name, **kwargs) | To run a task, use :py:meth:`bot.tasks.start(name, **kwargs) | ||||
| @@ -20,11 +20,11 @@ | |||||
| # 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. | ||||
| from earwigbot.tasks import BaseTask | |||||
| from earwigbot.tasks import Task | |||||
| __all__ = ["Task"] | |||||
| __all__ = ["AFCCatDelink"] | |||||
| class Task(BaseTask): | |||||
| class AFCCatDelink(Task): | |||||
| """A task to delink mainspace categories in declined [[WP:AFC]] | """A task to delink mainspace categories in declined [[WP:AFC]] | ||||
| submissions.""" | submissions.""" | ||||
| name = "afc_catdelink" | name = "afc_catdelink" | ||||
| @@ -26,11 +26,11 @@ from threading import Lock | |||||
| import oursql | import oursql | ||||
| from earwigbot.tasks import BaseTask | |||||
| from earwigbot.tasks import Task | |||||
| __all__ = ["Task"] | |||||
| __all__ = ["AFCCopyvios"] | |||||
| class Task(BaseTask): | |||||
| class AFCCopyvios(Task): | |||||
| """A task to check newly-edited [[WP:AFC]] submissions for copyright | """A task to check newly-edited [[WP:AFC]] submissions for copyright | ||||
| violations.""" | violations.""" | ||||
| name = "afc_copyvios" | name = "afc_copyvios" | ||||
| @@ -20,11 +20,11 @@ | |||||
| # 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. | ||||
| from earwigbot.tasks import BaseTask | |||||
| from earwigbot.tasks import Task | |||||
| __all__ = ["Task"] | |||||
| __all__ = ["AFCDailyCats"] | |||||
| class Task(BaseTask): | |||||
| class AFCDailyCats(Task): | |||||
| """ A task to create daily categories for [[WP:AFC]].""" | """ A task to create daily categories for [[WP:AFC]].""" | ||||
| name = "afc_dailycats" | name = "afc_dailycats" | ||||
| number = 3 | number = 3 | ||||
| @@ -32,11 +32,11 @@ from numpy import arange | |||||
| import oursql | import oursql | ||||
| from earwigbot import wiki | from earwigbot import wiki | ||||
| from earwigbot.tasks import BaseTask | |||||
| from earwigbot.tasks import Task | |||||
| __all__ = ["Task"] | |||||
| __all__ = ["AFCHistory"] | |||||
| class Task(BaseTask): | |||||
| class AFCHistory(Task): | |||||
| """A task to generate charts about AfC submissions over time. | """A task to generate charts about AfC submissions over time. | ||||
| The main function of the task is to work through the "AfC submissions by | The main function of the task is to work through the "AfC submissions by | ||||
| @@ -30,11 +30,11 @@ import oursql | |||||
| from earwigbot import exceptions | from earwigbot import exceptions | ||||
| from earwigbot import wiki | from earwigbot import wiki | ||||
| from earwigbot.tasks import BaseTask | |||||
| from earwigbot.tasks import Task | |||||
| __all__ = ["Task"] | |||||
| __all__ = ["AFCStatistics"] | |||||
| class Task(BaseTask): | |||||
| class AFCStatistics(Task): | |||||
| """A task to generate statistics for WikiProject Articles for Creation. | """A task to generate statistics for WikiProject Articles for Creation. | ||||
| Statistics are stored in a MySQL database ("u_earwig_afc_statistics") | Statistics are stored in a MySQL database ("u_earwig_afc_statistics") | ||||
| @@ -87,7 +87,9 @@ class Task(BaseTask): | |||||
| action = kwargs.get("action") | action = kwargs.get("action") | ||||
| if not self.db_access_lock.acquire(False): # Non-blocking | if not self.db_access_lock.acquire(False): # Non-blocking | ||||
| if action == "sync": | if action == "sync": | ||||
| self.logger.info("A sync is already ongoing; aborting") | |||||
| return | return | ||||
| self.logger.info("Waiting for database access lock") | |||||
| self.db_access_lock.acquire() | self.db_access_lock.acquire() | ||||
| try: | try: | ||||
| @@ -20,11 +20,11 @@ | |||||
| # 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. | ||||
| from earwigbot.tasks import BaseTask | |||||
| from earwigbot.tasks import Task | |||||
| __all__ = ["Task"] | |||||
| __all__ = ["AFCUndated"] | |||||
| class Task(BaseTask): | |||||
| class AFCUndated(Task): | |||||
| """A task to clear [[Category:Undated AfC submissions]].""" | """A task to clear [[Category:Undated AfC submissions]].""" | ||||
| name = "afc_undated" | name = "afc_undated" | ||||
| @@ -20,11 +20,11 @@ | |||||
| # 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. | ||||
| from earwigbot.tasks import BaseTask | |||||
| from earwigbot.tasks import Task | |||||
| __all__ = ["Task"] | |||||
| __all__ = ["BLPTag"] | |||||
| class Task(BaseTask): | |||||
| 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 = "blptag" | ||||
| @@ -20,11 +20,11 @@ | |||||
| # 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. | ||||
| from earwigbot.tasks import BaseTask | |||||
| from earwigbot.tasks import Task | |||||
| __all__ = ["Task"] | |||||
| __all__ = ["FeedDailyCats"] | |||||
| class Task(BaseTask): | |||||
| class FeedDailyCats(Task): | |||||
| """A task to create daily categories for [[WP:FEED]].""" | """A task to create daily categories for [[WP:FEED]].""" | ||||
| name = "feed_dailycats" | name = "feed_dailycats" | ||||
| @@ -20,11 +20,11 @@ | |||||
| # 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. | ||||
| from earwigbot.tasks import BaseTask | |||||
| from earwigbot.tasks import Task | |||||
| __all__ = ["Task"] | |||||
| __all__ = ["WikiProjectTagger"] | |||||
| class Task(BaseTask): | |||||
| class WikiProjectTagger(Task): | |||||
| """A task to tag talk pages with WikiProject Banners.""" | """A task to tag talk pages with WikiProject Banners.""" | ||||
| name = "wikiproject_tagger" | name = "wikiproject_tagger" | ||||
| @@ -20,11 +20,11 @@ | |||||
| # 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. | ||||
| from earwigbot.tasks import BaseTask | |||||
| from earwigbot.tasks import Task | |||||
| __all__ = ["Task"] | |||||
| __all__ = ["WrongMIME"] | |||||
| class Task(BaseTask): | |||||
| 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 = "wrongmime" | ||||