Browse Source

Merge branch 'feature/irc_update' into develop

tags/v0.1^2
Ben Kurtovic 12 years ago
parent
commit
275c82ec7c
44 changed files with 673 additions and 727 deletions
  1. +1
    -2
      earwigbot/__init__.py
  2. +0
    -75
      earwigbot/classes/base_command.py
  3. +0
    -117
      earwigbot/classes/base_task.py
  4. +123
    -71
      earwigbot/commands/__init__.py
  5. +3
    -3
      earwigbot/commands/afc_report.py
  6. +1
    -1
      earwigbot/commands/afc_status.py
  7. +1
    -1
      earwigbot/commands/calc.py
  8. +1
    -1
      earwigbot/commands/chanops.py
  9. +1
    -1
      earwigbot/commands/crypt.py
  10. +3
    -3
      earwigbot/commands/ctcp.py
  11. +1
    -1
      earwigbot/commands/editcount.py
  12. +1
    -1
      earwigbot/commands/git.py
  13. +3
    -3
      earwigbot/commands/help.py
  14. +1
    -1
      earwigbot/commands/link.py
  15. +1
    -1
      earwigbot/commands/praise.py
  16. +1
    -1
      earwigbot/commands/registration.py
  17. +1
    -1
      earwigbot/commands/remind.py
  18. +1
    -1
      earwigbot/commands/replag.py
  19. +37
    -0
      earwigbot/commands/restart.py
  20. +1
    -1
      earwigbot/commands/rights.py
  21. +1
    -1
      earwigbot/commands/test.py
  22. +7
    -6
      earwigbot/commands/threads.py
  23. +1
    -1
      earwigbot/config.py
  24. +0
    -137
      earwigbot/frontend.py
  25. +5
    -5
      earwigbot/irc/__init__.py
  26. +60
    -35
      earwigbot/irc/connection.py
  27. +0
    -0
      earwigbot/irc/data.py
  28. +99
    -0
      earwigbot/irc/frontend.py
  29. +0
    -0
      earwigbot/irc/rc.py
  30. +88
    -0
      earwigbot/irc/watcher.py
  31. +21
    -31
      earwigbot/main.py
  32. +3
    -3
      earwigbot/rules.py
  33. +186
    -91
      earwigbot/tasks/__init__.py
  34. +1
    -1
      earwigbot/tasks/afc_catdelink.py
  35. +1
    -1
      earwigbot/tasks/afc_copyvios.py
  36. +1
    -1
      earwigbot/tasks/afc_dailycats.py
  37. +1
    -1
      earwigbot/tasks/afc_history.py
  38. +1
    -1
      earwigbot/tasks/afc_statistics.py
  39. +1
    -1
      earwigbot/tasks/afc_undated.py
  40. +1
    -1
      earwigbot/tasks/blptag.py
  41. +1
    -1
      earwigbot/tasks/feed_dailycats.py
  42. +1
    -1
      earwigbot/tasks/wrongmime.py
  43. +11
    -8
      earwigbot/tests/__init__.py
  44. +0
    -114
      earwigbot/watcher.py

+ 1
- 2
earwigbot/__init__.py View File

@@ -32,6 +32,5 @@ __version__ = "0.1.dev"
__email__ = "ben.kurtovic@verizon.net" __email__ = "ben.kurtovic@verizon.net"


from earwigbot import ( from earwigbot import (
blowfish, config, classes, commands, config, frontend, main, rules, tasks,
tests, watcher, wiki
blowfish, commands, config, irc, main, rules, runner, tasks, tests, wiki
) )

+ 0
- 75
earwigbot/classes/base_command.py View File

@@ -1,75 +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.

import logging

__all__ = ["BaseCommand"]

class BaseCommand(object):
"""A base class for commands on IRC.

This docstring is reported to the user when they use !help <command>.
"""
# This is the command's name, as reported to the user when they use !help:
name = None

# Hooks are "msg", "msg_private", "msg_public", and "join". "msg" is the
# default behavior; if you wish to override that, change the value in your
# command subclass:
hooks = ["msg"]

def __init__(self, connection):
"""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.
"""
self.connection = connection
logger_name = ".".join(("earwigbot", "commands", self.name))
self.logger = logging.getLogger(logger_name)
self.logger.setLevel(logging.DEBUG)

def check(self, data):
"""Returns 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.

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

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.
"""
pass

+ 0
- 117
earwigbot/classes/base_task.py View File

@@ -1,117 +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.

import logging

from earwigbot.config import config
from earwigbot import wiki

__all__ = ["BaseTask"]

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

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

This is called once immediately after the task class is loaded by
the task manager (in tasks._load_task()).
"""
pass

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 run(self, **kwargs):
"""Main entry point to run a given task.

This is called directly by tasks.start() and is the main way to make a
task do stuff. kwargs will be any keyword arguments passed to start()
which are entirely optional.

The same task instance is preserved between runs, so you can
theoretically store data in self (e.g.
start('mytask', action='store', data='foo')) and then use it later
(e.g. start('mytask', action='save')).
"""
pass

def make_summary(self, comment):
"""Makes an edit summary by filling in variables in a config value.

config.wiki["summary"] is used, where $2 is replaced by the main
summary body, given as a method arg, and $1 is replaced by the task
number.

If the config value is not found, we just return the arg as-is.
"""
try:
summary = config.wiki["summary"]
except KeyError:
return comment
return summary.replace("$1", str(self.number)).replace("$2", comment)

def shutoff_enabled(self, site=None):
"""Returns whether on-wiki shutoff is enabled for this task.

We check a certain page for certain content. This is determined by
our config file: config.wiki["shutoff"]["page"] is used as the title,
with $1 replaced by our username and $2 replaced by the task number,
and config.wiki["shutoff"]["disabled"] is used as the content.

If the page has that content or the page does not exist, then shutoff
is "disabled", meaning the bot is supposed to run normally, and we
return False. If the page's content is something other than what we
expect, shutoff is enabled, and we return True.

If a site is not provided, we'll try to use self.site if it's set.
Otherwise, we'll use our default site.
"""
if not site:
try:
site = self.site
except AttributeError:
site = wiki.get_site()

try:
cfg = config.wiki["shutoff"]
except KeyError:
return False
title = cfg.get("page", "User:$1/Shutoff/Task $2")
username = site.get_user().name()
title = title.replace("$1", username).replace("$2", str(self.number))
page = site.get_page(title)

try:
content = page.get()
except wiki.PageNotFoundError:
return False
if content == cfg.get("disabled", "run"):
return False

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

+ 123
- 71
earwigbot/commands/__init__.py View File

@@ -24,88 +24,140 @@
EarwigBot's IRC Command Manager EarwigBot's IRC Command Manager


This package provides the IRC "commands" used by the bot's front-end component. This package provides the IRC "commands" used by the bot's front-end component.
In __init__, you can find some functions used to load and run these commands.
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.
""" """


import logging import logging
import os import os
import sys import sys


from earwigbot.classes import BaseCommand
from earwigbot.config import config from earwigbot.config import config


__all__ = ["load", "get_all", "check"]
__all__ = ["BaseCommand", "command_manager"]


# Base directory when searching for commands:
base_dir = os.path.dirname(os.path.abspath(__file__))
class BaseCommand(object):
"""A base class for commands on IRC.


# Store commands in a dict, where the key is the command's name and the value
# is an instance of the command's class:
_commands = {}

# Logger for this module:
logger = logging.getLogger("earwigbot.tasks")

def _load_command(connection, filename):
"""Try to load a specific command from a module, identified by file name.

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 _commands, and report the
addition to the user. Any problems along the way will either be ignored or
reported.
"""
global _commands

# Strip .py from the end of the filename and join with our package name:
name = ".".join(("commands", filename[:-3]))
try:
__import__(name)
except:
logger.exception("Couldn't load file {0}".format(filename))
return

command = sys.modules[name].Command(connection)
if not isinstance(command, BaseCommand):
return

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

def load(connection):
"""Load all valid commands into the _commands global variable.

`connection` is a Connection object that is given to each command's
constructor.
This docstring is reported to the user when they use !help <command>.
""" """
files = os.listdir(base_dir)
files.sort()
# This is the command's name, as reported to the user when they use !help:
name = None

# Hooks are "msg", "msg_private", "msg_public", and "join". "msg" is the
# default behavior; if you wish to override that, change the value in your
# command subclass:
hooks = ["msg"]

def __init__(self, connection):
"""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.
"""
self.connection = connection
logger_name = ".".join(("earwigbot", "commands", self.name))
self.logger = logging.getLogger(logger_name)
self.logger.setLevel(logging.DEBUG)

def check(self, data):
"""Returns 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.

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

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.
"""
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


for filename in files:
if filename.startswith("_") or not filename.endswith(".py"):
continue
try: try:
_load_command(connection, filename)
command = sys.modules[name].Command(self._connection)
except AttributeError: except AttributeError:
pass # The file is doesn't contain a command, so just move on

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

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

def check(hook, data):
"""Given an event on IRC, check if there's anything we can respond to."""
# Parse command arguments into data.command and data.args:
data.parse_args()

for command in _commands.values():
if hook in command.hooks:
if command.check(data):
try:
command.process(data)
except:
logger.exception("Error executing command '{0}'".format(data.command))
break
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()

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

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


import re import re


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


class Command(BaseCommand): class Command(BaseCommand):
"""Get information about an AFC submission by name.""" """Get information about an AFC submission by name."""
@@ -36,7 +36,7 @@ class Command(BaseCommand):
self.data = data self.data = data


try: try:
self.statistics = tasks.get("afc_statistics")
self.statistics = task_manager.get("afc_statistics")
except KeyError: except KeyError:
e = "Cannot run command: requires afc_statistics task." e = "Cannot run command: requires afc_statistics task."
self.logger.error(e) self.logger.error(e)


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

@@ -23,7 +23,7 @@
import re import re


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


class Command(BaseCommand): class Command(BaseCommand):


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

@@ -23,7 +23,7 @@
import re import re
import urllib import urllib


from earwigbot.classes import BaseCommand
from earwigbot.commands import BaseCommand


class Command(BaseCommand): class Command(BaseCommand):
"""A somewhat advanced calculator: see http://futureboy.us/fsp/frink.fsp """A somewhat advanced calculator: see http://futureboy.us/fsp/frink.fsp


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

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


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


class Command(BaseCommand): class Command(BaseCommand):


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

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


import hashlib import hashlib


from earwigbot.classes import BaseCommand
from earwigbot import blowfish from earwigbot import blowfish
from earwigbot.commands import BaseCommand


class Command(BaseCommand): class Command(BaseCommand):
"""Provides hash functions with !hash (!hash list for supported algorithms) """Provides hash functions with !hash (!hash list for supported algorithms)


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

@@ -23,8 +23,8 @@
import platform import platform
import time import time


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


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

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

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


from urllib import quote_plus from urllib import quote_plus


from earwigbot.classes import BaseCommand
from earwigbot import wiki from earwigbot import wiki
from earwigbot.commands import BaseCommand


class Command(BaseCommand): class Command(BaseCommand):
"""Return a user's edit count.""" """Return a user's edit count."""


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

@@ -24,7 +24,7 @@ import shlex
import subprocess import subprocess
import re import re


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


class Command(BaseCommand): class Command(BaseCommand):


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

@@ -22,15 +22,15 @@


import re import re


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


class Command(BaseCommand): class Command(BaseCommand):
"""Displays help information.""" """Displays help information."""
name = "help" name = "help"


def process(self, data): def process(self, data):
self.cmnds = commands.get_all()
self.cmnds = command_manager.get_all()
if not data.args: if not data.args:
self.do_main_help(data) self.do_main_help(data)
else: else:


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

@@ -23,7 +23,7 @@
import re import re
from urllib import quote from urllib import quote


from earwigbot.classes import BaseCommand
from earwigbot.commands import BaseCommand


class Command(BaseCommand): class Command(BaseCommand):
"""Convert a Wikipedia page name into a URL.""" """Convert a Wikipedia page name into a URL."""


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

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


import random import random


from earwigbot.classes import BaseCommand
from earwigbot.commands import BaseCommand


class Command(BaseCommand): class Command(BaseCommand):
"""Praise people!""" """Praise people!"""


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

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


import time import time


from earwigbot.classes import BaseCommand
from earwigbot import wiki from earwigbot import wiki
from earwigbot.commands import BaseCommand


class Command(BaseCommand): class Command(BaseCommand):
"""Return when a user registered.""" """Return when a user registered."""


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

@@ -23,7 +23,7 @@
import threading import threading
import time import time


from earwigbot.classes import BaseCommand
from earwigbot.commands import BaseCommand


class Command(BaseCommand): class Command(BaseCommand):
"""Set a message to be repeated to you in a certain amount of time.""" """Set a message to be repeated to you in a certain amount of time."""


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

@@ -24,7 +24,7 @@ from os.path import expanduser


import oursql import oursql


from earwigbot.classes import BaseCommand
from earwigbot.commands import BaseCommand


class Command(BaseCommand): class Command(BaseCommand):
"""Return the replag for a specific database on the Toolserver.""" """Return the replag for a specific database on the Toolserver."""


+ 37
- 0
earwigbot/commands/restart.py View File

@@ -0,0 +1,37 @@
# -*- 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
from earwigbot.config import config

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

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

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

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

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


from earwigbot.classes import BaseCommand
from earwigbot import wiki from earwigbot import wiki
from earwigbot.commands import BaseCommand


class Command(BaseCommand): class Command(BaseCommand):
"""Retrieve a list of rights for a given username.""" """Retrieve a list of rights for a given username."""


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

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


import random import random


from earwigbot.classes import BaseCommand
from earwigbot.commands import BaseCommand


class Command(BaseCommand): class Command(BaseCommand):
"""Test the bot!""" """Test the bot!"""


+ 7
- 6
earwigbot/commands/threads.py View File

@@ -23,9 +23,10 @@
import threading import threading
import re import re


from earwigbot import tasks
from earwigbot.classes import BaseCommand, Data, KwargParseException
from earwigbot.commands import BaseCommand
from earwigbot.config import config from earwigbot.config import config
from earwigbot.irc import KwargParseException
from earwigbot.tasks import task_manager


class Command(BaseCommand): class Command(BaseCommand):
"""Manage wiki tasks from IRC, and check on thread status.""" """Manage wiki tasks from IRC, and check on thread status."""
@@ -105,7 +106,7 @@ class Command(BaseCommand):
def do_listall(self): def do_listall(self):
"""With !tasks listall or !tasks all, list all loaded tasks, and report """With !tasks listall or !tasks all, list all loaded tasks, and report
whether they are currently running or idle.""" whether they are currently running or idle."""
all_tasks = tasks.get_all().keys()
all_tasks = task_manager.get_all().keys()
threads = threading.enumerate() threads = threading.enumerate()
tasklist = [] tasklist = []


@@ -146,14 +147,14 @@ class Command(BaseCommand):
self.connection.reply(data, msg) self.connection.reply(data, msg)
return return


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


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




+ 1
- 1
earwigbot/config.py View File

@@ -66,7 +66,7 @@ class _ConfigNode(object):
data = self.__dict__.copy() data = self.__dict__.copy()
for key, val in data.iteritems(): for key, val in data.iteritems():
if isinstance(val, _ConfigNode): if isinstance(val, _ConfigNode):
data[key] = val.dump()
data[key] = val._dump()
return data return data


def _load(self, data): def _load(self, data):


+ 0
- 137
earwigbot/frontend.py View File

@@ -1,137 +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 IRC Frontend Component

The IRC frontend runs on a normal IRC server and expects users to interact with
it and give it commands. Commands are stored as "command classes", subclasses
of BaseCommand in irc/base_command.py. All command classes are automatically
imported by irc/command_handler.py if they are in irc/commands.
"""

import logging
import re

from earwigbot import commands
from earwigbot.classes import Connection, Data, BrokenSocketException
from earwigbot.config import config

__all__ = ["get_connection", "startup", "main"]

connection = None
logger = logging.getLogger("earwigbot.frontend")
sender_regex = re.compile(":(.*?)!(.*?)@(.*?)\Z")

def get_connection():
"""Return a new Connection() instance with information about our server
connection, but don't actually connect yet."""
cf = config.irc["frontend"]
connection = Connection(cf["host"], cf["port"], cf["nick"], cf["ident"],
cf["realname"], logger)
return connection

def startup(conn):
"""Accept a single arg, a Connection() object, and set our global variable
'connection' to it. Load all command classes in irc/commands with
command_handler, and then establish a connection with the IRC server."""
global connection
connection = conn
commands.load(connection)
connection.connect()

def main():
"""Main loop for the frontend component.

get_connection() and startup() should have already been called before this.
"""
read_buffer = str()

while 1:
try:
read_buffer = read_buffer + connection.get()
except BrokenSocketException:
logger.warn("Socket has broken on front-end; restarting bot")
return

lines = read_buffer.split("\n")
read_buffer = lines.pop()
for line in lines:
ret = _process_message(line)
if ret:
return

def _process_message(line):
"""Process a single message from IRC."""
line = line.strip().split()
data = Data(line) # new Data instance to store info about this line

if line[1] == "JOIN":
data.nick, data.ident, data.host = sender_regex.findall(line[0])[0]
data.chan = line[2]
# Check for 'join' hooks in our commands:
commands.check("join", data)

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

if data.chan == 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
commands.check("msg_private", data)
else:
# Check for public-only command hooks:
commands.check("msg_public", data)

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

# Hardcode the !restart command (we can't restart from within an
# ordinary command):
if data.msg in ["!restart", ".restart"]:
if data.host in config.irc["permissions"]["owners"]:
logger.info("Restarting bot per owner request")
return True

# If we are pinged, pong back:
elif line[0] == "PING":
msg = " ".join(("PONG", line[1]))
connection.send(msg)

# On successful connection to the server:
elif line[1] == "376":
# If we're supposed to auth to NickServ, do that:
try:
username = config.irc["frontend"]["nickservUsername"]
password = config.irc["frontend"]["nickservPassword"]
except KeyError:
pass
else:
msg = " ".join(("IDENTIFY", username, password))
connection.say("NickServ", msg)

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

earwigbot/classes/__init__.py → earwigbot/irc/__init__.py View File

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


from earwigbot.classes.base_command import *
from earwigbot.classes.base_task import *
from earwigbot.classes.connection import *
from earwigbot.classes.data import *
from earwigbot.classes.rc import *
from earwigbot.irc.connection import *
from earwigbot.irc.data import *
from earwigbot.irc.frontend import *
from earwigbot.irc.rc import *
from earwigbot.irc.watcher import *

earwigbot/classes/connection.py → earwigbot/irc/connection.py View File

@@ -23,93 +23,118 @@
import socket import socket
import threading import threading


__all__ = ["BrokenSocketException", "Connection"]
__all__ = ["BrokenSocketException", "IRCConnection"]


class BrokenSocketException(Exception): class BrokenSocketException(Exception):
"""A socket has broken, because it is not sending data. Raised by
Connection.get()."""
"""A socket has broken, because it is not sending data.

Raised by IRCConnection()._get().
"""
pass pass


class Connection(object):
class IRCConnection(object):
"""A class to interface with IRC.""" """A class to interface with IRC."""


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


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


def connect(self):
def _connect(self):
"""Connect to our IRC server.""" """Connect to our IRC server."""
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try: try:
self.sock.connect((self.host, self.port))
self._sock.connect((self.host, self.port))
except socket.error: except socket.error:
self.logger.critical("Couldn't connect to IRC server", exc_info=1) self.logger.critical("Couldn't connect to IRC server", exc_info=1)
exit(1) exit(1)
self.send("NICK %s" % self.nick)
self.send("USER %s %s * :%s" % (self.ident, self.host, self.realname))
self._send("NICK {0}".format(self.nick))
self._send("USER {0} {1} * :{2}".format(self.ident, self.host, self.realname))


def close(self):
def _close(self):
"""Close our connection with the IRC server.""" """Close our connection with the IRC server."""
try: try:
self.sock.shutdown(socket.SHUT_RDWR) # shut down connection first
self._sock.shutdown(socket.SHUT_RDWR) # Shut down connection first
except socket.error: except socket.error:
pass # ignore if the socket is already down
self.sock.close()
pass # Ignore if the socket is already down
self._sock.close()


def get(self, size=4096):
def _get(self, size=4096):
"""Receive (i.e. get) data from the server.""" """Receive (i.e. get) data from the server."""
data = self.sock.recv(4096)
data = self._sock.recv(4096)
if not data: if not data:
# Socket isn't giving us any data, so it is dead or broken: # Socket isn't giving us any data, so it is dead or broken:
raise BrokenSocketException() raise BrokenSocketException()
return data return data


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


def say(self, target, msg): def say(self, target, msg):
"""Send a private message to a target on the server.""" """Send a private message to a target on the server."""
message = "".join(("PRIVMSG ", target, " :", msg))
self.send(message)
msg = "PRIVMSG {0} :{1}".format(target, msg)
self._send(msg)


def reply(self, data, msg): def reply(self, data, msg):
"""Send a private message as a reply to a user on the server.""" """Send a private message as a reply to a user on the server."""
message = "".join((chr(2), data.nick, chr(0x0f), ": ", msg))
self.say(data.chan, message)
msg = "\x02{0}\x0f: {1}".format(data.nick, msg)
self.say(data.chan, msg)


def action(self, target, msg): def action(self, target, msg):
"""Send a private message to a target on the server as an action.""" """Send a private message to a target on the server as an action."""
message = "".join((chr(1), "ACTION ", msg, chr(1)))
self.say(target, message)
msg = "\x01ACTION {0}\x01".format(msg)
self.say(target, msg)


def notice(self, target, msg): def notice(self, target, msg):
"""Send a notice to a target on the server.""" """Send a notice to a target on the server."""
message = "".join(("NOTICE ", target, " :", msg))
self.send(message)
msg = "NOTICE {0} :{1}".format(target, msg)
self._send(msg)


def join(self, chan): def join(self, chan):
"""Join a channel on the server.""" """Join a channel on the server."""
message = " ".join(("JOIN", chan))
self.send(message)
msg = "JOIN {0}".format(chan)
self._send(msg)


def part(self, chan): def part(self, chan):
"""Part from a channel on the server.""" """Part from a channel on the server."""
message = " ".join(("PART", chan))
self.send(message)
msg = "PART {0}".format(chan)
self._send(msg)


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

def pong(self, target):
"""Pong another entity on the server."""
msg = "PONG {0}".format(target)
self._send(msg)

def loop(self):
"""Main loop for the IRC connection."""
self.is_running = True
read_buffer = ""
while 1:
try:
read_buffer += self._get()
except BrokenSocketException:
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:
break

earwigbot/classes/data.py → earwigbot/irc/data.py View File


+ 99
- 0
earwigbot/irc/frontend.py View File

@@ -0,0 +1,99 @@
# -*- 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
import re

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

__all__ = ["Frontend"]

class Frontend(IRCConnection):
"""
EarwigBot's IRC Frontend Component

The IRC frontend runs on a normal IRC server and expects users to interact
with it and give it commands. Commands are stored as "command classes",
subclasses of BaseCommand in classes/base_command.py. All command classes
are automatically imported by commands/__init__.py if they are in
commands/.
"""
sender_regex = re.compile(":(.*?)!(.*?)@(.*?)\Z")

def __init__(self):
self.logger = logging.getLogger("earwigbot.frontend")
cf = 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)
self._connect()

def _process_message(self, line):
"""Process a single message from IRC."""
line = line.strip().split()
data = Data(line) # New Data instance to store info about this line

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)

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]

if data.chan == 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)
else:
# Check for public-only command hooks:
command_manager.check("msg_public", data)

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

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

# On successful connection to the server:
elif line[1] == "376":
# If we're supposed to auth to NickServ, do that:
try:
username = config.irc["frontend"]["nickservUsername"]
password = config.irc["frontend"]["nickservPassword"]
except KeyError:
pass
else:
msg = "IDENTIFY {0} {1}".format(username, password)
self.say("NickServ", msg)

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

earwigbot/classes/rc.py → earwigbot/irc/rc.py View File


+ 88
- 0
earwigbot/irc/watcher.py View File

@@ -0,0 +1,88 @@
# -*- 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 earwigbot import rules
from earwigbot.irc import IRCConnection, RC, BrokenSocketException
from earwigbot.config import config

__all__ = ["Watcher"]

class Watcher(IRCConnection):
"""
EarwigBot's IRC Watcher Component

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 rules.py's process() function, which can result
in wiki bot tasks being started (located in tasks/) 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"]
base = super(Watcher, self)
base.__init__(cf["host"], cf["port"], cf["nick"], cf["ident"],
cf["realname"], self.logger)
self.frontend = frontend
self._connect()

def _process_message(self, line):
"""Process a single message from IRC."""
line = line.strip().split()

if line[1] == "PRIVMSG":
chan = line[2]

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

msg = " ".join(line[3:])[1:]
rc = RC(msg) # New RC object to store this event's data
rc.parse() # Parse a message into pagenames, usernames, etc.
self._process_rc(rc) # Report to frontend channels or start tasks

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

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

def _process_rc(self, rc):
"""Process a recent change event from IRC (or, an RC object).

The actual processing is configurable, so we don't have that hard-coded
here. We simply call rules's process() function and expect a list of
channels back, which we report the event data to.
"""
chans = rules.process(rc)
if chans and self.frontend:
pretty = rc.prettify()
for chan in chans:
self.frontend.say(chan, pretty)

+ 21
- 31
earwigbot/main.py View File

@@ -49,27 +49,22 @@ import logging
import threading import threading
import time import time


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


logger = logging.getLogger("earwigbot") logger = logging.getLogger("earwigbot")
f_conn = None
w_conn = None


def irc_watcher(f_conn=None):
def irc_watcher(frontend=None):
"""Function to handle the IRC watcher as another thread (if frontend and/or """Function to handle the IRC watcher as another thread (if frontend and/or
scheduler is enabled), otherwise run as the main thread.""" scheduler is enabled), otherwise run as the main thread."""
global w_conn
while 1: # restart the watcher component if it breaks (and nothing else)
w_conn = watcher.get_connection()
w_conn.connect()
while 1: # Restart the watcher component if it breaks (and nothing else)
watcher = Watcher(frontend)
try: try:
watcher.main(w_conn, f_conn)
watcher.loop()
except: except:
logger.exception("Watcher had an error") logger.exception("Watcher had an error")
time.sleep(5) # sleep a bit before restarting watcher
time.sleep(5) # Sleep a bit before restarting watcher
logger.warn("Watcher has stopped; restarting component") logger.warn("Watcher has stopped; restarting component")


def wiki_scheduler(): def wiki_scheduler():
@@ -77,56 +72,51 @@ def wiki_scheduler():
primary thread if the IRC frontend is not enabled.""" primary thread if the IRC frontend is not enabled."""
while 1: while 1:
time_start = time.time() time_start = time.time()
now = time.gmtime(time_start)

tasks.schedule(now)

task_manager.schedule()
time_end = time.time() time_end = time.time()
time_diff = time_start - time_end time_diff = time_start - time_end
if time_diff < 60: # sleep until the next minute
if time_diff < 60: # Sleep until the next minute
time.sleep(60 - time_diff) time.sleep(60 - time_diff)


def irc_frontend(): def irc_frontend():
"""If the IRC frontend is enabled, make it run on our primary thread, and """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 enable the wiki scheduler and IRC watcher on new threads if they are
enabled.""" enabled."""
global f_conn
logger.info("Starting IRC frontend") logger.info("Starting IRC frontend")
f_conn = frontend.get_connection()
frontend.startup(f_conn)
frontend = Frontend()


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


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


frontend.main()
frontend.loop()


if "irc_watcher" in config.components: if "irc_watcher" in config.components:
w_conn.close() w_conn.close()
f_conn.close() f_conn.close()


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


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


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


+ 3
- 3
earwigbot/rules.py View File

@@ -29,7 +29,7 @@ recieves an event from IRC.


import re import re


from earwigbot import tasks
from earwigbot.tasks import task_manager


afc_prefix = "wikipedia( talk)?:(wikiproject )?articles for creation" afc_prefix = "wikipedia( talk)?:(wikiproject )?articles for creation"


@@ -56,7 +56,7 @@ def process(rc):
chans.update(("##earwigbot", "#wikipedia-en-afc-feed")) chans.update(("##earwigbot", "#wikipedia-en-afc-feed"))


if r_page.search(page_name): if r_page.search(page_name):
#tasks.start("afc_copyvios", page=rc.page)
#task_manager.start("afc_copyvios", page=rc.page)
chans.add("#wikipedia-en-afc-feed") chans.add("#wikipedia-en-afc-feed")


elif r_ffu.match(page_name): elif r_ffu.match(page_name):
@@ -76,7 +76,7 @@ def process(rc):


elif rc.flags == "restore" and r_restore.match(comment): elif rc.flags == "restore" and r_restore.match(comment):
p = r_restored_page.findall(rc.comment)[0] p = r_restored_page.findall(rc.comment)[0]
#tasks.start("afc_copyvios", page=p)
#task_manager.start("afc_copyvios", page=p)
chans.add("#wikipedia-en-afc-feed") chans.add("#wikipedia-en-afc-feed")


elif rc.flags == "protect" and r_protect.match(comment): elif rc.flags == "protect" and r_protect.match(comment):


+ 186
- 91
earwigbot/tasks/__init__.py View File

@@ -23,8 +23,10 @@
""" """
EarwigBot's Wiki Task Manager EarwigBot's Wiki Task Manager


This package provides the wiki bot "tasks" EarwigBot runs. Here in __init__,
you can find some functions used to load and run these 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.
""" """


import logging import logging
@@ -33,106 +35,199 @@ import sys
import threading import threading
import time import time


from earwigbot.classes import BaseTask
from earwigbot import wiki
from earwigbot.config import config from earwigbot.config import config


__all__ = ["load", "schedule", "start", "get", "get_all"]

# Base directory when searching for tasks:
base_dir = os.path.dirname(os.path.abspath(__file__))

# Store loaded tasks as a dict where the key is the task name and the value is
# an instance of the task class:
_tasks = {}

# Logger for this module:
logger = logging.getLogger("earwigbot.commands")

def _load_task(filename):
"""Try to load a specific task from a module, identified by file name."""
global _tasks

# Strip .py from the end of the filename and join with our package name:
name = ".".join(("tasks", filename[:-3]))
try:
__import__(name)
except:
logger.exception("Couldn't load file {0}:".format(filename))
return

task = sys.modules[name].Task()
task._setup_logger()
if not isinstance(task, BaseTask):
return

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

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

def load():
"""Load all valid tasks from bot/tasks/, into the _tasks variable."""
files = os.listdir(base_dir)
files.sort()

for filename in files:
if filename.startswith("_") or not filename.endswith(".py"):
continue
__all__ = ["BaseTask", "task_manager"]

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

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

This is called once immediately after the task class is loaded by
the task manager (in tasks._load_task()).
"""
pass

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 run(self, **kwargs):
"""Main entry point to run a given task.

This is called directly by tasks.start() and is the main way to make a
task do stuff. kwargs will be any keyword arguments passed to start()
which are entirely optional.

The same task instance is preserved between runs, so you can
theoretically store data in self (e.g.
start('mytask', action='store', data='foo')) and then use it later
(e.g. start('mytask', action='save')).
"""
pass

