Browse Source

Merge branch 'develop' into feature/documentation

tags/v0.1^2
Ben Kurtovic 12 years ago
parent
commit
f7f490702b
55 changed files with 1680 additions and 1185 deletions
  1. +1
    -17
      .gitignore
  2. +0
    -70
      bot.py
  3. +21
    -5
      earwigbot/__init__.py
  4. +188
    -0
      earwigbot/bot.py
  5. +45
    -99
      earwigbot/commands/__init__.py
  6. +10
    -10
      earwigbot/commands/afc_report.py
  7. +11
    -13
      earwigbot/commands/afc_status.py
  8. +3
    -3
      earwigbot/commands/calc.py
  9. +55
    -17
      earwigbot/commands/chanops.py
  10. +9
    -9
      earwigbot/commands/crypt.py
  11. +7
    -8
      earwigbot/commands/ctcp.py
  12. +3
    -3
      earwigbot/commands/editcount.py
  13. +20
    -21
      earwigbot/commands/git.py
  14. +8
    -8
      earwigbot/commands/help.py
  15. +3
    -3
      earwigbot/commands/link.py
  16. +2
    -2
      earwigbot/commands/praise.py
  17. +67
    -0
      earwigbot/commands/quit.py
  18. +4
    -4
      earwigbot/commands/registration.py
  19. +5
    -5
      earwigbot/commands/remind.py
  20. +1
    -1
      earwigbot/commands/replag.py
  21. +3
    -3
      earwigbot/commands/rights.py
  22. +3
    -2
      earwigbot/commands/test.py
  23. +19
    -35
      earwigbot/commands/threads.py
  24. +147
    -112
      earwigbot/config.py
  25. +38
    -19
      earwigbot/irc/connection.py
  26. +19
    -22
      earwigbot/irc/frontend.py
  27. +26
    -23
      earwigbot/irc/watcher.py
  28. +0
    -132
      earwigbot/main.py
  29. +216
    -0
      earwigbot/managers.py
  30. +0
    -65
      earwigbot/runner.py
  31. +24
    -126
      earwigbot/tasks/__init__.py
  32. +3
    -1
      earwigbot/tasks/afc_catdelink.py
  33. +5
    -5
      earwigbot/tasks/afc_copyvios.py
  34. +3
    -1
      earwigbot/tasks/afc_dailycats.py
  35. +22
    -21
      earwigbot/tasks/afc_history.py
  36. +35
    -34
      earwigbot/tasks/afc_statistics.py
  37. +3
    -1
      earwigbot/tasks/afc_undated.py
  38. +3
    -1
      earwigbot/tasks/blptag.py
  39. +3
    -1
      earwigbot/tasks/feed_dailycats.py
  40. +10
    -12
      earwigbot/tasks/wikiproject_tagger.py
  41. +3
    -1
      earwigbot/tasks/wrongmime.py
  42. +88
    -0
      earwigbot/util.py
  43. +11
    -7
      earwigbot/wiki/__init__.py
  44. +2
    -0
      earwigbot/wiki/category.py
  45. +4
    -1
      earwigbot/wiki/constants.py
  46. +0
    -211
      earwigbot/wiki/functions.py
  47. +5
    -3
      earwigbot/wiki/page.py
  48. +42
    -32
      earwigbot/wiki/site.py
  49. +363
    -0
      earwigbot/wiki/sitesdb.py
  50. +2
    -0
      earwigbot/wiki/user.py
  51. +61
    -0
      setup.py
  52. +50
    -12
      tests/__init__.py
  53. +0
    -0
      tests/test_blowfish.py
  54. +1
    -1
      tests/test_calc.py
  55. +3
    -3
      tests/test_test.py

+ 1
- 17
.gitignore View File

@@ -1,19 +1,3 @@
# Ignore python bytecode:
*.pyc

# Ignore bot-specific config file:
config.yml

# Ignore logs directory:
logs/

# Ignore cookies file:
.cookies

# Ignore OS X's crud:
*.egg-info
.DS_Store

# Ignore pydev's nonsense:
.project
.pydevproject
.settings/

+ 0
- 70
bot.py View File

@@ -1,70 +0,0 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

"""
EarwigBot

This is a thin wrapper for EarwigBot's main bot code, specified by bot_script.
The wrapper will automatically restart the bot when it shuts down (from
!restart, for example). It requests the bot's password at startup and reuses it
every time the bot restarts internally, so you do not need to re-enter the
password after using !restart.

For information about the bot as a whole, see the attached README.md file (in
markdown format!), the docs/ directory, and the LICENSE file for licensing
information. EarwigBot is released under the MIT license.
"""
from getpass import getpass
from subprocess import Popen, PIPE
from os import path
from sys import executable
from time import sleep

import earwigbot

bot_script = path.join(earwigbot.__path__[0], "runner.py")

def main():
print "EarwigBot v{0}\n".format(earwigbot.__version__)

is_encrypted = earwigbot.config.config.load()
if is_encrypted: # Passwords in the config file are encrypted
key = getpass("Enter key to unencrypt bot passwords: ")
else:
key = None

while 1:
bot = Popen([executable, bot_script], stdin=PIPE)
print >> bot.stdin, path.dirname(path.abspath(__file__))
if is_encrypted:
print >> bot.stdin, key
return_code = bot.wait()
if return_code == 1:
exit() # Let critical exceptions in the subprocess cause us to
# exit as well
else:
sleep(5) # Sleep between bot runs following a non-critical
# subprocess exit

if __name__ == "__main__":
main()

+ 21
- 5
earwigbot/__init__.py View File

@@ -21,16 +21,32 @@
# SOFTWARE.

"""
EarwigBot - http://earwig.github.com/earwig/earwigbot
EarwigBot is a Python robot that edits Wikipedia and interacts with people over
IRC. - http://earwig.github.com/earwig/earwigbot

See README.md for a basic overview, or the docs/ directory for details.
"""

__author__ = "Ben Kurtovic"
__copyright__ = "Copyright (C) 2009, 2010, 2011 by Ben Kurtovic"
__copyright__ = "Copyright (C) 2009, 2010, 2011, 2012 by Ben Kurtovic"
__license__ = "MIT License"
__version__ = "0.1.dev"
__email__ = "ben.kurtovic@verizon.net"
__release__ = False

if not __release__:
def _add_git_commit_id_to_version(version):
from git import Repo
from os.path import split, dirname
path = split(dirname(__file__))[0]
commit_id = Repo(path).head.object.hexsha
return version + ".git+" + commit_id[:8]
try:
__version__ = _add_git_commit_id_to_version(__version__)
except Exception:
pass
finally:
del _add_git_commit_id_to_version

from earwigbot import (
blowfish, commands, config, irc, main, runner, tasks, tests, wiki
)
from earwigbot import (blowfish, bot, commands, config, irc, managers, tasks,
util, wiki)

+ 188
- 0
earwigbot/bot.py View File

@@ -0,0 +1,188 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

import logging
from threading import Lock, Thread, enumerate as enumerate_threads
from time import sleep, time

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):
"""
The 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 Bot() object is accessable from within commands and tasks as self.bot.
This is the primary way to access data from other components of the bot.
For example, our BotConfig object is accessable from bot.config, tasks
can be started with bot.tasks.start(), and sites can be loaded from the
wiki toolset with bot.wiki.get_site().
"""

def __init__(self, root_dir, level=logging.INFO):
self.config = BotConfig(root_dir, level)
self.logger = logging.getLogger("earwigbot")
self.commands = CommandManager(self)
self.tasks = TaskManager(self)
self.wiki = SitesDB(self.config)
self.frontend = None
self.watcher = None

self.component_lock = Lock()
self._keep_looping = True

self.config.load()
self.commands.load()
self.tasks.load()

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.frontend = Frontend(self)
Thread(name="irc_frontend", target=self.frontend.loop).start()

if self.config.components.get("irc_watcher"):
self.logger.info("Starting IRC watcher")
self.watcher = Watcher(self)
Thread(name="irc_watcher", target=self.watcher.loop).start()

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 _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_task_threads(self):
"""Notify the user of which task threads are going to be killed.

Unfortunately, there is no method right now of stopping 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 tasks are given no
advance warning of their forced shutdown.
"""
tasks = []
non_tasks = self.config.components.keys() + ["MainThread", "reminder"]
for thread in enumerate_threads():
if thread.name not in non_tasks and thread.is_alive():
tasks.append(thread.name)
if tasks:
log = "The following tasks will be killed: {0}"
self.logger.warn(log.format(" ".join(tasks)))

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")
self._start_irc_components()
self._start_wiki_scheduler()
while self._keep_looping:
with self.component_lock:
if self.frontend and self.frontend.is_stopped():
self.logger.warn("IRC frontend has stopped; restarting")
self.frontend = Frontend(self)
Thread(name=name, target=self.frontend.loop).start()
if self.watcher and self.watcher.is_stopped():
self.logger.warn("IRC watcher has stopped; restarting")
self.watcher = Watcher(self)
Thread(name=name, target=self.watcher.loop).start()
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 bot.commands.load() or
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_task_threads()

+ 45
- 99
earwigbot/commands/__init__.py View File

@@ -21,21 +21,16 @@
# SOFTWARE.

"""
EarwigBot's IRC Command Manager
EarwigBot's IRC Commands

This package provides the IRC "commands" used by the bot's front-end component.
This module contains the BaseCommand class (import with
`from earwigbot.commands import BaseCommand`) and an internal _CommandManager
class. This can be accessed through the `command_manager` singleton.
`from earwigbot.commands import BaseCommand`), whereas the package contains
various built-in commands. Additional commands can be installed as plugins in
the bot's working directory.
"""

import logging
import os
import sys

from earwigbot.config import config

__all__ = ["BaseCommand", "command_manager"]
__all__ = ["BaseCommand"]

class BaseCommand(object):
"""A base class for commands on IRC.
@@ -50,114 +45,65 @@ class BaseCommand(object):
# command subclass:
hooks = ["msg"]

def __init__(self, connection):
def __init__(self, bot):
"""Constructor for new commands.

This is called once when the command is loaded (from
commands._load_command()). `connection` is a Connection object,
allowing us to do self.connection.say(), self.connection.send(), etc,
from within a method.
commands._load_command()). `bot` is out base Bot object. Generally you
shouldn't need to override this; if you do, call
super(Command, self).__init__() first.
"""
self.connection = connection
logger_name = ".".join(("earwigbot", "commands", self.name))
self.logger = logging.getLogger(logger_name)
self.logger.setLevel(logging.DEBUG)
self.bot = bot
self.config = bot.config
self.logger = bot.commands.logger.getChild(self.name)

# Convenience functions:
self.say = lambda target, msg: self.bot.frontend.say(target, msg)
self.reply = lambda data, msg: self.bot.frontend.reply(data, msg)
self.action = lambda target, msg: self.bot.frontend.action(target, msg)
self.notice = lambda target, msg: self.bot.frontend.notice(target, msg)
self.join = lambda chan: self.bot.frontend.join(chan)
self.part = lambda chan, msg=None: self.bot.frontend.part(chan, msg)
self.mode = lambda t, level, msg: self.bot.frontend.mode(t, level, msg)
self.pong = lambda target: self.bot.frontend.pong(target)

def _wrap_check(self, data):
"""Check whether this command should be called, catching errors."""
try:
return self.check(data)
except Exception:
e = "Error checking command '{0}' with data: {1}:"
self.logger.exception(e.format(self.name, data))

def _wrap_process(self, data):
"""process() the message, catching and reporting any errors."""
try:
self.process(data)
except Exception:
e = "Error executing command '{0}':"
self.logger.exception(e.format(data.command))

def check(self, data):
"""Returns whether this command should be called in response to 'data'.
"""Return whether this command should be called in response to 'data'.

Given a Data() instance, return True if we should respond to this
activity, or False if we should ignore it or it doesn't apply to us.
Be aware that since this is called for each message sent on IRC, it
should not be cheap to execute and unlikely to throw exceptions.

Most commands return True if data.command == self.name, otherwise they
return False. This is the default behavior of check(); you need only
override it if you wish to change that.
"""
if data.is_command and data.command == self.name:
return True
return False
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 self.check() which is called automatically by the command handler,
we know this is something we should respond to, so (usually) something
like 'if data.command != "command_name": return' is unnecessary.
we know this is something we should respond to, so something like
`if data.command != "command_name": return` is usually unnecessary.
Note that
"""
pass


class _CommandManager(object):
def __init__(self):
self.logger = logging.getLogger("earwigbot.tasks")
self._base_dir = os.path.dirname(os.path.abspath(__file__))
self._connection = None
self._commands = {}

def _load_command(self, filename):
"""Load a specific command from a module, identified by filename.

Given a Connection object and a filename, we'll first try to import
it, and if that works, make an instance of the 'Command' class inside
(assuming it is an instance of BaseCommand), add it to self._commands,
and log the addition. Any problems along the way will either be
ignored or logged.
"""
# Strip .py from the filename's end and join with our package name:
name = ".".join(("commands", filename[:-3]))
try:
__import__(name)
except:
self.logger.exception("Couldn't load file {0}".format(filename))
return

try:
command = sys.modules[name].Command(self._connection)
except AttributeError:
return # No command in this module
if not isinstance(command, BaseCommand):
return

self._commands[command.name] = command
self.logger.debug("Added command {0}".format(command.name))

def load(self, connection):
"""Load all valid commands into self._commands.

`connection` is a Connection object that is given to each command's
constructor.
"""
self._connection = connection

files = os.listdir(self._base_dir)
files.sort()
for filename in files:
if filename.startswith("_") or not filename.endswith(".py"):
continue
self._load_command(filename)

msg = "Found {0} commands: {1}"
commands = ", ".join(self._commands.keys())
self.logger.info(msg.format(len(self._commands), commands))

def get_all(self):
"""Return our dict of all loaded commands."""
return self._commands

def check(self, hook, data):
"""Given an IRC event, check if there's anything we can respond to."""
# Parse command arguments into data.command and data.args:
data.parse_args()
for command in self._commands.values():
if hook in command.hooks:
if command.check(data):
try:
command.process(data)
except Exception:
e = "Error executing command '{0}'"
self.logger.exception(e.format(data.command))
break


command_manager = _CommandManager()

+ 10
- 10
earwigbot/commands/afc_report.py View File

@@ -24,27 +24,28 @@ import re

from earwigbot import wiki
from earwigbot.commands import BaseCommand
from earwigbot.tasks import task_manager

class Command(BaseCommand):
"""Get information about an AFC submission by name."""
name = "report"

def process(self, data):
self.site = wiki.get_site()
self.site = self.bot.wiki.get_site()
self.site._maxlag = None
self.data = data

try:
self.statistics = task_manager.get("afc_statistics")
self.statistics = self.bot.tasks.get("afc_statistics")
except KeyError:
e = "Cannot run command: requires afc_statistics task."
e = "Cannot run command: requires afc_statistics task (from earwigbot_plugins)"
self.logger.error(e)
msg = "command requires afc_statistics task (from earwigbot_plugins)"
self.reply(data, msg)
return

if not data.args:
msg = "what submission do you want me to give information about?"
self.connection.reply(data, msg)
self.reply(data, msg)
return

title = " ".join(data.args)
@@ -68,8 +69,7 @@ class Command(BaseCommand):
if page:
return self.report(page)

msg = "submission \x0302{0}\x0301 not found.".format(title)
self.connection.reply(data, msg)
self.reply(data, "submission \x0302{0}\x0301 not found.".format(title))

def get_page(self, title):
page = self.site.get_page(title, follow_redirects=False)
@@ -90,9 +90,9 @@ class Command(BaseCommand):
if status == "accepted":
msg3 = "Reviewed by \x0302{0}\x0301 ({1})"

self.connection.reply(self.data, msg1.format(short, url))
self.connection.say(self.data.chan, msg2.format(status))
self.connection.say(self.data.chan, msg3.format(user_name, user_url))
self.reply(self.data, msg1.format(short, url))
self.say(self.data.chan, msg2.format(status))
self.say(self.data.chan, msg3.format(user_name, user_url))

def get_status(self, page):
if page.is_redirect():


+ 11
- 13
earwigbot/commands/afc_status.py View File

@@ -22,9 +22,7 @@

import re

from earwigbot import wiki
from earwigbot.commands import BaseCommand
from earwigbot.config import config

