Переглянути джерело

Merge branch 'feature/irc_update' into develop

tags/v0.1^2
Ben Kurtovic 12 роки тому
джерело
коміт
275c82ec7c
44 змінених файлів з 673 додано та 727 видалено
  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 Переглянути файл

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

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 Переглянути файл

@@ -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 Переглянути файл

@@ -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 Переглянути файл

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

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 os
import sys

from earwigbot.classes import BaseCommand
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:
_load_command(connection, filename)
command = sys.modules[name].Command(self._connection)
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 Переглянути файл

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

import re

from earwigbot.classes import BaseCommand
from earwigbot import tasks
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."""
@@ -36,7 +36,7 @@ class Command(BaseCommand):
self.data = data

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


+ 1
- 1
earwigbot/commands/afc_status.py Переглянути файл

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

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

class Command(BaseCommand):


+ 1
- 1
earwigbot/commands/calc.py Переглянути файл

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

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

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


+ 1
- 1
earwigbot/commands/chanops.py Переглянути файл

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

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

class Command(BaseCommand):


+ 1
- 1
earwigbot/commands/crypt.py Переглянути файл

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

import hashlib

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

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


+ 3
- 3
earwigbot/commands/ctcp.py Переглянути файл

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

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

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

+ 1
- 1
earwigbot/commands/editcount.py Переглянути файл

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

from urllib import quote_plus

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

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


+ 1
- 1
earwigbot/commands/git.py Переглянути файл

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

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

class Command(BaseCommand):


+ 3
- 3
earwigbot/commands/help.py Переглянути файл

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

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):
"""Displays help information."""
name = "help"

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


+ 1
- 1
earwigbot/commands/link.py Переглянути файл

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

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

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


+ 1
- 1
earwigbot/commands/praise.py Переглянути файл

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

import random

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

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


+ 1
- 1
earwigbot/commands/registration.py Переглянути файл

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

import time

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

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


+ 1
- 1
earwigbot/commands/remind.py Переглянути файл

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

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

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


+ 1
- 1
earwigbot/commands/replag.py Переглянути файл

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

import oursql

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

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


+ 37
- 0
earwigbot/commands/restart.py Переглянути файл

@@ -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 Переглянути файл

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

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

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


+ 1
- 1
earwigbot/commands/test.py Переглянути файл

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

import random

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

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


+ 7
- 6
earwigbot/commands/threads.py Переглянути файл

@@ -23,9 +23,10 @@
import threading
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.irc import KwargParseException
from earwigbot.tasks import task_manager

class Command(BaseCommand):
"""Manage wiki tasks from IRC, and check on thread status."""
@@ -105,7 +106,7 @@ class Command(BaseCommand):
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 = tasks.get_all().keys()
all_tasks = task_manager.get_all().keys()
threads = threading.enumerate()
tasklist = []

@@ -146,14 +147,14 @@ class Command(BaseCommand):
self.connection.reply(data, msg)
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:
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))
return

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)
self.connection.reply(data, msg)



+ 1
- 1
earwigbot/config.py Переглянути файл

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

def _load(self, data):


+ 0
- 137
earwigbot/frontend.py Переглянути файл

@@ -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 Переглянути файл

@@ -20,8 +20,8 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# 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 Переглянути файл

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

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

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

class Connection(object):
class IRCConnection(object):
"""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.port = port
self.nick = nick
self.ident = ident
self.realname = realname
self.logger = logger
self.is_running = False

# 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."""
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
self.sock.connect((self.host, self.port))
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.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."""
try:
self.sock.shutdown(socket.SHUT_RDWR) # shut down connection first
self._sock.shutdown(socket.SHUT_RDWR) # Shut down connection first
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."""
data = self.sock.recv(4096)
data = self._sock.recv(4096)
if not data:
# Socket isn't giving us any data, so it is dead or broken:
raise BrokenSocketException()
return data

def send(self, msg):
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:
self.sock.sendall(msg + "\r\n")
with self._lock:
self._sock.sendall(msg + "\r\n")
self.logger.debug(msg)

def say(self, target, msg):
"""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):
"""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):
"""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):
"""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):
"""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):
"""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):
"""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 Переглянути файл


+ 99
- 0
earwigbot/irc/frontend.py Переглянути файл

@@ -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 Переглянути файл


+ 88
- 0
earwigbot/irc/watcher.py Переглянути файл

@@ -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 Переглянути файл

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

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

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
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:
watcher.main(w_conn, f_conn)
watcher.loop()
except:
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")

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

tasks.schedule(now)

task_manager.schedule()
time_end = time.time()
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)

def irc_frontend():
"""If the IRC frontend is enabled, make it run on our primary thread, and
enable the wiki scheduler and IRC watcher on new threads if they are
enabled."""
global f_conn
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")
tasks.load()
task_manager.load()
t_scheduler = threading.Thread(target=wiki_scheduler)
t_scheduler.name = "wiki-scheduler"
t_scheduler.daemon = True
t_scheduler.start()

if "irc_watcher" in config.components:
if config.components.get("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.daemon = True
t_watcher.start()

frontend.main()
frontend.loop()

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

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
# additional components through that function
# additional components through that function:
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
# another thread iff it is enabled
# another thread iff it is enabled:
logger.info("Starting wiki scheduler")
tasks.load()
task_manager.load()
if "irc_watcher" in enabled:
logger.info("Starting IRC watcher")
t_watcher = threading.Thread(target=irc_watcher)
@@ -135,7 +125,7 @@ def main():
t_watcher.start()
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
# only and don't worry about anything else:
logger.info("Starting IRC watcher")


+ 3
- 3
earwigbot/rules.py Переглянути файл

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

import re

from earwigbot import tasks
from earwigbot.tasks import task_manager

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

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

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

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

elif rc.flags == "restore" and r_restore.match(comment):
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")

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


+ 186
- 91
earwigbot/tasks/__init__.py Переглянути файл

@@ -23,8 +23,10 @@
"""
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
@@ -33,106 +35,199 @@ import sys
import threading
import time

from earwigbot.classes import BaseTask
from earwigbot import wiki
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:
_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 Переглянути файл

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

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

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


+ 1
- 1
earwigbot/tasks/afc_copyvios.py Переглянути файл

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

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

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


+ 1
- 1
earwigbot/tasks/afc_dailycats.py Переглянути файл

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

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

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


+ 1
- 1
earwigbot/tasks/afc_history.py Переглянути файл

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

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

# Valid submission statuses:
STATUS_NONE = 0


+ 1
- 1
earwigbot/tasks/afc_statistics.py Переглянути файл

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

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

# Chart status number constants:
CHART_NONE = 0


+ 1
- 1
earwigbot/tasks/afc_undated.py Переглянути файл

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

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

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


+ 1
- 1
earwigbot/tasks/blptag.py Переглянути файл

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

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

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


+ 1
- 1
earwigbot/tasks/feed_dailycats.py Переглянути файл

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

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

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


+ 1
- 1
earwigbot/tasks/wrongmime.py Переглянути файл

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

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

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


+ 11
- 8
earwigbot/tests/__init__.py Переглянути файл

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

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

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

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

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

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

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

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

+ 0
- 114
earwigbot/watcher.py Переглянути файл

@@ -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)

Завантаження…
Відмінити
Зберегти