# -*- coding: utf-8 -*- # # Copyright (C) 2009-2016 Ben Kurtovic # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import ast from itertools import chain import operator import random from threading import RLock, Thread import time from earwigbot.commands import Command from earwigbot.irc import Data DISPLAY = ["display", "show", "info", "details"] CANCEL = ["cancel", "stop", "delete", "del", "stop", "unremind", "forget", "disregard"] SNOOZE = ["snooze", "delay", "reset", "adjust", "modify", "change"] SNOOZE_ONLY = ["snooze", "delay", "reset"] def _format_time(epoch): """Format a UNIX timestamp nicely.""" lctime = time.localtime(epoch) if lctime.tm_year == time.localtime().tm_year: return time.strftime("%b %d %H:%M:%S %Z", lctime) else: return time.strftime("%b %d, %Y %H:%M:%S %Z", lctime) class Remind(Command): """Set a message to be repeated to you in a certain amount of time. See usage with !remind help.""" name = "remind" commands = ["remind", "reminder", "reminders", "snooze", "cancel", "unremind", "forget"] @staticmethod def _normalize(command): """Convert a command name into its canonical form.""" if command in DISPLAY: return "display" if command in CANCEL: return "cancel" if command in SNOOZE_ONLY: return "snooze" if command in SNOOZE: # "adjust" == snoozing active reminders return "adjust" @staticmethod def _parse_time(arg): """Parse the wait time for a reminder.""" ast_to_op = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod, ast.Pow: operator.pow } time_units = { "s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800, "y": 31536000 } def _evaluate(node): """Convert an AST node into a real number or raise an exception.""" if isinstance(node, ast.Num): if not isinstance(node.n, (int, float)): raise ValueError(node.n) return node.n elif isinstance(node, ast.BinOp): left, right = _evaluate(node.left), _evaluate(node.right) return ast_to_op[type(node.op)](left, right) else: raise ValueError(node) for unit, factor in time_units.items(): arg = arg.replace(unit, "*" + str(factor)) try: parsed = int(_evaluate(ast.parse(arg, mode="eval").body)) except (SyntaxError, KeyError): raise ValueError(arg) if parsed <= 0: raise ValueError(parsed) return parsed def _get_reminder_by_id(self, user, rid): """Return the _Reminder object that corresponds to a particular ID. Raises IndexError on failure. """ rid = rid.upper() if user not in self.reminders: raise IndexError(rid) return [robj for robj in self.reminders[user] if robj.id == rid][0] def _get_new_id(self): """Get a free ID for a new reminder.""" taken = set(robj.id for robj in chain(*list(self.reminders.values()))) num = random.choice(list(set(range(4096)) - taken)) return "R{0:03X}".format(num) def _start_reminder(self, reminder, user): """Start the given reminder object for the given user.""" if user in self.reminders: self.reminders[user].append(reminder) else: self.reminders[user] = [reminder] self._thread.add(reminder) def _create_reminder(self, data): """Create a new reminder for the given user.""" try: wait = self._parse_time(data.args[0]) except ValueError: msg = "Invalid time \x02{0}\x0F. Time must be a positive integer, in seconds." return self.reply(data, msg.format(data.args[0])) if wait > 1000 * 365 * 24 * 60 * 60: # Hard to think of a good upper limit, but 1000 years works. msg = "Given time \x02{0}\x0F is too large. Keep it reasonable." return self.reply(data, msg.format(data.args[0])) message = " ".join(data.args[1:]) try: rid = self._get_new_id() except IndexError: msg = "Couldn't set a new reminder: no free IDs available." return self.reply(data, msg) reminder = _Reminder(rid, data.host, wait, message, data, self) self._start_reminder(reminder, data.host) msg = "Set reminder \x0303{0}\x0F ({1})." self.reply(data, msg.format(rid, reminder.end_time)) def _display_reminder(self, data, reminder): """Display a particular reminder's information.""" msg = 'Reminder \x0303{0}\x0F: {1} seconds ({2}): "{3}".' msg = msg.format(reminder.id, reminder.wait, reminder.end_time, reminder.message) self.reply(data, msg) def _cancel_reminder(self, data, reminder): """Cancel a pending reminder.""" self._thread.remove(reminder) self.unstore_reminder(reminder.id) self.reminders[data.host].remove(reminder) if not self.reminders[data.host]: del self.reminders[data.host] msg = "Reminder \x0303{0}\x0F canceled." self.reply(data, msg.format(reminder.id)) def _snooze_reminder(self, data, reminder, arg=None): """Snooze a reminder to be re-triggered after a period of time.""" verb = "snoozed" if reminder.expired else "adjusted" try: duration = self._parse_time(arg) if arg else None except ValueError: duration = None reminder.reset(duration) end = _format_time(reminder.end) msg = "Reminder \x0303{0}\x0F {1} until {2}." self.reply(data, msg.format(reminder.id, verb, end)) def _load_reminders(self): """Load previously made reminders from the database.""" permdb = self.config.irc["permissions"] try: database = permdb.get_attr("command:remind", "data") except KeyError: return permdb.set_attr("command:remind", "data", "[]") connect_wait = 30 for item in ast.literal_eval(database): rid, user, wait, end, message, data = item if end < time.time() + connect_wait: # Make reminders that have expired while the bot was offline # trigger shortly after startup end = time.time() + connect_wait data = Data.unserialize(data) reminder = _Reminder(rid, user, wait, message, data, self, end) self._start_reminder(reminder, user) def _show_reminders(self, data): """Show all of a user's current reminders.""" if data.host not in self.reminders: self.reply(data, "You have no reminders. Set one with " "\x0306!remind [time] [message]\x0F. See also: " "\x0306!remind help\x0F.") return shorten = lambda s: (s[:37] + "..." if len(s) > 40 else s) dest = lambda data: ( "privately" if data.is_private else "in {0}".format(data.chan)) fmt = lambda robj: '\x0303{0}\x0F ("{1}" {2}, {3})'.format( robj.id, shorten(robj.message), dest(robj.data), robj.end_time) rlist = ", ".join(fmt(robj) for robj in self.reminders[data.host]) self.reply(data, "Your reminders: {0}.".format(rlist)) def _show_all_reminders(self, data): """Show all reminders to bot admins.""" if not self.config.irc["permissions"].is_admin(data): self.reply(data, "You must be a bot admin to view other users' " "reminders. View your own with " "\x0306!reminders\x0F.") return if not self.reminders: self.reply(data, "There are no active reminders.") return dest = lambda data: ( "privately" if data.is_private else "in {0}".format(data.chan)) fmt = lambda robj, user: '\x0303{0}\x0F (for {1} {2}, {3})'.format( robj.id, user, dest(robj.data), robj.end_time) rlist = (fmt(rem, user) for user, rems in self.reminders.items() for rem in rems) self.reply(data, "All reminders: {0}.".format(", ".join(rlist))) def _show_help(self, data): """Reply to the user with help for all major subcommands.""" parts = [ ("Add new", "!remind [time] [message]"), ("List all", "!reminders"), ("Get info", "!remind [id]"), ("Cancel", "!remind cancel [id]"), ("Adjust", "!remind adjust [id] [time]"), ("Restart", "!snooze [id] [time]"), ("Admin", "!remind all") ] extra = "The \x0306[id]\x0F can be omitted if you have only one reminder." joined = " ".join("{0}: \x0306{1}\x0F.".format(k, v) for k, v in parts) self.reply(data, joined + " " + extra) def _dispatch_command(self, data, command, args): """Handle a reminder-processing subcommand.""" user = data.host reminder = None if args and args[0].upper().startswith("R"): try: reminder = self._get_reminder_by_id(user, args[0]) except IndexError: msg = "Couldn't find a reminder for \x0302{0}\x0F with ID \x0303{1}\x0F." self.reply(data, msg.format(user, args[0])) return args.pop(0) elif user not in self.reminders: msg = "You have no reminders to {0}." self.reply(data, msg.format(self._normalize(command))) return elif len(self.reminders[user]) == 1: reminder = self.reminders[user][0] elif command in SNOOZE_ONLY: # Select most recent expired reminder rmds = [rmd for rmd in self.reminders[user] if rmd.expired] rmds.sort(key=lambda rmd: rmd.end) if len(rmds) > 0: reminder = rmds[-1] elif command in SNOOZE or command in CANCEL: # Select only active one rmds = [rmd for rmd in self.reminders[user] if not rmd.expired] if len(rmds) == 1: reminder = rmds[0] if not reminder: msg = "You have {0} reminders. {1} which one?" num = len(self.reminders[user]) command = self._normalize(command).capitalize() self.reply(data, msg.format(num, command)) return if command in DISPLAY: self._display_reminder(data, reminder) elif command in CANCEL: self._cancel_reminder(data, reminder) elif command in SNOOZE: self._snooze_reminder(data, reminder, args[0] if args else None) else: msg = "Unknown action \x02{0}\x0F for reminder \x0303{1}\x0F." self.reply(data, msg.format(command, reminder.id)) def _process(self, data): """Main entry point.""" if data.command in SNOOZE + CANCEL: return self._dispatch_command(data, data.command, data.args) if not data.args: return self._show_reminders(data) if data.args[0] == "help": return self._show_help(data) if data.args[0] == "list": return self._show_reminders(data) if data.args[0] == "all": return self._show_all_reminders(data) if data.args[0] in DISPLAY + CANCEL + SNOOZE: return self._dispatch_command(data, data.args[0], data.args[1:]) try: self._get_reminder_by_id(data.host, data.args[0]) except IndexError: return self._create_reminder(data) if len(data.args) == 1: return self._dispatch_command(data, "display", data.args) self._dispatch_command( data, data.args[1], [data.args[0]] + data.args[2:]) @property def lock(self): """Return the reminder modification/access lock.""" return self._lock def setup(self): self.reminders = {} self._lock = RLock() self._thread = _ReminderThread(self._lock) self._load_reminders() def process(self, data): with self.lock: self._process(data) def unload(self): self._thread.stop() def store_reminder(self, reminder): """Store a serialized reminder into the database.""" permdb = self.config.irc["permissions"] try: dump = permdb.get_attr("command:remind", "data") except KeyError: dump = "[]" database = ast.literal_eval(dump) database.append(reminder) permdb.set_attr("command:remind", "data", str(database)) def unstore_reminder(self, rid): """Remove a reminder from the database by ID.""" permdb = self.config.irc["permissions"] try: dump = permdb.get_attr("command:remind", "data") except KeyError: dump = "[]" database = ast.literal_eval(dump) database = [item for item in database if item[0] != rid] permdb.set_attr("command:remind", "data", str(database)) class _ReminderThread: """A single thread that handles reminders.""" def __init__(self, lock): self._thread = None self._abort = False self._active = {} self._lock = lock def _running(self): """Return if the thread should still be running.""" return self._active and not self._abort def _get_soonest(self): """Get the soonest reminder to trigger.""" return min(self._active.values(), key=lambda robj: robj.end) def _get_ready_reminder(self): """Block until a reminder is ready to be triggered.""" while self._running(): if self._get_soonest().end <= time.time(): return self._get_soonest() self._lock.release() time.sleep(0.25) self._lock.acquire() def _callback(self): """Internal callback function to be executed by the reminder thread.""" with self._lock: while True: reminder = self._get_ready_reminder() if not reminder: break if reminder.trigger(): del self._active[reminder.id] self._thread = None def _start(self): """Start the thread.""" self._thread = Thread(target=self._callback, name="reminder") self._thread.daemon = True self._thread.start() self._abort = False def add(self, reminder): """Add a reminder to the table of active reminders.""" self._active[reminder.id] = reminder if not self._thread: self._start() def remove(self, reminder): """Remove a reminder from the table of active reminders.""" if reminder.id in self._active: del self._active[reminder.id] if not self._active: self.stop() def stop(self): """Stop the thread.""" if not self._thread: return self._abort = True self._thread = None class _Reminder: """Represents a single reminder.""" def __init__(self, rid, user, wait, message, data, cmdobj, end=None): self.id = rid self.wait = wait self.end = time.time() + wait if end is None else end self.message = message self._user = user self._data = data self._cmdobj = cmdobj self._expired = False self._save() def _save(self): """Save this reminder to the database.""" data = self._data.serialize() item = (self.id, self._user, self.wait, self.end, self.message, data) self._cmdobj.store_reminder(item) def _fire(self): """Activate the reminder for the user.""" self._cmdobj.reply(self._data, self.message) self._cmdobj.unstore_reminder(self.id) self.end = time.time() + (60 * 60 * 24) self._expired = True def _finalize(self): """Clean up after a reminder has been expired for too long.""" try: self._cmdobj.reminders[self._user].remove(self) if not self._cmdobj.reminders[self._user]: del self._cmdobj.reminders[self._user] except (KeyError, ValueError): # Already canceled by the user pass @property def data(self): """Return the IRC data object associated with this reminder.""" return self._data @property def end_time(self): """Return a string representing the end time of a reminder.""" if self._expired or self.end < time.time(): return "expired" return "ends {0}".format(_format_time(self.end)) @property def expired(self): """Return whether the reminder is expired.""" return self._expired def reset(self, wait=None): """Reactivate a reminder.""" if wait is not None: self.wait = wait self.end = self.wait + time.time() self._expired = False self._cmdobj.unstore_reminder(self.id) self._save() def trigger(self): """Hook run by the reminder thread.""" if not self._expired: self._fire() return False else: self._finalize() return True