diff --git a/.gitignore b/.gitignore index 282791f..4984243 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,6 @@ -# Ignore python bytecode: *.pyc - -# Ignore secure config files: -config/secure.py - -# Ignore pydev's nonsense: -.project -.pydevproject -.settings/ +*.egg +*.egg-info +.DS_Store +build +docs/_build diff --git a/LICENSE b/LICENSE index e9a8be1..104339b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,4 @@ -Copyright (c) 2009-2011 Ben Kurtovic (The Earwig) - +Copyright (C) 2009-2012 Ben Kurtovic Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README b/README deleted file mode 100644 index 54e2260..0000000 --- a/README +++ /dev/null @@ -1,20 +0,0 @@ -EarwigBot[1] is a Python[2] robot that edits Wikipedia. - -Development began, based on the Pywikipedia framework[3], in early 2009. -Approval for its fist task, a copyright violation detector[4], was carried out -in May, and the bot has been running consistently ever since (with the -exception of Jan/Feb 2011). It currently handles several ongoing tasks[5], -ranging from statistics generation to category cleanup, and on-demand tasks -such as WikiProject template tagging. Since it started running, the bot has -made over 45,000 edits. - -A project to rewrite it from scratch began in early April 2011, thus moving -away from the Pywikipedia framework and allowing for less overall code, better -integration between bot parts, and easier maintenance. - -Links: -[1] http://toolserver.org/~earwig/earwigbot/ -[2] http://python.org/ -[3] http://pywikipediabot.sourceforge.net/ -[4] http://en.wikipedia.org/wiki/Wikipedia:Bots/Requests_for_approval/EarwigBot_1 -[5] http://en.wikipedia.org/wiki/User:EarwigBot#Tasks diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..4a9e3b0 --- /dev/null +++ b/README.rst @@ -0,0 +1,205 @@ +EarwigBot +========= + +EarwigBot_ is a Python_ robot that edits Wikipedia_ and interacts with people +over IRC_. This file provides a basic overview of how to install and setup the +bot; more detailed information is located in the ``docs/`` directory (available +online at PyPI_). + +History +------- + +Development began, based on the `Pywikipedia framework`_, in early 2009. +Approval for its fist task, a `copyright violation detector`_, was carried out +in May, and the bot has been running consistently ever since (with the +exception of Jan/Feb 2011). It currently handles `several ongoing tasks`_ +ranging from statistics generation to category cleanup, and on-demand tasks +such as WikiProject template tagging. Since it started running, the bot has +made over 50,000 edits. + +A project to rewrite it from scratch began in early April 2011, thus moving +away from the Pywikipedia framework and allowing for less overall code, better +integration between bot parts, and easier maintenance. + +Installation +------------ + +This package contains the core ``earwigbot``, abstracted enough that it should +be usable and customizable by anyone running a bot on a MediaWiki site. Since +it is component-based, the IRC components can be disabled if desired. IRC +commands and bot tasks specific to `my instance of EarwigBot`_ that I don't +feel the average user will need are available from the repository +`earwigbot-plugins`_. + +It's recommended to run the bot's unit tests before installing. Run ``python +setup.py test`` from the project's root directory. Note that some +tests require an internet connection, and others may take a while to run. +Coverage is currently rather incomplete. + +Latest release (v0.1) +~~~~~~~~~~~~~~~~~~~~~ + +EarwigBot is available from the `Python Package Index`_, so you can install the +latest release with ``pip install earwigbot`` (`get pip`_). + +You can also install it from source [1]_ directly:: + + curl -Lo earwigbot.tgz https://github.com/earwig/earwigbot/tarball/v0.1 + tar -xf earwigbot.tgz + cd earwig-earwigbot-* + python setup.py install + cd .. + rm -r earwigbot.tgz earwig-earwigbot-* + +Development version +~~~~~~~~~~~~~~~~~~~ + +You can install the development version of the bot from ``git`` by using +setuptools/distribute's ``develop`` command [1]_, probably on the ``develop`` +branch which contains (usually) working code. ``master`` contains the latest +release. EarwigBot uses `git flow`_, so you're free to +browse by tags or by new features (``feature/*`` branches):: + + git clone git://github.com/earwig/earwigbot.git earwigbot + cd earwigbot + python setup.py develop + +Setup +----- + +The bot stores its data in a "working directory", including its config file and +databases. This is also the location where you will place custom IRC commands +and bot tasks, which will be explained later. It doesn't matter where this +directory is, as long as the bot can write to it. + +Start the bot with ``earwigbot path/to/working/dir``, or just ``earwigbot`` if +the working directory is the current directory. It will notice that no +``config.yml`` file exists and take you through the setup process. + +There is currently no way to edit the ``config.yml`` file from within the bot +after it has been created, but YAML is a very straightforward format, so you +should be able to make any necessary changes yourself. Check out the +`explanation of YAML`_ on Wikipedia for help. + +After setup, the bot will start. This means it will connect to the IRC servers +it has been configured for, schedule bot tasks to run at specific times, and +then wait for instructions (as commands on IRC). For a list of commands, say +"``!help``" (commands are messages prefixed with an exclamation mark). + +You can stop the bot at any time with Control+C, same as you stop a normal +Python program, and it will try to exit safely. You can also use the +"``!quit``" command on IRC. + +Customizing +----------- + +The bot's working directory contains a ``commands`` subdirectory and a +``tasks`` subdirectory. Custom IRC commands can be placed in the former, +whereas custom wiki bot tasks go into the latter. Developing custom modules is +explained below, and in more detail through the bot's documentation on PyPI_ +(or in the ``docs/`` dir). + +Note that custom commands will override built-in commands and tasks with the +same name. + +``Bot`` and ``BotConfig`` +~~~~~~~~~~~~~~~~~~~~~~~~~ + +`earwigbot.bot.Bot`_ is EarwigBot's main class. You don't have to instantiate +this yourself, but it's good to be familiar with its attributes and methods, +because it is the main way to communicate with other parts of the bot. A +``Bot`` object is accessible as an attribute of commands and tasks (i.e., +``self.bot``). + +`earwigbot.config.BotConfig`_ stores configuration information for the bot. Its +docstring explains what each attribute is used for, but essentially each "node" +(one of ``config.components``, ``wiki``, ``irc``, ``commands``, ``tasks``, and +``metadata``) maps to a section of the bot's ``config.yml`` file. For example, +if ``config.yml`` includes something like:: + + irc: + frontend: + nick: MyAwesomeBot + channels: + - "##earwigbot" + - "#channel" + - "#other-channel" + +...then ``config.irc["frontend"]["nick"]`` will be ``"MyAwesomeBot"`` and +``config.irc["frontend"]["channels"]`` will be ``["##earwigbot", "#channel", +"#other-channel"]``. + +Custom IRC commands +~~~~~~~~~~~~~~~~~~~ + +Custom commands are subclasses of `earwigbot.commands.Command`_ that override +``Command``'s ``process()`` (and optionally ``check()`` or ``setup()``) +methods. + +The bot has a wide selection of built-in commands and plugins to act as sample +code and/or to give ideas. Start with test_, and then check out chanops_ and +afc_status_ for some more complicated scripts. + +Custom bot tasks +~~~~~~~~~~~~~~~~ + +Custom tasks are subclasses of `earwigbot.tasks.Task`_ that override ``Task``'s +``run()`` (and optionally ``setup()``) methods. + +See the built-in wikiproject_tagger_ task for a relatively straightforward +task, or the afc_statistics_ plugin for a more complicated one. + +The Wiki Toolset +---------------- + +EarwigBot's answer to the `Pywikipedia framework`_ is the Wiki Toolset +(``earwigbot.wiki``), which you will mainly access through ``bot.wiki``. + +``bot.wiki`` provides three methods for the management of Sites - +``get_site()``, ``add_site()``, and ``remove_site()``. Sites are objects that +simply represent a MediaWiki site. A single instance of EarwigBot (i.e. a +single *working directory*) is expected to relate to a single site or group of +sites using the same login info (like all WMF wikis with CentralAuth). + +Load your default site (the one that you picked during setup) with +``site = bot.wiki.get_site()``. + +Not all aspects of the toolset are covered in the docs. Explore `its code and +docstrings`_ to learn how to use it in a more hands-on fashion. For reference, +``bot.wiki`` is an instance of ``earwigbot.wiki.SitesDB`` tied to the +``sites.db`` file in the bot's working directory. + +Footnotes +--------- + +- Questions, comments, or suggestions about the documentation? `Let me know`_ + so I can improve it for other people. + +.. [1] ``python setup.py install``/``develop`` may require root, or use the + ``--user`` switch to install for the current user only. + +.. _EarwigBot: http://en.wikipedia.org/wiki/User:EarwigBot +.. _Python: http://python.org/ +.. _Wikipedia: http://en.wikipedia.org/ +.. _IRC: http://en.wikipedia.org/wiki/Internet_Relay_Chat +.. _PyPI: http://packages.python.org/earwigbot +.. _Pywikipedia framework: http://pywikipediabot.sourceforge.net/ +.. _copyright violation detector: http://en.wikipedia.org/wiki/Wikipedia:Bots/Requests_for_approval/EarwigBot_1 +.. _several ongoing tasks: http://en.wikipedia.org/wiki/User:EarwigBot#Tasks +.. _my instance of EarwigBot: http://en.wikipedia.org/wiki/User:EarwigBot +.. _earwigbot-plugins: https://github.com/earwig/earwigbot-plugins +.. _Python Package Index: http://pypi.python.org +.. _get pip: http://pypi.python.org/pypi/pip +.. _git flow: http://nvie.com/posts/a-successful-git-branching-model/ +.. _explanation of YAML: http://en.wikipedia.org/wiki/YAML +.. _earwigbot.bot.Bot: https://github.com/earwig/earwigbot/blob/develop/earwigbot/bot.py +.. _earwigbot.config.BotConfig: https://github.com/earwig/earwigbot/blob/develop/earwigbot/config.py +.. _earwigbot.commands.Command: https://github.com/earwig/earwigbot/blob/develop/earwigbot/commands/__init__.py +.. _test: https://github.com/earwig/earwigbot/blob/develop/earwigbot/commands/test.py +.. _chanops: https://github.com/earwig/earwigbot/blob/develop/earwigbot/commands/chanops.py +.. _afc_status: https://github.com/earwig/earwigbot-plugins/blob/develop/commands/afc_status.py +.. _earwigbot.tasks.Task: https://github.com/earwig/earwigbot/blob/develop/earwigbot/tasks/__init__.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 +.. _its code and docstrings: https://github.com/earwig/earwigbot/tree/develop/earwigbot/wiki +.. _Let me know: ben.kurtovic@verizon.net diff --git a/config/__init__.py b/config/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/config/irc.py b/config/irc.py deleted file mode 100644 index ee9ef3e..0000000 --- a/config/irc.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- - -# EarwigBot Configuration File -# This file contains information that the bot uses to connect to IRC. - -# our main (front-end) server's hostname and port -HOST = "irc.freenode.net" -PORT = 6667 - -# our watcher server's hostname, port, and RC channel -WATCHER_HOST = "irc.wikimedia.org" -WATCHER_PORT = 6667 -WATCHER_CHAN = "#en.wikipedia" - -# our nick, ident, and real name, used on both servers -NICK = "EarwigBot" -IDENT = "earwigbot" -REALNAME = "[[w:en:User:EarwigBot]]" - -# channels to join on main server's startup -CHANS = ["##earwigbot", "##earwig", "#wikipedia-en-afc"] - -# hardcoded hostnames of users with certain permissions -OWNERS = ["wikipedia/The-Earwig"] # can use owner-only commands (!restart and !git) -ADMINS = ["wikipedia/The-Earwig", "wikipedia/LeonardBloom"] # can use high-risk commands, e.g. !op diff --git a/config/main.py b/config/main.py deleted file mode 100644 index 6e8c082..0000000 --- a/config/main.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- - -# EarwigBot Configuration File -# This file tells the bot which of its components should be enabled. - -# The IRC frontend (configured in config/irc.py) sits on a public IRC network, -# responds to commands given to it, and reports edits (if the IRC watcher -# component is enabled). -enable_irc_frontend = True - -# The IRC watcher (connection details configured in config/irc.py as well) sits -# on an IRC network that gives a recent changes feed, usually irc.wikimedia.net. -# It looks for edits matching certain (often regex) patterns (rules configured -# in config/watcher.py), and either reports them to the IRC frontend (if -# enabled), or activates a task on the WikiBot (if configured to do). -enable_irc_watcher = True - -# EarwigBot doesn't have to edit a wiki, although this is its main purpose. If -# the wiki schedule is disabled, it will not be able to handle scheduled tasks -# that involve editing (such as creating a daily category every day at midnight -# UTC), but it can still edit through rules given in the watcher, and bot tasks -# can still be activated by the command line. The schedule is configured in -# config/schedule.py. -enable_wiki_schedule = True diff --git a/config/schedule.py b/config/schedule.py deleted file mode 100644 index 093050b..0000000 --- a/config/schedule.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- - -# EarwigBot Configuration File -# This file tells the bot when to run certain wiki-editing tasks. - -def check(minute, hour, month_day, month, week_day): - tasks = [] # tasks to run this turn, each as a tuple of (task_name, kwargs) or just task_name - - if minute == 0: # run every hour on the hour - tasks.append(("afc_statistics", {"action": "save"})) # save statistics to [[Template:AFC_statistics]] - - if hour == 0: # run every day at midnight - tasks.append("afc_dailycats") # create daily categories for WP:AFC - tasks.append("feed_dailycats") # create daily categories for WP:FEED - - if week_day == 0: # run every Sunday at midnight (that is, the start of Sunday, not the end) - tasks.append("afc_undated") # clear [[Category:Undated AfC submissions]] - - if week_day == 1: # run every Monday at midnight - tasks.append("afc_catdelink") # delink mainspace categories in declined AfC submissions - - if week_day == 2: # run every Tuesday at midnight - tasks.append("wrongmime") # tag files whose extensions do not agree with their MIME type - - if week_day == 3: # run every Wednesday at midnight - tasks.append("blptag") # add |blp=yes to {{WPB}} or {{WPBS}} when it is used along with {{WP Biography}} - - return tasks diff --git a/config/secure.default.py b/config/secure.default.py deleted file mode 100644 index 0db882e..0000000 --- a/config/secure.default.py +++ /dev/null @@ -1,9 +0,0 @@ -# -*- coding: utf-8 -*- - -# EarwigBot Configuration File -# This file contains information that should be kept hidden, including passwords. - -# IRC: identify ourselves to NickServ? -NS_AUTH = False -NS_USER = "" -NS_PASS = "" diff --git a/config/watcher.py b/config/watcher.py deleted file mode 100644 index 6e2fe28..0000000 --- a/config/watcher.py +++ /dev/null @@ -1,69 +0,0 @@ -# -*- coding: utf-8 -*- - -# EarwigBot Configuration File -# This file contains rules for the bot's watcher component. - -import re - -from wiki import task_manager - -# Define different report channels on our front-end server. They /must/ be in CHANS in config/irc.py or the bot will not be able to send messages to them (unless they have -n set). -AFC_CHANS = ["#wikipedia-en-afc"] # report recent AfC changes/give AfC status messages upon join -BOT_CHANS = ["##earwigbot", "#wikipedia-en-afc"] # report edits containing "!earwigbot" - -# Define some commonly used strings. -afc_prefix = "wikipedia( talk)?:(wikiproject )?articles for creation" - -# Define our compiled regexps used when finding certain edits. -r_page = re.compile(afc_prefix) -r_ffu = re.compile("wikipedia( talk)?:files for upload") -r_move1 = re.compile("moved \[\[{}".format(afc_prefix)) # an AFC page was either moved locally or out -r_move2 = re.compile("moved \[\[(.*?)\]\] to \[\[{}".format(afc_prefix)) # an outside page was moved into AFC -r_moved_pages = re.compile("^moved \[\[(.*?)\]\] to \[\[(.*?)\]\]") -r_delete = re.compile("deleted \"\[\[{}".format(afc_prefix)) -r_deleted_page = re.compile("^deleted \"\[\[(.*?)\]\]") -r_restore = re.compile("restored \"\[\[{}".format(afc_prefix)) -r_restored_page = re.compile("^restored \"\[\[(.*?)\]\]") -r_protect = re.compile("protected \"\[\[{}".format(afc_prefix)) - -def process(rc): - chans = set() # channels to report this message to - page_name = rc.page.lower() - comment = rc.comment.lower() - - if "!earwigbot" in rc.msg.lower(): - chans.update(BOT_CHANS) - - if r_page.search(page_name): - task_manager.start_task("afc_statistics", action="process_edit", page=rc.page) - task_manager.start_task("afc_copyvios", action="process_edit", page=rc.page) - chans.update(AFC_CHANS) - - elif r_ffu.match(page_name): - chans.update(AFC_CHANS) - - elif page_name.startswith("template:afc submission"): - chans.update(AFC_CHANS) - - elif rc.flags == "move" and (r_move1.match(comment) or r_move2.match(comment)): - p = r_moved_pages.findall(rc.comment)[0] - task_manager.start_task("afc_statistics", action="process_move", pages=p) - task_manager.start_task("afc_copyvios", action="process_move", pages=p) - chans.update(AFC_CHANS) - - elif rc.flags == "delete" and r_delete.match(comment): - p = r_deleted_page.findall(rc.comment)[0][0] - task_manager.start_task("afc_statistics", action="process_delete", page=p) - task_manager.start_task("afc_copyvios", action="process_delete", page=p) - chans.update(AFC_CHANS) - - elif rc.flags == "restore" and r_restore.match(comment): - p = r_restored_page.findall(rc.comment)[0][0] - task_manager.start_task("afc_statistics", action="process_restore", page=p) - task_manager.start_task("afc_copyvios", action="process_restore", page=p) - chans.update(AFC_CHANS) - - elif rc.flags == "protect" and r_protect.match(comment): - chans.update(AFC_CHANS) - - return chans diff --git a/core/__init__.py b/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/core/main.py b/core/main.py deleted file mode 100644 index 3000def..0000000 --- a/core/main.py +++ /dev/null @@ -1,122 +0,0 @@ -# -*- coding: utf-8 -*- - -## EarwigBot's Core - -## EarwigBot has three components that can run independently of each other: an -## IRC front-end, an IRC watcher, and a wiki scheduler. -## * The IRC front-end runs on a normal IRC server and expects users to -## interact with it/give it commands. -## * The IRC watcher runs on a wiki recent-changes server and listens for -## edits. Users cannot interact with this part of the bot. -## * The wiki scheduler runs wiki-editing bot tasks in separate threads at -## user-defined times through a cron-like interface. - -## There is a "priority" system here: -## 1. If the IRC frontend is enabled, it will run on the main thread, and the -## IRC watcher and wiki scheduler (if enabled) will run on separate threads. -## 2. If the wiki scheduler is enabled, it will run on the main thread, and the -## IRC watcher (if enabled) will run on a separate thread. -## 3. If the IRC watcher is enabled, it will run on the main (and only) thread. -## Else, the bot will stop, as no components are enabled. - -import threading -import time -import traceback -import sys -import os - -parent_dir = os.path.split(sys.path[0])[0] -sys.path.append(parent_dir) # make sure we look in the parent directory for modules - -from config.main import * -from irc import frontend, watcher -from wiki import task_manager - -f_conn = None -w_conn = None - -def irc_watcher(f_conn): - """Function to handle the IRC watcher as another thread (if frontend and/or - scheduler is enabled), otherwise run as the main thread.""" - global w_conn - print "\nStarting IRC watcher..." - while 1: # restart the watcher component if (just) it breaks - w_conn = watcher.get_connection() - w_conn.connect() - print # print a blank line here to signify that the bot has finished starting up - try: - watcher.main(w_conn, f_conn) - except: - traceback.print_exc() - time.sleep(5) # sleep a bit before restarting watcher - print "\nWatcher has stopped; restarting component..." - -def wiki_scheduler(): - """Function to handle the wiki scheduler as another thread, or as the - primary thread if the IRC frontend is not enabled.""" - while 1: - time_start = time.time() - now = time.gmtime(time_start) - - task_manager.start_tasks(now) - - time_end = time.time() - time_diff = time_start - time_end - if time_diff < 60: # sleep until the next minute - time.sleep(60 - time_diff) - -def irc_frontend(): - """If the IRC frontend is enabled, make it run on our primary thread, and - enable the wiki scheduler and IRC watcher on new threads if they are - enabled.""" - global f_conn - - print "\nStarting IRC frontend..." - f_conn = frontend.get_connection() - frontend.startup(f_conn) - - if enable_wiki_schedule: - print "\nStarting wiki scheduler..." - task_manager.load_tasks() - t_scheduler = threading.Thread(target=wiki_scheduler) - t_scheduler.name = "wiki-scheduler" - t_scheduler.daemon = True - t_scheduler.start() - - if enable_irc_watcher: - t_watcher = threading.Thread(target=irc_watcher, args=(f_conn,)) - t_watcher.name = "irc-watcher" - t_watcher.daemon = True - t_watcher.start() - - frontend.main() - - if enable_irc_watcher: - w_conn.close() - f_conn.close() - -def run(): - if enable_irc_frontend: # make the frontend run on our primary thread if enabled, and enable additional components through that function - irc_frontend() - - elif enable_wiki_schedule: # the scheduler is enabled - run it on the main thread, but also run the IRC watcher on another thread if it is enabled - print "\nStarting wiki scheduler..." - task_manager.load_tasks() - if enable_irc_watcher: - t_watcher = threading.Thread(target=irc_watcher, args=(f_conn,)) - t_watcher.name = "irc-watcher" - t_watcher.daemon = True - t_watcher.start() - wiki_scheduler() - - elif enable_irc_watcher: # the IRC watcher is our only enabled component, so run its function only and don't worry about anything else - irc_watcher() - - else: # nothing is enabled! - exit("\nNo bot parts are enabled; stopping...") - -if __name__ == "__main__": - try: - run() - except KeyboardInterrupt: - exit("\nKeyboardInterrupt: stopping main bot loop.") diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..80e5ec7 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,153 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + -rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/EarwigBot.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/EarwigBot.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/EarwigBot" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/EarwigBot" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." diff --git a/docs/api/earwigbot.commands.rst b/docs/api/earwigbot.commands.rst new file mode 100644 index 0000000..e2af424 --- /dev/null +++ b/docs/api/earwigbot.commands.rst @@ -0,0 +1,9 @@ +commands Package +================ + +:mod:`commands` Package +----------------------- + +.. automodule:: earwigbot.commands + :members: + :undoc-members: diff --git a/docs/api/earwigbot.config.rst b/docs/api/earwigbot.config.rst new file mode 100644 index 0000000..d7da88c --- /dev/null +++ b/docs/api/earwigbot.config.rst @@ -0,0 +1,46 @@ +config Package +============== + +:mod:`config` Package +--------------------- + +.. automodule:: earwigbot.config + :members: + :undoc-members: + +:mod:`formatter` Module +----------------------- + +.. automodule:: earwigbot.config.formatter + :members: + :undoc-members: + :show-inheritance: + +:mod:`node` Module +------------------ + +.. automodule:: earwigbot.config.node + :members: + :undoc-members: + +:mod:`ordered_yaml` Module +-------------------------- + +.. automodule:: earwigbot.config.ordered_yaml + :members: + :undoc-members: + :show-inheritance: + +:mod:`permissions` Module +------------------------- + +.. automodule:: earwigbot.config.permissions + :members: + :undoc-members: + +:mod:`script` Module +-------------------- + +.. automodule:: earwigbot.config.script + :members: + :undoc-members: diff --git a/docs/api/earwigbot.irc.rst b/docs/api/earwigbot.irc.rst new file mode 100644 index 0000000..7503f18 --- /dev/null +++ b/docs/api/earwigbot.irc.rst @@ -0,0 +1,46 @@ +irc Package +=========== + +:mod:`irc` Package +------------------ + +.. automodule:: earwigbot.irc + :members: + :undoc-members: + +:mod:`connection` Module +------------------------ + +.. automodule:: earwigbot.irc.connection + :members: + :undoc-members: + +:mod:`data` Module +------------------ + +.. automodule:: earwigbot.irc.data + :members: + :undoc-members: + +:mod:`frontend` Module +---------------------- + +.. automodule:: earwigbot.irc.frontend + :members: + :undoc-members: + :show-inheritance: + +:mod:`rc` Module +---------------- + +.. automodule:: earwigbot.irc.rc + :members: + :undoc-members: + +:mod:`watcher` Module +--------------------- + +.. automodule:: earwigbot.irc.watcher + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/earwigbot.rst b/docs/api/earwigbot.rst new file mode 100644 index 0000000..aa03f6e --- /dev/null +++ b/docs/api/earwigbot.rst @@ -0,0 +1,57 @@ +earwigbot Package +================= + +:mod:`earwigbot` Package +------------------------ + +.. automodule:: earwigbot.__init__ + :members: + :undoc-members: + +:mod:`bot` Module +----------------- + +.. automodule:: earwigbot.bot + :members: + :undoc-members: + +:mod:`exceptions` Module +------------------------ + +.. automodule:: earwigbot.exceptions + :members: + :undoc-members: + :show-inheritance: + +:mod:`lazy` Module +------------------ + +.. automodule:: earwigbot.lazy + :members: + :undoc-members: + +:mod:`managers` Module +---------------------- + +.. automodule:: earwigbot.managers + :members: _ResourceManager, CommandManager, TaskManager + :undoc-members: + :show-inheritance: + +:mod:`util` Module +------------------ + +.. automodule:: earwigbot.util + :members: + :undoc-members: + +Subpackages +----------- + +.. toctree:: + + earwigbot.commands + earwigbot.config + earwigbot.irc + earwigbot.tasks + earwigbot.wiki diff --git a/docs/api/earwigbot.tasks.rst b/docs/api/earwigbot.tasks.rst new file mode 100644 index 0000000..1e0a50d --- /dev/null +++ b/docs/api/earwigbot.tasks.rst @@ -0,0 +1,16 @@ +tasks Package +============= + +:mod:`tasks` Package +-------------------- + +.. automodule:: earwigbot.tasks + :members: + :undoc-members: + +:mod:`wikiproject_tagger` Module +-------------------------------- + +.. automodule:: earwigbot.tasks.wikiproject_tagger + :members: + :show-inheritance: diff --git a/docs/api/earwigbot.wiki.copyvios.rst b/docs/api/earwigbot.wiki.copyvios.rst new file mode 100644 index 0000000..abddf7a --- /dev/null +++ b/docs/api/earwigbot.wiki.copyvios.rst @@ -0,0 +1,47 @@ +copyvios Package +================ + +:mod:`copyvios` Package +----------------------- + +.. automodule:: earwigbot.wiki.copyvios + :members: + :undoc-members: + +:mod:`exclusions` Module +------------------------ + +.. automodule:: earwigbot.wiki.copyvios.exclusions + :members: + :undoc-members: + +:mod:`markov` Module +-------------------- + +.. automodule:: earwigbot.wiki.copyvios.markov + :members: + :undoc-members: + :show-inheritance: + +:mod:`parsers` Module +--------------------- + +.. automodule:: earwigbot.wiki.copyvios.parsers + :members: + :undoc-members: + :show-inheritance: + +:mod:`result` Module +-------------------- + +.. automodule:: earwigbot.wiki.copyvios.result + :members: + :undoc-members: + +:mod:`search` Module +-------------------- + +.. automodule:: earwigbot.wiki.copyvios.search + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/earwigbot.wiki.rst b/docs/api/earwigbot.wiki.rst new file mode 100644 index 0000000..45b009b --- /dev/null +++ b/docs/api/earwigbot.wiki.rst @@ -0,0 +1,59 @@ +wiki Package +============ + +:mod:`wiki` Package +------------------- + +.. automodule:: earwigbot.wiki + :members: + :undoc-members: + +:mod:`category` Module +---------------------- + +.. automodule:: earwigbot.wiki.category + :members: + :undoc-members: + +:mod:`constants` Module +----------------------- + +.. automodule:: earwigbot.wiki.constants + :members: + :undoc-members: + +:mod:`page` Module +------------------ + +.. automodule:: earwigbot.wiki.page + :members: + :undoc-members: + :show-inheritance: + +:mod:`site` Module +------------------ + +.. automodule:: earwigbot.wiki.site + :members: + :undoc-members: + +:mod:`sitesdb` Module +--------------------- + +.. automodule:: earwigbot.wiki.sitesdb + :members: + :undoc-members: + +:mod:`user` Module +------------------ + +.. automodule:: earwigbot.wiki.user + :members: + :undoc-members: + +Subpackages +----------- + +.. toctree:: + + earwigbot.wiki.copyvios diff --git a/docs/api/modules.rst b/docs/api/modules.rst new file mode 100644 index 0000000..3bf56b4 --- /dev/null +++ b/docs/api/modules.rst @@ -0,0 +1,7 @@ +earwigbot +========= + +.. toctree:: + :maxdepth: 6 + + earwigbot diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..bd18ce3 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- +# +# EarwigBot documentation build configuration file, created by +# sphinx-quickstart on Sun Apr 29 01:42:25 2012. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys, os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +sys.path.insert(0, os.path.abspath('..')) + +# -- General configuration ----------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage', 'sphinx.ext.viewcode'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'EarwigBot' +copyright = u'2009, 2010, 2011, 2012 Ben Kurtovic' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '0.1' +# The full version, including alpha/beta/rc tags. +release = '0.1' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'nature' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'EarwigBotdoc' + + +# -- Options for LaTeX output -------------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ('index', 'EarwigBot.tex', u'EarwigBot Documentation', + u'Ben Kurtovic', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output -------------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'earwigbot', u'EarwigBot Documentation', + [u'Ben Kurtovic'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------------ + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'EarwigBot', u'EarwigBot Documentation', + u'Ben Kurtovic', 'EarwigBot', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' diff --git a/docs/customizing.rst b/docs/customizing.rst new file mode 100644 index 0000000..3336353 --- /dev/null +++ b/docs/customizing.rst @@ -0,0 +1,240 @@ +Customizing +=========== + +The bot's working directory contains a :file:`commands` subdirectory and a +:file:`tasks` subdirectory. Custom IRC commands can be placed in the former, +whereas custom wiki bot tasks go into the latter. Developing custom modules is +explained in detail in this documentation. + +Note that custom commands will override built-in commands and tasks with the +same name. + +:py:class:`~earwigbot.bot.Bot` and :py:class:`~earwigbot.bot.BotConfig` +----------------------------------------------------------------------- + +:py:class:`earwigbot.bot.Bot` is EarwigBot's main class. You don't have to +instantiate this yourself, but it's good to be familiar with its attributes and +methods, because it is the main way to communicate with other parts of the bot. +A :py:class:`~earwigbot.bot.Bot` object is accessible as an attribute of +commands and tasks (i.e., :py:attr:`self.bot`). + +The most useful attributes are: + +- :py:attr:`~earwigbot.bot.Bot.config`: an instance of + :py:class:`~earwigbot.config.BotConfig`, for accessing the bot's + configuration data (see below). + +- :py:attr:`~earwigbot.bot.Bot.commands`: the bot's + :py:class:`~earwigbot.managers.CommandManager`, which is used internally to + run IRC commands (through + :py:meth:`commands.call() `, which + you shouldn't have to use); you can safely reload all commands with + :py:meth:`commands.load() `. + +- :py:attr:`~earwigbot.bot.Bot.tasks`: the bot's + :py:class:`~earwigbot.managers.TaskManager`, which can be used to start tasks + with :py:meth:`tasks.start(task_name, **kwargs) + `. :py:meth:`tasks.load() + ` can be used to safely reload all + tasks. + +- :py:attr:`~earwigbot.bot.Bot.frontend` / + :py:attr:`~earwigbot.bot.Bot.watcher`: instances of + :py:class:`earwigbot.irc.Frontend ` and + :py:class:`earwigbot.irc.Watcher `, + respectively, which represent the bot's connections to these two servers; you + can, for example, send a message to the frontend with + :py:meth:`frontend.say(chan, msg) + ` (more on communicating with IRC + below). + +- :py:attr:`~earwigbot.bot.Bot.wiki`: interface with the + :doc:`Wiki Toolset `. + +- Finally, :py:meth:`~earwigbot.bot.Bot.restart` (restarts IRC components and + reloads config, commands, and tasks) and :py:meth:`~earwigbot.bot.Bot.stop` + can be used almost anywhere. Both take an optional "reason" that will be + logged and used as the quit message when disconnecting from IRC. + +:py:class:`earwigbot.config.BotConfig` stores configuration information for the +bot. Its docstrings explains what each attribute is used for, but essentially +each "node" (one of :py:attr:`config.components +`, +:py:attr:`~earwigbot.config.BotConfig.wiki`, +:py:attr:`~earwigbot.config.BotConfig.irc`, +:py:attr:`~earwigbot.config.BotConfig.commands`, +:py:attr:`~earwigbot.config.BotConfig.tasks`, or +:py:attr:`~earwigbot.config.BotConfig.metadata`) maps to a section +of the bot's :file:`config.yml` file. For example, if :file:`config.yml` +includes something like:: + + irc: + frontend: + nick: MyAwesomeBot + channels: + - "##earwigbot" + - "#channel" + - "#other-channel" + +...then :py:attr:`config.irc["frontend"]["nick"]` will be ``"MyAwesomeBot"`` +and :py:attr:`config.irc["frontend"]["channels"]` will be +``["##earwigbot", "#channel", "#other-channel"]``. + +Custom IRC commands +------------------- + +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` or +:py:meth:`~earwigbot.commands.Command.setup`) 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.commands` is a list of + names that will trigger this command. It defaults to the command's + :py:attr:`~earwigbot.commands.Command.name`, but you can override it with + multiple names to serve as aliases. This is handled by the default + :py:meth:`~earwigbot.commands.Command.check` implementation (see below), so + if :py:meth:`~earwigbot.commands.Command.check` is overridden, this is + ignored by everything except the help_ command (so ``!help alias`` will + trigger help for the actual command). + +- Class attribute :py:attr:`~earwigbot.commands.Command.hooks` is a list of the + "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.setup` is called *once* with no + arguments immediately after the command is first loaded. Does nothing by + default; treat it like an :py:meth:`__init__` if you want + (:py:meth:`~earwigbot.tasks.Command.__init__` does things by default and a + dedicated setup method is often easier than overriding + :py:meth:`~earwigbot.tasks.Command.__init__` and using :py:obj:`super`). + +- Method :py:meth:`~earwigbot.commands.Command.check` is passed a + :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` (or :py:attr:`data.command + ` is in + :py:attr:`~earwigbot.commands.Command.commands` if that list is overriden; + see above), which is suitable for most cases. A possible reason for + overriding is if you want to do something in response to events from a + specific channel only. Note that by returning ``True``, you prevent any other + commands from responding to this message. + +- Method :py:meth:`~earwigbot.commands.Command.process` is passed the same + :py:class:`~earwigbot.irc.data.Data` object as + :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) + `, :py:meth:`reply(data, msg) + ` (convenience function; sends + a reply to the issuer of the command in the channel it was received), + :py:meth:`action(chan_or_user, msg) + `, + :py:meth:`notice(chan_or_user, msg) + `, :py:meth:`join(chan) + `, and + :py:meth:`part(chan) `. + +Commands have access to :py:attr:`config.commands[command_name]` for config +information, which is a node in :file:`config.yml` like every other attribute +of :py:attr:`bot.config`. This can be used to store, for example, API keys or +SQL connection info, so that these can be easily changed without modifying the +command itself. + +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 +code and/or to give ideas. Start with test_, and then check out chanops_ and +afc_status_ for some more complicated scripts. + +Custom bot tasks +---------------- + +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.Task`'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.tasks.Task.name` is the name of the + task. This must be specified. + +- 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 + ``"([[WP:BOT|Bot]]; [[User:EarwigBot#Task $1|Task $1]]): $2"``, which the + task class's :py:meth:`make_summary(comment) + ` method will take and replace + ``$1`` with the task number and ``$2`` with the details of the edit. + + Additionally, :py:meth:`~earwigbot.tasks.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 + ``"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 + page ``[[User:EarwigBot/Shutoff/Task 14]].`` If the page's content does *not* + match :py:attr:`config.wiki["shutoff"]["disabled"]` (``"run"`` by default), + then shutoff is considered to be *enabled* and + :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 + these methods, feel free to leave this attribute blank. + +- Method :py:meth:`~earwigbot.tasks.Task.setup` is called *once* with no + arguments immediately after the task is first loaded. Does nothing by + default; treat it like an :py:meth:`__init__` if you want + (:py:meth:`~earwigbot.tasks.Task.__init__` does things by default and a + dedicated setup method is often easier than overriding + :py:meth:`~earwigbot.tasks.Task.__init__` and using :py:obj:`super`). + +- Method :py:meth:`~earwigbot.tasks.Task.run` is called with any number of + keyword arguments every time the task is executed (by + :py:meth:`tasks.start(task_name, **kwargs) + `, usually). This is where the bulk of + the task's code goes. For interfacing with MediaWiki sites, read up on the + :doc:`Wiki Toolset `. + +Tasks have access to :py:attr:`config.tasks[task_name]` for config information, +which is a node in :file:`config.yml` like every other attribute of +:py:attr:`bot.config`. This can be used to store, for example, edit summaries +or templates to append to user talk pages, so that these can be easily changed +without modifying the task itself. + +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 +task, or the afc_statistics_ plugin for a more complicated one. + +.. _help: https://github.com/earwig/earwigbot/blob/develop/earwigbot/commands/help.py +.. _afc_status: https://github.com/earwig/earwigbot-plugins/blob/develop/commands/afc_status.py +.. _test: https://github.com/earwig/earwigbot/blob/develop/earwigbot/commands/test.py +.. _chanops: https://github.com/earwig/earwigbot/blob/develop/earwigbot/commands/chanops.py +.. _wikiproject_tagger: https://github.com/earwig/earwigbot/blob/develop/earwigbot/tasks/wikiproject_tagger.py +.. _afc_statistics: https://github.com/earwig/earwigbot-plugins/blob/develop/tasks/afc_statistics.py diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..8d446dc --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,48 @@ +EarwigBot v0.1 Documentation +============================ + +EarwigBot_ is a Python_ robot that edits Wikipedia_ and interacts with people +over IRC_. + +History +------- + +Development began, based on the `Pywikipedia framework`_, in early 2009. +Approval for its fist task, a `copyright violation detector`_, was carried out +in May, and the bot has been running consistently ever since (with the +exception of Jan/Feb 2011). It currently handles `several ongoing tasks`_ +ranging from statistics generation to category cleanup, and on-demand tasks +such as WikiProject template tagging. Since it started running, the bot has +made over 50,000 edits. + +A project to rewrite it from scratch began in early April 2011, thus moving +away from the Pywikipedia framework and allowing for less overall code, better +integration between bot parts, and easier maintenance. + +.. _EarwigBot: http://en.wikipedia.org/wiki/User:EarwigBot +.. _Python: http://python.org/ +.. _Wikipedia: http://en.wikipedia.org/ +.. _IRC: http://en.wikipedia.org/wiki/Internet_Relay_Chat +.. _Pywikipedia framework: http://pywikipediabot.sourceforge.net/ +.. _copyright violation detector: http://en.wikipedia.org/wiki/Wikipedia:Bots/Requests_for_approval/EarwigBot_1 +.. _several ongoing tasks: http://en.wikipedia.org/wiki/User:EarwigBot#Tasks + +Contents +-------- + +.. toctree:: + :maxdepth: 2 + + installation + setup + customizing + toolset + tips + API Reference + +Indices and tables +------------------ + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..12fc907 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,55 @@ +Installation +============ + +This package contains the core :py:mod:`earwigbot`, abstracted enough that it +should be usable and customizable by anyone running a bot on a MediaWiki site. +Since it is component-based, the IRC components can be disabled if desired. IRC +commands and bot tasks specific to `my instance of EarwigBot`_ that I don't +feel the average user will need are available from the repository +`earwigbot-plugins`_. + +It's recommended to run the bot's unit tests before installing. Run +:command:`python setup.py test` from the project's root directory. Note that +some tests require an internet connection, and others may take a while to run. +Coverage is currently rather incomplete. + +Latest release (v0.1) +--------------------- + +EarwigBot is available from the `Python Package Index`_, so you can install the +latest release with :command:`pip install earwigbot` (`get pip`_). + +You can also install it from source [1]_ directly:: + + curl -Lo earwigbot.tgz https://github.com/earwig/earwigbot/tarball/v0.1 + tar -xf earwigbot.tgz + cd earwig-earwigbot-* + python setup.py install + cd .. + rm -r earwigbot.tgz earwig-earwigbot-* + +Development version +------------------- + +You can install the development version of the bot from :command:`git` by using +setuptools/`distribute`_'s :command:`develop` command [1]_, probably on the +``develop`` branch which contains (usually) working code. ``master`` contains +the latest release. EarwigBot uses `git flow`_, so you're free to browse by +tags or by new features (``feature/*`` branches):: + + git clone git://github.com/earwig/earwigbot.git earwigbot + cd earwigbot + python setup.py develop + +.. rubric:: Footnotes + +.. [1] :command:`python setup.py install`/:command:`develop` may require root, + or use the :command:`--user` switch to install for the current user + only. + +.. _my instance of EarwigBot: http://en.wikipedia.org/wiki/User:EarwigBot +.. _earwigbot-plugins: https://github.com/earwig/earwigbot-plugins +.. _Python Package Index: http://pypi.python.org +.. _get pip: http://pypi.python.org/pypi/pip +.. _distribute: http://pypi.python.org/pypi/distribute +.. _git flow: http://nvie.com/posts/a-successful-git-branching-model/ diff --git a/docs/setup.rst b/docs/setup.rst new file mode 100644 index 0000000..81523df --- /dev/null +++ b/docs/setup.rst @@ -0,0 +1,28 @@ +Setup +===== + +The bot stores its data in a "working directory", including its config file and +databases. This is also the location where you will place custom IRC commands +and bot tasks, which will be explained later. It doesn't matter where this +directory is, as long as the bot can write to it. + +Start the bot with :command:`earwigbot path/to/working/dir`, or just +:command:`earwigbot` if the working directory is the current directory. It will +notice that no :file:`config.yml` file exists and take you through the setup +process. + +There is currently no way to edit the :file:`config.yml` file from within the +bot after it has been created, but YAML is a very straightforward format, so +you should be able to make any necessary changes yourself. Check out the +`explanation of YAML`_ on Wikipedia for help. + +After setup, the bot will start. This means it will connect to the IRC servers +it has been configured for, schedule bot tasks to run at specific times, and +then wait for instructions (as commands on IRC). For a list of commands, say +"``!help``" (commands are messages prefixed with an exclamation mark). + +You can stop the bot at any time with :kbd:`Control-c`, same as you stop a +normal Python program, and it will try to exit safely. You can also use the +"``!quit``" command on IRC. + +.. _explanation of YAML: http://en.wikipedia.org/wiki/YAML diff --git a/docs/tips.rst b/docs/tips.rst new file mode 100644 index 0000000..4f4052e --- /dev/null +++ b/docs/tips.rst @@ -0,0 +1,46 @@ +Tips +==== + +- Logging_ is a fantastic way to monitor the bot's progress as it runs. It has + a slew of built-in loggers, and enabling log retention (so logs are saved to + :file:`logs/` in the working directory) is highly recommended. In the normal + setup, there are three log files, each of which "rotate" at a specific time + (:file:`filename.log` becomes :file:`filename.log.2012-04-10`, for example). + The :file:`debug.log` file rotates every hour, and maintains six hours of + logs of every level (``DEBUG`` and up). :file:`bot.log` rotates every day at + midnight, and maintains seven days of non-debug logs (``INFO`` and up). + Finally, :file:`error.log` rotates every Sunday night, and maintains four + weeks of logs indicating unexpected events (``WARNING`` and up). + + To use logging in your commands or tasks (recommended), + :py:class:~earwigbot.commands.BaseCommand` and + :py:class:~earwigbot.tasks.BaseTask` provide :py:attr:`logger` attributes + configured for the specific command or task. If you're working with other + classes, :py:attr:`bot.logger` is the root logger + (:py:obj:`logging.getLogger("earwigbot")` by default), so you can use + :py:func:`~logging.Logger.getChild` to make your logger. For example, task + loggers are essentially + :py:attr:`bot.logger.getChild("tasks").getChild(task.name) `. + +- A very useful IRC command is "``!reload``", which reloads all commands and + tasks without restarting the bot. [1]_ Combined with using the `!git plugin`_ + for pulling repositories from IRC, this can provide a seamless command/task + development workflow if the bot runs on an external server and you set up + its working directory as a git repo. + +- You can run a task by itself instead of the entire bot with + :command:`earwigbot path/to/working/dir --task task_name`. + +- Questions, comments, or suggestions about the documentation? `Let me know`_, + or `create an issue`_ so I can improve it for other people. + +.. rubric:: Footnotes + +.. [1] In reality, all this does is call :py:meth:`bot.commands.load() + ` and + :py:meth:`bot.tasks.load() `! + +.. _logging: http://docs.python.org/library/logging.html +.. _!git plugin: https://github.com/earwig/earwigbot-plugins/blob/develop/commands/git.py +.. _Let me know: ben.kurtovic@verizon.net +.. _create an issue: https://github.com/earwig/earwigbot/issues diff --git a/docs/toolset.rst b/docs/toolset.rst new file mode 100644 index 0000000..5306fc3 --- /dev/null +++ b/docs/toolset.rst @@ -0,0 +1,247 @@ +The Wiki Toolset +================ + +EarwigBot's answer to the `Pywikipedia framework`_ is the Wiki Toolset +(:py:mod:`earwigbot.wiki`), which you will mainly access through +:py:attr:`bot.wiki `. + +:py:attr:`bot.wiki ` provides three methods for the +management of Sites - :py:meth:`~earwigbot.wiki.sitesdb.SitesDB.get_site`, +:py:meth:`~earwigbot.wiki.sitesdb.SitesDB.add_site`, and +:py:meth:`~earwigbot.wiki.sitesdb.SitesDB.remove_site`. Sites are objects that +simply represent a MediaWiki site. A single instance of EarwigBot (i.e. a +single *working directory*) is expected to relate to a single site or group of +sites using the same login info (like all WMF wikis with `CentralAuth`_). + +Load your default site (the one that you picked during setup) with +``site = bot.wiki.get_site()``. + +Dealing with other sites +~~~~~~~~~~~~~~~~~~~~~~~~ + +*Skip this section if you're only working with one site.* + +If a site is *already known to the bot* (meaning that it is stored in the +:file:`sites.db` file, which includes just your default wiki at first), you can +load a site with ``site = bot.wiki.get_site(name)``, where ``name`` might be +``"enwiki"`` or ``"frwiktionary"`` (you can also do +``site = bot.wiki.get_site(project="wikipedia", lang="en")``). Recall that not +giving any arguments to ``get_site()`` will return the default site. + +:py:meth:`~earwigbot.wiki.sitesdb.SitesDB.add_site` is used to add new sites to +the sites database. It may be called with similar arguments as +:py:meth:`~earwigbot.wiki.sitesdb.SitesDB.get_site`, but the difference is +important. :py:meth:`~earwigbot.wiki.sitesdb.SitesDB.get_site` only needs +enough information to identify the site in its database, which is usually just +its name; the database stores all other necessary connection info. With +:py:meth:`~earwigbot.wiki.sitesdb.SitesDB.add_site`, you need to provide enough +connection info so the toolset can successfully access the site's API/SQL +databases and store that information for later. That might not be much; for WMF +wikis, you can usually use code like this:: + + project, lang = "wikipedia", "es" + try: + site = bot.wiki.get_site(project=project, lang=lang) + except earwigbot.SiteNotFoundError: + # Load site info from http://es.wikipedia.org/w/api.php: + site = bot.wiki.add_site(project=project, lang=lang) + +This works because EarwigBot assumes that the URL for the site is +``"//{lang}.{project}.org"``, the API is at ``/w/api.php``, and the SQL +connection info (if any) is stored as ``config.wiki["sql"]``. This might change +if you're dealing with non-WMF wikis, where the code might look something more +like:: + + project, lang = "mywiki", "it" + try: + site = bot.wiki.get_site(project=project, lang=lang) + except earwigbot.SiteNotFoundError: + # Load site info from http://mysite.net/mywiki/it/s/api.php: + base_url = "http://mysite.net/" + project + "/" + lang + db_name = lang + project + "_p" + sql = {host: "sql.mysite.net", db: db_name} + site = bot.wiki.add_site(base_url=base_url, script_path="/s", sql=sql) + +:py:meth:`~earwigbot.wiki.sitesdb.SitesDB.remove_site` does the opposite of +:py:meth:`~earwigbot.wiki.sitesdb.SitesDB.add_site`: give it a site's name or a +project/lang pair like :py:meth:`~earwigbot.wiki.sitesdb.SitesDB.get_site` +takes, and it'll remove that site from the sites database. + +Sites +~~~~~ + +:py:class:`earwigbot.wiki.Site ` objects provide the +following attributes: + +- :py:attr:`~earwigbot.wiki.site.Site.name`: the site's name (or "wikiid"), + like ``"enwiki"`` +- :py:attr:`~earwigbot.wiki.site.Site.project`: the site's project name, like + ``"wikipedia"`` +- :py:attr:`~earwigbot.wiki.site.Site.lang`: the site's language code, like + ``"en"`` +- :py:attr:`~earwigbot.wiki.site.Site.domain`: the site's web domain, like + ``"en.wikipedia.org"`` +- :py:attr:`~earwigbot.wiki.site.Site.url`: the site's full base URL, like + ``"https://en.wikipedia.org"`` + +and the following methods: + +- :py:meth:`api_query(**kwargs) `: does an + API query with the given keyword arguments as params +- :py:meth:`sql_query(query, params=(), ...) + `: does an SQL query and yields its + results (as a generator) +- :py:meth:`~earwigbot.wiki.site.Site.get_replag`: returns the estimated + database replication lag (if we have the site's SQL connection info) +- :py:meth:`namespace_id_to_name(id, all=False) + `: given a namespace ID, + returns the primary associated namespace name (or a list of all names when + ``all`` is ``True``) +- :py:meth:`namespace_name_to_id(name) + `: given a namespace name, + returns the associated namespace ID +- :py:meth:`get_page(title, follow_redirects=False, ...) + `: returns a ``Page`` object for the given + title (or a :py:class:`~earwigbot.wiki.category.Category` object if the + page's namespace is "``Category:``") +- :py:meth:`get_category(catname, follow_redirects=False, ...) + `: returns a ``Category`` object for + the given title (sans namespace) +- :py:meth:`get_user(username) `: returns a + :py:class:`~earwigbot.wiki.user.User` object for the given username +- :py:meth:`delegate(services, ...) `: + delegates a task to either the API or SQL depending on various conditions, + such as server lag + +Pages and categories +~~~~~~~~~~~~~~~~~~~~ + +Create :py:class:`earwigbot.wiki.Page ` objects with +:py:meth:`site.get_page(title) `, +:py:meth:`page.toggle_talk() `, +:py:meth:`user.get_userpage() `, or +:py:meth:`user.get_talkpage() `. They +provide the following attributes: + +- :py:attr:`~earwigbot.wiki.page.Page.site`: the page's corresponding + :py:class:`~earwigbot.wiki.site.Site` object +- :py:attr:`~earwigbot.wiki.page.Page.title`: the page's title, or pagename +- :py:attr:`~earwigbot.wiki.page.Page.exists`: whether or not the page exists +- :py:attr:`~earwigbot.wiki.page.Page.pageid`: an integer ID representing the + page +- :py:attr:`~earwigbot.wiki.page.Page.url`: the page's URL +- :py:attr:`~earwigbot.wiki.page.Page.namespace`: the page's namespace as an + integer +- :py:attr:`~earwigbot.wiki.page.Page.protection`: the page's current + protection status +- :py:attr:`~earwigbot.wiki.page.Page.is_talkpage`: ``True`` if the page is a + talkpage, else ``False`` +- :py:attr:`~earwigbot.wiki.page.Page.is_redirect`: ``True`` if the page is a + redirect, else ``False`` + +and the following methods: + +- :py:meth:`~earwigbot.wiki.page.Page.reload`: forcibly reloads the page's + attributes (emphasis on *reload* - this is only necessary if there is reason + to believe they have changed) +- :py:meth:`toggle_talk(...) `: returns a + content page's talk page, or vice versa +- :py:meth:`~earwigbot.wiki.page.Page.get`: returns page content +- :py:meth:`~earwigbot.wiki.page.Page.get_redirect_target`: if the page is a + redirect, returns its destination +- :py:meth:`~earwigbot.wiki.page.Page.get_creator`: returns a + :py:class:`~earwigbot.wiki.user.User` object representing the first user to + edit the page +- :py:meth:`edit(text, summary, minor=False, bot=True, force=False) + `: replaces the page's content with ``text`` + or creates a new page +- :py:meth:`add_section(text, title, minor=False, bot=True, force=False) + `: adds a new section named ``title`` + at the bottom of the page +- :py:meth:`copyvio_check(...) + `: checks the page for + copyright violations +- :py:meth:`copyvio_compare(url, ...) + `: checks the page like + :py:meth:`~earwigbot.wiki.copyvios.CopyvioMixIn.copyvio_check`, but + against a specific URL +- :py:meth:`check_exclusion(username=None, optouts=None) + `: checks whether or not we are + allowed to edit the page per ``{{bots}}``/``{{nobots}}`` + +Additionally, :py:class:`~earwigbot.wiki.category.Category` objects (created +with :py:meth:`site.get_category(name) ` +or :py:meth:`site.get_page(title) ` where +``title`` is in the ``Category:`` namespace) provide the following additional +attributes: + +- :py:attr:`~earwigbot.wiki.category.Category.size`: the total number of + members in the category +- :py:attr:`~earwigbot.wiki.category.Category.pages`: the number of pages in + the category +- :py:attr:`~earwigbot.wiki.category.Category.files`: the number of files in + the category +- :py:attr:`~earwigbot.wiki.category.Category.subcats`: the number of + subcategories in the category + +And the following additional method: + +- :py:meth:`get_members(limit=None, ...) + `: iterates over + :py:class:`~earwigbot.wiki.page.Page`\ s in the category, until either the + category is exhausted or (if given) ``limit`` is reached + +Users +~~~~~ + +Create :py:class:`earwigbot.wiki.User ` objects with +:py:meth:`site.get_user(name) ` or +:py:meth:`page.get_creator() `. They +provide the following attributes: + +- :py:attr:`~earwigbot.wiki.user.User.site`: the user's corresponding + :py:class:`~earwigbot.wiki.site.Site` object +- :py:attr:`~earwigbot.wiki.user.User.name`: the user's username +- :py:attr:`~earwigbot.wiki.user.User.exists`: ``True`` if the user exists, or + ``False`` if they do not +- :py:attr:`~earwigbot.wiki.user.User.userid`: an integer ID representing the + user +- :py:attr:`~earwigbot.wiki.user.User.blockinfo`: information about any current + blocks on the user (``False`` if no block, or a dict of + ``{"by": blocking_user, "reason": block_reason, + "expiry": block_expire_time}``) +- :py:attr:`~earwigbot.wiki.user.User.groups`: a list of the user's groups +- :py:attr:`~earwigbot.wiki.user.User.rights`: a list of the user's rights +- :py:attr:`~earwigbot.wiki.user.User.editcount`: the number of edits made by + the user +- :py:attr:`~earwigbot.wiki.user.User.registration`: the time the user + registered as a :py:obj:`time.struct_time` +- :py:attr:`~earwigbot.wiki.user.User.emailable`: ``True`` if you can email the + user, ``False`` if you cannot +- :py:attr:`~earwigbot.wiki.user.User.gender`: the user's gender (``"male"``, + ``"female"``, or ``"unknown"``) +- :py:attr:`~earwigbot.wiki.user.User.is_ip`: ``True`` if the user is an IP + address, IPv4 or IPv6, otherwise ``False`` + +and the following methods: + +- :py:meth:`~earwigbot.wiki.user.User.reload`: forcibly reloads the user's + attributes (emphasis on *reload* - this is only necessary if there is reason + to believe they have changed) +- :py:meth:`~earwigbot.wiki.user.User.get_userpage`: returns a + :py:class:`~earwigbot.wiki.page.Page` object representing the user's userpage +- :py:meth:`~earwigbot.wiki.user.User.get_talkpage`: returns a + :py:class:`~earwigbot.wiki.page.Page` object representing the user's talkpage + +Additional features +~~~~~~~~~~~~~~~~~~~ + +Not all aspects of the toolset are covered here. Explore `its code and +docstrings`_ to learn how to use it in a more hands-on fashion. For reference, +:py:attr:`bot.wiki ` is an instance of +:py:class:`earwigbot.wiki.SitesDB ` tied to the +:file:`sites.db` file in the bot's working directory. + +.. _Pywikipedia framework: http://pywikipediabot.sourceforge.net/ +.. _CentralAuth: http://www.mediawiki.org/wiki/Extension:CentralAuth +.. _its code and docstrings: https://github.com/earwig/earwigbot/tree/develop/earwigbot/wiki diff --git a/earwigbot.py b/earwigbot.py deleted file mode 100644 index f7852ef..0000000 --- a/earwigbot.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- - -import time -from subprocess import * - -try: - from config import irc, main, schedule, secure, watcher -except ImportError: - print """Missing a config file! Make sure you have configured the bot. All *.py.default files in config/ -should have their .default extension removed, and the info inside should be corrected.""" - exit() - -def main(): - while 1: - call(['python', 'core/main.py']) - time.sleep(5) # sleep for five seconds between bot runs - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - exit("\nKeyboardInterrupt: stopping bot wrapper.") diff --git a/earwigbot/__init__.py b/earwigbot/__init__.py new file mode 100644 index 0000000..696ce3f --- /dev/null +++ b/earwigbot/__init__.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +`EarwigBot `_ is a Python robot that edits +Wikipedia and interacts with people over IRC. + +See :file:`README.rst` for an overview, or the :file:`docs/` directory for +details. This documentation is also available `online +`_. +""" + +__author__ = "Ben Kurtovic" +__copyright__ = "Copyright (C) 2009, 2010, 2011, 2012 Ben Kurtovic" +__license__ = "MIT License" +__version__ = "0.1" +__email__ = "ben.kurtovic@verizon.net" +__release__ = True + +if not __release__: + def _get_git_commit_id(): + """Return the ID of the git HEAD commit.""" + from git import Repo + from os.path import split, dirname + path = split(dirname(__file__))[0] + commit_id = Repo(path).head.object.hexsha + return commit_id[:8] + try: + __version__ += ".git+" + _get_git_commit_id() + except Exception: + pass + finally: + del _get_git_commit_id + +from earwigbot import lazy + +importer = lazy.LazyImporter() + +bot = importer.new("earwigbot.bot") +commands = importer.new("earwigbot.commands") +config = importer.new("earwigbot.config") +exceptions = importer.new("earwigbot.exceptions") +irc = importer.new("earwigbot.irc") +managers = importer.new("earwigbot.managers") +tasks = importer.new("earwigbot.tasks") +util = importer.new("earwigbot.util") +wiki = importer.new("earwigbot.wiki") + +del importer diff --git a/earwigbot/bot.py b/earwigbot/bot.py new file mode 100644 index 0000000..27dd9c0 --- /dev/null +++ b/earwigbot/bot.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import logging +from threading import Lock, Thread, enumerate as enumerate_threads +from time import sleep, time + +from earwigbot import __version__ +from earwigbot.config import BotConfig +from earwigbot.irc import Frontend, Watcher +from earwigbot.managers import CommandManager, TaskManager +from earwigbot.wiki import SitesDB + +__all__ = ["Bot"] + +class Bot(object): + """ + **EarwigBot: Main Bot Class** + + The :py:class:`Bot` class is the core of EarwigBot, essentially responsible + for starting the various bot components and making sure they are all happy. + + EarwigBot has three components that can run independently of each other: an + IRC front-end, an IRC watcher, and a wiki scheduler. + + - The IRC front-end runs on a normal IRC server and expects users to + interact with it/give it commands. + - The IRC watcher runs on a wiki recent-changes server and listens for + edits. Users cannot interact with this part of the bot. + - The wiki scheduler runs wiki-editing bot tasks in separate threads at + user-defined times through a cron-like interface. + + The :py:class:`Bot` object is accessible from within commands and tasks as + :py:attr:`self.bot`. This is the primary way to access data from other + components of the bot. For example, our + :py:class:`~earwigbot.config.BotConfig` object is accessable from + :py:attr:`bot.config`, tasks can be started with + :py:meth:`bot.tasks.start() `, and + sites can be loaded from the wiki toolset with + :py:meth:`bot.wiki.get_site() `. + """ + + def __init__(self, root_dir, level=logging.INFO): + self.config = BotConfig(self, root_dir, level) + self.logger = logging.getLogger("earwigbot") + self.commands = CommandManager(self) + self.tasks = TaskManager(self) + self.wiki = SitesDB(self) + self.frontend = None + self.watcher = None + + self.component_lock = Lock() + self._keep_looping = True + + self.config.load() + self.commands.load() + self.tasks.load() + + def __repr__(self): + """Return the canonical string representation of the Bot.""" + return "Bot(config={0!r})".format(self.config) + + def __str__(self): + """Return a nice string representation of the Bot.""" + return "".format(self.config.root_dir) + + 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): + """Start the IRC frontend/watcher in separate threads if enabled.""" + if self.config.components.get("irc_frontend"): + self.logger.info("Starting IRC frontend") + self._dispatch_irc_component("frontend", Frontend) + if self.config.components.get("irc_watcher"): + self.logger.info("Starting IRC watcher") + self._dispatch_irc_component("watcher", Watcher) + + def _start_wiki_scheduler(self): + """Start the wiki scheduler in a separate thread if enabled.""" + def wiki_scheduler(): + while self._keep_looping: + time_start = time() + self.tasks.schedule() + time_end = time() + time_diff = time_start - time_end + if time_diff < 60: # Sleep until the next minute + sleep(60 - time_diff) + + if self.config.components.get("wiki_scheduler"): + self.logger.info("Starting wiki scheduler") + thread = Thread(name="wiki_scheduler", target=wiki_scheduler) + thread.daemon = True # Stop if other threads stop + 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): + """Request the IRC frontend and watcher to stop if enabled.""" + if self.frontend: + self.frontend.stop(msg) + if self.watcher: + self.watcher.stop(msg) + + def _stop_daemon_threads(self): + """Notify the user of which threads are going to be killed. + + Unfortunately, there is no method right now of stopping command and + task threads safely. This is because there is no way to tell them to + stop like the IRC components can be told; furthermore, they are run as + daemons, and daemon threads automatically stop without calling any + __exit__ or try/finally code when all non-daemon threads stop. They + were originally implemented as regular non-daemon threads, but this + meant there was no way to completely stop the bot if tasks were + running, because all other threads would exit and threading would + absorb KeyboardInterrupts. + + The advantage of this is that stopping the bot is truly guarenteed to + *stop* the bot, while the disadvantage is that the threads are given no + advance warning of their forced shutdown. + """ + tasks = [] + component_names = self.config.components.keys() + skips = component_names + ["MainThread", "reminder", "irc:quit"] + for thread in enumerate_threads(): + if thread.name not in skips and thread.is_alive(): + tasks.append(thread.name) + if tasks: + log = "The following commands or tasks will be killed: {0}" + self.logger.warn(log.format(" ".join(tasks))) + + @property + def is_running(self): + """Whether or not the bot is currently running. + + This may return ``False`` even if the bot is still technically active, + but in the process of shutting down. + """ + return self._keep_looping + + def run(self): + """Main entry point into running the bot. + + Starts all config-enabled components and then enters an idle loop, + ensuring that all components remain online and restarting components + that get disconnected from their servers. + """ + self.logger.info("Starting bot (EarwigBot {0})".format(__version__)) + self._start_irc_components() + self._start_wiki_scheduler() + while self._keep_looping: + with self.component_lock: + self._keep_irc_component_alive("frontend", Frontend) + self._keep_irc_component_alive("watcher", Watcher) + sleep(2) + + def restart(self, msg=None): + """Reload config, commands, tasks, and safely restart IRC components. + + This is thread-safe, and it will gracefully stop IRC components before + reloading anything. Note that you can safely reload commands or tasks + without restarting the bot with :py:meth:`bot.commands.load() + ` or + :py:meth:`bot.tasks.load() `. + These should not interfere with running components or tasks. + + If given, *msg* will be used as our quit message. + """ + if msg: + self.logger.info('Restarting bot ("{0}")'.format(msg)) + else: + self.logger.info("Restarting bot") + with self.component_lock: + self._stop_irc_components(msg) + self.config.load() + self.commands.load() + self.tasks.load() + self._start_irc_components() + + def stop(self, msg=None): + """Gracefully stop all bot components. + + If given, *msg* will be used as our quit message. + """ + if msg: + self.logger.info('Stopping bot ("{0}")'.format(msg)) + else: + self.logger.info("Stopping bot") + with self.component_lock: + self._stop_irc_components(msg) + self._keep_looping = False + self._stop_daemon_threads() diff --git a/earwigbot/commands/__init__.py b/earwigbot/commands/__init__.py new file mode 100644 index 0000000..ecb299c --- /dev/null +++ b/earwigbot/commands/__init__.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +__all__ = ["Command"] + +class Command(object): + """ + **EarwigBot: Base IRC Command** + + This package provides built-in IRC "commands" used by the bot's front-end + component. Additional commands can be installed as plugins in the bot's + working directory. + + 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 + "``. + """ + # The command's name, as reported to the user when they use !help: + name = None + + # A list of names that will trigger this command. If left empty, it will + # be triggered by the command's name and its name only: + commands = [] + + # Hooks are "msg", "msg_private", "msg_public", and "join". "msg" is the + # default behavior; if you wish to override that, change the value in your + # command subclass: + hooks = ["msg"] + + def __init__(self, bot): + """Constructor for new commands. + + This is called once when the command is loaded (from + :py:meth:`commands.load() `). + *bot* is out base :py:class:`~earwigbot.bot.Bot` object. Don't override + this directly; if you do, remember to place + ``super(Command, self).__init()`` first. Use :py:meth:`setup` for + typical command-init/setup needs. + """ + self.bot = bot + self.config = bot.config + self.logger = bot.commands.logger.getChild(self.name) + + # Convenience functions: + self.say = lambda target, msg, hidelog=False: self.bot.frontend.say(target, msg, hidelog) + self.reply = lambda data, msg, hidelog=False: self.bot.frontend.reply(data, msg, hidelog) + self.action = lambda target, msg, hidelog=False: self.bot.frontend.action(target, msg, hidelog) + self.notice = lambda target, msg, hidelog=False: self.bot.frontend.notice(target, msg, hidelog) + self.join = lambda chan, hidelog=False: self.bot.frontend.join(chan, hidelog) + self.part = lambda chan, msg=None, hidelog=False: self.bot.frontend.part(chan, msg, hidelog) + self.mode = lambda t, level, msg, hidelog=False: self.bot.frontend.mode(t, level, msg, hidelog) + self.ping = lambda target, hidelog=False: self.bot.frontend.ping(target, hidelog) + self.pong = lambda target, hidelog=False: self.bot.frontend.pong(target, hidelog) + + self.setup() + + def __repr__(self): + """Return the canonical string representation of the Command.""" + res = "Command(name={0!r}, commands={1!r}, hooks={2!r}, bot={3!r})" + return res.format(self.name, self.commands, self.hooks, self.bot) + + def __str__(self): + """Return a nice string representation of the Command.""" + return "".format(self.name, self.bot) + + def setup(self): + """Hook called immediately after the command is loaded. + + Does nothing by default; feel free to override. + """ + pass + + def check(self, data): + """Return whether this command should be called in response to *data*. + + Given a :py:class:`~earwigbot.irc.data.Data` instance, return ``True`` + if we should respond to this activity, or ``False`` if we should ignore + it and move on. Be aware that since this is called for each message + sent on IRC, it should be cheap to execute and unlikely to throw + exceptions. + + Most commands return ``True`` only if :py:attr:`data.command + ` ``==`` :py:attr:`self.name `, + or :py:attr:`data.command ` is in + :py:attr:`self.commands ` if that list is overriden. This is + the default behavior; you should only override it if you wish to change + that. + """ + if self.commands: + return data.is_command and data.command in self.commands + return data.is_command and data.command == self.name + + def process(self, data): + """Main entry point for doing a command. + + Handle an activity (usually a message) on IRC. At this point, thanks + to :py:meth:`check` which is called automatically by the command + handler, we know this is something we should respond to. Place your + command's body here. + """ + pass diff --git a/earwigbot/commands/access.py b/earwigbot/commands/access.py new file mode 100644 index 0000000..1132348 --- /dev/null +++ b/earwigbot/commands/access.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import re + +from earwigbot.commands import Command + +class Access(Command): + """Control and get info on who can access the bot.""" + name = "access" + commands = ["access", "permission", "permissions", "perm", "perms"] + + def process(self, data): + if not data.args: + self.reply(data, "Subcommands are self, list, add, remove.") + return + permdb = self.config.irc["permissions"] + if data.args[0] == "self": + self.do_self(data, permdb) + elif data.args[0] == "list": + self.do_list(data, permdb) + elif data.args[0] == "add": + self.do_add(data, permdb) + elif data.args[0] == "remove": + self.do_remove(data, permdb) + else: + msg = "Unknown subcommand \x0303{0}\x0F.".format(data.args[0]) + self.reply(data, msg) + + def do_self(self, data, permdb): + if permdb.is_owner(data): + msg = "You are a bot owner (matching rule \x0302{0}\x0F)." + self.reply(data, msg.format(permdb.is_owner(data))) + elif permdb.is_admin(data): + msg = "You are a bot admin (matching rule \x0302{0}\x0F)." + self.reply(data, msg.format(permdb.is_admin(data))) + else: + self.reply(data, "You do not match any bot access rules.") + + def do_list(self, data, permdb): + if len(data.args) > 1: + if data.args[1] in ["owner", "owners"]: + name, rules = "owners", permdb.data.get(permdb.OWNER) + elif data.args[1] in ["admin", "admins"]: + name, rules = "admins", permdb.data.get(permdb.ADMIN) + else: + msg = "Unknown access level \x0302{0}\x0F." + self.reply(data, msg.format(data.args[1])) + return + if rules: + msg = "Bot {0}: {1}.".format(name, ", ".join(map(str, rules))) + else: + msg = "No bot {0}.".format(name) + self.reply(data, msg) + else: + owners = len(permdb.data.get(permdb.OWNER, [])) + admins = len(permdb.data.get(permdb.ADMIN, [])) + msg = "There are {0} bot owners and {1} bot admins. Use '!{2} list owners' or '!{2} list admins' for details." + self.reply(data, msg.format(owners, admins, data.command)) + + def do_add(self, data, permdb): + user = self.get_user_from_args(data, permdb) + if user: + nick, ident, host = user + if data.args[1] in ["owner", "owners"]: + name, level, adder = "owner", permdb.OWNER, permdb.add_owner + else: + name, level, adder = "admin", permdb.ADMIN, permdb.add_admin + if permdb.has_exact(level, nick, ident, host): + rule = "{0}!{1}@{2}".format(nick, ident, host) + msg = "\x0302{0}\x0F is already a bot {1}.".format(rule, name) + self.reply(data, msg) + else: + rule = adder(nick, ident, host) + msg = "Added bot {0} \x0302{1}\x0F.".format(name, rule) + self.reply(data, msg) + + def do_remove(self, data, permdb): + user = self.get_user_from_args(data, permdb) + if user: + nick, ident, host = user + if data.args[1] in ["owner", "owners"]: + name, rmver = "owner", permdb.remove_owner + else: + name, rmver = "admin", permdb.remove_admin + rule = rmver(nick, ident, host) + if rule: + msg = "Removed bot {0} \x0302{1}\x0F.".format(name, rule) + self.reply(data, msg) + else: + rule = "{0}!{1}@{2}".format(nick, ident, host) + msg = "No bot {0} matching \x0302{1}\x0F.".format(name, rule) + self.reply(data, msg) + + def get_user_from_args(self, data, permdb): + if not permdb.is_owner(data): + msg = "You must be a bot owner to add users to the access list." + self.reply(data, msg) + return + levels = ["owner", "owners", "admin", "admins"] + if len(data.args) == 1 or data.args[1] not in levels: + msg = "Please specify an access level ('owners' or 'admins')." + self.reply(data, msg) + return + if len(data.args) == 2: + self.no_arg_error(data) + return + kwargs = data.kwargs + if "nick" in kwargs or "ident" in kwargs or "host" in kwargs: + nick = kwargs.get("nick", "*") + ident = kwargs.get("ident", "*") + host = kwargs.get("host", "*") + return nick, ident, host + user = re.match(r"(.*?)!(.*?)@(.*?)$", data.args[2]) + if not user: + self.no_arg_error(data) + return + return user.group(1), user.group(2), user.group(3) + + def no_arg_error(self, data): + msg = 'Please specify a user, either as "\x0302nick\x0F!\x0302ident\x0F@\x0302host\x0F"' + msg += ' or "nick=\x0302nick\x0F, ident=\x0302ident\x0F, host=\x0302host\x0F".' + self.reply(data, msg) diff --git a/earwigbot/commands/calc.py b/earwigbot/commands/calc.py new file mode 100644 index 0000000..de16202 --- /dev/null +++ b/earwigbot/commands/calc.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import re +import urllib + +from earwigbot.commands import Command + +class Calc(Command): + """A somewhat advanced calculator: see http://futureboy.us/fsp/frink.fsp + for details.""" + name = "calc" + + def process(self, data): + if not data.args: + self.reply(data, "What do you want me to calculate?") + return + + query = ' '.join(data.args) + query = self.cleanup(query) + + url = "http://futureboy.us/fsp/frink.fsp?fromVal={0}" + url = url.format(urllib.quote(query)) + result = urllib.urlopen(url).read() + + r_result = re.compile(r'(?i)(.*?)') + r_tag = re.compile(r'<\S+.*?>') + + match = r_result.search(result) + if not match: + self.reply(data, "Calculation error.") + return + + result = match.group(1) + result = r_tag.sub("", result) # strip span.warning tags + result = result.replace(">", ">") + result = result.replace("(undefined symbol)", "(?) ") + result = result.strip() + + if not result: + result = '?' + elif " in " in query: + result += " " + query.split(" in ", 1)[1] + + res = "%s = %s" % (query, result) + self.reply(data, res) + + def cleanup(self, query): + fixes = [ + (' in ', ' -> '), + (' over ', ' / '), + (u'£', 'GBP '), + (u'€', 'EUR '), + ('\$', 'USD '), + (r'\bKB\b', 'kilobytes'), + (r'\bMB\b', 'megabytes'), + (r'\bGB\b', 'kilobytes'), + ('kbps', '(kilobits / second)'), + ('mbps', '(megabits / second)') + ] + + for original, fix in fixes: + query = re.sub(original, fix, query) + return query.strip() diff --git a/earwigbot/commands/chanops.py b/earwigbot/commands/chanops.py new file mode 100644 index 0000000..ea54500 --- /dev/null +++ b/earwigbot/commands/chanops.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from earwigbot.commands import Command + +class ChanOps(Command): + """Voice, devoice, op, or deop users in the channel, or join or part from + other channels.""" + name = "chanops" + commands = ["chanops", "voice", "devoice", "op", "deop", "join", "part"] + + def process(self, data): + if data.command == "chanops": + msg = "Available commands are !voice, !devoice, !op, !deop, !join, and !part." + self.reply(data, msg) + return + de_escalate = data.command in ["devoice", "deop"] + if de_escalate and (not data.args or data.args[0] == data.nick): + target = data.nick + elif not self.config.irc["permissions"].is_admin(data): + self.reply(data, "You must be a bot admin to use this command.") + return + + if data.command == "join": + self.do_join(data) + elif data.command == "part": + self.do_part(data) + else: + # If it is just !op/!devoice/whatever without arguments, assume + # they want to do this to themselves: + if not data.args: + target = data.nick + else: + target = data.args[0] + command = data.command.upper() + self.say("ChanServ", " ".join((command, data.chan, target))) + log = "{0} requested {1} on {2} in {3}" + self.logger.info(log.format(data.nick, command, target, data.chan)) + + def do_join(self, data): + if data.args: + channel = data.args[0] + if not channel.startswith("#"): + channel = "#" + channel + else: + msg = "You must specify a channel to join or part from." + self.reply(data, msg) + return + + self.join(channel) + log = "{0} requested JOIN to {1}".format(data.nick, channel) + self.logger.info(log) + + def do_part(self, data): + channel = data.chan + reason = None + if data.args: + if data.args[0].startswith("#"): + # "!part #channel reason for parting" + channel = data.args[0] + if data.args[1:]: + reason = " ".join(data.args[1:]) + else: # "!part reason for parting"; assume current channel + reason = " ".join(data.args) + + msg = "Requested by {0}".format(data.nick) + log = "{0} requested PART from {1}".format(data.nick, channel) + if reason: + msg += ": {0}".format(reason) + log += ' ("{0}")'.format(reason) + self.part(channel, msg) + self.logger.info(log) diff --git a/earwigbot/commands/crypt.py b/earwigbot/commands/crypt.py new file mode 100644 index 0000000..0123be1 --- /dev/null +++ b/earwigbot/commands/crypt.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import hashlib + +from Crypto.Cipher import Blowfish + +from earwigbot.commands import Command + +class Crypt(Command): + """Provides hash functions with !hash (!hash list for supported algorithms) + and Blowfish encryption with !encrypt and !decrypt.""" + name = "crypt" + commands = ["crypt", "hash", "encrypt", "decrypt"] + + def process(self, data): + if data.command == "crypt": + msg = "Available commands are !hash, !encrypt, and !decrypt." + self.reply(data, msg) + return + + if not data.args: + msg = "What do you want me to {0}?".format(data.command) + self.reply(data, msg) + return + + if data.command == "hash": + algo = data.args[0] + if algo == "list": + algos = ', '.join(hashlib.algorithms) + msg = algos.join(("Supported algorithms: ", ".")) + self.reply(data, msg) + elif algo in hashlib.algorithms: + string = ' '.join(data.args[1:]) + result = getattr(hashlib, algo)(string).hexdigest() + self.reply(data, result) + else: + msg = "Unknown algorithm: '{0}'.".format(algo) + self.reply(data, msg) + + else: + key = data.args[0] + text = " ".join(data.args[1:]) + + if not text: + msg = "A key was provided, but text to {0} was not." + self.reply(data, msg.format(data.command)) + return + + cipher = Blowfish.new(hashlib.sha256(key).digest()) + try: + if data.command == "encrypt": + if len(text) % 8: + pad = 8 - len(text) % 8 + text = text.ljust(len(text) + pad, "\x00") + self.reply(data, cipher.encrypt(text).encode("hex")) + else: + self.reply(data, cipher.decrypt(text.decode("hex"))) + except ValueError as error: + self.reply(data, error.message) diff --git a/earwigbot/commands/ctcp.py b/earwigbot/commands/ctcp.py new file mode 100644 index 0000000..0ed6412 --- /dev/null +++ b/earwigbot/commands/ctcp.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import platform +import time + +from earwigbot import __version__ +from earwigbot.commands import Command + +class CTCP(Command): + """Not an actual command; this module implements responses to the CTCP + requests PING, TIME, and VERSION.""" + name = "ctcp" + hooks = ["msg_private"] + + def check(self, data): + if data.is_command and data.command == "ctcp": + return True + + commands = ["PING", "TIME", "VERSION"] + msg = data.line[3] + if msg[:2] == ":\x01" and msg[2:].rstrip("\x01") in commands: + return True + return False + + def process(self, data): + if data.is_command: + return + + target = data.nick + command = data.line[3][1:].strip("\x01") + + if command == "PING": + msg = " ".join(data.line[4:]) + if msg: + self.notice(target, "\x01PING {0}\x01".format(msg)) + else: + self.notice(target, "\x01PING\x01") + + elif command == "TIME": + ts = time.strftime("%a, %d %b %Y %H:%M:%S %Z", time.localtime()) + self.notice(target, "\x01TIME {0}\x01".format(ts)) + + elif command == "VERSION": + default = "EarwigBot - $1 - Python/$2 https://github.com/earwig/earwigbot" + vers = self.config.irc.get("version", default) + vers = vers.replace("$1", __version__) + vers = vers.replace("$2", platform.python_version()) + self.notice(target, "\x01VERSION {0}\x01".format(vers)) diff --git a/earwigbot/commands/dictionary.py b/earwigbot/commands/dictionary.py new file mode 100644 index 0000000..407f5f3 --- /dev/null +++ b/earwigbot/commands/dictionary.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import re + +from earwigbot import exceptions +from earwigbot.commands import Command + +class Dictionary(Command): + """Define words and stuff.""" + name = "dictionary" + commands = ["dict", "dictionary", "define"] + + def process(self, data): + if not data.args: + self.reply(data, "What do you want me to define?") + return + + term = " ".join(data.args) + lang = self.bot.wiki.get_site().lang + try: + defined = self.define(term, lang) + except exceptions.APIError: + msg = "Cannot find a {0}-language Wiktionary." + self.reply(data, msg.format(lang)) + else: + self.reply(data, defined.encode("utf8")) + + def define(self, term, lang, tries=2): + try: + site = self.bot.wiki.get_site(project="wiktionary", lang=lang) + except exceptions.SiteNotFoundError: + site = self.bot.wiki.add_site(project="wiktionary", lang=lang) + + page = site.get_page(term, follow_redirects=True) + try: + entry = page.get() + except (exceptions.PageNotFoundError, exceptions.InvalidPageError): + if term.lower() != term and tries: + return self.define(term.lower(), lang, tries - 1) + if term.capitalize() != term and tries: + return self.define(term.capitalize(), lang, tries - 1) + return "No definition found." + + level, languages = self.get_languages(entry) + if not languages: + return u"Couldn't parse {0}!".format(page.url) + + result = [] + for lang, section in sorted(languages.items()): + definition = self.get_definition(section, level) + result.append(u"({0}) {1}".format(lang, definition)) + return u"; ".join(result) + + def get_languages(self, entry, level=2): + regex = r"(?:\A|\n)==\s*([a-zA-Z0-9_ ]*?)\s*==(?:\Z|\n)" + split = re.split(regex, entry) + if len(split) % 2 == 0: + if level == 2: + return self.get_languages(entry, level=3) + else: + return 3, None + return 2, None + + split.pop(0) + languages = {} + for i in xrange(0, len(split), 2): + languages[split[i]] = split[i + 1] + return level, languages + + def get_definition(self, section, level): + parts_of_speech = { + "v.": "Verb", + "n.": "Noun", + "pron.": "Pronoun", + "adj.": "Adjective", + "adv.": "Adverb", + "prep.": "Preposition", + "conj.": "Conjunction", + "inter.": "Interjection", + "symbol": "Symbol", + "suffix": "Suffix", + "initialism": "Initialism", + "phrase": "Phrase", + "proverb": "Proverb", + "prop. n.": "Proper noun", + "abbr.": "Abbreviation", + "punct.": "Punctuation mark", + } + blocks = "=" * (level + 1) + defs = [] + for part, basename in parts_of_speech.iteritems(): + fullnames = [basename, "\{\{" + basename + "\}\}", + "\{\{" + basename.lower() + "\}\}"] + for fullname in fullnames: + regex = blocks + "\s*" + fullname + "\s*" + blocks + if re.search(regex, section): + regex = blocks + "\s*" + fullname + regex += "\s*{0}(.*?)(?:(?:{0})|\Z)".format(blocks) + bodies = re.findall(regex, section, re.DOTALL) + if bodies: + for body in bodies: + definition = self.parse_body(body) + if definition: + msg = u"\x02{0}\x0F {1}" + defs.append(msg.format(part, definition)) + + return "; ".join(defs) + + def parse_body(self, body): + substitutions = [ + ("", ""), + ("(.*?)", ""), + ("\[\[[^\]|]*?\|([^\]|]*?)\]\]", r"\1"), + ("\{\{unsupported\|(.*?)\}\}", r"\1"), + ("\{\{(.*?) of\|([^}|]*?)(\|(.*?))?\}\}", r"\1 of \2."), + ("\{\{w\|(.*?)\}\}", r"\1"), + ("\{\{surname(.*?)\}\}", r"A surname."), + ("\{\{given name\|([^}|]*?)(\|(.*?))?\}\}", r"A \1 given name."), + ] + + senses = [] + for line in body.splitlines(): + line = line.strip() + if re.match("#\s*[^:*#]", line): + for regex, repl in substitutions: + line = re.sub(regex, repl, line) + line = self.strip_templates(line) + line = line[1:].replace("'''", "").replace("''", "") + line = line.replace("[[", "").replace("]]", "") + if line.strip(): + senses.append(line.strip()[0].upper() + line.strip()[1:]) + + if not senses: + return None + if len(senses) == 1: + return senses[0] + + result = [] # Number the senses incrementally + for i, sense in enumerate(senses): + result.append(u"{0}. {1}".format(i + 1, sense)) + return " ".join(result) + + def strip_templates(self, line): + line = list(line) + stripped = "" + depth = 0 + while line: + this = line.pop(0) + if line: + next = line[0] + else: + next = "" + if this == "{" and next == "{": + line.pop(0) + depth += 1 + elif this == "}" and next == "}": + line.pop(0) + depth -= 1 + elif depth == 0: + stripped += this + return stripped diff --git a/earwigbot/commands/editcount.py b/earwigbot/commands/editcount.py new file mode 100644 index 0000000..2adea14 --- /dev/null +++ b/earwigbot/commands/editcount.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from urllib import quote_plus + +from earwigbot import exceptions +from earwigbot.commands import Command + +class Editcount(Command): + """Return a user's edit count.""" + name = "editcount" + commands = ["ec", "editcount"] + + def process(self, data): + if not data.args: + name = data.nick + else: + name = ' '.join(data.args) + + site = self.bot.wiki.get_site() + user = site.get_user(name) + + try: + count = user.editcount + except exceptions.UserNotFoundError: + msg = "The user \x0302{0}\x0F does not exist." + self.reply(data, msg.format(name)) + return + + safe = quote_plus(user.name.encode("utf8")) + url = "http://toolserver.org/~tparis/pcount/index.php?name={0}&lang={1}&wiki={2}" + fullurl = url.format(safe, site.lang, site.project) + msg = "\x0302{0}\x0F has {1} edits ({2})." + self.reply(data, msg.format(name, count, fullurl)) diff --git a/earwigbot/commands/help.py b/earwigbot/commands/help.py new file mode 100644 index 0000000..7c9bd7f --- /dev/null +++ b/earwigbot/commands/help.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import re + +from earwigbot.commands import Command + +class Help(Command): + """Displays help information.""" + name = "help" + + def check(self, data): + if data.is_command: + if data.command == "help": + return True + if not data.command and data.trigger == data.my_nick: + return True + return False + + def process(self, data): + if not data.command: + self.do_hello(data) + elif data.args: + self.do_command_help(data) + else: + self.do_main_help(data) + + def do_main_help(self, data): + """Give the user a general help message with a list of all commands.""" + msg = "Hi, I'm a bot! I have {0} commands loaded: {1}. You can get help for any command with '!help '." + cmnds = sorted([cmnd.name for cmnd in self.bot.commands]) + msg = msg.format(len(cmnds), ', '.join(cmnds)) + self.reply(data, msg) + + def do_command_help(self, data): + """Give the user help for a specific command.""" + target = data.args[0] + + for command in self.bot.commands: + if command.name == target or target in command.commands: + if command.__doc__: + doc = command.__doc__.replace("\n", "") + doc = re.sub("\s\s+", " ", doc) + msg = 'Help for command \x0303{0}\x0F: "{1}"' + self.reply(data, msg.format(target, doc)) + return + + msg = "Sorry, no help for \x0303{0}\x0F.".format(target) + self.reply(data, msg) + + def do_hello(self, data): + self.say(data.chan, "Yes, {0}?".format(data.nick)) diff --git a/earwigbot/commands/lag.py b/earwigbot/commands/lag.py new file mode 100644 index 0000000..cee0ee1 --- /dev/null +++ b/earwigbot/commands/lag.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from earwigbot import exceptions +from earwigbot.commands import Command + +class Lag(Command): + """Return the replag for a specific database on the Toolserver.""" + name = "lag" + commands = ["lag", "replag", "maxlag"] + + def process(self, data): + site = self.get_site(data) + if not site: + return + if data.command == "replag": + base = "\x0302{0}\x0F: {1}." + msg = base.format(site.name, self.get_replag(site)) + elif data.command == "maxlag": + base = "\x0302{0}\x0F: {1}." + msg = base.format(site.name, self.get_maxlag(site).capitalize()) + else: + base = "\x0302{0}\x0F: {1}; {2}." + msg = base.format(site.name, self.get_replag(site), + self.get_maxlag(site)) + self.reply(data, msg) + + def get_replag(self, site): + return "Toolserver replag is {0}".format(self.time(site.get_replag())) + + def get_maxlag(self, site): + return "database maxlag is {0}".format(self.time(site.get_maxlag())) + + def get_site(self, data): + if data.kwargs and "project" in data.kwargs and "lang" in data.kwargs: + project, lang = data.kwargs["project"], data.kwargs["lang"] + return self.get_site_from_proj_and_lang(data, project, lang) + + if not data.args: + return self.bot.wiki.get_site() + + if len(data.args) > 1: + name = " ".join(data.args) + self.reply(data, "Unknown site: \x0302{0}\x0F.".format(name)) + return + name = data.args[0] + if "." in name: + lang, project = name.split(".")[:2] + elif ":" in name: + project, lang = name.split(":")[:2] + else: + try: + return self.bot.wiki.get_site(name) + except exceptions.SiteNotFoundError: + msg = "Unknown site: \x0302{0}\x0F.".format(name) + self.reply(data, msg) + return + return self.get_site_from_proj_and_lang(data, project, lang) + + def get_site_from_proj_and_lang(self, data, project, lang): + try: + site = self.bot.wiki.get_site(project=project, lang=lang) + except exceptions.SiteNotFoundError: + try: + site = self.bot.wiki.add_site(project=project, lang=lang) + except exceptions.APIError: + msg = "Site \x0302{0}:{1}\x0F not found." + self.reply(data, msg.format(project, lang)) + return + return site + + def time(self, seconds): + parts = [("year", 31536000), ("day", 86400), ("hour", 3600), + ("minute", 60), ("second", 1)] + msg = [] + for name, size in parts: + num = seconds / size + seconds -= num * size + if num: + chunk = "{0} {1}".format(num, name if num == 1 else name + "s") + msg.append(chunk) + return ", ".join(msg) if msg else "0 seconds" diff --git a/earwigbot/commands/langcode.py b/earwigbot/commands/langcode.py new file mode 100644 index 0000000..860c32e --- /dev/null +++ b/earwigbot/commands/langcode.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from earwigbot.commands import Command + +class Langcode(Command): + """Convert a language code into its name and a list of WMF sites in that + language, or a name into its code.""" + name = "langcode" + commands = ["langcode", "lang", "language"] + + def process(self, data): + if not data.args: + self.reply(data, "Please specify a language code.") + return + + code, lcase = data.args[0], data.args[0].lower() + site = self.bot.wiki.get_site() + matrix = site.api_query(action="sitematrix")["sitematrix"] + del matrix["count"] + del matrix["specials"] + + for site in matrix.itervalues(): + if not site["name"]: + continue + name = site["name"].encode("utf8") + localname = site["localname"].encode("utf8") + if site["code"] == lcase: + if name != localname: + name += " ({0})".format(localname) + sites = ", ".join([s["url"] for s in site["site"]]) + msg = "\x0302{0}\x0F is {1} ({2})".format(code, name, sites) + self.reply(data, msg) + return + elif name.lower() == lcase or localname.lower() == lcase: + if name != localname: + name += " ({0})".format(localname) + sites = ", ".join([s["url"] for s in site["site"]]) + msg = "{0} is \x0302{1}\x0F ({2})" + self.reply(data, msg.format(name, site["code"], sites)) + return + + self.reply(data, "Language \x0302{0}\x0F not found.".format(code)) diff --git a/earwigbot/commands/link.py b/earwigbot/commands/link.py new file mode 100644 index 0000000..858ff35 --- /dev/null +++ b/earwigbot/commands/link.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import re + +from earwigbot.commands import Command + +class Link(Command): + """Convert a Wikipedia page name into a URL.""" + name = "link" + + def setup(self): + self.last = {} + + def check(self, data): + if re.search("(\[\[(.*?)\]\])|(\{\{(.*?)\}\})", data.msg): + self.last[data.chan] = data.msg # Store most recent link + return data.is_command and data.command == self.name + + def process(self, data): + self.site = self.bot.wiki.get_site() + + if re.search("(\[\[(.*?)\]\])|(\{\{(.*?)\}\})", data.msg): + links = u" , ".join(self.parse_line(data.msg)) + self.reply(data, links.encode("utf8")) + + elif data.command == "link": + if not data.args: + if data.chan in self.last: + links = u" , ".join(self.parse_line(self.last[data.chan])) + self.reply(data, links.encode("utf8")) + else: + self.reply(data, "What do you want me to link to?") + return + pagename = " ".join(data.args) + link = self.site.get_page(pagename).url.encode("utf8") + self.reply(data, link) + + def parse_line(self, line): + """Return a list of links within a line of text.""" + results = [] + + # Destroy {{{template parameters}}}: + line = re.sub("\{\{\{(.*?)\}\}\}", "", line) + + # Find all [[links]]: + links = re.findall("(\[\[(.*?)(\||\]\]))", line) + if links: + # re.findall() returns a list of tuples, but we only want the 2nd + # item in each tuple: + results = [self.site.get_page(name[1]).url for name in links] + + # Find all {{templates}} + templates = re.findall("(\{\{(.*?)(\||\}\}))", line) + if templates: + p_tmpl = lambda name: self.site.get_page("Template:" + name).url + templates = [p_tmpl(i[1]) for i in templates] + results += templates + + return results diff --git a/earwigbot/commands/notes.py b/earwigbot/commands/notes.py new file mode 100644 index 0000000..a430ae7 --- /dev/null +++ b/earwigbot/commands/notes.py @@ -0,0 +1,319 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from datetime import datetime +from os import path +import re +import sqlite3 as sqlite +from threading import Lock + +from earwigbot.commands import Command + +class Notes(Command): + """A mini IRC-based wiki for storing notes, tips, and reminders.""" + name = "notes" + commands = ["notes", "note", "about"] + version = 2 + + def setup(self): + self._dbfile = path.join(self.config.root_dir, "notes.db") + self._db_access_lock = Lock() + + def process(self, data): + commands = { + "help": self.do_help, + "list": self.do_list, + "read": self.do_read, + "edit": self.do_edit, + "info": self.do_info, + "rename": self.do_rename, + "delete": self.do_delete, + } + + if not data.args: + msg = "\x0302The Earwig Mini-Wiki\x0F: running v{0}. Subcommands are: {1}. You can get help on any with '!{2} help subcommand'." + cmnds = ", ".join((commands)) + self.reply(data, msg.format(self.version, cmnds, data.command)) + return + command = data.args[0].lower() + if command in commands: + commands[command](data) + else: + msg = "Unknown subcommand: \x0303{0}\x0F.".format(command) + self.reply(data, msg) + + def do_help(self, data): + """Get help on a subcommand.""" + info = { + "help": "Get help on other subcommands.", + "list": "List existing entries.", + "read": "Read an existing entry ('!notes read [name]').", + "edit": """Modify or create a new entry ('!notes edit name + [entry content]...'). If modifying, you must be the + entry author or a bot admin.""", + "info": """Get information on an existing entry ('!notes info + [name]').""", + "rename": """Rename an existing entry ('!notes rename [old_name] + [new_name]'). You must be the entry author or a bot + admin.""", + "delete": """Delete an existing entry ('!notes delete [name]'). You + must be the entry author or a bot admin.""", + } + + try: + command = data.args[1] + except IndexError: + self.reply(data, "Please specify a subcommand to get help on.") + return + try: + help_ = re.sub(r"\s\s+", " ", info[command].replace("\n", "")) + self.reply(data, "\x0303{0}\x0F: ".format(command) + help_) + except KeyError: + msg = "Unknown subcommand: \x0303{0}\x0F.".format(command) + self.reply(data, msg) + + def do_list(self, data): + """Show a list of entries in the notes database.""" + query = "SELECT entry_title FROM entries" + with sqlite.connect(self._dbfile) as conn, self._db_access_lock: + try: + entries = conn.execute(query).fetchall() + except sqlite.OperationalError: + entries = [] + + if entries: + entries = [entry[0] for entry in entries] + self.reply(data, "Entries: {0}".format(", ".join(entries))) + else: + self.reply(data, "No entries in the database.") + + def do_read(self, data): + """Read an entry from the notes database.""" + query = """SELECT entry_title, rev_content FROM entries + INNER JOIN revisions ON entry_revision = rev_id + WHERE entry_slug = ?""" + try: + slug = self.slugify(data.args[1]) + except IndexError: + self.reply(data, "Please specify an entry to read from.") + return + + with sqlite.connect(self._dbfile) as conn, self._db_access_lock: + try: + title, content = conn.execute(query, (slug,)).fetchone() + except (sqlite.OperationalError, TypeError): + title, content = slug, None + + if content: + self.reply(data, "\x0302{0}\x0F: {1}".format(title, content)) + else: + self.reply(data, "Entry \x0302{0}\x0F not found.".format(title)) + + def do_edit(self, data): + """Edit an entry in the notes database.""" + query1 = """SELECT entry_id, entry_title, user_host FROM entries + INNER JOIN revisions ON entry_revision = rev_id + INNER JOIN users ON rev_user = user_id + WHERE entry_slug = ?""" + query2 = "INSERT INTO revisions VALUES (?, ?, ?, ?, ?)" + query3 = "INSERT INTO entries VALUES (?, ?, ?, ?)" + query4 = "UPDATE entries SET entry_revision = ? WHERE entry_id = ?" + try: + slug = self.slugify(data.args[1]) + except IndexError: + self.reply(data, "Please specify an entry to edit.") + return + content = " ".join(data.args[2:]).strip() + if not content: + self.reply(data, "Please give some content to put in the entry.") + return + + with sqlite.connect(self._dbfile) as conn, self._db_access_lock: + create = True + try: + id_, title, author = conn.execute(query1, (slug,)).fetchone() + create = False + except sqlite.OperationalError: + id_, title, author = 1, data.args[1], data.host + self.create_db(conn) + except TypeError: + id_ = self.get_next_entry(conn) + title, author = data.args[1], data.host + permdb = self.config.irc["permissions"] + if author != data.host and not permdb.is_admin(data): + msg = "You must be an author or a bot admin to edit this entry." + self.reply(data, msg) + return + revid = self.get_next_revision(conn) + userid = self.get_user(conn, data.host) + now = datetime.utcnow().strftime("%b %d, %Y %H:%M:%S") + conn.execute(query2, (revid, id_, userid, now, content)) + if create: + conn.execute(query3, (id_, slug, title, revid)) + else: + conn.execute(query4, (revid, id_)) + + self.reply(data, "Entry \x0302{0}\x0F updated.".format(title)) + + def do_info(self, data): + """Get info on an entry in the notes database.""" + query = """SELECT entry_title, rev_timestamp, user_host FROM entries + INNER JOIN revisions ON entry_id = rev_entry + INNER JOIN users ON rev_user = user_id + WHERE entry_slug = ?""" + try: + slug = self.slugify(data.args[1]) + except IndexError: + self.reply(data, "Please specify an entry to get info on.") + return + + with sqlite.connect(self._dbfile) as conn, self._db_access_lock: + try: + info = conn.execute(query, (slug,)).fetchall() + except sqlite.OperationalError: + info = [] + + if info: + title = info[0][0] + times = [datum[1] for datum in info] + earliest = min(times) + msg = "\x0302{0}\x0F: {1} edits since {2}" + msg = msg.format(title, len(info), earliest) + if len(times) > 1: + latest = max(times) + msg += "; last edit on {0}".format(latest) + names = [datum[2] for datum in info] + msg += "; authors: {0}.".format(", ".join(list(set(names)))) + self.reply(data, msg) + else: + title = data.args[1] + self.reply(data, "Entry \x0302{0}\x0F not found.".format(title)) + + def do_rename(self, data): + """Rename an entry in the notes database.""" + query1 = """SELECT entry_id, user_host FROM entries + INNER JOIN revisions ON entry_revision = rev_id + INNER JOIN users ON rev_user = user_id + WHERE entry_slug = ?""" + query2 = """UPDATE entries SET entry_slug = ?, entry_title = ? + WHERE entry_id = ?""" + try: + slug = self.slugify(data.args[1]) + except IndexError: + self.reply(data, "Please specify an entry to rename.") + return + try: + newtitle = data.args[2] + except IndexError: + self.reply(data, "Please specify a new name for the entry.") + return + if newtitle == data.args[1]: + self.reply(data, "The old and new names are identical.") + return + + with sqlite.connect(self._dbfile) as conn, self._db_access_lock: + try: + id_, author = conn.execute(query1, (slug,)).fetchone() + except (sqlite.OperationalError, TypeError): + msg = "Entry \x0302{0}\x0F not found.".format(data.args[1]) + self.reply(data, msg) + return + permdb = self.config.irc["permissions"] + if author != data.host and not permdb.is_admin(data): + msg = "You must be an author or a bot admin to rename this entry." + self.reply(data, msg) + return + conn.execute(query2, (self.slugify(newtitle), newtitle, id_)) + + msg = "Entry \x0302{0}\x0F renamed to \x0302{1}\x0F." + self.reply(data, msg.format(data.args[1], newtitle)) + + def do_delete(self, data): + """Delete an entry from the notes database.""" + query1 = """SELECT entry_id, user_host FROM entries + INNER JOIN revisions ON entry_revision = rev_id + INNER JOIN users ON rev_user = user_id + WHERE entry_slug = ?""" + query2 = "DELETE FROM entries WHERE entry_id = ?" + query3 = "DELETE FROM revisions WHERE rev_entry = ?" + try: + slug = self.slugify(data.args[1]) + except IndexError: + self.reply(data, "Please specify an entry to delete.") + return + + with sqlite.connect(self._dbfile) as conn, self._db_access_lock: + try: + id_, author = conn.execute(query1, (slug,)).fetchone() + except (sqlite.OperationalError, TypeError): + msg = "Entry \x0302{0}\x0F not found.".format(data.args[1]) + self.reply(data, msg) + return + permdb = self.config.irc["permissions"] + if author != data.host and not permdb.is_admin(data): + msg = "You must be an author or a bot admin to delete this entry." + self.reply(data, msg) + return + conn.execute(query2, (id_,)) + conn.execute(query3, (id_,)) + + self.reply(data, "Entry \x0302{0}\x0F deleted.".format(data.args[1])) + + def slugify(self, name): + """Convert *name* into an identifier for storing in the database.""" + return name.lower().replace("_", "").replace("-", "") + + def create_db(self, conn): + """Initialize the notes database with its necessary tables.""" + script = """ + CREATE TABLE entries (entry_id, entry_slug, entry_title, + entry_revision); + CREATE TABLE users (user_id, user_host); + CREATE TABLE revisions (rev_id, rev_entry, rev_user, rev_timestamp, + rev_content); + """ + conn.executescript(script) + + def get_next_entry(self, conn): + """Get the next entry ID.""" + query = "SELECT MAX(entry_id) FROM entries" + later = conn.execute(query).fetchone()[0] + return later + 1 if later else 1 + + def get_next_revision(self, conn): + """Get the next revision ID.""" + query = "SELECT MAX(rev_id) FROM revisions" + later = conn.execute(query).fetchone()[0] + return later + 1 if later else 1 + + def get_user(self, conn, host): + """Get the user ID corresponding to a hostname, or make one.""" + query1 = "SELECT user_id FROM users WHERE user_host = ?" + query2 = "SELECT MAX(user_id) FROM users" + query3 = "INSERT INTO users VALUES (?, ?)" + user = conn.execute(query1, (host,)).fetchone() + if user: + return user[0] + last = conn.execute(query2).fetchone()[0] + later = last + 1 if last else 1 + conn.execute(query3, (later, host)) + return later diff --git a/earwigbot/commands/quit.py b/earwigbot/commands/quit.py new file mode 100644 index 0000000..0331d08 --- /dev/null +++ b/earwigbot/commands/quit.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from earwigbot.commands import Command + +class Quit(Command): + """Quit, restart, or reload components from the bot. Only the owners can + run this command.""" + name = "quit" + commands = ["quit", "restart", "reload"] + + def process(self, data): + if not self.config.irc["permissions"].is_owner(data): + self.reply(data, "You must be a bot owner to use this command.") + return + if data.command == "quit": + self.do_quit(data) + elif data.command == "restart": + self.do_restart(data) + else: + self.do_reload(data) + + def do_quit(self, data): + args = data.args + if data.trigger == data.my_nick: + reason = " ".join(args) + else: + if not args or args[0].lower() != data.my_nick: + self.reply(data, "To confirm this action, the first argument must be my name.") + return + reason = " ".join(args[1:]) + + if reason: + self.bot.stop("Stopped by {0}: {1}".format(data.nick, reason)) + else: + self.bot.stop("Stopped by {0}".format(data.nick)) + + def do_restart(self, data): + if data.args: + msg = " ".join(data.args) + self.bot.restart("Restarted by {0}: {1}".format(data.nick, msg)) + else: + self.bot.restart("Restarted by {0}".format(data.nick)) + + def do_reload(self, data): + self.logger.info("{0} requested command/task reload".format(data.nick)) + self.bot.commands.load() + self.bot.tasks.load() + self.reply(data, "IRC commands and bot tasks reloaded.") diff --git a/earwigbot/commands/registration.py b/earwigbot/commands/registration.py new file mode 100644 index 0000000..74d2ce0 --- /dev/null +++ b/earwigbot/commands/registration.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import time + +from earwigbot import exceptions +from earwigbot.commands import Command + +class Registration(Command): + """Return when a user registered.""" + name = "registration" + commands = ["registration", "reg", "age"] + + def process(self, data): + if not data.args: + name = data.nick + else: + name = ' '.join(data.args) + + site = self.bot.wiki.get_site() + user = site.get_user(name) + + try: + reg = user.registration + except exceptions.UserNotFoundError: + msg = "The user \x0302{0}\x0F does not exist." + self.reply(data, msg.format(name)) + return + + date = time.strftime("%b %d, %Y at %H:%M:%S UTC", reg) + age = self.get_diff(time.mktime(reg), time.mktime(time.gmtime())) + + if user.gender == "male": + gender = "He's" + elif user.gender == "female": + gender = "She's" + else: + gender = "They're" # Singular they? + + msg = "\x0302{0}\x0F registered on {1}. {2} {3} old." + self.reply(data, msg.format(name, date, gender, age)) + + def get_diff(self, t1, t2): + parts = [("year", 31536000), ("day", 86400), ("hour", 3600), + ("minute", 60), ("second", 1)] + msg = [] + for name, size in parts: + num = int(t2 - t1) / size + t1 += num * size + if num: + chunk = "{0} {1}".format(num, name if num == 1 else name + "s") + msg.append(chunk) + return ", ".join(msg) if msg else "0 seconds" diff --git a/earwigbot/commands/remind.py b/earwigbot/commands/remind.py new file mode 100644 index 0000000..e8470cb --- /dev/null +++ b/earwigbot/commands/remind.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2009-2012 Ben Kurtovic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from threading import Timer +import time + +from earwigbot.commands import Command + +class Remind(Command): + """Set a message to be repeated to you in a certain amount of time.""" + name = "remind" + commands = ["remind", "reminder"] + + def process(self, data): + if not data.args: + msg = "Please specify a time (in seconds) and a message in the following format: !remind