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/
This commit is contained in:
Lilac-Rose
2026-03-27 18:07:24 +01:00
parent 6680a3f74c
commit 402caab22d
8 changed files with 277 additions and 287 deletions

3
.gitignore vendored
View File

@@ -8,6 +8,9 @@ venv/
.txt
logs/
data/
*.zip
bad_apple.mp3
*/bad-apple/
# ARG — keep out of public repo to prevent spoilers
arg/

1
bot.py
View File

@@ -35,7 +35,6 @@ COG_FOLDERS = [
"stats",
"wordle",
"reminders",
"arg",
]
# --- Bot setup ---

97
events/daily_fractal.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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