class Command(BaseCommand):
"""Get the number of pending AfC submissions, open redirect requests, and
@@ -39,19 +37,19 @@ class Command(BaseCommand):

try:
if data.line[1] == "JOIN" and data.chan == "#wikipedia-en-afc":
if data.nick != config.irc["frontend"]["nick"]:
if data.nick != self.config.irc["frontend"]["nick"]:
return True
except IndexError:
pass
return False

def process(self, data):
self.site = wiki.get_site()
self.site = self.bot.wiki.get_site()
self.site._maxlag = None

if data.line[1] == "JOIN":
status = " ".join(("\x02Current status:\x0F", self.get_status()))
self.connection.notice(data.nick, status)
self.notice(data.nick, status)
return

if data.args:
@@ -59,17 +57,17 @@ class Command(BaseCommand):
if action.startswith("sub") or action == "s":
subs = self.count_submissions()
msg = "there are \x0305{0}\x0301 pending AfC submissions (\x0302WP:AFC\x0301)."
self.connection.reply(data, msg.format(subs))
self.reply(data, msg.format(subs))

elif action.startswith("redir") or action == "r":
redirs = self.count_redirects()
msg = "there are \x0305{0}\x0301 open redirect requests (\x0302WP:AFC/R\x0301)."
self.connection.reply(data, msg.format(redirs))
self.reply(data, msg.format(redirs))

elif action.startswith("file") or action == "f":
files = self.count_redirects()
msg = "there are \x0305{0}\x0301 open file upload requests (\x0302WP:FFU\x0301)."
self.connection.reply(data, msg.format(files))
self.reply(data, msg.format(files))

elif action.startswith("agg") or action == "a":
try:
@@ -80,21 +78,21 @@ class Command(BaseCommand):
agg_num = self.get_aggregate_number(agg_data)
except ValueError:
msg = "\x0303{0}\x0301 isn't a number!"
self.connection.reply(data, msg.format(data.args[1]))
self.reply(data, msg.format(data.args[1]))
return
aggregate = self.get_aggregate(agg_num)
msg = "aggregate is \x0305{0}\x0301 (AfC {1})."
self.connection.reply(data, msg.format(agg_num, aggregate))
self.reply(data, msg.format(agg_num, aggregate))

elif action.startswith("nocolor") or action == "n":
self.connection.reply(data, self.get_status(color=False))
self.reply(data, self.get_status(color=False))

else:
msg = "unknown argument: \x0303{0}\x0301. Valid args are 'subs', 'redirs', 'files', 'agg', 'nocolor'."
self.connection.reply(data, msg.format(data.args[0]))
self.reply(data, msg.format(data.args[0]))

else:
self.connection.reply(data, self.get_status())
self.reply(data, self.get_status())

def get_status(self, color=True):
subs = self.count_submissions()


+ 3
- 3
earwigbot/commands/calc.py View File

@@ -32,7 +32,7 @@ class Command(BaseCommand):

def process(self, data):
if not data.args:
self.connection.reply(data, "what do you want me to calculate?")
self.reply(data, "what do you want me to calculate?")
return

query = ' '.join(data.args)
@@ -47,7 +47,7 @@ class Command(BaseCommand):

match = r_result.search(result)
if not match:
self.connection.reply(data, "Calculation error.")
self.reply(data, "Calculation error.")
return

result = match.group(1)
@@ -62,7 +62,7 @@ class Command(BaseCommand):
result += " " + query.split(" in ", 1)[1]

res = "%s = %s" % (query, result)
self.connection.reply(data, res)
self.reply(data, res)

def cleanup(self, query):
fixes = [


+ 55
- 17
earwigbot/commands/chanops.py View File

@@ -21,35 +21,73 @@
# SOFTWARE.

from earwigbot.commands import BaseCommand
from earwigbot.config import config

class Command(BaseCommand):
"""Voice, devoice, op, or deop users in the channel."""
"""Voice, devoice, op, or deop users in the channel, or join or part from
other channels."""
name = "chanops"

def check(self, data):
commands = ["chanops", "voice", "devoice", "op", "deop"]
if data.is_command and data.command in commands:
cmnds = ["chanops", "voice", "devoice", "op", "deop", "join", "part"]
if data.is_command and data.command in cmnds:
return True
return False

def process(self, data):
if data.command == "chanops":
msg = "available commands are !voice, !devoice, !op, and !deop."
self.connection.reply(data, msg)
msg = "available commands are !voice, !devoice, !op, !deop, !join, and !part."
self.reply(data, msg)
return

if data.host not in config.irc["permissions"]["admins"]:
msg = "you must be a bot admin to use this command."
self.connection.reply(data, msg)
if data.host not in self.config.irc["permissions"]["admins"]:
self.reply(data, "you must be a bot admin to use this command.")
return

# If it is just !op/!devoice/whatever without arguments, assume they
# want to do this to themselves:
if not data.args:
target = data.nick
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:
target = data.args[0]
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 = " ".join((data.command, data.chan, target))
self.connection.say("ChanServ", msg)
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)

+ 9
- 9
earwigbot/commands/crypt.py View File

@@ -39,12 +39,12 @@ class Command(BaseCommand):
def process(self, data):
if data.command == "crypt":
msg = "available commands are !hash, !encrypt, and !decrypt."
self.connection.reply(data, msg)
self.reply(data, msg)
return

if not data.args:
msg = "what do you want me to {0}?".format(data.command)
self.connection.reply(data, msg)
self.reply(data, msg)
return

if data.command == "hash":
@@ -52,14 +52,14 @@ class Command(BaseCommand):
if algo == "list":
algos = ', '.join(hashlib.algorithms)
msg = algos.join(("supported algorithms: ", "."))
self.connection.reply(data, msg)
self.reply(data, msg)
elif algo in hashlib.algorithms:
string = ' '.join(data.args[1:])
result = getattr(hashlib, algo)(string).hexdigest()
self.connection.reply(data, result)
self.reply(data, result)
else:
msg = "unknown algorithm: '{0}'.".format(algo)
self.connection.reply(data, msg)
self.reply(data, msg)

else:
key = data.args[0]
@@ -67,14 +67,14 @@ class Command(BaseCommand):

if not text:
msg = "a key was provided, but text to {0} was not."
self.connection.reply(data, msg.format(data.command))
self.reply(data, msg.format(data.command))
return

try:
if data.command == "encrypt":
self.connection.reply(data, blowfish.encrypt(key, text))
self.reply(data, blowfish.encrypt(key, text))
else:
self.connection.reply(data, blowfish.decrypt(key, text))
self.reply(data, blowfish.decrypt(key, text))
except blowfish.BlowfishError as error:
msg = "{0}: {1}.".format(error.__class__.__name__, error)
self.connection.reply(data, msg)
self.reply(data, msg)

+ 7
- 8
earwigbot/commands/ctcp.py View File

@@ -25,11 +25,10 @@ import time

from earwigbot import __version__
from earwigbot.commands import BaseCommand
from earwigbot.config import config

class Command(BaseCommand):
"""Not an actual command, this module is used to respond to the CTCP
commands PING, TIME, and VERSION."""
"""Not an actual command; this module implements responses to the CTCP
requests PING, TIME, and VERSION."""
name = "ctcp"
hooks = ["msg_private"]

@@ -53,17 +52,17 @@ class Command(BaseCommand):
if command == "PING":
msg = " ".join(data.line[4:])
if msg:
self.connection.notice(target, "\x01PING {0}\x01".format(msg))
self.notice(target, "\x01PING {0}\x01".format(msg))
else:
self.connection.notice(target, "\x01PING\x01")
self.notice(target, "\x01PING\x01")

elif command == "TIME":
ts = time.strftime("%a, %d %b %Y %H:%M:%S %Z", time.localtime())
self.connection.notice(target, "\x01TIME {0}\x01".format(ts))
self.notice(target, "\x01TIME {0}\x01".format(ts))

elif command == "VERSION":
default = "EarwigBot - $1 - Python/$2 https://github.com/earwig/earwigbot"
vers = config.irc.get("version", default)
vers = self.config.irc.get("version", default)
vers = vers.replace("$1", __version__)
vers = vers.replace("$2", platform.python_version())
self.connection.notice(target, "\x01VERSION {0}\x01".format(vers))
self.notice(target, "\x01VERSION {0}\x01".format(vers))

+ 3
- 3
earwigbot/commands/editcount.py View File

@@ -41,7 +41,7 @@ class Command(BaseCommand):
else:
name = ' '.join(data.args)

site = wiki.get_site()
site = self.bot.wiki.get_site()
site._maxlag = None
user = site.get_user(name)

@@ -49,10 +49,10 @@ class Command(BaseCommand):
count = user.editcount()
except wiki.UserNotFoundError:
msg = "the user \x0302{0}\x0301 does not exist."
self.connection.reply(data, msg.format(name))
self.reply(data, msg.format(name))
return

safe = quote_plus(user.name())
url = "http://toolserver.org/~tparis/pcount/index.php?name={0}&lang=en&wiki=wikipedia"
msg = "\x0302{0}\x0301 has {1} edits ({2})."
self.connection.reply(data, msg.format(name, count, url.format(safe)))
self.reply(data, msg.format(name, count, url.format(safe)))

+ 20
- 21
earwigbot/commands/git.py View File

@@ -25,7 +25,6 @@ import subprocess
import re

from earwigbot.commands import BaseCommand
from earwigbot.config import config

class Command(BaseCommand):
"""Commands to interface with the bot's git repository; use '!git' for a
@@ -34,9 +33,9 @@ class Command(BaseCommand):

def process(self, data):
self.data = data
if data.host not in config.irc["permissions"]["owners"]:
if data.host not in self.config.irc["permissions"]["owners"]:
msg = "you must be a bot owner to use this command."
self.connection.reply(data, msg)
self.reply(data, msg)
return

if not data.args:
@@ -66,7 +65,7 @@ class Command(BaseCommand):

else: # They asked us to do something we don't know
msg = "unknown argument: \x0303{0}\x0301.".format(data.args[0])
self.connection.reply(data, msg)
self.reply(data, msg)

def exec_shell(self, command):
"""Execute a shell command and get the output."""
@@ -90,13 +89,13 @@ class Command(BaseCommand):
for key in sorted(help.keys()):
msg += "\x0303{0}\x0301 ({1}), ".format(key, help[key])
msg = msg[:-2] # Trim last comma and space
self.connection.reply(self.data, "sub-commands are: {0}.".format(msg))
self.reply(self.data, "sub-commands are: {0}.".format(msg))

def do_branch(self):
"""Get our current branch."""
branch = self.exec_shell("git name-rev --name-only HEAD")
msg = "currently on branch \x0302{0}\x0301.".format(branch)
self.connection.reply(self.data, msg)
self.reply(self.data, msg)

def do_branches(self):
"""Get a list of branches."""
@@ -107,14 +106,14 @@ class Command(BaseCommand):
branches = branches.replace('\n ', ', ')
branches = branches.strip()
msg = "branches: \x0302{0}\x0301.".format(branches)
self.connection.reply(self.data, msg)
self.reply(self.data, msg)

def do_checkout(self):
"""Switch branches."""
try:
branch = self.data.args[1]
except IndexError: # no branch name provided
self.connection.reply(self.data, "switch to which branch?")
self.reply(self.data, "switch to which branch?")
return

current_branch = self.exec_shell("git name-rev --name-only HEAD")
@@ -123,51 +122,51 @@ class Command(BaseCommand):
result = self.exec_shell("git checkout %s" % branch)
if "Already on" in result:
msg = "already on \x0302{0}\x0301!".format(branch)
self.connection.reply(self.data, msg)
self.reply(self.data, msg)
else:
ms = "switched from branch \x0302{1}\x0301 to \x0302{1}\x0301."
msg = ms.format(current_branch, branch)
self.connection.reply(self.data, msg)
self.reply(self.data, msg)

except subprocess.CalledProcessError:
# Git couldn't switch branches; assume the branch doesn't exist:
msg = "branch \x0302{0}\x0301 doesn't exist!".format(branch)
self.connection.reply(self.data, msg)
self.reply(self.data, msg)

def do_delete(self):
"""Delete a branch, while making sure that we are not already on it."""
try:
delete_branch = self.data.args[1]
except IndexError: # no branch name provided
self.connection.reply(self.data, "delete which branch?")
self.reply(self.data, "delete which branch?")
return

current_branch = self.exec_shell("git name-rev --name-only HEAD")

if current_branch == delete_branch:
msg = "you're currently on this branch; please checkout to a different branch before deleting."
self.connection.reply(self.data, msg)
self.reply(self.data, msg)
return

try:
self.exec_shell("git branch -d %s" % delete_branch)
msg = "branch \x0302{0}\x0301 has been deleted locally."
self.connection.reply(self.data, msg.format(delete_branch))
self.reply(self.data, msg.format(delete_branch))
except subprocess.CalledProcessError:
# Git couldn't switch branches; assume the branch doesn't exist:
msg = "branch \x0302{0}\x0301 doesn't exist!".format(delete_branch)
self.connection.reply(self.data, msg)
self.reply(self.data, msg)

def do_pull(self):
"""Pull from our remote repository."""
branch = self.exec_shell("git name-rev --name-only HEAD")
msg = "pulling from remote (currently on \x0302{0}\x0301)..."
self.connection.reply(self.data, msg.format(branch))
self.reply(self.data, msg.format(branch))

result = self.exec_shell("git pull")

if "Already up-to-date." in result:
self.connection.reply(self.data, "done; no new changes.")
self.reply(self.data, "done; no new changes.")
else:
regex = "\s*((.*?)\sfile(.*?)tions?\(-\))"
changes = re.findall(regex, result)[0][0]
@@ -177,11 +176,11 @@ class Command(BaseCommand):
cmnd_url = "git config --get remote.{0}.url".format(remote)
url = self.exec_shell(cmnd_url)
msg = "done; {0} [from {1}].".format(changes, url)
self.connection.reply(self.data, msg)
self.reply(self.data, msg)
except subprocess.CalledProcessError:
# Something in .git/config is not specified correctly, so we
# cannot get the remote's URL. However, pull was a success:
self.connection.reply(self.data, "done; %s." % changes)
self.reply(self.data, "done; %s." % changes)

def do_status(self):
"""Check whether we have anything to pull."""
@@ -189,7 +188,7 @@ class Command(BaseCommand):
result = self.exec_shell("git fetch --dry-run")
if not result: # Nothing was fetched, so remote and local are equal
msg = "last commit was {0}. Local copy is \x02up-to-date\x0F with remote."
self.connection.reply(self.data, msg.format(last))
self.reply(self.data, msg.format(last))
else:
msg = "last local commit was {0}. Remote is \x02ahead\x0F of local copy."
self.connection.reply(self.data, msg.format(last))
self.reply(self.data, msg.format(last))

+ 8
- 8
earwigbot/commands/help.py View File

@@ -22,7 +22,7 @@

import re

from earwigbot.commands import BaseCommand, command_manager
from earwigbot.commands import BaseCommand
from earwigbot.irc import Data

class Command(BaseCommand):
@@ -30,7 +30,6 @@ class Command(BaseCommand):
name = "help"

def process(self, data):
self.cmnds = command_manager.get_all()
if not data.args:
self.do_main_help(data)
else:
@@ -39,9 +38,9 @@ class Command(BaseCommand):
def do_main_help(self, data):
"""Give the user a general help message with a list of all commands."""
msg = "Hi, I'm a bot! I have {0} commands loaded: {1}. You can get help for any command with '!help <command>'."
cmnds = sorted(self.cmnds.keys())
cmnds = sorted(self.bot.commands)
msg = msg.format(len(cmnds), ', '.join(cmnds))
self.connection.reply(data, msg)
self.reply(data, msg)

def do_command_help(self, data):
"""Give the user help for a specific command."""
@@ -53,16 +52,17 @@ class Command(BaseCommand):
dummy.command = command.lower()
dummy.is_command = True

for cmnd in self.cmnds.values():
for cmnd_name in self.bot.commands:
cmnd = self.bot.commands.get(cmnd_name)
if not cmnd.check(dummy):
continue
if cmnd.__doc__:
doc = cmnd.__doc__.replace("\n", "")
doc = re.sub("\s\s+", " ", doc)
msg = "info for command \x0303{0}\x0301: \"{1}\""
self.connection.reply(data, msg.format(command, doc))
msg = "help for command \x0303{0}\x0301: \"{1}\""
self.reply(data, msg.format(command, doc))
return
break

msg = "sorry, no help for \x0303{0}\x0301.".format(command)
self.connection.reply(data, msg)
self.reply(data, msg)

+ 3
- 3
earwigbot/commands/link.py View File

@@ -43,15 +43,15 @@ class Command(BaseCommand):
if re.search("(\[\[(.*?)\]\])|(\{\{(.*?)\}\})", msg):
links = self.parse_line(msg)
links = " , ".join(links)
self.connection.reply(data, links)
self.reply(data, links)

elif data.command == "link":
if not data.args:
self.connection.reply(data, "what do you want me to link to?")
self.reply(data, "what do you want me to link to?")
return
pagename = ' '.join(data.args)
link = self.parse_link(pagename)
self.connection.reply(data, link)
self.reply(data, link)

def parse_line(self, line):
results = []


+ 2
- 2
earwigbot/commands/praise.py View File

@@ -45,7 +45,7 @@ class Command(BaseCommand):
msg = "You use this command to praise certain people. Who they are is a secret."
else:
msg = "You're doing it wrong."
self.connection.reply(data, msg)
self.reply(data, msg)
return

self.connection.say(data.chan, msg)
self.say(data.chan, msg)

+ 67
- 0
earwigbot/commands/quit.py View File

@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

from earwigbot.commands import BaseCommand

class Command(BaseCommand):
"""Quit, restart, or reload components from the bot. Only the owners can
run this command."""
name = "quit"

def check(self, data):
commands = ["quit", "restart", "reload"]
return data.is_command and data.command in commands

def process(self, data):
if data.host not in self.config.irc["permissions"]["owners"]:
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):
nick = self.config.irc.frontend["nick"]
if not data.args or data.args[0].lower() != nick.lower():
self.reply(data, "to confirm this action, the first argument must be my nickname.")
return
if data.args[1:]:
msg = " ".join(data.args[1:])
self.bot.stop("Stopped by {0}: {1}".format(data.nick, msg))
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.")

+ 4
- 4
earwigbot/commands/registration.py View File

@@ -30,7 +30,7 @@ class Command(BaseCommand):
name = "registration"

def check(self, data):
commands = ["registration", "age"]
commands = ["registration", "reg", "age"]
if data.is_command and data.command in commands:
return True
return False
@@ -41,7 +41,7 @@ class Command(BaseCommand):
else:
name = ' '.join(data.args)

site = wiki.get_site()
site = self.bot.wiki.get_site()
site._maxlag = None
user = site.get_user(name)

@@ -49,7 +49,7 @@ class Command(BaseCommand):
reg = user.registration()
except wiki.UserNotFoundError:
msg = "the user \x0302{0}\x0301 does not exist."
self.connection.reply(data, msg.format(name))
self.reply(data, msg.format(name))
return

date = time.strftime("%b %d, %Y at %H:%M:%S UTC", reg)
@@ -64,7 +64,7 @@ class Command(BaseCommand):
gender = "They're"
msg = "\x0302{0}\x0301 registered on {1}. {2} {3} old."
self.connection.reply(data, msg.format(name, date, gender, age))
self.reply(data, msg.format(name, date, gender, age))

def get_diff(self, t1, t2):
parts = {"years": 31536000, "days": 86400, "hours": 3600,


+ 5
- 5
earwigbot/commands/remind.py View File

@@ -37,19 +37,19 @@ class Command(BaseCommand):
def process(self, data):
if not data.args:
msg = "please specify a time (in seconds) and a message in the following format: !remind <time> <msg>."
self.connection.reply(data, msg)
self.reply(data, msg)
return

try:
wait = int(data.args[0])
except ValueError:
msg = "the time must be given as an integer, in seconds."
self.connection.reply(data, msg)
self.reply(data, msg)
return
message = ' '.join(data.args[1:])
if not message:
msg = "what message do you want me to give you when time is up?"
self.connection.reply(data, msg)
self.reply(data, msg)
return

end = time.localtime(time.time() + wait)
@@ -58,7 +58,7 @@ class Command(BaseCommand):

msg = 'Set reminder for "{0}" in {1} seconds (ends {2}).'
msg = msg.format(message, wait, end_time_with_timezone)
self.connection.reply(data, msg)
self.reply(data, msg)

t_reminder = threading.Thread(target=self.reminder,
args=(data, message, wait))
@@ -68,4 +68,4 @@ class Command(BaseCommand):

def reminder(self, data, message, wait):
time.sleep(wait)
self.connection.reply(data, message)
self.reply(data, message)

+ 1
- 1
earwigbot/commands/replag.py View File

@@ -47,4 +47,4 @@ class Command(BaseCommand):
conn.close()

msg = "Replag on \x0302{0}\x0301 is \x02{1}\x0F seconds."
self.connection.reply(data, msg.format(args["db"], replag))
self.reply(data, msg.format(args["db"], replag))

+ 3
- 3
earwigbot/commands/rights.py View File

@@ -39,7 +39,7 @@ class Command(BaseCommand):
else:
name = ' '.join(data.args)

site = wiki.get_site()
site = self.bot.wiki.get_site()
site._maxlag = None
user = site.get_user(name)

@@ -47,7 +47,7 @@ class Command(BaseCommand):
rights = user.groups()
except wiki.UserNotFoundError:
msg = "the user \x0302{0}\x0301 does not exist."
self.connection.reply(data, msg.format(name))
self.reply(data, msg.format(name))
return

try:
@@ -55,4 +55,4 @@ class Command(BaseCommand):
except ValueError:
pass
msg = "the rights for \x0302{0}\x0301 are {1}."
self.connection.reply(data, msg.format(name, ', '.join(rights)))
self.reply(data, msg.format(name, ', '.join(rights)))

+ 3
- 2
earwigbot/commands/test.py View File

@@ -29,8 +29,9 @@ class Command(BaseCommand):
name = "test"

def process(self, data):
user = "\x02{0}\x0F".format(data.nick)
hey = random.randint(0, 1)
if hey:
self.connection.say(data.chan, "Hey \x02%s\x0F!" % data.nick)
self.say(data.chan, "Hey {0}!".format(user))
else:
self.connection.say(data.chan, "'sup \x02%s\x0F?" % data.nick)
self.say(data.chan, "'sup {0}?".format(user))

+ 19
- 35
earwigbot/commands/threads.py View File

@@ -24,9 +24,7 @@ import threading
import re

from earwigbot.commands import BaseCommand
from earwigbot.config import config
from earwigbot.irc import KwargParseException
from earwigbot.tasks import task_manager

class Command(BaseCommand):
"""Manage wiki tasks from IRC, and check on thread status."""
@@ -40,9 +38,9 @@ class Command(BaseCommand):

def process(self, data):
self.data = data
if data.host not in config.irc["permissions"]["owners"]:
if data.host not in self.config.irc["permissions"]["owners"]:
msg = "you must be a bot owner to use this command."
self.connection.reply(data, msg)
self.reply(data, msg)
return

if not data.args:
@@ -50,7 +48,7 @@ class Command(BaseCommand):
self.do_list()
else:
msg = "no arguments provided. Maybe you wanted '!{0} list', '!{0} start', or '!{0} listall'?"
self.connection.reply(data, msg.format(data.command))
self.reply(data, msg.format(data.command))
return

if data.args[0] == "list":
@@ -64,7 +62,7 @@ class Command(BaseCommand):

else: # They asked us to do something we don't know
msg = "unknown argument: \x0303{0}\x0301.".format(data.args[0])
self.connection.reply(data, msg)
self.reply(data, msg)

def do_list(self):
"""With !tasks list (or abbreviation !tasklist), list all running
@@ -78,10 +76,9 @@ class Command(BaseCommand):
for thread in threads:
tname = thread.name
if tname == "MainThread":
tname = self.get_main_thread_name()
t = "\x0302{0}\x0301 (as main thread, id {1})"
normal_threads.append(t.format(tname, thread.ident))
elif tname in ["irc-frontend", "irc-watcher", "wiki-scheduler"]:
t = "\x0302MainThread\x0301 (id {0})"
normal_threads.append(t.format(thread.ident))
elif tname in self.config.components:
t = "\x0302{0}\x0301 (id {1})"
normal_threads.append(t.format(tname, thread.ident))
elif tname.startswith("reminder"):
@@ -101,18 +98,14 @@ class Command(BaseCommand):
msg = "\x02{0}\x0F threads active: {1}, and \x020\x0F task threads."
msg = msg.format(len(threads), ', '.join(normal_threads))

self.connection.reply(self.data, msg)
self.reply(self.data, msg)

def do_listall(self):
"""With !tasks listall or !tasks all, list all loaded tasks, and report
whether they are currently running or idle."""
all_tasks = task_manager.get_all().keys()
threads = threading.enumerate()
tasklist = []

all_tasks.sort()

for task in all_tasks:
for task in sorted(self.bot.tasks):
threadlist = [t for t in threads if t.name.startswith(task)]
ids = [str(t.ident) for t in threadlist]
if not ids:
@@ -124,10 +117,10 @@ class Command(BaseCommand):
t = "\x0302{0}\x0301 (\x02active\x0F as ids {1})"
tasklist.append(t.format(task, ', '.join(ids)))

tasklist = ", ".join(tasklist)
tasks = ", ".join(tasklist)

msg = "{0} tasks loaded: {1}.".format(len(all_tasks), tasklist)
self.connection.reply(self.data, msg)
msg = "{0} tasks loaded: {1}.".format(len(tasklist), tasks)
self.reply(self.data, msg)

def do_start(self):
"""With !tasks start, start any loaded task by name with or without
@@ -137,32 +130,23 @@ class Command(BaseCommand):
try:
task_name = data.args[1]
except IndexError: # No task name given
self.connection.reply(data, "what task do you want me to start?")
self.reply(data, "what task do you want me to start?")
return

try:
data.parse_kwargs()
except KwargParseException, arg:
msg = "error parsing argument: \x0303{0}\x0301.".format(arg)
self.connection.reply(data, msg)
self.reply(data, msg)
return

if task_name not in task_manager.get_all().keys():
if task_name not in self.bot.tasks:
# This task does not exist or hasn't been loaded:
msg = "task could not be found; either tasks/{0}.py doesn't exist, or it wasn't loaded correctly."
self.connection.reply(data, msg.format(task_name))
msg = "task could not be found; either it doesn't exist, or it wasn't loaded correctly."
self.reply(data, msg.format(task_name))
return

data.kwargs["fromIRC"] = True
task_manager.start(task_name, **data.kwargs)
self.bot.tasks.start(task_name, **data.kwargs)
msg = "task \x0302{0}\x0301 started.".format(task_name)
self.connection.reply(data, msg)

def get_main_thread_name(self):
"""Return the "proper" name of the MainThread."""
if "irc_frontend" in config.components:
return "irc-frontend"
elif "wiki_schedule" in config.components:
return "wiki-scheduler"
else:
return "irc-watcher"
self.reply(data, msg)

+ 147
- 112
earwigbot/config.py View File

@@ -20,31 +20,7 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

"""
EarwigBot's YAML Config File Parser

This handles all tasks involving reading and writing to our config file,
including encrypting and decrypting passwords and making a new config file from
scratch at the inital bot run.

Usually you'll just want to do "from earwigbot.config import config", which
returns a singleton _BotConfig object, with data accessible from various
attributes and functions:

* config.components - enabled components
* config.wiki - information about wiki-editing
* config.tasks - information for bot tasks
* config.irc - information about IRC
* config.metadata - miscellaneous information
* config.schedule() - tasks scheduled to run at a given time

Additionally, _BotConfig has some functions used in config loading:
* config.load() - loads and parses our config file, returning True if
passwords are stored encrypted or False otherwise
* config.decrypt() - given a key, decrypts passwords inside our config
variables; won't work if passwords aren't encrypted
"""

from getpass import getpass
import logging
import logging.handlers
from os import mkdir, path
@@ -53,44 +29,39 @@ import yaml

from earwigbot import blowfish

__all__ = ["config"]

class _ConfigNode(object):
def __iter__(self):
for key in self.__dict__.iterkeys():
yield key

def __getitem__(self, item):
return self.__dict__.__getitem__(item)

def _dump(self):
data = self.__dict__.copy()
for key, val in data.iteritems():
if isinstance(val, _ConfigNode):
data[key] = val._dump()
return data

def _load(self, data):
self.__dict__ = data.copy()

def _decrypt(self, key, intermediates, item):
base = self.__dict__
try:
for inter in intermediates:
base = base[inter]
except KeyError:
return
if item in base:
base[item] = blowfish.decrypt(key, base[item])

def get(self, *args, **kwargs):
return self.__dict__.get(*args, **kwargs)


class _BotConfig(object):
def __init__(self):
self._script_dir = path.dirname(path.abspath(__file__))
self._root_dir = path.split(self._script_dir)[0]
__all__ = ["BotConfig"]

class BotConfig(object):
"""
EarwigBot's YAML Config File Manager

This handles all tasks involving reading and writing to our config file,
including encrypting and decrypting passwords and making a new config file
from scratch at the inital bot run.

BotConfig has a few properties and functions, including the following:
* config.root_dir - bot's working directory; contains config.yml, logs/
* config.path - path to the bot's config file
* config.components - enabled components
* config.wiki - information about wiki-editing
* config.tasks - information for bot tasks
* config.irc - information about IRC
* config.metadata - miscellaneous information
* config.schedule() - tasks scheduled to run at a given time

BotConfig also has some functions used in config loading:
* config.load() - loads and parses our config file, returning True if
passwords are stored encrypted or False otherwise;
can also be used to easily reload config
* config.decrypt() - given a key, decrypts passwords inside our config
variables, and remembers to decrypt the password if
config is reloaded; won't do anything if passwords
aren't encrypted
"""

def __init__(self, root_dir, level):
self._root_dir = root_dir
self._logging_level = level
self._config_path = path.join(self._root_dir, "config.yml")
self._log_dir = path.join(self._root_dir, "logs")
self._decryption_key = None
@@ -105,21 +76,29 @@ class _BotConfig(object):
self._nodes = [self._components, self._wiki, self._tasks, self._irc,
self._metadata]

self._decryptable_nodes = [ # Default nodes to decrypt
(self._wiki, ("password")),
(self._wiki, ("search", "credentials", "key")),
(self._wiki, ("search", "credentials", "secret")),
(self._irc, ("frontend", "nickservPassword")),
(self._irc, ("watcher", "nickservPassword")),
]

def _load(self):
"""Load data from our JSON config file (config.yml) into _config."""
"""Load data from our JSON config file (config.yml) into self._data."""
filename = self._config_path
with open(filename, 'r') as fp:
try:
self._data = yaml.load(fp)
except yaml.YAMLError as error:
print "Error parsing config file {0}:".format(filename)
print error
exit(1)
raise

def _setup_logging(self):
"""Configures the logging module so it works the way we want it to."""
log_dir = self._log_dir
logger = logging.getLogger("earwigbot")
logger.handlers = [] # Remove any handlers already attached to us
logger.setLevel(logging.DEBUG)

if self.metadata.get("enableLogging"):
@@ -135,7 +114,7 @@ class _BotConfig(object):
else:
msg = "log_dir ({0}) exists but is not a directory!"
print msg.format(log_dir)
exit(1)
return

main_handler = hand(logfile("bot.log"), "midnight", 1, 7)
error_handler = hand(logfile("error.log"), "W6", 1, 4)
@@ -149,40 +128,51 @@ class _BotConfig(object):
h.setFormatter(formatter)
logger.addHandler(h)

stream_handler = logging.StreamHandler()
stream_handler.setLevel(logging.DEBUG)
stream_handler.setFormatter(color_formatter)
logger.addHandler(stream_handler)
self._stream_handler = stream = logging.StreamHandler()
stream.setLevel(self._logging_level)
stream.setFormatter(color_formatter)
logger.addHandler(stream)

else:
logger.addHandler(logging.NullHandler())
def _decrypt(self, node, nodes):
"""Try to decrypt the contents of a config node. Use self.decrypt()."""
try:
node._decrypt(self._decryption_key, nodes[:-1], nodes[-1])
except blowfish.BlowfishError as error:
print "Error decrypting passwords:"
raise

def _make_new(self):
"""Make a new config file based on the user's input."""
encrypt = raw_input("Would you like to encrypt passwords stored in config.yml? [y/n] ")
if encrypt.lower().startswith("y"):
is_encrypted = True
else:
is_encrypted = False

return is_encrypted

@property
def script_dir(self):
return self._script_dir
#m = "Would you like to encrypt passwords stored in config.yml? [y/n] "
#encrypt = raw_input(m)
#if encrypt.lower().startswith("y"):
# is_encrypted = True
#else:
# is_encrypted = False
raise NotImplementedError()
# yaml.dumps()

@property
def root_dir(self):
return self._root_dir

@property
def config_path(self):
def logging_level(self):
return self._logging_level

@logging_level.setter
def logging_level(self, level):
self._logging_level = level
self._stream_handler.setLevel(level)

@property
def path(self):
return self._config_path

@property
def log_dir(self):
return self._log_dir
@property
def data(self):
"""The entire config file."""
@@ -221,7 +211,7 @@ class _BotConfig(object):
"""Return True if passwords are encrypted, otherwise False."""
return self.metadata.get("encryptPasswords", False)

def load(self, config_path=None, log_dir=None):
def load(self):
"""Load, or reload, our config file.

First, check if we have a valid config file, and if not, notify the
@@ -232,21 +222,16 @@ class _BotConfig(object):
wiki, tasks, irc, metadata) for easy access (as well as the internal
_data variable).

If everything goes well, return True if stored passwords are
encrypted in the file, or False if they are not.
If config is being reloaded, encrypted items will be automatically
decrypted if they were decrypted beforehand.
"""
if config_path:
self._config_path = config_path
if log_dir:
self._log_dir = log_dir

if not path.exists(self._config_path):
print "You haven't configured the bot yet!"
choice = raw_input("Would you like to do this now? [y/n] ")
print "Config file not found:", self._config_path
choice = raw_input("Would you like to create a config file now? [y/n] ")
if choice.lower().startswith("y"):
return self._make_new()
self._make_new()
else:
exit(1)
exit(1) # TODO: raise an exception instead

self._load()
data = self._data
@@ -257,25 +242,28 @@ class _BotConfig(object):
self.metadata._load(data.get("metadata", {}))

self._setup_logging()
return self.is_encrypted()
if self.is_encrypted():
if not self._decryption_key:
key = getpass("Enter key to decrypt bot passwords: ")
self._decryption_key = key
for node, nodes in self._decryptable_nodes:
self._decrypt(node, nodes)

def decrypt(self, node, *nodes):
"""Use self._decryption_key to decrypt an object in our config tree.

If this is called when passwords are not encrypted (check with
config.is_encrypted()), nothing will happen.
config.is_encrypted()), nothing will happen. We'll also keep track of
this node if config.load() is called again (i.e. to reload) and
automatically decrypt it.

An example usage would be:
Example usage:
config.decrypt(config.irc, "frontend", "nickservPassword")
-> decrypts config.irc["frontend"]["nickservPassword"]
"""
if not self.is_encrypted():
return
try:
node._decrypt(self._decryption_key, nodes[:-1], nodes[-1])
except blowfish.BlowfishError as error:
print "\nError decrypting passwords:"
print "{0}: {1}.".format(error.__class__.__name__, error)
exit(1)
self._decryptable_nodes.append((node, nodes))
if self.is_encrypted():
self._decrypt(node, nodes)

def schedule(self, minute, hour, month_day, month, week_day):
"""Return a list of tasks scheduled to run at the specified time.
@@ -311,6 +299,56 @@ class _BotConfig(object):
return tasks


class _ConfigNode(object):
def __iter__(self):
for key in self.__dict__:
yield key

def __getitem__(self, item):
return self.__dict__.__getitem__(item)

def _dump(self):
data = self.__dict__.copy()
for key, val in data.iteritems():
if isinstance(val, _ConfigNode):
data[key] = val._dump()
return data

def _load(self, data):
self.__dict__ = data.copy()

def _decrypt(self, key, intermediates, item):
base = self.__dict__
try:
for inter in intermediates:
base = base[inter]
except KeyError:
return
if item in base:
base[item] = blowfish.decrypt(key, base[item])

def get(self, *args, **kwargs):
return self.__dict__.get(*args, **kwargs)

def keys(self):
return self.__dict__.keys()

def values(self):
return self.__dict__.values()
def items(self):
return self.__dict__.items()

def iterkeys(self):
return self.__dict__.iterkeys()

def itervalues(self):
return self.__dict__.itervalues()

def iteritems(self):
return self.__dict__.iteritems()


class _BotFormatter(logging.Formatter):
def __init__(self, color=False):
self._format = super(_BotFormatter, self).format
@@ -336,6 +374,3 @@ class _BotFormatter(logging.Formatter):
if record.levelno == logging.CRITICAL:
record.lvl = l.join(("\x1b[1m\x1b[31m", "\x1b[0m")) # Bold red
return record


config = _BotConfig()

+ 38
- 19
earwigbot/irc/connection.py View File

@@ -21,7 +21,8 @@
# SOFTWARE.

import socket
import threading
from threading import Lock
from time import sleep

__all__ = ["BrokenSocketException", "IRCConnection"]

@@ -35,17 +36,16 @@ class BrokenSocketException(Exception):
class IRCConnection(object):
"""A class to interface with IRC."""

def __init__(self, host, port, nick, ident, realname, logger):
def __init__(self, host, port, nick, ident, realname):
self.host = host
self.port = port
self.nick = nick
self.ident = ident
self.realname = realname
self.logger = logger
self.is_running = False
self._is_running = False

# A lock to prevent us from sending two messages at once:
self._lock = threading.Lock()
self._send_lock = Lock()

def _connect(self):
"""Connect to our IRC server."""
@@ -53,8 +53,9 @@ class IRCConnection(object):
try:
self._sock.connect((self.host, self.port))
except socket.error:
self.logger.critical("Couldn't connect to IRC server", exc_info=1)
exit(1)
self.logger.exception("Couldn't connect to IRC server; retrying")
sleep(8)
self._connect()
self._send("NICK {0}".format(self.nick))
self._send("USER {0} {1} * :{2}".format(self.ident, self.host, self.realname))

@@ -68,7 +69,7 @@ class IRCConnection(object):

def _get(self, size=4096):
"""Receive (i.e. get) data from the server."""
data = self._sock.recv(4096)
data = self._sock.recv(size)
if not data:
# Socket isn't giving us any data, so it is dead or broken:
raise BrokenSocketException()
@@ -76,11 +77,17 @@ class IRCConnection(object):

def _send(self, msg):
"""Send data to the server."""
# Ensure that we only send one message at a time with a blocking lock:
with self._lock:
with self._send_lock:
self._sock.sendall(msg + "\r\n")
self.logger.debug(msg)

def _quit(self, msg=None):
"""Issue a quit message to the server."""
if msg:
self._send("QUIT :{0}".format(msg))
else:
self._send("QUIT")

def say(self, target, msg):
"""Send a private message to a target on the server."""
msg = "PRIVMSG {0} :{1}".format(target, msg)
@@ -106,14 +113,16 @@ class IRCConnection(object):
msg = "JOIN {0}".format(chan)
self._send(msg)

def part(self, chan):
"""Part from a channel on the server."""
msg = "PART {0}".format(chan)
self._send(msg)
def part(self, chan, msg=None):
"""Part from a channel on the server, optionally using an message."""
if msg:
self._send("PART {0} :{1}".format(chan, msg))
else:
self._send("PART {0}".format(chan))

def mode(self, chan, level, msg):
def mode(self, target, level, msg):
"""Send a mode message to the server."""
msg = "MODE {0} {1} {2}".format(chan, level, msg)
msg = "MODE {0} {1} {2}".format(target, level, msg)
self._send(msg)

def pong(self, target):
@@ -123,19 +132,29 @@ class IRCConnection(object):

def loop(self):
"""Main loop for the IRC connection."""
self.is_running = True
self._is_running = True
read_buffer = ""
while 1:
try:
read_buffer += self._get()
except BrokenSocketException:
self.is_running = False
self._is_running = False
break

lines = read_buffer.split("\n")
read_buffer = lines.pop()
for line in lines:
self._process_message(line)
if not self.is_running:
if self.is_stopped():
self._close()
break

def stop(self, msg=None):
"""Request the IRC connection to close at earliest convenience."""
if self._is_running:
self._quit(msg)
self._is_running = False

def is_stopped(self):
"""Return whether the IRC connection has been (or is to be) closed."""
return not self._is_running

+ 19
- 22
earwigbot/irc/frontend.py View File

@@ -20,12 +20,9 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

import logging
import re

from earwigbot.commands import command_manager
from earwigbot.irc import IRCConnection, Data, BrokenSocketException
from earwigbot.config import config
from earwigbot.irc import IRCConnection, Data

__all__ = ["Frontend"]

@@ -41,13 +38,14 @@ class Frontend(IRCConnection):
"""
sender_regex = re.compile(":(.*?)!(.*?)@(.*?)\Z")

def __init__(self):
self.logger = logging.getLogger("earwigbot.frontend")
cf = config.irc["frontend"]
def __init__(self, bot):
self.bot = bot
self.logger = bot.logger.getChild("frontend")

cf = bot.config.irc["frontend"]
base = super(Frontend, self)
base.__init__(cf["host"], cf["port"], cf["nick"], cf["ident"],
cf["realname"], self.logger)
command_manager.load(self)
cf["realname"])
self._connect()

