aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar shtlrs <[email protected]>2022-11-27 23:01:52 +0100
committerGravatar shtlrs <[email protected]>2022-11-27 23:01:52 +0100
commitd8850b42cc0cbcc2a615160d20709935557a7d67 (patch)
treec6cfdbfdf8d3bd153c102ad7c86ac639dc6bca83
parentmake the roles view ephemeral when sent in roles channel (diff)
parentMerge pull request #2347 from shtlrs/improve-get-or-fetch-channel-type-hints (diff)
Merge branch 'main' into 2332-permanent-role-view
-rw-r--r--.github/CODEOWNERS1
-rw-r--r--bot/constants.py2
-rw-r--r--bot/exts/help_channels/__init__.py7
-rw-r--r--bot/exts/help_channels/_channel.py159
-rw-r--r--bot/exts/help_channels/_cog.py59
-rw-r--r--bot/resources/tags/return.md19
-rw-r--r--bot/resources/tags/sql-fstring.md2
-rw-r--r--bot/utils/channel.py4
-rw-r--r--config-default.yml7
9 files changed, 183 insertions, 77 deletions
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 0bc2bb793..7cd00a0d6 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -5,7 +5,6 @@
bot/exts/info/codeblock/** @MarkKoz
bot/exts/utils/extensions.py @MarkKoz
bot/exts/utils/snekbox.py @MarkKoz @jb3
-bot/exts/help_channels/** @MarkKoz
bot/exts/moderation/** @mbaruh @Den4200 @ks129 @jb3
bot/exts/info/** @Den4200 @jb3
bot/exts/info/information.py @mbaruh @jb3
diff --git a/bot/constants.py b/bot/constants.py
index 90527d66a..3c29ce887 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -617,6 +617,8 @@ class HelpChannels(metaclass=YAMLGetter):
section = 'help_channels'
enable: bool
+ idle_minutes: int
+ deleted_idle_minutes: int
cmd_whitelist: List[int]
diff --git a/bot/exts/help_channels/__init__.py b/bot/exts/help_channels/__init__.py
index 00b4a735b..643497b9f 100644
--- a/bot/exts/help_channels/__init__.py
+++ b/bot/exts/help_channels/__init__.py
@@ -1,8 +1,15 @@
from bot.bot import Bot
+from bot.constants import HelpChannels
from bot.exts.help_channels._cog import HelpForum
+from bot.log import get_logger
+
+log = get_logger(__name__)
async def setup(bot: Bot) -> None:
"""Load the HelpForum cog."""
+ if not HelpChannels.enable:
+ log.warning("HelpChannel.enabled set to false, not loading help channel cog.")
+ return
await bot.add_cog(HelpForum(bot))
diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py
index 74d65107b..0cee24817 100644
--- a/bot/exts/help_channels/_channel.py
+++ b/bot/exts/help_channels/_channel.py
@@ -1,9 +1,10 @@
"""Contains all logic to handle changes to posts in the help forum."""
-import asyncio
import textwrap
+from datetime import timedelta
+import arrow
import discord
-from pydis_core.utils import members
+from pydis_core.utils import members, scheduling
import bot
from bot import constants
@@ -41,22 +42,22 @@ def is_help_forum_post(channel: discord.abc.GuildChannel) -> bool:
return getattr(channel, "parent_id", None) == constants.Channels.help_system_forum
-async def _close_help_thread(closed_thread: discord.Thread, closed_on: _stats.ClosingReason) -> None:
- """Close the help thread and record stats."""
+async def _close_help_post(closed_post: discord.Thread, closing_reason: _stats.ClosingReason) -> None:
+ """Close the help post and record stats."""
embed = discord.Embed(description=DORMANT_MSG)
- await closed_thread.send(embed=embed)
- await closed_thread.edit(archived=True, locked=True, reason="Locked a dormant help channel")
+ await closed_post.send(embed=embed)
+ await closed_post.edit(archived=True, locked=True, reason="Locked a dormant help post")
_stats.report_post_count()
- await _stats.report_complete_session(closed_thread, closed_on)
+ await _stats.report_complete_session(closed_post, closing_reason)
- poster = closed_thread.owner
- cooldown_role = closed_thread.guild.get_role(constants.Roles.help_cooldown)
+ poster = closed_post.owner
+ cooldown_role = closed_post.guild.get_role(constants.Roles.help_cooldown)
if poster is None:
# We can't include the owner ID/name here since the thread only contains None
log.info(
- f"Failed to remove cooldown role for owner of thread ({closed_thread.id}). "
+ f"Failed to remove cooldown role for owner of post ({closed_post.id}). "
f"The user is likely no longer on the server."
)
return
@@ -64,7 +65,7 @@ async def _close_help_thread(closed_thread: discord.Thread, closed_on: _stats.Cl
await members.handle_role_change(poster, poster.remove_roles, cooldown_role)
-async def send_opened_post_message(thread: discord.Thread) -> None:
+async def send_opened_post_message(post: discord.Thread) -> None:
"""Send the opener message in the new help post."""
embed = discord.Embed(
color=constants.Colours.bright_green,
@@ -72,24 +73,24 @@ async def send_opened_post_message(thread: discord.Thread) -> None:
)
embed.set_author(name=POST_TITLE)
embed.set_footer(text=POST_FOOTER)
- await thread.send(embed=embed)
+ await post.send(embed=embed)
-async def send_opened_post_dm(thread: discord.Thread) -> None:
+async def send_opened_post_dm(post: discord.Thread) -> None:
"""Send the opener a DM message with a jump link to their new post."""
embed = discord.Embed(
- title="Help channel opened",
- description=f"You opened {thread.mention}.",
+ title="Help post opened",
+ description=f"You opened {post.mention}.",
colour=constants.Colours.bright_green,
- timestamp=thread.created_at,
+ timestamp=post.created_at,
)
embed.set_thumbnail(url=constants.Icons.green_questionmark)
- message = thread.starter_message
+ message = post.starter_message
if not message:
try:
- message = await thread.fetch_message(thread.id)
+ message = await post.fetch_message(post.id)
except discord.HTTPException:
- log.warning(f"Could not fetch message for thread {thread.id}")
+ log.warning(f"Could not fetch message for post {post.id}")
return
formatted_message = textwrap.shorten(message.content, width=100, placeholder="...").strip()
@@ -105,52 +106,49 @@ async def send_opened_post_dm(thread: discord.Thread) -> None:
)
try:
- await thread.owner.send(embed=embed)
- log.trace(f"Sent DM to {thread.owner} ({thread.owner_id}) after posting in help forum.")
+ await post.owner.send(embed=embed)
+ log.trace(f"Sent DM to {post.owner} ({post.owner_id}) after posting in help forum.")
except discord.errors.Forbidden:
log.trace(
- f"Ignoring to send DM to {thread.owner} ({thread.owner_id}) after posting in help forum: DMs disabled.",
+ f"Ignoring to send DM to {post.owner} ({post.owner_id}) after posting in help forum: DMs disabled.",
)
-async def help_thread_opened(opened_thread: discord.Thread, *, reopen: bool = False) -> None:
+async def help_post_opened(opened_post: discord.Thread, *, reopen: bool = False) -> None:
"""Apply new post logic to a new help forum post."""
_stats.report_post_count()
- if not isinstance(opened_thread.owner, discord.Member):
- log.debug(f"{opened_thread.owner_id} isn't a member. Closing post.")
- await _close_help_thread(opened_thread, _stats.ClosingReason.CLEANUP)
+ if not isinstance(opened_post.owner, discord.Member):
+ log.debug(f"{opened_post.owner_id} isn't a member. Closing post.")
+ await _close_help_post(opened_post, _stats.ClosingReason.CLEANUP)
return
- # Discord sends the open event long before the thread is ready for actions in the API.
- # This causes actions such as fetching the message, pinning message, etc to fail.
- # We sleep here to try and delay our code enough so the thread is ready in the API.
- await asyncio.sleep(2)
-
- await send_opened_post_dm(opened_thread)
+ await send_opened_post_dm(opened_post)
try:
- await opened_thread.starter_message.pin()
- except discord.HTTPException as e:
+ await opened_post.starter_message.pin()
+ except (discord.HTTPException, AttributeError) as e:
# Suppress if the message was not found, most likely deleted
- if e.code != 10008:
+ # The message being deleted could be surfaced as an AttributeError on .starter_message,
+ # or as an exception from the Discord API, depending on timing and cache status.
+ if isinstance(e, discord.HTTPException) and e.code != 10008:
raise e
- await send_opened_post_message(opened_thread)
+ await send_opened_post_message(opened_post)
- cooldown_role = opened_thread.guild.get_role(constants.Roles.help_cooldown)
- await members.handle_role_change(opened_thread.owner, opened_thread.owner.add_roles, cooldown_role)
+ cooldown_role = opened_post.guild.get_role(constants.Roles.help_cooldown)
+ await members.handle_role_change(opened_post.owner, opened_post.owner.add_roles, cooldown_role)
-async def help_thread_closed(closed_thread: discord.Thread) -> None:
+async def help_post_closed(closed_post: discord.Thread) -> None:
"""Apply archive logic to a manually closed help forum post."""
- await _close_help_thread(closed_thread, _stats.ClosingReason.COMMAND)
+ await _close_help_post(closed_post, _stats.ClosingReason.COMMAND)
-async def help_thread_archived(archived_thread: discord.Thread) -> None:
+async def help_post_archived(archived_post: discord.Thread) -> None:
"""Apply archive logic to an archived help forum post."""
- async for thread_update in archived_thread.guild.audit_logs(limit=50, action=discord.AuditLogAction.thread_update):
- if thread_update.target.id != archived_thread.id:
+ async for thread_update in archived_post.guild.audit_logs(limit=50, action=discord.AuditLogAction.thread_update):
+ if thread_update.target.id != archived_post.id:
continue
# Don't apply close logic if the post was archived by the bot, as it
@@ -158,13 +156,74 @@ async def help_thread_archived(archived_thread: discord.Thread) -> None:
if thread_update.user.id == bot.instance.user.id:
return
- await _close_help_thread(archived_thread, _stats.ClosingReason.INACTIVE)
+ await _close_help_post(archived_post, _stats.ClosingReason.INACTIVE)
-async def help_thread_deleted(deleted_thread_event: discord.RawThreadDeleteEvent) -> None:
- """Record appropriate stats when a help thread is deleted."""
+async def help_post_deleted(deleted_post_event: discord.RawThreadDeleteEvent) -> None:
+ """Record appropriate stats when a help post is deleted."""
_stats.report_post_count()
- cached_thread = deleted_thread_event.thread
- if cached_thread and not cached_thread.archived:
- # If the thread is in the bot's cache, and it was not archived before deleting, report a complete session.
- await _stats.report_complete_session(cached_thread, _stats.ClosingReason.DELETED)
+ cached_post = deleted_post_event.thread
+ if cached_post and not cached_post.archived:
+ # If the post is in the bot's cache, and it was not archived before deleting, report a complete session.
+ await _stats.report_complete_session(cached_post, _stats.ClosingReason.DELETED)
+
+
+async def get_closing_time(post: discord.Thread) -> tuple[arrow.Arrow, _stats.ClosingReason]:
+ """
+ Return the time at which the given help `post` should be closed along with the reason.
+
+ The time is calculated by first checking if the opening message is deleted.
+ If it is, then get the last 100 messages (the most that can be fetched in one API call).
+ If less than 100 message are returned, and none are from the post owner, then assume the poster
+ has sent no further messages and close deleted_idle_minutes after the post creation time.
+
+ Otherwise, use the most recent message's create_at date and add `idle_minutes_claimant`.
+ """
+ try:
+ starter_message = post.starter_message or await post.fetch_message(post.id)
+ except discord.NotFound:
+ starter_message = None
+
+ last_100_messages = [message async for message in post.history(limit=100, oldest_first=False)]
+
+ if starter_message is None and len(last_100_messages) < 100:
+ if not discord.utils.get(last_100_messages, author__id=post.owner_id):
+ time = arrow.Arrow.fromdatetime(post.created_at)
+ time += timedelta(minutes=constants.HelpChannels.deleted_idle_minutes)
+ return time, _stats.ClosingReason.DELETED
+
+ time = arrow.Arrow.fromdatetime(last_100_messages[0].created_at)
+ time += timedelta(minutes=constants.HelpChannels.idle_minutes)
+ return time, _stats.ClosingReason.INACTIVE
+
+
+async def maybe_archive_idle_post(post: discord.Thread, scheduler: scheduling.Scheduler, has_task: bool = True) -> None:
+ """
+ Archive the `post` if idle, or schedule the archive for later if still active.
+
+ If `has_task` is True and rescheduling is required, the extant task to make the post
+ dormant will first be cancelled.
+ """
+ if post.locked:
+ log.trace(f"Not closing already closed post #{post} ({post.id}).")
+ return
+
+ log.trace(f"Handling open post #{post} ({post.id}).")
+
+ closing_time, closing_reason = await get_closing_time(post)
+
+ if closing_time < (arrow.utcnow() + timedelta(seconds=1)):
+ # Closing time is in the past.
+ # Add 1 second due to POSIX timestamps being lower resolution than datetime objects.
+ log.info(
+ f"#{post} ({post.id}) is idle past {closing_time} and will be archived. Reason: {closing_reason.value}"
+ )
+ await _close_help_post(post, closing_reason)
+ return
+
+ if has_task:
+ scheduler.cancel(post.id)
+ delay = (closing_time - arrow.utcnow()).seconds
+ log.info(f"#{post} ({post.id}) is still active; scheduling it to be archived after {delay} seconds.")
+
+ scheduler.schedule_later(delay, post.id, maybe_archive_idle_post(post, scheduler, has_task=True))
diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py
index bb2f43c5a..31f30b7aa 100644
--- a/bot/exts/help_channels/_cog.py
+++ b/bot/exts/help_channels/_cog.py
@@ -4,6 +4,7 @@ import typing as t
import discord
from discord.ext import commands
+from pydis_core.utils import scheduling
from bot import constants
from bot.bot import Bot
@@ -28,7 +29,22 @@ class HelpForum(commands.Cog):
def __init__(self, bot: Bot):
self.bot = bot
- self.help_forum_channel_id = constants.Channels.help_system_forum
+ self.scheduler = scheduling.Scheduler(self.__class__.__name__)
+ self.help_forum_channel: discord.ForumChannel = None
+
+ async def cog_unload(self) -> None:
+ """Cancel all scheduled tasks on unload."""
+ self.scheduler.cancel_all()
+
+ async def cog_load(self) -> None:
+ """Archive all idle open posts, schedule check for later for active open posts."""
+ log.trace("Initialising help forum cog.")
+ self.help_forum_channel = self.bot.get_channel(constants.Channels.help_system_forum)
+ if not isinstance(self.help_forum_channel, discord.ForumChannel):
+ raise TypeError("Channels.help_system_forum is not a forum channel!")
+
+ for post in self.help_forum_channel.threads:
+ await _channel.maybe_archive_idle_post(post, self.scheduler, has_task=False)
async def close_check(self, ctx: commands.Context) -> bool:
"""Return True if the channel is a help post, and the user is the claimant or has a whitelisted role."""
@@ -53,7 +69,7 @@ class HelpForum(commands.Cog):
mod_alerts = self.bot.get_channel(constants.Channels.mod_alerts)
await mod_alerts.send(
f"<@&{constants.Roles.moderators}>\n"
- f"<@{post.owner_id}> ({post.owner_id}) opened the thread {post.mention} ({post.id}), "
+ f"<@{post.owner_id}> ({post.owner_id}) opened the post {post.mention} ({post.id}), "
"which triggered the token filter with its name!\n"
f"**Match:** {match.group()}"
)
@@ -74,7 +90,9 @@ class HelpForum(commands.Cog):
# Don't use a discord.py check because the check needs to fail silently.
if await self.close_check(ctx):
log.info(f"Close command invoked by {ctx.author} in #{ctx.channel}.")
- await _channel.help_thread_closed(ctx.channel)
+ await _channel.help_post_closed(ctx.channel)
+ if ctx.channel.id in self.scheduler:
+ self.scheduler.cancel(ctx.channel.id)
@help_forum_group.command(name="dm", root_aliases=("helpdm",))
async def help_dm_command(
@@ -113,33 +131,48 @@ class HelpForum(commands.Cog):
await ctx.channel.edit(name=title)
- @commands.Cog.listener()
- async def on_thread_create(self, thread: discord.Thread) -> None:
+ @commands.Cog.listener("on_message")
+ async def new_post_listener(self, message: discord.Message) -> None:
"""Defer application of new post logic for posts the help forum to the _channel helper."""
- if thread.parent_id != self.help_forum_channel_id:
+ if not isinstance(message.channel, discord.Thread):
+ return
+ thread = message.channel
+
+ if not message.id == thread.id:
+ # Opener messages have the same ID as the thread
+ return
+
+ if thread.parent_id != self.help_forum_channel.id:
return
await self.post_with_disallowed_title_check(thread)
- await _channel.help_thread_opened(thread)
+ await _channel.help_post_opened(thread)
+
+ delay = min(constants.HelpChannels.deleted_idle_minutes, constants.HelpChannels.idle_minutes) * 60
+ self.scheduler.schedule_later(
+ delay,
+ thread.id,
+ _channel.maybe_archive_idle_post(thread, self.scheduler)
+ )
@commands.Cog.listener()
async def on_thread_update(self, before: discord.Thread, after: discord.Thread) -> None:
"""Defer application archive logic for posts in the help forum to the _channel helper."""
- if after.parent_id != self.help_forum_channel_id:
+ if after.parent_id != self.help_forum_channel.id:
return
if not before.archived and after.archived:
- await _channel.help_thread_archived(after)
+ await _channel.help_post_archived(after)
if before.name != after.name:
await self.post_with_disallowed_title_check(after)
@commands.Cog.listener()
async def on_raw_thread_delete(self, deleted_thread_event: discord.RawThreadDeleteEvent) -> None:
"""Defer application of new post logic for posts the help forum to the _channel helper."""
- if deleted_thread_event.parent_id == self.help_forum_channel_id:
- await _channel.help_thread_deleted(deleted_thread_event)
+ if deleted_thread_event.parent_id == self.help_forum_channel.id:
+ await _channel.help_post_deleted(deleted_thread_event)
- @commands.Cog.listener()
- async def on_message(self, message: discord.Message) -> None:
+ @commands.Cog.listener("on_message")
+ async def new_post_message_listener(self, message: discord.Message) -> None:
"""Defer application of new message logic for messages in the help forum to the _message helper."""
if not _channel.is_help_forum_post(message.channel):
return None
diff --git a/bot/resources/tags/return.md b/bot/resources/tags/return.md
index e37f0eebc..1d65ab1ae 100644
--- a/bot/resources/tags/return.md
+++ b/bot/resources/tags/return.md
@@ -1,27 +1,25 @@
**Return Statement**
-When calling a function, you'll often want it to give you a value back. In order to do that, you must `return` it. The reason for this is because functions have their own scope. Any values defined within the function body are inaccessible outside of that function.
-
-*For more information about scope, see `!tags scope`*
+A value created inside a function can't be used outside of it unless you `return` it.
Consider the following function:
```py
def square(n):
- return n*n
+ return n * n
```
-If we wanted to store 5 squared in a variable called `x`, we could do that like so:
+If we wanted to store 5 squared in a variable called `x`, we would do:
`x = square(5)`. `x` would now equal `25`.
**Common Mistakes**
```py
>>> def square(n):
-... n*n # calculates then throws away, returns None
+... n * n # calculates then throws away, returns None
...
>>> x = square(5)
>>> print(x)
None
>>> def square(n):
-... print(n*n) # calculates and prints, then throws away and returns None
+... print(n * n) # calculates and prints, then throws away and returns None
...
>>> x = square(5)
25
@@ -29,7 +27,6 @@ None
None
```
**Things to note**
-• `print()` and `return` do **not** accomplish the same thing. `print()` will only print the value, it will not be accessible outside of the function afterwards.
-• A function will return `None` if it ends without reaching an explicit `return` statement.
-• When you want to print a value calculated in a function, instead of printing inside the function, it is often better to return the value and print the *function call* instead.
-• [Official documentation for `return`](https://docs.python.org/3/reference/simple_stmts.html#the-return-statement)
+• `print()` and `return` do **not** accomplish the same thing. `print()` will show the value, and then it will be gone.
+• A function will return `None` if it ends without a `return` statement.
+• When you want to print a value from a function, it's best to return the value and print the *function call* instead, like `print(square(5))`.
diff --git a/bot/resources/tags/sql-fstring.md b/bot/resources/tags/sql-fstring.md
index 538a0aa87..fa28b6e3b 100644
--- a/bot/resources/tags/sql-fstring.md
+++ b/bot/resources/tags/sql-fstring.md
@@ -12,5 +12,5 @@ db.execute(query, params)
Note: Different database libraries support different placeholder styles, e.g. `%s` and `$1`. Consult your library's documentation for details.
**See Also**
-• [Extended Example with SQLite](https://docs.python.org/3/library/sqlite3.html) (search for "Instead, use the DB-API's parameter substitution")
+• [Python sqlite3 docs](https://docs.python.org/3/library/sqlite3.html#how-to-use-placeholders-to-bind-values-in-sql-queries) - How to use placeholders to bind values in SQL queries
• [PEP-249](https://peps.python.org/pep-0249/) - A specification of how database libraries in Python should work
diff --git a/bot/utils/channel.py b/bot/utils/channel.py
index 821a3732a..20f433a3f 100644
--- a/bot/utils/channel.py
+++ b/bot/utils/channel.py
@@ -48,7 +48,9 @@ def is_in_category(channel: discord.TextChannel, category_id: int) -> bool:
return getattr(channel, "category_id", None) == category_id
-async def get_or_fetch_channel(channel_id: int) -> discord.abc.GuildChannel:
+async def get_or_fetch_channel(
+ channel_id: int
+) -> discord.abc.GuildChannel | discord.abc.PrivateChannel | discord.Thread:
"""Attempt to get or fetch a channel and return it."""
log.trace(f"Getting the channel {channel_id}.")
diff --git a/config-default.yml b/config-default.yml
index 9a3f35008..86474c204 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -496,6 +496,13 @@ free:
help_channels:
enable: true
+ # Allowed duration of inactivity before archiving a help post
+ idle_minutes: 30
+
+ # Allowed duration of inactivity when post is empty (due to deleted messages)
+ # before archiving a help post
+ deleted_idle_minutes: 5
+
# Roles which are allowed to use the command which makes channels dormant
cmd_whitelist:
- *HELPERS_ROLE