A Python robot that edits Wikipedia and interacts with people over IRC https://en.wikipedia.org/wiki/User:EarwigBot
Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

298 рядки
12 KiB

  1. #! /usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. #
  4. # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
  5. #
  6. # Permission is hereby granted, free of charge, to any person obtaining a copy
  7. # of this software and associated documentation files (the "Software"), to deal
  8. # in the Software without restriction, including without limitation the rights
  9. # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  10. # copies of the Software, and to permit persons to whom the Software is
  11. # furnished to do so, subject to the following conditions:
  12. #
  13. # The above copyright notice and this permission notice shall be included in
  14. # all copies or substantial portions of the Software.
  15. #
  16. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  17. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  18. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  19. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  20. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  21. # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  22. # SOFTWARE.
  23. import imp
  24. from os import listdir, path
  25. from re import sub
  26. from threading import RLock, Thread
  27. from time import gmtime, strftime
  28. from earwigbot.commands import Command
  29. from earwigbot.tasks import Task
  30. __all__ = ["CommandManager", "TaskManager"]
  31. class _ResourceManager(object):
  32. """
  33. **EarwigBot: Resource Manager**
  34. Resources are essentially objects dynamically loaded by the bot, both
  35. packaged with it (built-in resources) and created by users (plugins, aka
  36. custom resources). Currently, the only two types of resources are IRC
  37. commands and bot tasks. These are both loaded from two locations: the
  38. :py:mod:`earwigbot.commands` and :py:mod:`earwigbot.tasks packages`, and
  39. the :file:`commands/` and :file:`tasks/` directories within the bot's
  40. working directory.
  41. This class handles the low-level tasks of (re)loading resources via
  42. :py:meth:`load`, retrieving specific resources via :py:meth:`get`, and
  43. iterating over all resources via :py:meth:`__iter__`.
  44. """
  45. def __init__(self, bot, name, base):
  46. self.bot = bot
  47. self.logger = bot.logger.getChild(name)
  48. self._resources = {}
  49. self._resource_name = name # e.g. "commands" or "tasks"
  50. self._resource_base = base # e.g. Command or Task
  51. self._resource_access_lock = RLock()
  52. def __repr__(self):
  53. """Return the canonical string representation of the manager."""
  54. res = "{0}(bot={1!r}, name={2!r}, base={3!r})"
  55. return res.format(self.__class__.__name__, self.bot,
  56. self._resource_name, self._resource_base)
  57. def __str__(self):
  58. """Return a nice string representation of the manager."""
  59. return "<{0} of {1}>".format(self.__class__.__name__, self.bot)
  60. def __iter__(self):
  61. with self.lock:
  62. for resource in self._resources.itervalues():
  63. yield resource
  64. def _is_disabled(self, name):
  65. """Check whether a resource should be disabled."""
  66. conf = getattr(self.bot.config, self._resource_name)
  67. disabled = conf.get("disable", [])
  68. enabled = conf.get("enable", [])
  69. return name not in enabled and (disabled is True or name in disabled)
  70. def _load_resource(self, name, path, klass):
  71. """Instantiate a resource class and add it to the dictionary."""
  72. res_type = self._resource_name[:-1] # e.g. "command" or "task"
  73. if hasattr(klass, "name"):
  74. classname = getattr(klass, "name")
  75. if self._is_disabled(name) and self._is_disabled(classname):
  76. log = "Skipping disabled {0} {1}"
  77. self.logger.debug(log.format(res_type, classname))
  78. return
  79. try:
  80. resource = klass(self.bot) # Create instance of resource
  81. except Exception:
  82. e = "Error instantiating {0} class in '{1}' (from {2})"
  83. self.logger.exception(e.format(res_type, name, path))
  84. else:
  85. self._resources[resource.name] = resource
  86. self.logger.debug("Loaded {0} {1}".format(res_type, resource.name))
  87. def _load_module(self, name, path):
  88. """Load a specific resource from a module, identified by name and path.
  89. We'll first try to import it using imp magic, and if that works, make
  90. instances of any classes inside that are subclasses of the base
  91. (:py:attr:`self._resource_base <_resource_base>`), add them to the
  92. resources dictionary with :py:meth:`self._load_resource()
  93. <_load_resource>`, and finally log the addition. Any problems along
  94. the way will either be ignored or logged.
  95. """
  96. f, path, desc = imp.find_module(name, [path])
  97. try:
  98. module = imp.load_module(name, f, path, desc)
  99. except Exception:
  100. e = "Couldn't load module '{0}' (from {1})"
  101. self.logger.exception(e.format(name, path))
  102. return
  103. finally:
  104. f.close()
  105. for obj in vars(module).values():
  106. if type(obj) is type:
  107. isresource = issubclass(obj, self._resource_base)
  108. if isresource and not obj is self._resource_base:
  109. self._load_resource(name, path, obj)
  110. def _load_directory(self, dir):
  111. """Load all valid resources in a given directory."""
  112. self.logger.debug("Loading directory {0}".format(dir))
  113. processed = []
  114. for name in listdir(dir):
  115. if not name.endswith(".py") and not name.endswith(".pyc"):
  116. continue
  117. if name.startswith("_") or name.startswith("."):
  118. continue
  119. modname = sub("\.pyc?$", "", name) # Remove extension
  120. if modname in processed:
  121. continue
  122. processed.append(modname)
  123. if self._is_disabled(modname):
  124. log = "Skipping disabled module {0}".format(modname)
  125. self.logger.debug(log)
  126. continue
  127. self._load_module(modname, dir)
  128. def _unload_resources(self):
  129. """Unload all resources, calling their unload hooks in the process."""
  130. res_type = self._resource_name[:-1] # e.g. "command" or "task"
  131. for resource in self:
  132. if not hasattr(resource, "unload"):
  133. continue
  134. try:
  135. resource.unload()
  136. except Exception:
  137. e = "Error unloading {0} '{1}'"
  138. self.logger.exception(e.format(res_type, resource.name))
  139. self._resources.clear()
  140. @property
  141. def lock(self):
  142. """The resource access/modify lock."""
  143. return self._resource_access_lock
  144. def load(self):
  145. """Load (or reload) all valid resources into :py:attr:`_resources`."""
  146. name = self._resource_name # e.g. "commands" or "tasks"
  147. with self.lock:
  148. self._unload_resources()
  149. builtin_dir = path.join(path.dirname(__file__), name)
  150. plugins_dir = path.join(self.bot.config.root_dir, name)
  151. conf = getattr(self.bot.config, name)
  152. if conf.get("disable") is True and not conf.get("enable"):
  153. log = "Skipping disabled builtins directory: {0}"
  154. self.logger.debug(log.format(builtin_dir))
  155. else:
  156. self._load_directory(builtin_dir) # Built-in resources
  157. if path.exists(plugins_dir) and path.isdir(plugins_dir):
  158. self._load_directory(plugins_dir) # Custom resources, plugins
  159. else:
  160. log = "Skipping nonexistent plugins directory: {0}"
  161. self.logger.debug(log.format(plugins_dir))
  162. if self._resources:
  163. msg = "Loaded {0} {1}: {2}"
  164. resources = ", ".join(self._resources.keys())
  165. self.logger.info(msg.format(len(self._resources), name, resources))
  166. else:
  167. self.logger.info("Loaded 0 {0}".format(name))
  168. def get(self, key):
  169. """Return the class instance associated with a certain resource.
  170. Will raise :py:exc:`KeyError` if the resource (a command or task) is
  171. not found.
  172. """
  173. with self.lock:
  174. return self._resources[key]
  175. class CommandManager(_ResourceManager):
  176. """
  177. Manages (i.e., loads, reloads, and calls) IRC commands.
  178. """
  179. def __init__(self, bot):
  180. super(CommandManager, self).__init__(bot, "commands", Command)
  181. def _wrap_check(self, command, data):
  182. """Check whether a command should be called, catching errors."""
  183. try:
  184. return command.check(data)
  185. except Exception:
  186. e = "Error checking command '{0}' with data: {1}:"
  187. self.logger.exception(e.format(command.name, data))
  188. def _wrap_process(self, command, data):
  189. """process() the message, catching and reporting any errors."""
  190. try:
  191. command.process(data)
  192. except Exception:
  193. e = "Error executing command '{0}':"
  194. self.logger.exception(e.format(command.name))
  195. def call(self, hook, data):
  196. """Respond to a hook type and a :py:class:`~.Data` object.
  197. .. note::
  198. The special ``rc`` hook actually passes a :class:`~.RC` object.
  199. """
  200. if data.chan in self.bot.config.irc["frontend"].get("quiet", []):
  201. return
  202. for command in self:
  203. if hook in command.hooks and self._wrap_check(command, data):
  204. thread = Thread(target=self._wrap_process,
  205. args=(command, data))
  206. start_time = strftime("%b %d %H:%M:%S")
  207. thread.name = "irc:{0} ({1})".format(command.name, start_time)
  208. thread.daemon = True
  209. thread.start()
  210. return
  211. class TaskManager(_ResourceManager):
  212. """
  213. Manages (i.e., loads, reloads, schedules, and runs) wiki bot tasks.
  214. """
  215. def __init__(self, bot):
  216. super(TaskManager, self).__init__(bot, "tasks", Task)
  217. def _wrapper(self, task, **kwargs):
  218. """Wrapper for task classes: run the task and catch any errors."""
  219. try:
  220. task.run(**kwargs)
  221. except Exception:
  222. msg = "Task '{0}' raised an exception and had to stop:"
  223. self.logger.exception(msg.format(task.name))
  224. else:
  225. msg = "Task '{0}' finished successfully"
  226. self.logger.info(msg.format(task.name))
  227. if kwargs.get("fromIRC"):
  228. kwargs.get("_IRCCallback")()
  229. def start(self, task_name, **kwargs):
  230. """Start a given task in a new daemon thread, and return the thread.
  231. kwargs are passed to :py:meth:`task.run() <earwigbot.tasks.Task.run>`.
  232. If the task is not found, ``None`` will be returned and an error will
  233. be logged.
  234. """
  235. msg = "Starting task '{0}' in a new thread"
  236. self.logger.info(msg.format(task_name))
  237. try:
  238. task = self.get(task_name)
  239. except KeyError:
  240. e = "Couldn't find task '{0}'"
  241. self.logger.error(e.format(task_name))
  242. return
  243. task_thread = Thread(target=self._wrapper, args=(task,), kwargs=kwargs)
  244. start_time = strftime("%b %d %H:%M:%S")
  245. task_thread.name = "{0} ({1})".format(task_name, start_time)
  246. task_thread.daemon = True
  247. task_thread.start()
  248. return task_thread
  249. def schedule(self, now=None):
  250. """Start all tasks that are supposed to be run at a given time."""
  251. if not now:
  252. now = gmtime()
  253. # Get list of tasks to run this turn:
  254. tasks = self.bot.config.schedule(now.tm_min, now.tm_hour, now.tm_mday,
  255. now.tm_mon, now.tm_wday)
  256. for task in tasks:
  257. if isinstance(task, list): # They've specified kwargs,
  258. self.start(task[0], **task[1]) # so pass those to start
  259. else: # Otherwise, just pass task_name
  260. self.start(task)