def _process_message(self, line):
@@ -58,36 +56,35 @@ class Frontend(IRCConnection):
if line[1] == "JOIN":
data.nick, data.ident, data.host = self.sender_regex.findall(line[0])[0]
data.chan = line[2]
# Check for 'join' hooks in our commands:
command_manager.check("join", data)
data.parse_args()
self.bot.commands.check("join", data)

elif line[1] == "PRIVMSG":
data.nick, data.ident, data.host = self.sender_regex.findall(line[0])[0]
data.msg = " ".join(line[3:])[1:]
data.chan = line[2]
data.parse_args()

if data.chan == config.irc["frontend"]["nick"]:
if data.chan == self.bot.config.irc["frontend"]["nick"]:
# This is a privmsg to us, so set 'chan' as the nick of the
# sender, then check for private-only command hooks:
data.chan = data.nick
command_manager.check("msg_private", data)
self.bot.commands.check("msg_private", data)
else:
# Check for public-only command hooks:
command_manager.check("msg_public", data)
self.bot.commands.check("msg_public", data)

# Check for command hooks that apply to all messages:
command_manager.check("msg", data)
self.bot.commands.check("msg", data)

# If we are pinged, pong back:
elif line[0] == "PING":
elif line[0] == "PING": # If we are pinged, pong back
self.pong(line[1])

# On successful connection to the server:
elif line[1] == "376":
elif line[1] == "376": # On successful connection to the server
# If we're supposed to auth to NickServ, do that:
try:
username = config.irc["frontend"]["nickservUsername"]
password = config.irc["frontend"]["nickservPassword"]
username = self.bot.config.irc["frontend"]["nickservUsername"]
password = self.bot.config.irc["frontend"]["nickservPassword"]
except KeyError:
pass
else:
@@ -95,5 +92,5 @@ class Frontend(IRCConnection):
self.say("NickServ", msg)

# Join all of our startup channels:
for chan in config.irc["frontend"]["channels"]:
for chan in self.bot.config.irc["frontend"]["channels"]:
self.join(chan)

+ 26
- 23
earwigbot/irc/watcher.py View File

@@ -21,10 +21,8 @@
# SOFTWARE.

import imp
import logging

from earwigbot.irc import IRCConnection, RC, BrokenSocketException
from earwigbot.config import config
from earwigbot.irc import IRCConnection, RC

__all__ = ["Watcher"]

@@ -35,17 +33,18 @@ class Watcher(IRCConnection):
The IRC watcher runs on a wiki recent-changes server and listens for
edits. Users cannot interact with this part of the bot. When an event
occurs, we run it through some rules stored in our config, which can result
in wiki bot tasks being started (located in tasks/) or messages being sent
to channels on the IRC frontend.
in wiki bot tasks being started or messages being sent to channels on the
IRC frontend.
"""

def __init__(self, frontend=None):
self.logger = logging.getLogger("earwigbot.watcher")
cf = config.irc["watcher"]
def __init__(self, bot):
self.bot = bot
self.logger = bot.logger.getChild("watcher")

cf = bot.config.irc["watcher"]
base = super(Watcher, self)
base.__init__(cf["host"], cf["port"], cf["nick"], cf["ident"],
cf["realname"], self.logger)
self.frontend = frontend
cf["realname"])
self._prepare_process_hook()
self._connect()

@@ -58,7 +57,7 @@ class Watcher(IRCConnection):

# Ignore messages originating from channels not in our list, to
# prevent someone PMing us false data:
if chan not in config.irc["watcher"]["channels"]:
if chan not in self.bot.config.irc["watcher"]["channels"]:
return

msg = " ".join(line[3:])[1:]
@@ -72,33 +71,35 @@ class Watcher(IRCConnection):

# When we've finished starting up, join all watcher channels:
elif line[1] == "376":
for chan in config.irc["watcher"]["channels"]:
for chan in self.bot.config.irc["watcher"]["channels"]:
self.join(chan)

def _prepare_process_hook(self):
"""Create our RC event process hook from information in config.

This will get put in the function self._process_hook, which takes an RC
object and returns a list of frontend channels to report this event to.
This will get put in the function self._process_hook, which takes the
Bot object and an RC object and returns a list of frontend channels to
report this event to.
"""
# Set a default RC process hook that does nothing:
self._process_hook = lambda rc: ()
try:
rules = config.data["rules"]
rules = self.bot.config.data["rules"]
except KeyError:
return
module = imp.new_module("_rc_event_processing_rules")
path = self.bot.config.path
try:
exec compile(rules, config.config_path, "exec") in module.__dict__
exec compile(rules, path, "exec") in module.__dict__
except Exception:
e = "Could not compile config file's RC event rules"
e = "Could not compile config file's RC event rules:"
self.logger.exception(e)
return
self._process_hook_module = module
try:
self._process_hook = module.process
except AttributeError:
e = "RC event rules compiled correctly, but no process(rc) function was found"
e = "RC event rules compiled correctly, but no process(bot, rc) function was found"
self.logger.error(e)
return

@@ -110,8 +111,10 @@ class Watcher(IRCConnection):
self._prepare_process_hook() from information in the "rules" section of
our config.
"""
chans = self._process_hook(rc)
if chans and self.frontend:
pretty = rc.prettify()
for chan in chans:
self.frontend.say(chan, pretty)
chans = self._process_hook(self.bot, rc)
with self.bot.component_lock:
frontend = self.bot.frontend
if chans and frontend and not frontend.is_stopped():
pretty = rc.prettify()
for chan in chans:
frontend.say(chan, pretty)

+ 0
- 132
earwigbot/main.py View File

@@ -1,132 +0,0 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

"""
EarwigBot's Main Module

The core is essentially responsible for starting the various bot components
(irc, scheduler, etc) and making sure they are all happy. An explanation of the
different components follows:

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 logging
import threading
import time

from earwigbot.config import config
from earwigbot.irc import Frontend, Watcher
from earwigbot.tasks import task_manager

logger = logging.getLogger("earwigbot")

def irc_watcher(frontend=None):
"""Function to handle the IRC watcher as another thread (if frontend and/or
scheduler is enabled), otherwise run as the main thread."""
while 1: # Restart the watcher component if it breaks (and nothing else)
watcher = Watcher(frontend)
try:
watcher.loop()
except:
logger.exception("Watcher had an error")
time.sleep(5) # Sleep a bit before restarting watcher
logger.warn("Watcher 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()
task_manager.schedule()
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."""
logger.info("Starting IRC frontend")
frontend = Frontend()

if config.components.get("wiki_schedule"):
logger.info("Starting wiki scheduler")
task_manager.load()
t_scheduler = threading.Thread(target=wiki_scheduler)
t_scheduler.name = "wiki-scheduler"
t_scheduler.daemon = True
t_scheduler.start()

if config.components.get("irc_watcher"):
logger.info("Starting IRC watcher")
t_watcher = threading.Thread(target=irc_watcher, args=(frontend,))
t_watcher.name = "irc-watcher"
t_watcher.daemon = True
t_watcher.start()

frontend.loop()

