| @@ -20,43 +20,245 @@ | |||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
| # SOFTWARE. | |||
| from threading import Timer | |||
| from itertools import chain | |||
| import random | |||
| from threading import Thread | |||
| import time | |||
| from earwigbot.commands import Command | |||
| DISPLAY = ["display", "show", "list", "info"] | |||
| CANCEL = ["cancel", "stop", "delete", "del"] | |||
| SNOOZE = ["snooze", "delay", "reset", "adjust"] | |||
| class Remind(Command): | |||
| """Set a message to be repeated to you in a certain amount of time.""" | |||
| name = "remind" | |||
| commands = ["remind", "reminder"] | |||
| commands = ["remind", "reminder", "reminders", "snooze"] | |||
| def process(self, data): | |||
| if not data.args: | |||
| msg = "Please specify a time (in seconds) and a message in the following format: !remind <time> <msg>." | |||
| self.reply(data, msg) | |||
| return | |||
| @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: | |||
| return "snooze" | |||
| def _really_get_reminder_by_id(self, user, rid): | |||
| """Return the _Reminder object that corresponds to a particular ID. | |||
| Raises IndexError on failure. | |||
| """ | |||
| if user not in self.reminders: | |||
| raise IndexError(rid) | |||
| return [robj for robj in self.reminders[user] if robj.id == rid][0] | |||
| def _get_reminder_by_id(self, user, rid, data): | |||
| """Return the _Reminder object that corresponds to a particular ID. | |||
| Sends an error message to the user on failure. | |||
| """ | |||
| try: | |||
| return self._really_get_reminder_by_id(user, rid) | |||
| except IndexError: | |||
| msg = "Couldn't find a reminder for \x0302{0}\x0F with ID \x0303{1}\x0F." | |||
| self.reply(data, msg.format(user, rid)) | |||
| def _get_new_id(self): | |||
| """Get a free ID for a new reminder.""" | |||
| taken = set(robj.id for robj in chain(*self.reminders.values())) | |||
| num = random.choice(list(set(range(4096)) - taken)) | |||
| return "R-{0:03X}".format(num) | |||
| def _create_reminder(self, data, user): | |||
| """Create a new reminder for the given user.""" | |||
| try: | |||
| wait = int(data.args[0]) | |||
| except ValueError: | |||
| msg = "The time must be given as an integer, in seconds." | |||
| self.reply(data, msg) | |||
| assert wait > 0 | |||
| except (ValueError, AssertionError): | |||
| msg = "Invalid time \x02{0}\x0F. Time must be a positive integer, in seconds." | |||
| 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, user, wait, message, data, self) | |||
| reminder.start() | |||
| if user in self.reminders: | |||
| self.reminders[user].append(reminder) | |||
| else: | |||
| self.reminders[user] = [reminder] | |||
| msg = "Set reminder \x0303{0}\x0F ({1})." | |||
| self.reply(data, msg.format(rid, wait, reminder.end_time)) | |||
| def _display_reminder(self, data, reminder): | |||
| """Display a particular reminder's information.""" | |||
| msg = 'Reminder \x0303{0}\x0F: {1} seconds ({2}): "{3}". !remind [cancel|snooze] {0}.' | |||
| msg = msg.format(reminder.id, reminder.wait, reminder.end_time, | |||
| reminder.message) | |||
| self.reply(data, msg) | |||
| def _cancel_reminder(self, data, user, reminder): | |||
| """Cancel a pending reminder.""" | |||
| reminder.stop() | |||
| self.reminders[user].remove(reminder) | |||
| if not self.reminders[user]: | |||
| del self.reminders[user] | |||
| 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.""" | |||
| if arg: | |||
| try: | |||
| duration = int(data.args[arg]) | |||
| assert duration > 0 | |||
| reminder.wait = duration | |||
| except (IndexError, ValueError, AssertionError): | |||
| pass | |||
| reminder.start() | |||
| end_time = time.strftime("%b %d %H:%M:%S %Z", reminder.end) | |||
| msg = "Reminder \x0303{0}\x0F snoozed until {1}." | |||
| self.reply(data, msg.format(reminder.id, end_time)) | |||
| def _handle_command(self, command, data, user, reminder, arg=None): | |||
| """Handle a reminder-processing subcommand.""" | |||
| if command in DISPLAY: | |||
| self._display_reminder(data, reminder) | |||
| elif command in CANCEL: | |||
| self._cancel_reminder(data, user, reminder) | |||
| elif command in SNOOZE: | |||
| self._snooze_reminder(data, reminder, arg) | |||
| else: | |||
| msg = "Unknown action \x02{0}\x0F for reminder \x0303{1}\x0F." | |||
| self.reply(data, msg.format(command, reminder.id)) | |||
| def _show_reminders(self, data, user): | |||
| """Show all of a user's current reminders.""" | |||
| shorten = lambda s: (s[:37] + "..." if len(s) > 40 else s) | |||
| tmpl = '\x0303{0}\x0F ("{1}", {2})' | |||
| fmt = lambda robj: tmpl.format(robj.id, shorten(robj.message), | |||
| robj.end_time) | |||
| if user in self.reminders: | |||
| rlist = ", ".join(fmt(robj) for robj in self.reminders[user]) | |||
| msg = "Your reminders: {1}.".format(rlist) | |||
| else: | |||
| msg = "You have no reminders. Set one with !remind <time> <msg>." | |||
| self.reply(data, msg) | |||
| def _process_snooze_command(self, data, user): | |||
| """Process the !snooze command.""" | |||
| if not data.args: | |||
| if user not in self.reminders: | |||
| self.reply(data, "You have no reminders to snooze.") | |||
| elif len(self.reminders[user]) == 1: | |||
| self._snooze_reminder(data, self.reminders[user][0]) | |||
| else: | |||
| msg = "You have {0} reminders. Snooze which one?" | |||
| self.reply(data, msg.format(len(self.reminders[user]))) | |||
| return | |||
| reminder = self._get_reminder_by_id(user, data.args[0], data) | |||
| if reminder: | |||
| self._snooze_reminder(data, reminder, 1) | |||
| def setup(self): | |||
| self.reminders = {} | |||
| def process(self, data): | |||
| if data.command == "snooze": | |||
| return self._process_snooze_command(data, data.host) | |||
| if not data.args: | |||
| return self._show_reminders(data, data.host) | |||
| user = data.host | |||
| if len(data.args) == 1: | |||
| command = data.args[0] | |||
| if command in DISPLAY + CANCEL + SNOOZE: | |||
| if user not in self.reminders: | |||
| msg = "You have no reminders to {0}." | |||
| self.reply(data, msg.format(self._normalize(command))) | |||
| elif len(self.reminders[user]) == 1: | |||
| reminder = self.reminders[user][0] | |||
| self._handle_command(command, data, user, reminder) | |||
| else: | |||
| 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 | |||
| reminder = self._get_reminder_by_id(user, data.args[0], data) | |||
| if reminder: | |||
| self._display_reminder(data, reminder) | |||
| return | |||
| message = ' '.join(data.args[1:]) | |||
| if not message: | |||
| msg = "What message do you want me to give you when time is up?" | |||
| self.reply(data, msg) | |||
| if data.args[0] in DISPLAY + CANCEL + SNOOZE: | |||
| reminder = self._get_reminder_by_id(user, data.args[1], data) | |||
| if reminder: | |||
| self._handle_command(data.args[0], data, user, reminder, 2) | |||
| return | |||
| end = time.localtime(time.time() + wait) | |||
| end_time = time.strftime("%b %d %H:%M:%S", end) | |||
| end_time_with_timezone = time.strftime("%b %d %H:%M:%S %Z", end) | |||
| try: | |||
| reminder = self._really_get_reminder_by_id(user, data.args[0]) | |||
| except IndexError: | |||
| return self._create_reminder(data, user) | |||
| msg = 'Set reminder for "{0}" in {1} seconds (ends {2}).' | |||
| msg = msg.format(message, wait, end_time_with_timezone) | |||
| self.reply(data, msg) | |||
| self._handle_command(data.args[1], data, user, reminder, 2) | |||
| t_reminder = Timer(wait, self.reply, args=(data, message)) | |||
| t_reminder.name = "reminder " + end_time | |||
| t_reminder.daemon = True | |||
| t_reminder.start() | |||
| class _Reminder(object): | |||
| """Represents a single reminder.""" | |||
| def __init__(self, rid, user, wait, message, data, cmdobj): | |||
| self.id = rid | |||
| self.wait = wait | |||
| self.message = message | |||
| self.end = None | |||
| self._user = user | |||
| self._data = data | |||
| self._cmdobj = cmdobj | |||
| self._thread = None | |||
| def _callback(self): | |||
| """Internal callback function to be executed by the reminder thread.""" | |||
| thread = self._thread | |||
| while time.time() < thread.end: | |||
| if thread.abort: | |||
| return | |||
| time.sleep(1) | |||
| time.sleep(60) | |||
| 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 end_time(self): | |||
| """Return a string representing the end time of a reminder.""" | |||
| if self.end >= time.time(): | |||
| end_time = time.strftime("%b %d %H:%M:%S %Z", self.end) | |||
| return "ends {0}".format(end_time) | |||
| return "expired" | |||
| def start(self): | |||
| """Start the reminder timer thread. Stops it if already running.""" | |||
| self.stop() | |||
| self._thread = Thread(target=self._callback, name="remind-" + self.id) | |||
| self._thread.end = self.end = time.time() + self.wait | |||
| self._thread.daemon = True | |||
| self._thread.abort = False | |||
| self._thread.start() | |||
| def stop(self): | |||
| """Stop a currently running reminder.""" | |||
| if not self._thread: | |||
| return | |||
| self._thread.abort = True | |||
| self._thread = None | |||