From 402caab22d40b0c3adc1d8bcd45e7dd710b20010 Mon Sep 17 00:00:00 2001 From: Lilac-Rose Date: Fri, 27 Mar 2026 18:07:24 +0100 Subject: [PATCH] feat: add daily fractal poster, per-command stats, and misc fixes daily fractal: - add events/daily_fractal.py: posts today's fractal from lilacrose.dev to channel 876772600704020533 daily at noon UTC - palette name derived from seed as fallback for cached fractals - add !fractal owner-only command to manually trigger a fractal post stats: - add per_command_usage table to track usage per command name - increment_usage now accepts command name from both prefix and slash commands - expose per-command breakdown in stats payload avatar bad apple: - add invert parameter to flip dark/light regions - add real-time render and encode progress bars via interaction edits - process frames in chunks to cap peak RAM usage - track and cancel active render tasks and ffmpeg processes on cog unload - switch output to mp4 only (drop webm/alpha path), use ultrafast preset - check file size against guild limit before uploading - remove 30-second frame cap moderation: - infraction auto-removal: skip users who left and rejoined after infraction date - add Reason field to infraction removal approval and denial embeds send_embed: support zlib-compressed embed strings with legacy fallback chore: - remove events/status_alerts.py - remove arg from COG_FOLDERS - update .gitignore: add *.zip, bad_apple.mp3, */bad-apple/ --- .gitignore | 3 + bot.py | 1 - events/daily_fractal.py | 97 +++++++++++++++++ events/status_alerts.py | 222 --------------------------------------- image/avatar.py | 186 ++++++++++++++++++++++---------- moderation/infraction.py | 14 ++- moderation/send_embed.py | 9 +- stats/stats.py | 32 +++++- 8 files changed, 277 insertions(+), 287 deletions(-) create mode 100644 events/daily_fractal.py delete mode 100644 events/status_alerts.py diff --git a/.gitignore b/.gitignore index 0b4bc42..5cfcc49 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ venv/ .txt logs/ data/ +*.zip +bad_apple.mp3 +*/bad-apple/ # ARG — keep out of public repo to prevent spoilers arg/ diff --git a/bot.py b/bot.py index 23e0fe4..34d3603 100644 --- a/bot.py +++ b/bot.py @@ -35,7 +35,6 @@ COG_FOLDERS = [ "stats", "wordle", "reminders", - "arg", ] # --- Bot setup --- diff --git a/events/daily_fractal.py b/events/daily_fractal.py new file mode 100644 index 0000000..fb3e8f4 --- /dev/null +++ b/events/daily_fractal.py @@ -0,0 +1,97 @@ +import io +import aiohttp +import discord +from discord.ext import commands, tasks +from datetime import datetime, time, timezone +from utils.logger import get_logger + +logger = get_logger(__name__) + +FRACTAL_CHANNEL_ID = 876772600704020533 +WEBSITE_BASE = "http://localhost:3000" +OWNER_ID = 252130669919076352 + +PALETTE_NAMES = [ + "Fire", "Ocean", "Forest", "Sunset", "Purple Dream", + "Toxic", "Ice", "Copper", "Neon Pink", "Volcanic", + "Electric Blue", "Autumn", "Candy", "Matrix", "Deep Sea", + "Crimson", "Gold Rush", "Midnight", "Lava", "Rainbow", +] + +DAILY_POST_TIME = time(hour=12, minute=0, tzinfo=timezone.utc) + + +class DailyFractal(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.daily_fractal.start() + + def cog_unload(self): + self.daily_fractal.cancel() + + async def _post_fractal(self): + channel = self.bot.get_channel(FRACTAL_CHANNEL_ID) + if channel is None: + logger.error("Daily fractal: channel not found") + return + + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + + try: + async with aiohttp.ClientSession() as session: + async with session.get(f"{WEBSITE_BASE}/fractal/api/today") as resp: + if resp.status != 200: + logger.error(f"Daily fractal: API returned {resp.status}") + return + data = await resp.json() + + meta = data.get("metadata", {}) + + async with session.get(f"{WEBSITE_BASE}/fractal/api/image/{today}") as img_resp: + if img_resp.status != 200: + logger.error(f"Daily fractal: image fetch returned {img_resp.status}") + return + image_bytes = await img_resp.read() + + except Exception as e: + logger.error(f"Daily fractal: failed to fetch fractal: {e}") + return + + fractal_name = meta.get("name", "Unknown") + fractal_type = meta.get("type", "unknown").replace("_", " ").title() + seed = meta.get("seed") + palette = meta.get("palette") or (PALETTE_NAMES[seed % len(PALETTE_NAMES)] if seed is not None else "Unknown") + + embed = discord.Embed( + title=f"Fractal of the Day — {today}", + description=f"**{fractal_name}**", + color=0xB266FF, + ) + embed.add_field(name="Type", value=fractal_type, inline=True) + embed.add_field(name="Palette", value=palette, inline=True) + embed.add_field(name="Seed", value=str(seed) if seed is not None else "Unknown", inline=True) + embed.set_image(url="attachment://fractal.png") + embed.set_footer(text="lilacrose.dev/fractal") + + file = discord.File(io.BytesIO(image_bytes), filename="fractal.png") + await channel.send(embed=embed, file=file) + logger.info(f"Daily fractal posted for {today}: {fractal_name}") + + @tasks.loop(time=DAILY_POST_TIME) + async def daily_fractal(self): + await self._post_fractal() + + @commands.command(name="fractal") + async def fractal_command(self, ctx): + if ctx.author.id != OWNER_ID: + return + await ctx.message.delete() + await self._post_fractal() + + @daily_fractal.before_loop + async def before_daily_fractal(self): + await self.bot.wait_until_ready() + + +async def setup(bot): + await bot.add_cog(DailyFractal(bot)) diff --git a/events/status_alerts.py b/events/status_alerts.py deleted file mode 100644 index ce64d05..0000000 --- a/events/status_alerts.py +++ /dev/null @@ -1,222 +0,0 @@ -import discord -from discord.ext import commands, tasks -import sqlite3 -import os -from pathlib import Path -from datetime import datetime -from zoneinfo import ZoneInfo -from utils.logger import get_logger - -logger = get_logger(__name__) - -class StatusMonitor(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.db_path = Path("/home/lilacrose/lilacrose.dev2.0/monitor.db") - self.admin_user_id = 252130669919076352 - self.last_status = {} - self.check_status.start() - - def cog_unload(self): - """Stop the monitoring loop when cog is unloaded""" - self.check_status.cancel() - - @tasks.loop(minutes=1) - async def check_status(self): - """Check service status every minute""" - try: - if not self.db_path.exists(): - return - - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row - cur = conn.cursor() - - cur.execute(""" - SELECT service_name, status, error_message, timestamp - FROM service_checks - WHERE id IN ( - SELECT MAX(id) - FROM service_checks - GROUP BY service_name - ) - """) - - current_statuses = {row['service_name']: row for row in cur.fetchall()} - conn.close() - - admin = await self.bot.fetch_user(self.admin_user_id) - if not admin: - return - - for service_name, row in current_statuses.items(): - current_status = row['status'] - error_msg = row['error_message'] - - service_display_names = { - "website": "TERMINAL//FKLR-F23", - "game_tracker": "GAME_TRACKER//FKLR-F23", - "forms_server": "Lacie Bot Website", - "file_server": "File Server", - "discord_bot": "Lacie Bot" - } - display_name = service_display_names.get(service_name, service_name) - - # If we haven't seen this service before, just store it - if service_name not in self.last_status: - self.last_status[service_name] = current_status - continue - - previous_status = self.last_status[service_name] - - # Service went DOWN - if current_status == "down" and previous_status != "down": - embed = discord.Embed( - title="🔴 Service Down Alert", - description=f"**{display_name}** has gone offline", - color=discord.Color.red(), - timestamp=datetime.now(ZoneInfo("America/New_York")) - ) - if error_msg: - embed.add_field(name="Error", value=f"```{error_msg}```", inline=False) - embed.add_field(name="Service ID", value=f"`{service_name}`", inline=True) - embed.set_footer(text="Status Monitor Alert") - - try: - await admin.send(embed=embed) - except Exception as e: - logger.error(f"Failed to send down alert for {service_name}: {e}") - - # Service went DEGRADED - elif current_status == "degraded" and previous_status == "up": - embed = discord.Embed( - title="⚠️ Service Degraded", - description=f"**{display_name}** is experiencing issues", - color=discord.Color.orange(), - timestamp=datetime.now(ZoneInfo("America/New_York")) - ) - if error_msg: - embed.add_field(name="Error", value=f"```{error_msg}```", inline=False) - embed.add_field(name="Service ID", value=f"`{service_name}`", inline=True) - embed.set_footer(text="Status Monitor Alert") - - try: - await admin.send(embed=embed) - except Exception as e: - logger.error(f"Failed to send degraded alert for {service_name}: {e}") - - # Service RECOVERED - elif current_status == "up" and previous_status in ["down", "degraded"]: - # Calculate downtime - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row - cur = conn.cursor() - - cur.execute(""" - SELECT started_at, ended_at, duration_seconds - FROM downtime_incidents - WHERE service_name = ? AND ended_at IS NOT NULL - ORDER BY id DESC LIMIT 1 - """, (service_name,)) - - incident = cur.fetchone() - conn.close() - - embed = discord.Embed( - title="✅ Service Recovered", - description=f"**{display_name}** is back online", - color=discord.Color.green(), - timestamp=datetime.now(ZoneInfo("America/New_York")) - ) - - if incident and incident['duration_seconds']: - duration = incident['duration_seconds'] - if duration < 60: - duration_str = f"{int(duration)} seconds" - elif duration < 3600: - duration_str = f"{int(duration / 60)} minutes" - else: - duration_str = f"{duration / 3600:.1f} hours" - embed.add_field(name="Downtime", value=duration_str, inline=True) - - embed.add_field(name="Service ID", value=f"`{service_name}`", inline=True) - embed.set_footer(text="Status Monitor Alert") - - try: - await admin.send(embed=embed) - except Exception as e: - logger.error(f"Failed to send recovery alert for {service_name}: {e}") - - # Update last known status - self.last_status[service_name] = current_status - - except Exception as e: - logger.error(f"Error checking status: {e}", exc_info=True) - - @check_status.before_loop - async def before_check_status(self): - """Wait for bot to be ready before starting the loop""" - await self.bot.wait_until_ready() - logger.info("Starting status monitoring loop...") - - # Initialize last_status with current state on startup - try: - if self.db_path.exists(): - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row - cur = conn.cursor() - - cur.execute(""" - SELECT service_name, status - FROM service_checks - WHERE id IN ( - SELECT MAX(id) - FROM service_checks - GROUP BY service_name - ) - """) - - for row in cur.fetchall(): - self.last_status[row['service_name']] = row['status'] - - conn.close() - logger.info(f"Initialized with {len(self.last_status)} services") - except Exception as e: - logger.error(f"Failed to initialize: {e}", exc_info=True) - - @commands.command(name="statustest") - @commands.is_owner() - async def status_test(self, ctx): - """Test the status monitoring alerts (Owner only)""" - embed = discord.Embed( - title="🔔 Status Monitor Test", - description="This is a test alert from the status monitoring system", - color=discord.Color.blue(), - timestamp=datetime.now(ZoneInfo("America/New_York")) - ) - embed.add_field(name="Database Path", value=f"`{self.db_path}`", inline=False) - embed.add_field(name="Database Exists", value="✅ Yes" if self.db_path.exists() else "❌ No", inline=True) - embed.add_field(name="Monitoring", value=f"{len(self.last_status)} services", inline=True) - embed.add_field(name="Admin User ID", value=f"`{self.admin_user_id}`", inline=True) - - if self.last_status: - status_list = "\n".join([f"• `{name}`: {status}" for name, status in self.last_status.items()]) - embed.add_field(name="Current Status", value=status_list, inline=False) - - await ctx.send(embed=embed) - - # Try to DM the admin - try: - admin = await self.bot.fetch_user(self.admin_user_id) - test_embed = discord.Embed( - title="✅ DM Test Successful", - description="If you're seeing this, status alerts will work!", - color=discord.Color.green() - ) - await admin.send(embed=test_embed) - await ctx.send("✅ Test DM sent successfully!") - except Exception as e: - await ctx.send(f"❌ Failed to send test DM: {e}") - -async def setup(bot): - await bot.add_cog(StatusMonitor(bot)) \ No newline at end of file diff --git a/image/avatar.py b/image/avatar.py index e9e2bc1..6bebf81 100644 --- a/image/avatar.py +++ b/image/avatar.py @@ -18,17 +18,12 @@ from utils.logger import get_logger logger = get_logger(__name__) def _composite_frame(dark, changed, dual, frame_h, frame_w, t1_rgb, t1_alpha, t2_rgb, t2_alpha): + output = np.zeros((frame_h, frame_w, 3), dtype=np.uint8) + show1 = dark & changed & (t1_alpha > 0) + output[show1] = t1_rgb[show1] if dual: - output = np.zeros((frame_h, frame_w, 3), dtype=np.uint8) - show1 = dark & changed & (t1_alpha > 0) show2 = ~dark & changed & (t2_alpha > 0) - output[show1] = t1_rgb[show1] output[show2] = t2_rgb[show2] - else: - output = np.zeros((frame_h, frame_w, 4), dtype=np.uint8) - show1 = dark & changed & (t1_alpha > 0) - output[show1, :3] = t1_rgb[show1] - output[show1, 3] = t1_alpha[show1] return output.tobytes() class AvatarCommands(commands.Cog): @@ -40,15 +35,24 @@ class AvatarCommands(commands.Cog): self.bad_apple_path = Path(__file__).parent.parent / "media" / "bad-apple" self.bad_apple_audio_path = Path(__file__).parent.parent / "media" / "bad_apple.mp3" self._bad_apple_frames: list[Path] = [] - + self._active_renders: set[asyncio.Task] = set() + self._active_ffmpeg: set = set() # set of asyncio.subprocess.Process + async def cog_load(self): self.session = aiohttp.ClientSession() if self.bad_apple_path.exists(): self._bad_apple_frames = sorted(self.bad_apple_path.glob("*.jpg")) - + async def cog_unload(self): if self.session: await self.session.close() + for task in list(self._active_renders): + task.cancel() + for proc in list(self._active_ffmpeg): + try: + proc.kill() + except Exception: + pass def get_avatar_url(self, user, avatar_type_choice): """Returns a valid avatar object (never None).""" @@ -544,7 +548,7 @@ class AvatarCommands(commands.Cog): app_commands.Choice(name="Global Avatar", value="global") ] ) - async def avatar_bad_apple(self, interaction: discord.Interaction, user: discord.User = None, user2: discord.User = None, tile_count: int = 16, delta_only: bool = False, avatar_type: app_commands.Choice[str] = None): + async def avatar_bad_apple(self, interaction: discord.Interaction, user: discord.User = None, user2: discord.User = None, tile_count: int = 16, delta_only: bool = False, invert: bool = False, avatar_type: app_commands.Choice[str] = None): await interaction.response.defer(thinking=True) user = user or interaction.user @@ -557,6 +561,8 @@ class AvatarCommands(commands.Cog): await interaction.followup.send("Error: Bad Apple frames not found.", ephemeral=True) return + task = asyncio.current_task() + self._active_renders.add(task) try: if not self.session or self.session.closed: self.session = aiohttp.ClientSession() @@ -581,21 +587,68 @@ class AvatarCommands(commands.Cog): audio_offset = await self._detect_audio_offset(ffmpeg_exe) frame_skip = int(audio_offset * 30) - frame_w, frame_h, has_alpha = await asyncio.to_thread( - self._process_bad_apple_frames, avatar_bytes, tile_count, raw_path, avatar2_bytes, frame_skip, delta_only + loop = asyncio.get_event_loop() + + async def update_progress(done: int, total: int) -> None: + pct = done / total * 100 + filled = int(pct / 5) + bar = "█" * filled + "░" * (20 - filled) + try: + await interaction.edit_original_response( + content=f"-# Rendering... `[{bar}]` {pct:.0f}% ({done}/{total} frames)" + ) + except Exception: + pass + + def progress_callback(done: int, total: int) -> None: + asyncio.run_coroutine_threadsafe(update_progress(done, total), loop) + + frame_w, frame_h = await asyncio.to_thread( + self._process_bad_apple_frames, avatar_bytes, tile_count, raw_path, avatar2_bytes, frame_skip, delta_only, progress_callback, invert ) - buf = await self._encode_bad_apple_video(raw_path, frame_w, frame_h, has_alpha, ffmpeg_exe, audio_offset) + total_frames = len(self._bad_apple_frames) - frame_skip + + async def update_encode_progress(done: int, total: int) -> None: + pct = done / total * 100 + filled = int(pct / 5) + bar = "█" * filled + "░" * (20 - filled) + try: + await interaction.edit_original_response( + content=f"-# Encoding... `[{bar}]` {pct:.0f}% ({done}/{total} frames)" + ) + except Exception: + pass + + def encode_progress_callback(done: int, total: int) -> None: + asyncio.run_coroutine_threadsafe(update_encode_progress(done, total), loop) + + buf = await self._encode_bad_apple_video(raw_path, frame_w, frame_h, ffmpeg_exe, audio_offset, total_frames, encode_progress_callback) label = f"{user.display_name} vs {user2.display_name}" if user2 else user.display_name - ext = "webm" if has_alpha else "mp4" + + file_size = buf.getbuffer().nbytes + limit = interaction.guild.filesize_limit if interaction.guild else 8_388_608 + if file_size > limit: + mb = file_size / 1_048_576 + limit_mb = limit / 1_048_576 + await interaction.followup.send( + f"The rendered video is {mb:.1f} MB, which exceeds this server's upload limit of {limit_mb:.0f} MB.", + ephemeral=True + ) + return + await interaction.followup.send( f"Bad Apple, but it's {label}:", - file=discord.File(buf, filename=f"bad_apple.{ext}") + file=discord.File(buf, filename="bad_apple.mp4") ) + except asyncio.CancelledError: + pass except Exception: logger.exception("Error in avatar bad_apple") await interaction.followup.send("An error occurred while generating the video.", ephemeral=True) + finally: + self._active_renders.discard(task) def _build_tiled(self, avatar_bytes: bytes, frame_w: int, frame_h: int, tile_count: int) -> np.ndarray: """Build a tiled RGBA canvas of the avatar at frame resolution.""" @@ -607,14 +660,11 @@ class AvatarCommands(commands.Cog): tiles_y = -(-frame_h // tile_h) return np.tile(tile_arr, (tiles_y, tiles_x, 1))[:frame_h, :frame_w] - def _process_bad_apple_frames(self, avatar_bytes: bytes, tile_count: int, raw_path: str, avatar2_bytes: bytes | None = None, frame_skip: int = 0, delta_only: bool = False) -> tuple[int, int, bool]: + def _process_bad_apple_frames(self, avatar_bytes: bytes, tile_count: int, raw_path: str, avatar2_bytes: bytes | None = None, frame_skip: int = 0, delta_only: bool = False, progress_callback=None, invert: bool = False) -> tuple[int, int]: """Process frames and write raw video data to raw_path. Returns (frame_w, frame_h, has_alpha).""" from concurrent.futures import ThreadPoolExecutor - FPS = 30 - MAX_FRAMES = FPS * 30 - - frame_files = (self._bad_apple_frames or sorted(self.bad_apple_path.glob("*.jpg")))[frame_skip:frame_skip + MAX_FRAMES] + frame_files = (self._bad_apple_frames or sorted(self.bad_apple_path.glob("*.jpg")))[frame_skip:] if not frame_files: raise ValueError("No Bad Apple frames found") @@ -632,38 +682,47 @@ class AvatarCommands(commands.Cog): t2_rgb = t2_alpha = None workers = min(8, os.cpu_count() or 4) + # Process in chunks so only ~CHUNK frames are in RAM at once + CHUNK = 240 # ~160 MB peak for RGBA at 480x360 + ones = np.ones((frame_h, frame_w), dtype=bool) def load_mask(path): return np.array(Image.open(path).convert("L").resize((frame_w, frame_h), Image.Resampling.NEAREST)) - if delta_only: - # Need all masks up front so each frame can reference the previous one - with ThreadPoolExecutor(max_workers=workers) as ex: - masks = list(ex.map(load_mask, frame_files)) + def load_and_composite(path): + mask = load_mask(path) < 128 + return _composite_frame(~mask if invert else mask, ones, dual, frame_h, frame_w, t1_rgb, t1_alpha, t2_rgb, t2_alpha) - def composite(i): - mask = masks[i] - dark = mask < 128 - changed = np.abs(mask.astype(np.int16) - masks[i - 1].astype(np.int16)) > 20 if i > 0 else np.ones((frame_h, frame_w), dtype=bool) - return _composite_frame(dark, changed, dual, frame_h, frame_w, t1_rgb, t1_alpha, t2_rgb, t2_alpha) - - with ThreadPoolExecutor(max_workers=workers) as ex: - frame_bytes = list(ex.map(composite, range(len(frame_files)))) - else: - # Single pass: load + composite together, no intermediate mask storage - ones = np.ones((frame_h, frame_w), dtype=bool) - - def load_and_composite(path): - mask = load_mask(path) - return _composite_frame(mask < 128, ones, dual, frame_h, frame_w, t1_rgb, t1_alpha, t2_rgb, t2_alpha) - - with ThreadPoolExecutor(max_workers=workers) as ex: - frame_bytes = list(ex.map(load_and_composite, frame_files)) + total = len(frame_files) + done = 0 with open(raw_path, "wb") as f: - f.writelines(frame_bytes) + if not delta_only: + for i in range(0, total, CHUNK): + chunk = frame_files[i:i + CHUNK] + with ThreadPoolExecutor(max_workers=workers) as ex: + for fb in ex.map(load_and_composite, chunk): + f.write(fb) + done += len(chunk) + if progress_callback: + progress_callback(done, total) + else: + prev_mask = None + for i in range(0, total, CHUNK): + chunk = frame_files[i:i + CHUNK] + with ThreadPoolExecutor(max_workers=workers) as ex: + chunk_masks = list(ex.map(load_mask, chunk)) + for j, mask in enumerate(chunk_masks): + dark = ~(mask < 128) if invert else (mask < 128) + ref = chunk_masks[j - 1] if j > 0 else prev_mask + changed = np.abs(mask.astype(np.int16) - ref.astype(np.int16)) > 20 if ref is not None else ones + f.write(_composite_frame(dark, changed, dual, frame_h, frame_w, t1_rgb, t1_alpha, t2_rgb, t2_alpha)) + prev_mask = chunk_masks[-1] + done += len(chunk) + if progress_callback: + progress_callback(done, total) - return frame_w, frame_h, not dual + return frame_w, frame_h async def _detect_audio_offset(self, ffmpeg_exe: str) -> float: """Detect duration of silence at the start of the Bad Apple audio.""" @@ -684,46 +743,59 @@ class AvatarCommands(commands.Cog): pass return 0.0 - async def _encode_bad_apple_video(self, raw_path: str, frame_w: int, frame_h: int, has_alpha: bool = True, ffmpeg_exe: str = None, audio_offset: float = 0.0) -> io.BytesIO: + async def _encode_bad_apple_video(self, raw_path: str, frame_w: int, frame_h: int, ffmpeg_exe: str = None, audio_offset: float = 0.0, total_frames: int = 0, progress_callback=None) -> io.BytesIO: """Encode raw frames + audio into MP4 (dual-user) or WebM with alpha (single-user).""" if ffmpeg_exe is None: ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe() - suffix = ".webm" if has_alpha else ".mp4" + suffix = ".mp4" tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) tmp.close() out_path = tmp.name - if has_alpha: - video_flags = ["-vcodec", "libvpx-vp9", "-pix_fmt", "yuva420p", "-auto-alt-ref", "0", "-crf", "30", "-b:v", "0"] - else: - video_flags = ["-vcodec", "libx264", "-pix_fmt", "yuv420p", "-preset", "fast", "-crf", "23"] + encode_threads = str(max(1, (os.cpu_count() or 4) // 2)) + video_flags = ["-vcodec", "libx264", "-pix_fmt", "yuv420p", "-preset", "ultrafast", "-crf", "23", "-threads", encode_threads] - # Use atrim filter to cut silence at sample level and reset timestamps to 0, - # so the output file contains zero leading silence for players to strip. audio_filter = f"atrim=start={audio_offset:.6f},asetpts=PTS-STARTPTS" if audio_offset > 0 else "anull" cmd = [ ffmpeg_exe, "-y", + "-progress", "pipe:1", "-nostats", "-loglevel", "error", "-f", "rawvideo", "-vcodec", "rawvideo", "-s", f"{frame_w}x{frame_h}", - "-pix_fmt", "rgba" if has_alpha else "rgb24", "-r", "30", + "-pix_fmt", "rgb24", "-r", "30", "-i", raw_path, "-i", str(self.bad_apple_audio_path), "-map", "0:v", "-map", "1:a", "-af", audio_filter, *video_flags, - "-t", "30", "-shortest", out_path, ] proc = await asyncio.create_subprocess_exec( *cmd, - stdout=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, ) - await proc.wait() + self._active_ffmpeg.add(proc) + try: + async def read_progress(): + while True: + line = await proc.stdout.readline() + if not line: + break + text = line.decode().strip() + if text.startswith("frame=") and progress_callback and total_frames: + try: + done = int(text.split("=", 1)[1]) + progress_callback(done, total_frames) + except ValueError: + pass + + await asyncio.gather(proc.wait(), read_progress()) + finally: + self._active_ffmpeg.discard(proc) with open(out_path, "rb") as f: video_bytes = f.read() diff --git a/moderation/infraction.py b/moderation/infraction.py index 100777a..bc47043 100644 --- a/moderation/infraction.py +++ b/moderation/infraction.py @@ -124,7 +124,7 @@ class InfractionCommand(ModerationBase): member = guild.get_member(user_id) if not member: return # User not in server, skip - + # Get the most recent ACTIVE infraction for this user (not removed, not skipped, not pending) self.c.execute(""" SELECT id, timestamp, type, reason, moderator_id @@ -140,11 +140,19 @@ class InfractionCommand(ModerationBase): inf_id, timestamp_str, inf_type, reason, mod_id = most_recent infraction_date = datetime.fromisoformat(timestamp_str) - + # Check if 4 months have passed four_months_ago = datetime.utcnow() - timedelta(days=120) if infraction_date > four_months_ago: return # Not eligible yet + + # Check that the user hasn't left and rejoined since the infraction. + # If joined_at is after the infraction date, they left and came back — clock resets. + joined_at = member.joined_at + if joined_at: + joined_naive = joined_at.replace(tzinfo=None) + if joined_naive > infraction_date: + return # Left and rejoined after infraction, not eligible # Check if user got ANY infractions after this one self.c.execute(""" @@ -591,6 +599,7 @@ class InfractionRemovalView(discord.ui.View): embed.add_field(name="User", value=self.user_tag, inline=True) embed.add_field(name="Infraction ID", value=str(self.inf_id), inline=True) embed.add_field(name="Type", value=self.inf_type, inline=True) + embed.add_field(name="Reason", value=self.reason or "None", inline=False) embed.add_field(name="Original Date", value=self.timestamp.replace("T", " ")[:19], inline=True) embed.add_field(name="Removed By", value=interaction.user.mention, inline=True) embed.add_field(name="Removed At", value=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), inline=True) @@ -646,6 +655,7 @@ class InfractionRemovalView(discord.ui.View): embed.add_field(name="User", value=self.user_tag, inline=True) embed.add_field(name="Infraction ID", value=str(self.inf_id), inline=True) embed.add_field(name="Type", value=self.inf_type, inline=True) + embed.add_field(name="Reason", value=self.reason or "None", inline=False) embed.add_field(name="Original Date", value=self.timestamp.replace("T", " ")[:19], inline=True) embed.add_field(name="Decision By", value=interaction.user.mention, inline=True) embed.add_field(name="Decision At", value=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), inline=True) diff --git a/moderation/send_embed.py b/moderation/send_embed.py index bd04c06..3747edd 100644 --- a/moderation/send_embed.py +++ b/moderation/send_embed.py @@ -2,6 +2,7 @@ import discord from discord.ext import commands from discord.ui import View, Button import base64 +import zlib import json from .loader import ModerationBase @@ -17,8 +18,12 @@ class SendEmbedCommand(ModerationBase): # Decode the embed string try: async with ctx.typing(): - decoded_json = base64.urlsafe_b64decode(embed_string.encode()).decode() - embed_data = json.loads(decoded_json) + raw = base64.urlsafe_b64decode(embed_string.encode()) + try: + raw = zlib.decompress(raw) + except zlib.error: + pass # Legacy uncompressed string + embed_data = json.loads(raw.decode()) # Ensure embed_data is a list (for embed chains) if not isinstance(embed_data, list): diff --git a/stats/stats.py b/stats/stats.py index 564535a..ab669fa 100644 --- a/stats/stats.py +++ b/stats/stats.py @@ -62,6 +62,13 @@ class Stats(commands.Cog): if cursor.fetchone() is None: cursor.execute("INSERT INTO command_usage (id, total) VALUES (1, 0)") + cursor.execute(""" + CREATE TABLE IF NOT EXISTS per_command_usage ( + command_name TEXT PRIMARY KEY, + count INTEGER DEFAULT 0 + ) + """) + cursor.execute(""" CREATE TABLE IF NOT EXISTS message_stats ( channel_id INTEGER PRIMARY KEY, @@ -87,13 +94,27 @@ class Stats(commands.Cog): db.commit() db.close() - def increment_usage(self): + def increment_usage(self, command_name: str = None): db = sqlite3.connect(DB_PATH) cursor = db.cursor() cursor.execute("UPDATE command_usage SET total = total + 1 WHERE id=1") + if command_name: + cursor.execute(""" + INSERT INTO per_command_usage (command_name, count) + VALUES (?, 1) + ON CONFLICT(command_name) DO UPDATE SET count = count + 1 + """, (command_name,)) db.commit() db.close() + def get_per_command_usage(self) -> List[Tuple[str, int]]: + db = sqlite3.connect(DB_PATH) + cursor = db.cursor() + cursor.execute("SELECT command_name, count FROM per_command_usage ORDER BY count DESC") + results = cursor.fetchall() + db.close() + return results + def get_usage(self): db = sqlite3.connect(DB_PATH) cursor = db.cursor() @@ -286,6 +307,7 @@ class Stats(commands.Cog): avg_messages_per_day = self.get_average_messages_per_day() total_messages = self.get_total_messages() top_words = self.get_top_words(10) + per_command = self.get_per_command_usage() return { 'server': { @@ -317,18 +339,22 @@ class Stats(commands.Cog): }, 'averageMessagesPerDay': avg_messages_per_day, 'topWords': [{'word': word, 'count': count} for word, count in top_words] + }, + 'commandStats': { + 'perCommand': [{'command': name, 'count': count} for name, count in per_command] } } @commands.Cog.listener() async def on_command(self, ctx): - self.increment_usage() + self.increment_usage(ctx.command.qualified_name if ctx.command else None) @commands.Cog.listener() async def on_interaction(self, interaction: discord.Interaction): # Only count application commands (slash commands) if interaction.type == discord.InteractionType.application_command: - self.increment_usage() + name = interaction.data.get("name") if interaction.data else None + self.increment_usage(name) @commands.Cog.listener() async def on_message(self, message):