Skip to content
Snippets Groups Projects
Commit 51b5e4d1 authored by Sven Mäder's avatar Sven Mäder :speech_balloon:
Browse files

Modify reminder bot for ETH Zurich lunch menus

parent 779ec1fd
No related branches found
No related tags found
No related merge requests found
# ⏰ 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.
![example of interacting with the bot](screenshot.png)
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
# 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
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
This diff is collapsed.
......@@ -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)
# 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
......@@ -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)
)"""
)
......@@ -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)
......@@ -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})'
screenshot.png

108 KiB

0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment