| @@ -21,13 +21,15 @@ | |||||
| # SOFTWARE. | # SOFTWARE. | ||||
| import ast | import ast | ||||
| from contextlib import contextmanager | |||||
| from itertools import chain | from itertools import chain | ||||
| import operator | import operator | ||||
| import random | import random | ||||
| from threading import Thread | |||||
| from threading import RLock, Thread | |||||
| import time | import time | ||||
| from earwigbot.commands import Command | from earwigbot.commands import Command | ||||
| from earwigbot.irc import Data | |||||
| DISPLAY = ["display", "show", "list", "info", "details"] | DISPLAY = ["display", "show", "list", "info", "details"] | ||||
| CANCEL = ["cancel", "stop", "delete", "del", "stop", "unremind", "forget", | CANCEL = ["cancel", "stop", "delete", "del", "stop", "unremind", "forget", | ||||
| @@ -60,6 +62,7 @@ class Remind(Command): | |||||
| ast.Pow: operator.pow | ast.Pow: operator.pow | ||||
| } | } | ||||
| time_units = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800} | time_units = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800} | ||||
| def _evaluate(node): | def _evaluate(node): | ||||
| """Convert an AST node into a real number or raise an exception.""" | """Convert an AST node into a real number or raise an exception.""" | ||||
| if isinstance(node, ast.Num): | if isinstance(node, ast.Num): | ||||
| @@ -76,6 +79,7 @@ class Remind(Command): | |||||
| factor, arg = time_units[arg[-1]], arg[:-1] | factor, arg = time_units[arg[-1]], arg[:-1] | ||||
| else: | else: | ||||
| factor = 1 | factor = 1 | ||||
| try: | try: | ||||
| parsed = int(_evaluate(ast.parse(arg, mode="eval").body) * factor) | parsed = int(_evaluate(ast.parse(arg, mode="eval").body) * factor) | ||||
| except (SyntaxError, KeyError): | except (SyntaxError, KeyError): | ||||
| @@ -84,6 +88,12 @@ class Remind(Command): | |||||
| raise ValueError(parsed) | raise ValueError(parsed) | ||||
| return parsed | return parsed | ||||
| @contextmanager | |||||
| def _db(self): | |||||
| """Return a threadsafe context manager for the permissions database.""" | |||||
| with self._db_lock: | |||||
| yield self.config.irc["permissions"] | |||||
| def _really_get_reminder_by_id(self, user, rid): | def _really_get_reminder_by_id(self, user, rid): | ||||
| """Return the _Reminder object that corresponds to a particular ID. | """Return the _Reminder object that corresponds to a particular ID. | ||||
| @@ -111,6 +121,14 @@ class Remind(Command): | |||||
| num = random.choice(list(set(range(4096)) - taken)) | num = random.choice(list(set(range(4096)) - taken)) | ||||
| return "R{0:03X}".format(num) | return "R{0:03X}".format(num) | ||||
| def _start_reminder(self, reminder, user): | |||||
| """Start the given reminder object for the given user.""" | |||||
| reminder.start() | |||||
| if user in self.reminders: | |||||
| self.reminders[user].append(reminder) | |||||
| else: | |||||
| self.reminders[user] = [reminder] | |||||
| def _create_reminder(self, data, user): | def _create_reminder(self, data, user): | ||||
| """Create a new reminder for the given user.""" | """Create a new reminder for the given user.""" | ||||
| try: | try: | ||||
| @@ -118,22 +136,22 @@ class Remind(Command): | |||||
| except ValueError: | except ValueError: | ||||
| msg = "Invalid time \x02{0}\x0F. Time must be a positive integer, in seconds." | msg = "Invalid time \x02{0}\x0F. Time must be a positive integer, in seconds." | ||||
| return self.reply(data, msg.format(data.args[0])) | return self.reply(data, msg.format(data.args[0])) | ||||
| if wait > 1000 * 365 * 24 * 60 * 60: | if wait > 1000 * 365 * 24 * 60 * 60: | ||||
| # Hard to think of a good upper limit, but 1000 years works. | # Hard to think of a good upper limit, but 1000 years works. | ||||
| msg = "Given time \x02{0}\x0F is too large. Keep it reasonable." | msg = "Given time \x02{0}\x0F is too large. Keep it reasonable." | ||||
| return self.reply(data, msg.format(data.args[0])) | return self.reply(data, msg.format(data.args[0])) | ||||
| end = time.time() + wait | |||||
| message = " ".join(data.args[1:]) | message = " ".join(data.args[1:]) | ||||
| try: | try: | ||||
| rid = self._get_new_id() | rid = self._get_new_id() | ||||
| except IndexError: | except IndexError: | ||||
| msg = "Couldn't set a new reminder: no free IDs available." | msg = "Couldn't set a new reminder: no free IDs available." | ||||
| return self.reply(data, msg) | 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] | |||||
| reminder = _Reminder(rid, user, wait, end, message, data, self) | |||||
| self._start_reminder(reminder, user) | |||||
| msg = "Set reminder \x0303{0}\x0F ({1})." | msg = "Set reminder \x0303{0}\x0F ({1})." | ||||
| self.reply(data, msg.format(rid, reminder.end_time)) | self.reply(data, msg.format(rid, reminder.end_time)) | ||||
| @@ -162,11 +180,30 @@ class Remind(Command): | |||||
| reminder.wait = duration | reminder.wait = duration | ||||
| except (IndexError, ValueError): | except (IndexError, ValueError): | ||||
| pass | pass | ||||
| reminder.end = time.time() + reminder.wait | |||||
| reminder.start() | reminder.start() | ||||
| end = time.strftime("%b %d %H:%M:%S %Z", time.localtime(reminder.end)) | end = time.strftime("%b %d %H:%M:%S %Z", time.localtime(reminder.end)) | ||||
| msg = "Reminder \x0303{0}\x0F {1} until {2}." | msg = "Reminder \x0303{0}\x0F {1} until {2}." | ||||
| self.reply(data, msg.format(reminder.id, verb, end)) | self.reply(data, msg.format(reminder.id, verb, end)) | ||||
| def _load_reminders(self): | |||||
| """Load previously made reminders from the database.""" | |||||
| with self._db() as permdb: | |||||
| try: | |||||
| database = permdb.get_attr("command:remind", "data") | |||||
| except KeyError: | |||||
| return | |||||
| permdb.set_attr("command:remind", "data", "[]") | |||||
| for item in ast.literal_eval(database): | |||||
| rid, user, wait, end, message, data = item | |||||
| if end < time.time(): | |||||
| continue | |||||
| data = Data.unserialize(data) | |||||
| reminder = _Reminder(rid, user, wait, end, message, data, self) | |||||
| self._start_reminder(reminder, user) | |||||
| def _handle_command(self, command, data, user, reminder, arg=None): | def _handle_command(self, command, data, user, reminder, arg=None): | ||||
| """Handle a reminder-processing subcommand.""" | """Handle a reminder-processing subcommand.""" | ||||
| if command in DISPLAY: | if command in DISPLAY: | ||||
| @@ -190,7 +227,8 @@ class Remind(Command): | |||||
| rlist = ", ".join(fmt(robj) for robj in self.reminders[user]) | rlist = ", ".join(fmt(robj) for robj in self.reminders[user]) | ||||
| msg = "Your reminders: {0}.".format(rlist) | msg = "Your reminders: {0}.".format(rlist) | ||||
| else: | else: | ||||
| msg = "You have no reminders. Set one with \x0306!remind [time] [message]\x0F. See also: \x0306!remind help\x0F." | |||||
| msg = ("You have no reminders. Set one with \x0306!remind [time] " | |||||
| "[message]\x0F. See also: \x0306!remind help\x0F.") | |||||
| self.reply(data, msg) | self.reply(data, msg) | ||||
| def _process_snooze_command(self, data, user): | def _process_snooze_command(self, data, user): | ||||
| @@ -239,6 +277,8 @@ class Remind(Command): | |||||
| def setup(self): | def setup(self): | ||||
| self.reminders = {} | self.reminders = {} | ||||
| self._db_lock = RLock() | |||||
| self._load_reminders() | |||||
| def process(self, data): | def process(self, data): | ||||
| if data.command == "snooze": | if data.command == "snooze": | ||||
| @@ -284,15 +324,42 @@ class Remind(Command): | |||||
| self._handle_command(data.args[1], data, user, reminder, 2) | self._handle_command(data.args[1], data, user, reminder, 2) | ||||
| def unload(self): | |||||
| for reminder in chain(*self.reminders.values()): | |||||
| reminder.stop(delete=False) | |||||
| def store_reminder(self, reminder): | |||||
| """Store a serialized reminder into the database.""" | |||||
| with self._db() as permdb: | |||||
| 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.""" | |||||
| with self._db() as permdb: | |||||
| 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 _Reminder(object): | class _Reminder(object): | ||||
| """Represents a single reminder.""" | """Represents a single reminder.""" | ||||
| def __init__(self, rid, user, wait, message, data, cmdobj): | |||||
| def __init__(self, rid, user, wait, end, message, data, cmdobj): | |||||
| self.id = rid | self.id = rid | ||||
| self.wait = wait | self.wait = wait | ||||
| self.end = end | |||||
| self.message = message | self.message = message | ||||
| self.end = None | |||||
| self._user = user | self._user = user | ||||
| self._data = data | self._data = data | ||||
| @@ -307,6 +374,7 @@ class _Reminder(object): | |||||
| if thread.abort: | if thread.abort: | ||||
| return | return | ||||
| self._cmdobj.reply(self._data, self.message) | self._cmdobj.reply(self._data, self.message) | ||||
| self._delete() | |||||
| for i in xrange(60): | for i in xrange(60): | ||||
| time.sleep(1) | time.sleep(1) | ||||
| if thread.abort: | if thread.abort: | ||||
| @@ -318,6 +386,16 @@ class _Reminder(object): | |||||
| except (KeyError, ValueError): # Already canceled by the user | except (KeyError, ValueError): # Already canceled by the user | ||||
| pass | pass | ||||
| 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 _delete(self): | |||||
| """Remove this reminder from the database.""" | |||||
| self._cmdobj.unstore_reminder(self.id) | |||||
| @property | @property | ||||
| def end_time(self): | def end_time(self): | ||||
| """Return a string representing the end time of a reminder.""" | """Return a string representing the end time of a reminder.""" | ||||
| @@ -330,14 +408,17 @@ class _Reminder(object): | |||||
| """Start the reminder timer thread. Stops it if already running.""" | """Start the reminder timer thread. Stops it if already running.""" | ||||
| self.stop() | self.stop() | ||||
| self._thread = Thread(target=self._callback, name="remind-" + self.id) | self._thread = Thread(target=self._callback, name="remind-" + self.id) | ||||
| self._thread.end = self.end = time.time() + self.wait | |||||
| self._thread.end = self.end | |||||
| self._thread.daemon = True | self._thread.daemon = True | ||||
| self._thread.abort = False | self._thread.abort = False | ||||
| self._thread.start() | self._thread.start() | ||||
| self._save() | |||||
| def stop(self): | |||||
| def stop(self, delete=True): | |||||
| """Stop a currently running reminder.""" | """Stop a currently running reminder.""" | ||||
| if not self._thread: | if not self._thread: | ||||
| return | return | ||||
| if delete: | |||||
| self._delete() | |||||
| self._thread.abort = True | self._thread.abort = True | ||||
| self._thread = None | self._thread = None | ||||