diff --git a/newhelpmenu/converter.py b/newhelpmenu/converter.py index a2a0c50..cbf5a25 100644 --- a/newhelpmenu/converter.py +++ b/newhelpmenu/converter.py @@ -17,19 +17,29 @@ from discord import ui # ──────────────────────────── helpers ──────────────────────────── def _truncate(text: str, limit: int = 4000) -> str: - """Truncate to Discord's TextDisplay limit.""" + """Truncate to Discord's TextDisplay limit, attempting to preserve word boundaries.""" if len(text) <= limit: return text - return text[: limit - 3] + "..." + + # Try to break at last space before limit + truncated = text[: limit - 3] + last_space = truncated.rfind(" ") + if last_space > limit * 0.8: # Only use space if it's not too far back + truncated = truncated[:last_space] + + return truncated + "..." def _field_to_md(field: Any) -> str: - """Convert an embed field to markdown.""" - name = getattr(field, "name", "") or "" - value = getattr(field, "value", "") or "" - if name: - return f"**{name}**\n{value}" - return value + """Convert an embed field to markdown with safe attribute access.""" + try: + name = getattr(field, "name", "") or "" + value = getattr(field, "value", "") or "" + if name: + return f"**{name}**\n{value}" + return value + except (AttributeError, TypeError): + return str(field) if field else "" # ──────────────────────────── core ──────────────────────────── @@ -67,26 +77,32 @@ def embed_to_container( container = ui.Container(accent_colour=discord.Colour(color)) - # ── Author line ── + # ── Author line ── (with safe attribute access) author_text = "" - if embed.author and embed.author.name: - author_name = embed.author.name - if embed.author.url: - author_text = f"[{author_name}]({embed.author.url})" - else: - author_text = author_name + try: + if embed.author and getattr(embed.author, "name", None): + author_name = embed.author.name + if getattr(embed.author, "url", None): + author_text = f"[{author_name}]({embed.author.url})" + else: + author_text = author_name + except (AttributeError, TypeError): + pass - # ── Title + URL ── + # ── Title + URL ── (with safe attribute access) title_text = "" - if embed.title: - title = embed.title - if embed.url: - title_text = f"### [{title}]({embed.url})" - else: - title_text = f"### {title}" + try: + if embed.title: + title = embed.title + if getattr(embed, "url", None): + title_text = f"### [{title}]({embed.url})" + else: + title_text = f"### {title}" + except (AttributeError, TypeError): + pass - # ── Description ── - desc_text = embed.description or "" + # ── Description ── (with safe attribute access) + desc_text = getattr(embed, "description", "") or "" # Build the header block — combine author, title, description header_parts = [] @@ -101,8 +117,11 @@ def embed_to_container( # ── Thumbnail? → use Section with accessory ── thumbnail_url = None - if show_thumbnail and embed.thumbnail and embed.thumbnail.url: - thumbnail_url = embed.thumbnail.url + try: + if show_thumbnail and getattr(embed, "thumbnail", None) and getattr(embed.thumbnail, "url", None): + thumbnail_url = embed.thumbnail.url + except (AttributeError, TypeError): + pass if header_md: if thumbnail_url: @@ -116,7 +135,8 @@ def embed_to_container( container.add_item(ui.TextDisplay(_truncate(header_md))) # ── Fields ── - if embed.fields: + fields = getattr(embed, "fields", None) + if fields: if header_md: container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) @@ -246,7 +266,8 @@ def embeds_to_layout( def view_items_to_action_rows(view: ui.View) -> List[ui.ActionRow]: """Extract action rows from a regular View for re-use in LayoutView.""" rows: List[ui.ActionRow] = [] - for child in view.children: + # Use list() to avoid issues if view.children is modified during iteration + for child in list(view.children): if isinstance(child, ui.ActionRow): rows.append(child) elif isinstance(child, (ui.Button, ui.Select)): diff --git a/newhelpmenu/formatter.py b/newhelpmenu/formatter.py index 108f44b..5442cca 100644 --- a/newhelpmenu/formatter.py +++ b/newhelpmenu/formatter.py @@ -24,8 +24,11 @@ from redbot.core.utils.chat_formatting import humanize_list, pagify log = logging.getLogger("red.evecogs.newhelpmenu.formatter") +# Discord limits: 25 fields per embed, 6000 chars total +# We use conservative limits to ensure clean formatting MAX_FIELDS_PER_PAGE = 6 # Keep pages clean, not cramped -EMBED_CHAR_LIMIT = 5800 +EMBED_CHAR_LIMIT = 5800 # Leave buffer below Discord's 6000 limit +MAX_CATEGORIES_IN_OVERVIEW = 18 # Stay well under 25 field limit # ══════════════════════════════════════════════════════════════════ @@ -55,21 +58,29 @@ async def gather_bot_help_data( cog_commands: Dict[str, List[commands.Command]] = defaultdict(list) + # Gather all commands first, then batch permission checks for efficiency + pending_checks: List[Tuple[commands.Command, str]] = [] + for cmd in sorted(bot.commands, key=lambda c: c.qualified_name): if cmd.hidden and not show_hidden: continue if cmd.qualified_name in blacklisted_commands: continue - try: - if not await cmd.can_run(ctx): - continue - except Exception: - continue cog_name = cmd.cog_name or "No Category" if cog_name in blacklisted_cogs: continue - cog_commands[cog_name].append(cmd) + + pending_checks.append((cmd, cog_name)) + + # Run permission checks (this is the slow part) + for cmd, cog_name in pending_checks: + try: + if await cmd.can_run(ctx): + cog_commands[cog_name].append(cmd) + except Exception: + # Permission check failed, skip this command + pass cog_to_category: Dict[str, str] = {} for cat_name, cog_list in categories.items(): @@ -357,9 +368,9 @@ def build_bot_help_embeds( # Show categories as compact inline fields for i, (cat_name, emoji, cmds) in enumerate(category_data): - if i >= 18: # Stay well under 25 + if i >= MAX_CATEGORIES_IN_OVERVIEW: overview.add_field( - name=f"… +{len(category_data) - 18} more", + name=f"… +{len(category_data) - MAX_CATEGORIES_IN_OVERVIEW} more", value=f"Use the dropdown to browse.", inline=False, ) @@ -374,7 +385,7 @@ def build_bot_help_embeds( ) # Pad with empty inline field if odd number for cleaner grid - field_count = min(len(category_data), 18) + field_count = min(len(category_data), MAX_CATEGORIES_IN_OVERVIEW) if field_count % 2 == 1: overview.add_field(name="\u200b", value="\u200b", inline=True) diff --git a/newhelpmenu/newhelpmenu.py b/newhelpmenu/newhelpmenu.py index 2dd25f4..30c605c 100644 --- a/newhelpmenu/newhelpmenu.py +++ b/newhelpmenu/newhelpmenu.py @@ -100,17 +100,30 @@ class NewHelpMenu(commands.Cog): self._active_views: Dict[int, ui.LayoutView] = {} # msg_id -> view self._patched = False + # Guild settings cache with TTL (guild_id -> (settings, timestamp)) + self._settings_cache: Dict[int, Tuple[Dict, float]] = {} + self._cache_ttl = 60.0 # Cache for 60 seconds + + async def cog_load(self) -> None: + """Called when cog is loaded — set up monkey patches.""" + await self._apply_patches() + async def initialize(self): - """Called after cog is added — set up monkey patches.""" + """Called after cog is added — set up monkey patches (legacy support).""" await self._apply_patches() async def cog_unload(self): """Restore everything on unload.""" await self._remove_patches() - # Stop all active views - for view in self._active_views.values(): - view.stop() + # Stop all active views and clean up + for msg_id, view in list(self._active_views.items()): + try: + view.stop() + except Exception as e: + log.warning(f"Failed to stop view {msg_id}: {e}") self._active_views.clear() + # Clear settings cache + self._settings_cache.clear() # Restore Red's original help command if self._original_help_command is not None: self.bot.add_command(self._original_help_command) @@ -120,6 +133,21 @@ class NewHelpMenu(commands.Cog): # MONKEY PATCHING # ═══════════════════════════════════════════════════════════════ + async def _get_cached_settings(self, guild: discord.Guild) -> Dict[str, Any]: + """Get guild settings with caching to reduce database load.""" + import time + now = time.time() + + if guild.id in self._settings_cache: + settings, timestamp = self._settings_cache[guild.id] + if now - timestamp < self._cache_ttl: + return settings + + # Cache miss or expired - fetch from database + settings = await self.config.guild(guild).all() + self._settings_cache[guild.id] = (settings, now) + return settings + async def _apply_patches(self): """Monkey-patch Context.send AND channel.send to intercept embeds globally.""" if self._patched: @@ -129,31 +157,31 @@ class NewHelpMenu(commands.Cog): original_channel_send = discord.abc.Messageable.send cog_ref = self # closure reference - _converting = set() # recursion guard (task ids) + from contextvars import ContextVar + _converting: ContextVar[bool] = ContextVar("newhelpmenu_converting", default=False) async def _convert_and_send(original_fn, self_obj, content, kwargs): """Shared logic: convert embeds → CV2, call the original fn.""" # Recursion guard: Context.send calls super().send (Messageable.send) - # which we also patch. Use asyncio task id to detect re-entry. - task = id(asyncio.current_task()) - if task in _converting: + # which we also patch. Use contextvar for thread-safe recursion detection. + if _converting.get(): return await original_fn(self_obj, content, **kwargs) - _converting.add(task) + _converting.set(True) try: # Resolve the guild from whatever object we're sending to - guild = None - if isinstance(self_obj, commands.Context): - guild = self_obj.guild - elif isinstance(self_obj, (discord.TextChannel, discord.VoiceChannel, - discord.StageChannel, discord.Thread)): - guild = self_obj.guild - elif isinstance(self_obj, discord.Member): + guild = getattr(self_obj, 'guild', None) + if guild is None and isinstance(self_obj, commands.Context): guild = self_obj.guild if guild is None: return await original_fn(self_obj, content, **kwargs) - settings = await cog_ref.config.guild(guild).all() + # Use cached settings to reduce database load + try: + settings = await cog_ref._get_cached_settings(guild) + except Exception as e: + log.warning(f"Failed to get cached settings for guild {guild.id}: {e}") + return await original_fn(self_obj, content, **kwargs) if not settings["enabled"] or not settings["embed_override"]: return await original_fn(self_obj, content, **kwargs) @@ -215,17 +243,23 @@ class NewHelpMenu(commands.Cog): kwargs.pop("embeds", None) msg = await original_fn(self_obj, None, **kwargs) - # Track for cleanup + # Track for cleanup with automatic removal on stop if msg: cog_ref._active_views[msg.id] = layout + # Add cleanup callback + original_stop = layout.stop + def cleanup_stop(): + original_stop() + cog_ref._active_views.pop(msg.id, None) + layout.stop = cleanup_stop return msg except Exception as e: - log.warning(f"CV2 conversion failed, falling back to original: {e}") + log.warning(f"CV2 conversion failed, falling back to original: {e}", exc_info=True) return await original_fn(self_obj, content, **kwargs) finally: - _converting.discard(task) + _converting.set(False) async def patched_ctx_send(ctx_self, content=None, **kwargs): """Wrapper around Context.send.""" @@ -411,18 +445,33 @@ class NewHelpMenu(commands.Cog): return custom_id = interaction.data.get("custom_id", "") if interaction.data else "" - if not custom_id.startswith(("help_", "cv2menu_")): - return + # Use unique namespace prefix to avoid collisions with other cogs + if not custom_id.startswith(("nhm_help_", "nhm_cv2menu_")): + # Legacy support for old IDs + if not custom_id.startswith(("help_", "cv2menu_")): + return msg_id = interaction.message.id if interaction.message else None if msg_id and msg_id in self._active_views: view = self._active_views[msg_id] if hasattr(view, "handle_interaction"): - await view.handle_interaction(interaction) + try: + await view.handle_interaction(interaction) + except Exception as e: + log.error(f"Failed to handle interaction {custom_id}: {e}", exc_info=True) + try: + if not interaction.response.is_done(): + await interaction.response.send_message( + "⚠️ An error occurred processing your interaction.", + ephemeral=True + ) + except Exception: + pass return try: - await interaction.response.defer() + if not interaction.response.is_done(): + await interaction.response.defer() except Exception: pass diff --git a/rubylogging/info.json b/rubylogging/info.json index 2b85fbc..ea61b53 100644 --- a/rubylogging/info.json +++ b/rubylogging/info.json @@ -1,14 +1,17 @@ { "author": ["everestmcarthur"], - "description": "The ultimate Discord event logging system. Captures every single Discord event with individual toggles, advanced filtering, customizable formats (detailed/compact/JSON), and comprehensive tracking of all Discord gateway events.", - "install_msg": "Thanks for installing RubyLogging! Use `[p]rlog quickstart #channel` to get started, or `[p]rlog list` to see all 100+ available events.", - "short": "Ultimate event logger - every Discord event, individually toggleable", + "description": "The ultimate Discord event logging system. Captures every single Discord event with individual toggles, advanced filtering, customizable formats (detailed/compact/JSON), comprehensive tracking of all Discord gateway events, and intelligent attachment/media logging with image/GIF/video previews and deleted attachment recovery.", + "install_msg": "Thanks for installing RubyLogging! Use `[p]rlog quickstart #channel` to get started with attachment logging enabled, or `[p]rlog list` to see all 100+ available events. Media logging automatically tracks and previews images, GIFs, videos, and stickers - even after deletion!", + "short": "Ultimate event logger - every Discord event, individually toggleable, with intelligent media tracking", "min_bot_version": "3.5.0", "tags": [ "logging", "events", "audit", "moderation", - "tracking" + "tracking", + "attachments", + "media", + "images" ] } diff --git a/rubylogging/rubylogging.py b/rubylogging/rubylogging.py index cb6bc3e..b5322e6 100644 --- a/rubylogging/rubylogging.py +++ b/rubylogging/rubylogging.py @@ -167,6 +167,12 @@ class RubyLogging(commands.Cog): "ignore_users": [], "ignore_channels": [], "filter_patterns": [], + # Attachment logging + "log_attachments": True, + "log_stickers": True, + "show_attachment_previews": True, + "track_deleted_attachments": True, + "max_preview_attachments": 4, } self.config.register_guild(**default_guild) @@ -175,6 +181,10 @@ class RubyLogging(commands.Cog): self._handlers = {} self._register_all_handlers() + # Attachment cache for deleted messages (message_id -> attachments) + self._attachment_cache: Dict[int, List[Dict[str, Any]]] = {} + self._cache_max_size = 1000 + def _register_all_handlers(self) -> None: """Dynamically register all event handlers.""" for category, events in EVENTS.items(): @@ -190,11 +200,290 @@ class RubyLogging(commands.Cog): self.bot.add_listener(handler, f"on_{event}") self._handlers[event] = handler + # Register special handlers for attachment tracking + self.bot.add_listener(self._cache_message_attachments, "on_message") + self.bot.add_listener(self._handle_message_delete_with_cache, "on_message_delete") + self.bot.add_listener(self._handle_bulk_delete_with_cache, "on_bulk_message_delete") + async def cog_unload(self) -> None: """Remove all registered event listeners.""" for event, handler in self._handlers.items(): self.bot.remove_listener(handler, f"on_{event}") + # Remove special attachment handlers + self.bot.remove_listener(self._cache_message_attachments, "on_message") + self.bot.remove_listener(self._handle_message_delete_with_cache, "on_message_delete") + self.bot.remove_listener(self._handle_bulk_delete_with_cache, "on_bulk_message_delete") + + # Clear attachment cache + self._attachment_cache.clear() + + # ═══════════════════════════════════════════════════════════════════════ + # Attachment Caching for Deleted Messages + # ═══════════════════════════════════════════════════════════════════════ + + async def _cache_message_attachments(self, message: discord.Message) -> None: + """Cache message attachments for later retrieval if deleted.""" + if not message.guild: + return + + # Check if attachment tracking is enabled + try: + config = self.config.guild(message.guild) + if not await config.track_deleted_attachments(): + return + except Exception: + return + + # Only cache if message has attachments or stickers + if not message.attachments and not message.stickers: + return + + # Build attachment data + attachment_data = [] + for att in message.attachments: + attachment_data.append({ + "filename": att.filename, + "url": att.url, + "proxy_url": att.proxy_url, + "size": att.size, + "content_type": att.content_type, + "is_image": att.content_type.startswith("image/") if att.content_type else False, + "is_video": att.content_type.startswith("video/") if att.content_type else False, + "is_gif": att.content_type == "image/gif" if att.content_type else att.filename.lower().endswith(".gif"), + "width": att.width, + "height": att.height, + }) + + # Add stickers + for sticker in message.stickers: + attachment_data.append({ + "filename": f"{sticker.name}.png", + "url": sticker.url, + "proxy_url": sticker.url, + "size": 0, + "content_type": "sticker", + "is_image": True, + "is_video": False, + "is_gif": sticker.format == discord.StickerFormatType.gif if hasattr(sticker, 'format') else False, + "width": None, + "height": None, + "sticker_id": sticker.id, + "sticker_name": sticker.name, + }) + + # Store in cache + self._attachment_cache[message.id] = attachment_data + + # Prevent unbounded growth + if len(self._attachment_cache) > self._cache_max_size: + # Remove oldest entries (first 100) + oldest_keys = list(self._attachment_cache.keys())[:100] + for key in oldest_keys: + self._attachment_cache.pop(key, None) + + async def _handle_message_delete_with_cache(self, message: discord.Message) -> None: + """Handle message deletion with cached attachment data.""" + if not message.guild: + return + + # Process the normal message_delete event + await self._process_event("message_delete", "messages", message) + + # If message had cached attachments, log them separately + cached = self._attachment_cache.pop(message.id, None) + if cached: + await self._log_deleted_attachments(message, cached) + + async def _handle_bulk_delete_with_cache(self, messages: List[discord.Message]) -> None: + """Handle bulk message deletion with cached attachments.""" + if not messages: + return + + guild = messages[0].guild if messages else None + if not guild: + return + + # Process normal bulk delete event + await self._process_event("raw_bulk_message_delete", "messages", messages) + + # Check for any cached attachments + deleted_with_attachments = [] + for message in messages: + cached = self._attachment_cache.pop(message.id, None) + if cached: + deleted_with_attachments.append((message, cached)) + + if deleted_with_attachments: + await self._log_bulk_deleted_attachments(guild, deleted_with_attachments) + + async def _log_deleted_attachments( + self, message: discord.Message, attachments: List[Dict[str, Any]] + ) -> None: + """Log deleted message attachments with previews.""" + if not message.guild: + return + + config = self.config.guild(message.guild) + + # Check if attachment logging is enabled + if not await config.log_attachments(): + return + + enabled_events = await config.enabled_events() + if "message_delete" not in enabled_events: + return + + channel_id = await config.log_channel() + if not channel_id: + return + + channel = message.guild.get_channel(channel_id) + if not channel or not isinstance(channel, discord.TextChannel): + return + + # Apply filters + if await self._should_ignore(message.guild, [message], {}): + return + + # Build embed + accent = await config.accent_color() + color_coded = await config.color_coded() + include_timestamp = await config.include_timestamp() + + color = discord.Color.red() if color_coded else discord.Color.greyple() + embed = discord.Embed( + title="📎 Deleted Message Had Attachments", + color=color, + ) + + if include_timestamp: + embed.timestamp = datetime.now(timezone.utc) + + embed.add_field( + name="Message Info", + value=( + f"**Author:** {message.author.mention} (`{message.author.id}`)\n" + f"**Channel:** {message.channel.mention}\n" + f"**Message ID:** `{message.id}`" + ), + inline=False, + ) + + # List all attachments + attachment_lines = [] + images_for_preview = [] + show_previews = await config.show_attachment_previews() + max_previews = await config.max_preview_attachments() + + for i, att in enumerate(attachments, 1): + size_mb = att["size"] / 1024 / 1024 + type_emoji = "🖼️" if att["is_image"] else "🎬" if att["is_video"] else "📎" + + if att["content_type"] == "sticker": + type_str = "Sticker" + attachment_lines.append( + f"{type_emoji} **{att['sticker_name']}** (Sticker)\n" + f"╰ [View]({att['url']})" + ) + else: + if att["is_gif"]: + type_str = "GIF" + type_emoji = "🎞️" + elif att["is_image"]: + type_str = "Image" + elif att["is_video"]: + type_str = "Video" + else: + type_str = att["content_type"] or "File" + + dims = "" + if att["width"] and att["height"]: + dims = f" ({att['width']}x{att['height']})" + + attachment_lines.append( + f"{type_emoji} **{att['filename']}** ({size_mb:.2f} MB)\n" + f"╰ Type: {type_str}{dims} · [Download]({att['url']})" + ) + + # Collect images for preview + if show_previews and att["is_image"] and len(images_for_preview) < max_previews: + images_for_preview.append(att["url"]) + + embed.add_field( + name=f"📋 Attachments ({len(attachments)})", + value="\n\n".join(attachment_lines)[:1024], + inline=False, + ) + + # Add preview for first image + if images_for_preview: + embed.set_image(url=images_for_preview[0]) + + try: + await channel.send(embed=embed) + + # Send additional previews if multiple images + if len(images_for_preview) > 1: + for preview_url in images_for_preview[1:max_previews]: + preview_embed = discord.Embed(color=color) + preview_embed.set_image(url=preview_url) + await channel.send(embed=preview_embed) + + except discord.HTTPException as e: + LOG.error(f"Failed to send deleted attachment log: {e}") + + async def _log_bulk_deleted_attachments( + self, guild: discord.Guild, deleted_data: List[Tuple[discord.Message, List[Dict[str, Any]]]] + ) -> None: + """Log bulk deleted attachments.""" + config = self.config.guild(guild) + + if not await config.log_attachments(): + return + + channel_id = await config.log_channel() + if not channel_id: + return + + channel = guild.get_channel(channel_id) + if not channel or not isinstance(channel, discord.TextChannel): + return + + accent = await config.accent_color() + color_coded = await config.color_coded() + color = discord.Color.red() if color_coded else discord.Color.greyple() + + embed = discord.Embed( + title="🗑️ Bulk Delete - Attachments Lost", + description=f"**{len(deleted_data)}** deleted messages contained attachments", + color=color, + timestamp=datetime.now(timezone.utc), + ) + + summary_lines = [] + total_attachments = 0 + for message, attachments in deleted_data[:10]: # Show first 10 + total_attachments += len(attachments) + summary_lines.append( + f"**{message.author}** in {message.channel.mention}\n" + f"╰ {len(attachments)} attachment(s) · Message ID: `{message.id}`" + ) + + if len(deleted_data) > 10: + summary_lines.append(f"\n*...and {len(deleted_data) - 10} more messages with attachments*") + + embed.add_field( + name=f"📎 Total Attachments Lost: {total_attachments}", + value="\n\n".join(summary_lines)[:1024], + inline=False, + ) + + try: + await channel.send(embed=embed) + except discord.HTTPException as e: + LOG.error(f"Failed to send bulk deleted attachments log: {e}") + # ═══════════════════════════════════════════════════════════════════════ # Event Processing Core # ═══════════════════════════════════════════════════════════════════════ @@ -435,11 +724,35 @@ class RubyLogging(commands.Cog): elif isinstance(obj, discord.Role): return f"**Role:** {obj.mention} (`{obj.id}`)\n**Color:** {obj.color}\n**Position:** {obj.position}" elif isinstance(obj, discord.Message): - content = obj.content[:100] + "..." if len(obj.content) > 100 else obj.content + content = obj.content[:100] + "..." if len(obj.content) > 100 else obj.content or "*No text content*" + + # Add attachment info + attachment_info = "" + if obj.attachments: + att_types = [] + for att in obj.attachments: + if att.content_type: + if att.content_type.startswith("image/"): + if att.content_type == "image/gif": + att_types.append("🎞️ GIF") + else: + att_types.append("🖼️ Image") + elif att.content_type.startswith("video/"): + att_types.append("🎬 Video") + else: + att_types.append("📎 File") + else: + att_types.append("📎 File") + attachment_info = f"\n**Attachments:** {', '.join(att_types)} ({len(obj.attachments)} total)" + + if obj.stickers: + sticker_names = ", ".join(s.name for s in obj.stickers) + attachment_info += f"\n**Stickers:** {sticker_names}" + return ( f"**Author:** {obj.author.mention}\n" f"**Channel:** {obj.channel.mention if hasattr(obj.channel, 'mention') else obj.channel}\n" - f"**Content:** {content}\n" + f"**Content:** {content}{attachment_info}\n" f"**ID:** `{obj.id}`" ) elif isinstance(obj, discord.Emoji): @@ -745,6 +1058,13 @@ class RubyLogging(commands.Cog): ignore_users = await config.ignore_users() ignore_channels = await config.ignore_channels() + # Attachment settings + log_attachments = await config.log_attachments() + log_stickers = await config.log_stickers() + show_previews = await config.show_attachment_previews() + track_deleted = await config.track_deleted_attachments() + max_previews = await config.max_preview_attachments() + embed = discord.Embed( title="🔧 RubyLogging Settings", color=discord.Color.blue(), @@ -777,6 +1097,21 @@ class RubyLogging(commands.Cog): inline=True, ) + # Attachment settings + attachment_status = ( + f"{'✅' if log_attachments else '❌'} Logging\n" + f"{'✅' if log_stickers else '❌'} Stickers\n" + f"{'✅' if show_previews else '❌'} Previews\n" + f"{'✅' if track_deleted else '❌'} Track Deleted\n" + f"📊 Max Previews: {max_previews}\n" + f"💾 Cached: {len(self._attachment_cache)}" + ) + embed.add_field( + name="📎 Attachments", + value=attachment_status, + inline=True, + ) + # Event breakdown by category category_stats = [] for category, events in EVENTS.items(): @@ -818,16 +1153,119 @@ class RubyLogging(commands.Cog): await guild_config.enabled_events.set(common_events) await guild_config.ignore_bots.set(True) + # Enable attachment logging by default + await guild_config.log_attachments.set(True) + await guild_config.log_stickers.set(True) + await guild_config.show_attachment_previews.set(True) + await guild_config.track_deleted_attachments.set(True) + await ctx.send( f"✅ **Quick setup complete!**\n" f"• Log channel: {channel.mention}\n" f"• Enabled {len(common_events)} common events\n" - f"• Bot events ignored\n\n" - f"Use `{ctx.prefix}rlog list` to see all events and customize further." + f"• Bot events ignored\n" + f"• Attachment logging enabled (images, GIFs, videos)\n" + f"• Deleted attachment tracking enabled\n\n" + f"Use `{ctx.prefix}rlog list` to see all events and customize further.\n" + f"Use `{ctx.prefix}rlog attachments` to configure media logging." ) + @rlog.group(name="attachments", aliases=["attach", "files"]) + async def rlog_attachments(self, ctx: commands.Context) -> None: + """Attachment and media logging settings.""" + if ctx.invoked_subcommand is None: + config = self.config.guild(ctx.guild) + log_attachments = await config.log_attachments() + log_stickers = await config.log_stickers() + show_previews = await config.show_attachment_previews() + track_deleted = await config.track_deleted_attachments() + max_previews = await config.max_preview_attachments() + + status = ( + f"**📎 Attachment Logging**\n" + f"{'✅' if log_attachments else '❌'} Log Attachments: {'On' if log_attachments else 'Off'}\n" + f"{'✅' if log_stickers else '❌'} Log Stickers: {'On' if log_stickers else 'Off'}\n" + f"{'✅' if show_previews else '❌'} Show Previews: {'On' if show_previews else 'Off'}\n" + f"{'✅' if track_deleted else '❌'} Track Deleted: {'On' if track_deleted else 'Off'}\n" + f"📊 Max Previews: {max_previews}\n\n" + f"**Cache Status:** {len(self._attachment_cache)} messages cached" + ) + await ctx.send(status) + + @rlog_attachments.command(name="toggle") + async def rlog_attach_toggle(self, ctx: commands.Context) -> None: + """Toggle attachment logging on/off.""" + current = await self.config.guild(ctx.guild).log_attachments() + new_val = not current + await self.config.guild(ctx.guild).log_attachments.set(new_val) + status = "enabled" if new_val else "disabled" + await ctx.send(f"✅ Attachment logging **{status}**") + + @rlog_attachments.command(name="stickers") + async def rlog_attach_stickers(self, ctx: commands.Context) -> None: + """Toggle sticker logging on/off.""" + current = await self.config.guild(ctx.guild).log_stickers() + new_val = not current + await self.config.guild(ctx.guild).log_stickers.set(new_val) + status = "enabled" if new_val else "disabled" + await ctx.send(f"✅ Sticker logging **{status}**") + + @rlog_attachments.command(name="previews") + async def rlog_attach_previews(self, ctx: commands.Context) -> None: + """Toggle attachment preview embeds on/off.""" + current = await self.config.guild(ctx.guild).show_attachment_previews() + new_val = not current + await self.config.guild(ctx.guild).show_attachment_previews.set(new_val) + status = "enabled" if new_val else "disabled" + await ctx.send( + f"✅ Attachment previews **{status}**\n" + f"Images/GIFs will {'be shown' if new_val else 'not be shown'} in log embeds." + ) + + @rlog_attachments.command(name="tracking") + async def rlog_attach_tracking(self, ctx: commands.Context) -> None: + """Toggle deleted attachment tracking on/off. + + When enabled, the bot caches attachments from all messages so they can + be logged when messages are deleted. Slight memory overhead. + """ + current = await self.config.guild(ctx.guild).track_deleted_attachments() + new_val = not current + await self.config.guild(ctx.guild).track_deleted_attachments.set(new_val) + status = "enabled" if new_val else "disabled" + + if new_val: + await ctx.send( + f"✅ Deleted attachment tracking **{status}**\n" + f"The bot will now cache attachments to log them when messages are deleted.\n" + f"**Note:** Only attachments from messages sent *after* enabling this will be tracked." + ) + else: + # Clear cache when disabled + self._attachment_cache.clear() + await ctx.send( + f"✅ Deleted attachment tracking **{status}**\n" + f"Cleared {len(self._attachment_cache)} cached attachments." + ) + + @rlog_attachments.command(name="maxpreviews") + async def rlog_attach_maxpreviews(self, ctx: commands.Context, count: int) -> None: + """Set maximum number of attachment previews to show (1-10).""" + count = max(1, min(10, count)) + await self.config.guild(ctx.guild).max_preview_attachments.set(count) + await ctx.send(f"✅ Max attachment previews set to **{count}**") + + @rlog_attachments.command(name="clearcache") + @commands.admin_or_permissions(manage_guild=True) + async def rlog_attach_clearcache(self, ctx: commands.Context) -> None: + """Clear the attachment cache (admin only).""" + count = len(self._attachment_cache) + self._attachment_cache.clear() + await ctx.send(f"✅ Cleared **{count}** cached attachments from memory.") + @rlog.command(name="reset") async def rlog_reset(self, ctx: commands.Context) -> None: """Reset all logging settings to default.""" await self.config.guild(ctx.guild).clear() + self._attachment_cache.clear() await ctx.send("✅ All logging settings have been reset.") diff --git a/userslash/__init__.py b/userslash/__init__.py index 05b0e41..b1f1d74 100644 --- a/userslash/__init__.py +++ b/userslash/__init__.py @@ -70,6 +70,13 @@ class UserSlash(commands.Cog): if self._slash_registered: self.bot.tree.remove_command(user_slash_command.name, guild=None) + # Clean up context variable to prevent leaks + from .utils import contexts + try: + contexts.set(None) # type: ignore + except (LookupError, TypeError): + pass # Already empty + async def _delayed_setup(self) -> None: """Wait for the bot to be fully ready, then register the slash command.""" await self.bot.wait_until_red_ready() diff --git a/userslash/context.py b/userslash/context.py index 9a09ee9..abbf13d 100644 --- a/userslash/context.py +++ b/userslash/context.py @@ -117,8 +117,19 @@ class InterContext(commands.Context): async def send(self, *args, **kwargs): interaction = self._interaction if interaction.is_expired(): - assert interaction.channel - return await interaction.channel.send(*args, **kwargs) # type: ignore + # Interaction expired - try to fall back to channel send + if interaction.channel: + try: + return await interaction.channel.send(*args, **kwargs) # type: ignore + except (discord.HTTPException, AttributeError) as e: + # Fabricated DM channel or permissions issue + import logging + log = logging.getLogger("red.evecogs.userslash.context") + log.warning(f"Failed to send via expired interaction channel: {e}") + raise discord.InteractionResponded("Interaction expired and fallback send failed") from e + else: + raise discord.InteractionResponded("Interaction expired with no channel available") + await self.typing(ephemeral=True) self._deferring = False delete_after = kwargs.pop("delete_after", None) diff --git a/userslash/slash_bridge.py b/userslash/slash_bridge.py index e504f37..71cde72 100644 --- a/userslash/slash_bridge.py +++ b/userslash/slash_bridge.py @@ -29,6 +29,11 @@ from .utils import walk_aliases LOG = logging.getLogger("red.evecogs.userslash.slash_bridge") +# Constants +MAX_SLASH_COMMAND_NAME_LENGTH = 32 +AUTOCOMPLETE_USER_INSTALL_LIMIT = 24 +AUTOCOMPLETE_GUILD_LIMIT = 6 + # ------------------------------------------------------------------ # Context helpers @@ -95,17 +100,30 @@ async def _check_whitelist(interaction: discord.Interaction) -> bool: interaction.command, "extras", {} ).get("_userslash_config") if config is None: + LOG.debug("Whitelist check: config not available, allowing") return True # config not wired yet — allow # If the bot is a full member of this guild, skip the whitelist - if interaction.guild and interaction.guild.me: + # More robust check: interaction.guild_id present but guild not in cache = not a member + if interaction.guild and getattr(interaction.guild, "me", None): return True - if not await config.whitelist_enabled(): - return True + try: + if not await config.whitelist_enabled(): + return True + except Exception as e: + LOG.warning(f"Failed to check whitelist status: {e}") + return True # Fail open - whitelist: list = await config.whitelisted_users() - return interaction.user.id in whitelist + try: + whitelist: list = await config.whitelisted_users() + result = interaction.user.id in whitelist + if not result: + LOG.info(f"User {interaction.user.id} blocked by whitelist in user-install context") + return result + except Exception as e: + LOG.error(f"Failed to fetch whitelist: {e}") + return True # Fail open to avoid breaking functionality # ------------------------------------------------------------------ @@ -135,19 +153,25 @@ async def user_slash_command( set_contextual_locale(str(interaction.guild_locale or interaction.locale)) # --- Whitelist gate --- - if not await _check_whitelist(interaction): - if not interaction.response.is_done(): - await interaction.response.send_message( - "❌ You are not authorised to use this bot via user-install. " - "Ask a bot owner to add you with `[p]userslash whitelist add`.", - ephemeral=True, - ) - return + try: + if not await _check_whitelist(interaction): + if not interaction.response.is_done(): + await interaction.response.send_message( + "❌ You are not authorised to use this bot via user-install. " + "Ask a bot owner to add you with `[p]userslash whitelist add`.", + ephemeral=True, + ) + return + except Exception as e: + LOG.exception(f"Whitelist check failed for user {interaction.user.id}: {e}") + # Fail open - allow command to proceed rather than break functionality + pass # --- Block guild-only commands in user-install contexts --- if _is_user_install_context(interaction): probe = interaction.client.get_command(command) if probe and _command_needs_guild(probe): + LOG.debug(f"Blocked guild-only command '{command}' in user-install context") if not interaction.response.is_done(): await interaction.response.send_message( f"❌ `{command}` requires a server context and can't be " @@ -289,7 +313,7 @@ async def _autocomplete( await asyncio.get_event_loop().run_in_executor( None, heapq.nlargest, - 24 if user_install else 6, + AUTOCOMPLETE_USER_INSTALL_LIMIT if user_install else AUTOCOMPLETE_GUILD_LIMIT, pool, functools.partial(fuzz.token_sort_ratio, current), ), @@ -298,7 +322,7 @@ async def _autocomplete( else: if user_install: # Show available DM-compatible commands when nothing is typed yet - extracted = pool[:24] + extracted = pool[:AUTOCOMPLETE_USER_INSTALL_LIMIT] extracted.append("help") else: extracted = ["help"] @@ -335,6 +359,9 @@ async def _autocomplete( async def _error(interaction: discord.Interaction, error: Exception): assert isinstance(interaction.client, Red) + # Log the original error for debugging + LOG.error(f"Slash command error for user {interaction.user.id}: {error}", exc_info=error) + if isinstance(error, app_commands.CommandInvokeError): error = error.original original = getattr(error, "original", error) @@ -352,14 +379,17 @@ async def _error(interaction: discord.Interaction, error: Exception): "❌ This command cannot be used here (it may require a server).", ephemeral=True, ) - except discord.HTTPException: - pass + except discord.HTTPException as e: + LOG.warning(f"Failed to send error message: {e}") return # Everything else — forward to Red's built-in error handler - ctx = await InterContext.from_interaction( - interaction, recreate_message=True - ) - await interaction.client.on_command_error( - ctx, commands.CommandInvokeError(original) - ) + try: + ctx = await InterContext.from_interaction( + interaction, recreate_message=True + ) + await interaction.client.on_command_error( + ctx, commands.CommandInvokeError(original) + ) + except Exception as e: + LOG.exception(f"Failed to forward error to Red's handler: {e}") diff --git a/userslash/utils.py b/userslash/utils.py index 21817fd..e2f3758 100644 --- a/userslash/utils.py +++ b/userslash/utils.py @@ -16,6 +16,8 @@ try: except ImportError: import re +# Discord's limit for slash command names +SLASH_COMMAND_NAME_MAX_LENGTH = 32 # ContextVar holding the current InterContext for the active interaction contexts: ContextVar["InterContext"] = ContextVar("userslash_contexts") @@ -28,7 +30,7 @@ def valid_app_name(name: str) -> str: name = "_".join( re.findall(VALID_SLASH_COMMAND_NAME.pattern.strip("^$"), name.lower()) ) - return validate_name(name[:32]) + return validate_name(name[:SLASH_COMMAND_NAME_MAX_LENGTH]) class Thinking: