Skip to content

feat: update moderation, tools, and utility cogs #960

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 35 additions & 33 deletions tux/cogs/moderation/tempban.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING

import discord
from discord.ext import commands, tasks
Expand All @@ -13,13 +14,16 @@

from . import ModerationCogBase

if TYPE_CHECKING:
from tux.bot import Tux


class TempBan(ModerationCogBase):
def __init__(self, bot: Tux) -> None:
super().__init__(bot)
self.tempban.usage = generate_usage(self.tempban, TempBanFlags)
self._processing_tempbans = False # Lock to prevent overlapping task runs
self.tempban_check.start()
self.check_tempbans.start()

@commands.hybrid_command(name="tempban", aliases=["tb"])
@commands.guild_only()
Expand Down Expand Up @@ -152,53 +156,51 @@ async def _process_tempban_case(self, case: Case) -> tuple[int, int]:

return processed_count, failed_count

@tasks.loop(minutes=1)
async def tempban_check(self) -> None:
"""
Check for expired tempbans at a set interval and unban the user if the ban has expired.

Uses a simple locking mechanism to prevent overlapping executions.
Processes bans in smaller batches to prevent timeout issues.

Raises
------
Exception
If an error occurs while checking for expired tempbans.
"""
# Skip if already processing
@tasks.loop(minutes=1, name="tempban_checker")
async def check_tempbans(self) -> None:
"""Checks for expired tempbans and unbans the user."""
if self._processing_tempbans:
logger.debug("Tempban check is already in progress. Skipping.")
return

self._processing_tempbans = True
try:
self._processing_tempbans = True

# Get expired tempbans
expired_cases = await self.db.case.get_expired_tempbans()
processed_cases = 0
failed_cases = 0

if not expired_cases:
return

logger.info(f"Processing {len(expired_cases)} expired tempban cases.")

processed, failed = 0, 0
for case in expired_cases:
# Process each case using the helper method
processed, failed = await self._process_tempban_case(case)
processed_cases += processed
failed_cases += failed
p, f = await self._process_tempban_case(case)
processed += p
failed += f

if processed_cases > 0 or failed_cases > 0:
logger.info(f"Tempban check: processed {processed_cases} cases, {failed_cases} failures")
if processed or failed:
logger.info(f"Finished processing tempbans. Processed: {processed}, Failed: {failed}.")

except Exception as e:
logger.error(f"Failed to check tempbans: {e}")
finally:
self._processing_tempbans = False

@tempban_check.before_loop
async def before_tempban_check(self) -> None:
"""Wait for the bot to be ready before starting the loop."""
@check_tempbans.before_loop
async def before_check_tempbans(self) -> None:
"""Wait until the bot is ready."""
await self.bot.wait_until_ready()

@check_tempbans.error
async def on_tempban_error(self, error: BaseException) -> None:
"""Handles errors in the tempban checking loop."""
logger.error(f"Error in tempban checker loop: {error}")

if isinstance(error, Exception):
self.bot.sentry_manager.capture_exception(error)
else:
raise error

async def cog_unload(self) -> None:
"""Cancel the tempban check loop when the cog is unloaded."""
self.tempban_check.cancel()
self.check_tempbans.cancel()


async def setup(bot: Tux) -> None:
Expand Down
23 changes: 6 additions & 17 deletions tux/cogs/tools/wolfram.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import asyncio
import io
from urllib.parse import quote_plus

Expand All @@ -17,22 +16,7 @@
class Wolfram(commands.Cog):
def __init__(self, bot: Tux) -> None:
self.bot = bot

# Verify AppID configuration; unload cog if missing
if not CONFIG.WOLFRAM_APP_ID:
logger.warning("Wolfram Alpha API ID is not set. Some Science/Math commands will not work.")
# Store the task reference
self._unload_task = asyncio.create_task(self._unload_self())
else:
logger.info("Wolfram Alpha API ID is set, Science/Math commands that depend on it will work.")

async def _unload_self(self):
"""Unload this cog if configuration is missing."""
try:
await self.bot.unload_extension("tux.cogs.tools.wolfram")
logger.info("Wolfram cog has been unloaded due to missing configuration")
except Exception as e:
logger.error(f"Failed to unload Wolfram cog: {e}")
logger.info("Wolfram Alpha cog initialized successfully.")

@commands.hybrid_command(name="wolfram", description="Query Wolfram|Alpha Simple API and return an image result.")
@app_commands.describe(
Expand Down Expand Up @@ -96,4 +80,9 @@ async def wolfram(self, ctx: commands.Context[Tux], *, query: str) -> None:


async def setup(bot: Tux) -> None:
# Check if Wolfram API ID is configured before loading the cog
if not CONFIG.WOLFRAM_APP_ID:
logger.warning("Wolfram Alpha API ID is not set. Skipping Wolfram cog.")
return

await bot.add_cog(Wolfram(bot))
22 changes: 20 additions & 2 deletions tux/cogs/utility/afk.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import discord
from discord.ext import commands, tasks
from loguru import logger

from prisma.models import AFKModel
from tux.bot import Tux
Expand All @@ -24,6 +25,9 @@ def __init__(self, bot: Tux) -> None:
self.afk.usage = generate_usage(self.afk)
self.permafk.usage = generate_usage(self.permafk)

async def cog_unload(self) -> None:
self.handle_afk_expiration.cancel()

@commands.hybrid_command(
name="afk",
)
Expand Down Expand Up @@ -183,8 +187,8 @@ async def check_afk(self, message: discord.Message) -> None:
),
)

@tasks.loop(seconds=120)
async def handle_afk_expiration(self):
@tasks.loop(seconds=120, name="afk_expiration_handler")
async def handle_afk_expiration(self) -> None:
"""
Check AFK database at a regular interval,
Remove AFK from users with an entry that has expired.
Expand All @@ -201,6 +205,20 @@ async def handle_afk_expiration(self):
else:
await del_afk(self.db, member, entry.nickname)

@handle_afk_expiration.before_loop
async def before_handle_afk_expiration(self) -> None:
"""Wait until the bot is ready."""
await self.bot.wait_until_ready()

@handle_afk_expiration.error
async def on_handle_afk_expiration_error(self, error: BaseException) -> None:
"""Handles errors in the AFK expiration handler loop."""
logger.error(f"Error in AFK expiration handler loop: {error}")
if isinstance(error, Exception):
self.bot.sentry_manager.capture_exception(error)
else:
raise error

async def _get_expired_afk_entries(self, guild_id: int) -> list[AFKModel]:
"""
Get all expired AFK entries for a guild.
Expand Down
82 changes: 13 additions & 69 deletions tux/cogs/utility/remindme.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import asyncio
import contextlib
import datetime

import discord
from discord.ext import commands
from loguru import logger

from prisma.models import Reminder
from tux.bot import Tux
from tux.cogs.services.reminders import ReminderService
from tux.database.controllers import DatabaseController
from tux.ui.embeds import EmbedCreator
from tux.utils.constants import CONST
from tux.utils.functions import convert_to_seconds, generate_usage


Expand All @@ -18,67 +16,6 @@ def __init__(self, bot: Tux) -> None:
self.bot = bot
self.db = DatabaseController()
self.remindme.usage = generate_usage(self.remindme)
self._initialized = False

async def send_reminder(self, reminder: Reminder) -> None:
user = self.bot.get_user(reminder.reminder_user_id)
if user is not None:
embed = EmbedCreator.create_embed(
bot=self.bot,
embed_type=EmbedCreator.INFO,
user_name=user.name,
user_display_avatar=user.display_avatar.url,
title="Reminder",
description=reminder.reminder_content,
)

try:
await user.send(embed=embed)

except discord.Forbidden:
channel = self.bot.get_channel(reminder.reminder_channel_id)

if isinstance(channel, discord.TextChannel | discord.Thread | discord.VoiceChannel):
with contextlib.suppress(discord.Forbidden):
await channel.send(
content=f"{user.mention} Failed to DM you, sending in channel",
embed=embed,
)
return

else:
logger.error(
f"Failed to send reminder {reminder.reminder_id}, DMs closed and channel not found.",
)

else:
logger.error(
f"Failed to send reminder {reminder.reminder_id}, user with ID {reminder.reminder_user_id} not found.",
)

try:
await self.db.reminder.delete_reminder_by_id(reminder.reminder_id)
except Exception as e:
logger.error(f"Failed to delete reminder: {e}")

@commands.Cog.listener()
async def on_ready(self) -> None:
if self._initialized:
return

self._initialized = True

reminders = await self.db.reminder.get_all_reminders()
dt_now = datetime.datetime.now(datetime.UTC)

for reminder in reminders:
seconds = (reminder.reminder_expires_at - dt_now).total_seconds()

if seconds <= 0:
await self.send_reminder(reminder)
continue

self.bot.loop.call_later(seconds, asyncio.create_task, self.send_reminder(reminder))

@commands.hybrid_command(
name="remindme",
Expand All @@ -102,7 +39,7 @@ async def remindme(
- m = minutes
- s = seconds

Example: `!remindme 1h30m "Take a break"` will remind you in 1 hour and 30 minutes.
Example: `$remindme 1h30m take a break` will remind you in 1 hour and 30 minutes.

Parameters
----------
Expand All @@ -111,7 +48,7 @@ async def remindme(
time : str
The time to set the reminder for (e.g. 2d, 1h30m).
reminder : str
The reminder message.
The reminder message (quotes are not required).
"""

seconds = convert_to_seconds(time)
Expand All @@ -120,11 +57,17 @@ async def remindme(
await ctx.reply(
"Invalid time format. Please use the format `[number][M/w/d/h/m/s]`.",
ephemeral=True,
delete_after=30,
delete_after=CONST.DEFAULT_DELETE_AFTER,
)
return

expires_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=seconds)
reminder_service = self.bot.get_cog("ReminderService")

if not isinstance(reminder_service, ReminderService):
await ctx.reply("Reminder service not available.", ephemeral=True, delete_after=CONST.DEFAULT_DELETE_AFTER)
logger.error("ReminderService not found or is not the correct type.")
return

try:
reminder_obj = await self.db.reminder.insert_reminder(
Expand All @@ -135,7 +78,8 @@ async def remindme(
guild_id=ctx.guild.id if ctx.guild else 0,
)

self.bot.loop.call_later(seconds, asyncio.create_task, self.send_reminder(reminder_obj))
# Schedule the reminder using our new queue system
await reminder_service.schedule_reminder(reminder_obj)

embed = EmbedCreator.create_embed(
bot=self.bot,
Expand Down