* _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" | ||||