From 5e02d5ded0b9d1947e0e9d5455b134d9e2299a7d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 19 Jun 2020 21:48:18 -0700 Subject: Scheduler: use separate logger for each instance Each instance now requires a name to be specified, which will be used as the suffix of the logger's name. This removes the need to manually prepend every log message with the name. --- bot/utils/scheduling.py | 41 +++++++++++++++++------------------------ 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 8b778a093..002ef42cf 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -7,16 +7,14 @@ from functools import partial from bot.utils import CogABCMeta -log = logging.getLogger(__name__) - class Scheduler(metaclass=CogABCMeta): """Task scheduler.""" - def __init__(self): - # Keep track of the child cog's name so the logs are clear. - self.cog_name = self.__class__.__name__ + def __init__(self, name: str): + self.name = name + self._log = logging.getLogger(f"{__name__}.{name}") self._scheduled_tasks: t.Dict[t.Hashable, asyncio.Task] = {} @abstractmethod @@ -37,19 +35,17 @@ class Scheduler(metaclass=CogABCMeta): `task_data` is passed to the `Scheduler._scheduled_task()` coroutine. """ - log.trace(f"{self.cog_name}: scheduling task #{task_id}...") + self._log.trace(f"Scheduling task #{task_id}...") if task_id in self._scheduled_tasks: - log.debug( - f"{self.cog_name}: did not schedule task #{task_id}; task was already scheduled." - ) + self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") return task = asyncio.create_task(self._scheduled_task(task_data)) task.add_done_callback(partial(self._task_done_callback, task_id)) self._scheduled_tasks[task_id] = task - log.debug(f"{self.cog_name}: scheduled task #{task_id} {id(task)}.") + self._log.debug(f"Scheduled task #{task_id} {id(task)}.") def cancel_task(self, task_id: t.Hashable, ignore_missing: bool = False) -> None: """ @@ -57,22 +53,22 @@ class Scheduler(metaclass=CogABCMeta): If `ignore_missing` is True, a warning will not be sent if a task isn't found. """ - log.trace(f"{self.cog_name}: cancelling task #{task_id}...") + self._log.trace(f"Cancelling task #{task_id}...") task = self._scheduled_tasks.get(task_id) if not task: if not ignore_missing: - log.warning(f"{self.cog_name}: failed to unschedule {task_id} (no task found).") + self._log.warning(f"Failed to unschedule {task_id} (no task found).") return del self._scheduled_tasks[task_id] task.cancel() - log.debug(f"{self.cog_name}: unscheduled task #{task_id} {id(task)}.") + self._log.debug(f"Unscheduled task #{task_id} {id(task)}.") def cancel_all(self) -> None: """Unschedule all known tasks.""" - log.debug(f"{self.cog_name}: unscheduling all tasks") + self._log.debug("Unscheduling all tasks") for task_id in self._scheduled_tasks.copy(): self.cancel_task(task_id, ignore_missing=True) @@ -84,24 +80,24 @@ class Scheduler(metaclass=CogABCMeta): If `done_task` and the task associated with `task_id` are different, then the latter will not be deleted. In this case, a new task was likely rescheduled with the same ID. """ - log.trace(f"{self.cog_name}: performing done callback for task #{task_id} {id(done_task)}.") + self._log.trace(f"Performing done callback for task #{task_id} {id(done_task)}.") scheduled_task = self._scheduled_tasks.get(task_id) if scheduled_task and done_task is scheduled_task: # A task for the ID exists and its the same as the done task. # Since this is the done callback, the task is already done so no need to cancel it. - log.trace(f"{self.cog_name}: deleting task #{task_id} {id(done_task)}.") + self._log.trace(f"Deleting task #{task_id} {id(done_task)}.") del self._scheduled_tasks[task_id] elif scheduled_task: # A new task was likely rescheduled with the same ID. - log.debug( - f"{self.cog_name}: the scheduled task #{task_id} {id(scheduled_task)} " + self._log.debug( + f"The scheduled task #{task_id} {id(scheduled_task)} " f"and the done task {id(done_task)} differ." ) elif not done_task.cancelled(): - log.warning( - f"{self.cog_name}: task #{task_id} not found while handling task {id(done_task)}! " + self._log.warning( + f"Task #{task_id} not found while handling task {id(done_task)}! " f"A task somehow got unscheduled improperly (i.e. deleted but not cancelled)." ) @@ -109,7 +105,4 @@ class Scheduler(metaclass=CogABCMeta): exception = done_task.exception() # Log the exception if one exists. if exception: - log.error( - f"{self.cog_name}: error in task #{task_id} {id(done_task)}!", - exc_info=exception - ) + self._log.error(f"Error in task #{task_id} {id(done_task)}!", exc_info=exception) -- cgit v1.2.3 From 5ded9651ab260c43053a660f2fc239aa722db5c7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 19 Jun 2020 21:59:05 -0700 Subject: Scheduler: directly take the awaitable to schedule This is a major change which simplifies the interface. It removes the need to implement an abstract method, which means the class can now be instantiated rather than subclassed. --- bot/utils/scheduling.py | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 002ef42cf..70fb1972b 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -2,13 +2,10 @@ import asyncio import contextlib import logging import typing as t -from abc import abstractmethod from functools import partial -from bot.utils import CogABCMeta - -class Scheduler(metaclass=CogABCMeta): +class Scheduler: """Task scheduler.""" def __init__(self, name: str): @@ -17,31 +14,15 @@ class Scheduler(metaclass=CogABCMeta): self._log = logging.getLogger(f"{__name__}.{name}") self._scheduled_tasks: t.Dict[t.Hashable, asyncio.Task] = {} - @abstractmethod - async def _scheduled_task(self, task_object: t.Any) -> None: - """ - A coroutine which handles the scheduling. - - This is added to the scheduled tasks, and should wait the task duration, execute the desired - code, then clean up the task. - - For example, in Reminders this will wait for the reminder duration, send the reminder, - then make a site API request to delete the reminder from the database. - """ - - def schedule_task(self, task_id: t.Hashable, task_data: t.Any) -> None: - """ - Schedules a task. - - `task_data` is passed to the `Scheduler._scheduled_task()` coroutine. - """ + def schedule_task(self, task_id: t.Hashable, task: t.Awaitable) -> None: + """Schedule the execution of a task.""" self._log.trace(f"Scheduling task #{task_id}...") if task_id in self._scheduled_tasks: self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") return - task = asyncio.create_task(self._scheduled_task(task_data)) + task = asyncio.create_task(task) task.add_done_callback(partial(self._task_done_callback, task_id)) self._scheduled_tasks[task_id] = task -- cgit v1.2.3 From 4bb6bde1c79f3ffd3d452dd7ffe489d9b093f567 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 19 Jun 2020 22:00:26 -0700 Subject: Scheduler: name tasks Makes them easier to identify when debugging. --- bot/utils/scheduling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 70fb1972b..f2640ed5e 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -22,7 +22,7 @@ class Scheduler: self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") return - task = asyncio.create_task(task) + task = asyncio.create_task(task, name=f"{self.name}_{task_id}") task.add_done_callback(partial(self._task_done_callback, task_id)) self._scheduled_tasks[task_id] = task -- cgit v1.2.3 From 5130611719735d8e58c1d0faeeeaffe4553843dd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 19 Jun 2020 22:49:41 -0700 Subject: Scheduler: add support for in operator --- bot/utils/scheduling.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index f2640ed5e..00fca4169 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -14,6 +14,10 @@ class Scheduler: self._log = logging.getLogger(f"{__name__}.{name}") self._scheduled_tasks: t.Dict[t.Hashable, asyncio.Task] = {} + def __contains__(self, task_id: t.Hashable) -> bool: + """Return True if a task with the given `task_id` is currently scheduled.""" + return task_id in self._scheduled_tasks + def schedule_task(self, task_id: t.Hashable, task: t.Awaitable) -> None: """Schedule the execution of a task.""" self._log.trace(f"Scheduling task #{task_id}...") -- cgit v1.2.3 From c81d3bdd1769a02ba02af18e52150629e655e3c9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 19 Jun 2020 23:02:24 -0700 Subject: Scheduler: use pop instead of get when cancelling --- bot/utils/scheduling.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 00fca4169..6f498348d 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -39,17 +39,17 @@ class Scheduler: If `ignore_missing` is True, a warning will not be sent if a task isn't found. """ self._log.trace(f"Cancelling task #{task_id}...") - task = self._scheduled_tasks.get(task_id) - if not task: + try: + task = self._scheduled_tasks.pop(task_id) + except KeyError: if not ignore_missing: self._log.warning(f"Failed to unschedule {task_id} (no task found).") - return - - del self._scheduled_tasks[task_id] - task.cancel() + else: + del self._scheduled_tasks[task_id] + task.cancel() - self._log.debug(f"Unscheduled task #{task_id} {id(task)}.") + self._log.debug(f"Unscheduled task #{task_id} {id(task)}.") def cancel_all(self) -> None: """Unschedule all known tasks.""" -- cgit v1.2.3 From e09276191f5bcaa0dbf34fdbff51654027528688 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 09:37:21 -0700 Subject: Scheduler: remove ignore_missing param The ability to use the `in` operator makes this obsolete. Callers can check themselves if a task exists before they try to cancel it. --- bot/utils/scheduling.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 6f498348d..d9b48034b 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -32,19 +32,14 @@ class Scheduler: self._scheduled_tasks[task_id] = task self._log.debug(f"Scheduled task #{task_id} {id(task)}.") - def cancel_task(self, task_id: t.Hashable, ignore_missing: bool = False) -> None: - """ - Unschedule the task identified by `task_id`. - - If `ignore_missing` is True, a warning will not be sent if a task isn't found. - """ + def cancel_task(self, task_id: t.Hashable) -> None: + """Unschedule the task identified by `task_id`. Log a warning if the task doesn't exist.""" self._log.trace(f"Cancelling task #{task_id}...") try: task = self._scheduled_tasks.pop(task_id) except KeyError: - if not ignore_missing: - self._log.warning(f"Failed to unschedule {task_id} (no task found).") + self._log.warning(f"Failed to unschedule {task_id} (no task found).") else: del self._scheduled_tasks[task_id] task.cancel() @@ -56,7 +51,7 @@ class Scheduler: self._log.debug("Unscheduling all tasks") for task_id in self._scheduled_tasks.copy(): - self.cancel_task(task_id, ignore_missing=True) + self.cancel_task(task_id) def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None: """ @@ -70,7 +65,7 @@ class Scheduler: scheduled_task = self._scheduled_tasks.get(task_id) if scheduled_task and done_task is scheduled_task: - # A task for the ID exists and its the same as the done task. + # A task for the ID exists and is the same as the done task. # Since this is the done callback, the task is already done so no need to cancel it. self._log.trace(f"Deleting task #{task_id} {id(done_task)}.") del self._scheduled_tasks[task_id] -- cgit v1.2.3 From 19e41aae30e19374054d9ed37f36faa2104f751c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 13:20:24 -0700 Subject: Scheduler: drop _task suffix from method names It's redundant. After all, this scheduler cannot schedule anything else. --- bot/utils/scheduling.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index d9b48034b..4a003d4fe 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -18,7 +18,7 @@ class Scheduler: """Return True if a task with the given `task_id` is currently scheduled.""" return task_id in self._scheduled_tasks - def schedule_task(self, task_id: t.Hashable, task: t.Awaitable) -> None: + def schedule(self, task_id: t.Hashable, task: t.Awaitable) -> None: """Schedule the execution of a task.""" self._log.trace(f"Scheduling task #{task_id}...") @@ -32,7 +32,7 @@ class Scheduler: self._scheduled_tasks[task_id] = task self._log.debug(f"Scheduled task #{task_id} {id(task)}.") - def cancel_task(self, task_id: t.Hashable) -> None: + def cancel(self, task_id: t.Hashable) -> None: """Unschedule the task identified by `task_id`. Log a warning if the task doesn't exist.""" self._log.trace(f"Cancelling task #{task_id}...") @@ -51,7 +51,7 @@ class Scheduler: self._log.debug("Unscheduling all tasks") for task_id in self._scheduled_tasks.copy(): - self.cancel_task(task_id) + self.cancel(task_id) def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None: """ -- cgit v1.2.3 From ee47b2afda1f8f409c1c60bd874d15b1d1a52ca6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 13:23:50 -0700 Subject: Scheduler: rename "task" param to "coroutine" Naming it "task" is inaccurate because `create_task` accepts a coroutine rather than a Task. What it does is wrap the coroutine in a Task. --- bot/utils/scheduling.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 4a003d4fe..625b726d2 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -18,15 +18,15 @@ class Scheduler: """Return True if a task with the given `task_id` is currently scheduled.""" return task_id in self._scheduled_tasks - def schedule(self, task_id: t.Hashable, task: t.Awaitable) -> None: - """Schedule the execution of a task.""" + def schedule(self, task_id: t.Hashable, coroutine: t.Coroutine) -> None: + """Schedule the execution of a coroutine.""" self._log.trace(f"Scheduling task #{task_id}...") if task_id in self._scheduled_tasks: self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") return - task = asyncio.create_task(task, name=f"{self.name}_{task_id}") + task = asyncio.create_task(coroutine, name=f"{self.name}_{task_id}") task.add_done_callback(partial(self._task_done_callback, task_id)) self._scheduled_tasks[task_id] = task -- cgit v1.2.3 From f807bf72fa649242b910e309d7043c8bdc2b1fdc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 13:54:20 -0700 Subject: Scheduler: add a method to schedule with a delay --- bot/utils/scheduling.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 625b726d2..ac67278f6 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -32,6 +32,10 @@ class Scheduler: self._scheduled_tasks[task_id] = task self._log.debug(f"Scheduled task #{task_id} {id(task)}.") + def schedule_later(self, delay: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None: + """Schedule `coroutine` to be executed after the given `delay` number of seconds.""" + self.schedule(task_id, self._await_later(delay, coroutine)) + def cancel(self, task_id: t.Hashable) -> None: """Unschedule the task identified by `task_id`. Log a warning if the task doesn't exist.""" self._log.trace(f"Cancelling task #{task_id}...") @@ -53,6 +57,21 @@ class Scheduler: for task_id in self._scheduled_tasks.copy(): self.cancel(task_id) + async def _await_later(self, delay: t.Union[int, float], coroutine: t.Coroutine) -> None: + """Await `coroutine` after the given `delay` number of seconds.""" + try: + self._log.trace(f"Waiting {delay} seconds before awaiting the coroutine.") + await asyncio.sleep(delay) + + # Use asyncio.shield to prevent the coroutine from cancelling itself. + self._log.trace("Done waiting; now awaiting the coroutine.") + await asyncio.shield(coroutine) + finally: + # Close it to prevent unawaited coroutine warnings, + # which would happen if the task was cancelled during the sleep. + self._log.trace("Explicitly closing the coroutine.") + coroutine.close() + def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None: """ Delete the task and raise its exception if one exists. -- cgit v1.2.3 From dfcf71f36c85e357028ea2f86aac7e38c6b8ab47 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 14:02:23 -0700 Subject: Scheduler: add a method to schedule at a specific datetime --- bot/utils/scheduling.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index ac67278f6..f5308059a 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -2,6 +2,7 @@ import asyncio import contextlib import logging import typing as t +from datetime import datetime from functools import partial @@ -32,6 +33,18 @@ class Scheduler: self._scheduled_tasks[task_id] = task self._log.debug(f"Scheduled task #{task_id} {id(task)}.") + def schedule_at(self, time: datetime, task_id: t.Hashable, coroutine: t.Coroutine) -> None: + """ + Schedule `coroutine` to be executed at the given naïve UTC `time`. + + If `time` is in the past, schedule `coroutine` immediately. + """ + delay = (time - datetime.utcnow()).total_seconds() + if delay > 0: + coroutine = self._await_later(delay, coroutine) + + self.schedule(task_id, coroutine) + def schedule_later(self, delay: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None: """Schedule `coroutine` to be executed after the given `delay` number of seconds.""" self.schedule(task_id, self._await_later(delay, coroutine)) -- cgit v1.2.3 From f2f4b425dc8988ffaf9b1ebe8c2a5b449a50a48e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 16:25:48 -0700 Subject: Update Filtering's scheduler to the new API --- bot/cogs/filtering.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 76ea68660..099606b82 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -19,7 +19,6 @@ from bot.constants import ( ) from bot.utils.redis_cache import RedisCache from bot.utils.scheduling import Scheduler -from bot.utils.time import wait_until log = logging.getLogger(__name__) @@ -60,7 +59,7 @@ def expand_spoilers(text: str) -> str: OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) -class Filtering(Cog, Scheduler): +class Filtering(Cog): """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" # Redis cache mapping a user ID to the last timestamp a bad nickname alert was sent @@ -68,8 +67,7 @@ class Filtering(Cog, Scheduler): def __init__(self, bot: Bot): self.bot = bot - super().__init__() - + self.scheduler = Scheduler(self.__class__.__name__) self.name_lock = asyncio.Lock() staff_mistake_str = "If you believe this was a mistake, please let staff know!" @@ -268,7 +266,7 @@ class Filtering(Cog, Scheduler): } await self.bot.api_client.post('bot/offensive-messages', json=data) - self.schedule_task(msg.id, data) + self.schedule_msg_delete(data) log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") if is_private: @@ -457,12 +455,10 @@ class Filtering(Cog, Scheduler): except discord.errors.Forbidden: await channel.send(f"{filtered_member.mention} {reason}") - async def _scheduled_task(self, msg: dict) -> None: + def schedule_msg_delete(self, msg: dict) -> None: """Delete an offensive message once its deletion date is reached.""" delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) - - await wait_until(delete_at) - await self.delete_offensive_msg(msg) + self.scheduler.schedule_at(delete_at, msg['id'], self.delete_offensive_msg(msg)) async def reschedule_offensive_msg_deletion(self) -> None: """Get all the pending message deletion from the API and reschedule them.""" @@ -477,7 +473,7 @@ class Filtering(Cog, Scheduler): if delete_at < now: await self.delete_offensive_msg(msg) else: - self.schedule_task(msg['id'], msg) + self.schedule_msg_delete(msg) async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None: """Delete an offensive message, and then delete it from the db.""" -- cgit v1.2.3 From 90f0cb34cefdc362336cfb27b2e94f8925f312f4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 16:42:26 -0700 Subject: Update Reminders's scheduler to the new API --- bot/cogs/reminders.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index c242d2920..0d20bdb2b 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -17,7 +17,7 @@ from bot.converters import Duration from bot.pagination import LinePaginator from bot.utils.checks import without_role_check from bot.utils.scheduling import Scheduler -from bot.utils.time import humanize_delta, wait_until +from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -25,12 +25,12 @@ WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 -class Reminders(Scheduler, Cog): +class Reminders(Cog): """Provide in-channel reminder functionality.""" def __init__(self, bot: Bot): self.bot = bot - super().__init__() + self.scheduler = Scheduler(self.__class__.__name__) self.bot.loop.create_task(self.reschedule_reminders()) @@ -56,7 +56,7 @@ class Reminders(Scheduler, Cog): late = relativedelta(now, remind_at) await self.send_reminder(reminder, late) else: - self.schedule_task(reminder["id"], reminder) + self.schedule_reminder(reminder) def ensure_valid_reminder( self, @@ -99,17 +99,18 @@ class Reminders(Scheduler, Cog): await ctx.send(embed=embed) - async def _scheduled_task(self, reminder: dict) -> None: + def schedule_reminder(self, reminder: dict) -> None: """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" reminder_id = reminder["id"] reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) - # Send the reminder message once the desired duration has passed - await wait_until(reminder_datetime) - await self.send_reminder(reminder) + async def _remind() -> None: + await self.send_reminder(reminder) - log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") - await self._delete_reminder(reminder_id) + log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") + await self._delete_reminder(reminder_id) + + self.scheduler.schedule_at(reminder_datetime, reminder_id, _remind()) async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None: """Delete a reminder from the database, given its ID, and cancel the running task.""" @@ -117,15 +118,15 @@ class Reminders(Scheduler, Cog): if cancel_task: # Now we can remove it from the schedule list - self.cancel_task(reminder_id) + self.scheduler.cancel(reminder_id) async def _reschedule_reminder(self, reminder: dict) -> None: """Reschedule a reminder object.""" log.trace(f"Cancelling old task #{reminder['id']}") - self.cancel_task(reminder["id"]) + self.scheduler.cancel(reminder["id"]) log.trace(f"Scheduling new task #{reminder['id']}") - self.schedule_task(reminder["id"], reminder) + self.schedule_reminder(reminder) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" @@ -223,7 +224,7 @@ class Reminders(Scheduler, Cog): delivery_dt=expiration, ) - self.schedule_task(reminder["id"], reminder) + self.schedule_reminder(reminder) @remind_group.command(name="list") async def list_reminders(self, ctx: Context) -> t.Optional[discord.Message]: -- cgit v1.2.3 From 6c76a04dab61de0ae4ea786c97f160805640d0c5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 16:45:10 -0700 Subject: Update Silence's scheduler to the new API --- bot/cogs/moderation/silence.py | 32 ++++++-------------------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index c8ab6443b..ae4fb7b64 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -1,7 +1,7 @@ import asyncio import logging from contextlib import suppress -from typing import NamedTuple, Optional +from typing import Optional from discord import TextChannel from discord.ext import commands, tasks @@ -16,13 +16,6 @@ from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) -class TaskData(NamedTuple): - """Data for a scheduled task.""" - - delay: int - ctx: Context - - class SilenceNotifier(tasks.Loop): """Loop notifier for posting notices to `alert_channel` containing added channels.""" @@ -61,25 +54,17 @@ class SilenceNotifier(tasks.Loop): await self._alert_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}") -class Silence(Scheduler, commands.Cog): +class Silence(commands.Cog): """Commands for stopping channel messages for `verified` role in a channel.""" def __init__(self, bot: Bot): - super().__init__() self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) self.muted_channels = set() + self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) self._get_instance_vars_event = asyncio.Event() - async def _scheduled_task(self, task: TaskData) -> None: - """Calls `self.unsilence` on expired silenced channel to unsilence it.""" - await asyncio.sleep(task.delay) - log.info("Unsilencing channel after set delay.") - - # Because `self.unsilence` explicitly cancels this scheduled task, it is shielded - # to avoid prematurely cancelling itself - await asyncio.shield(task.ctx.invoke(self.unsilence)) - async def _get_instance_vars(self) -> None: """Get instance variables after they're available to get from the guild.""" await self.bot.wait_until_guild_available() @@ -109,12 +94,7 @@ class Silence(Scheduler, commands.Cog): await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") - task_data = TaskData( - delay=duration*60, - ctx=ctx - ) - - self.schedule_task(ctx.channel.id, task_data) + self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: @@ -164,7 +144,7 @@ class Silence(Scheduler, commands.Cog): if current_overwrite.send_messages is False: await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=None)) log.info(f"Unsilenced channel #{channel} ({channel.id}).") - self.cancel_task(channel.id) + self.scheduler.cancel(channel.id) self.notifier.remove_channel(channel) self.muted_channels.discard(channel) return True -- cgit v1.2.3 From 0e69211295c6d7656b776870aa2bd8aab9244f5f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 16:56:14 -0700 Subject: Update HelpChannels's scheduler to the new API --- bot/cogs/help_channels.py | 70 ++++++++++++++--------------------------------- 1 file changed, 20 insertions(+), 50 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 187adfe51..93ef07c84 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -1,5 +1,4 @@ import asyncio -import inspect import json import logging import random @@ -57,14 +56,7 @@ through our guide for [asking a good question]({ASKING_GUIDE_URL}). CoroutineFunc = t.Callable[..., t.Coroutine] -class TaskData(t.NamedTuple): - """Data for a scheduled task.""" - - wait_time: int - callback: t.Awaitable - - -class HelpChannels(Scheduler, commands.Cog): +class HelpChannels(commands.Cog): """ Manage the help channel system of the guild. @@ -114,9 +106,8 @@ class HelpChannels(Scheduler, commands.Cog): claim_times = RedisCache() def __init__(self, bot: Bot): - super().__init__() - self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) # Categories self.available_category: discord.CategoryChannel = None @@ -145,7 +136,7 @@ class HelpChannels(Scheduler, commands.Cog): for task in self.queue_tasks: task.cancel() - self.cancel_all() + self.scheduler.cancel_all() def create_channel_queue(self) -> asyncio.Queue: """ @@ -229,10 +220,11 @@ class HelpChannels(Scheduler, commands.Cog): await self.remove_cooldown_role(ctx.author) # Ignore missing task when cooldown has passed but the channel still isn't dormant. - self.cancel_task(ctx.author.id, ignore_missing=True) + if ctx.author.id in self.scheduler: + self.scheduler.cancel(ctx.author.id) await self.move_to_dormant(ctx.channel, "command") - self.cancel_task(ctx.channel.id) + self.scheduler.cancel(ctx.channel.id) else: log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") @@ -474,16 +466,15 @@ class HelpChannels(Scheduler, commands.Cog): else: # Cancel the existing task, if any. if has_task: - self.cancel_task(channel.id) - - data = TaskData(idle_seconds - time_elapsed, self.move_idle_channel(channel)) + self.scheduler.cancel(channel.id) + delay = idle_seconds - time_elapsed log.info( f"#{channel} ({channel.id}) is still active; " - f"scheduling it to be moved after {data.wait_time} seconds." + f"scheduling it to be moved after {delay} seconds." ) - self.schedule_task(channel.id, data) + self.scheduler.schedule_later(delay, channel.id, self.move_idle_channel(channel)) async def move_to_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None: """ @@ -588,8 +579,7 @@ class HelpChannels(Scheduler, commands.Cog): timeout = constants.HelpChannels.idle_minutes * 60 log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.") - data = TaskData(timeout, self.move_idle_channel(channel)) - self.schedule_task(channel.id, data) + self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel)) self.report_stats() async def notify(self) -> None: @@ -722,10 +712,10 @@ class HelpChannels(Scheduler, commands.Cog): log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") # Cancel existing dormant task before scheduling new. - self.cancel_task(msg.channel.id) + self.scheduler.cancel(msg.channel.id) - task = TaskData(constants.HelpChannels.deleted_idle_minutes * 60, self.move_idle_channel(msg.channel)) - self.schedule_task(msg.channel.id, task) + delay = constants.HelpChannels.deleted_idle_minutes * 60 + self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) async def is_empty(self, channel: discord.TextChannel) -> bool: """Return True if the most recent message in `channel` is the bot's `AVAILABLE_MSG`.""" @@ -752,8 +742,8 @@ class HelpChannels(Scheduler, commands.Cog): await self.remove_cooldown_role(member) else: # The member is still on a cooldown; re-schedule it for the remaining time. - remaining = cooldown - in_use_time.seconds - await self.schedule_cooldown_expiration(member, remaining) + delay = cooldown - in_use_time.seconds + self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) async def add_cooldown_role(self, member: discord.Member) -> None: """Add the help cooldown role to `member`.""" @@ -804,16 +794,11 @@ class HelpChannels(Scheduler, commands.Cog): # Cancel the existing task, if any. # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). - self.cancel_task(member.id, ignore_missing=True) + if member.id in self.scheduler: + self.scheduler.cancel(member.id) - await self.schedule_cooldown_expiration(member, constants.HelpChannels.claim_minutes * 60) - - async def schedule_cooldown_expiration(self, member: discord.Member, seconds: int) -> None: - """Schedule the cooldown role for `member` to be removed after a duration of `seconds`.""" - log.trace(f"Scheduling removal of {member}'s ({member.id}) cooldown.") - - callback = self.remove_cooldown_role(member) - self.schedule_task(member.id, TaskData(seconds, callback)) + delay = constants.HelpChannels.claim_minutes * 60 + self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) async def send_available_message(self, channel: discord.TextChannel) -> None: """Send the available message by editing a dormant message or sending a new message.""" @@ -855,21 +840,6 @@ class HelpChannels(Scheduler, commands.Cog): return channel - async def _scheduled_task(self, data: TaskData) -> None: - """Await the `data.callback` coroutine after waiting for `data.wait_time` seconds.""" - try: - log.trace(f"Waiting {data.wait_time} seconds before awaiting callback.") - await asyncio.sleep(data.wait_time) - - # Use asyncio.shield to prevent callback from cancelling itself. - # The parent task (_scheduled_task) will still get cancelled. - log.trace("Done waiting; now awaiting the callback.") - await asyncio.shield(data.callback) - finally: - if inspect.iscoroutine(data.callback): - log.trace("Explicitly closing coroutine.") - data.callback.close() - def validate_config() -> None: """Raise a ValueError if the cog's config is invalid.""" -- cgit v1.2.3 From 23e663d5ff992d13a7685b44f09da0f21b390b0c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 17:17:56 -0700 Subject: Update InfractionScheduler's scheduler to the new API --- bot/cogs/moderation/management.py | 4 ++-- bot/cogs/moderation/scheduler.py | 23 +++++++++-------------- bot/cogs/moderation/superstarify.py | 2 +- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index c39c7f3bc..e87f3d7a4 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -135,11 +135,11 @@ class ModManagement(commands.Cog): if 'expires_at' in request_data: # A scheduled task should only exist if the old infraction wasn't permanent if old_infraction['expires_at']: - self.infractions_cog.cancel_task(new_infraction['id']) + self.infractions_cog.scheduler.cancel(new_infraction['id']) # If the infraction was not marked as permanent, schedule a new expiration task if request_data['expires_at']: - self.infractions_cog.schedule_task(new_infraction['id'], new_infraction) + self.infractions_cog.scheduler.schedule(new_infraction['id'], new_infraction) log_text += f""" Previous expiry: {old_infraction['expires_at'] or "Permanent"} diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index d75a72ddb..601e238c9 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -1,4 +1,3 @@ -import asyncio import logging import textwrap import typing as t @@ -23,13 +22,13 @@ from .utils import UserSnowflake log = logging.getLogger(__name__) -class InfractionScheduler(Scheduler): +class InfractionScheduler: """Handles the application, pardoning, and expiration of infractions.""" def __init__(self, bot: Bot, supported_infractions: t.Container[str]): - super().__init__() - self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) + self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) @property @@ -49,7 +48,7 @@ class InfractionScheduler(Scheduler): ) for infraction in infractions: if infraction["expires_at"] is not None and infraction["type"] in supported_infractions: - self.schedule_task(infraction["id"], infraction) + self.schedule_expiration(infraction) async def reapply_infraction( self, @@ -155,7 +154,7 @@ class InfractionScheduler(Scheduler): await action_coro if expiry: # Schedule the expiration of the infraction. - self.schedule_task(infraction["id"], infraction) + self.schedule_expiration(infraction) except discord.HTTPException as e: # Accordingly display that applying the infraction failed. confirm_msg = ":x: failed to apply" @@ -278,7 +277,7 @@ class InfractionScheduler(Scheduler): # Cancel pending expiration task. if infraction["expires_at"] is not None: - self.cancel_task(infraction["id"]) + self.scheduler.cancel(infraction["id"]) # Accordingly display whether the user was successfully notified via DM. dm_emoji = "" @@ -415,7 +414,7 @@ class InfractionScheduler(Scheduler): # Cancel the expiration task. if infraction["expires_at"] is not None: - self.cancel_task(infraction["id"]) + self.scheduler.cancel(infraction["id"]) # Send a log message to the mod log. if send_log: @@ -449,7 +448,7 @@ class InfractionScheduler(Scheduler): """ raise NotImplementedError - async def _scheduled_task(self, infraction: utils.Infraction) -> None: + def schedule_expiration(self, infraction: utils.Infraction) -> None: """ Marks an infraction expired after the delay from time of scheduling to time of expiration. @@ -457,8 +456,4 @@ class InfractionScheduler(Scheduler): expiration task is cancelled. """ expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) - await time.wait_until(expiry) - - # Because deactivate_infraction() explicitly cancels this scheduled task, it is shielded - # to avoid prematurely cancelling itself. - await asyncio.shield(self.deactivate_infraction(infraction)) + self.scheduler.schedule_at(expiry, infraction["id"], self.deactivate_infraction(infraction)) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 45a010f00..867de815a 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -146,7 +146,7 @@ class Superstarify(InfractionScheduler, Cog): log.debug(f"Changing nickname of {member} to {forced_nick}.") self.mod_log.ignore(constants.Event.member_update, member.id) await member.edit(nick=forced_nick, reason=reason) - self.schedule_task(id_, infraction) + self.schedule_expiration(infraction) # Send a DM to the user to notify them of their new infraction. await utils.notify_infraction( -- cgit v1.2.3 From 58d20203870f293de9410db4bf0e602696d04c2c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 23 Jun 2020 23:52:50 -0700 Subject: Scheduler: close coroutine if task ID already exists This prevents unawaited coroutine warnings. --- bot/utils/scheduling.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index f5308059a..4e99db76c 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -20,11 +20,17 @@ class Scheduler: return task_id in self._scheduled_tasks def schedule(self, task_id: t.Hashable, coroutine: t.Coroutine) -> None: - """Schedule the execution of a coroutine.""" + """ + Schedule the execution of a coroutine. + + If a task with `task_id` already exists, close `coroutine` instead of scheduling it. + This prevents unawaited coroutine warnings. + """ self._log.trace(f"Scheduling task #{task_id}...") if task_id in self._scheduled_tasks: self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") + coroutine.close() return task = asyncio.create_task(coroutine, name=f"{self.name}_{task_id}") -- cgit v1.2.3 From bc6817536a7db4242cfa725ce809ced45f7cb556 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 24 Jun 2020 16:46:14 -0700 Subject: Scheduler: remove duplicate dict delete The task is already popped from the dict, so there is no need to delete it afterwards. --- bot/utils/scheduling.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 4e99db76c..4110598d5 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -64,7 +64,6 @@ class Scheduler: except KeyError: self._log.warning(f"Failed to unschedule {task_id} (no task found).") else: - del self._scheduled_tasks[task_id] task.cancel() self._log.debug(f"Unscheduled task #{task_id} {id(task)}.") -- cgit v1.2.3 From e09307e0f8f570279271c99525e0cde6cfa84d5b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 25 Jun 2020 11:51:19 -0700 Subject: Scheduler: only close unawaited coroutines The coroutine may cancel the scheduled task, which would also trigger the finally block. The coroutine isn't necessarily finished when it cancels the task, so it shouldn't be closed in this case. --- bot/utils/scheduling.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 4110598d5..cf2a1f110 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -1,5 +1,6 @@ import asyncio import contextlib +import inspect import logging import typing as t from datetime import datetime @@ -87,8 +88,11 @@ class Scheduler: finally: # Close it to prevent unawaited coroutine warnings, # which would happen if the task was cancelled during the sleep. - self._log.trace("Explicitly closing the coroutine.") - coroutine.close() + # Only close it if it's not been awaited yet. This check is important because the + # coroutine may cancel this task, which would also trigger the finally block. + if inspect.getcoroutinestate(coroutine) == "CORO_CREATED": + self._log.trace("Explicitly closing the coroutine.") + coroutine.close() def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None: """ -- cgit v1.2.3 From 4fd2ff500cd889c1086334e82f695857689ae328 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 29 Jun 2020 19:11:52 -0700 Subject: Scheduler: add details to class docstring --- bot/utils/scheduling.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index cf2a1f110..fc453f19e 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -8,7 +8,17 @@ from functools import partial class Scheduler: - """Task scheduler.""" + """ + Schedule the execution of coroutines and keep track of them. + + Coroutines can be scheduled immediately with `schedule` or in the future with `schedule_at` + or `schedule_later`. A unique ID is required to be given in order to keep track of the + resulting Tasks. Any scheduled task can be cancelled prematurely using `cancel` by providing + the same ID used to schedule it. The `in` operator is supported for checking if a task with a + given ID is currently scheduled. + + Any exception raised in a scheduled task is logged when the task is done. + """ def __init__(self, name: str): self.name = name -- cgit v1.2.3 From c641f7fbbebd4c4c18539c32eb3d3907c8e71dee Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 29 Jun 2020 19:15:43 -0700 Subject: Scheduler: explain the name param in the docstring --- bot/utils/scheduling.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index fc453f19e..0987c5de8 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -11,6 +11,10 @@ class Scheduler: """ Schedule the execution of coroutines and keep track of them. + When instantiating a Scheduler, a name must be provided. This name is used to distinguish the + instance's log messages from other instances. Using the name of the class or module containing + the instance is suggested. + Coroutines can be scheduled immediately with `schedule` or in the future with `schedule_at` or `schedule_later`. A unique ID is required to be given in order to keep track of the resulting Tasks. Any scheduled task can be cancelled prematurely using `cancel` by providing -- cgit v1.2.3 From da93dc5d2eb06eae05c6180de2bd66f3fca90c1d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 30 Jun 2020 18:41:44 -0700 Subject: Scheduler: more verbose logging in _await_later Showing the task ID in the logs makes them distinguishable from logs for other tasks. The coroutine state is logged because it may come in handy while debugging; the coroutine inspection check hasn't been proven yet in production. --- bot/utils/scheduling.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 0987c5de8..9fc519393 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -62,13 +62,13 @@ class Scheduler: """ delay = (time - datetime.utcnow()).total_seconds() if delay > 0: - coroutine = self._await_later(delay, coroutine) + coroutine = self._await_later(delay, task_id, coroutine) self.schedule(task_id, coroutine) def schedule_later(self, delay: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None: """Schedule `coroutine` to be executed after the given `delay` number of seconds.""" - self.schedule(task_id, self._await_later(delay, coroutine)) + self.schedule(task_id, self._await_later(delay, task_id, coroutine)) def cancel(self, task_id: t.Hashable) -> None: """Unschedule the task identified by `task_id`. Log a warning if the task doesn't exist.""" @@ -90,23 +90,26 @@ class Scheduler: for task_id in self._scheduled_tasks.copy(): self.cancel(task_id) - async def _await_later(self, delay: t.Union[int, float], coroutine: t.Coroutine) -> None: + async def _await_later(self, delay: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None: """Await `coroutine` after the given `delay` number of seconds.""" try: - self._log.trace(f"Waiting {delay} seconds before awaiting the coroutine.") + self._log.trace(f"Waiting {delay} seconds before awaiting coroutine for #{task_id}.") await asyncio.sleep(delay) # Use asyncio.shield to prevent the coroutine from cancelling itself. - self._log.trace("Done waiting; now awaiting the coroutine.") + self._log.trace(f"Done waiting for #{task_id}; now awaiting the coroutine.") await asyncio.shield(coroutine) finally: # Close it to prevent unawaited coroutine warnings, # which would happen if the task was cancelled during the sleep. # Only close it if it's not been awaited yet. This check is important because the # coroutine may cancel this task, which would also trigger the finally block. - if inspect.getcoroutinestate(coroutine) == "CORO_CREATED": - self._log.trace("Explicitly closing the coroutine.") + state = inspect.getcoroutinestate(coroutine) + if state == "CORO_CREATED": + self._log.debug(f"Explicitly closing the coroutine for #{task_id}.") coroutine.close() + else: + self._log.debug(f"Finally block reached for #{task_id}; {state=}") def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None: """ -- cgit v1.2.3 From 14cfd1e9dd4d149fb554b84969fed27f85ad5361 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 6 Jul 2020 10:09:03 -0700 Subject: Scheduler: assert the coroutine hasn't been awaited yet It'd fail to schedule the coroutine otherwise anyway. There is also the potential to close the coroutine, which may be unexpected to see for a coroutine that was already running (despite being documented). --- bot/utils/scheduling.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 9fc519393..fddb0c2fe 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -43,6 +43,9 @@ class Scheduler: """ self._log.trace(f"Scheduling task #{task_id}...") + msg = f"Cannot schedule an already started coroutine for #{task_id}" + assert inspect.getcoroutinestate(coroutine) == "CORO_CREATED", msg + if task_id in self._scheduled_tasks: self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") coroutine.close() -- cgit v1.2.3 From 30114ac8c118220b743d4a91f737f8ad973eeb9c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 6 Jul 2020 10:10:47 -0700 Subject: Scheduler: document coroutine closing elsewhere --- bot/utils/scheduling.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index fddb0c2fe..03f31d78f 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -36,10 +36,10 @@ class Scheduler: def schedule(self, task_id: t.Hashable, coroutine: t.Coroutine) -> None: """ - Schedule the execution of a coroutine. + Schedule the execution of a `coroutine`. - If a task with `task_id` already exists, close `coroutine` instead of scheduling it. - This prevents unawaited coroutine warnings. + If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This + prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere. """ self._log.trace(f"Scheduling task #{task_id}...") @@ -62,6 +62,9 @@ class Scheduler: Schedule `coroutine` to be executed at the given naïve UTC `time`. If `time` is in the past, schedule `coroutine` immediately. + + If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This + prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere. """ delay = (time - datetime.utcnow()).total_seconds() if delay > 0: @@ -70,7 +73,12 @@ class Scheduler: self.schedule(task_id, coroutine) def schedule_later(self, delay: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None: - """Schedule `coroutine` to be executed after the given `delay` number of seconds.""" + """ + Schedule `coroutine` to be executed after the given `delay` number of seconds. + + If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This + prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere. + """ self.schedule(task_id, self._await_later(delay, task_id, coroutine)) def cancel(self, task_id: t.Hashable) -> None: -- cgit v1.2.3 From 210c0a09b1bced80d03ed9ac81845f5f94c8b687 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 13:23:22 +0200 Subject: Ping @Moderators in ModLog Instead of pinging @everyone, let's just ping the people who actually need to see the mod alerts or the modlogs, which would be the mods. `@everyone` is currently not permitted by our allowed_mentions setting, so this also restores pings to those channels. GitHub #1038 https://github.com/python-discord/bot/issues/1038 --- bot/cogs/antispam.py | 4 ++-- bot/cogs/filtering.py | 2 +- bot/cogs/moderation/modlog.py | 10 +++++----- bot/cogs/watchchannels/watchchannel.py | 4 ++-- bot/constants.py | 4 ++-- config-default.yml | 4 ++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 0bcca578d..71382bba9 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -98,7 +98,7 @@ class DeletionContext: text=mod_alert_message, thumbnail=last_message.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, - ping_everyone=AntiSpamConfig.ping_everyone + ping_moderators=AntiSpamConfig.ping_moderators ) @@ -132,7 +132,7 @@ class AntiSpam(Cog): await self.mod_log.send_log_message( title="Error: AntiSpam configuration validation failed!", text=body, - ping_everyone=True, + ping_moderators=True, icon_url=Icons.token_removed, colour=Colour.red() ) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 76ea68660..a5d59085f 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -329,7 +329,7 @@ class Filtering(Cog, Scheduler): text=message, thumbnail=msg.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, - ping_everyone=Filter.ping_everyone, + ping_moderators=Filter.ping_moderators, additional_embeds=additional_embeds, additional_embeds_msg=additional_embeds_msg ) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index ffbb87bbe..a37a9faf5 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -15,7 +15,7 @@ from discord.ext.commands import Cog, Context from discord.utils import escape_markdown from bot.bot import Bot -from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs +from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles, URLs from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -88,7 +88,7 @@ class ModLog(Cog, name="ModLog"): text: str, thumbnail: t.Optional[t.Union[str, discord.Asset]] = None, channel_id: int = Channels.mod_log, - ping_everyone: bool = False, + ping_moderators: bool = False, files: t.Optional[t.List[discord.File]] = None, content: t.Optional[str] = None, additional_embeds: t.Optional[t.List[discord.Embed]] = None, @@ -114,11 +114,11 @@ class ModLog(Cog, name="ModLog"): if thumbnail: embed.set_thumbnail(url=thumbnail) - if ping_everyone: + if ping_moderators: if content: - content = f"@everyone\n{content}" + content = f"<@&{Roles.moderators}>\n{content}" else: - content = "@everyone" + content = f"<@&{Roles.moderators}>" channel = self.bot.get_channel(channel_id) log_message = await channel.send(content=content, embed=embed, files=files) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 7c58a0fb5..8c4af4581 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -120,7 +120,7 @@ class WatchChannel(metaclass=CogABCMeta): await self.modlog.send_log_message( title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", text=message, - ping_everyone=True, + ping_moderators=True, icon_url=Icons.token_removed, colour=Color.red() ) @@ -132,7 +132,7 @@ class WatchChannel(metaclass=CogABCMeta): await self.modlog.send_log_message( title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", text="Could not retrieve the list of watched users from the API and messages will not be relayed.", - ping_everyone=True, + ping_moderators=True, icon_url=Icons.token_removed, colour=Color.red() ) diff --git a/bot/constants.py b/bot/constants.py index a1b392c82..34b312d2d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -225,7 +225,7 @@ class Filter(metaclass=YAMLGetter): notify_user_invites: bool notify_user_domains: bool - ping_everyone: bool + ping_moderators: bool offensive_msg_delete_days: int guild_invite_whitelist: List[int] domain_blacklist: List[str] @@ -522,7 +522,7 @@ class AntiSpam(metaclass=YAMLGetter): section = 'anti_spam' clean_offending: bool - ping_everyone: bool + ping_moderators: bool punishment: Dict[str, Dict[str, int]] rules: Dict[str, Dict[str, int]] diff --git a/config-default.yml b/config-default.yml index 64c4e715b..5dd96d67a 100644 --- a/config-default.yml +++ b/config-default.yml @@ -269,7 +269,7 @@ filter: notify_user_domains: false # Filter configuration - ping_everyone: true # Ping @everyone when we send a mod-alert? + ping_moderators: true # Ping @everyone when we send a mod-alert? offensive_msg_delete_days: 7 # How many days before deleting an offensive message? guild_invite_whitelist: @@ -428,7 +428,7 @@ urls: anti_spam: # Clean messages that violate a rule. clean_offending: true - ping_everyone: true + ping_moderators: true punishment: role_id: *MUTED_ROLE -- cgit v1.2.3 From 57e210ccfcc91132182029f1d931118e715439b2 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 13:38:02 +0200 Subject: Allow role pings in Syncers and help_channels.py Now that we're running Discord 1.4.0a, we need to explicitely allow all the role mentions for sends that don't use ping one of the globally whitelisted role pings, which are Moderators, Admins and Owners. We were pinging roles other than Mods+ in exactly two cases: - Inside the Syncers, whenever we ask for sync confirmation (if the number of roles or users to sync is unusually high) - In the help_channels.py system, whenever we max out help channels and are unable to create more. This commit addresses both of these. GitHub #1038 https://github.com/python-discord/bot/issues/1038 --- bot/cogs/help_channels.py | 4 +++- bot/cogs/sync/syncers.py | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 187adfe51..fd1a449c1 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -624,11 +624,13 @@ class HelpChannels(Scheduler, commands.Cog): channel = self.bot.get_channel(constants.HelpChannels.notify_channel) mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) + allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_roles] message = await channel.send( f"{mentions} A new available help channel is needed but there " f"are no more dormant ones. Consider freeing up some in-use channels manually by " - f"using the `{constants.Bot.prefix}dormant` command within the channels." + f"using the `{constants.Bot.prefix}dormant` command within the channels.", + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) ) self.bot.stats.incr("help.out_of_channel_alerts") diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 536455668..f7ba811bc 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -5,6 +5,7 @@ import typing as t from collections import namedtuple from functools import partial +import discord from discord import Guild, HTTPException, Member, Message, Reaction, User from discord.ext.commands import Context @@ -68,7 +69,11 @@ class Syncer(abc.ABC): ) return None - message = await channel.send(f"{self._CORE_DEV_MENTION}{msg_content}") + allowed_roles = [discord.Object(constants.Roles.core_developers)] + message = await channel.send( + f"{self._CORE_DEV_MENTION}{msg_content}", + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) + ) else: await message.edit(content=msg_content) -- cgit v1.2.3 From 3fd89d59081f2c906fa43265471d235f4f5b4749 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 15:08:54 +0200 Subject: Remove pointless comment This comment violates the DRY principle. Co-authored-by: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 5dd96d67a..0f6a25ef2 100644 --- a/config-default.yml +++ b/config-default.yml @@ -269,7 +269,7 @@ filter: notify_user_domains: false # Filter configuration - ping_moderators: true # Ping @everyone when we send a mod-alert? + ping_moderators: true offensive_msg_delete_days: 7 # How many days before deleting an offensive message? guild_invite_whitelist: -- cgit v1.2.3 From 4527c038d21149d4d3fab73c54b9a1ad31e671c0 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 13 Jul 2020 00:17:37 +0200 Subject: Revert "Ping @Moderators in ModLog" Let's continue to use "@everyone" for now, and add an explicit allow for it so that it successfully pings people. There's a full justification for this in the pull request. https://github.com/python-discord/bot/issues/1038 --- bot/cogs/antispam.py | 4 ++-- bot/cogs/filtering.py | 2 +- bot/cogs/moderation/modlog.py | 17 +++++++++++------ bot/cogs/watchchannels/watchchannel.py | 4 ++-- bot/constants.py | 4 ++-- config-default.yml | 4 ++-- 6 files changed, 20 insertions(+), 15 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 71382bba9..0bcca578d 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -98,7 +98,7 @@ class DeletionContext: text=mod_alert_message, thumbnail=last_message.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, - ping_moderators=AntiSpamConfig.ping_moderators + ping_everyone=AntiSpamConfig.ping_everyone ) @@ -132,7 +132,7 @@ class AntiSpam(Cog): await self.mod_log.send_log_message( title="Error: AntiSpam configuration validation failed!", text=body, - ping_moderators=True, + ping_everyone=True, icon_url=Icons.token_removed, colour=Colour.red() ) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index a5d59085f..76ea68660 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -329,7 +329,7 @@ class Filtering(Cog, Scheduler): text=message, thumbnail=msg.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, - ping_moderators=Filter.ping_moderators, + ping_everyone=Filter.ping_everyone, additional_embeds=additional_embeds, additional_embeds_msg=additional_embeds_msg ) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index a37a9faf5..0a63f57b8 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -15,7 +15,7 @@ from discord.ext.commands import Cog, Context from discord.utils import escape_markdown from bot.bot import Bot -from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles, URLs +from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -88,7 +88,7 @@ class ModLog(Cog, name="ModLog"): text: str, thumbnail: t.Optional[t.Union[str, discord.Asset]] = None, channel_id: int = Channels.mod_log, - ping_moderators: bool = False, + ping_everyone: bool = False, files: t.Optional[t.List[discord.File]] = None, content: t.Optional[str] = None, additional_embeds: t.Optional[t.List[discord.Embed]] = None, @@ -114,14 +114,19 @@ class ModLog(Cog, name="ModLog"): if thumbnail: embed.set_thumbnail(url=thumbnail) - if ping_moderators: + if ping_everyone: if content: - content = f"<@&{Roles.moderators}>\n{content}" + content = f"@everyone\n{content}" else: - content = f"<@&{Roles.moderators}>" + content = "@everyone" channel = self.bot.get_channel(channel_id) - log_message = await channel.send(content=content, embed=embed, files=files) + log_message = await channel.send( + content=content, + embed=embed, + files=files, + allowed_mentions=discord.AllowedMentions(everyone=True) + ) if additional_embeds: if additional_embeds_msg: diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 8c4af4581..7c58a0fb5 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -120,7 +120,7 @@ class WatchChannel(metaclass=CogABCMeta): await self.modlog.send_log_message( title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", text=message, - ping_moderators=True, + ping_everyone=True, icon_url=Icons.token_removed, colour=Color.red() ) @@ -132,7 +132,7 @@ class WatchChannel(metaclass=CogABCMeta): await self.modlog.send_log_message( title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", text="Could not retrieve the list of watched users from the API and messages will not be relayed.", - ping_moderators=True, + ping_everyone=True, icon_url=Icons.token_removed, colour=Color.red() ) diff --git a/bot/constants.py b/bot/constants.py index 34b312d2d..a1b392c82 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -225,7 +225,7 @@ class Filter(metaclass=YAMLGetter): notify_user_invites: bool notify_user_domains: bool - ping_moderators: bool + ping_everyone: bool offensive_msg_delete_days: int guild_invite_whitelist: List[int] domain_blacklist: List[str] @@ -522,7 +522,7 @@ class AntiSpam(metaclass=YAMLGetter): section = 'anti_spam' clean_offending: bool - ping_moderators: bool + ping_everyone: bool punishment: Dict[str, Dict[str, int]] rules: Dict[str, Dict[str, int]] diff --git a/config-default.yml b/config-default.yml index 0f6a25ef2..636b9db37 100644 --- a/config-default.yml +++ b/config-default.yml @@ -269,7 +269,7 @@ filter: notify_user_domains: false # Filter configuration - ping_moderators: true + ping_everyone: true offensive_msg_delete_days: 7 # How many days before deleting an offensive message? guild_invite_whitelist: @@ -428,7 +428,7 @@ urls: anti_spam: # Clean messages that violate a rule. clean_offending: true - ping_moderators: true + ping_everyone: true punishment: role_id: *MUTED_ROLE -- cgit v1.2.3 From e1c3b66f5f4d1f421d6469bd4f0964166262832c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 12 Jul 2020 23:49:46 -0700 Subject: Fix rescheduling of edited infractions It was attempting to schedule a dictionary instead of a coroutine. Fixes #1043 Fixes BOT-6Y --- bot/cogs/moderation/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 4ef9d4209..672bb0e9c 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -139,7 +139,7 @@ class ModManagement(commands.Cog): # If the infraction was not marked as permanent, schedule a new expiration task if request_data['expires_at']: - self.infractions_cog.scheduler.schedule(new_infraction['id'], new_infraction) + self.infractions_cog.schedule_expiration(new_infraction) log_text += f""" Previous expiry: {old_infraction['expires_at'] or "Permanent"} -- cgit v1.2.3