def main():
if config.components.get("irc_frontend"):
# Make the frontend run on our primary thread if enabled, and enable
# additional components through that function:
irc_frontend()

elif config.components.get("wiki_schedule"):
# Run the scheduler on the main thread, but also run the IRC watcher on
# another thread iff it is enabled:
logger.info("Starting wiki scheduler")
task_manager.load()
if "irc_watcher" in enabled:
logger.info("Starting IRC watcher")
t_watcher = threading.Thread(target=irc_watcher)
t_watcher.name = "irc-watcher"
t_watcher.daemon = True
t_watcher.start()
wiki_scheduler()

elif config.components.get("irc_watcher"):
# The IRC watcher is our only enabled component, so run its function
# only and don't worry about anything else:
logger.info("Starting IRC watcher")
irc_watcher()

else: # Nothing is enabled!
logger.critical("No bot parts are enabled; stopping")
exit(1)

+ 216
- 0
earwigbot/managers.py View File

@@ -0,0 +1,216 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

import imp
from os import listdir, path
from re import sub
from threading import Lock, Thread
from time import gmtime, strftime

from earwigbot.commands import BaseCommand
from earwigbot.tasks import BaseTask

__all__ = ["CommandManager", "TaskManager"]

class _ResourceManager(object):
"""
EarwigBot's Base Resource Manager

Resources are essentially objects dynamically loaded by the bot, both
packaged with it (built-in resources) and created by users (plugins, aka
custom resources). Currently, the only two types of resources are IRC
commands and bot tasks. These are both loaded from two locations: the
earwigbot.commands and earwigbot.tasks packages, and the commands/ and
tasks/ directories within the bot's working directory.

This class handles the low-level tasks of (re)loading resources via load(),
retrieving specific resources via get(), and iterating over all resources
via __iter__(). If iterating over resources, it is recommended to acquire
self.lock beforehand and release it afterwards (alternatively, wrap your
code in a `with` statement) so an attempt at reloading resources in another
thread won't disrupt your iteration.
"""
def __init__(self, bot, name, attribute, base):
self.bot = bot
self.logger = bot.logger.getChild(name)

self._resources = {}
self._resource_name = name # e.g. "commands" or "tasks"
self._resource_attribute = attribute # e.g. "Command" or "Task"
self._resource_base = base # e.g. BaseCommand or BaseTask
self._resource_access_lock = Lock()

@property
def lock(self):
return self._resource_access_lock

def __iter__(self):
for name in self._resources:
yield name

def _load_resource(self, name, path):
"""Load a specific resource from a module, identified by name and path.

We'll first try to import it using imp magic, and if that works, make
an instance of the 'Command' class inside (assuming it is an instance
of BaseCommand), add it to self._commands, and log the addition. Any
problems along the way will either be ignored or logged.
"""
f, path, desc = imp.find_module(name, [path])
try:
module = imp.load_module(name, f, path, desc)
except Exception:
e = "Couldn't load module {0} (from {1})"
self.logger.exception(e.format(name, path))
return
finally:
f.close()

attr = self._resource_attribute
if not hasattr(module, attr):
return # No resources in this module
resource_class = getattr(module, attr)
try:
resource = resource_class(self.bot) # Create instance of resource
except Exception:
e = "Error instantiating {0} class in {1} (from {2})"
self.logger.exception(e.format(attr, name, path))
return
if not isinstance(resource, self._resource_base):
return

self._resources[resource.name] = resource
self.logger.debug("Loaded {0} {1}".format(attr.lower(), resource.name))

def _load_directory(self, dir):
"""Load all valid resources in a given directory."""
processed = []
for name in listdir(dir):
if not name.endswith(".py") and not name.endswith(".pyc"):
continue
if name.startswith("_") or name.startswith("."):
continue
modname = sub("\.pyc?$", "", name) # Remove extension
if modname not in processed:
self._load_resource(modname, dir)
processed.append(modname)

def load(self):
"""Load (or reload) all valid resources into self._resources."""
name = self._resource_name # e.g. "commands" or "tasks"
with self.lock:
self._resources.clear()
builtin_dir = path.join(path.dirname(__file__), name)
plugins_dir = path.join(self.bot.config.root_dir, name)
self._load_directory(builtin_dir) # Built-in resources
self._load_directory(plugins_dir) # Custom resources, aka plugins

msg = "Loaded {0} {1}: {2}"
resources = ", ".join(self._resources.keys())
self.logger.info(msg.format(len(self._resources), name, resources))

def get(self, key):
"""Return the class instance associated with a certain resource.

Will raise KeyError if the resource (command or task) is not found.
"""
return self._resources[key]


class CommandManager(_ResourceManager):
"""
EarwigBot's IRC Command Manager

Manages (i.e., loads, reloads, and calls) IRC commands.
"""
def __init__(self, bot):
base = super(CommandManager, self)
base.__init__(bot, "commands", "Command", BaseCommand)

def check(self, hook, data):
"""Given an IRC event, check if there's anything we can respond to."""
self.lock.acquire()
for command in self._resources.itervalues():
if hook in command.hooks and command._wrap_check(data):
self.lock.release()
command._wrap_process(data)
return
self.lock.release()


class TaskManager(_ResourceManager):
"""
EarwigBot's Bot Task Manager

Manages (i.e., loads, reloads, schedules, and runs) wiki bot tasks.
"""
def __init__(self, bot):
super(TaskManager, self).__init__(bot, "tasks", "Task", BaseTask)

def _wrapper(self, task, **kwargs):
"""Wrapper for task classes: run the task and catch any errors."""
try:
task.run(**kwargs)
except Exception:
msg = "Task '{0}' raised an exception and had to stop:"
self.logger.exception(msg.format(task.name))
else:
msg = "Task '{0}' finished without error"
self.logger.info(msg.format(task.name))

def start(self, task_name, **kwargs):
"""Start a given task in a new daemon thread, and return the thread.

kwargs are passed to task.run(). If the task is not found, None will be
returned.
"""
msg = "Starting task '{0}' in a new thread"
self.logger.info(msg.format(task_name))

try:
task = self.get(task_name)
except KeyError:
e = "Couldn't find task '{0}'"
self.logger.error(e.format(task_name))
return

task_thread = Thread(target=self._wrapper, args=(task,), kwargs=kwargs)
start_time = strftime("%b %d %H:%M:%S")
task_thread.name = "{0} ({1})".format(task_name, start_time)
task_thread.daemon = True
task_thread.start()
return task_thread

def schedule(self, now=None):
"""Start all tasks that are supposed to be run at a given time."""
if not now:
now = gmtime()
# Get list of tasks to run this turn:
tasks = self.bot.config.schedule(now.tm_min, now.tm_hour, now.tm_mday,
now.tm_mon, now.tm_wday)

for task in tasks:
if isinstance(task, list): # They've specified kwargs,
self.start(task[0], **task[1]) # so pass those to start
else: # Otherwise, just pass task_name
self.start(task)

+ 0
- 65
earwigbot/runner.py View File

@@ -1,65 +0,0 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

"""
EarwigBot Runner

This is a very simple script that can be run from anywhere. It will add the
'earwigbot' package to sys.path if it's not already in there (i.e., it hasn't
been "installed"), accept a root_dir (the directory in which bot.py is located)
and a decryption key from raw_input (if passwords are encrypted), then call
config.load() and decrypt any passwords, and finally call the main() function
of earwigbot.main.
"""

from os import path
import sys

def run():
pkg_dir = path.split(path.dirname(path.abspath(__file__)))[0]
if pkg_dir not in sys.path:
sys.path.insert(0, pkg_dir)

from earwigbot.config import config
from earwigbot import main

root_dir = raw_input()
config_path = path.join(root_dir, "config.yml")
log_dir = path.join(root_dir, "logs")
is_encrypted = config.load(config_path, log_dir)
if is_encrypted:
config._decryption_key = raw_input()
config.decrypt(config.wiki, "password")
config.decrypt(config.wiki, "search", "credentials", "key")
config.decrypt(config.wiki, "search", "credentials", "secret")
config.decrypt(config.irc, "frontend", "nickservPassword")
config.decrypt(config.irc, "watcher", "nickservPassword")

try:
main.main()
except KeyboardInterrupt:
main.logger.critical("KeyboardInterrupt: stopping main bot loop")
exit(1)

if __name__ == "__main__":
run()

+ 24
- 126
earwigbot/tasks/__init__.py View File

@@ -21,43 +21,44 @@
# SOFTWARE.

"""
EarwigBot's Wiki Task Manager
EarwigBot's Bot Tasks

This package provides the wiki bot "tasks" EarwigBot runs. This module contains
the BaseTask class (import with `from earwigbot.tasks import BaseTask`) and an
internal _TaskManager class. This can be accessed through the `task_manager`
singleton.
"""
the BaseTask class (import with `from earwigbot.tasks import BaseTask`),
whereas the package contains various built-in tasks. Additional tasks can be
installed as plugins in the bot's working directory.

import logging
import os
import sys
import threading
import time
To run a task, use bot.tasks.start(name, **kwargs). **kwargs get passed to the
Task's run() function.
"""

from earwigbot import wiki
from earwigbot.config import config

__all__ = ["BaseTask", "task_manager"]
__all__ = ["BaseTask"]

class BaseTask(object):
"""A base class for bot tasks that edit Wikipedia."""
name = None
number = 0

def __init__(self):
def __init__(self, bot):
"""Constructor for new tasks.

This is called once immediately after the task class is loaded by
the task manager (in tasks._load_task()).
the task manager (in tasks._load_task()). Don't override this directly
(or if you do, remember super(Task, self).__init()) - use setup().
"""
pass
self.bot = bot
self.config = bot.config
self.logger = bot.tasks.logger.getChild(self.name)
self.setup()

def _setup_logger(self):
"""Set up a basic module-level logger."""
logger_name = ".".join(("earwigbot", "tasks", self.name))
self.logger = logging.getLogger(logger_name)
self.logger.setLevel(logging.DEBUG)
def setup(self):
"""Hook called immediately after the task is loaded.

Does nothing by default; feel free to override.
"""
pass

def run(self, **kwargs):
"""Main entry point to run a given task.
@@ -83,7 +84,7 @@ class BaseTask(object):
If the config value is not found, we just return the arg as-is.
"""
try:
summary = config.wiki["summary"]
summary = self.bot.config.wiki["summary"]
except KeyError:
return comment
return summary.replace("$1", str(self.number)).replace("$2", comment)
@@ -108,10 +109,10 @@ class BaseTask(object):
try:
site = self.site
except AttributeError:
site = wiki.get_site()
site = self.bot.wiki.get_site()

try:
cfg = config.wiki["shutoff"]
cfg = self.config.wiki["shutoff"]
except KeyError:
return False
title = cfg.get("page", "User:$1/Shutoff/Task $2")
@@ -128,106 +129,3 @@ class BaseTask(object):

self.logger.warn("Emergency task shutoff has been enabled!")
return True


class _TaskManager(object):
def __init__(self):
self.logger = logging.getLogger("earwigbot.commands")
self._base_dir = os.path.dirname(os.path.abspath(__file__))
self._tasks = {}

def _load_task(self, filename):
"""Load a specific task from a module, identified by file name."""
# Strip .py from the filename's end and join with our package name:
name = ".".join(("tasks", filename[:-3]))
try:
__import__(name)
except:
self.logger.exception("Couldn't load file {0}:".format(filename))
return

try:
task = sys.modules[name].Task()
except AttributeError:
return # No task in this module
if not isinstance(task, BaseTask):
return
task._setup_logger()

self._tasks[task.name] = task
self.logger.debug("Added task {0}".format(task.name))

def _wrapper(self, task, **kwargs):
"""Wrapper for task classes: run the task and catch any errors."""
try:
task.run(**kwargs)
except:
msg = "Task '{0}' raised an exception and had to stop"
self.logger.exception(msg.format(task.name))
else:
msg = "Task '{0}' finished without error"
self.logger.info(msg.format(task.name))

def load(self):
"""Load all valid tasks from tasks/ into self._tasks."""
files = os.listdir(self._base_dir)
files.sort()

for filename in files:
if filename.startswith("_") or not filename.endswith(".py"):
continue
self._load_task(filename)

msg = "Found {0} tasks: {1}"
tasks = ', '.join(self._tasks.keys())
self.logger.info(msg.format(len(self._tasks), tasks))

def schedule(self, now=None):
"""Start all tasks that are supposed to be run at a given time."""
if not now:
now = time.gmtime()
# Get list of tasks to run this turn:
tasks = config.schedule(now.tm_min, now.tm_hour, now.tm_mday,
now.tm_mon, now.tm_wday)

for task in tasks:
if isinstance(task, list): # They've specified kwargs,
self.start(task[0], **task[1]) # so pass those to start_task
else: # Otherwise, just pass task_name
self.start(task)

def start(self, task_name, **kwargs):
"""Start a given task in a new thread. Pass args to the task's run()
function."""
msg = "Starting task '{0}' in a new thread"
self.logger.info(msg.format(task_name))

try:
task = self._tasks[task_name]
except KeyError:
e = "Couldn't find task '{0}': bot/tasks/{0}.py does not exist"
self.logger.error(e.format(task_name))
return

func = lambda: self._wrapper(task, **kwargs)
task_thread = threading.Thread(target=func)
start_time = time.strftime("%b %d %H:%M:%S")
task_thread.name = "{0} ({1})".format(task_name, start_time)

# Stop bot task threads automagically if the main bot stops:
task_thread.daemon = True

task_thread.start()

def get(self, task_name):
"""Return the class instance associated with a certain task name.

