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.

536 line
18 KiB

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