diff --git a/.gitignore b/.gitignore index a60ef1080bf2d7b04a20f45863f53cc8136c6be7..0e6a272859e872a4d5f17194df076399e6a8b0ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ *.mbp +*.pyc +/.idea +config.yaml +bot.db +bot.db-shm +bot.db-wal +config.yaml diff --git a/README.md b/README.md index 62eda7a9a8f9a34258bdf9ddf7e946d6f1ee7236..a77bb89c859f9517b40e5ddd7f419af631bc0e2a 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,97 @@ -# reminder +# â° reminder-agenda bot A [maubot](https://github.com/maubot/maubot) to remind you about things. -## Hosted instances -* maunium.net: [@reminder:maunium.net](https://matrix.to/#/@reminder:maunium.net). -* t2bot.io: https://t2bot.io/reminderbot/ +Basically [matrix-reminder-bot](https://github.com/anoadragon453/matrix-reminder-bot/tree/master) and [maubot/reminder](https://github.com/maubot/reminder) smushed together. This project includes code taken from both repositories, credit goes to them! + + + +## Features + +* Set once-off reminders and recurring reminders +* To-do lists +* Parse natural language (e.g. `every friday at 2pm`) and crontab syntax +* Remind just yourself or the whole room +* Subscribe to other people's reminders +* Lots of [locales](https://dateparser.readthedocs.io/en/latest/supported_locales.html) +* Per-user rate limits +* Maubot! + +## Setup +This bot requires python libraries that aren't included in the official maubot docker image. + +* [apscheduler](https://github.com/agronholm/apscheduler) +* [dateparser](https://github.com/scrapinghub/dateparser) +* [cron_descriptor](https://github.com/Salamek/cron-descriptor) (optional, shows cron reminders with natural language) +* [arrow](https://github.com/arrow-py/arrow) (optional, allows more advanced displays of time ranges) + +Be sure to add them to the optional-requirements.txt file in [maubot](https://github.com/maubot/maubot) and build a new docker image with +`docker build --tag maubot-for-reminders . -f Dockerfile` + +This is pretty easy to use with the [ansible deployment](https://github.com/spantaleev/matrix-docker-ansible-deploy), just add this line to your vars.yml: `matrix_bot_maubot_docker_image: maubot-for-reminders` ## Usage -Use `!remind <date> <message>` to set a reminder. To subscribe to a reminder set by someone else, -upvote the message with a 👠reaction. To cancel a reminder, remove the message or reaction. -After the reminder fires, you can re-schedule it with `!reminder again <date>`. +### Creating optionally recurring reminders: +`!remind <message containing date>` Adds a reminder by extracting the date from the text +* `!remind abolish closed-access journals at 3pm tomorrow` +* `!remind 8 hours buy more pumpkins` +* `!remind 2023-11-30 15:00 befriend rats` + +`!remind <date>; <message>` Bypasses text parsing by explicitly specifying the date +* `!remind 2 days 4 hours; do something` + +`!remind [room] [every] ...` +* `[room]` pings the whole room +* `[every]` create recurring reminders `!remind every friday 3pm take out the trash` + +`!remind [room] <cron> <message>` Schedules a reminder using a crontab syntax +* `!remind cron 30 9 * * mon-fri do something` sets reminders for 9:30am, Monday through Friday. +* `!remind cron` lists more examples + +You can also reply to any message with `!remind ...` to get reminded about that message.\\ +To get pinged by someone else's reminder, react to their message with ðŸ‘. + +### Creating agenda items +`!agenda [room] <message>` creates an agenda item. Agenda items are like reminders but don't have a time, for things like to-do lists. + +### Listing active reminders +`!remind list [all] [my] [subscribed]` lists all reminders in a room +* `all` lists all reminders from every room +* `my` lists only reminders you created +* `subscribed` lists only reminders you are subscribed to + +### Deleting reminders +Cancel reminders by removing the message creating it, unsubscribe by removing your upvote.\\ +Cancel recurring reminders by replying to the ping with `!remind cancel|delete` +* `!remind cancel|delete <ID>` deletes a reminder matching the 4 letter ID shown by `list` +* `!remind cancel|delete <message>` deletes a reminder *beginning with* <message> + * e.g. `!remind delete buy more` would delete the reminder `buy more pumpkins` -`<date>` can be a time delta (e.g. `2 days 1.5 hours` or `friday at 15:00`) -or an absolute date (e.g. `2020-03-27 15:00`). +### Rescheduling +Reminders can be rescheduled after they have fired by replying with `!remind <new date>` -Note that subscribing to and cancelling reminders is only possible before the last minute. -Each minute reminders that are scheduled to go off during that minute are sent to the event loop, -at which point their target user list can no longer be updated. +### Settings +Dates are parsed using your [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zone) and [locale](https://dateparser.readthedocs.io/en/latest/supported_locales.html). +* `!remind tz|timezone [new-timezone]` view or set your timezone +* `!remind locale [new-locale]` view or set your locale -To set the timezone for date parsing and output for your messages, use `!remind tz <timezone>`. -It's recommended to use a [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones), -but anything supported by Pytz will work. +## Cron Syntax +``` +* any value +, value list separator +- range of values +/ step -Similarly, you can set the locale for date parsing with `!remind locale <list of locales>`. If you -provide multiple locales, each one will be tried for parsing your input until one matches. Unlike -the timezone, the locale only affects input, not output. You can view available locales using -`!remind locales`. You can also contribute new locales by making a pull request -(see [locales.py](reminder/locales.py), content warning: long regexes). +┌─────── minute (0 - 59) +│ ┌─────── hour (0 - 23) +│ │ ┌─────── day of the month (1 - 31) +│ │ │ ┌─────── month (1 - 12) +│ │ │ │ ┌─────── weekday (0 - 6) (Sunday to Saturday) +│ │ │ │ │ +* * * * * <message> +``` -To list your upcoming reminders, use `!remind list` +``` +30 9 * * * Every day at 9:30am +0/30 9-17 * * mon-fri Every 30 minutes from 9am to 5pm, Monday through Friday +0 14 1,16 * * 2:00pm on the 1st and 16th day of the month +0 0 1-7 * mon First Monday of the month at midnight \ No newline at end of file diff --git a/base-config.yaml b/base-config.yaml index ab9095faef3038c601d06f85352da56e938f3bc3..22a4eb698a1fbc18c534a9e91c5ba664b15ee4b7 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -1,9 +1,29 @@ # Default timezone for users who did not set one. -# This is parsed with pytz, so the usual format is Continent/City, e.g. Europe/Helsinki. -default_timezone: 'UTC' +# This is parsed with dateparser, so Continent/City, UTC offsets, and abbreviations should work. +# e.g. Europe/Helsinki, EET, +0300 +# See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones +default_timezone: America/Los_Angeles + +# Default locale/language compatible with dateparser +# See https://dateparser.readthedocs.io/en/latest/supported_locales.html +default_locale: en # Base command without the prefix (!). # If a list is provided, the first is the main name and the rest are aliases. base_command: - remind -- reminder +- remindme + +# Alias used to create an agenda items +agenda_command: +- agenda + + +# If verbose is true, display full confirmation messages. If false, confirm by reacting with :thumbs-up: +verbose: true + +# Rate limit for individual users, as the number of reminders allowed to fire in an interval +rate_limit: 10 +rate_limit_minutes: 60 + +admin_power_level: 50 diff --git a/maubot.yaml b/maubot.yaml index edde9b3f32c2d91c669ac05e045d599c2c461cba..b8a7b5fc6c5017ed1934840bafc49d61a89f6723 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,6 +1,6 @@ maubot: 0.4.1 -id: xyz.maubot.reminder -version: 0.2.2 +id: org.bytemarx.reminder +version: 0.1.0 license: AGPL-3.0-or-later modules: - reminder @@ -8,6 +8,12 @@ main_class: ReminderBot extra_files: - base-config.yaml dependencies: -- python-dateutil - pytz +- dateparser +- apscheduler +soft_dependencies: +- cron_descriptor +- arrow database: true +database_type: asyncpg + diff --git a/reminder/bot.py b/reminder/bot.py index b42224a5f7cb8542166904d6bdd8504df5a18334..dbd7872f2fd53cf381bee7b6acbfa8ff8d7d268d 100644 --- a/reminder/bot.py +++ b/reminder/bot.py @@ -1,4 +1,4 @@ -# reminder - A maubot plugin to remind you about things. +# reminder - A maubot plugin to create_reminder you about things. # Copyright (C) 2020 Tulir Asokan # # This program is free software: you can redistribute it and/or modify @@ -13,273 +13,412 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <https://www.gnu.org/licenses/>. -from typing import Type, Tuple, List +import re +from typing import Type, Tuple, List, Dict from datetime import datetime, timedelta -from html import escape -import asyncio - import pytz from mautrix.types import (EventType, RedactionEvent, StateEvent, Format, MessageType, - TextMessageEventContent, ReactionEvent, UserID) -from mautrix.util.config import BaseProxyConfig -from mautrix.util import background_task + TextMessageEventContent, ReactionEvent, UserID, EventID, RelationType) from maubot import Plugin, MessageEvent from maubot.handlers import command, event +from mautrix.util.async_db import UpgradeTable +from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper +from .migrations import upgrade_table from .db import ReminderDatabase -from .util import Config, ReminderInfo, DateArgument, parse_timezone, format_time -from .locales import locales +from .util import validate_locale, validate_timezone, CommandSyntaxError, parse_date, CommandSyntax +from .reminder import Reminder +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +# TODO: merge licenses +class Config(BaseProxyConfig): + def do_update(self, helper: ConfigUpdateHelper) -> None: + helper.copy("default_timezone") + helper.copy("default_locale") + helper.copy("base_command") + helper.copy("agenda_command") + helper.copy("rate_limit_minutes") + helper.copy("rate_limit") + helper.copy("verbose") + helper.copy("admin_power_level") class ReminderBot(Plugin): - db: ReminderDatabase - reminder_loop_task: asyncio.Future base_command: str base_aliases: Tuple[str, ...] + agenda_command: Tuple[str, ...] default_timezone: pytz.timezone + scheduler: AsyncIOScheduler + reminders: Dict[EventID, Reminder] + db: ReminderDatabase @classmethod def get_config_class(cls) -> Type[BaseProxyConfig]: return Config + @classmethod + def get_db_upgrade_table(cls) -> UpgradeTable: + return upgrade_table + + + async def start(self) -> None: - self.on_external_config_update() + self.scheduler = AsyncIOScheduler() + # self.scheduler.configure({"apscheduler.timezone": self.config["default_timezone"]}) + self.scheduler.start() self.db = ReminderDatabase(self.database) - self.reminder_loop_task = asyncio.create_task(self.reminder_loop()) + self.on_external_config_update() + # load all reminders + self.reminders = await self.db.load_all(self) def on_external_config_update(self) -> None: self.config.load_and_update() bc = self.config["base_command"] + ac = self.config["agenda_command"] self.base_command = bc[0] if isinstance(bc, list) else bc self.base_aliases = tuple(bc) if isinstance(bc, list) else (bc,) - raw_timezone = self.config["default_timezone"] - try: - self.default_timezone = pytz.timezone(raw_timezone) - except pytz.UnknownTimeZoneError: - self.log.warning(f"Unknown default timezone {raw_timezone}") - self.default_timezone = pytz.UTC + self.agenda_command = tuple(ac) if isinstance(ac, list) else (ac,) + + # If the locale or timezone is invalid, use default one + self.db.defaults.locale = self.config["default_locale"] + if not validate_locale(self.config["default_locale"]): + self.log.warning(f'unknown default locale: {self.config["default_locale"]}') + self.db.defaults.locale = "en" + self.db.defaults.timezone = self.config["default_timezone"] + if not validate_timezone(self.config["default_timezone"]): + self.log.warning(f'unknown default timezone: {self.config["default_timezone"]}') + self.db.defaults.timezone = "UTC" + async def stop(self) -> None: - self.reminder_loop_task.cancel() + self.scheduler.shutdown(wait=False) - async def reminder_loop(self) -> None: - try: - self.log.debug("Reminder loop started") - while True: - now = datetime.now(tz=pytz.UTC) - next_minute = (now + timedelta(minutes=1)).replace(second=0, microsecond=0) - await asyncio.sleep((next_minute - now).total_seconds()) - await self.schedule_nearby_reminders(next_minute) - except asyncio.CancelledError: - self.log.debug("Reminder loop stopped") - except Exception: - self.log.exception("Exception in reminder loop") - - async def schedule_nearby_reminders(self, now: datetime) -> None: - until = now + timedelta(minutes=1) - for reminder in self.db.all_in_range(now, until): - background_task.create(self.send_reminder(reminder)) - - async def send_reminder(self, reminder: ReminderInfo) -> None: - try: - await self._send_reminder(reminder) - except Exception: - self.log.exception("Failed to send reminder") - async def _send_reminder(self, reminder: ReminderInfo) -> None: - if len(reminder.users) == 0: - self.log.debug(f"Cancelling reminder {reminder}, no users left to remind") - return - wait = (reminder.date - datetime.now(tz=pytz.UTC)).total_seconds() - if wait > 0: - self.log.debug(f"Waiting {wait} seconds to send {reminder}") - await asyncio.sleep(wait) - else: - self.log.debug(f"Sending {reminder} immediately") - users = " ".join(reminder.users) - users_html = " ".join(f"<a href='https://matrix.to/#/{user_id}'>{user_id}</a>" - for user_id in reminder.users) - content = TextMessageEventContent( - msgtype=MessageType.TEXT, body=f"{users}: {reminder.message}", format=Format.HTML, - formatted_body=f"{users_html}: {escape(reminder.message)}") - content["xyz.maubot.reminder"] = { - "id": reminder.id, - "message": reminder.message, - "targets": list(reminder.users), - "reply_to": reminder.reply_to, - } - if reminder.reply_to: - content.set_reply(await self.client.get_event(reminder.room_id, reminder.reply_to)) - await self.client.send_message(reminder.room_id, content) @command.new(name=lambda self: self.base_command, - aliases=lambda self, alias: alias in self.base_aliases, + aliases=lambda self, alias: alias in self.base_aliases + self.agenda_command, help="Create a reminder", require_subcommand=False, arg_fallthrough=False) - @DateArgument("date", required=True) + @command.argument("room", matches="room", required=False) + @command.argument("every", matches="every", required=False) + @command.argument("start_time", matches="(.*?);", pass_raw=True, required=False) + @command.argument("cron", matches="cron ?(?:\s*\S*){0,5}", pass_raw=True, required=False) @command.argument("message", pass_raw=True, required=False) - async def remind(self, evt: MessageEvent, date: datetime, message: str) -> None: - date = date.replace(microsecond=0) - now = datetime.now(tz=pytz.UTC).replace(microsecond=0) - if date < now: - await evt.reply(f"Sorry, {date} is in the past and I don't have a time machine :(") - return - rem = ReminderInfo(date=date, room_id=evt.room_id, message=message, - reply_to=evt.content.get_reply_to(), users={evt.sender: evt.event_id}) - await self._remind(evt, rem, now) - - @remind.subcommand("reschedule", help="Reschedule a reminder you got", aliases=("again",)) - @DateArgument("date", required=True) - async def reschedule(self, evt: MessageEvent, date: datetime) -> None: + async def create_reminder(self, evt: MessageEvent, + room: str = None, + every: str = None, + start_time: Tuple[str] = None, + cron: Tuple[str] = None, + message: str = None, + again: bool = False) -> None: + """Create a reminder or an alarm with a given target + Args: + evt: + room: + cron: + every: + start_time: + message: + again: + """ + date_str = None reply_to_id = evt.content.get_reply_to() - if not reply_to_id: - await evt.reply("You must reply to a reminder event to reschedule it.") - return - date = date.replace(microsecond=0) - now = datetime.now(tz=pytz.UTC).replace(microsecond=0) - if date < now: - await evt.reply(f"Sorry, {date} is in the past and I don't have a time machine :(") - return - reply_to = await self.client.get_event(evt.room_id, reply_to_id) + user_info = await self.db.get_user_info(evt.sender) + # Determine is the agenda command was used instead of creating a subcommand so [room] can still be used + agenda = evt.content.body[1:].startswith(self.agenda_command) + if agenda: + # Use the date the message was created as the date for agenda items + start_time = datetime.now(tz=pytz.UTC) + + # If we are replying to a previous reminder, recreate the original reminder with a new time + if reply_to_id: + reply_to = await self.client.get_event(room_id=evt.room_id, event_id=reply_to_id) + if "org.bytemarx.reminder" in reply_to.content: + again = True + start_time = (message,) + message = reply_to.content["org.bytemarx.reminder"]["message"] + reply_to_id = reply_to.content["org.bytemarx.reminder"]["reply_to"] + event_id = reply_to.content["org.bytemarx.reminder"]["id"] + if event_id in self.reminders: + await self.reminders[event_id].cancel() + try: - reminder_info = reply_to.content["xyz.maubot.reminder"] - rem = ReminderInfo(date=date, room_id=evt.room_id, message=reminder_info["message"], - reply_to=reminder_info["reply_to"], users={evt.sender: evt.event_id}) - except KeyError: - await evt.reply("That doesn't look like a valid reminder event.") + if not cron and not agenda: + if start_time: + start_time, date_str = parse_date(start_time[0], user_info) + elif message.strip(): # extract the date from the message if not explicitly given + start_time, date_str = parse_date(message, user_info, search_text=True) + + # Check if "every" appears immediately before the date, if so, the reminder should be recurring. + # This makes "every" possible to use in a sentence instead of just with @command.argument("every") + if not every: + every = message.lower().find('every ' + date_str.lower()) >= 0 + + # Remove the date from the messages, converting "buy ice cream on monday" to "buy ice cream" + compiled = re.compile("(every )?" + re.escape(date_str), re.IGNORECASE) + message = compiled.sub("", message, 1).strip() + + else: # If no arguments are supplied, return the help message + await evt.reply(self._help_message()) + return + reminder = Reminder( + bot=self, + room_id=evt.room_id, + message=message, + event_id=evt.event_id, + reply_to=reply_to_id, + start_time=start_time, + cron_tab=cron, + recur_every=date_str if every else None, + is_agenda=agenda, + creator=evt.sender, + user_info=user_info, + ) + + except CommandSyntaxError as e: + await evt.reply(e.message) return - await self._remind(evt, rem, now, again=True) - async def _remind(self, evt: MessageEvent, rem: ReminderInfo, now: datetime, again: bool = False - ) -> None: - if rem.date == now: - await self.send_reminder(rem) + # Record the reminder and subscribe to it + await self.db.store_reminder(reminder) + + # If the command was called with a "room_command", make the reminder ping the room + user_id = UserID("@room") if room else evt.sender + await reminder.add_subscriber(subscribing_event=evt.event_id, user_id=user_id) + + # Send a message to the room confirming the creation of the reminder + await self.confirm_reminder(evt, reminder, again=again, agenda=agenda) + + self.reminders[reminder.event_id] = reminder + + + async def confirm_reminder(self, evt: MessageEvent, reminder: Reminder, again: bool = False, agenda: bool = False): + """Sends a message to the room confirming the reminder is set + If verbose is set in the config, print out the full message. If false, just react with 👠+ + Args: + evt: + reminder: The Reminder to confirm + again: Is this a reminder that was rescheduled? + agenda: Is this an agenda instead of a reminder? + """ + if self.config["verbose"]: + + action = "add this to the agenda for" if agenda else "remind" if reminder.message else "ping" + target = "the room" if "@room" in reminder.subscribed_users.values() else "you" + message = f"to {reminder.message}" if reminder.message else "" + + if reminder.reply_to: + evt_link = f"[message](https://matrix.to/#/{reminder.room_id}/{reminder.reply_to})" + message += f" (replying to that {evt_link})" if reminder.message else f" about that {evt_link}" + + msg = f"I'll {action} {target} {message}" + msg += " again" if again else "" + + if again: + msg += " again" + if not agenda: + formatted_time = reminder.formatted_time(await self.db.get_user_info(evt.sender)) + msg += " " + formatted_time + + + confirmation_event = await evt.reply(f"{msg}\n\n" + f"(others can \U0001F44D the message above to get pinged too)") + else: + confirmation_event = await evt.react("\U0001F44D") + await reminder.set_confirmation(confirmation_event) + + + # @command.new("cancel", help="Cancel a recurring reminder", aliases=("delete",)) + @create_reminder.subcommand("cancel", help="Cancel a recurring reminder", aliases=("delete",)) + @command.argument("search_text", pass_raw=True, required=False) + async def cancel_reminder(self, evt: MessageEvent, search_text: str) -> None: + """Cancel a reminder by replying to a reminder, or searching by either message or event ID""" + + reminder = [] + if evt.content.get_reply_to(): + reminder_message = await self.client.get_event(evt.room_id, evt.content.get_reply_to()) + if "org.bytemarx.reminder" not in reminder_message.content: + await evt.reply("That doesn't look like a valid reminder event.") + return + reminder = self.reminders[reminder_message.content["org.bytemarx.reminder"]["id"]] + elif search_text: + # First, check the reminders created by the user, then everything else + for rem in sorted(self.reminders.values(), key=lambda x: x.creator == evt.sender, reverse=True): + # Using the first four base64 digits of the hash, p(collision) > 0.01 at ~10000 reminders + if rem.event_id[1:5] == search_text or re.match(re.escape(search_text.strip()), rem.message.strip(), re.IGNORECASE): + # if rem.event_id[1:5] == search_text or rem.message.upper().strip() == search_text.upper().strip(): + reminder = rem + break + else: # Display the help message + await evt.reply(CommandSyntax.REMINDER_CANCEL.value) + return + + if not reminder: + await evt.reply(f"It doesn't look like you have any reminders matching the text `{search_text}`") return - remind_type = "remind you " - if rem.reply_to: - evt_link = f"[event](https://matrix.to/#/{rem.room_id}/{rem.reply_to})" - if rem.message: - remind_type += f"to {rem.message} (replying to that {evt_link})" - else: - remind_type += f"about that {evt_link}" - elif rem.message: - remind_type += f"to {rem.message}" + + power_levels = await self.client.get_state_event(room_id=reminder.room_id,event_type=EventType.ROOM_POWER_LEVELS) + user_power = power_levels.users.get(evt.sender, power_levels.users_default) + + if reminder.creator == evt.sender or user_power >= self.config["admin_power_level"]: + await reminder.cancel() + await evt.reply("Reminder cancelled!") if self.config["verbose"] else await evt.react("ðŸ‘") else: - remind_type = "ping you" - rem.message = "ping" - if again: - remind_type += " again" - msg = (f"I'll {remind_type} {self.format_time(evt.sender, rem)}.\n\n" - f"(others can \U0001F44D this message to get pinged too)") - rem.event_id = await evt.reply(msg) - self.db.insert(rem) - now = datetime.now(tz=pytz.UTC) - if (rem.date - now).total_seconds() < 60 and now.minute == rem.date.minute: - self.log.debug(f"Reminder {rem} is in less than a minute, scheduling now...") - background_task.create(self.send_reminder(rem)) - - @remind.subcommand("help", help="Usage instructions") + await evt.reply(f"Power levels of {self.config['admin_power_level']} are required to cancel other people's reminders") + + + @create_reminder.subcommand("help", help="Usage instructions") async def help(self, evt: MessageEvent) -> None: - await evt.reply(f"Maubot [Reminder](https://github.com/maubot/reminder) plugin.\n\n" - f"* !{self.base_command} <date> <message> - Add a reminder\n" - f"* !{self.base_command} again <date> - Reply to a reminder to reschedule it\n" - f"* !{self.base_command} list - Get a list of your reminders\n" - f"* !{self.base_command} tz <timezone> - Set your time zone\n" - f"* !{self.base_command} locale <locale> - Set your locale\n" - f"* !{self.base_command} locales - List available locales\n\n" - "<date> can be a time delta (e.g. `2 days 1.5 hours` or `friday at 15:00`) " - "or an absolute date (e.g. `2020-03-27 15:00`)\n\n" - "To get mentioned by a reminder added by someone else, upvote the message " - "by reacting with \U0001F44D.\n\n" - "To cancel a reminder, remove the message or reaction.") - - @remind.subcommand("list", help="List your reminders") - @command.argument("all", required=False) - async def list(self, evt: MessageEvent, all: str) -> None: - room_id = evt.room_id - if "all" in all: - room_id = None - - def format_rem(rem: ReminderInfo) -> str: - if rem.reply_to: - evt_link = f"[event](https://matrix.to/#/{rem.room_id}/{rem.reply_to})" - if rem.message: - return f'"{rem.message}" (replying to {evt_link})' + await evt.reply(self._help_message(), allow_html=True) + + + @create_reminder.subcommand("list", help="List your reminders") + @command.argument("my", parser=lambda x: (re.sub(r"\bmy\b", "", x), re.search(r"\bmy\b", x)), required=False, pass_raw=True) # I hate it but it makes arguments not positional + @command.argument("subscribed", parser=lambda x: (re.sub(r"\bsubscribed\b", "", x), re.search(r"\bsubscribed\b", x)), required=False, pass_raw=True) + @command.argument("all", parser=lambda x: (re.sub(r"\ball\b", "", x), re.search(r"\ball\b", x)), required=False, pass_raw=True) + async def list(self, evt: MessageEvent, all: str, subscribed: str, my: str) -> None: + """Print out a formatted list of all the reminders for a user + + Args: + evt: message event + my: only list reminders the user created + all: list all reminders in every room + subscribed: only list reminders the user is subscribed to + """ + room_id = None if all else evt.room_id + user_info = await self.db.get_user_info(evt.sender) + categories = {"**📜 Agenda items**": [], '**📅 Cron reminders**': [], '**🔠Repeating reminders**': [], '**1ï¸âƒ£ One-time reminders**': []} + + # Sort the reminders by their next run date and format as bullet points + for reminder in sorted(self.reminders.values(), key=lambda x: x.job.next_run_time if x.job else datetime(2000,1,1,tzinfo=pytz.UTC)): + if ( + (not subscribed or any(x in reminder.subscribed_users.values() for x in [evt.sender, "@room"])) and + (not my or evt.sender == reminder.creator) and + (all or reminder.room_id == room_id)): + + message = reminder.message + next_run = reminder.formatted_time(user_info) + short_event_id = f"[`{reminder.event_id[1:5]}`](https://matrix.to/#/{reminder.room_id}/{reminder.event_id})" + + if reminder.reply_to: + evt_link = f"[event](https://matrix.to/#/{reminder.room_id}/{reminder.reply_to})" + message = f'{message} (replying to {evt_link})' if message else evt_link + + if reminder.cron_tab: + category = "**📅 Cron reminders**" + elif reminder.recur_every: + category = "**🔠Repeating reminders**" + elif not reminder.is_agenda: + category = "**1ï¸âƒ£ One-time reminders**" else: - return evt_link - else: - return f'"{rem.message}"' - - reminders_str = "\n".join(f"* {format_rem(reminder)} {self.format_time(evt.sender, reminder)}" - for reminder in self.db.all_for_user(evt.sender, room_id=room_id)) - message = "upcoming reminders" - if room_id: - message += " in this room" - if len(reminders_str) == 0: - await evt.reply(f"You have no {message} :(") - else: - await evt.reply(f"Your {message}:\n\n{reminders_str}") - - def format_time(self, sender: UserID, reminder: ReminderInfo) -> str: - return format_time(reminder.date.astimezone(self.db.get_timezone(sender))) - - @remind.subcommand("locales", help="List available locales") - async def locales(self, evt: MessageEvent) -> None: - def _format_key(key: str) -> str: - language, country = key.split("_") - return f"{language.lower()}_{country.upper()}" - - await evt.reply("Available locales:\n\n" + - "\n".join(f"* `{_format_key(key)}` - {locale.name}" - for key, locale in locales.items())) - - @staticmethod - def _fmt_locales(locale_ids: List[str]) -> str: - locale_names = [locales[id].name for id in locale_ids] - if len(locale_names) == 0: - return "unset" - elif len(locale_names) == 1: - return locale_names[0] - else: - return ", ".join(locale_names[:-1]) + " and " + locale_names[-1] + category = "**📜 Agenda items**" + categories[category].append(f"* {short_event_id} {next_run} **{message}**") + + # Upack the nested dict into a flat list of reminders seperated by category + in_room_msg = " in this room" if room_id else "" + output = [] + for category, reminders in categories.items(): + if reminders: + output.append("\n" + category + in_room_msg) + for reminder in reminders: + output.append(reminder) + output = "\n".join(output) + + if not output: + output = f"You have no upcoming reminders{in_room_msg} :(" + await evt.reply(output) + + - @remind.subcommand("locale", help="Set your locale") + @create_reminder.subcommand("locale", help="Set your locale") @command.argument("locale", required=False, pass_raw=True) async def locale(self, evt: MessageEvent, locale: str) -> None: if not locale: - await evt.reply(f"Your locale is {self._fmt_locales(self.db.get_locales(evt.sender))}") + await evt.reply(f"Your locale is `{(await self.db.get_user_info(evt.sender)).locale}`") return - locale_ids = [part.strip() for part in locale.lower().split(" ")] - for locale_id in locale_ids: - if locale_id not in locales: - await evt.reply(f"Locale `{locale_id}` is not supported") - return - self.db.set_locales(evt.sender, locale_ids) - await evt.reply(f"Set your locale to {self._fmt_locales(locale_ids)}") + if validate_locale(locale): + await self.db.set_user_info(evt.sender, key="locale", value=locale) + await evt.reply(f"Setting your locale to {locale}") + else: + await evt.reply(f"Unknown locale: `{locale}`\n\n" + f"[Available locales](https://dateparser.readthedocs.io/en/latest/supported_locales.html)" + f" (case sensitive)") + - @remind.subcommand("timezone", help="Set your timezone", aliases=("tz",)) - @command.argument("timezone", parser=parse_timezone, required=False) + + @create_reminder.subcommand("timezone", help="Set your timezone", aliases=("tz",)) + @command.argument("timezone", required=False, pass_raw=True) async def timezone(self, evt: MessageEvent, timezone: pytz.timezone) -> None: if not timezone: - await evt.reply(f"Your time zone is {self.db.get_timezone(evt.sender).zone}") + await evt.reply(f"Your timezone is `{(await self.db.get_user_info(evt.sender)).timezone}`") return - self.db.set_timezone(evt.sender, timezone) - await evt.reply(f"Set your timezone to {timezone.zone}") + if validate_timezone(timezone): + await self.db.set_user_info(evt.sender, key="timezone", value=timezone) + await evt.reply(f"Setting your timezone to {timezone}") + else: + await evt.reply(f"Unknown timezone: `{timezone}`\n\n" + f"[Available timezones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)") + @command.passive(regex=r"(?:\U0001F44D[\U0001F3FB-\U0001F3FF]?)", field=lambda evt: evt.content.relates_to.key, event_type=EventType.REACTION, msgtypes=None) async def subscribe_react(self, evt: ReactionEvent, _: Tuple[str]) -> None: - reminder = self.db.get_by_event_id(evt.content.relates_to.event_id) + """ + Subscribe to a reminder by reacting with "ðŸ‘"ï¸ + """ + reminder_id = evt.content.relates_to.event_id + reminder = self.reminders.get(reminder_id) if reminder: - self.db.add_user(reminder, evt.sender, evt.event_id) + await reminder.add_subscriber(user_id=evt.sender, subscribing_event=evt.event_id) + @event.on(EventType.ROOM_REDACTION) async def redact(self, evt: RedactionEvent) -> None: - self.db.redact_event(evt.redacts) + """Unsubscribe from a reminder by redacting the message""" + for key, reminder in self.reminders.items(): + if evt.redacts in reminder.subscribed_users: + await reminder.remove_subscriber(subscribing_event=evt.redacts) + + # If the reminder has no users left, cancel it + if not reminder.subscribed_users or reminder.event_id == evt.redacts: + await reminder.cancel(redact_confirmation=True) + break + @event.on(EventType.ROOM_TOMBSTONE) async def tombstone(self, evt: StateEvent) -> None: - if not evt.content.replacement_room: - return - self.db.update_room_id(evt.room_id, evt.content.replacement_room) + """If a room gets upgraded or replaced, move any reminders to the new room""" + if evt.content.replacement_room: + await self.db.update_room_id(old_id=evt.room_id, new_id=evt.content.replacement_room) + + def _help_message(self) -> str: + return f""" +**â° Maubot [Reminder](https://github.com/maubot/reminder) plugin**\\ +TLDR: `!remind every friday 3pm take out the trash` `!remind cancel take out the trash`\\ +All commands can be called with `!{"|".join(self.base_aliases)}` + +**Creating optionally recurring reminders:** +{CommandSyntax.REMINDER_CREATE.value.format(base_command=self.base_command)} + +**Creating agenda items** +{CommandSyntax.AGENDA_CREATE.value.format(agenda_command="|".join(self.agenda_command))} + +**Listing active reminders** +{CommandSyntax.REMINDER_LIST.value.format(base_command=self.base_command)} + +**Deleting reminders** + +{CommandSyntax.REMINDER_CANCEL.value.format(base_command=self.base_command)} + +**Rescheduling** + +{CommandSyntax.REMINDER_RESCHEDULE.value.format(base_command=self.base_command)} + +**Settings** + +{CommandSyntax.REMINDER_SETTINGS.value.format(base_command=self.base_command, + default_tz=self.db.defaults.timezone, + default_locale=self.db.defaults.locale)} +""" \ No newline at end of file diff --git a/reminder/db.py b/reminder/db.py index 2cce8e39ce9f29f78b7d21f7228ef798a3749be9..be60f75127d8b704eabbe5ba355016979cbd68ff 100644 --- a/reminder/db.py +++ b/reminder/db.py @@ -13,199 +13,222 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <https://www.gnu.org/licenses/>. -from typing import Optional, Iterator, Dict, List +from __future__ import annotations +import logging + +from typing import Optional, Iterator, Dict, List, DefaultDict from datetime import datetime +from collections import defaultdict +from .util import validate_timezone, validate_locale, UserInfo import pytz -from sqlalchemy import (Column, String, Integer, Text, DateTime, ForeignKey, Table, MetaData, - select, and_) -from sqlalchemy.engine.base import Engine + +from mautrix.util.async_db import Database from mautrix.types import UserID, EventID, RoomID +from typing import Dict, Literal, TYPE_CHECKING + + +if TYPE_CHECKING: + from .bot import ReminderBot + +from .reminder import Reminder -from .util import ReminderInfo + +logger = logging.getLogger(__name__) class ReminderDatabase: - reminder: Table - reminder_target: Table - timezone: Table - tz_cache: Dict[UserID, pytz.timezone] - locale_cache: Dict[UserID, List[str]] - db: Engine - - def __init__(self, db: Engine) -> None: + cache: DefaultDict[UserID, UserInfo] + db: Database + defaults: UserInfo + + def __init__(self, db: Database, defaults: UserInfo = UserInfo()) -> None: self.db = db - self.tz_cache = {} - self.locale_cache = {} - - meta = MetaData() - meta.bind = db - - self.reminder = Table("reminder", meta, - Column("id", Integer, primary_key=True, autoincrement=True), - Column("date", DateTime, nullable=False), - Column("room_id", String(255), nullable=False), - Column("event_id", String(255), nullable=False), - Column("message", Text, nullable=False), - Column("reply_to", String(255), nullable=True)) - self.reminder_target = Table("reminder_target", meta, - Column("reminder_id", Integer, - ForeignKey("reminder.id", ondelete="CASCADE"), - primary_key=True), - Column("user_id", String(255), primary_key=True), - Column("event_id", String(255), nullable=False)) - self.timezone = Table("timezone", meta, - Column("user_id", String(255), primary_key=True), - Column("timezone", String(255), nullable=False)) - self.locale = Table("locale", meta, - Column("user_id", String(255), primary_key=True), - Column("locales", String(255), nullable=False)) - - meta.create_all() - - def set_timezone(self, user_id: UserID, tz: pytz.timezone) -> None: - with self.db.begin() as tx: - tx.execute(self.timezone.delete().where(self.timezone.c.user_id == user_id)) - tx.execute(self.timezone.insert().values(user_id=user_id, timezone=tz.zone)) - self.tz_cache[user_id] = tz - - def get_timezone(self, user_id: UserID, default_tz: Optional[pytz.timezone] = None - ) -> Optional[pytz.timezone]: - try: - return self.tz_cache[user_id] - except KeyError: - rows = self.db.execute(select([self.timezone.c.timezone]) - .where(self.timezone.c.user_id == user_id)) - try: - self.tz_cache[user_id] = pytz.timezone(next(rows)[0]) - except (pytz.UnknownTimeZoneError, StopIteration, IndexError): - self.tz_cache[user_id] = default_tz or pytz.UTC - return self.tz_cache[user_id] - - def set_locales(self, user_id: UserID, locales: List[str]) -> None: - with self.db.begin() as tx: - tx.execute(self.locale.delete().where(self.locale.c.user_id == user_id)) - tx.execute(self.locale.insert().values(user_id=user_id, locales=",".join(locales))) - self.locale_cache[user_id] = locales - - def get_locales(self, user_id: UserID) -> List[str]: - try: - return self.locale_cache[user_id] - except KeyError: - rows = self.db.execute(select([self.locale.c.locales]) - .where(self.locale.c.user_id == user_id)) - try: - self.locale_cache[user_id] = next(rows)[0].split(",") - except (StopIteration, IndexError): - self.locale_cache[user_id] = ["en_iso"] - return self.locale_cache[user_id] - - def all_for_user(self, user_id: UserID, room_id: Optional[RoomID] = None - ) -> Iterator[ReminderInfo]: - where = [self.reminder.c.id == self.reminder_target.c.reminder_id, - self.reminder_target.c.user_id == user_id, - self.reminder.c.date > datetime.now(tz=pytz.UTC)] - if room_id: - where.append(self.reminder.c.room_id == room_id) - rows = self.db.execute(select([self.reminder]).where(and_(*where))) - for row in rows: - yield ReminderInfo(id=row[0], date=row[1].replace(tzinfo=pytz.UTC), room_id=row[2], - event_id=row[3], message=row[4], reply_to=row[5], users=[user_id]) - - def get(self, id: int) -> Optional[ReminderInfo]: - return self._get_one(self.reminder.c.id == id) - - def get_by_event_id(self, event_id: EventID) -> Optional[ReminderInfo]: - reminder = self._get_one(self.reminder.c.event_id == event_id) - if reminder: - return reminder - rows = self.db.execute(select([self.reminder_target.c.reminder_id]) - .where(self.reminder_target.c.event_id == event_id)) - try: - reminder_id = int(next(rows)[0]) - return self.get(reminder_id) - except (StopIteration, IndexError, ValueError): - return None - - def _get_one(self, whereclause) -> Optional[ReminderInfo]: - rows = self.db.execute(select([self.reminder, self.reminder_target.c.user_id, - self.reminder_target.c.event_id]).where( - and_(whereclause, self.reminder_target.c.reminder_id == self.reminder.c.id))) - try: - first_row = next(rows) - except StopIteration: - return None - info = ReminderInfo(id=first_row[0], date=first_row[1].replace(tzinfo=pytz.UTC), - room_id=first_row[2], event_id=first_row[3], message=first_row[4], - reply_to=first_row[5], users={first_row[6]: first_row[7]}) - for row in rows: - info.users[row[6]] = row[7] - return info - - def _get_many(self, whereclause) -> Iterator[ReminderInfo]: - rows = self.db.execute(select([self.reminder, self.reminder_target.c.user_id, - self.reminder_target.c.event_id]) - .where(whereclause) - .order_by(self.reminder.c.id, self.reminder.c.date)) - building_reminder = None + self.cache = defaultdict() + self.defaults = defaults + + + async def get_user_info(self, user_id: UserID) -> UserInfo: + """ Get the timezone and locale for a user. Data is cached in memory. + Args: + user_id: ID for the user to query + Returns: + UserInfo: a dataclass with keys: 'locale' and 'timezone' + """ + if user_id not in self.cache: + query = "SELECT timezone, locale FROM user_settings WHERE user_id = $1" + row = dict(await self.db.fetchrow(query, user_id) or {}) + + locale = row.get("locale", self.defaults.locale) + timezone = row.get("timezone", self.defaults.timezone) + + # If fetched locale is invalid, use default one + if not locale or not validate_locale(locale): + locale = self.defaults.locale + + # If fetched timezone is invalid, use default one + if not timezone or not validate_timezone(timezone): + timezone = self.defaults.timezone + + self.cache[user_id] = UserInfo(locale=locale, timezone=timezone) + + return self.cache[user_id] + + + async def set_user_info(self, user_id: UserID, key: Literal["timezone", "locale"], value: str) -> None: + # Make sure user_info is populated first + await self.get_user_info(user_id) + # Update cache + setattr(self.cache[user_id], key, value) + # Update the db + q = """ + INSERT INTO user_settings (user_id, {0}) + VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET {0} = EXCLUDED.{0} + """.format(key) + await self.db.execute(q, user_id, value) + + async def store_reminder(self, reminder: Reminder) -> None: + """Add a new reminder in the database""" + # hella messy but I don't know what else to do that works with both asyncpg and aiosqlite + await self.db.execute(""" + INSERT INTO reminder ( + event_id, + room_id, + start_time, + message, + reply_to, + cron_tab, + recur_every, + is_agenda, + creator + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + """, + reminder.event_id, + reminder.room_id, + reminder.start_time.replace(microsecond=0).isoformat() if reminder.start_time else None, + reminder.message, + reminder.reply_to, + reminder.cron_tab, + reminder.recur_every, + reminder.is_agenda, + reminder.creator) + + + async def load_all(self, bot: ReminderBot) -> Dict[EventID, Reminder]: + """ Load all reminders in the database and return them as a dict for the main bot + Args: + bot: it feels dirty to do it this way, but it seems to work and make code cleaner but feel free to fix this + Returns: + a dict of Reminders, with the event id as the key. + """ + rows = await self.db.fetch(""" + SELECT + event_id, + room_id, + message, + reply_to, + start_time, + recur_every, + cron_tab, + is_agenda, + user_id, + confirmation_event, + subscribing_event, + creator + FROM reminder NATURAL JOIN reminder_target + """) + logger.debug(f"Loaded {len(rows)} reminders") + reminders = {} for row in rows: - if building_reminder is not None: - if building_reminder.id == row[0]: - building_reminder.users[row[6]] = row[7] - continue - yield building_reminder - building_reminder = ReminderInfo(id=row[0], date=row[1].replace(tzinfo=pytz.UTC), - room_id=row[2], event_id=row[3], message=row[4], - reply_to=row[5], users={row[6]: row[7]}) - if building_reminder is not None: - yield building_reminder - - def all(self) -> Iterator[ReminderInfo]: - yield from self._get_many(self.reminder_target.c.reminder_id == self.reminder.c.id) - - def all_in_range(self, after: datetime, before: datetime) -> Iterator[ReminderInfo]: - yield from self._get_many(and_(self.reminder_target.c.reminder_id == self.reminder.c.id, - after <= self.reminder.c.date, - self.reminder.c.date < before)) - - def insert(self, reminder: ReminderInfo) -> None: - with self.db.begin() as tx: - res = tx.execute(self.reminder.insert() - .values(date=reminder.date, room_id=reminder.room_id, - event_id=reminder.event_id, message=reminder.message, - reply_to=reminder.reply_to)) - reminder.id = res.inserted_primary_key[0] - tx.execute(self.reminder_target.insert(), - [{"reminder_id": reminder.id, "user_id": user_id, - "event_id": event_id} - for user_id, event_id in reminder.users.items()]) - - def update_room_id(self, old: RoomID, new: RoomID) -> None: - self.db.execute(self.reminder.update() - .where(self.reminder.c.room_id == old) - .values(room_id=new)) - - def redact_event(self, event_id: EventID) -> None: - self.db.execute(self.reminder_target.delete() - .where(self.reminder_target.c.event_id == event_id)) - - def add_user(self, reminder: ReminderInfo, user_id: UserID, event_id: EventID) -> bool: - if user_id in reminder.users: - return False - self.db.execute(self.reminder_target.insert() - .values(reminder_id=reminder.id, user_id=user_id, event_id=event_id)) - if isinstance(reminder.users, list): - reminder.users.append(user_id) - elif isinstance(reminder.users, dict): - reminder.users[user_id] = event_id - return True - - def remove_user(self, reminder: ReminderInfo, user_id: UserID) -> bool: - if user_id not in reminder.users: - return False - self.db.execute(self.reminder_target.delete().where( - and_(self.reminder_target.c.reminder_id == reminder.id, - self.reminder_target.c.user_id == user_id))) - reminder.users.remove(user_id) - return True + # Reminder subscribers are stored in a separate table instead of in an array type for sqlite support + if row["event_id"] in reminders: + # reminders[row["event_id"]].subscribed_users.append(row["user_id"]) + reminders[row["event_id"]].subscribed_users[row["user_id"]] = row["subscribing_event"] + continue + + start_time = datetime.fromisoformat(row["start_time"]) if row["start_time"] else None + + if start_time and not row["is_agenda"]: + # If this is a one-off reminder whose start time is in the past, then it will + # never fire. Ignore and delete the row from the db + if not row["recur_every"] and not row["cron_tab"]: + now = datetime.now(tz=pytz.UTC) + + if start_time < now: + logger.warning( + "Deleting missed reminder in room %s: %s - %s", + row["room_id"], + row["start_time"], + row["message"], + ) + await self.delete_reminder(row["event_id"]) + continue + + reminders[row["event_id"]] = Reminder( + bot=bot, + event_id=row["event_id"], + room_id=row["room_id"], + message=row["message"], + reply_to=row["reply_to"], + start_time=start_time, + recur_every=row["recur_every"], + cron_tab=row["cron_tab"], + is_agenda=row["is_agenda"], + subscribed_users={row["subscribing_event"]: row["user_id"]}, + creator=row["creator"], + user_info= await self.get_user_info(row["creator"]), + confirmation_event=row["confirmation_event"], + ) + + return reminders + + async def delete_reminder(self, event_id: EventID): + await self.db.execute( + """ + DELETE FROM reminder WHERE event_id = $1 + """, + event_id + ) + + async def reschedule_reminder(self, start_time: datetime, event_id: EventID): + await self.db.execute( + """ + UPDATE reminder SET start_time=$1 WHERE event_id=$2 + """, + start_time.replace(microsecond=0).isoformat(), + event_id + ) + async def update_room_id(self, old_id: RoomID, new_id: RoomID) -> None: + await self.db.execute(""" + UPDATE reminder + SET room_id = $1 + WHERE room_id = $2 + """, + new_id, + old_id) + + async def add_subscriber(self, reminder_id: EventID, user_id: UserID, subscribing_event: EventID) -> None: + await self.db.execute(""" + INSERT INTO reminder_target (event_id, user_id, subscribing_event) VALUES ($1, $2, $3) + """, + reminder_id, + user_id, + subscribing_event) + + async def remove_subscriber(self, subscribing_event: EventID): + """Remove a user's subscription from a reminder""" + await self.db.execute(""" + DELETE FROM reminder_target WHERE subscribing_event = $1 + """, + subscribing_event + ) + + async def set_confirmation_event(self, event_id: EventID, confirmation_event: EventID): + await self.db.execute(""" + UPDATE reminder + SET confirmation_event = $1 + WHERE event_id = $2 + """, + confirmation_event, + event_id) \ No newline at end of file diff --git a/reminder/locale_util.py b/reminder/locale_util.py deleted file mode 100644 index 46e9041ee16a4ed4d23d48f29d4d54d275c1b99e..0000000000000000000000000000000000000000 --- a/reminder/locale_util.py +++ /dev/null @@ -1,182 +0,0 @@ -# reminder - A maubot plugin to remind you about things. -# Copyright (C) 2020 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. -from typing import NamedTuple, Union, Pattern, Match, Dict, Type, Optional, TYPE_CHECKING -from datetime import datetime -from abc import ABC, abstractmethod -import re - -from dateutil.relativedelta import MO - -WeekdayType = type(MO) - -if TYPE_CHECKING: - from typing import TypedDict - - - class RelativeDeltaParams(TypedDict): - year: int - month: int - day: int - hour: int - minute: int - second: int - microsecond: int - - years: Union[int, float] - months: Union[int, float] - weeks: Union[int, float] - days: Union[int, float] - hours: Union[int, float] - minutes: Union[int, float] - seconds: Union[int, float] - microseconds: Union[int, float] - - weekday: Union[int, WeekdayType] - - leapdays: int - yearday: int - nlyearday: int - - -class MatcherReturn(NamedTuple): - params: 'RelativeDeltaParams' - unconsumed: str - - -class Matcher(ABC): - @abstractmethod - def match(self, val: str) -> Optional[MatcherReturn]: - pass - - -def int_or_float(val: str) -> Union[int, float]: - if "," in val: - return float(val.replace(",", ".")) - elif "." in val: - return float(val) - return int(val) - - -class RegexMatcher(Matcher): - regex: Pattern - value_type: Type - - def __init__(self, pattern: str, value_type: Type = int_or_float) -> None: - self.regex = re.compile(pattern, re.IGNORECASE) - self.value_type = value_type - - def match(self, val: str) -> Optional[MatcherReturn]: - match = self.regex.match(val) - if match and match.end() > 0 and len(match.groups()) > 0: - return MatcherReturn(params=self._convert_match(match), unconsumed=val[match.end():]) - return None - - def _convert_match(self, match: Match) -> 'RelativeDeltaParams': - return self._convert_groups(match.groupdict()) - - def _convert_groups(self, groups: Dict[str, str]) -> 'RelativeDeltaParams': - return {key: self.value_type(value) for key, value in groups.items() if value} - - -class TimeMatcher(RegexMatcher): - def _convert_match(self, match: Match) -> 'RelativeDeltaParams': - groups = match.groupdict() - try: - meridiem = groups.pop("meridiem").lower() - except (KeyError, AttributeError): - meridiem = None - params = self._convert_groups(groups) - if meridiem == "pm": - params["hour"] += 12 - elif meridiem == "am" and params["hour"] == 12: - params["hour"] = 0 - return params - - -class ShortYearMatcher(RegexMatcher): - def _convert_match(self, match: Match) -> 'RelativeDeltaParams': - params = super()._convert_match(match) - if params["year"] < 100: - year = datetime.now().year - current_century = year // 100 - if params["year"] < year % 100: - current_century += 1 - params["year"] = (current_century * 100) + params["year"] - return params - - -class WeekdayMatcher(Matcher): - regex: Pattern - map: Dict[str, Union[int, WeekdayType]] - substr: int - - def __init__(self, pattern: str, map: Dict[str, Union[int, WeekdayType]], substr: int) -> None: - self.regex = re.compile(pattern, re.IGNORECASE) - self.map = map - self.substr = substr - - def match(self, val: str) -> Optional[MatcherReturn]: - match = self.regex.match(val) - if match and match.end() > 0: - weekday = self.map[match.string[:self.substr].lower()] - if isinstance(weekday, int): - weekday = (datetime.now().weekday() + weekday) % 7 - return MatcherReturn(params={"weekday": weekday}, unconsumed=val[match.end():]) - return None - - -class Locale(Matcher): - name: str - timedelta: Matcher - date: Matcher - weekday: Matcher - time: Matcher - - def __init__(self, name: str, timedelta: Matcher, date: Matcher, weekday: Matcher, - time: Matcher) -> None: - self.name = name - self.timedelta = timedelta - self.date = date - self.weekday = weekday - self.time = time - - def replace(self, name: str, timedelta: Matcher = None, date: Matcher = None, - weekday: Matcher = None, time: Matcher = None) -> 'Locale': - return Locale(name=name, timedelta=timedelta or self.timedelta, date=date or self.date, - weekday=weekday or self.weekday, time=time or self.time) - - def match(self, val: str) -> Optional[MatcherReturn]: - found_delta = self.timedelta.match(val) - if found_delta: - params, val = found_delta - else: - params = {} - found_day = self.weekday.match(val) - if found_day: - params, val = found_day - else: - found_date = self.date.match(val) - if found_date: - params, val = found_date - - found_time = self.time.match(val) - if found_time: - params = {**params, **found_time.params} - val = found_time.unconsumed - return MatcherReturn(params=params, unconsumed=val) if len(params) > 0 else None - - -Locales = Dict[str, Locale] diff --git a/reminder/locales.py b/reminder/locales.py deleted file mode 100644 index 1b289a1078d3aa42c3cb0b739267b7ab4f0298c6..0000000000000000000000000000000000000000 --- a/reminder/locales.py +++ /dev/null @@ -1,141 +0,0 @@ -# reminder - A maubot plugin to remind you about things. -# Copyright (C) 2020 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. -from dateutil.relativedelta import MO, TU, WE, TH, FR, SA, SU - -from .locale_util import (Locales, Locale, RegexMatcher, - WeekdayMatcher, TimeMatcher, ShortYearMatcher) - -locales: Locales = {} - -td_sep_en = r"(?:[\s,]{1,3}(?:and\s)?)?" -number = r"[+-]?\d+(?:[.,]\d+)?" -locales["en_iso"] = Locale( - name="English (ISO)", - timedelta=RegexMatcher(r"(?:(?:in|after)\s)?" - rf"(?:(?P<years>{number})\s?y(?:r|ear)?s?{td_sep_en})?" - rf"(?:(?P<months>{number})\s?mo(?:nth)?s?{td_sep_en})?" - rf"(?:(?P<weeks>{number})\s?w(?:k|eek)?s?{td_sep_en})?" - rf"(?:(?P<days>{number})\s?d(?:ays?)?{td_sep_en})?" - rf"(?:(?P<hours>{number})\s?h(?:(?:r|our)?s?){td_sep_en})?" - rf"(?:(?P<minutes>{number})\s?m(?:in(?:ute)?s?)?{td_sep_en})?" - rf"(?:(?P<seconds>{number})\s?s(?:ec(?:ond)?s?)?)?" - r"(?:\s|$)"), - date=RegexMatcher(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})\s"), - weekday=WeekdayMatcher(pattern=r"(?:today" - r"|tomorrow" - r"|mon(?:day)?" - r"|tues?(?:day)?" - r"|wed(?:nesday)?" - r"|thu(?:rs(?:day)?)?" - r"|fri(?:day)?" - r"|sat(?:urday)?" - r"|sun(?:day)?)" - r"(?:\s|$)", - map={ - "tod": +0, "tom": +1, "mon": MO, "tue": TU, "wed": WE, "thu": TH, - "fri": FR, "sat": SA, "sun": SU, - }, substr=3), - time=RegexMatcher(r"\s?(?:at\s)?" - r"(?P<hour>\d{2})" - r"[:.](?P<minute>\d{2})" - r"(?:[:.](?P<second>\d{2}))?" - r"(?:\s|$)"), -) - -time_12_en = TimeMatcher(r"\s?(?:at\s)?" - r"(?P<hour>\d{2})" - r"(?:[:.](?P<minute>\d{2}))?" - r"(?:[:.](?P<second>\d{2}))?" - r"(?:\s(?P<meridiem>a\.?m|p\.?m)\.?)?" - r"(?:\s|$)") - -locales["en_us"] = locales["en_iso"].replace( - name="English (US)", time=time_12_en, date=ShortYearMatcher( - r"(?P<month>\d{1,2})/(?P<day>\d{1,2})(?:/(?P<year>\d{2}(?:\d{2})?))?(?:\s|$)")) - -locales["en_uk"] = locales["en_iso"].replace( - name="English (UK)", time=time_12_en, date=ShortYearMatcher( - r"(?P<day>\d{1,2})/(?P<month>\d{1,2})(?:/(?P<year>\d{2}(?:\d{2})?))?(?:\s|$)")) - -td_sep_fi = r"(?:[\s,]{1,3}(?:ja\s)?)?" -locales["fi_fi"] = Locale( - name="Finnish", - timedelta=RegexMatcher(rf"(?:(?P<years>{number})\s?v(?:uo(?:tta|den))?{td_sep_fi})?" - rf"(?:(?P<months>{number})\s?k(?:k|uukau(?:si|tta|den))?{td_sep_fi})?" - rf"(?:(?P<weeks>{number})\s?v(?:k|iikk?o[an]?){td_sep_fi})?" - rf"(?:(?P<days>{number})\s?p(?:v|äivä[än]?){td_sep_fi})?" - rf"(?:(?P<hours>{number})\s?t(?:un(?:nin?|tia))?{td_sep_fi})?" - rf"(?:(?P<minutes>{number})\s?m(?:in(?:uut(?:in?|tia))?)?{td_sep_fi})?" - rf"(?:(?P<seconds>{number})\s?s(?:ek(?:un(?:nin?|tia))?)?)?" - r"(?:\s(?:kuluttua|päästä?))?" - r"(?:\s|$)"), - date=ShortYearMatcher(r"(?P<day>\d{1,2})\.(?P<month>\d{1,2})\.(?P<year>\d{2}(?:\d{2})?)\s"), - weekday=WeekdayMatcher(pattern=r"(?:tänään" - r"|(?:yli)?huomen" - r"|ma(?:aanantai)?" - r"|ti(?:iistai)?" - r"|ke(?:skiviikko)?" - r"|to(?:rstai)?" - r"|pe(?:rjantai)?" - r"|la(?:uantai)?" - r"|su(?:nnuntai)?)" - r"(?:na)?" - r"(?:\s|$)", - map={ - "tä": +0, "hu": +1, "yl": +2, - "ma": MO, "ti": TU, "ke": WE, "to": TH, "pe": FR, "la": SA, "su": SU, - }, substr=2), - time=RegexMatcher(r"\s?(?:ke?ll?o\.?\s)?" - r"(?P<hour>\d{1,2})" - r"[:.](?P<minute>\d{2})" - r"(?:[:.](?P<second>\d{2}))?" - r"(?:\s|$)"), -) - -td_sep_de = r"(?:[\s,]{1,3}(?:und\s)?)?" -locales["de_de"] = Locale( - name="German", - timedelta=RegexMatcher(rf"(?:in\s)?" - rf"(?:(?P<years>{number})\s?jahr(?:en)?{td_sep_de})?" - rf"(?:(?P<months>{number})\s?monat(?:en)?{td_sep_de})?" - rf"(?:(?P<weeks>{number})\s?wochen?{td_sep_de})?" - rf"(?:(?P<days>{number})\s?tag(?:en)?{td_sep_de})?" - rf"(?:(?P<hours>{number})\s?stunden?{td_sep_de})?" - rf"(?:(?P<minutes>{number})\s?minuten?{td_sep_de})?" - rf"(?:(?P<seconds>{number})\s?sekunden?)?" - r"(?:\s|$)"), - date=ShortYearMatcher( - r"(?P<day>\d{1,2})\.(?P<month>\d{1,2})\.(?P<year>\d{2}(?:\d{2})?)(?:\s|$)"), - weekday=WeekdayMatcher(pattern=r"(?:heute" - r"|(?:über)?morgen" - r"|mo(?:ntag)?" - r"|di(?:enstag)?" - r"|mi(?:ttwoch)?" - r"|do(?:nnerstag)?" - r"|fr(?:eitag)?" - r"|sa(?:mstag)?" - r"|so(?:nntag)?)" - r"(?:\s|$)", - map={ - "heu": +0, "mor": +1, "übe": +2, "mon": MO, "die": TU, "mit": WE, - "don": TH, "fre": FR, "sam": SA, "son": SU, - }, substr=3), - time=RegexMatcher(r"\s?(?:um\s)?" - r"(?P<hour>\d{1,2})" - r"[:.](?P<minute>\d{2})" - r"(?:[:.](?P<second>\d{2}))?" - r"(?:\s|$)"), -) diff --git a/reminder/migrations.py b/reminder/migrations.py new file mode 100644 index 0000000000000000000000000000000000000000..276e7fc6026e186d5e085eccecaef9b830413287 --- /dev/null +++ b/reminder/migrations.py @@ -0,0 +1,57 @@ +# reminder - A maubot plugin to remind you about things. +# Copyright (C) 2020 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +from mautrix.util.async_db import Connection, Scheme, UpgradeTable + +upgrade_table = UpgradeTable() + + +@upgrade_table.register(description="Latest revision") +async def upgrade_v1(conn: Connection, scheme: Scheme) -> None: + await conn.execute( + f"""CREATE TABLE IF NOT EXISTS reminder ( + event_id VARCHAR(255) NOT NULL, /* event_id of the message that created the reminder */ + room_id VARCHAR(255) NOT NULL, /* room_id for this reminder */ + start_time TEXT, /* time for one-off reminders, stored as an ISO 8601 string because sqlite doesn't have date types */ + message TEXT, /* message for the reminder */ + reply_to VARCHAR(255), /* if the reminder created as a reply to another message, this is that message's event_id */ + cron_tab TEXT, /* cron string if it's a cron reminder */ + creator VARCHAR(255), /* user_id of the person who created the reminder */ + recur_every TEXT, /* string to parse to schedule the next recurring reminder, e.g. 'tuesday at 2pm' */ + is_agenda BOOL, /* agendas are alarms that don't trigger */ + confirmation_event TEXT, /* event_id of the confirmation message, so that we can delete the confirmation if the reminder is deleted */ + PRIMARY KEY (event_id) + + )""" + ) + + await conn.execute( + f"""CREATE TABLE IF NOT EXISTS reminder_target ( + event_id VARCHAR(255) NOT NULL, /* event_id in the reminder table */ + user_id VARCHAR(255) NOT NULL, /* user_id of the subscriber */ + subscribing_event VARCHAR(255) NOT NULL, /* event_id of the event creating the subscription, either a 👠or the reminder message itself */ + PRIMARY KEY (user_id, event_id), + FOREIGN KEY (event_id) REFERENCES reminder (event_id) ON DELETE CASCADE + )""" + ) + + await conn.execute( + f"""CREATE TABLE IF NOT EXISTS user_settings ( + user_id VARCHAR(255) NOT NULL, /* user_id */ + timezone TEXT, /* user's timezone, e.g. America/Los_Angeles, PST. see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones */ + locale TEXT, /* user's locale or langauge, e.g. en, en-AU, fr, fr-CA. See https://dateparser.readthedocs.io/en/latest/supported_locales.html */ + PRIMARY KEY (user_id) + )""" + ) diff --git a/reminder/reminder.py b/reminder/reminder.py new file mode 100644 index 0000000000000000000000000000000000000000..3ff3c29b13563756d0bfc74eba27f304608a3ce7 --- /dev/null +++ b/reminder/reminder.py @@ -0,0 +1,218 @@ +from __future__ import annotations +import logging +from datetime import datetime +from typing import Dict, Optional, TYPE_CHECKING + +import pytz +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.date import DateTrigger + +from mautrix.util import markdown + +from mautrix.types import (Format, MessageType, TextMessageEventContent, UserID, EventID, RoomID) + +from .util import CommandSyntaxError, CommandSyntax, make_pill, parse_date, UserInfo, format_time + +try: + from cron_descriptor import Options, CasingTypeEnum, DescriptionTypeEnum, ExpressionDescriptor + USE_CRON_DESCRIPTOR = True +except ImportError: + USE_CRON_DESCRIPTOR = False + +if TYPE_CHECKING: + from .bot import ReminderBot + +logger = logging.getLogger(__name__) + +class Reminder(object): + def __init__( + self, + bot: ReminderBot, + room_id: RoomID, + message: str, + event_id: Optional[EventID] = None, + reply_to: Optional[EventID] = None, + start_time: Optional[datetime] = None, + recur_every: Optional[str] = None, + cron_tab: Optional[str] = None, + subscribed_users: Optional[Dict[EventID, UserID]] = None, + creator: Optional[UserID] = None, + user_info: Optional[UserInfo] = None, + confirmation_event: EventID = None, + is_agenda: bool = False, + ): + """An object containing information about a reminder, when it should go off, + whether it is recurring, etc. + + Args: + bot: instance of the main bot (this feels wrong but it works) + room_id: The ID of the room the reminder should appear in + message: The text to include in the reminder message + event_id: The event ID of the message creating the reminder + reply_to: The event ID of the message the reminder is replying to if applicable, so we can link it in the reply + start_time: When the reminder should first go off + recur_every: date string to parse to schedule the next recurring reminder + cron_tab: cron text + subscribed_users: dict of subscribed users with corresponding subscription events + creator: ID of the creator + user_info: contains timezone and locale for the person who created the reminder + confirmation_event: EventID of the confirmation message. Store this so it can be redacted when the reminder is removed. + is_agenda: Agenda items are reminders that never fire. + """ + self.bot = bot + self.room_id = room_id + self.message = message + self.event_id = event_id + self.reply_to = reply_to + self.start_time = start_time + self.recur_every = recur_every + self.cron_tab = cron_tab + self.creator = creator + self.subscribed_users = subscribed_users if subscribed_users else {} + self.confirmation_event = confirmation_event + self.is_agenda = is_agenda + + # Schedule the reminder + + # TODO add agenda + trigger = None + self.job = None + + if not is_agenda: + # Determine how the reminder is triggered. + # For both once-off and recurring reminders, user date trigger (runs once). + # Recurring reminders are rescheduled when the job runs, so we don't need to worry about them here. + if cron_tab: + try: + self.cron_tab = cron_tab.removeprefix("cron") + trigger = CronTrigger.from_crontab(self.cron_tab, timezone=user_info.timezone) + except ValueError as e: + raise CommandSyntaxError(f"The crontab `{self.cron_tab}` is invalid. \n\n\t{str(e)}", + CommandSyntax.CRON_EXAMPLE) + + elif recur_every and start_time < datetime.now(tz=pytz.UTC): + # If a recurring reminder's last fire was missed, this should fix it + start_time, _ = parse_date(self.recur_every, user_info=user_info) + trigger = DateTrigger(run_date=start_time, timezone=user_info.timezone) + + elif start_time: + trigger = DateTrigger(run_date=start_time, timezone=user_info.timezone) + + # Note down the job for later manipulation + self.job = self.bot.scheduler.add_job(self._fire, trigger=trigger, id=self.event_id) + + async def _fire(self): + """Called when a reminder fires""" + logger.debug("Reminder in room %s fired: %s", self.room_id, self.message) + + user_info = await self.bot.db.get_user_info(self.creator) + + # If this is a recurring message, parse the date again and reschedule the reminder + if self.recur_every: + start_time, _ = parse_date(self.recur_every, user_info=user_info) + trigger = DateTrigger(run_date=start_time, timezone=user_info.timezone) + self.job = self.bot.scheduler.add_job(self._fire, trigger=trigger, id=self.event_id) + await self.bot.db.reschedule_reminder(start_time=start_time, event_id=self.event_id) + + + # Check if the user is rate limited + reminder_count = user_info.check_rate_limit(max_calls=self.bot.config["rate_limit"], + time_window=self.bot.config["rate_limit_minutes"]) + + + # Send the message to the room if we aren't rate limited + if reminder_count > self.bot.config["rate_limit"]: + logger.debug(f"User {self.creator} is rate limited skipping reminder: {self.message}") + else: + # Build the message with the format "(users to ping) ⬆ï¸(link to the reminder): message text [next run] + targets = list(self.subscribed_users.values()) + link = f"https://matrix.to/#/{self.room_id}/{self.event_id}" + users = " ".join([(await make_pill(user_id=user_id, client=self.bot.client)) + for user_id in targets]) + body = f"{users}: [⬆]({link}) {self.message}" + if self.recur_every or self.cron_tab: + body += f"\n\nReminding again {self.formatted_time(user_info)}" + # Warn the user before rate limiting happens + if reminder_count == self.bot.config["rate_limit"]: + body += f"\n\n*You've reached the rate limit " \ + f"({self.bot.config['rate_limit']} per {self.bot.config['rate_limit_minutes']} minutes). " \ + f"Any upcoming reminders might be ignored!*" + + # Create the message, and include all the data necessary to reschedule the reminder in org.bytemarx.reminder + content = TextMessageEventContent( + msgtype=MessageType.TEXT, body=body, format=Format.HTML, formatted_body=markdown.render(body)) + content["org.bytemarx.reminder"] = {"id": self.event_id, + "message": self.message, + "reply_to": self.reply_to} + + # Add subscribed users to MSC3952 mentions + content["m.mentions"] = {"room": True} if "@room" in targets else {"user_ids": targets} + + if self.reply_to: + content.set_reply(await self.bot.client.get_event(self.room_id, self.reply_to)) + + await self.bot.client.send_message(self.room_id, content) + + # If this was a one-time reminder, cancel and remove from the reminders dict + if not self.recur_every and not self.cron_tab: + # We set cancel_alarm to False here else the associated alarms wouldn't even fire + await self.cancel(redact_confirmation=False) + + + async def cancel(self, redact_confirmation: bool = False): + """Cancels a reminder and all recurring instances + + Args: + redact_confirmation: Whether to also redact the confirmation message + """ + logger.debug(f"Removing reminder in room {self.room_id}: {self.message}") + # Delete the reminder from the database + await self.bot.db.delete_reminder(self.event_id) + # Delete any ongoing jobs + if self.job and self.bot.scheduler.get_job(self.job.id): + self.job.remove() + if redact_confirmation and self.confirmation_event: + await self.bot.client.redact(self.room_id, self.confirmation_event) + self.bot.reminders.pop(self.event_id) + + async def add_subscriber(self, user_id: UserID, subscribing_event: EventID): + if user_id not in self.subscribed_users.values(): + self.subscribed_users[subscribing_event] = user_id + await self.bot.db.add_subscriber(reminder_id=self.event_id, + user_id=user_id, + subscribing_event=subscribing_event) + + async def remove_subscriber(self, subscribing_event: EventID): + if subscribing_event in self.subscribed_users: + await self.bot.db.remove_subscriber(subscribing_event=subscribing_event) + del self.subscribed_users[subscribing_event] + + + def formatted_time(self, user_info: UserInfo): + """ + Format the next run time. as + Cron reminders are formatted using cron_descriptor, + Args: + user_info: + Returns: + + """ + if self.is_agenda: + return format_time(self.start_time, user_info) + else: + next_run = format_time(self.job.next_run_time, user_info) + if self.cron_tab: + # TODO add languages + if USE_CRON_DESCRIPTOR: + return f"{ExpressionDescriptor(self.cron_tab, casing_type=CasingTypeEnum.LowerCase)} (`{self.cron_tab}`), next run {next_run}" + else: + return f"`{self.cron_tab}`, next run {next_run}" + elif self.recur_every: + return f"every {self.recur_every}, next run {next_run}" + else: # once-off reminders + return next_run + + async def set_confirmation(self, confirmation_event: EventID): + """ Set the confirmation message so that it can be redacted if the message is deleted""" + self.confirmation_event = confirmation_event + await self.bot.db.set_confirmation_event(event_id=self.event_id, confirmation_event=confirmation_event) diff --git a/reminder/util.py b/reminder/util.py index a87b96e9ff01a6605b31e8b051e2c053912e6d6c..26ad73c071fe2a31c400c156a46455f4254ef830 100644 --- a/reminder/util.py +++ b/reminder/util.py @@ -13,94 +13,276 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <https://www.gnu.org/licenses/>. -from typing import Optional, Dict, List, Union, Tuple, TYPE_CHECKING +from __future__ import annotations +from collections import deque + +from typing import Optional, Dict, List, Tuple, TYPE_CHECKING from datetime import datetime, timedelta from attr import dataclass -import re - +from dateparser.search import search_dates +import dateparser +import logging import pytz -from dateutil.relativedelta import relativedelta +from enum import Enum +from maubot.client import MaubotMatrixClient from mautrix.types import UserID, RoomID, EventID -from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper -from maubot import MessageEvent -from maubot.handlers.command import Argument, ArgumentSyntaxError +from maubot.handlers.command import ArgumentSyntaxError -from .locales import locales +try: + import arrow + USE_ARROW = True +except ImportError: + USE_ARROW = False if TYPE_CHECKING: - from .bot import ReminderBot + from .reminder import Reminder + +logger = logging.getLogger(__name__) + + +class CommandSyntax(Enum): + REMINDER_CREATE = """ +`!{base_command} <message containing date>` Adds a reminder by extracting the date from the text +* `!{base_command} abolish closed-access journals at 3pm tomorrow` +* `!{base_command} 8 hours buy more pumpkins` +* `!{base_command} 2023-11-30 15:00 befriend rats` + +`!{base_command} <date>; <message>` Bypasses text parsing by explicitly specifying the date +* `!{base_command} 2 days 4 hours; do something` + +`!{base_command} [room] [every] ...` +* `[room]` pings the whole room +* `[every]` create recurring reminders `!{base_command} every friday 3pm take out the trash` + +`!{base_command} [room] <cron> <message>` Schedules a reminder using a crontab syntax +* `!{base_command} cron 30 9 * * mon-fri do something` sets reminders for 9:30am, Monday through Friday. +* `!{base_command} cron` lists more examples + +You can also reply to any message with `!{base_command} ...` to get reminded about that message.\\ +To get pinged by someone else's reminder, react to their message with ðŸ‘. +""" + + AGENDA_CREATE = """ +`!{agenda_command} [room] <message>` creates an agenda item. Agenda items are like reminders but don't have a time, for things like to-do lists. + """ + REMINDER_LIST = """ +`!{base_command} list [all] [my] [subscribed]` lists all reminders in a room +* `all` lists all reminders from every room +* `my` lists only reminders you created +* `subscribed` lists only reminders you are subscribed to + """ -class Config(BaseProxyConfig): - def do_update(self, helper: ConfigUpdateHelper) -> None: - helper.copy("default_timezone") - helper.copy("base_command") + REMINDER_CANCEL = """ +Cancel reminders by removing the message creating it, unsubscribe by removing your upvote.\\ +Cancel recurring reminders by replying to the ping with `!{base_command} cancel|delete` +* `!{base_command} cancel|delete <ID>` deletes a reminder matching the 4 letter ID shown by `list` +* `!{base_command} cancel|delete <message>` deletes a reminder *beginning with* <message> + * e.g. `!remind delete buy more` would delete the reminder `buy more pumpkins` +""" + REMINDER_RESCHEDULE = """ +Reminders can be rescheduled after they have fired by replying with `!{base_command} <new date>` +""" -class DateArgument(Argument): - def __init__(self, name: str, label: str = None, *, required: bool = False): - super().__init__(name, label=label, required=required, pass_raw=True) + REMINDER_SETTINGS = """ +Dates are parsed using your [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zone) and [locale](https://dateparser.readthedocs.io/en/latest/supported_locales.html). +Defaults are `{default_tz}` and `{default_locale}` +* `!{base_command} tz|timezone [new-timezone]` view or set your timezone +* `!{base_command} locale [new-locale]` view or set your locale +""" + + SEARCH_DATE_EXAMPLES = "Example: `abolish closed-access journals at 11am on wednesday`, `8 hours buy more pumpkins`, `2023-11-30 15:00 befriend rats`" + PARSE_DATE_EXAMPLES = "Examples: `Tuesday at noon`, `8 hours`, `2023-11-30 10:15 pm`" + + + "Cancel a reminder by either redacting the message, using `!cancel <message>`, or replying to a recurring reminder with `!cancel`" + + CRON_EXAMPLE = """ +``` +* any value +, value list separator +- range of values +/ step + +┌─────── minute (0 - 59) +│ ┌─────── hour (0 - 23) +│ │ ┌─────── day of the month (1 - 31) +│ │ │ ┌─────── month (1 - 12) +│ │ │ │ ┌─────── weekday (0 - 6) (Sunday to Saturday) +│ │ │ │ │ +* * * * * <message> +``` + +``` +30 9 * * * Every day at 9:30am +0/30 9-17 * * mon-fri Every 30 minutes from 9am to 5pm, Monday through Friday +0 14 1,16 * * 2:00pm on the 1st and 16th day of the month +0 0 1-7 * mon First Monday of the month at midnight +``` + """ + + +@dataclass +class UserInfo: + locale: str = None + timezone: str = None + last_reminders: deque = deque() - def match(self, val: str, evt: MessageEvent = None, instance: 'ReminderBot' = None - ) -> Tuple[str, Optional[datetime]]: - tz = pytz.UTC - use_locales = [locales["en_iso"]] - if instance: - tz = instance.db.get_timezone(evt.sender, instance.default_timezone) - locale_ids = instance.db.get_locales(evt.sender) - use_locales = [locales[id] for id in locale_ids if id in locales] + def check_rate_limit(self, max_calls=5, time_window=60) -> int: + """ Implement a sliding window rate limit on the number of reminders per user + Args: + max_calls: + time_window: moving window size in minutes + Returns: + The number of calls within the sliding window + """ + now = datetime.now(pytz.UTC) + # remove timestamps outside the sliding window + while len(self.last_reminders) and self.last_reminders[0] + timedelta(minutes=time_window) < now: + self.last_reminders.popleft() + if len(self.last_reminders) < max_calls: + self.last_reminders.append(now) + return len(self.last_reminders) - for locale in use_locales: - match = locale.match(val) - if match: - date = (datetime.now(tz=tz) + relativedelta(**match.params)).astimezone(pytz.UTC) - return match.unconsumed, date - return val, None +class CommandSyntaxError(ValueError): + def __init__(self, message: str, command: CommandSyntax | None = None): + """ Format error messages with examples """ + super().__init__(f"{message}") + if command: + message += "\n\n" + command.value + self.message = message -def parse_timezone(val: str) -> Optional[pytz.timezone]: - if not val: - return None +def validate_timezone(tz: str) -> bool | str: try: - return pytz.timezone(val) - except pytz.UnknownTimeZoneError as e: - raise ArgumentSyntaxError(f"{val} is not a valid time zone.", show_usage=False) from e + return dateparser.utils.get_timezone_from_tz_string(tz).tzname(None) + except pytz.UnknownTimeZoneError: + return False +def validate_locale(locale: str): + try: + return dateparser.languages.loader.LocaleDataLoader().get_locale(locale) + except ValueError: + return False + +def parse_date(str_with_time: str, user_info: UserInfo, search_text: bool=False) -> Tuple[datetime, str]: + """ + Extract the date from a string. + + Args: + str_with_time: A natural language string containing a date. + user_info: contains locale and timezone to search within. + search_text: + if True, search for the date in str_with_time e.g. "Make tea in 4 hours". + if False, expect no other text within str_with_time. + + Returns: + date (datetime): The datetime of the parsed date. + date_str (str): The string containing just the parsed date, + e.g. "4 hours" for str_with_time="Make tea in 4 hours". + """ + + # Until dateparser makes it so locales can be used in the searcher, use this to get date order + date_order = validate_locale(user_info.locale).info["date_order"] + + settings = {'TIMEZONE': user_info.timezone, + 'TO_TIMEZONE': 'UTC', + 'DATE_ORDER': date_order, + 'PREFER_DATES_FROM': 'future', + 'RETURN_AS_TIMEZONE_AWARE': True} + + if search_text: + results = search_dates(str_with_time, languages=[user_info.locale.split('-')[0]], settings=settings) + if not results: + raise CommandSyntaxError("Unable to extract date from string", CommandSyntax.SEARCH_DATE_EXAMPLES) + date_str, date = results[0] + else: + date = dateparser.parse(str_with_time, locales=[user_info.locale], settings=settings) + date_str = str_with_time + if not date: + raise CommandSyntaxError(f"The given time `{str_with_time}` is invalid.", CommandSyntax.PARSE_DATE_EXAMPLES) + + # Round datetime object to the nearest second for nicer display + date = date.replace(microsecond=0) + + # Disallow times in the past + if date < datetime.now(tz=pytz.UTC): + raise CommandSyntaxError(f"Sorry, `{date}` is in the past and I don't have a time machine (yet...)") + + return date, date_str def pluralize(val: int, unit: str) -> str: if val == 1: return f"{val} {unit}" return f"{val} {unit}s" +def format_time(time: datetime, user_info: UserInfo) -> str: + """ + Format time as something readable by humans. If arrow is installed use it. + Args: + time: datetime to format + user_info: contains locale (if using arrow) and timezone + + Returns: -def format_time(time: datetime) -> str: + """ now = datetime.now(tz=pytz.UTC).replace(microsecond=0) - if time - now <= timedelta(days=7): - delta = time - now - parts = [] - if delta.days > 0: - parts.append(pluralize(delta.days, "day")) - hours, seconds = divmod(delta.seconds, 60) - hours, minutes = divmod(hours, 60) - if hours > 0: - parts.append(pluralize(hours, "hour")) - if minutes > 0: - parts.append(pluralize(minutes, "minute")) - if seconds > 0: - parts.append(pluralize(seconds, "second")) - if len(parts) == 1: - return "in " + parts[0] - return "in " + ", ".join(parts[:-1]) + f" and {parts[-1]}" - return time.strftime("at %H:%M:%S %Z on %A, %B %-d %Y") + delta = time - now + # If the date is coming up in less than a week, print the duration instead of the date + if delta <= timedelta(days=7): + if USE_ARROW: + # Try using the locale with arrow, which isn't guaranteed to be valid because dateparser has more locales + try: + formatted_time = arrow.get(time).humanize(locale=user_info.locale.split('-')[0]) + except ValueError: + formatted_time = arrow.get(time).humanize() + else: + parts = [] + + relativedelta(now, time) + if delta.days > 0: + parts.append(pluralize(delta.days, "day")) + hours, seconds = divmod(delta.seconds, 60) + hours, minutes = divmod(hours, 60) + if hours > 0: + parts.append(pluralize(hours, "hour")) + if minutes > 0: + parts.append(pluralize(minutes, "minute")) + if seconds > 0: + parts.append(pluralize(seconds, "second")) + if len(parts) == 1: + return "in " + parts[0] + return "in " + ", ".join(parts[:-1]) + f" and {parts[-1]}" + else: + formatted_time = time.astimezone( + dateparser.utils.get_timezone_from_tz_string(user_info.timezone)).strftime( + "at %I:%M%P %Z on %A, %B %d %Y") + return formatted_time + + +async def make_pill(user_id: UserID, display_name: str = None, client: MaubotMatrixClient | None = None) -> str: + """Convert a user ID (and optionally a display name) to a formatted user 'pill' + + Args: + user_id: The MXID of the user. + display_name: An optional display name. Clients like Element will figure out the + correct display name no matter what, but other clients may not. + client: mautrix client so get the display name. + Returns: + The formatted user pill. + """ + # Use the user ID as the display_name if not provided + if client and not display_name: + if user_id == "@room": + return '@room' + else: + display_name = await client.get_displayname(user_id) + + display_name = display_name or user_id + + # return f'<a href="https://matrix.to/#/{user_id}">{display_name}</a>' + return f'[{display_name}](https://matrix.to/#/{user_id})' -@dataclass -class ReminderInfo: - id: int = None - date: datetime = None - room_id: RoomID = None - event_id: EventID = None - message: str = None - reply_to: EventID = None - users: Union[Dict[UserID, EventID], List[UserID]] = None diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..844341b41c443dcc6b2cf441e20c5fb18c73836e Binary files /dev/null and b/screenshot.png differ