Will raise KeyError if the task is not found.
"""
return self._tasks[task_name]

def get_all(self):
"""Return our dict of all loaded tasks."""
return self._tasks

task_manager = _TaskManager()

+ 3
- 1
earwigbot/tasks/afc_catdelink.py View File

@@ -22,12 +22,14 @@

from earwigbot.tasks import BaseTask

__all__ = ["Task"]

class Task(BaseTask):
"""A task to delink mainspace categories in declined [[WP:AFC]]
submissions."""
name = "afc_catdelink"

def __init__(self):
def setup(self):
pass

def run(self, **kwargs):


+ 5
- 5
earwigbot/tasks/afc_copyvios.py View File

@@ -26,18 +26,18 @@ from threading import Lock

import oursql

from earwigbot import wiki
from earwigbot.config import config
from earwigbot.tasks import BaseTask

__all__ = ["Task"]

class Task(BaseTask):
"""A task to check newly-edited [[WP:AFC]] submissions for copyright
violations."""
name = "afc_copyvios"
number = 1

def __init__(self):
cfg = config.tasks.get(self.name, {})
def setup(self):
cfg = self.config.tasks.get(self.name, {})
self.template = cfg.get("template", "AfC suspected copyvio")
self.ignore_list = cfg.get("ignoreList", [])
self.min_confidence = cfg.get("minConfidence", 0.5)
@@ -63,7 +63,7 @@ class Task(BaseTask):
if self.shutoff_enabled():
return
title = kwargs["page"]
page = wiki.get_site().get_page(title)
page = self.bot.wiki.get_site().get_page(title)
with self.db_access_lock:
self.conn = oursql.connect(**self.conn_data)
self.process(page)


+ 3
- 1
earwigbot/tasks/afc_dailycats.py View File

@@ -22,12 +22,14 @@

from earwigbot.tasks import BaseTask

__all__ = ["Task"]

class Task(BaseTask):
""" A task to create daily categories for [[WP:AFC]]."""
name = "afc_dailycats"
number = 3

def __init__(self):
def setup(self):
pass

def run(self, **kwargs):


+ 22
- 21
earwigbot/tasks/afc_history.py View File

@@ -32,14 +32,9 @@ from numpy import arange
import oursql

from earwigbot import wiki
from earwigbot.config import config
from earwigbot.tasks import BaseTask

# Valid submission statuses:
STATUS_NONE = 0
STATUS_PEND = 1
STATUS_DECLINE = 2
STATUS_ACCEPT = 3
__all__ = ["Task"]

class Task(BaseTask):
"""A task to generate charts about AfC submissions over time.
@@ -57,8 +52,14 @@ class Task(BaseTask):
"""
name = "afc_history"

def __init__(self):
cfg = config.tasks.get(self.name, {})
# Valid submission statuses:
STATUS_NONE = 0
STATUS_PEND = 1
STATUS_DECLINE = 2
STATUS_ACCEPT = 3

def setup(self):
cfg = self.config.tasks.get(self.name, {})
self.num_days = cfg.get("days", 90)
self.categories = cfg.get("categories", {})

@@ -73,7 +74,7 @@ class Task(BaseTask):
self.db_access_lock = Lock()

def run(self, **kwargs):
self.site = wiki.get_site()
self.site = self.bot.wiki.get_site()
with self.db_access_lock:
self.conn = oursql.connect(**self.conn_data)
@@ -137,7 +138,7 @@ class Task(BaseTask):
stored = cursor.fetchall()
status = self.get_status(title, pageid)

if status == STATUS_NONE:
if status == self.STATUS_NONE:
if stored:
cursor.execute(q_delete, (pageid,))
continue
@@ -155,14 +156,14 @@ class Task(BaseTask):
ns = page.namespace()

if ns == wiki.NS_FILE_TALK: # Ignore accepted FFU requests
return STATUS_NONE
return self.STATUS_NONE

if ns == wiki.NS_TALK:
new_page = page.toggle_talk()
sleep(2)
if new_page.is_redirect():
return STATUS_NONE # Ignore accepted AFC/R requests
return STATUS_ACCEPT
return self.STATUS_NONE # Ignore accepted AFC/R requests
return self.STATUS_ACCEPT

cats = self.categories
sq = self.site.sql_query
@@ -170,16 +171,16 @@ class Task(BaseTask):
match = lambda cat: list(sq(query, (cat.replace(" ", "_"), pageid)))

if match(cats["pending"]):
return STATUS_PEND
return self.STATUS_PEND
elif match(cats["unsubmitted"]):
return STATUS_NONE
return self.STATUS_NONE
elif match(cats["declined"]):
return STATUS_DECLINE
return STATUS_NONE
return self.STATUS_DECLINE
return self.STATUS_NONE

def get_date_counts(self, date):
query = "SELECT COUNT(*) FROM page WHERE page_date = ? AND page_status = ?"
statuses = [STATUS_PEND, STATUS_DECLINE, STATUS_ACCEPT]
statuses = [self.STATUS_PEND, self.STATUS_DECLINE, self.STATUS_ACCEPT]
counts = {}
with self.conn.cursor() as cursor:
for status in statuses:
@@ -193,9 +194,9 @@ class Task(BaseTask):
plt.xlabel(self.graph.get("xaxis", "Date"))
plt.ylabel(self.graph.get("yaxis", "Submissions"))

pends = [d[STATUS_PEND] for d in data.itervalues()]
declines = [d[STATUS_DECLINE] for d in data.itervalues()]
accepts = [d[STATUS_ACCEPT] for d in data.itervalues()]
pends = [d[self.STATUS_PEND] for d in data.itervalues()]
declines = [d[self.STATUS_DECLINE] for d in data.itervalues()]
accepts = [d[self.STATUS_ACCEPT] for d in data.itervalues()]
pends_declines = [p + d for p, d in zip(pends, declines)]
ind = arange(len(data))
xsize = self.graph.get("xsize", 1200)


+ 35
- 34
earwigbot/tasks/afc_statistics.py View File

@@ -30,17 +30,9 @@ from time import sleep
import oursql

from earwigbot import wiki
from earwigbot.config import config
from earwigbot.tasks import BaseTask

# Chart status number constants:
CHART_NONE = 0
CHART_PEND = 1
CHART_DRAFT = 2
CHART_REVIEW = 3
CHART_ACCEPT = 4
CHART_DECLINE = 5
CHART_MISPLACE = 6
__all__ = ["Task"]

class Task(BaseTask):
"""A task to generate statistics for WikiProject Articles for Creation.
@@ -53,8 +45,17 @@ class Task(BaseTask):
name = "afc_statistics"
number = 2

def __init__(self):
self.cfg = cfg = config.tasks.get(self.name, {})
# Chart status number constants:
CHART_NONE = 0
CHART_PEND = 1
CHART_DRAFT = 2
CHART_REVIEW = 3
CHART_ACCEPT = 4
CHART_DECLINE = 5
CHART_MISPLACE = 6

def setup(self):
self.cfg = cfg = self.config.tasks.get(self.name, {})

# Set some wiki-related attributes:
self.pagename = cfg.get("page", "Template:AFC statistics")
@@ -83,7 +84,7 @@ class Task(BaseTask):
(self.save()). We will additionally create an SQL connection with our
local database.
"""
self.site = wiki.get_site()
self.site = self.bot.wiki.get_site()
with self.db_access_lock:
self.conn = oursql.connect(**self.conn_data)

@@ -206,7 +207,7 @@ class Task(BaseTask):
replag = self.site.get_replag()
self.logger.debug("Server replag is {0}".format(replag))
if replag > 600 and not kwargs.get("ignore_replag"):
msg = "Sync canceled as replag ({0} secs) is greater than ten minutes."
msg = "Sync canceled as replag ({0} secs) is greater than ten minutes"
self.logger.warn(msg.format(replag))
return

@@ -286,7 +287,7 @@ class Task(BaseTask):
query = """DELETE FROM page, row USING page JOIN row
ON page_id = row_id WHERE row_chart IN (?, ?)
AND ADDTIME(page_special_time, '36:00:00') < NOW()"""
cursor.execute(query, (CHART_ACCEPT, CHART_DECLINE))
cursor.execute(query, (self.CHART_ACCEPT, self.CHART_DECLINE))

def update(self, **kwargs):
"""Update a page by name, regardless of whether anything has changed.
@@ -333,7 +334,7 @@ class Task(BaseTask):

namespace = self.site.get_page(title).namespace()
status, chart = self.get_status_and_chart(content, namespace)
if chart == CHART_NONE:
if chart == self.CHART_NONE:
msg = "Could not find a status for [[{0}]]".format(title)
self.logger.warn(msg)
return
@@ -367,7 +368,7 @@ class Task(BaseTask):

namespace = self.site.get_page(title).namespace()
status, chart = self.get_status_and_chart(content, namespace)
if chart == CHART_NONE:
if chart == self.CHART_NONE:
self.untrack_page(cursor, pageid)
return

@@ -499,23 +500,23 @@ class Task(BaseTask):
statuses = self.get_statuses(content)

if "R" in statuses:
status, chart = "r", CHART_REVIEW
status, chart = "r", self.CHART_REVIEW
elif "H" in statuses:
status, chart = "p", CHART_DRAFT
status, chart = "p", self.CHART_DRAFT
elif "P" in statuses:
status, chart = "p", CHART_PEND
status, chart = "p", self.CHART_PEND
elif "T" in statuses:
status, chart = None, CHART_NONE
status, chart = None, self.CHART_NONE
elif "D" in statuses:
status, chart = "d", CHART_DECLINE
status, chart = "d", self.CHART_DECLINE
else:
status, chart = None, CHART_NONE
status, chart = None, self.CHART_NONE

if namespace == wiki.NS_MAIN:
if not statuses:
status, chart = "a", CHART_ACCEPT
status, chart = "a", self.CHART_ACCEPT
else:
status, chart = None, CHART_MISPLACE
status, chart = None, self.CHART_MISPLACE

return status, chart

@@ -614,23 +615,23 @@ class Task(BaseTask):
returned if we cannot determine when the page was "special"-ed, or if
it was "special"-ed more than 250 edits ago.
"""
if chart ==CHART_NONE:
if chart ==self.CHART_NONE:
return None, None, None
elif chart == CHART_MISPLACE:
elif chart == self.CHART_MISPLACE:
return self.get_create(pageid)
elif chart == CHART_ACCEPT:
elif chart == self.CHART_ACCEPT:
search_for = None
search_not = ["R", "H", "P", "T", "D"]
elif chart == CHART_DRAFT:
elif chart == self.CHART_DRAFT:
search_for = "H"
search_not = []
elif chart == CHART_PEND:
elif chart == self.CHART_PEND:
search_for = "P"
search_not = []
elif chart == CHART_REVIEW:
elif chart == self.CHART_REVIEW:
search_for = "R"
search_not = []
elif chart == CHART_DECLINE:
elif chart == self.CHART_DECLINE:
search_for = "D"
search_not = ["R", "H", "P", "T"]

@@ -684,12 +685,12 @@ class Task(BaseTask):
"""
notes = ""

ignored_charts = [CHART_NONE, CHART_ACCEPT, CHART_DECLINE]
ignored_charts = [self.CHART_NONE, self.CHART_ACCEPT, self.CHART_DECLINE]
if chart in ignored_charts:
return notes

statuses = self.get_statuses(content)
if "D" in statuses and chart != CHART_MISPLACE:
if "D" in statuses and chart != self.CHART_MISPLACE:
notes += "|nr=1" # Submission was resubmitted

if len(content) < 500:
@@ -706,7 +707,7 @@ class Task(BaseTask):
if time_since_modify > max_time:
notes += "|no=1" # Submission hasn't been touched in over 4 days

if chart in [CHART_PEND, CHART_DRAFT]:
if chart in [self.CHART_PEND, self.CHART_DRAFT]:
submitter = self.site.get_user(s_user)
try:
if submitter.blockinfo():


+ 3
- 1
earwigbot/tasks/afc_undated.py View File

@@ -22,11 +22,13 @@

from earwigbot.tasks import BaseTask

__all__ = ["Task"]

class Task(BaseTask):
"""A task to clear [[Category:Undated AfC submissions]]."""
name = "afc_undated"

def __init__(self):
def setup(self):
pass

def run(self, **kwargs):


+ 3
- 1
earwigbot/tasks/blptag.py View File

@@ -22,12 +22,14 @@

from earwigbot.tasks import BaseTask

__all__ = ["Task"]

class Task(BaseTask):
"""A task to add |blp=yes to {{WPB}} or {{WPBS}} when it is used along with
{{WP Biography}}."""
name = "blptag"

def __init__(self):
def setup(self):
pass

def run(self, **kwargs):


+ 3
- 1
earwigbot/tasks/feed_dailycats.py View File

@@ -22,11 +22,13 @@

from earwigbot.tasks import BaseTask

__all__ = ["Task"]

class Task(BaseTask):
"""A task to create daily categories for [[WP:FEED]]."""
name = "feed_dailycats"

def __init__(self):
def setup(self):
pass

def run(self, **kwargs):


earwigbot/commands/restart.py → earwigbot/tasks/wikiproject_tagger.py View File

@@ -20,18 +20,16 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

from earwigbot.commands import BaseCommand
from earwigbot.config import config
from earwigbot.tasks import BaseTask

class Command(BaseCommand):
"""Restart the bot. Only the owner can do this."""
name = "restart"
__all__ = ["Task"]

def process(self, data):
if data.host not in config.irc["permissions"]["owners"]:
msg = "you must be a bot owner to use this command."
self.connection.reply(data, msg)
return
class Task(BaseTask):
"""A task to tag talk pages with WikiProject Banners."""
name = "wikiproject_tagger"

self.connection.logger.info("Restarting bot per owner request")
self.connection.is_running = False
def setup(self):
pass

def run(self, **kwargs):
pass

+ 3
- 1
earwigbot/tasks/wrongmime.py View File

@@ -22,12 +22,14 @@

from earwigbot.tasks import BaseTask

__all__ = ["Task"]

class Task(BaseTask):
"""A task to tag files whose extensions do not agree with their MIME
type."""
name = "wrongmime"

def __init__(self):
def setup(self):
pass

def run(self, **kwargs):


+ 88
- 0
earwigbot/util.py View File

@@ -0,0 +1,88 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

"""
This is EarwigBot's command-line utility, enabling you to easily start the
bot or run specific tasks.
"""

from argparse import ArgumentParser
import logging
from os import path
from time import sleep

from earwigbot import __version__
from earwigbot.bot import Bot

__all__ = ["main"]

def main():
version = "EarwigBot v{0}".format(__version__)
parser = ArgumentParser(description=__doc__)
parser.add_argument("path", nargs="?", metavar="PATH", default=path.curdir,
help="path to the bot's working directory, which will be created if it doesn't exist; current directory assumed if not specified")
parser.add_argument("-v", "--version", action="version", version=version)
parser.add_argument("-d", "--debug", action="store_true",
help="print all logs, including DEBUG-level messages")
parser.add_argument("-q", "--quiet", action="store_true",
help="don't print any logs except warnings and errors")
parser.add_argument("-t", "--task", metavar="NAME",
help="given the name of a task, the bot will run it instead of the main bot and then exit")
args = parser.parse_args()

level = logging.INFO
if args.debug and args.quiet:
parser.print_usage()
print "earwigbot: error: cannot show debug messages and be quiet at the same time"
return
if args.debug:
level = logging.DEBUG
elif args.quiet:
level = logging.WARNING
print version
print

bot = Bot(path.abspath(args.path), level=level)
if args.task:
thread = bot.tasks.start(args.task)
if not thread:
return
try:
while thread.is_alive(): # Keep it alive; it's a daemon
sleep(1)
except KeyboardInterrupt:
pass
finally:
if thread.is_alive():
bot.tasks.logger.warn("The task is will be killed")
else:
try:
bot.run()
except KeyboardInterrupt:
pass
finally:
if bot._keep_looping: # Indicates bot hasn't already been stopped
bot.stop()

if __name__ == "__main__":
main()

+ 11
- 7
earwigbot/wiki/__init__.py View File

@@ -27,18 +27,22 @@ This is a collection of classes and functions to read from and write to
Wikipedia and other wiki sites. No connection whatsoever to python-wikitools
written by Mr.Z-man, other than a similar purpose. We share no code.

Import the toolset with `from earwigbot import wiki`.
Import the toolset directly with `from earwigbot import wiki`. If using the
built-in integration with the rest of the bot, Bot() objects contain a `wiki`
attribute, which is a SitesDB object tied to the sites.db file located in the
same directory as config.yml. That object has the principal methods get_site,
add_site, and remove_site that should handle all of your Site (and thus, Page,
Category, and User) needs.
"""

import logging as _log
logger = _log.getLogger("earwigbot.wiki")
logger.addHandler(_log.NullHandler())

from earwigbot.wiki.category import *
from earwigbot.wiki.constants import *
from earwigbot.wiki.exceptions import *
from earwigbot.wiki.functions import *

from earwigbot.wiki.category import Category
from earwigbot.wiki.page import Page
from earwigbot.wiki.site import Site
from earwigbot.wiki.user import User
from earwigbot.wiki.page import *
from earwigbot.wiki.site import *
from earwigbot.wiki.sitesdb import *
from earwigbot.wiki.user import *

+ 2
- 0
earwigbot/wiki/category.py View File

@@ -22,6 +22,8 @@

from earwigbot.wiki.page import Page

__all__ = ["Category"]

class Category(Page):
"""
EarwigBot's Wiki Toolset: Category Class


+ 4
- 1
earwigbot/wiki/constants.py View File

@@ -27,13 +27,16 @@ This module defines some useful constants:
* USER_AGENT - our default User Agent when making API queries
* NS_* - default namespace IDs for easy lookup

Import with `from earwigbot.wiki import constants` or `from earwigbot.wiki.constants import *`.
Import directly with `from earwigbot.wiki import constants` or
`from earwigbot.wiki.constants import *`. These are also available from
earwigbot.wiki (e.g. `earwigbot.wiki.USER_AGENT`).
"""

# Default User Agent when making API queries:
from earwigbot import __version__ as _v
from platform import python_version as _p
USER_AGENT = "EarwigBot/{0} (Python/{1}; https://github.com/earwig/earwigbot)".format(_v, _p())
del _v, _p

# Default namespace IDs:
NS_MAIN = 0


+ 0
- 211
earwigbot/wiki/functions.py View File

@@ -1,211 +0,0 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

"""
EarwigBot's Wiki Toolset: Misc Functions

This module, a component of the wiki package, contains miscellaneous functions
that are not methods of any class, like get_site().

There's no need to import this module explicitly. All functions here are
automatically available from earwigbot.wiki.
"""

from cookielib import LWPCookieJar, LoadError
import errno
from getpass import getpass
from os import chmod, path
import platform
import stat

import earwigbot
from earwigbot.config import config
from earwigbot.wiki.exceptions import SiteNotFoundError
from earwigbot.wiki.site import Site

__all__ = ["get_site", "add_site", "del_site"]

_cookiejar = None

def _load_config():
"""Called by a config-requiring function, such as get_site(), when config
has not been loaded. This will usually happen only if we're running code
directly from Python's interpreter and not the bot itself, because
earwigbot.py or core/main.py will already call these functions.
"""
is_encrypted = config.load()
if is_encrypted: # Passwords in the config file are encrypted
key = getpass("Enter key to unencrypt bot passwords: ")
config._decryption_key = key
config.decrypt(config.wiki, "password")

def _get_cookiejar():
"""Returns a LWPCookieJar object loaded from our .cookies file. The same
one is returned every time.

