diff --git a/README.md b/README.md index 5b3aab7851ca96648404101ef669a1dde4036791..2fbcc561ab946c0aaae7cafd3da0ae369baf020b 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,37 @@ -# â° reminder-agenda bot -A [maubot](https://github.com/maubot/maubot) to remind you about things. +# ETH Zurich Lunch Bot -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! +A [maubot](https://github.com/maubot/maubot) plugin for the canteen lunch menus at ETH Zurich. - +Forked from [reminder-agenda bot](https://github.com/MxMarx/reminder), which is +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 all 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 +* Show lunch menu (optional canteens filter) +* Persistent user config: menu language, canteens filter, price category +* Set up recurring reminders to post the lunch menu * 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. +Dependencies: + +```bash +pip install pytz +pip install dateparser +pip install apscheduler +pip install cron_descriptor +``` + +* [pytz](https://pypi.org/project/pytz/) * [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) -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 -### Creating optionally recurring reminders: -`!remind|remindme|r <date> <message>` Adds a reminder -* `!remind 8 hours buy more pumpkins` -* `!remind 2023-11-30 15:00 befriend rats` -* `!remind abolish closed-access journals at 3pm tomorrow` -* `July 2`, `tuesday at 2pm`, `8pm`, `20 days`, `4d`, `2wk`, ... -* Dates doesn't need to be at the beginning of the string, but parsing works better if they are. - -`!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 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` - -### Rescheduling -Reminders can be rescheduled after they have fired by replying with `!remind <new date>` - -### 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 - -## Cron Syntax -``` -* 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> ``` - +!lunch +!lunch config +!lunch help ``` -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 1e0fc93d830742f3291fee8ad0a4a80860ffa8b8..7e20ddc22ff7fc88d822690a10702d5505d2a14a 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -1,45 +1,40 @@ -# https://github.com/MxMarx/reminder -# Feature requests welcome! +# Default timezone +default_timezone: Europe/Zurich -# Default timezone for users who did not set one. -# 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 language (locale) `en` or `de` 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 -- remindme - -# Agenda items are like reminders but don't have a time, for things like to-do lists. -# Aliases used to create an agenda items by calling "!agenda <message>": -agenda_command: -- agenda -- todo - -# subcommands used to cancel reminders -cancel_command: -- cancel -- delete + - lunch -# If verbose is true, display full confirmation messages. If false, confirm by reacting with :thumbs-up: -verbose: true +# Hunger command (same as the `!<base_command> menu` subcommand) +hunger_command: + - hunger + - hungry + - food + - essen + - lunchmenu # Rate limit for individual users, as the number of reminders allowed to fire in an interval rate_limit: 10 rate_limit_minutes: 60 -# Power level needed to delete someone else's reminder +# Power level needed to create reminders and delete someone else's reminders admin_power_level: 50 -# time_format: strftime format when listing reminders. -# "%-I:%M%P %Z on %A, %B %-d %Y" - 7:36pm PDT on Sunday, September 17 2023 -# "%Y-%m-%d %H:%M %Z" - 2023-09-17 19:36 PDT -# Currently, reminders within 7 days will be displayed as just the relative delta, e.g. "2 days and 1 hour" -time_format: "%-I:%M%P %Z on %A, %B %-d %Y" \ No newline at end of file +# time_format: strftime format when listing reminders +time_format: "%Y-%m-%d %H:%M %Z" + +# URLs of cookpit api https://idapps.ethz.ch/cookpit-pub-services/swagger-ui/ +url_facilities: https://idapps.ethz.ch/cookpit-pub-services/v1/facilities/?client-id=ethz-wcms&rs-first=0&rs-size=50 +url_menus: https://idapps.ethz.ch/cookpit-pub-services/v1/weeklyrotas/?client-id=ethz-wcms&rs-first=0&rs-size=50 + +# Default facilities filter +default_facilities: + - food market + - fusion meal + +# Default price to show (one of `int`, `ext`, `stud` or `off`) +default_price: int diff --git a/maubot.yaml b/maubot.yaml index 11a7e7d74eccba2c5f6ca40b443540c9259db854..d80bb2edb449ab0dca887e01631a5d90fd31f1ec 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,6 +1,6 @@ maubot: 0.4.1 -id: org.bytemarx.reminder -version: 0.1.2 +id: ch.ethz.phys.lunch +version: 0.0.2 license: AGPL-3.0-or-later modules: - reminder @@ -15,4 +15,3 @@ soft_dependencies: - cron_descriptor database: true database_type: asyncpg - diff --git a/reminder/bot.py b/reminder/bot.py index 5b7a4e0f2d76353c6d2360ca95e6cd869e96bfd4..9652983c6f2b024504fb10c0e866804eac99b4a3 100644 --- a/reminder/bot.py +++ b/reminder/bot.py @@ -1,4 +1,5 @@ -# reminder - A maubot plugin to create_reminder you about things. +# ethzlunch - A maubot plugin for the canteen lunch menus at ETH Zurich. +# Copyright (C) 2024 Sven Mäder # Copyright (C) 2020 Tulir Asokan # # This program is free software: you can redistribute it and/or modify @@ -13,13 +14,11 @@ # # 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/>. -import re from typing import Type, Tuple, List, Dict -from datetime import datetime, timedelta +from datetime import date, timedelta import pytz -from mautrix.types import (EventType, RedactionEvent, StateEvent, Format, MessageType, - TextMessageEventContent, ReactionEvent, UserID, EventID, RelationType) +from mautrix.types import (EventType, RedactionEvent, StateEvent, ReactionEvent, EventID) from maubot import Plugin, MessageEvent from maubot.handlers import command, event from mautrix.util.async_db import UpgradeTable @@ -27,34 +26,39 @@ from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper from .migrations import upgrade_table from .db import ReminderDatabase -from .util import validate_locale, validate_timezone, CommandSyntaxError, parse_date, CommandSyntax, make_pill +from .util import validate_locale, validate_timezone, CommandSyntaxError from .reminder import Reminder +from .ethz import (parse_facilities, parse_menus, filter_facilities, markdown_facilities, + markdown_menus) 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("cancel_command") + helper.copy("hunger_command") helper.copy("rate_limit_minutes") helper.copy("rate_limit") - helper.copy("verbose") helper.copy("admin_power_level") helper.copy("time_format") + helper.copy("url_facilities") + helper.copy("url_menus") + helper.copy("default_price") + helper.copy("default_facilities") class ReminderBot(Plugin): base_command: Tuple[str, ...] - agenda_command: Tuple[str, ...] - cancel_command: Tuple[str, ...] + hunger_command: Tuple[str, ...] default_timezone: pytz.timezone + admin_power_level: int scheduler: AsyncIOScheduler reminders: Dict[EventID, Reminder] db: ReminderDatabase + url_facilities: str + url_menus: str @classmethod def get_config_class(cls) -> Type[BaseProxyConfig]: @@ -64,26 +68,21 @@ class ReminderBot(Plugin): def get_db_upgrade_table(cls) -> UpgradeTable: return upgrade_table - - async def start(self) -> None: self.scheduler = AsyncIOScheduler() - # self.scheduler.configure({"apscheduler.timezone": self.config["default_timezone"]}) self.scheduler.start() self.db = ReminderDatabase(self.database) 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() def config_to_tuple(list_or_str: List | str): return tuple(list_or_str) if isinstance(list_or_str, list) else (list_or_str,) self.base_command = config_to_tuple(self.config["base_command"]) - self.agenda_command = config_to_tuple(self.config["agenda_command"]) - self.cancel_command = config_to_tuple(self.config["cancel_command"]) + self.hunger_command = config_to_tuple(self.config["hunger_command"]) + self.admin_power_level = self.config["admin_power_level"] # If the locale or timezone is invalid, use default one self.db.defaults.locale = self.config["default_locale"] @@ -94,95 +93,189 @@ class ReminderBot(Plugin): if not validate_timezone(self.config["default_timezone"]): self.log.warning(f'unknown default timezone: {self.config["default_timezone"]}') self.db.defaults.timezone = "UTC" - + self.url_facilities = self.config["url_facilities"] + self.url_menus = self.config["url_menus"] + self.db.defaults.price = self.config["default_price"] + self.db.defaults.facilities = ",".join(self.config["default_facilities"]) async def stop(self) -> None: self.scheduler.shutdown(wait=False) + async def get_facilities_data(self, lang: str) -> Dict: + headers = {"Accept": "application/json"} + params = {"lang": lang} + resp = await self.http.get(self.url_facilities, headers=headers, params=params) + if resp.status == 200: + data = await resp.json() + return data + resp.raise_for_status() + return None + + async def get_menus_data(self, lang: str) -> Dict: + today = date.today().strftime("%Y-%m-%d") + tomorrow = (date.today() + timedelta(days=1)).strftime("%Y-%m-%d") + headers = {"Accept": "application/json"} + params = {"lang": lang, "valid-after": today, "valid-before": tomorrow} + resp = await self.http.get(self.url_menus, headers=headers, params=params) + if resp.status == 200: + data = await resp.json() + return data + resp.raise_for_status() + return None + + async def get_facilities(self, user: str = "") -> Dict: + lang = (await self.db.get_user_info(user)).locale + facilities_data = await self.get_facilities_data(lang) + return parse_facilities(facilities_data) + + async def get_menus(self, user: str = "", facilities_filter: str = None) -> Dict: + user_info = await self.db.get_user_info(user) + lang = user_info.locale + price = user_info.price + + facilities = await self.get_facilities(user) + menus_data = await self.get_menus_data(lang) + + if not facilities_filter: + facilities_filter = user_info.facilities + + if facilities_filter == "all": + facilities_filter = None + + if facilities_filter: + facilities = filter_facilities(facilities, facilities_filter) + + return parse_menus(menus_data, facilities, customer=price) + + async def get_markdown_facilities(self, user: str = "") -> Dict | None: + facilities = await self.get_facilities(user=user) + + if facilities: + return markdown_facilities(facilities) + else: + return None + + async def get_markdown_menus(self, user: str = "", + facilities_filter: str = None) -> Dict | None: + menus = await self.get_menus(user=user, facilities_filter=facilities_filter) + + if menus: + return markdown_menus(menus) + else: + return None + async def show_lunch_menu(self, evt: MessageEvent, canteens: str) -> None: + markdown_menus = await self.get_markdown_menus(user=evt.sender, facilities_filter=canteens) + + if markdown_menus: + await evt.respond(markdown_menus) + else: + await evt.respond("No results") + + @command.new(name=lambda self: self.hunger_command[0], + aliases=lambda self, alias: alias in self.hunger_command) + @command.argument("canteens", pass_raw=True, required=False) + async def hunger(self, evt: MessageEvent, canteens: str) -> None: + await self.show_lunch_menu(evt, canteens) @command.new(name=lambda self: self.base_command[0], - aliases=lambda self, alias: alias in self.base_command + self.agenda_command, - help="Create a reminder", require_subcommand=False, arg_fallthrough=False) - @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 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: if true, ping the whole room - cron: crontab syntax - every: is the reminder recurring? - start_time: can be explicitly specified with a semicolon: !remind <start_time>; <message> - message: contains both the start_time and the message if not using a semicolon to separate them - again: - """ - date_str = None - reply_to_id = evt.content.get_reply_to() - reply_to = None - user_info = await self.db.get_user_info(evt.sender) + aliases=lambda self, alias: alias in self.base_command) + async def lunch(self, evt: MessageEvent) -> None: + pass + + @lunch.subcommand("menu", aliases=["menus", "show"], + help="Show lunch menu (canteens example: `all` or `poly,food market,fusion`)") + @command.argument("canteens", pass_raw=True, required=False) + async def show(self, evt: MessageEvent, canteens: str) -> None: + await self.show_lunch_menu(evt, canteens) + + @lunch.subcommand("canteen", aliases=["canteens", "mensa"], help="List all canteen names") + async def facilities_list(self, evt: MessageEvent) -> None: + markdown_facilities = await self.get_markdown_facilities(user=evt.sender) + + if markdown_facilities: + await evt.respond(markdown_facilities) + else: + await evt.respond("No results") + + @lunch.subcommand("config", aliases=["conf"], help="Set or show config settings") + async def settings(self, evt: MessageEvent) -> None: + pass + + @settings.subcommand("language", aliases=["lang"], help="Set menu language (`en` or `de`)") + @command.argument("lang") + async def config_lang(self, evt: MessageEvent, lang: str) -> None: + if not lang: + await evt.reply(f"Menu language is " + f"{(await self.db.get_user_info(evt.sender)).locale}") + return + if lang in ["en", "de"]: + await self.db.set_user_info(evt.sender, key="locale", value=lang) + await evt.react("ðŸ‘") + else: + await evt.reply(f"Unknown language: `{lang}`\n" + f"Available languages: `en`, `de`") + + @settings.subcommand("canteen", aliases=["canteens", "mensa"], + help="Set canteens (example: `all` or `poly,food market,fusion`)") + @command.argument("canteens", pass_raw=True) + async def config_canteen(self, evt: MessageEvent, canteens: str) -> None: + if not canteens: + canteens = (await self.db.get_user_info(evt.sender)).facilities + await evt.reply(f"Canteen filter is: `{canteens}`") + return - # 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() + await self.db.set_user_info(evt.sender, key="facilities", value=canteens) + await evt.react("ðŸ‘") - try: - 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) + @settings.subcommand("price", help="Set price category (`int`, `ext`, `stud` or `off`)") + @command.argument("category") + async def config_price(self, evt: MessageEvent, category: str) -> None: + if not category: + category = (await self.db.get_user_info(evt.sender)).price + off = " (prices not shown)" if category == "off" else "" + await evt.reply(f"Price category is: `{category}`{off}") + return - # 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 + if category in ["int", "ext", "stud", "off"]: + await self.db.set_user_info(evt.sender, key="price", value=category) + await evt.react("ðŸ‘") + else: + await evt.reply(f"Unknown price category: `{category}`\n" + f"Available price categories: `int`, `ext`, `stud`\n" + f"Disable prices in menus: `off`") + + @lunch.subcommand("remind", help="Create reminder (time: `hh:mm`, days default: `mon-fri`)") + @command.argument("time", matches="[0-9]{1,2}:[0-9]{2}") + @command.argument("days", required=False) + @command.argument("canteens", required=False, pass_raw=True) + async def remind(self, evt: MessageEvent, + time: str = None, + days: str = None, + canteens: str = None) -> None: + power_levels = await self.client.get_state_event(room_id=evt.room_id, + event_type=EventType.ROOM_POWER_LEVELS) + user_power = power_levels.users.get(evt.sender, power_levels.users_default) - # 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() + if user_power < self.admin_power_level: + await evt.reply(f"Power level of {self.admin_power_level} is required") + return + + user_info = await self.db.get_user_info(evt.sender) + hour, minute = tuple(time.split(':')) - else: # If no arguments are supplied, return the help message - await evt.reply(self._help_message()) - return + if not days: + days = "mon-fri" - # If the reminder was created by replying to a message, use that message's text - if reply_to_id and not message: - message = reply_to.content["body"] + cron = f"{minute} {hour} * * {days}" + try: reminder = Reminder( bot=self, room_id=evt.room_id, - message=message, + message=canteens, 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, ) @@ -191,198 +284,49 @@ class ReminderBot(Plugin): await evt.reply(e.message) return - # 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) - + await self.confirm_reminder(evt, reminder) 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? - """ + async def confirm_reminder(self, evt: MessageEvent, reminder: Reminder): confirmation_event = await evt.react("\U0001F44D") - - 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)") - await reminder.set_confirmation(confirmation_event) - - # @command.new("cancel", help="Cancel a recurring reminder", aliases=("delete",)) - @create_reminder.subcommand(name=lambda self: self.cancel_command[0], - help="Cancel a recurring reminder", - aliases=lambda self, alias: alias in self.cancel_command) - @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 = [] + body = "Reminder" + if reminder.message: + body += f" for `{reminder.message}`" + body += " scheduled" + if reminder.recur_every or reminder.cron_tab: + user_info = await self.db.get_user_info(evt.sender) + formatted_time = reminder.formatted_time(user_info) + body += f" {formatted_time}" + body += ".\n\nAnyone can \U0001F44D the command message above to get pinged." + + await evt.reply(body) + + @lunch.subcommand("cancel", help="Cancel reminder") + async def cancel_reminder(self, evt: MessageEvent) -> None: + reminders = [] 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: + if "ch.ethz.phys.lunch" 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.format(base_command=self.base_command[0], - cancel_command=self.cancel_command[0], - cancel_aliases="|".join(self.cancel_command))) - return - - if not reminder: - await evt.reply(f"It doesn't look like you have any reminders matching the text `{search_text}`") - return - - 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: - 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(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: - category = "**📜 Agenda items**" - - room_link = f"https://matrix.to/#/{reminder.room_id}" if all else "" - # creator_link = await make_pill(reminder.creator) if not my else "" - - categories[category].append(f"* {short_event_id + room_link} {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: - await evt.reply(f"You have no upcoming reminders{in_room_msg} :(") - - await evt.reply(output + f"\n\n`!{self.base_command[0]} list [all] [my] [subscribed]`\\" - f"\n`!{self.base_command[0]} {self.cancel_command[0]} [4-letter ID or start of message]`") - - - - @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 `{(await self.db.get_user_info(evt.sender)).locale}`") - return - 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}") + reminders = [self.reminders[reminder_message.content["ch.ethz.phys.lunch"]["id"]]] 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)") - + reminders = [v for k, v in self.reminders.items() if v.room_id == evt.room_id] + for reminder in reminders: + 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) - @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 timezone is `{(await self.db.get_user_info(evt.sender)).timezone}`") - return - 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)") + if reminder.creator == evt.sender or user_power >= self.admin_power_level: + await reminder.cancel() + else: + await evt.reply(f"Power level of {self.admin_power_level} is required") + await evt.react("ðŸ‘") @command.passive(regex=r"(?:\U0001F44D[\U0001F3FB-\U0001F3FF]?)", field=lambda evt: evt.content.relates_to.key, @@ -396,7 +340,6 @@ class ReminderBot(Plugin): if reminder: 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: """Unsubscribe from a reminder by redacting the message""" @@ -409,41 +352,39 @@ class ReminderBot(Plugin): await reminder.cancel(redact_confirmation=True) break - @event.on(EventType.ROOM_TOMBSTONE) async def tombstone(self, evt: StateEvent) -> None: """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/MxMarx/reminder) plugin**\\ -TLDR: `!{self.base_command[0]} every friday 3pm take out the trash` `!{self.base_command[0]} {self.cancel_command[0]} take out the trash` - -**Creating optionally recurring reminders:** -{CommandSyntax.REMINDER_CREATE.value.format(base_command=self.base_command[0], - base_aliases="|".join(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[0])} - -**Deleting reminders** - -{CommandSyntax.REMINDER_CANCEL.value.format(base_command=self.base_command[0], - cancel_command=self.cancel_command[0], - cancel_aliases="|".join(self.cancel_command))} - -**Rescheduling** - -{CommandSyntax.REMINDER_RESCHEDULE.value.format(base_command=self.base_command[0])} - -**Settings** + @lunch.subcommand("help", help="Show the help") + async def help(self, evt: MessageEvent) -> None: + await evt.reply(self._help_message()) -{CommandSyntax.REMINDER_SETTINGS.value.format(base_command=self.base_command[0], - default_tz=self.db.defaults.timezone, - default_locale=self.db.defaults.locale)} -""" \ No newline at end of file + def _help_message(self) -> str: + bc = f"!{self.base_command[0]}" + hc = f"`!{'`, `!'.join(self.hunger_command)}`" + default_facilities_markdown = '\n- '.join(self.config["default_facilities"]) + return (f"Type `{bc}` for available subcommands and syntax\n\n" + f"Type `{bc} menu` to show the lunch menus of the day\n\n" + f"By default the menus for then following canteens are shown:\n" + f"- {default_facilities_markdown}\n\n" + f"Type `{bc} canteens` to show all available canteens\n\n" + f"Type `{bc} config` for configuration settings and syntax\n\n" + f"Type `{bc} config canteen <canteens>` to configure other canteens.\n\n" + f"Replace `<canteens>` with a comma-separated list of canteen names,\n" + f"a comma-separated list of sequences of characters matching parts of\n" + f"canteen names (example: `poly,food market,fusion`) or `all`.\n" + f"This will store your canteen selection and remember it for any commands\n" + f"or reminders without explicit `[canteens]` selection.\n\n" + f"Type `{bc} config language de` to show menus in German\n\n" + f"Type `{bc} config price off` to hide menu prices\n\n" + f"Type `{bc} remind 11:00` to schedule a reminder in the room.\n" + f"The bot will then send the lunch menu every weekday at the specified time.\n" + f"A power level of {self.admin_power_level} is required for reminders.\n\n" + f"React with \U0001F44D to any `{bc} remind` command message\n" + f"to get pinged (mentioned) in the reminder.\n\n" + f"Type `{bc} cancel` in a new message to cancel all reminders in the room\n" + f"or reply to a reminder to cancel a specific reminder\n\n" + f"The following commands are aliases for the `{bc} menu` subcommand: {hc}") diff --git a/reminder/db.py b/reminder/db.py index be60f75127d8b704eabbe5ba355016979cbd68ff..fdf58ec5a205e7b7401031ef45cdd3876d0050bb 100644 --- a/reminder/db.py +++ b/reminder/db.py @@ -16,17 +16,16 @@ from __future__ import annotations import logging -from typing import Optional, Iterator, Dict, List, DefaultDict +from typing import Dict, DefaultDict, Literal, TYPE_CHECKING from datetime import datetime from collections import defaultdict -from .util import validate_timezone, validate_locale, UserInfo +from .util import validate_timezone, validate_locale, validate_price, validate_facilities, UserInfo import pytz from mautrix.util.async_db import Database from mautrix.types import UserID, EventID, RoomID -from typing import Dict, Literal, TYPE_CHECKING if TYPE_CHECKING: @@ -48,20 +47,22 @@ class ReminderDatabase: 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' + UserInfo: a dataclass with keys: 'locale', 'timezone', 'price' and 'facilities' """ if user_id not in self.cache: - query = "SELECT timezone, locale FROM user_settings WHERE user_id = $1" + query = ("SELECT timezone, locale, price, facilities" + " 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) + price = row.get("price", self.defaults.price) + facilities = row.get("facilities", self.defaults.facilities) # If fetched locale is invalid, use default one if not locale or not validate_locale(locale): @@ -71,19 +72,29 @@ class ReminderDatabase: if not timezone or not validate_timezone(timezone): timezone = self.defaults.timezone - self.cache[user_id] = UserInfo(locale=locale, timezone=timezone) + if not price or not validate_price(price): + price = self.defaults.price - return self.cache[user_id] + if not facilities or not validate_facilities(facilities): + facilities = self.defaults.facilities + + self.cache[user_id] = UserInfo(locale=locale, + timezone=timezone, + price=price, + facilities=facilities) + return self.cache[user_id] - async def set_user_info(self, user_id: UserID, key: Literal["timezone", "locale"], value: str) -> None: + async def set_user_info(self, user_id: UserID, + key: Literal["timezone", "locale", "price", "facilities"], + 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}) + 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) @@ -114,15 +125,30 @@ class ReminderDatabase: 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 + 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(""" + rows_reminders = await self.db.fetch(""" + SELECT + event_id, + room_id, + message, + reply_to, + start_time, + recur_every, + cron_tab, + is_agenda, + confirmation_event, + creator + FROM reminder + """) + + rows_subscribers = await self.db.fetch(""" SELECT event_id, room_id, @@ -138,14 +164,33 @@ class ReminderDatabase: creator FROM reminder NATURAL JOIN reminder_target """) - logger.debug(f"Loaded {len(rows)} reminders") + + logger.info(f"Loaded {len(rows_reminders)} reminders" + f" with {len(rows_subscribers)} subscribers") + + rows = rows_reminders + rows_subscribers reminders = {} + for row in rows: - # 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 + if "user_id" in row.keys(): + # If a row has the key `"user_id:` it is a subscriber. + # Reminder subscribers are stored in a separate table + # instead of in an array type for sqlite support. + # So we need to handle them here and add subscribers to reminders + if row["event_id"] in reminders: + rid = row["event_id"] + sid = row["subscribing_event"] + uid = row["user_id"] + # Reminders with already existing event_id in reminders are just subscribers + reminders[rid].subscribed_users[sid] = uid + # Reminder already exists + continue + + # New reminder with subscriber + subscribed_users = {row["subscribing_event"]: row["user_id"]} + else: + # New reminder without subscriber + subscribed_users = {} start_time = datetime.fromisoformat(row["start_time"]) if row["start_time"] else None @@ -165,6 +210,8 @@ class ReminderDatabase: await self.delete_reminder(row["event_id"]) continue + logger.info(f"load reminder: {row['event_id']}") + reminders[row["event_id"]] = Reminder( bot=bot, event_id=row["event_id"], @@ -175,9 +222,9 @@ class ReminderDatabase: recur_every=row["recur_every"], cron_tab=row["cron_tab"], is_agenda=row["is_agenda"], - subscribed_users={row["subscribing_event"]: row["user_id"]}, + subscribed_users=subscribed_users, creator=row["creator"], - user_info= await self.get_user_info(row["creator"]), + user_info=await self.get_user_info(row["creator"]), confirmation_event=row["confirmation_event"], ) @@ -199,6 +246,7 @@ class ReminderDatabase: 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 @@ -231,4 +279,4 @@ class ReminderDatabase: WHERE event_id = $2 """, confirmation_event, - event_id) \ No newline at end of file + event_id) diff --git a/reminder/ethz.py b/reminder/ethz.py new file mode 100644 index 0000000000000000000000000000000000000000..b0ce478cccd9e96cdbfcccce402a925ba45ec874 --- /dev/null +++ b/reminder/ethz.py @@ -0,0 +1,126 @@ +# ethzlunch - A maubot plugin for the canteen lunch menus at ETH Zurich. +# Copyright (C) 2024 Sven Mäder +# +# 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/>. +import re +from typing import List, Dict +from datetime import date + +client_id = "?client-id=ethz-wcms" +default_meal_time_names = ["lunch", "mittag"] + + +def parse_facilities(data: Dict) -> Dict: + data = data["facility-array"] + return {d["facility-name"]: d["facility-id"] for d in data} + + +def filter_facilities(facilities: Dict, facilities_filter: str) -> Dict: + filter_list = list(filter(len, re.split(r"\s?,\s?|\s?\n+\s?", facilities_filter))) + return {k: v for k, v in facilities.items() if any(f.lower() in k.lower() for f in filter_list)} + + +def markdown_facilities(facilities: Dict) -> str: + return "\n".join(['- ' + m for m in sorted(facilities)]) + + +def parse_menus(data: Dict, facilities: Dict, customer: str = "int", # noqa: C901 + meal_time_names: List = default_meal_time_names) -> Dict: + menus = {} + data = data["weekly-rota-array"] + + for facility_name, facility_id in facilities.items(): + weekday = date.today().weekday() + menu = next(filter(lambda m: m["facility-id"] == facility_id, data), None) + + if not menu: + menus[facility_name] = None + continue + + day = menu["day-of-week-array"][weekday] + + if "opening-hour-array" in day and day["opening-hour-array"]: + oha = day["opening-hour-array"][0] + open_hours_from = oha["time-from"] + open_hours_to = oha["time-to"] + open_hours = f"{open_hours_from} - {open_hours_to}" + else: + menus[facility_name] = None + continue + + if "meal-time-array" in oha and oha["meal-time-array"]: + mta = oha["meal-time-array"] + else: + menus[facility_name] = None + continue + + for mt in mta: + mt_name = mt["name"].lower() + + if not any(mtn in mt_name for mtn in meal_time_names): + continue + + mt_from = mt["time-from"] + mt_to = mt["time-to"] + time = f"{mt_from} - {mt_to}" + meals = {} + + if "line-array" not in mt: + menus[facility_name] = {"open": open_hours, "time": time, "meals": None} + continue + + for meal in mt["line-array"]: + try: + station = meal["name"].strip() + name = meal["meal"]["name"].strip() + description = meal["meal"]["description"].strip() + image_url = "" + if "image-url" in meal["meal"]: + image_url = meal["meal"]["image-url"].strip() + image = image_url + client_id if image_url else "" + price = "" + + if "meal-price-array" in meal["meal"]: + for mp in meal["meal"]["meal-price-array"]: + if customer in mp["customer-group-desc-short"].lower(): + price = mp["price"] + + meals[station] = {"name": name, "description": description, + "price": price, "image": image} + except KeyError: + continue + + menus[facility_name] = {"open": open_hours, "time": time, "meals": meals} + + return menus + + +def markdown_menus(menus: Dict) -> str: + md = "" + + for facility, value in dict(sorted(menus.items())).items(): + time = value["time"] if value and value['meals'] else "no menu" + md += f"#### {facility.lower()} ({time})\n" + + if value and value['meals']: + for meal, value in dict(sorted(value['meals'].items())).items(): + md += "- " + md += f"**{meal.lower()}** " + md += f"[{value['name'].lower()}]({value['image']})" + if value['price']: + md += f" [{float(value['price']):.2f}]" + md += f": {value['description'].lower()}" + md += "\n" + + return md diff --git a/reminder/migrations.py b/reminder/migrations.py index 276e7fc6026e186d5e085eccecaef9b830413287..6bfb192ed34c5bb991eef9da2f7e548d3a81a078 100644 --- a/reminder/migrations.py +++ b/reminder/migrations.py @@ -33,7 +33,6 @@ async def upgrade_v1(conn: Connection, scheme: Scheme) -> None: 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) - )""" ) @@ -52,6 +51,8 @@ async def upgrade_v1(conn: Connection, scheme: Scheme) -> None: 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 */ + price TEXT, /* user's price category */ + facilities TEXT, /* user's facilities filter list */ PRIMARY KEY (user_id) )""" ) diff --git a/reminder/reminder.py b/reminder/reminder.py index 291a178e47f368c02e1296f22f6d433b998a779a..1b1cb9784a5eebae8b4e1efc3eeae37a97d70364 100644 --- a/reminder/reminder.py +++ b/reminder/reminder.py @@ -8,13 +8,11 @@ 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 + from cron_descriptor import CasingTypeEnum, ExpressionDescriptor USE_CRON_DESCRIPTOR = True except ImportError: USE_CRON_DESCRIPTOR = False @@ -24,6 +22,7 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) + class Reminder(object): def __init__( self, @@ -49,14 +48,16 @@ class Reminder(object): 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 + 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. + 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 @@ -72,22 +73,20 @@ class Reminder(object): 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. + # 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)}", + raise CommandSyntaxError(f"The crontab is invalid. \n\n\t{str(e)}", CommandSyntax.CRON_EXAMPLE) elif recur_every and start_time < datetime.now(tz=pytz.UTC): @@ -114,41 +113,64 @@ class Reminder(object): 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"]) - + rate_limit = self.bot.config["rate_limit"] + rate_limit_minutes = self.bot.config["rate_limit_minutes"] + reminder_count = user_info.check_rate_limit(max_calls=rate_limit, + time_window=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] - # Note: ï¸using "⬆ï¸" as a link seems to have trouble rendering in element for android, but "⬆" works and element on my PC still renders it as the emoji + # Build the message with the format: + # (users to ping) ⬆ï¸(link to the reminder): message text [next run] + # Note: ï¸using "⬆ï¸" as a link seems to have trouble rendering in element for android, + # but "⬆" works and element on my PC still renders it as the emoji 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]) + for user_id in targets]) + + body = "" + + if users: + body += f"{users}: " - body = f"{users}: [⬆]({link}) {self.message}" + body += f"[ðŸ”]({link}) Reminder" + + if self.message: + body += f" for `{self.message}`" if self.recur_every or self.cron_tab: - body += f"\n\nReminding again {self.formatted_time(user_info)}." \ - f" Reply `!{self.bot.base_command[0]} {self.bot.cancel_command[0]}` to stop." + body += f" {self.formatted_time(user_info)}." + # body += f" Reply `!{self.bot.base_command[0]} cancel` to stop." # 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"({self.bot.config['rate_limit']} per " \ + f"{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 + menus = await self.bot.get_markdown_menus(user=self.creator, + facilities_filter=self.message) + + if not menus: + menus = "No results" + + body += f"\n\n{menus}" + + # Create the message, and include all the data necessary to reschedule the 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} + msgtype=MessageType.TEXT, + body=body, + format=Format.HTML, + formatted_body=markdown.render(body)) + + content["ch.ethz.phys.lunch"] = {"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} @@ -163,7 +185,6 @@ class Reminder(object): # 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 @@ -192,7 +213,6 @@ class Reminder(object): 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 @@ -203,21 +223,31 @@ class Reminder(object): """ if self.is_agenda: - return format_time(self.start_time, user_info=user_info, time_format=self.bot.config['time_format']) + return format_time(self.start_time, + user_info=user_info, + time_format=self.bot.config['time_format']) else: - next_run = format_time(self.job.next_run_time, user_info=user_info, time_format=self.bot.config['time_format']) + next_run = format_time(self.job.next_run_time, + user_info=user_info, + time_format=self.bot.config['time_format']) 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}" + ed = ExpressionDescriptor(self.cron_tab, + casing_type=CasingTypeEnum.LowerCase, + use_24hour_time_format=True) + return f"{ed}" + # return f"{ed} (`{self.cron_tab}`), next run {next_run}" else: - return f"`{self.cron_tab}`, next run at {next_run}" + return f"`{self.cron_tab}`" + # return f"`{self.cron_tab}`, next run at {next_run}" elif self.recur_every: return f"every {self.recur_every}, next run at {next_run}" - else: # once-off reminders + 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) + 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 50a81016ab74699bdb2395c2ff9f57712e05197b..ac410ebab740a2dca6f752702a04679db25c287a 100644 --- a/reminder/util.py +++ b/reminder/util.py @@ -19,7 +19,7 @@ import re from itertools import islice from collections import deque -from typing import Optional, Dict, List, Tuple, TYPE_CHECKING +from typing import Tuple from datetime import datetime, timedelta from attr import dataclass from dateparser.search import search_dates @@ -29,97 +29,22 @@ import pytz from enum import Enum from maubot.client import MaubotMatrixClient -from mautrix.types import UserID, RoomID, EventID -from maubot.handlers.command import ArgumentSyntaxError - -if TYPE_CHECKING: - from .reminder import Reminder +from mautrix.types import UserID logger = logging.getLogger(__name__) class CommandSyntax(Enum): - REMINDER_CREATE = """ -`!{base_aliases} <date> <message>` Adds a reminder -* `!{base_command} 8 hours buy more pumpkins` -* `!{base_command} 2023-11-30 15:00 befriend rats` -* `!{base_command} abolish closed-access journals at 3pm tomorrow` -* `July 2`, `tuesday at 2pm`, `8pm`, `20 days`, `4d`, `2wk`, ... -* Dates doesn't need to be at the beginning of the string, but parsing works better if they are. - -`!{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 - """ - - REMINDER_CANCEL = """ -Cancel reminders by removing the message creating it, unsubscribe by removing your upvote.\\ -Cancel recurring reminders by replying with `!{base_command} {cancel_aliases}` -* `!{base_command} {cancel_aliases} <ID>` deletes a reminder matching the 4 letter ID shown by `list` -* `!{base_command} {cancel_aliases} <message>` deletes a reminder **beginning with** <message> - * e.g. `!remind {cancel_command} buy more` would delete the reminder `buy more pumpkins` -""" - - REMINDER_RESCHEDULE = """ -Reminders can be rescheduled by replying to the ping with `!{base_command} <new_date>` -""" - - 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 -""" - PARSE_DATE_EXAMPLES = "Examples: `Tuesday at noon`, `2023-11-30 10:15 pm`, `July 2`, `6 hours`, `8pm`, `4d`, `2wk`" - - 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 -``` - """ + CRON_EXAMPLE = "Valid weekday examples: `mon-fri`, `mon,tue,thu`, `mon-wed,fri`" @dataclass class UserInfo: locale: str = None timezone: str = None + price: str = None + facilities: str = None last_reminders: deque = deque() def check_rate_limit(self, max_calls=5, time_window=60) -> int: @@ -138,6 +63,7 @@ class UserInfo: self.last_reminders.append(now) return len(self.last_reminders) + class CommandSyntaxError(ValueError): def __init__(self, message: str, command: CommandSyntax | None = None): """ Format error messages with examples """ @@ -147,19 +73,30 @@ class CommandSyntaxError(ValueError): message += "\n\n" + command.value self.message = message + def validate_timezone(tz: str) -> bool | str: try: 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]: + +def validate_price(price: str) -> bool: + return price.lower() in ["int", "ext", "stud", "off"] + + +def validate_facilities(facilities: str) -> bool: + return isinstance(facilities, str) + + +def parse_date(str_with_time: str, user_info: UserInfo, search_text: bool = False) -> Tuple[datetime, str]: """ Extract the date from a string. @@ -217,11 +154,13 @@ def parse_date(str_with_time: str, user_info: UserInfo, search_text: bool=False) 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, time_format: str = "%-I:%M%P %Z on %A, %B %-d %Y") -> str: """ Format time as something readable by humans. @@ -237,23 +176,23 @@ def format_time(time: datetime, user_info: UserInfo, time_format: str = "%-I:%M% # If the date is coming up in less than a week, print the two most significant figures of the duration if abs(delta) <= timedelta(days=7): - 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")) - - formatted_time = " and ".join(parts[0:2]) - if time > now: - formatted_time = "in " + formatted_time - else: - formatted_time = formatted_time + " ago" + 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")) + + formatted_time = " and ".join(parts[0:2]) + if time > now: + formatted_time = "in " + formatted_time + else: + formatted_time = formatted_time + " ago" else: formatted_time = time.astimezone( dateparser.utils.get_timezone_from_tz_string(user_info.timezone)).strftime(time_format) @@ -282,4 +221,3 @@ async def make_pill(user_id: UserID, display_name: str = None, client: MaubotMat # return f'<a href="https://matrix.to/#/{user_id}">{display_name}</a>' return f'[{display_name}](https://matrix.to/#/{user_id})' - diff --git a/screenshot.png b/screenshot.png deleted file mode 100644 index 844341b41c443dcc6b2cf441e20c5fb18c73836e..0000000000000000000000000000000000000000 Binary files a/screenshot.png and /dev/null differ