emote credit artist stuff, suggestion stats, better reminder syntax. encrypted reminders in db

This commit is contained in:
Lilac-Rose 2026-03-01 14:47:04 +01:00
parent a7dde9ba2d
commit 4a6bc3f42a
4 changed files with 252 additions and 42 deletions

View File

@ -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):

View File

@ -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))

View File

@ -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"<t:{unix_time}:R>"
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}'** <t:{unix_time}:R>.",
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 <t:{unix_time}:F> (<t:{unix_time}:R>).",
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"<t:{unix_time}:R>"
# 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"<t:{unix_time}: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))
await bot.add_cog(ReminderCog(bot))

View File

@ -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)