The .cookies file is located in the project root, same directory as
config.yml and bot.py. If it doesn't exist, we will create the file and set
it to be readable and writeable only by us. If it exists but the
information inside is bogus, we will ignore it.

This is normally called by _get_site_object_from_dict() (in turn called by
get_site()), and the cookiejar is passed to our Site's constructor, used
when it makes API queries. This way, we can easily preserve cookies between
sites (e.g., for CentralAuth), making logins easier.
"""
global _cookiejar
if _cookiejar is not None:
return _cookiejar

cookie_file = path.join(config.root_dir, ".cookies")
_cookiejar = LWPCookieJar(cookie_file)

try:
_cookiejar.load()
except LoadError:
pass # File contains bad data, so ignore it completely
except IOError as e:
if e.errno == errno.ENOENT: # "No such file or directory"
# Create the file and restrict reading/writing only to the owner,
# so others can't peak at our cookies:
open(cookie_file, "w").close()
chmod(cookie_file, stat.S_IRUSR|stat.S_IWUSR)
else:
raise

return _cookiejar

def _get_site_object_from_dict(name, d):
"""Return a Site object based on the contents of a dict, probably acquired
through our config file, and a separate name.
"""
project = d.get("project")
lang = d.get("lang")
base_url = d.get("baseURL")
article_path = d.get("articlePath")
script_path = d.get("scriptPath")
sql = d.get("sql", {})
namespaces = d.get("namespaces", {})
login = (config.wiki.get("username"), config.wiki.get("password"))
cookiejar = _get_cookiejar()
user_agent = config.wiki.get("userAgent")
assert_edit = config.wiki.get("assert")
maxlag = config.wiki.get("maxlag")
search_config = config.wiki.get("search")

if user_agent:
user_agent = user_agent.replace("$1", earwigbot.__version__)
user_agent = user_agent.replace("$2", platform.python_version())

return Site(name=name, project=project, lang=lang, base_url=base_url,
article_path=article_path, script_path=script_path, sql=sql,
namespaces=namespaces, login=login, cookiejar=cookiejar,
user_agent=user_agent, assert_edit=assert_edit, maxlag=maxlag,
search_config=search_config)

def get_site(name=None, project=None, lang=None):
"""Returns a Site instance based on information from our config file.

With no arguments, returns the default site as specified by our config
file. This is default = config.wiki["defaultSite"];
config.wiki["sites"][default].

With `name` specified, returns the site specified by
config.wiki["sites"][name].

With `project` and `lang` specified, returns the site specified by the
member of config.wiki["sites"], `s`, for which s["project"] == project and
s["lang"] == lang.

We will attempt to login to the site automatically
using config.wiki["username"] and config.wiki["password"] if both are
defined.

Specifying a project without a lang or a lang without a project will raise
TypeError. If all three args are specified, `name` will be first tried,
then `project` and `lang`. If, with any number of args, a site cannot be
found in the config, SiteNotFoundError is raised.
"""
# Check if config has been loaded, and load it if it hasn't:
if not config.is_loaded():
_load_config()

# Someone specified a project without a lang (or a lang without a project)!
if (project is None and lang is not None) or (project is not None and
lang is None):
e = "Keyword arguments 'lang' and 'project' must be specified together."
raise TypeError(e)

# No args given, so return our default site (project is None implies lang
# is None, so we don't need to add that in):
if name is None and project is None:
try:
default = config.wiki["defaultSite"]
except KeyError:
e = "Default site is not specified in config."
raise SiteNotFoundError(e)
try:
site = config.wiki["sites"][default]
except KeyError:
e = "Default site specified by config is not in the config's sites list."
raise SiteNotFoundError(e)
return _get_site_object_from_dict(default, site)

# Name arg given, but don't look at others unless `name` isn't found:
if name is not None:
try:
site = config.wiki["sites"][name]
except KeyError:
if project is None: # Implies lang is None, so only name was given
e = "Site '{0}' not found in config.".format(name)
raise SiteNotFoundError(e)
for sitename, site in config.wiki["sites"].items():
if site["project"] == project and site["lang"] == lang:
return _get_site_object_from_dict(sitename, site)
e = "Neither site '{0}' nor site '{1}:{2}' found in config."
e.format(name, project, lang)
raise SiteNotFoundError(e)
else:
return _get_site_object_from_dict(name, site)

# If we end up here, then project and lang are both not None:
for sitename, site in config.wiki["sites"].items():
if site["project"] == project and site["lang"] == lang:
return _get_site_object_from_dict(sitename, site)
e = "Site '{0}:{1}' not found in config.".format(project, lang)
raise SiteNotFoundError(e)

def add_site():
"""STUB: config editing is required first.

Returns True if the site was added successfully or False if the site was
already in our config. Raises ConfigError if saving the updated file failed
for some reason."""
pass

def del_site(name):
"""STUB: config editing is required first.

Returns True if the site was removed successfully or False if the site was
not in our config originally. Raises ConfigError if saving the updated file
failed for some reason."""
pass

+ 5
- 3
earwigbot/wiki/page.py View File

@@ -28,6 +28,8 @@ from urllib import quote
from earwigbot.wiki.copyright import CopyrightMixin
from earwigbot.wiki.exceptions import *

__all__ = ["Page"]

class Page(CopyrightMixin):
"""
EarwigBot's Wiki Toolset: Page Class
@@ -174,7 +176,7 @@ class Page(CopyrightMixin):

Assuming the API is sound, this should not raise any exceptions.
"""
if result is None:
if not result:
params = {"action": "query", "rvprop": "user", "intoken": "edit",
"prop": "info|revisions", "rvlimit": 1, "rvdir": "newer",
"titles": self._title, "inprop": "protection|url"}
@@ -240,7 +242,7 @@ class Page(CopyrightMixin):
Don't call this directly, ever - use .get(force=True) if you want to
force content reloading.
"""
if result is None:
if not result:
params = {"action": "query", "prop": "revisions", "rvlimit": 1,
"rvprop": "content|timestamp", "titles": self._title}
result = self._site._api_query(params)
@@ -471,7 +473,7 @@ class Page(CopyrightMixin):
"""
if force:
self._load_wrapper()
if self._fullurl is not None:
if self._fullurl:
return self._fullurl
else:
slug = quote(self._title.replace(" ", "_"), safe="/:")


+ 42
- 32
earwigbot/wiki/site.py View File

@@ -43,6 +43,8 @@ from earwigbot.wiki.exceptions import *
from earwigbot.wiki.page import Page
from earwigbot.wiki.user import User

__all__ = ["Site"]

class Site(object):
"""
EarwigBot's Wiki Toolset: Site Class
@@ -71,18 +73,19 @@ class Site(object):
def __init__(self, name=None, project=None, lang=None, base_url=None,
article_path=None, script_path=None, sql=None,
namespaces=None, login=(None, None), cookiejar=None,
user_agent=None, assert_edit=None, maxlag=None,
search_config=(None, None)):
user_agent=None, use_https=False, assert_edit=None,
maxlag=None, search_config=(None, None)):
"""Constructor for new Site instances.

This probably isn't necessary to call yourself unless you're building a
Site that's not in your config and you don't want to add it - normally
all you need is tools.get_site(name), which creates the Site for you
based on your config file. We accept a bunch of kwargs, but the only
ones you really "need" are `base_url` and `script_path` - this is
enough to figure out an API url. `login`, a tuple of
(username, password), is highly recommended. `cookiejar` will be used
to store cookies, and we'll use a normal CookieJar if none is given.
based on your config file and the sites database. We accept a bunch of
kwargs, but the only ones you really "need" are `base_url` and
`script_path` - this is enough to figure out an API url. `login`, a
tuple of (username, password), is highly recommended. `cookiejar` will
be used to store cookies, and we'll use a normal CookieJar if none is
given.

First, we'll store the given arguments as attributes, then set up our
URL opener. We'll load any of the attributes that weren't given from
@@ -99,7 +102,8 @@ class Site(object):
self._script_path = script_path
self._namespaces = namespaces

# Attributes used for API queries:
# Attributes used for API queries:
self._use_https = use_https
self._assert_edit = assert_edit
self._maxlag = maxlag
self._max_retries = 5
@@ -112,11 +116,11 @@ class Site(object):
self._search_config = search_config

# Set up cookiejar and URL opener for making API queries:
if cookiejar is not None:
if cookiejar:
self._cookiejar = cookiejar
else:
self._cookiejar = CookieJar()
if user_agent is None:
if not user_agent:
user_agent = USER_AGENT # Set default UA from wiki.constants
self._opener = build_opener(HTTPCookieProcessor(self._cookiejar))
self._opener.addheaders = [("User-Agent", user_agent),
@@ -127,9 +131,9 @@ class Site(object):

# If we have a name/pass and the API says we're not logged in, log in:
self._login_info = name, password = login
if name is not None and password is not None:
if name and password:
logged_in_as = self._get_username_from_cookies()
if logged_in_as is None or name != logged_in_as:
if not logged_in_as or name != logged_in_as:
self._login(login)

def __repr__(self):
@@ -137,10 +141,10 @@ class Site(object):
res = ", ".join((
"Site(name={_name!r}", "project={_project!r}", "lang={_lang!r}",
"base_url={_base_url!r}", "article_path={_article_path!r}",
"script_path={_script_path!r}", "assert_edit={_assert_edit!r}",
"maxlag={_maxlag!r}", "sql={_sql!r}", "login={0}",
"user_agent={2!r}", "cookiejar={1})"
))
"script_path={_script_path!r}", "use_https={_use_https!r}",
"assert_edit={_assert_edit!r}", "maxlag={_maxlag!r}",
"sql={_sql_data!r}", "login={0}", "user_agent={2!r}",
"cookiejar={1})"))
name, password = self._login_info
login = "({0}, {1})".format(repr(name), "hidden" if password else None)
cookies = self._cookiejar.__class__.__name__
@@ -162,7 +166,9 @@ class Site(object):

This will first attempt to construct an API url from self._base_url and
self._script_path. We need both of these, or else we'll raise
SiteAPIError.
SiteAPIError. If self._base_url is protocol-relative (introduced in
MediaWiki 1.18), we'll choose HTTPS if self._user_https is True,
otherwise HTTP.

We'll encode the given params, adding format=json along the way, as
well as &assert= and &maxlag= based on self._assert_edit and _maxlag.
@@ -180,11 +186,17 @@ class Site(object):
There's helpful MediaWiki API documentation at
<http://www.mediawiki.org/wiki/API>.
"""
if self._base_url is None or self._script_path is None:
if not self._base_url or self._script_path is None:
e = "Tried to do an API query, but no API URL is known."
raise SiteAPIError(e)

url = ''.join((self._base_url, self._script_path, "/api.php"))
base_url = self._base_url
if base_url.startswith("//"): # Protocol-relative URLs from 1.18
if self._use_https:
base_url = "https:" + base_url
else:
base_url = "http:" + base_url
url = ''.join((base_url, self._script_path, "/api.php"))

params["format"] = "json" # This is the only format we understand
if self._assert_edit: # If requested, ensure that we're logged in
@@ -193,7 +205,6 @@ class Site(object):
params["maxlag"] = self._maxlag

data = urlencode(params)

logger.debug("{0} -> {1}".format(url, data))

try:
@@ -231,7 +242,7 @@ class Site(object):
e = "Maximum number of retries reached ({0})."
raise SiteAPIError(e.format(self._max_retries))
tries += 1
msg = 'Server says: "{0}". Retrying in {1} seconds ({2}/{3}).'
msg = 'Server says "{0}"; retrying in {1} seconds ({2}/{3})'
logger.info(msg.format(info, wait, tries, self._max_retries))
sleep(wait)
return self._api_query(params, tries=tries, wait=wait*3)
@@ -332,15 +343,15 @@ class Site(object):
name = ''.join((self._name, "Token"))
cookie = self._get_cookie(name, domain)

if cookie is not None:
if cookie:
name = ''.join((self._name, "UserName"))
user_name = self._get_cookie(name, domain)
if user_name is not None:
if user_name:
return user_name.value

name = "centralauth_Token"
for cookie in self._cookiejar:
if cookie.domain_initial_dot is False or cookie.is_expired():
if not cookie.domain_initial_dot or cookie.is_expired():
continue
if cookie.name != name:
continue
@@ -348,7 +359,7 @@ class Site(object):
search = ''.join(("(.*?)", re_escape(cookie.domain)))
if re_match(search, domain): # Test it against our site
user_name = self._get_cookie("centralauth_User", cookie.domain)
if user_name is not None:
if user_name:
return user_name.value

def _get_username_from_api(self):
@@ -378,7 +389,7 @@ class Site(object):
single API query for our username (or IP address) and return that.
"""
name = self._get_username_from_cookies()
if name is not None:
if name:
return name
return self._get_username_from_api()

@@ -417,7 +428,7 @@ class Site(object):
"""
name, password = login
params = {"action": "login", "lgname": name, "lgpassword": password}
if token is not None:
if token:
params["lgtoken"] = token
result = self._api_query(params)
res = result["login"]["result"]
@@ -455,10 +466,9 @@ class Site(object):
def _sql_connect(self, **kwargs):
"""Attempt to establish a connection with this site's SQL database.

oursql.connect() will be called with self._sql_data as its kwargs,
which is usually config.wiki["sites"][self.name()]["sql"]. Any kwargs
given to this function will be passed to connect() and will have
precedence over the config file.
oursql.connect() will be called with self._sql_data as its kwargs.
Any kwargs given to this function will be passed to connect() and will
have precedence over the config file.

Will raise SQLError() if the module "oursql" is not available. oursql
may raise its own exceptions (e.g. oursql.InterfaceError) if it cannot
@@ -631,6 +641,6 @@ class Site(object):
If `username` is left as None, then a User object representing the
currently logged-in (or anonymous!) user is returned.
"""
if username is None:
if not username:
username = self._get_username()
return User(self, username)

+ 363
- 0
earwigbot/wiki/sitesdb.py View File

@@ -0,0 +1,363 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

from cookielib import LWPCookieJar, LoadError
import errno
from getpass import getpass
from os import chmod, path
from platform import python_version
import stat
import sqlite3 as sqlite

from earwigbot import __version__
from earwigbot.wiki.exceptions import SiteNotFoundError
from earwigbot.wiki.site import Site

__all__ = ["SitesDB"]

class SitesDB(object):
"""
EarwigBot's Wiki Toolset: Sites Database Manager

This class controls the sites.db file, which stores information about all
wiki sites known to the bot. Three public methods act as bridges between
the bot's config files and Site objects:
get_site -- returns a Site object corresponding to a given site name
add_site -- stores a site in the database, given connection info
remove_site -- removes a site from the database, given its name

There's usually no need to use this class directly. All public methods
here are available as bot.wiki.get_site(), bot.wiki.add_site(), and
bot.wiki.remove_site(), which use a sites.db file located in the same
directory as our config.yml file. Lower-level access can be achieved
by importing the manager class (`from earwigbot.wiki import SitesDB`).
"""

def __init__(self, config):
"""Set up the manager with an attribute for the BotConfig object."""
self.config = config
self._sitesdb = path.join(config.root_dir, "sites.db")
self._cookie_file = path.join(config.root_dir, ".cookies")
self._cookiejar = None

def _get_cookiejar(self):
"""Return a LWPCookieJar object loaded from our .cookies file.

The same .cookies file is returned every time, located in the project
root, same directory as config.yml and bot.py. If it doesn't exist, we
will create the file and set it to be readable and writeable only by
us. If it exists but the information inside is bogus, we'll ignore it.

This is normally called by _make_site_object() (in turn called by
get_site()), and the cookiejar is passed to our Site's constructor,
used when it makes API queries. This way, we can easily preserve
cookies between sites (e.g., for CentralAuth), making logins easier.
"""
if self._cookiejar:
return self._cookiejar

self._cookiejar = LWPCookieJar(self._cookie_file)

