Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* **Permission system**: Role assignment removal method and cache invalidation for role and command permission changes; command permission check function; enhanced permission error messaging for unconfigured commands
* **Utility**: Loguru logging for AFK nickname changes and restorations (target user ID, nickname changes, skipped restore events)
* **Config dashboard**: Togglesnippetlock command in configurable command list for command permissions
* **Snippets**: `$snippets`/`$ls` command now accepts an optional member argument to list snippets created by a specific user (`$ls @user`), using `FlexibleUserConverter` to support mentions, user IDs, and usernames including users who have left the guild

### Changed

Expand Down
7 changes: 6 additions & 1 deletion src/tux/modules/snippets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def _create_snippets_list_embed(
snippets: list[Snippet],
total_snippets: int,
search_query: str | None = None,
member: discord.User | None = None,
) -> discord.Embed:
"""Create an embed for displaying a paginated list of snippets.

Expand Down Expand Up @@ -101,7 +102,11 @@ def _create_snippets_list_embed(
)
count = len(snippets)
total_snippets = total_snippets or 0
embed_title = f"Snippets ({count}/{total_snippets})"
embed_title = (
f"Snippets by {member.display_name} ({count}/{total_snippets})"
if member
else f"Snippets ({count}/{total_snippets})"
)

footer_text, footer_icon_url = EmbedCreator.get_footer(
bot=ctx.bot,
Expand Down
28 changes: 20 additions & 8 deletions src/tux/modules/snippets/list_snippets.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@
all available code snippets in Discord guilds.
"""

import discord
from discord.ext import commands
from reactionmenu import ViewButton, ViewMenu
from sqlalchemy import desc

from tux.core.bot import Tux
from tux.core.converters import FlexibleUserConverter
from tux.database.models import Snippet
from tux.shared.constants import SNIPPET_PAGINATION_LIMIT

from . import SnippetsBaseCog

_MEMBER_PARAM = commands.param(default=None, converter=FlexibleUserConverter())


class ListSnippets(SnippetsBaseCog):
"""Discord cog for listing snippets."""
Expand All @@ -37,33 +41,39 @@ def __init__(self, bot: Tux) -> None:
async def list_snippets(
self,
ctx: commands.Context[Tux],
member: discord.User | None = _MEMBER_PARAM,
Copy link

@cubic-dev-ai cubic-dev-ai bot Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The new positional member converter prevents text queries from falling back to search: non-user first tokens now error out instead of running snippet search.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/tux/modules/snippets/list_snippets.py, line 44:

<comment>The new positional `member` converter prevents text queries from falling back to search: non-user first tokens now error out instead of running snippet search.</comment>

<file context>
@@ -37,33 +41,39 @@ def __init__(self, bot: Tux) -> None:
     async def list_snippets(
         self,
         ctx: commands.Context[Tux],
+        member: discord.User | None = _MEMBER_PARAM,
         *,
         search_query: str | None = None,
</file context>
Fix with Cubic

*,
search_query: str | None = None,
Comment on lines 41 to 46
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: When list_snippets is called with text that is not a valid user, the text is silently ignored instead of being used as the search_query.
Severity: MEDIUM

Suggested Fix

Modify the command's signature to ensure that text not matching a user is passed to the search_query. This could involve making search_query a regular positional parameter that can consume the remaining arguments, potentially by using a greedy converter or changing the parameter order.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/tux/modules/snippets/list_snippets.py#L41-L46

Potential issue: In the `list_snippets` command, the `member` parameter is a positional
argument with a custom converter, while `search_query` is a keyword-only argument. When
a user provides input that is not a valid user (e.g., `$snippets some text`), the
converter for `member` fails. The optional fallback correctly sets `member` to `None`,
but the remaining text is discarded because the keyword-only `search_query` parameter
cannot accept positional arguments. This results in the search functionality being
silently ignored when the command is invoked with a search term that doesn't parse as a
user.

Did we get this right? 👍 / 👎 to inform future reviews.

) -> None:
"""List snippets, optionally filtering by a search query.
"""List snippets, optionally filtering by member or search query.

Displays snippets in a paginated embed, sorted by usage count (descending).
The search query filters by snippet name or content (case-insensitive).
Optionally filter by a member's snippets or a search query.

Parameters
----------
ctx : commands.Context[Tux]
The context of the command.
member : discord.User | None, optional
The member whose snippets to list.
search_query : str | None, optional
The query to filter snippets by name or content.
"""
assert ctx.guild

# Fetch snippets with database-level ordering and optional search
if search_query:
if member is not None:
filtered_snippets = await self.db.snippet.get_snippets_by_creator(
member.id,
ctx.guild.id,
)
filtered_snippets.sort(key=lambda s: s.uses, reverse=True)
Comment on lines +65 to +69
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (performance): Consider pushing the ordering by usage count into the DB for the member-filtered query as well.

In get_all_snippets_by_guild_id ordering is done in the DB via order_by=desc(...), but for the member-specific path you fetch then sort in Python. To keep behavior consistent and avoid in-memory sorting for large result sets, consider adding an order_by option (or a variant) to get_snippets_by_creator so the DB returns results already ordered by uses.

Suggested implementation:

        if member is not None:
            filtered_snippets = await self.db.snippet.get_snippets_by_creator(
                member.id,
                ctx.guild.id,
                order_by=desc(Snippet.__table__.c.uses),  # type: ignore[attr-defined]
            )
        elif search_query:

To make this compile and work correctly, you'll also need to:

  1. Update the signature of get_snippets_by_creator (likely in your snippet repository/DAO) to accept an optional order_by parameter, defaulting to None, e.g.:

    async def get_snippets_by_creator(
        self,
        creator_id: int,
        guild_id: int,
        order_by: Any | None = None,
    ) -> list[Snippet]:
  2. Thread the order_by argument into the underlying query, similar to how it's done in get_all_snippets_by_guild_id, e.g.:

    query = select(Snippet).where(...)
    
    if order_by is not None:
        query = query.order_by(order_by)
  3. Ensure any other call sites of get_snippets_by_creator are updated if the new parameter is not keyword-only or if its position changes.

elif search_query:
filtered_snippets = await self.db.snippet.search_snippets(
ctx.guild.id,
search_query,
)
# Sort search results by usage count (most used first)
filtered_snippets.sort(key=lambda s: s.uses, reverse=True)
else:
# Fetch all snippets ordered by usage count from database
filtered_snippets = await self.db.snippet.get_all_snippets_by_guild_id(
ctx.guild.id,
order_by=desc(Snippet.__table__.c.uses), # type: ignore[attr-defined]
Expand All @@ -72,7 +82,9 @@ async def list_snippets(
if not filtered_snippets:
await self.send_snippet_error(
ctx,
description="No snippets found matching your query."
description=f"No snippets found for {member.display_name}."
if member
else "No snippets found matching your query."
if search_query
else "No snippets found.",
)
Expand All @@ -81,7 +93,6 @@ async def list_snippets(
# Set up pagination menu
menu = ViewMenu(ctx, menu_type=ViewMenu.TypeEmbed, show_page_director=False)

# Add pages based on filtered snippets
total_snippets = len(filtered_snippets)

for i in range(0, total_snippets, SNIPPET_PAGINATION_LIMIT):
Expand All @@ -92,6 +103,7 @@ async def list_snippets(
page_snippets,
total_snippets,
search_query,
member,
)

menu.add_page(embed)
Expand Down
Loading