mirror of
https://github.com/Lilac-Rose/Lacie.git
synced 2026-09-29 19:56:45 -05:00
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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
1
bot.py
@@ -35,7 +35,6 @@ COG_FOLDERS = [
|
||||
"stats",
|
||||
"wordle",
|
||||
"reminders",
|
||||
"arg",
|
||||
]
|
||||
|
||||
# --- Bot setup ---
|
||||
|
||||
97
events/daily_fractal.py
Normal file
97
events/daily_fractal.py
Normal 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))
|
||||
@@ -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))
|
||||
186
image/avatar.py
186
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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user