A Python robot that edits Wikipedia and interacts with people over IRC https://en.wikipedia.org/wiki/User:EarwigBot
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

315 行
12 KiB

  1. # -*- coding: utf-8 -*-
  2. #
  3. # Copyright (C) 2009-2014 Ben Kurtovic <ben.kurtovic@gmail.com>
  4. #
  5. # Permission is hereby granted, free of charge, to any person obtaining a copy
  6. # of this software and associated documentation files (the "Software"), to deal
  7. # in the Software without restriction, including without limitation the rights
  8. # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  9. # copies of the Software, and to permit persons to whom the Software is
  10. # furnished to do so, subject to the following conditions:
  11. #
  12. # The above copyright notice and this permission notice shall be included in
  13. # all copies or substantial portions of the Software.
  14. #
  15. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  16. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  17. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  18. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  19. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  20. # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  21. # SOFTWARE.
  22. import ast
  23. from itertools import chain
  24. import operator
  25. import random
  26. from threading import Thread
  27. import time
  28. from earwigbot.commands import Command
  29. DISPLAY = ["display", "show", "list", "info", "details"]
  30. CANCEL = ["cancel", "stop", "delete", "del", "stop"]
  31. SNOOZE = ["snooze", "delay", "reset", "adjust", "modify", "change"]
  32. class Remind(Command):
  33. """Set a message to be repeated to you in a certain amount of time."""
  34. name = "remind"
  35. commands = ["remind", "reminder", "reminders", "snooze"]
  36. @staticmethod
  37. def _normalize(command):
  38. """Convert a command name into its canonical form."""
  39. if command in DISPLAY:
  40. return "display"
  41. if command in CANCEL:
  42. return "cancel"
  43. if command in SNOOZE:
  44. return "snooze"
  45. @staticmethod
  46. def _parse_time(arg):
  47. """Parse the wait time for a reminder."""
  48. ast_to_op = {
  49. ast.Add: operator.add, ast.Sub: operator.sub,
  50. ast.Mult: operator.mul, ast.Div: operator.truediv,
  51. ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod,
  52. ast.Pow: operator.pow
  53. }
  54. def _evaluate(node):
  55. """Convert an AST node into a real number or raise an exception."""
  56. if isinstance(node, ast.Num):
  57. if not isinstance(node.n, (int, long, float)):
  58. raise ValueError(node.n)
  59. return node.n
  60. elif isinstance(node, ast.BinOp):
  61. left, right = _evaluate(node.left), _evaluate(node.right)
  62. return ast_to_op[type(node.op)](left, right)
  63. else:
  64. raise ValueError(node)
  65. try:
  66. parsed = int(_evaluate(ast.parse(arg, mode="eval").body))
  67. except (SyntaxError, KeyError):
  68. raise ValueError(arg)
  69. if parsed <= 0:
  70. raise ValueError(parsed)
  71. return parsed
  72. def _really_get_reminder_by_id(self, user, rid):
  73. """Return the _Reminder object that corresponds to a particular ID.
  74. Raises IndexError on failure.
  75. """
  76. rid = rid.upper()
  77. if user not in self.reminders:
  78. raise IndexError(rid)
  79. return [robj for robj in self.reminders[user] if robj.id == rid][0]
  80. def _get_reminder_by_id(self, user, rid, data):
  81. """Return the _Reminder object that corresponds to a particular ID.
  82. Sends an error message to the user on failure.
  83. """
  84. try:
  85. return self._really_get_reminder_by_id(user, rid)
  86. except IndexError:
  87. msg = "Couldn't find a reminder for \x0302{0}\x0F with ID \x0303{1}\x0F."
  88. self.reply(data, msg.format(user, rid))
  89. def _get_new_id(self):
  90. """Get a free ID for a new reminder."""
  91. taken = set(robj.id for robj in chain(*self.reminders.values()))
  92. num = random.choice(list(set(range(4096)) - taken))
  93. return "R{0:03X}".format(num)
  94. def _create_reminder(self, data, user):
  95. """Create a new reminder for the given user."""
  96. try:
  97. wait = self._parse_time(data.args[0])
  98. except ValueError:
  99. msg = "Invalid time \x02{0}\x0F. Time must be a positive integer, in seconds."
  100. return self.reply(data, msg.format(data.args[0]))
  101. message = " ".join(data.args[1:])
  102. try:
  103. rid = self._get_new_id()
  104. except IndexError:
  105. msg = "Couldn't set a new reminder: no free IDs available."
  106. return self.reply(data, msg)
  107. reminder = _Reminder(rid, user, wait, message, data, self)
  108. reminder.start()
  109. if user in self.reminders:
  110. self.reminders[user].append(reminder)
  111. else:
  112. self.reminders[user] = [reminder]
  113. msg = "Set reminder \x0303{0}\x0F ({1})."
  114. self.reply(data, msg.format(rid, reminder.end_time))
  115. def _display_reminder(self, data, reminder):
  116. """Display a particular reminder's information."""
  117. msg = 'Reminder \x0303{0}\x0F: {1} seconds ({2}): "{3}".'
  118. msg = msg.format(reminder.id, reminder.wait, reminder.end_time,
  119. reminder.message)
  120. self.reply(data, msg)
  121. def _cancel_reminder(self, data, user, reminder):
  122. """Cancel a pending reminder."""
  123. reminder.stop()
  124. self.reminders[user].remove(reminder)
  125. if not self.reminders[user]:
  126. del self.reminders[user]
  127. msg = "Reminder \x0303{0}\x0F canceled."
  128. self.reply(data, msg.format(reminder.id))
  129. def _snooze_reminder(self, data, reminder, arg=None):
  130. """Snooze a reminder to be re-triggered after a period of time."""
  131. verb = "snoozed" if reminder.end < time.time() else "adjusted"
  132. if arg:
  133. try:
  134. duration = self._parse_time(data.args[arg])
  135. reminder.wait = duration
  136. except (IndexError, ValueError):
  137. pass
  138. reminder.start()
  139. end = time.strftime("%b %d %H:%M:%S %Z", time.localtime(reminder.end))
  140. msg = "Reminder \x0303{0}\x0F {1} until {2}."
  141. self.reply(data, msg.format(reminder.id, verb, end))
  142. def _handle_command(self, command, data, user, reminder, arg=None):
  143. """Handle a reminder-processing subcommand."""
  144. if command in DISPLAY:
  145. self._display_reminder(data, reminder)
  146. elif command in CANCEL:
  147. self._cancel_reminder(data, user, reminder)
  148. elif command in SNOOZE:
  149. self._snooze_reminder(data, reminder, arg)
  150. else:
  151. msg = "Unknown action \x02{0}\x0F for reminder \x0303{1}\x0F."
  152. self.reply(data, msg.format(command, reminder.id))
  153. def _show_reminders(self, data, user):
  154. """Show all of a user's current reminders."""
  155. shorten = lambda s: (s[:37] + "..." if len(s) > 40 else s)
  156. tmpl = '\x0303{0}\x0F ("{1}", {2})'
  157. fmt = lambda robj: tmpl.format(robj.id, shorten(robj.message),
  158. robj.end_time)
  159. if user in self.reminders:
  160. rlist = ", ".join(fmt(robj) for robj in self.reminders[user])
  161. msg = "Your reminders: {0}.".format(rlist)
  162. else:
  163. msg = "You have no reminders. Set one with \x0306!remind [time] [message]\x0F. See also: \x0306!remind help\x0F."
  164. self.reply(data, msg)
  165. def _process_snooze_command(self, data, user):
  166. """Process the !snooze command."""
  167. if not data.args:
  168. if user not in self.reminders:
  169. self.reply(data, "You have no reminders to snooze.")
  170. elif len(self.reminders[user]) == 1:
  171. self._snooze_reminder(data, self.reminders[user][0])
  172. else:
  173. msg = "You have {0} reminders. Snooze which one?"
  174. self.reply(data, msg.format(len(self.reminders[user])))
  175. return
  176. reminder = self._get_reminder_by_id(user, data.args[0], data)
  177. if reminder:
  178. self._snooze_reminder(data, reminder, 1)
  179. def _show_help(self, data):
  180. """Reply to the user with help for all major subcommands."""
  181. parts = [
  182. ("Add new", "!remind [time] [message]"),
  183. ("List all", "!reminders"),
  184. ("Get info", "!remind [id]"),
  185. ("Cancel", "!remind cancel [id]"),
  186. ("Adjust", "!remind adjust [id] [time]"),
  187. ("Restart", "!snooze [id]")
  188. ]
  189. extra = "In most cases, \x0306[id]\x0F can be omitted if you have only one reminder."
  190. joined = " ".join("{0}: \x0306{1}\x0F.".format(k, v) for k, v in parts)
  191. self.reply(data, joined + " " + extra)
  192. def setup(self):
  193. self.reminders = {}
  194. def process(self, data):
  195. if data.command == "snooze":
  196. return self._process_snooze_command(data, data.host)
  197. if not data.args:
  198. return self._show_reminders(data, data.host)
  199. user = data.host
  200. if len(data.args) == 1:
  201. command = data.args[0]
  202. if command == "help":
  203. return self._show_help(data)
  204. if command in DISPLAY + CANCEL + SNOOZE:
  205. if user not in self.reminders:
  206. msg = "You have no reminders to {0}."
  207. self.reply(data, msg.format(self._normalize(command)))
  208. elif len(self.reminders[user]) == 1:
  209. reminder = self.reminders[user][0]
  210. self._handle_command(command, data, user, reminder)
  211. else:
  212. msg = "You have {0} reminders. {1} which one?"
  213. num = len(self.reminders[user])
  214. command = self._normalize(command).capitalize()
  215. self.reply(data, msg.format(num, command))
  216. return
  217. reminder = self._get_reminder_by_id(user, data.args[0], data)
  218. if reminder:
  219. self._display_reminder(data, reminder)
  220. return
  221. if data.args[0] in DISPLAY + CANCEL + SNOOZE:
  222. reminder = self._get_reminder_by_id(user, data.args[1], data)
  223. if reminder:
  224. self._handle_command(data.args[0], data, user, reminder, 2)
  225. return
  226. try:
  227. reminder = self._really_get_reminder_by_id(user, data.args[0])
  228. except IndexError:
  229. return self._create_reminder(data, user)
  230. self._handle_command(data.args[1], data, user, reminder, 2)
  231. class _Reminder(object):
  232. """Represents a single reminder."""
  233. def __init__(self, rid, user, wait, message, data, cmdobj):
  234. self.id = rid
  235. self.wait = wait
  236. self.message = message
  237. self.end = None
  238. self._user = user
  239. self._data = data
  240. self._cmdobj = cmdobj
  241. self._thread = None
  242. def _callback(self):
  243. """Internal callback function to be executed by the reminder thread."""
  244. thread = self._thread
  245. while time.time() < thread.end:
  246. time.sleep(1)
  247. if thread.abort:
  248. return
  249. self._cmdobj.reply(self._data, self.message)
  250. for i in xrange(60):
  251. time.sleep(1)
  252. if thread.abort:
  253. return
  254. try:
  255. self._cmdobj.reminders[self._user].remove(self)
  256. if not self._cmdobj.reminders[self._user]:
  257. del self._cmdobj.reminders[self._user]
  258. except (KeyError, ValueError): # Already canceled by the user
  259. pass
  260. @property
  261. def end_time(self):
  262. """Return a string representing the end time of a reminder."""
  263. if self.end >= time.time():
  264. ends = time.strftime("%b %d %H:%M:%S %Z", time.localtime(self.end))
  265. return "ends {0}".format(ends)
  266. return "expired"
  267. def start(self):
  268. """Start the reminder timer thread. Stops it if already running."""
  269. self.stop()
  270. self._thread = Thread(target=self._callback, name="remind-" + self.id)
  271. self._thread.end = self.end = time.time() + self.wait
  272. self._thread.daemon = True
  273. self._thread.abort = False
  274. self._thread.start()
  275. def stop(self):
  276. """Stop a currently running reminder."""
  277. if not self._thread:
  278. return
  279. self._thread.abort = True
  280. self._thread = None