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.

504 lines
18 KiB

  1. # -*- coding: utf-8 -*-
  2. #
  3. # Copyright (C) 2009-2016 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 RLock, Thread
  27. import time
  28. from earwigbot.commands import Command
  29. from earwigbot.irc import Data
  30. DISPLAY = ["display", "show", "info", "details"]
  31. CANCEL = ["cancel", "stop", "delete", "del", "stop", "unremind", "forget",
  32. "disregard"]
  33. SNOOZE = ["snooze", "delay", "reset", "adjust", "modify", "change"]
  34. SNOOZE_ONLY = ["snooze", "delay", "reset"]
  35. def _format_time(epoch):
  36. """Format a UNIX timestamp nicely."""
  37. lctime = time.localtime(epoch)
  38. if lctime.tm_year == time.localtime().tm_year:
  39. return time.strftime("%b %d %H:%M:%S %Z", lctime)
  40. else:
  41. return time.strftime("%b %d, %Y %H:%M:%S %Z", lctime)
  42. class Remind(Command):
  43. """Set a message to be repeated to you in a certain amount of time. See
  44. usage with !remind help."""
  45. name = "remind"
  46. commands = ["remind", "reminder", "reminders", "snooze", "cancel",
  47. "unremind", "forget"]
  48. @staticmethod
  49. def _normalize(command):
  50. """Convert a command name into its canonical form."""
  51. if command in DISPLAY:
  52. return "display"
  53. if command in CANCEL:
  54. return "cancel"
  55. if command in SNOOZE_ONLY:
  56. return "snooze"
  57. if command in SNOOZE: # "adjust" == snoozing active reminders
  58. return "adjust"
  59. @staticmethod
  60. def _parse_time(arg):
  61. """Parse the wait time for a reminder."""
  62. ast_to_op = {
  63. ast.Add: operator.add, ast.Sub: operator.sub,
  64. ast.Mult: operator.mul, ast.Div: operator.truediv,
  65. ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod,
  66. ast.Pow: operator.pow
  67. }
  68. time_units = {
  69. "s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800, "y": 31536000
  70. }
  71. def _evaluate(node):
  72. """Convert an AST node into a real number or raise an exception."""
  73. if isinstance(node, ast.Num):
  74. if not isinstance(node.n, (int, float)):
  75. raise ValueError(node.n)
  76. return node.n
  77. elif isinstance(node, ast.BinOp):
  78. left, right = _evaluate(node.left), _evaluate(node.right)
  79. return ast_to_op[type(node.op)](left, right)
  80. else:
  81. raise ValueError(node)
  82. for unit, factor in time_units.items():
  83. arg = arg.replace(unit, "*" + str(factor))
  84. try:
  85. parsed = int(_evaluate(ast.parse(arg, mode="eval").body))
  86. except (SyntaxError, KeyError):
  87. raise ValueError(arg)
  88. if parsed <= 0:
  89. raise ValueError(parsed)
  90. return parsed
  91. def _get_reminder_by_id(self, user, rid):
  92. """Return the _Reminder object that corresponds to a particular ID.
  93. Raises IndexError on failure.
  94. """
  95. rid = rid.upper()
  96. if user not in self.reminders:
  97. raise IndexError(rid)
  98. return [robj for robj in self.reminders[user] if robj.id == rid][0]
  99. def _get_new_id(self):
  100. """Get a free ID for a new reminder."""
  101. taken = set(robj.id for robj in chain(*list(self.reminders.values())))
  102. num = random.choice(list(set(range(4096)) - taken))
  103. return "R{0:03X}".format(num)
  104. def _start_reminder(self, reminder, user):
  105. """Start the given reminder object for the given user."""
  106. if user in self.reminders:
  107. self.reminders[user].append(reminder)
  108. else:
  109. self.reminders[user] = [reminder]
  110. self._thread.add(reminder)
  111. def _create_reminder(self, data):
  112. """Create a new reminder for the given user."""
  113. try:
  114. wait = self._parse_time(data.args[0])
  115. except ValueError:
  116. msg = "Invalid time \x02{0}\x0F. Time must be a positive integer, in seconds."
  117. return self.reply(data, msg.format(data.args[0]))
  118. if wait > 1000 * 365 * 24 * 60 * 60:
  119. # Hard to think of a good upper limit, but 1000 years works.
  120. msg = "Given time \x02{0}\x0F is too large. Keep it reasonable."
  121. return self.reply(data, msg.format(data.args[0]))
  122. message = " ".join(data.args[1:])
  123. try:
  124. rid = self._get_new_id()
  125. except IndexError:
  126. msg = "Couldn't set a new reminder: no free IDs available."
  127. return self.reply(data, msg)
  128. reminder = _Reminder(rid, data.host, wait, message, data, self)
  129. self._start_reminder(reminder, data.host)
  130. msg = "Set reminder \x0303{0}\x0F ({1})."
  131. self.reply(data, msg.format(rid, reminder.end_time))
  132. def _display_reminder(self, data, reminder):
  133. """Display a particular reminder's information."""
  134. msg = 'Reminder \x0303{0}\x0F: {1} seconds ({2}): "{3}".'
  135. msg = msg.format(reminder.id, reminder.wait, reminder.end_time,
  136. reminder.message)
  137. self.reply(data, msg)
  138. def _cancel_reminder(self, data, reminder):
  139. """Cancel a pending reminder."""
  140. self._thread.remove(reminder)
  141. self.unstore_reminder(reminder.id)
  142. self.reminders[data.host].remove(reminder)
  143. if not self.reminders[data.host]:
  144. del self.reminders[data.host]
  145. msg = "Reminder \x0303{0}\x0F canceled."
  146. self.reply(data, msg.format(reminder.id))
  147. def _snooze_reminder(self, data, reminder, arg=None):
  148. """Snooze a reminder to be re-triggered after a period of time."""
  149. verb = "snoozed" if reminder.expired else "adjusted"
  150. try:
  151. duration = self._parse_time(arg) if arg else None
  152. except ValueError:
  153. duration = None
  154. reminder.reset(duration)
  155. end = _format_time(reminder.end)
  156. msg = "Reminder \x0303{0}\x0F {1} until {2}."
  157. self.reply(data, msg.format(reminder.id, verb, end))
  158. def _load_reminders(self):
  159. """Load previously made reminders from the database."""
  160. permdb = self.config.irc["permissions"]
  161. try:
  162. database = permdb.get_attr("command:remind", "data")
  163. except KeyError:
  164. return
  165. permdb.set_attr("command:remind", "data", "[]")
  166. connect_wait = 30
  167. for item in ast.literal_eval(database):
  168. rid, user, wait, end, message, data = item
  169. if end < time.time() + connect_wait:
  170. # Make reminders that have expired while the bot was offline
  171. # trigger shortly after startup
  172. end = time.time() + connect_wait
  173. data = Data.unserialize(data)
  174. reminder = _Reminder(rid, user, wait, message, data, self, end)
  175. self._start_reminder(reminder, user)
  176. def _show_reminders(self, data):
  177. """Show all of a user's current reminders."""
  178. if data.host not in self.reminders:
  179. self.reply(data, "You have no reminders. Set one with "
  180. "\x0306!remind [time] [message]\x0F. See also: "
  181. "\x0306!remind help\x0F.")
  182. return
  183. shorten = lambda s: (s[:37] + "..." if len(s) > 40 else s)
  184. dest = lambda data: (
  185. "privately" if data.is_private else "in {0}".format(data.chan))
  186. fmt = lambda robj: '\x0303{0}\x0F ("{1}" {2}, {3})'.format(
  187. robj.id, shorten(robj.message), dest(robj.data), robj.end_time)
  188. rlist = ", ".join(fmt(robj) for robj in self.reminders[data.host])
  189. self.reply(data, "Your reminders: {0}.".format(rlist))
  190. def _show_all_reminders(self, data):
  191. """Show all reminders to bot admins."""
  192. if not self.config.irc["permissions"].is_admin(data):
  193. self.reply(data, "You must be a bot admin to view other users' "
  194. "reminders. View your own with "
  195. "\x0306!reminders\x0F.")
  196. return
  197. if not self.reminders:
  198. self.reply(data, "There are no active reminders.")
  199. return
  200. dest = lambda data: (
  201. "privately" if data.is_private else "in {0}".format(data.chan))
  202. fmt = lambda robj, user: '\x0303{0}\x0F (for {1} {2}, {3})'.format(
  203. robj.id, user, dest(robj.data), robj.end_time)
  204. rlist = (fmt(rem, user) for user, rems in self.reminders.items()
  205. for rem in rems)
  206. self.reply(data, "All reminders: {0}.".format(", ".join(rlist)))
  207. def _show_help(self, data):
  208. """Reply to the user with help for all major subcommands."""
  209. parts = [
  210. ("Add new", "!remind [time] [message]"),
  211. ("List all", "!reminders"),
  212. ("Get info", "!remind [id]"),
  213. ("Cancel", "!remind cancel [id]"),
  214. ("Adjust", "!remind adjust [id] [time]"),
  215. ("Restart", "!snooze [id] [time]"),
  216. ("Admin", "!remind all")
  217. ]
  218. extra = "The \x0306[id]\x0F can be omitted if you have only one reminder."
  219. joined = " ".join("{0}: \x0306{1}\x0F.".format(k, v) for k, v in parts)
  220. self.reply(data, joined + " " + extra)
  221. def _dispatch_command(self, data, command, args):
  222. """Handle a reminder-processing subcommand."""
  223. user = data.host
  224. reminder = None
  225. if args and args[0].upper().startswith("R"):
  226. try:
  227. reminder = self._get_reminder_by_id(user, args[0])
  228. except IndexError:
  229. msg = "Couldn't find a reminder for \x0302{0}\x0F with ID \x0303{1}\x0F."
  230. self.reply(data, msg.format(user, args[0]))
  231. return
  232. args.pop(0)
  233. elif user not in self.reminders:
  234. msg = "You have no reminders to {0}."
  235. self.reply(data, msg.format(self._normalize(command)))
  236. return
  237. elif len(self.reminders[user]) == 1:
  238. reminder = self.reminders[user][0]
  239. elif command in SNOOZE_ONLY: # Select most recent expired reminder
  240. rmds = [rmd for rmd in self.reminders[user] if rmd.expired]
  241. rmds.sort(key=lambda rmd: rmd.end)
  242. if len(rmds) > 0:
  243. reminder = rmds[-1]
  244. elif command in SNOOZE or command in CANCEL: # Select only active one
  245. rmds = [rmd for rmd in self.reminders[user] if not rmd.expired]
  246. if len(rmds) == 1:
  247. reminder = rmds[0]
  248. if not reminder:
  249. msg = "You have {0} reminders. {1} which one?"
  250. num = len(self.reminders[user])
  251. command = self._normalize(command).capitalize()
  252. self.reply(data, msg.format(num, command))
  253. return
  254. if command in DISPLAY:
  255. self._display_reminder(data, reminder)
  256. elif command in CANCEL:
  257. self._cancel_reminder(data, reminder)
  258. elif command in SNOOZE:
  259. self._snooze_reminder(data, reminder, args[0] if args else None)
  260. else:
  261. msg = "Unknown action \x02{0}\x0F for reminder \x0303{1}\x0F."
  262. self.reply(data, msg.format(command, reminder.id))
  263. def _process(self, data):
  264. """Main entry point."""
  265. if data.command in SNOOZE + CANCEL:
  266. return self._dispatch_command(data, data.command, data.args)
  267. if not data.args:
  268. return self._show_reminders(data)
  269. if data.args[0] == "help":
  270. return self._show_help(data)
  271. if data.args[0] == "list":
  272. return self._show_reminders(data)
  273. if data.args[0] == "all":
  274. return self._show_all_reminders(data)
  275. if data.args[0] in DISPLAY + CANCEL + SNOOZE:
  276. return self._dispatch_command(data, data.args[0], data.args[1:])
  277. try:
  278. self._get_reminder_by_id(data.host, data.args[0])
  279. except IndexError:
  280. return self._create_reminder(data)
  281. if len(data.args) == 1:
  282. return self._dispatch_command(data, "display", data.args)
  283. self._dispatch_command(
  284. data, data.args[1], [data.args[0]] + data.args[2:])
  285. @property
  286. def lock(self):
  287. """Return the reminder modification/access lock."""
  288. return self._lock
  289. def setup(self):
  290. self.reminders = {}
  291. self._lock = RLock()
  292. self._thread = _ReminderThread(self._lock)
  293. self._load_reminders()
  294. def process(self, data):
  295. with self.lock:
  296. self._process(data)
  297. def unload(self):
  298. self._thread.stop()
  299. def store_reminder(self, reminder):
  300. """Store a serialized reminder into the database."""
  301. permdb = self.config.irc["permissions"]
  302. try:
  303. dump = permdb.get_attr("command:remind", "data")
  304. except KeyError:
  305. dump = "[]"
  306. database = ast.literal_eval(dump)
  307. database.append(reminder)
  308. permdb.set_attr("command:remind", "data", str(database))
  309. def unstore_reminder(self, rid):
  310. """Remove a reminder from the database by ID."""
  311. permdb = self.config.irc["permissions"]
  312. try:
  313. dump = permdb.get_attr("command:remind", "data")
  314. except KeyError:
  315. dump = "[]"
  316. database = ast.literal_eval(dump)
  317. database = [item for item in database if item[0] != rid]
  318. permdb.set_attr("command:remind", "data", str(database))
  319. class _ReminderThread:
  320. """A single thread that handles reminders."""
  321. def __init__(self, lock):
  322. self._thread = None
  323. self._abort = False
  324. self._active = {}
  325. self._lock = lock
  326. def _running(self):
  327. """Return if the thread should still be running."""
  328. return self._active and not self._abort
  329. def _get_soonest(self):
  330. """Get the soonest reminder to trigger."""
  331. return min(self._active.values(), key=lambda robj: robj.end)
  332. def _get_ready_reminder(self):
  333. """Block until a reminder is ready to be triggered."""
  334. while self._running():
  335. if self._get_soonest().end <= time.time():
  336. return self._get_soonest()
  337. self._lock.release()
  338. time.sleep(0.25)
  339. self._lock.acquire()
  340. def _callback(self):
  341. """Internal callback function to be executed by the reminder thread."""
  342. with self._lock:
  343. while True:
  344. reminder = self._get_ready_reminder()
  345. if not reminder:
  346. break
  347. if reminder.trigger():
  348. del self._active[reminder.id]
  349. self._thread = None
  350. def _start(self):
  351. """Start the thread."""
  352. self._thread = Thread(target=self._callback, name="reminder")
  353. self._thread.daemon = True
  354. self._thread.start()
  355. self._abort = False
  356. def add(self, reminder):
  357. """Add a reminder to the table of active reminders."""
  358. self._active[reminder.id] = reminder
  359. if not self._thread:
  360. self._start()
  361. def remove(self, reminder):
  362. """Remove a reminder from the table of active reminders."""
  363. if reminder.id in self._active:
  364. del self._active[reminder.id]
  365. if not self._active:
  366. self.stop()
  367. def stop(self):
  368. """Stop the thread."""
  369. if not self._thread:
  370. return
  371. self._abort = True
  372. self._thread = None
  373. class _Reminder:
  374. """Represents a single reminder."""
  375. def __init__(self, rid, user, wait, message, data, cmdobj, end=None):
  376. self.id = rid
  377. self.wait = wait
  378. self.end = time.time() + wait if end is None else end
  379. self.message = message
  380. self._user = user
  381. self._data = data
  382. self._cmdobj = cmdobj
  383. self._expired = False
  384. self._save()
  385. def _save(self):
  386. """Save this reminder to the database."""
  387. data = self._data.serialize()
  388. item = (self.id, self._user, self.wait, self.end, self.message, data)
  389. self._cmdobj.store_reminder(item)
  390. def _fire(self):
  391. """Activate the reminder for the user."""
  392. self._cmdobj.reply(self._data, self.message)
  393. self._cmdobj.unstore_reminder(self.id)
  394. self.end = time.time() + (60 * 60 * 24)
  395. self._expired = True
  396. def _finalize(self):
  397. """Clean up after a reminder has been expired for too long."""
  398. try:
  399. self._cmdobj.reminders[self._user].remove(self)
  400. if not self._cmdobj.reminders[self._user]:
  401. del self._cmdobj.reminders[self._user]
  402. except (KeyError, ValueError): # Already canceled by the user
  403. pass
  404. @property
  405. def data(self):
  406. """Return the IRC data object associated with this reminder."""
  407. return self._data
  408. @property
  409. def end_time(self):
  410. """Return a string representing the end time of a reminder."""
  411. if self._expired or self.end < time.time():
  412. return "expired"
  413. return "ends {0}".format(_format_time(self.end))
  414. @property
  415. def expired(self):
  416. """Return whether the reminder is expired."""
  417. return self._expired
  418. def reset(self, wait=None):
  419. """Reactivate a reminder."""
  420. if wait is not None:
  421. self.wait = wait
  422. self.end = self.wait + time.time()
  423. self._expired = False
  424. self._cmdobj.unstore_reminder(self.id)
  425. self._save()
  426. def trigger(self):
  427. """Hook run by the reminder thread."""
  428. if not self._expired:
  429. self._fire()
  430. return False
  431. else:
  432. self._finalize()
  433. return True