mirror of
https://github.com/Lilac-Rose/Lacie.git
synced 2026-03-21 17:54:50 -05:00
emote credit artist stuff, suggestion stats, better reminder syntax. encrypted reminders in db
This commit is contained in:
parent
a7dde9ba2d
commit
4a6bc3f42a
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user