aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Leon Sandøy <[email protected]>2018-10-06 21:22:21 +0000
committerGravatar Leon Sandøy <[email protected]>2018-10-06 21:22:21 +0000
commita9b280e819de555a3f52d0e0f37da002122eae93 (patch)
tree4442cf1f0f88faa1b13d40413ce6242c5ca673ac
parentMerge branch 'better-moderation' into 'master' (diff)
parentAdd Reminders cog. (diff)
Merge branch 'remind-command' into 'master'
Add Reminders cog. See merge request python-discord/projects/bot!55
-rw-r--r--bot/__main__.py1
-rw-r--r--bot/cogs/moderation.py29
-rw-r--r--bot/cogs/reminders.py442
-rw-r--r--bot/constants.py7
-rw-r--r--bot/utils/scheduling.py22
-rw-r--r--bot/utils/time.py22
-rw-r--r--config-default.yml6
7 files changed, 503 insertions, 26 deletions
diff --git a/bot/__main__.py b/bot/__main__.py
index e4dbbfcde..602846ded 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -65,6 +65,7 @@ bot.load_extension("bot.cogs.information")
bot.load_extension("bot.cogs.moderation")
bot.load_extension("bot.cogs.off_topic_names")
bot.load_extension("bot.cogs.reddit")
+bot.load_extension("bot.cogs.reminders")
bot.load_extension("bot.cogs.site")
bot.load_extension("bot.cogs.snakes")
bot.load_extension("bot.cogs.snekbox")
diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py
index 79e7f0f9f..4a0e4c0f4 100644
--- a/bot/cogs/moderation.py
+++ b/bot/cogs/moderation.py
@@ -1,5 +1,4 @@
import asyncio
-import datetime
import logging
import textwrap
from typing import Dict
@@ -14,6 +13,8 @@ from bot.constants import Colours, Event, Icons, Keys, Roles, URLs
from bot.converters import InfractionSearchQuery
from bot.decorators import with_role
from bot.pagination import LinePaginator
+from bot.utils.scheduling import create_task
+from bot.utils.time import parse_rfc1123, wait_until
log = logging.getLogger(__name__)
@@ -754,10 +755,7 @@ class Moderation:
if infraction_id in self.expiration_tasks:
return
- task: asyncio.Task = asyncio.ensure_future(self._scheduled_expiration(infraction_object), loop=loop)
-
- # Silently ignore exceptions in a callback (handles the CancelledError nonsense)
- task.add_done_callback(_silent_exception)
+ task: asyncio.Task = create_task(loop, self._scheduled_expiration(infraction_object))
self.expiration_tasks[infraction_id] = task
@@ -787,12 +785,7 @@ class Moderation:
# transform expiration to delay in seconds
expiration_datetime = parse_rfc1123(infraction_object["expires_at"])
- delay = expiration_datetime - datetime.datetime.now(tz=datetime.timezone.utc)
- delay_seconds = delay.total_seconds()
-
- if delay_seconds > 1.0:
- log.debug(f"Scheduling expiration for infraction {infraction_id} in {delay_seconds} seconds")
- await asyncio.sleep(delay_seconds)
+ await wait_until(expiration_datetime)
log.debug(f"Marking infraction {infraction_id} as inactive (expired).")
await self._deactivate_infraction(infraction_object)
@@ -855,20 +848,6 @@ class Moderation:
# endregion
-RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
-
-
-def parse_rfc1123(time_str):
- return datetime.datetime.strptime(time_str, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc)
-
-
-def _silent_exception(future):
- try:
- future.exception()
- except Exception: # noqa: S110
- pass
-
-
def setup(bot):
bot.add_cog(Moderation(bot))
log.info("Cog loaded: Moderation")
diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py
new file mode 100644
index 000000000..98d7942b3
--- /dev/null
+++ b/bot/cogs/reminders.py
@@ -0,0 +1,442 @@
+import asyncio
+import datetime
+import logging
+import random
+import textwrap
+
+from aiohttp import ClientResponseError
+from dateutil.relativedelta import relativedelta
+from discord import Colour, Embed
+from discord.ext.commands import Bot, Context, group
+
+from bot.constants import (
+ Channels, Icons, Keys, NEGATIVE_REPLIES,
+ POSITIVE_REPLIES, Roles, URLs
+)
+from bot.pagination import LinePaginator
+from bot.utils.scheduling import create_task
+from bot.utils.time import humanize_delta, parse_rfc1123, wait_until
+
+log = logging.getLogger(__name__)
+
+STAFF_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers)
+WHITELISTED_CHANNELS = (Channels.bot,)
+MAXIMUM_REMINDERS = 5
+
+
+# The scheduling parts of this cog are pretty much directly copied
+# from the moderation cog. I'll be working on making it more
+# webscale:tm: as soon as possible, because this is a mess :D
+class Reminders:
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ self.headers = {"X-API-Key": Keys.site_api}
+ self.reminder_tasks = {}
+
+ async def on_ready(self):
+ # Get all the current reminders for re-scheduling
+ response = await self.bot.http_session.get(
+ url=URLs.site_reminders_api,
+ headers=self.headers
+ )
+
+ response_data = await response.json()
+
+ # Find the current time, timezone-aware.
+ now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
+ loop = asyncio.get_event_loop()
+
+ for reminder in response_data["reminders"]:
+ remind_at = parse_rfc1123(reminder["remind_at"])
+
+ # If the reminder is already overdue ...
+ if remind_at < now:
+ late = relativedelta(now, remind_at)
+ await self.send_reminder(reminder, late)
+
+ else:
+ self.schedule_reminder(loop, reminder)
+
+ @staticmethod
+ async def _send_confirmation(ctx: Context, response: dict, on_success: str):
+ """
+ Send an embed confirming whether or not a change was made successfully.
+
+ :return: A Boolean value indicating whether it failed (True) or passed (False)
+ """
+
+ embed = Embed()
+
+ if not response.get("success"):
+ embed.colour = Colour.red()
+ embed.title = random.choice(NEGATIVE_REPLIES)
+ embed.description = response.get("error_message", "An unexpected error occurred.")
+
+ log.warn(f"Unable to create/edit/delete a reminder. Response: {response}")
+ failed = True
+
+ else:
+ embed.colour = Colour.green()
+ embed.title = random.choice(POSITIVE_REPLIES)
+ embed.description = on_success
+
+ failed = False
+
+ await ctx.send(embed=embed)
+ return failed
+
+ def schedule_reminder(self, loop: asyncio.AbstractEventLoop, reminder):
+ """
+ Schedule a reminder from the bot at the requested time.
+
+ :param loop: the asyncio event loop
+ :param reminder: the data of the reminder.
+ """
+
+ # Avoid duplicate schedules, just in case.
+ reminder_id = reminder["id"]
+ if reminder_id in self.reminder_tasks:
+ return
+
+ # Make a scheduled task and add it to the list
+ task: asyncio.Task = create_task(loop, self._scheduled_reminder(reminder))
+ self.reminder_tasks[reminder_id] = task
+
+ async def _scheduled_reminder(self, reminder):
+ """
+ A coroutine which sends the reminder once the time is reached.
+
+ :param reminder: the data of the reminder.
+ :return:
+ """
+
+ reminder_id = reminder["id"]
+ reminder_datetime = parse_rfc1123(reminder["remind_at"])
+
+ # Send the reminder message once the desired duration has passed
+ await wait_until(reminder_datetime)
+ await self.send_reminder(reminder)
+
+ log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).")
+ await self._delete_reminder(reminder)
+
+ # Now we can begone with it from our schedule list.
+ self.cancel_reminder(reminder_id)
+
+ def cancel_reminder(self, reminder_id: str):
+ """
+ Un-schedules a task to send a reminder.
+
+ :param reminder_id: the ID of the reminder in question
+ """
+
+ task = self.reminder_tasks.get(reminder_id)
+
+ if task is None:
+ log.warning(f"Failed to unschedule {reminder_id}: no task found.")
+ return
+
+ task.cancel()
+ log.debug(f"Unscheduled {reminder_id}.")
+ del self.reminder_tasks[reminder_id]
+
+ async def _delete_reminder(self, reminder_id: str):
+ """
+ Delete a reminder from the database, given its ID.
+
+ :param reminder_id: The ID of the reminder.
+ """
+
+ # The API requires a list, so let's give it one :)
+ json_data = {
+ "reminders": [
+ reminder_id
+ ]
+ }
+
+ await self.bot.http_session.delete(
+ url=URLs.site_reminders_api,
+ headers=self.headers,
+ json=json_data
+ )
+
+ # Now we can remove it from the schedule list
+ self.cancel_reminder(reminder_id)
+
+ async def _reschedule_reminder(self, reminder):
+ """
+ Reschedule a reminder object.
+
+ :param reminder: The reminder to be rescheduled.
+ """
+
+ loop = asyncio.get_event_loop()
+
+ self.cancel_reminder(reminder["id"])
+ self.schedule_reminder(loop, reminder)
+
+ async def send_reminder(self, reminder, late: relativedelta = None):
+ """
+ Send the reminder.
+
+ :param reminder: The data about the reminder.
+ :param late: How late the reminder is (if at all)
+ """
+
+ channel = self.bot.get_channel(int(reminder["channel_id"]))
+ user = self.bot.get_user(int(reminder["user_id"]))
+
+ embed = Embed()
+ embed.colour = Colour.blurple()
+ embed.set_author(
+ icon_url=Icons.remind_blurple,
+ name="It has arrived!"
+ )
+
+ embed.description = f"Here's your reminder: `{reminder['content']}`"
+
+ if late:
+ embed.colour = Colour.red()
+ embed.set_author(
+ icon_url=Icons.remind_red,
+ name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!"
+ )
+
+ await channel.send(
+ content=user.mention,
+ embed=embed
+ )
+ await self._delete_reminder(reminder["id"])
+
+ @group(name="remind", aliases=("reminder", "reminders"), invoke_without_command=True)
+ async def remind_group(self, ctx: Context, duration: str, *, content: str):
+ """
+ Commands for managing your reminders.
+ """
+
+ await ctx.invoke(self.new_reminder, duration=duration, content=content)
+
+ @remind_group.command(name="new", aliases=("add", "create"))
+ async def new_reminder(self, ctx: Context, duration: str, *, content: str):
+ """
+ Set yourself a simple reminder.
+ """
+
+ embed = Embed()
+
+ # Make sure the reminder should actually be made.
+ if ctx.author.top_role.id not in STAFF_ROLES:
+
+ # If they don't have permission to set a reminder in this channel
+ if ctx.channel.id not in WHITELISTED_CHANNELS:
+ embed.colour = Colour.red()
+ embed.title = random.choice(NEGATIVE_REPLIES)
+ embed.description = "Sorry, you can't do that here!"
+
+ return await ctx.send(embed=embed)
+
+ # Get their current active reminders
+ response = await self.bot.http_session.get(
+ url=URLs.site_reminders_user_api.format(user_id=ctx.author.id),
+ headers=self.headers
+ )
+
+ active_reminders = await response.json()
+
+ # Let's limit this, so we don't get 10 000
+ # reminders from kip or something like that :P
+ if len(active_reminders) > MAXIMUM_REMINDERS:
+ embed.colour = Colour.red()
+ embed.title = random.choice(NEGATIVE_REPLIES)
+ embed.description = "You have too many active reminders!"
+
+ return await ctx.send(embed=embed)
+
+ # Now we can attempt to actually set the reminder.
+ try:
+ response = await self.bot.http_session.post(
+ url=URLs.site_reminders_api,
+ headers=self.headers,
+ json={
+ "user_id": str(ctx.author.id),
+ "duration": duration,
+ "content": content,
+ "channel_id": str(ctx.channel.id)
+ }
+ )
+
+ response_data = await response.json()
+
+ # AFAIK only happens if the user enters, like, a quintillion weeks
+ except ClientResponseError:
+ embed.colour = Colour.red()
+ embed.title = random.choice(NEGATIVE_REPLIES)
+ embed.description = (
+ "An error occurred while adding your reminder to the database. "
+ "Did you enter a reasonable duration?"
+ )
+
+ log.warn(f"User {ctx.author} attempted to create a reminder for {duration}, but failed.")
+
+ return await ctx.send(embed=embed)
+
+ # Confirm to the user whether or not it worked.
+ failed = await self._send_confirmation(
+ ctx, response_data,
+ on_success="Your reminder has been created successfully!"
+ )
+
+ # If it worked, schedule the reminder.
+ if not failed:
+ loop = asyncio.get_event_loop()
+ self.schedule_reminder(loop=loop, reminder=response_data["reminder"])
+
+ @remind_group.command(name="list")
+ async def list_reminders(self, ctx: Context):
+ """
+ View a paginated embed of all reminders for your user.
+ """
+
+ # Get all the user's reminders from the database.
+ response = await self.bot.http_session.get(
+ url=URLs.site_reminders_user_api,
+ params={"user_id": str(ctx.author.id)},
+ headers=self.headers
+ )
+
+ data = await response.json()
+ now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
+
+ # Make a list of tuples so it can be sorted by time.
+ reminders = [
+ (rem["content"], rem["remind_at"], rem["friendly_id"]) for rem in data["reminders"]
+ ]
+
+ reminders.sort(key=lambda rem: rem[1])
+
+ lines = []
+
+ for index, (content, remind_at, friendly_id) in enumerate(reminders):
+ # Parse and humanize the time, make it pretty :D
+ remind_datetime = parse_rfc1123(remind_at)
+ time = humanize_delta(relativedelta(remind_datetime, now))
+
+ text = textwrap.dedent(f"""
+ **Reminder #{index}:** *expires in {time}* (ID: {friendly_id})
+ {content}
+ """).strip()
+
+ lines.append(text)
+
+ embed = Embed()
+ embed.colour = Colour.blurple()
+ embed.title = f"Reminders for {ctx.author}"
+
+ # Remind the user that they have no reminders :^)
+ if not lines:
+ embed.description = "No active reminders could be found."
+ return await ctx.send(embed=embed)
+
+ # Construct the embed and paginate it.
+ embed.colour = Colour.blurple()
+
+ await LinePaginator.paginate(
+ lines,
+ ctx, embed,
+ max_lines=3,
+ empty=True
+ )
+
+ @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True)
+ async def edit_reminder_group(self, ctx: Context):
+ """
+ Commands for modifying your current reminders.
+ """
+
+ await ctx.invoke(self.bot.get_command("help"), "reminders", "edit")
+
+ @edit_reminder_group.command(name="duration", aliases=("time",))
+ async def edit_reminder_duration(self, ctx: Context, friendly_id: str, duration: str):
+ """
+ Edit one of your reminders' duration.
+ """
+
+ # Send the request to update the reminder in the database
+ response = await self.bot.http_session.patch(
+ url=URLs.site_reminders_user_api,
+ headers=self.headers,
+ json={
+ "user_id": str(ctx.author.id),
+ "friendly_id": friendly_id,
+ "duration": duration
+ }
+ )
+
+ # Send a confirmation message to the channel
+ response_data = await response.json()
+ failed = await self._send_confirmation(
+ ctx, response_data,
+ on_success="That reminder has been edited successfully!"
+ )
+
+ if not failed:
+ await self._reschedule_reminder(response_data["reminder"])
+
+ @edit_reminder_group.command(name="content", aliases=("reason",))
+ async def edit_reminder_content(self, ctx: Context, friendly_id: str, *, content: str):
+ """
+ Edit one of your reminders' content.
+ """
+
+ # Send the request to update the reminder in the database
+ response = await self.bot.http_session.patch(
+ url=URLs.site_reminders_user_api,
+ headers=self.headers,
+ json={
+ "user_id": str(ctx.author.id),
+ "friendly_id": friendly_id,
+ "content": content
+ }
+ )
+
+ # Send a confirmation message to the channel
+ response_data = await response.json()
+ failed = await self._send_confirmation(
+ ctx, response_data,
+ on_success="That reminder has been edited successfully!"
+ )
+
+ if not failed:
+ await self._reschedule_reminder(response_data["reminder"])
+
+ @remind_group.command("delete", aliases=("remove",))
+ async def delete_reminder(self, ctx: Context, friendly_id: str):
+ """
+ Delete one of your active reminders.
+ """
+
+ # Send the request to delete the reminder from the database
+ response = await self.bot.http_session.delete(
+ url=URLs.site_reminders_user_api,
+ headers=self.headers,
+ json={
+ "user_id": str(ctx.author.id),
+ "friendly_id": friendly_id
+ }
+ )
+
+ response_data = await response.json()
+ failed = await self._send_confirmation(
+ ctx, response_data,
+ on_success="That reminder has been deleted successfully!"
+ )
+
+ if not failed:
+ self.cancel_reminder(response_data["reminder_id"])
+
+
+def setup(bot: Bot):
+ bot.add_cog(Reminders(bot))
+ log.info("Cog loaded: Reminders")
diff --git a/bot/constants.py b/bot/constants.py
index e718eb059..2433d15ef 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -289,6 +289,10 @@ class Icons(metaclass=YAMLGetter):
pencil: str
+ remind_blurple: str
+ remind_green: str
+ remind_red: str
+
class CleanMessages(metaclass=YAMLGetter):
section = "bot"
@@ -342,7 +346,6 @@ class Roles(metaclass=YAMLGetter):
muted: int
owner: int
verified: int
- muted: int
helpers: int
@@ -397,6 +400,8 @@ class URLs(metaclass=YAMLGetter):
site_logs_view: str
site_names_api: str
site_quiz_api: str
+ site_reminders_api: str
+ site_reminders_user_api: str
site_schema: str
site_settings_api: str
site_special_api: str
diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py
new file mode 100644
index 000000000..f9b844046
--- /dev/null
+++ b/bot/utils/scheduling.py
@@ -0,0 +1,22 @@
+import asyncio
+import contextlib
+
+
+def create_task(loop: asyncio.AbstractEventLoop, coro_or_future):
+ """
+ Creates an asyncio.Task object from a coroutine or future object.
+
+ :param loop: the asyncio event loop.
+ :param coro_or_future: the coroutine or future object to be scheduled.
+ """
+
+ task: asyncio.Task = asyncio.ensure_future(coro_or_future, loop=loop)
+
+ # Silently ignore exceptions in a callback (handles the CancelledError nonsense)
+ task.add_done_callback(_silent_exception)
+ return task
+
+
+def _silent_exception(future):
+ with contextlib.suppress(Exception):
+ future.exception()
diff --git a/bot/utils/time.py b/bot/utils/time.py
index 77cef4670..8e5d4e1bd 100644
--- a/bot/utils/time.py
+++ b/bot/utils/time.py
@@ -1,7 +1,10 @@
+import asyncio
import datetime
from dateutil.relativedelta import relativedelta
+RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
+
def _stringify_time_unit(value: int, unit: str):
"""
@@ -89,3 +92,22 @@ def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max
humanized = humanize_delta(delta, precision, max_units)
return f"{humanized} ago"
+
+
+def parse_rfc1123(time_str):
+ return datetime.datetime.strptime(time_str, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc)
+
+
+# Hey, this could actually be used in the off_topic_names and reddit cogs :)
+async def wait_until(time: datetime.datetime):
+ """
+ Wait until a given time.
+
+ :param time: A datetime.datetime object to wait until.
+ """
+
+ delay = time - datetime.datetime.now(tz=datetime.timezone.utc)
+ delay_seconds = delay.total_seconds()
+
+ if delay_seconds > 1.0:
+ await asyncio.sleep(delay_seconds)
diff --git a/config-default.yml b/config-default.yml
index ce7639186..7130eb540 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -72,6 +72,10 @@ style:
pencil: "https://cdn.discordapp.com/emojis/470326272401211415.png"
+ remind_blurple: "https://cdn.discordapp.com/emojis/477907609215827968.png"
+ remind_green: "https://cdn.discordapp.com/emojis/477907607785570310.png"
+ remind_red: "https://cdn.discordapp.com/emojis/477907608057937930.png"
+
guild:
id: 267624335836053506
@@ -225,6 +229,8 @@ urls:
site_names_api: !JOIN [*SCHEMA, *API, "/bot/snake_names"]
site_off_topic_names_api: !JOIN [*SCHEMA, *API, "/bot/off-topic-names"]
site_quiz_api: !JOIN [*SCHEMA, *API, "/bot/snake_quiz"]
+ site_reminders_api: !JOIN [*SCHEMA, *API, "/bot/reminders"]
+ site_reminders_user_api: !JOIN [*SCHEMA, *API, "/bot/reminders/user"]
site_settings_api: !JOIN [*SCHEMA, *API, "/bot/settings"]
site_special_api: !JOIN [*SCHEMA, *API, "/bot/special_snakes"]
site_tags_api: !JOIN [*SCHEMA, *API, "/bot/tags"]