try:
self._cookiejar.load()
except LoadError:
pass # File contains bad data, so ignore it completely
except IOError as e:
if e.errno == errno.ENOENT: # "No such file or directory"
# Create the file and restrict reading/writing only to the
# owner, so others can't peak at our cookies:
open(cookie_file, "w").close()
chmod(cookie_file, stat.S_IRUSR|stat.S_IWUSR)
else:
raise

return self._cookiejar

def _create_sitesdb(self):
"""Initialize the sitesdb file with its three necessary tables."""
script = """
CREATE TABLE sites (site_name, site_project, site_lang, site_base_url,
site_article_path, site_script_path);
CREATE TABLE sql_data (sql_site, sql_data_key, sql_data_value);
CREATE TABLE namespaces (ns_site, ns_id, ns_name, ns_is_primary_name);
"""
with sqlite.connect(self._sitesdb) as conn:
conn.executescript(script)

def _load_site_from_sitesdb(self, name):
"""Return all information stored in the sitesdb relating to given site.

The information will be returned as a tuple, containing the site's
name, project, language, base URL, article path, script path, SQL
connection data, and namespaces, in that order. If the site is not
found in the database, SiteNotFoundError will be raised. An empty
database will be created before the exception is raised if none exists.
"""
query1 = "SELECT * FROM sites WHERE site_name = ?"
query2 = "SELECT sql_data_key, sql_data_value FROM sql_data WHERE sql_site = ?"
query3 = "SELECT ns_id, ns_name, ns_is_primary_name FROM namespaces WHERE ns_site = ?"
error = "Site '{0}' not found in the sitesdb.".format(name)
with sqlite.connect(self._sitesdb) as conn:
try:
site_data = conn.execute(query1, (name,)).fetchone()
except sqlite.OperationalError:
self._create_sitesdb()
raise SiteNotFoundError(error)
if not site_data:
raise SiteNotFoundError(error)
sql_data = conn.execute(query2, (name,)).fetchall()
ns_data = conn.execute(query3, (name,)).fetchall()

name, project, lang, base_url, article_path, script_path = site_data
sql = dict(sql_data)
namespaces = {}
for ns_id, ns_name, ns_is_primary_name in ns_data:
try:
if ns_is_primary_name: # "Primary" name goes first in list
namespaces[ns_id].insert(0, ns_name)
else: # Ordering of the aliases doesn't matter
namespaces[ns_id].append(ns_name)
except KeyError:
namespaces[ns_id] = [ns_name]

return (name, project, lang, base_url, article_path, script_path, sql,
namespaces)

def _make_site_object(self, name):
"""Return a Site object associated with the site 'name' in our sitesdb.

This calls _load_site_from_sitesdb(), so SiteNotFoundError will be
raised if the site is not in our sitesdb.
"""
cookiejar = self._get_cookiejar()
(name, project, lang, base_url, article_path, script_path, sql,
namespaces) = self._load_site_from_sitesdb(name)

config = self.config
login = (config.wiki.get("username"), config.wiki.get("password"))
user_agent = config.wiki.get("userAgent")
use_https = config.wiki.get("useHTTPS", False)
assert_edit = config.wiki.get("assert")
maxlag = config.wiki.get("maxlag")
search_config = config.wiki.get("search")

if user_agent:
user_agent = user_agent.replace("$1", __version__)
user_agent = user_agent.replace("$2", python_version())

return Site(name=name, project=project, lang=lang, base_url=base_url,
article_path=article_path, script_path=script_path,
sql=sql, namespaces=namespaces, login=login,
cookiejar=cookiejar, user_agent=user_agent,
use_https=use_https, assert_edit=assert_edit,
maxlag=maxlag, search_config=search_config)

def _get_site_name_from_sitesdb(self, project, lang):
"""Return the name of the first site with the given project and lang.

If the site is not found, return None. An empty sitesdb will be created
if none exists.
"""
query = "SELECT site_name FROM sites WHERE site_project = ? and site_lang = ?"
with sqlite.connect(self._sitesdb) as conn:
try:
site = conn.execute(query, (project, lang)).fetchone()
return site[0] if site else None
except sqlite.OperationalError:
self._create_sitesdb()

def _add_site_to_sitesdb(self, site):
"""Extract relevant info from a Site object and add it to the sitesdb.

Works like a reverse _load_site_from_sitesdb(); the site's project,
language, base URL, article path, script path, SQL connection data, and
namespaces are extracted from the site and inserted into the sites
database. If the sitesdb doesn't exist, we'll create it first.
"""
name = site.name()
sites_data = (name, site.project(), site.lang(), site._base_url,
site._article_path, site._script_path)
sql_data = [(name, key, val) for key, val in site._sql_data.iteritems()]
ns_data = []
for ns_id, ns_names in site._namespaces.iteritems():
ns_data.append((name, ns_id, ns_names.pop(0), True))
for ns_name in ns_names:
ns_data.append((name, ns_id, ns_name, False))

with sqlite.connect(self._sitesdb) as conn:
check_exists = "SELECT 1 FROM sites WHERE site_name = ?"
try:
exists = conn.execute(check_exists, (name,)).fetchone()
except sqlite.OperationalError:
self._create_sitesdb()
else:
if exists:
conn.execute("DELETE FROM sites WHERE site_name = ?", (name,))
conn.execute("DELETE FROM sql_data WHERE sql_site = ?", (name,))
conn.execute("DELETE FROM namespaces WHERE ns_site = ?", (name,))
conn.execute("INSERT INTO sites VALUES (?, ?, ?, ?, ?, ?)", sites_data)
conn.executemany("INSERT INTO sql_data VALUES (?, ?, ?)", sql_data)
conn.executemany("INSERT INTO namespaces VALUES (?, ?, ?, ?)", ns_data)

def _remove_site_from_sitesdb(self, name):
"""Remove a site by name from the sitesdb."""
with sqlite.connect(self._sitesdb) as conn:
cursor = conn.execute("DELETE FROM sites WHERE site_name = ?", (name,))
if cursor.rowcount == 0:
return False
else:
conn.execute("DELETE FROM sql_data WHERE sql_site = ?", (name,))
conn.execute("DELETE FROM namespaces WHERE ns_site = ?", (name,))
return True

def get_site(self, name=None, project=None, lang=None):
"""Return a Site instance based on information from the sitesdb.

With no arguments, return the default site as specified by our config
file. This is config.wiki["defaultSite"].

With 'name' specified, return the site with that name. This is
equivalent to the site's 'wikiid' in the API, like 'enwiki'.

With 'project' and 'lang' specified, return the site whose project and
language match these values. If there are multiple sites with the same
values (unlikely), this is not a reliable way of loading a site. Call
the function with an explicit 'name' in that case.

We will attempt to login to the site automatically using
config.wiki["username"] and config.wiki["password"] if both are
defined.

Specifying a project without a lang or a lang without a project will
raise TypeError. If all three args are specified, 'name' will be first
tried, then 'project' and 'lang' if 'name' doesn't work. If a site
cannot be found in the sitesdb, SiteNotFoundError will be raised. An
empty sitesdb will be created if none is found.
"""
# Someone specified a project without a lang, or vice versa:
if (project and not lang) or (not project and lang):
e = "Keyword arguments 'lang' and 'project' must be specified together."
raise TypeError(e)

# No args given, so return our default site:
if not name and not project and not lang:
try:
default = self.config.wiki["defaultSite"]
except KeyError:
e = "Default site is not specified in config."
raise SiteNotFoundError(e)
return self._make_site_object(default)

# Name arg given, but don't look at others unless `name` isn't found:
if name:
try:
return self._make_site_object(name)
except SiteNotFoundError:
if project and lang:
name = self._get_site_name_from_sitesdb(project, lang)
if name:
return self._make_site_object(name)
raise

# If we end up here, then project and lang are the only args given:
name = self._get_site_name_from_sitesdb(project, lang)
if name:
return self._make_site_object(name)
e = "Site '{0}:{1}' not found in the sitesdb.".format(project, lang)
raise SiteNotFoundError(e)

def add_site(self, project=None, lang=None, base_url=None,
script_path="/w", sql=None):
"""Add a site to the sitesdb so it can be retrieved with get_site().

If only a project and a lang are given, we'll guess the base_url as
"//{lang}.{project}.org" (which is protocol-relative, becoming 'https'
if 'useHTTPS' is True in config otherwise 'http'). If this is wrong,
provide the correct base_url as an argument (in which case project and
lang are ignored). Most wikis use "/w" as the script path (meaning the
API is located at "{base_url}{script_path}/api.php" ->
"//{lang}.{project}.org/w/api.php"), so this is the default. If your
wiki is different, provide the script_path as an argument. The only
other argument to Site() that we can't get from config files or by
querying the wiki itself is SQL connection info, so provide a dict of
kwargs as `sql` and Site will pass it to oursql.connect(**sql),
allowing you to make queries with site.sql_query().

Returns True if the site was added successfully or False if the site is
already in our sitesdb (this can be done purposefully to update old
site info). Raises SiteNotFoundError if not enough information has
been provided to identify the site (e.g. a project but not a lang).
"""
if not base_url:
if not project or not lang:
e = "Without a base_url, both a project and a lang must be given."
raise SiteNotFoundError(e)
base_url = "//{0}.{1}.org".format(lang, project)
cookiejar = self._get_cookiejar()

config = self.config
login = (config.wiki.get("username"), config.wiki.get("password"))
user_agent = config.wiki.get("userAgent")
use_https = config.wiki.get("useHTTPS", False)
assert_edit = config.wiki.get("assert")
maxlag = config.wiki.get("maxlag")
search_config = config.wiki.get("search")

# Create a temp Site object to log in and load the other attributes:
site = Site(base_url=base_url, script_path=script_path, sql=sql,
login=login, cookiejar=cookiejar, user_agent=user_agent,
use_https=use_https, assert_edit=assert_edit,
maxlag=maxlag, search_config=search_config)

self._add_site_to_sitesdb(site)
return site

def remove_site(self, name=None, project=None, lang=None):
"""Remove a site from the sitesdb.

Returns True if the site was removed successfully or False if the site
was not in our sitesdb originally. If all three args (name, project,
and lang) are given, we'll first try 'name' and then try the latter two
if 'name' wasn't found in the database. Raises TypeError if a project
was given but not a language, or vice versa. Will create an empty
sitesdb if none was found.
"""
# Someone specified a project without a lang, or vice versa:
if (project and not lang) or (not project and lang):
e = "Keyword arguments 'lang' and 'project' must be specified together."
raise TypeError(e)

if name:
was_removed = self._remove_site_from_sitesdb(name)
if not was_removed:
if project and lang:
name = self._get_site_name_from_sitesdb(project, lang)
if name:
return self._remove_site_from_sitesdb(name)
return was_removed

if project and lang:
name = self._get_site_name_from_sitesdb(project, lang)
if name:
return self._remove_site_from_sitesdb(name)

return False

+ 2
- 0
earwigbot/wiki/user.py View File

@@ -26,6 +26,8 @@ from earwigbot.wiki.constants import *
from earwigbot.wiki.exceptions import UserNotFoundError
from earwigbot.wiki.page import Page

__all__ = ["User"]

class User(object):
"""
EarwigBot's Wiki Toolset: User Class


+ 61
- 0
setup.py View File

@@ -0,0 +1,61 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright (C) 2009-2012 by Ben Kurtovic <ben.kurtovic@verizon.net>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

from setuptools import setup, find_packages

from earwigbot import __version__

with open("README.rst") as fp:
long_docs = fp.read()

setup(
name = "earwigbot",
packages = find_packages(exclude=("tests",)),
entry_points = {"console_scripts": ["earwigbot = earwigbot.util:main"]},
install_requires = ["PyYAML >= 3.10", # Config parsing
"oursql >= 0.9.3", # Talking with MediaWiki databases
"oauth2 >= 1.5.211", # Talking with Yahoo BOSS Search
"GitPython >= 0.3.2.RC1", # Interfacing with git
],
test_suite = "tests",
version = __version__,
author = "Ben Kurtovic",
author_email = "ben.kurtovic@verizon.net",
url = "https://github.com/earwig/earwigbot",
description = "EarwigBot is a Python robot that edits Wikipedia and interacts with people over IRC.",
long_description = long_docs,
download_url = "https://github.com/earwig/earwigbot/tarball/v{0}".format(__version__),
keywords = "earwig earwigbot irc wikipedia wiki mediawiki",
license = "MIT License",
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Console",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python :: 2.7",
"Topic :: Communications :: Chat :: Internet Relay Chat",
"Topic :: Internet :: WWW/HTTP"
],
)

earwigbot/tests/__init__.py → tests/__init__.py View File

@@ -23,26 +23,41 @@
"""
EarwigBot's Unit Tests

This module __init__ file provides some support code for unit tests.
This __init__ file provides some support code for unit tests.

Test cases:
-- CommandTestCase provides setUp() for creating a fake connection, plus
some other helpful methods for testing IRC commands.

Fake objects:
-- FakeBot implements Bot, using the Fake* equivalents of all objects
whenever possible.
-- FakeBotConfig implements BotConfig with silent logging.
-- FakeIRCConnection implements IRCConnection, using an internal string
buffer for data instead of sending it over a socket.

CommandTestCase is a subclass of unittest.TestCase that provides setUp() for
creating a fake connection and some other helpful methods. It uses
FakeConnection, a subclass of classes.Connection, but with an internal string
instead of a socket for data.
"""

import logging
from os import path
import re
from threading import Lock
from unittest import TestCase

from earwigbot.bot import Bot
from earwigbot.commands import CommandManager
from earwigbot.config import BotConfig
from earwigbot.irc import IRCConnection, Data
from earwigbot.tasks import TaskManager
from earwigbot.wiki import SitesDBManager

class CommandTestCase(TestCase):
re_sender = re.compile(":(.*?)!(.*?)@(.*?)\Z")

def setUp(self, command):
self.connection = FakeConnection()
self.connection._connect()
self.command = command(self.connection)
self.bot = FakeBot(path.dirname(__file__))
self.command = command(self.bot)
self.command.connection = self.connection = self.bot.frontend

def get_single(self):
data = self.connection._get().split("\n")
@@ -92,15 +107,38 @@ class CommandTestCase(TestCase):
line = ":Foo!bar@example.com JOIN :#channel".strip().split()
return self.maker(line, line[2][1:])

class FakeConnection(IRCConnection):
def __init__(self):
pass

class FakeBot(Bot):
def __init__(self, root_dir):
self.config = FakeBotConfig(root_dir)
self.logger = logging.getLogger("earwigbot")
self.commands = CommandManager(self)
self.tasks = TaskManager(self)
self.wiki = SitesDBManager(self.config)
self.frontend = FakeIRCConnection(self)
self.watcher = FakeIRCConnection(self)

self.component_lock = Lock()
self._keep_looping = True


class FakeBotConfig(BotConfig):
def _setup_logging(self):
logger = logging.getLogger("earwigbot")
logger.addHandler(logging.NullHandler())


class FakeIRCConnection(IRCConnection):
def __init__(self, bot):
self.bot = bot
self._is_running = False
self._connect()

def _connect(self):
self._buffer = ""

def _close(self):
pass
self._buffer = ""

def _get(self, size=4096):
data, self._buffer = self._buffer, ""

earwigbot/tests/test_blowfish.py → tests/test_blowfish.py View File


earwigbot/tests/test_calc.py → tests/test_calc.py View File

@@ -23,7 +23,7 @@
import unittest

from earwigbot.commands.calc import Command
from earwigbot.tests import CommandTestCase
from tests import CommandTestCase

class TestCalc(CommandTestCase):


earwigbot/tests/test_test.py → tests/test_test.py View File

@@ -23,7 +23,7 @@
import unittest

from earwigbot.commands.test import Command
from earwigbot.tests import CommandTestCase
from tests import CommandTestCase

class TestTest(CommandTestCase):

@@ -38,12 +38,12 @@ class TestTest(CommandTestCase):
self.assertTrue(self.command.check(self.make_msg("TEST", "foo")))

def test_process(self):
def _test():
def test():
self.command.process(self.make_msg("test"))
self.assertSaidIn(["Hey \x02Foo\x0F!", "'sup \x02Foo\x0F?"])

for i in xrange(64):
_test()
test()

if __name__ == "__main__":
unittest.main(verbosity=2)

Loading…
Cancel
Save