diff --git a/commands/emote_credits.py b/commands/emote_credits.py index 9700e99..d42892a 100644 --- a/commands/emote_credits.py +++ b/commands/emote_credits.py @@ -166,6 +166,90 @@ class EmoteCredits(ModerationBase, commands.Cog): ) await interaction.followup.send(embed=embed, ephemeral=True) + @app_commands.command(name="emote_artists", description="List all artists who have credited emotes or stickers") + async def emote_artists(self, interaction: discord.Interaction): + await interaction.response.defer() + + async with aiosqlite.connect(self.db_path) as conn: + async with conn.execute( + "SELECT artist, COUNT(*) as count FROM emote_credits GROUP BY LOWER(artist) ORDER BY count DESC" + ) as cursor: + rows = await cursor.fetchall() + + if not rows: + await interaction.followup.send("No credits in the database yet.", ephemeral=True) + return + + lines = [f"**{artist}** — {count} emote{'s' if count != 1 else ''}" for artist, count in rows] + chunks = [lines[i:i+20] for i in range(0, len(lines), 20)] + + embeds = [] + for i, chunk in enumerate(chunks): + embed = discord.Embed( + title=f"🎨 Emote Artists (Page {i+1}/{len(chunks)})", + description="\n".join(chunk), + color=get_embed_color(interaction.user.id) + ) + embed.set_footer(text=f"Total artists: {len(rows)} • Use /emote_by_artist to see an artist's work") + embeds.append(embed) + + await interaction.followup.send(embed=embeds[0]) + for embed in embeds[1:]: + await interaction.followup.send(embed=embed) + + @app_commands.command(name="emote_by_artist", description="View all emotes and stickers credited to a specific artist") + @app_commands.describe(artist="The artist's name to look up") + async def emote_by_artist(self, interaction: discord.Interaction, artist: str): + await interaction.response.defer() + + async with aiosqlite.connect(self.db_path) as conn: + async with conn.execute( + "SELECT emote_name FROM emote_credits WHERE LOWER(artist) = LOWER(?) ORDER BY emote_name", + (artist,) + ) as cursor: + rows = await cursor.fetchall() + + if not rows: + # Try a partial match and suggest + async with aiosqlite.connect(self.db_path) as conn: + async with conn.execute( + "SELECT DISTINCT artist FROM emote_credits WHERE LOWER(artist) LIKE LOWER(?) ORDER BY artist", + (f"%{artist}%",) + ) as cursor: + suggestions = [r[0] for r in await cursor.fetchall()] + + if suggestions: + await interaction.followup.send( + f"No artist named **{artist}** found. Did you mean: {', '.join(f'`{s}`' for s in suggestions[:5])}?", + ephemeral=True + ) + else: + await interaction.followup.send(f"No artist named **{artist}** found.", ephemeral=True) + return + + emote_names = [r[0] for r in rows] + guild_emoji_map = {e.name.lower(): e for e in interaction.guild.emojis} + + lines = [] + for name in emote_names: + emoji = guild_emoji_map.get(name.lower()) + lines.append(f"{emoji} `{name}`" if emoji else f"`{name}`") + + chunks = [lines[i:i+20] for i in range(0, len(lines), 20)] + embeds = [] + for i, chunk in enumerate(chunks): + embed = discord.Embed( + title=f"🎨 Emotes by {artist} (Page {i+1}/{len(chunks)})", + description="\n".join(chunk), + color=get_embed_color(interaction.user.id) + ) + embed.set_footer(text=f"Total: {len(emote_names)} emote{'s' if len(emote_names) != 1 else ''}") + embeds.append(embed) + + await interaction.followup.send(embed=embeds[0]) + for embed in embeds[1:]: + await interaction.followup.send(embed=embed) + @app_commands.command(name="missing_credits", description="[Admin] List all server emotes and stickers without credits") @app_commands.checks.has_permissions(administrator=True) async def missing_credits(self, interaction: discord.Interaction): diff --git a/events/ping_protect.py b/events/ping_protect.py index 245af58..31e277b 100644 --- a/events/ping_protect.py +++ b/events/ping_protect.py @@ -5,6 +5,7 @@ from pathlib import Path import aiosqlite NO_PINGS_ROLE_ID = 1439583411517001819 +PINGS_OK_ROLE_ID = 1439583327844827227 PROTECTED_USER_ID = 252130669919076352 # only lilac gets ping tracking @@ -72,6 +73,9 @@ class PingProtect(commands.GroupCog, name="noping"): for user in message.mentions: member = message.guild.get_member(user.id) + if member and any(r.id == PINGS_OK_ROLE_ID for r in member.roles): + continue + is_protected = ( user.id == PROTECTED_USER_ID or (member and any(r.id == NO_PINGS_ROLE_ID for r in member.roles)) diff --git a/reminders/reminder.py b/reminders/reminder.py index 563d981..46e3b14 100644 --- a/reminders/reminder.py +++ b/reminders/reminder.py @@ -7,9 +7,38 @@ import re from pathlib import Path from embed.embed_color import get_embed_color from utils.logger import get_logger +from cryptography.fernet import Fernet, InvalidToken +from dateutil import parser as dateutil_parser +from dateutil import tz logger = get_logger(__name__) +KEY_PATH = Path(__file__).parent.parent / "data" / "reminder.key" + + +def load_or_create_key() -> Fernet: + if KEY_PATH.exists(): + key = KEY_PATH.read_bytes() + else: + key = Fernet.generate_key() + KEY_PATH.write_bytes(key) + return Fernet(key) + + +fernet = load_or_create_key() + + +def encrypt(text: str) -> str: + return fernet.encrypt(text.encode()).decode() + + +def decrypt(token: str) -> str: + try: + return fernet.decrypt(token.encode()).decode() + except (InvalidToken, Exception): + # Fallback for any pre-encryption reminders still in the DB + return token + def parse_timeframe(timeframe: str) -> timedelta: """Parse strings like '1h', '2d', '3w', '30m' into timedelta.""" @@ -34,10 +63,47 @@ def parse_timeframe(timeframe: str) -> timedelta: raise ValueError("Invalid time unit.") +def parse_datetime(when: str, tzname: str | None) -> datetime: + """ + Parse a human-readable date/time string into a UTC-aware datetime. + Supports formats like 'March 5 2:30pm', '2026-03-05 14:30', '5th 3pm', etc. + tzname: IANA timezone name or UTC offset string like 'US/Eastern', 'UTC+5', 'EST' + """ + user_tz = tz.UTC + if tzname: + resolved = tz.gettz(tzname) + if resolved is None: + raise ValueError(f"Unknown timezone '{tzname}'. Use an IANA name like 'US/Eastern' or 'UTC+5'.") + user_tz = resolved + + now_user = datetime.now(user_tz) + + try: + # Use now_user as the default so missing date parts fall back to today + parsed = dateutil_parser.parse(when, default=now_user) + except (ValueError, OverflowError): + raise ValueError( + "Couldn't understand that date/time. Try something like:\n" + "`March 5 2:30pm`, `2026-03-05 14:30`, `5th at 9am`" + ) + + # If no timezone info in the parsed string, attach the user's tz + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=user_tz) + + # If the result is in the past and the user didn't specify a year, roll to next year + if parsed < now_user and str(now_user.year) not in when and str(now_user.year - 1) not in when: + parsed = parsed.replace(year=parsed.year + 1) + + if parsed < datetime.now(timezone.utc): + raise ValueError("That time is in the past.") + + return parsed.astimezone(timezone.utc) + + class ReminderCog(commands.Cog): def __init__(self, bot): self.bot = bot - # Database will be stored next to this Python file self.db_path = Path(__file__).parent.parent / "data" / "reminders.db" async def setup_database(self): @@ -53,15 +119,23 @@ class ReminderCog(commands.Cog): await db.commit() async def cog_load(self): - """Called automatically when the cog is added — safe place to start background tasks.""" await self.setup_database() if not self.check_reminders.is_running(): self.check_reminders.start() - # Create reminder command group + async def _insert_reminder(self, user_id: int, message: str, remind_at: datetime) -> int: + async with aiosqlite.connect(self.db_path) as db: + await db.execute( + "INSERT INTO reminders (user_id, message, remind_at) VALUES (?, ?, ?)", + (user_id, encrypt(message), remind_at.isoformat()), + ) + await db.commit() + async with db.execute("SELECT last_insert_rowid()") as cursor: + return (await cursor.fetchone())[0] + reminder_group = app_commands.Group(name="reminder", description="Manage your reminders") - @reminder_group.command(name="set", description="Set a reminder and get a DM when it's time") + @reminder_group.command(name="set", description="Set a reminder using a duration (e.g. '10m', '2h', '3d')") @app_commands.describe( timeframe="How long until reminder (e.g., '10m', '2h', '3d', '1w')", message="What to remind you about" @@ -74,18 +148,11 @@ class ReminderCog(commands.Cog): return await interaction.response.send_message(str(e), ephemeral=True) remind_at = datetime.now(timezone.utc) + delta - async with aiosqlite.connect(self.db_path) as db: - await db.execute( - "INSERT INTO reminders (user_id, message, remind_at) VALUES (?, ?, ?)", - (interaction.user.id, message, remind_at.isoformat()), - ) - await db.commit() + await self._insert_reminder(interaction.user.id, message, remind_at) - # Format the time nicely unix_time = int(remind_at.timestamp()) - time_str = f"" await interaction.response.send_message( - f"✅ Reminder set! I'll DM you about **'{message}'** {time_str}.", + f"✅ Reminder set! I'll DM you about **'{message}'** .", ephemeral=True ) except Exception as e: @@ -93,6 +160,31 @@ class ReminderCog(commands.Cog): if not interaction.response.is_done(): await interaction.response.send_message(f"❌ Error: {e}", ephemeral=True) + @reminder_group.command(name="at", description="Set a reminder for a specific date/time (e.g. 'March 5 2:30pm')") + @app_commands.describe( + when="Date and time for the reminder, e.g. 'March 5 2:30pm', '2026-03-05 14:30', '5th at 9am'", + message="What to remind you about", + timezone="Your timezone, e.g. 'US/Eastern', 'UTC+5', 'Europe/London' (default: UTC)" + ) + async def reminder_at(self, interaction: discord.Interaction, when: str, message: str, timezone: str = None): + try: + try: + remind_at = parse_datetime(when, timezone) + except ValueError as e: + return await interaction.response.send_message(str(e), ephemeral=True) + + await self._insert_reminder(interaction.user.id, message, remind_at) + + unix_time = int(remind_at.timestamp()) + await interaction.response.send_message( + f"✅ Reminder set! I'll DM you about **'{message}'** on ().", + ephemeral=True + ) + except Exception as e: + logger.error(f"Error in reminder_at: {e}", exc_info=True) + if not interaction.response.is_done(): + await interaction.response.send_message(f"❌ Error: {e}", ephemeral=True) + @reminder_group.command(name="list", description="View your active reminders") async def reminder_list(self, interaction: discord.Interaction): async with aiosqlite.connect(self.db_path) as db: @@ -114,19 +206,18 @@ class ReminderCog(commands.Cog): ) for reminder_id, message, remind_at in rows: + message = decrypt(message) remind_time = datetime.fromisoformat(remind_at) unix_time = int(remind_time.timestamp()) - time_str = f"" - - # Calculate time remaining + now = datetime.now(timezone.utc) time_diff = remind_time - now - + if time_diff.total_seconds() > 0: days = time_diff.days hours, remainder = divmod(time_diff.seconds, 3600) minutes, _ = divmod(remainder, 60) - + if days > 0: time_remaining = f"in {days}d {hours}h" elif hours > 0: @@ -135,10 +226,10 @@ class ReminderCog(commands.Cog): time_remaining = f"in {minutes}m" else: time_remaining = "overdue" - + embed.add_field( - name=f"ID: {reminder_id} - {message}", - value=f"⏰ {time_str} ({time_remaining})", + name=f"ID: {reminder_id} — {message}", + value=f"⏰ ({time_remaining})", inline=False ) @@ -149,22 +240,20 @@ class ReminderCog(commands.Cog): @app_commands.describe(reminder_id="The ID of the reminder to remove (from /reminder list)") async def reminder_remove(self, interaction: discord.Interaction, reminder_id: int): async with aiosqlite.connect(self.db_path) as db: - # First check if the reminder exists and belongs to the user async with db.execute( "SELECT message FROM reminders WHERE id = ? AND user_id = ?", (reminder_id, interaction.user.id), ) as cursor: row = await cursor.fetchone() - + if not row: return await interaction.response.send_message( f"❌ Reminder with ID {reminder_id} not found or doesn't belong to you.", ephemeral=True ) - - message = row[0] - - # Delete the reminder + + message = decrypt(row[0]) + await db.execute( "DELETE FROM reminders WHERE id = ? AND user_id = ?", (reminder_id, interaction.user.id), @@ -179,34 +268,26 @@ class ReminderCog(commands.Cog): @reminder_group.command(name="clear", description="Remove all your active reminders") async def reminder_clear(self, interaction: discord.Interaction): async with aiosqlite.connect(self.db_path) as db: - # Check how many reminders the user has async with db.execute( "SELECT COUNT(*) FROM reminders WHERE user_id = ?", (interaction.user.id,), ) as cursor: count = (await cursor.fetchone())[0] - + if count == 0: return await interaction.response.send_message( - "You have no active reminders to clear!", - ephemeral=True + "You have no active reminders to clear!", ephemeral=True ) - - # Delete all reminders for this user - await db.execute( - "DELETE FROM reminders WHERE user_id = ?", - (interaction.user.id,), - ) + + await db.execute("DELETE FROM reminders WHERE user_id = ?", (interaction.user.id,)) await db.commit() await interaction.response.send_message( - f"✅ Cleared all {count} reminder(s)!", - ephemeral=True + f"✅ Cleared all {count} reminder(s)!", ephemeral=True ) @tasks.loop(seconds=60) async def check_reminders(self): - """Runs every minute, checks and delivers reminders.""" now = datetime.now(timezone.utc) async with aiosqlite.connect(self.db_path) as db: async with db.execute( @@ -216,6 +297,7 @@ class ReminderCog(commands.Cog): reminders_due = await cursor.fetchall() for reminder_id, user_id, message in reminders_due: + message = decrypt(message) user = self.bot.get_user(user_id) if user: try: @@ -227,7 +309,7 @@ class ReminderCog(commands.Cog): ) await user.send(embed=embed) except discord.Forbidden: - pass # user has DMs disabled or bot blocked + pass await db.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,)) await db.commit() @@ -238,4 +320,4 @@ class ReminderCog(commands.Cog): async def setup(bot): - await bot.add_cog(ReminderCog(bot)) \ No newline at end of file + await bot.add_cog(ReminderCog(bot)) diff --git a/suggestion/suggest.py b/suggestion/suggest.py index c72b589..17a83fc 100644 --- a/suggestion/suggest.py +++ b/suggestion/suggest.py @@ -643,6 +643,46 @@ class Suggestion(commands.GroupCog, name="suggest"): logger.error(f"Error in listsuggestions command: {e}", exc_info=True) await interaction.followup.send(error_msg[:2000]) + @app_commands.command(name="stats", description="View suggestion stats for yourself or another user") + async def suggestionstats(self, interaction: discord.Interaction, member: Optional[discord.Member] = None): + await interaction.response.defer(ephemeral=False) + + try: + target = member or interaction.user + + async with self.db.execute( + "SELECT COUNT(*) FROM suggestions WHERE user_id = ?", (target.id,) + ) as cursor: + total = (await cursor.fetchone())[0] + + async with self.db.execute( + "SELECT COUNT(*) FROM suggestions WHERE user_id = ? AND status IN (?, ?)", + (target.id, "Approved", "Completed") + ) as cursor: + accepted = (await cursor.fetchone())[0] + + async with self.db.execute( + "SELECT COUNT(*) FROM suggestions WHERE user_id = ? AND status = ?", + (target.id, "Denied") + ) as cursor: + rejected = (await cursor.fetchone())[0] + + embed = discord.Embed( + title=f"📊 Suggestion Stats — {target.display_name}", + color=get_embed_color(target.id) + ) + embed.set_thumbnail(url=target.display_avatar.url) + embed.add_field(name="Total Submitted", value=str(total), inline=True) + embed.add_field(name="Accepted", value=str(accepted), inline=True) + embed.add_field(name="Rejected", value=str(rejected), inline=True) + + await interaction.followup.send(embed=embed) + + except Exception as e: + error_msg = f"❌ An error occurred: {str(e)}\n```{traceback.format_exc()}```" + logger.error(f"Error in suggestionstats command: {e}", exc_info=True) + await interaction.followup.send(error_msg[:2000]) + @app_commands.command(name="todo", description="View your approved suggestions to-do list") async def todolist(self, interaction: discord.Interaction, member: Optional[discord.Member] = None): await interaction.response.defer(ephemeral=False)