A Python robot that edits Wikipedia and interacts with people over IRC https://en.wikipedia.org/wiki/User:EarwigBot
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

340 lines
13 KiB

  1. # -*- coding: utf-8 -*-
  2. #
  3. # Copyright (C) 2009-2015 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", "unremind", "forget",
  31. "disregard"]
  32. SNOOZE = ["snooze", "delay", "reset", "adjust", "modify", "change"]
  33. class Remind(Command):
  34. """Set a message to be repeated to you in a certain amount of time."""
  35. name = "remind"
  36. commands = ["remind", "reminder", "reminders", "snooze", "cancel",
  37. "unremind", "forget"]
  38. @staticmethod
  39. def _normalize(command):
  40. """Convert a command name into its canonical form."""
  41. if command in DISPLAY:
  42. return "display"
  43. if command in CANCEL:
  44. return "cancel"
  45. if command in SNOOZE:
  46. return "snooze"
  47. @staticmethod
  48. def _parse_time(arg):
  49. """Parse the wait time for a reminder."""
  50. ast_to_op = {
  51. ast.Add: operator.add, ast.Sub: operator.sub,
  52. ast.Mult: operator.mul, ast.Div: operator.truediv,
  53. ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod,
  54. ast.Pow: operator.pow
  55. }
  56. time_units = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
  57. def _evaluate(node):
  58. """Convert an AST node into a real number or raise an exception."""
  59. if isinstance(node, ast.Num):
  60. if not isinstance(node.n, (int, long, float)):
  61. raise ValueError(node.n)
  62. return node.n
  63. elif isinstance(node, ast.BinOp):
  64. left, right = _evaluate(node.left), _evaluate(node.right)
  65. return ast_to_op[type(node.op)](left, right)
  66. else:
  67. raise ValueError(node)
  68. if arg and arg[-1] in time_units:
  69. factor, arg = time_units[arg[-1]], arg[:-1]
  70. else:
  71. factor = 1
  72. try:
  73. parsed = int(_evaluate(ast.parse(arg, mode="eval").body) * factor)
  74. except (SyntaxError, KeyError):
  75. raise ValueError(arg)
  76. if parsed <= 0:
  77. raise ValueError(parsed)
  78. return parsed
  79. def _really_get_reminder_by_id(self, user, rid):
  80. """Return the _Reminder object that corresponds to a particular ID.
  81. Raises IndexError on failure.
  82. """
  83. rid = rid.upper()
  84. if user not in self.reminders:
  85. raise IndexError(rid)
  86. return [robj for robj in self.reminders[user] if robj.id == rid][0]
  87. def _get_reminder_by_id(self, user, rid, data):
  88. """Return the _Reminder object that corresponds to a particular ID.
  89. Sends an error message to the user on failure.
  90. """
  91. try:
  92. return self._really_get_reminder_by_id(user, rid)
  93. except IndexError:
  94. msg = "Couldn't find a reminder for \x0302{0}\x0F with ID \x0303{1}\x0F."
  95. self.reply(data, msg.format(user, rid))
  96. def _get_new_id(self):
  97. """Get a free ID for a new reminder."""
  98. taken = set(robj.id for robj in chain(*self.reminders.values()))
  99. num = random.choice(list(set(range(4096)) - taken))
  100. return "R{0:03X}".format(num)
  101. def _create_reminder(self, data, user):
  102. """Create a new reminder for the given user."""
  103. try:
  104. wait = self._parse_time(data.args[0])
  105. except ValueError:
  106. msg = "Invalid time \x02{0}\x0F. Time must be a positive integer, in seconds."
  107. return self.reply(data, msg.format(data.args[0]))
  108. message = " ".join(data.args[1:])
  109. try:
  110. rid = self._get_new_id()
  111. except IndexError:
  112. msg = "Couldn't set a new reminder: no free IDs available."
  113. return self.reply(data, msg)
  114. reminder = _Reminder(rid, user, wait, message, data, self)
  115. reminder.start()
  116. if user in self.reminders:
  117. self.reminders[user].append(reminder)
  118. else:
  119. self.reminders[user] = [reminder]
  120. msg = "Set reminder \x0303{0}\x0F ({1})."
  121. self.reply(data, msg.format(rid, reminder.end_time))
  122. def _display_reminder(self, data, reminder):
  123. """Display a particular reminder's information."""
  124. msg = 'Reminder \x0303{0}\x0F: {1} seconds ({2}): "{3}".'
  125. msg = msg.format(reminder.id, reminder.wait, reminder.end_time,
  126. reminder.message)
  127. self.reply(data, msg)
  128. def _cancel_reminder(self, data, user, reminder):
  129. """Cancel a pending reminder."""
  130. reminder.stop()
  131. self.reminders[user].remove(reminder)
  132. if not self.reminders[user]:
  133. del self.reminders[user]
  134. msg = "Reminder \x0303{0}\x0F canceled."
  135. self.reply(data, msg.format(reminder.id))
  136. def _snooze_reminder(self, data, reminder, arg=None):
  137. """Snooze a reminder to be re-triggered after a period of time."""
  138. verb = "snoozed" if reminder.end < time.time() else "adjusted"
  139. if arg:
  140. try:
  141. duration = self._parse_time(data.args[arg])
  142. reminder.wait = duration
  143. except (IndexError, ValueError):
  144. pass
  145. reminder.start()
  146. end = time.strftime("%b %d %H:%M:%S %Z", time.localtime(reminder.end))
  147. msg = "Reminder \x0303{0}\x0F {1} until {2}."
  148. self.reply(data, msg.format(reminder.id, verb, end))
  149. def _handle_command(self, command, data, user, reminder, arg=None):
  150. """Handle a reminder-processing subcommand."""
  151. if command in DISPLAY:
  152. self._display_reminder(data, reminder)
  153. elif command in CANCEL:
  154. self._cancel_reminder(data, user, reminder)
  155. elif command in SNOOZE:
  156. self._snooze_reminder(data, reminder, arg)
  157. else:
  158. msg = "Unknown action \x02{0}\x0F for reminder \x0303{1}\x0F."
  159. self.reply(data, msg.format(command, reminder.id))
  160. def _show_reminders(self, data, user):
  161. """Show all of a user's current reminders."""
  162. shorten = lambda s: (s[:37] + "..." if len(s) > 40 else s)
  163. tmpl = '\x0303{0}\x0F ("{1}", {2})'
  164. fmt = lambda robj: tmpl.format(robj.id, shorten(robj.message),
  165. robj.end_time)
  166. if user in self.reminders:
  167. rlist = ", ".join(fmt(robj) for robj in self.reminders[user])
  168. msg = "Your reminders: {0}.".format(rlist)
  169. else:
  170. msg = "You have no reminders. Set one with \x0306!remind [time] [message]\x0F. See also: \x0306!remind help\x0F."
  171. self.reply(data, msg)
  172. def _process_snooze_command(self, data, user):
  173. """Process the !snooze command."""
  174. if not data.args:
  175. if user not in self.reminders:
  176. self.reply(data, "You have no reminders to snooze.")
  177. elif len(self.reminders[user]) == 1:
  178. self._snooze_reminder(data, self.reminders[user][0])
  179. else:
  180. msg = "You have {0} reminders. Snooze which one?"
  181. self.reply(data, msg.format(len(self.reminders[user])))
  182. return
  183. reminder = self._get_reminder_by_id(user, data.args[0], data)
  184. if reminder:
  185. self._snooze_reminder(data, reminder, 1)
  186. def _process_cancel_command(self, data, user):
  187. """Process the !cancel, !unremind, and !forget commands."""
  188. if not data.args:
  189. if user not in self.reminders:
  190. self.reply(data, "You have no reminders to cancel.")
  191. elif len(self.reminders[user]) == 1:
  192. self._cancel_reminder(data, user, self.reminders[user][0])
  193. else:
  194. msg = "You have {0} reminders. Cancel which one?"
  195. self.reply(data, msg.format(len(self.reminders[user])))
  196. return
  197. reminder = self._get_reminder_by_id(user, data.args[0], data)
  198. if reminder:
  199. self._cancel_reminder(data, user, reminder)
  200. def _show_help(self, data):
  201. """Reply to the user with help for all major subcommands."""
  202. parts = [
  203. ("Add new", "!remind [time] [message]"),
  204. ("List all", "!reminders"),
  205. ("Get info", "!remind [id]"),
  206. ("Cancel", "!remind cancel [id]"),
  207. ("Adjust", "!remind adjust [id] [time]"),
  208. ("Restart", "!snooze [id]")
  209. ]
  210. extra = "In most cases, \x0306[id]\x0F can be omitted if you have only one reminder."
  211. joined = " ".join("{0}: \x0306{1}\x0F.".format(k, v) for k, v in parts)
  212. self.reply(data, joined + " " + extra)
  213. def setup(self):
  214. self.reminders = {}
  215. def process(self, data):
  216. if data.command == "snooze":
  217. return self._process_snooze_command(data, data.host)
  218. if data.command in ["cancel", "unremind", "forget"]:
  219. return self._process_cancel_command(data, data.host)
  220. if not data.args:
  221. return self._show_reminders(data, data.host)
  222. user = data.host
  223. if len(data.args) == 1:
  224. command = data.args[0]
  225. if command == "help":
  226. return self._show_help(data)
  227. if command in DISPLAY + CANCEL + SNOOZE:
  228. if user not in self.reminders:
  229. msg = "You have no reminders to {0}."
  230. self.reply(data, msg.format(self._normalize(command)))
  231. elif len(self.reminders[user]) == 1:
  232. reminder = self.reminders[user][0]
  233. self._handle_command(command, data, user, reminder)
  234. else:
  235. msg = "You have {0} reminders. {1} which one?"
  236. num = len(self.reminders[user])
  237. command = self._normalize(command).capitalize()
  238. self.reply(data, msg.format(num, command))
  239. return
  240. reminder = self._get_reminder_by_id(user, data.args[0], data)
  241. if reminder:
  242. self._display_reminder(data, reminder)
  243. return
  244. if data.args[0] in DISPLAY + CANCEL + SNOOZE:
  245. reminder = self._get_reminder_by_id(user, data.args[1], data)
  246. if reminder:
  247. self._handle_command(data.args[0], data, user, reminder, 2)
  248. return
  249. try:
  250. reminder = self._really_get_reminder_by_id(user, data.args[0])
  251. except IndexError:
  252. return self._create_reminder(data, user)
  253. self._handle_command(data.args[1], data, user, reminder, 2)
  254. class _Reminder(object):
  255. """Represents a single reminder."""
  256. def __init__(self, rid, user, wait, message, data, cmdobj):
  257. self.id = rid
  258. self.wait = wait
  259. self.message = message
  260. self.end = None
  261. self._user = user
  262. self._data = data
  263. self._cmdobj = cmdobj
  264. self._thread = None
  265. def _callback(self):
  266. """Internal callback function to be executed by the reminder thread."""
  267. thread = self._thread
  268. while time.time() < thread.end:
  269. time.sleep(1)
  270. if thread.abort:
  271. return
  272. self._cmdobj.reply(self._data, self.message)
  273. for i in xrange(60):
  274. time.sleep(1)
  275. if thread.abort:
  276. return
  277. try:
  278. self._cmdobj.reminders[self._user].remove(self)
  279. if not self._cmdobj.reminders[self._user]:
  280. del self._cmdobj.reminders[self._user]
  281. except (KeyError, ValueError): # Already canceled by the user
  282. pass
  283. @property
  284. def end_time(self):
  285. """Return a string representing the end time of a reminder."""
  286. if self.end >= time.time():
  287. ends = time.strftime("%b %d %H:%M:%S %Z", time.localtime(self.end))
  288. return "ends {0}".format(ends)
  289. return "expired"
  290. def start(self):
  291. """Start the reminder timer thread. Stops it if already running."""
  292. self.stop()
  293. self._thread = Thread(target=self._callback, name="remind-" + self.id)
  294. self._thread.end = self.end = time.time() + self.wait
  295. self._thread.daemon = True
  296. self._thread.abort = False
  297. self._thread.start()
  298. def stop(self):
  299. """Stop a currently running reminder."""
  300. if not self._thread:
  301. return
  302. self._thread.abort = True
  303. self._thread = None