def make_summary(self, comment):
"""Makes an edit summary by filling in variables in a config value.

config.wiki["summary"] is used, where $2 is replaced by the main
summary body, given as a method arg, and $1 is replaced by the task
number.

If the config value is not found, we just return the arg as-is.
"""
try: try:
_load_task(filename)
except AttributeError:
pass # The file is doesn't contain a task, so just move on
summary = config.wiki["summary"]
except KeyError:
return comment
return summary.replace("$1", str(self.number)).replace("$2", comment)

def shutoff_enabled(self, site=None):
"""Returns whether on-wiki shutoff is enabled for this task.

We check a certain page for certain content. This is determined by
our config file: config.wiki["shutoff"]["page"] is used as the title,
with $1 replaced by our username and $2 replaced by the task number,
and config.wiki["shutoff"]["disabled"] is used as the content.

If the page has that content or the page does not exist, then shutoff
is "disabled", meaning the bot is supposed to run normally, and we
return False. If the page's content is something other than what we
expect, shutoff is enabled, and we return True.

If a site is not provided, we'll try to use self.site if it's set.
Otherwise, we'll use our default site.
"""
if not site:
try:
site = self.site
except AttributeError:
site = wiki.get_site()

try:
cfg = config.wiki["shutoff"]
except KeyError:
return False
title = cfg.get("page", "User:$1/Shutoff/Task $2")
username = site.get_user().name()
title = title.replace("$1", username).replace("$2", str(self.number))
page = site.get_page(title)

try:
content = page.get()
except wiki.PageNotFoundError:
return False
if content == cfg.get("disabled", "run"):
return False

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


logger.info("Found {0} tasks: {1}".format(len(_tasks), ', '.join(_tasks.keys())))
try:
task = sys.modules[name].Task()
except AttributeError:
return # No task in this module
if not isinstance(task, BaseTask):
return
task._setup_logger()


def schedule(now=time.gmtime()):
"""Start all tasks that are supposed to be run at a given time."""
# 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)
self._tasks[task.name] = task
self.logger.debug("Added task {0}".format(task.name))


for task in tasks:
if isinstance(task, list): # they've specified kwargs
start(task[0], **task[1]) # so pass those to start_task
else: # otherwise, just pass task_name
start(task)
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))


def start(task_name, **kwargs):
"""Start a given task in a new thread. Pass args to the task's run()
function."""
logger.info("Starting task '{0}' in a new thread".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


try:
task = _tasks[task_name]
except KeyError:
error = "Couldn't find task '{0}': bot/tasks/{0}.py does not exist"
logger.error(error.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)


task_thread = threading.Thread(target=lambda: _wrapper(task, **kwargs))
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


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


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


def get(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]


Will raise KeyError if the task is not found.
"""
return _tasks[task_name]
def get_all(self):
"""Return our dict of all loaded tasks."""
return self._tasks


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

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

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


from earwigbot.classes import BaseTask
from earwigbot.tasks import BaseTask


class Task(BaseTask): class Task(BaseTask):
"""A task to delink mainspace categories in declined [[WP:AFC]] """A task to delink mainspace categories in declined [[WP:AFC]]


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

@@ -27,8 +27,8 @@ from threading import Lock
import oursql import oursql


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


class Task(BaseTask): class Task(BaseTask):
"""A task to check newly-edited [[WP:AFC]] submissions for copyright """A task to check newly-edited [[WP:AFC]] submissions for copyright


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

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


from earwigbot.classes import BaseTask
from earwigbot.tasks import BaseTask


class Task(BaseTask): class Task(BaseTask):
""" A task to create daily categories for [[WP:AFC]].""" """ A task to create daily categories for [[WP:AFC]]."""


+ 1
- 1
earwigbot/tasks/afc_history.py View File

@@ -32,8 +32,8 @@ from numpy import arange
import oursql import oursql


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


# Valid submission statuses: # Valid submission statuses:
STATUS_NONE = 0 STATUS_NONE = 0


+ 1
- 1
earwigbot/tasks/afc_statistics.py View File

@@ -30,8 +30,8 @@ from time import sleep
import oursql import oursql


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


# Chart status number constants: # Chart status number constants:
CHART_NONE = 0 CHART_NONE = 0


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

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


from earwigbot.classes import BaseTask
from earwigbot.tasks import BaseTask


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


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

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


from earwigbot.classes import BaseTask
from earwigbot.tasks import BaseTask


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


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

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


from earwigbot.classes import BaseTask
from earwigbot.tasks import BaseTask


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


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

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


from earwigbot.classes import BaseTask
from earwigbot.tasks import BaseTask


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


+ 11
- 8
earwigbot/tests/__init__.py View File

@@ -34,18 +34,18 @@ instead of a socket for data.
import re import re
from unittest import TestCase from unittest import TestCase


from earwigbot.classes import Connection, Data
from earwigbot.irc import IRCConnection, Data


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


def setUp(self, command): def setUp(self, command):
self.connection = FakeConnection() self.connection = FakeConnection()
self.connection.connect()
self.connection._connect()
self.command = command(self.connection) self.command = command(self.connection)


def get_single(self): def get_single(self):
data = self.connection.get().split("\n")
data = self.connection._get().split("\n")
line = data.pop(0) line = data.pop(0)
for remaining in data[1:]: for remaining in data[1:]:
self.connection.send(remaining) self.connection.send(remaining)
@@ -92,16 +92,19 @@ class CommandTestCase(TestCase):
line = ":Foo!bar@example.com JOIN :#channel".strip().split() line = ":Foo!bar@example.com JOIN :#channel".strip().split()
return self.maker(line, line[2][1:]) return self.maker(line, line[2][1:])


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

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


def close(self):
def _close(self):
pass pass


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


def send(self, msg):
def _send(self, msg):
self._buffer += msg + "\n" self._buffer += msg + "\n"

+ 0
- 114
earwigbot/watcher.py View File

@@ -1,114 +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 IRC Watcher Component

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 rules.py's process() function, which can result in wiki bot tasks
being started (located in tasks/) or messages being sent to channels on the IRC
frontend.
"""

import logging

from earwigbot import rules
from earwigbot.classes import Connection, RC, BrokenSocketException
from earwigbot.config import config

frontend_conn = None
logger = logging.getLogger("earwigbot.watcher")

def get_connection():
"""Return a new Connection() instance with connection information.

Don't actually connect yet.
"""
cf = config.irc["watcher"]
connection = Connection(cf["host"], cf["port"], cf["nick"], cf["ident"],
cf["realname"], logger)
return connection

def main(connection, f_conn=None):
"""Main loop for the Watcher IRC Bot component.
get_connection() should have already been called and the connection should
have been started with connection.connect(). Accept the frontend connection
as well as an optional parameter in order to send messages directly to
frontend IRC channels.
"""
global frontend_conn
frontend_conn = f_conn
read_buffer = str()

while 1:
try:
read_buffer = read_buffer + connection.get()
except BrokenSocketException:
return

lines = read_buffer.split("\n")
read_buffer = lines.pop()

for line in lines:
_process_message(connection, line)

def _process_message(connection, line):
"""Process a single message from IRC."""
line = line.strip().split()

if line[1] == "PRIVMSG":
chan = line[2]

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

msg = ' '.join(line[3:])[1:]
rc = RC(msg) # new RC object to store this event's data
rc.parse() # parse a message into pagenames, usernames, etc.
process_rc(rc) # report to frontend channels or start tasks

# If we are pinged, pong back to the server:
elif line[0] == "PING":
msg = " ".join(("PONG", line[1]))
connection.send(msg)

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

def process_rc(rc):
"""Process a recent change event from IRC (or, an RC object).

The actual processing is configurable, so we don't have that hard-coded
here. We simply call rules's process() function and expect a list of
channels back, which we report the event data to.
"""
chans = rules.process(rc)
if chans and frontend_conn:
pretty = rc.prettify()
for chan in chans:
frontend_conn.say(chan, pretty)

Loading…
Cancel
Save