Search code examples
pythondiscorddiscord.py

How to change choices without restarting. discord.py


My bot is based on a database that is updated frequently. The bot of one of the teams provides a choice of lesson topics (taken from the table). It seems to work, but if changes are made to the table while the bot is running, then without restarting it, new selection options will not appear

the command:

@app_commands.command()
@app_commands.default_permissions(administrator=True)
@app_commands.choices(lesson=lesson_choices())
async def create_or_update_mark(self, interaction: Interaction,
                                student: discord.Member,
                                lesson: Choice[str],
                                logiks: app_commands.Range[int, 0, 8]):
    with MarkRepository(student_id=student.id, lesson_title=lesson.value) as lr:
        lr.create_or_update(mark_dto=MarkCreateDTO(logiks=logiks))

    await interaction.response.send_message("logiks has been changed", ephemeral=True)

The selection list is taken from the function lesson_choices

def lesson_choices() -> list[Choice[str]]:
    return [
        Choice(name=lesson.title, value=lesson.title)
        for lesson in LessonRepository.get_all_lessons()
    ]

It seems clear that the decorator determines the choices when running the code, but this does not suit me. Are there any ways to help change the selection options without restarting the bot?

I wanted to use autocomplete but I didn't even have basic options there. Maybe I didn't implement it correctly

Autocomplete command

@app_commands.command()
@app_commands.default_permissions(administrator=True)
@app_commands.autocomplete(lesson=lesson_autocomplete)
async def create_or_update_mark(self, interaction: Interaction,
                                student: discord.Member,
                                lesson: str,
                                logiks: app_commands.Range[int, 0, 8]):
    with MarkRepository(student_id=student.id, lesson_title=lesson) as lr:
        lr.create_or_update(mark_dto=MarkCreateDTO(logiks=logiks))

    await interaction.response.send_message("logiks has been changed", ephemeral=True)

lesson_autocomplete:

async def lesson_autocomplete(interaction: Interaction, current: str) -> list[app_commands.Choice[str]]:
    lessons = [lesson_dto.title for lesson_dto in LessonRepository.get_all_lessons()]
    print(lessons)
    return [
        app_commands.Choice(name=lesson, value=lesson)
        for lesson in lessons if current.lower() in lesson.lower()
    ]

Solution

  • I see that you want to show a dynamic list of choices dependent on the current state of your Database and that you are having some trouble displaying them. Here's what I have noticed and here is what I recommend you do:

    1. I see that contacting your database is a blocking function (this means that you are using a database library that is not asynchronous). To combat this, I highly recommend that you switch to an async library so as not to block your event loop anytime you contact your database for information/updates.
    2. I see that in your attempted autocomplete you contact your database to fetch the current lesson titles - but your list comprehension logic is a bit off and you fetch the current list of lessons in the autocomplete callback, this is unfortunate for a couple reasons:
      1. for lesson in lessons if current.lower() in lesson.lower() -> this means that the autocomplete will only display something if the user has typed a lesson title completely, it will not suggest lessons based on what they're currently typing.
      2. LessonRepository.get_all_lessons() -> The autocomplete method is called every single time the user types a new character (or deletes one), so this method is being called MANY times per second. You want to move this to a cache so that you do not need to make any such calls. This is vital because you only have 3 seconds to respond to the interaction with an autocomplete.

    Making these suggested changes while moving our logic to a Transformer (handles complex logic for transforming arguments and providing autocomplete support), would result in the following change to our code:

    from typing import TYPE_CHECKING, Dict, List, Any, Optional, TypeAlias, Union
    import discord
    from discord.ext import commands
    from discord import app_commands
    import difflib
    
    GUILD_ID: TypeAlias = int
    MEMBER_ID: TypeAlias = int
    LESSON_ID: TypeAlias = int
    
    
    # Some class definition that encapsulates the information from your database.
    class Lesson:
        id: int
        title: str
        # ... some other data here
    
    
    class MyBot(commands.Bot):
        if TYPE_CHECKING:
            some_function_for_loading_the_lessons_cache: Any
    
        def __init__(self, *args: Any, **kwargs: Any):
            super().__init__(*args, **kwargs)
    
            # We want to keep track of all the lessons your bot is 
            # holding here for each student in each guild. To achieve this, let's store a nested dictionary of 
            # {guild_id: {student_id: {lesson_name: lesson_information}}}. Of course, you can change this to your needs!
            self.lessons_cache: Dict[GUILD_ID, Dict[MEMBER_ID, Dict[LESSON_ID, Lesson]]] = {}
    
        async def setup_hook(self):
            # This async function is called once during your bots 
            # startup, and can be used to load your cache and sync your application commands.
    
            await self.tree.sync()
            await self.some_function_for_loading_the_lessons_cache()
    
    
    class LessonTransformer(app_commands.Transformer):
        async def find_similar_lesson_titles(self, lessons: Dict[LESSON_ID, Lesson], title: str) -> Dict[LESSON_ID, Lesson]:
            # We can use difflib to find similar lesson titles, a standard python library utility!
            similar = difflib.get_close_matches(title, map(lambda l: l.title, lessons.values()), n=15, cutoff=1)
            return {lesson.id: lesson for lesson in lessons.values() if lesson.title in similar}
    
        async def autocomplete(self, interaction: discord.Interaction[MyBot], value: str, /) -> List[app_commands.Choice[str]]:
            # Prerequisite: We know that this command can ONLY be invoked in a server (guild)
            assert interaction.guild is not None
    
            # Check if the invoker of this command has already filled out the "student" argument of this command,
            # if they have, then we can use that information to filter to all lessons from only that student
            student: Optional[discord.Member] = interaction.namespace.get('student')
            if student is None:
                # This person skipped the student argument before going to the lesson argument, so we can only show them
                # so much.
                lessons: Dict[MEMBER_ID, Dict[LESSON_ID, Lesson]] = interaction.client.lessons_cache.get(
                    interaction.guild.id, {}
                )
    
                # Flatten this dict into just a list of lessons
                flat_lessons: Dict[LESSON_ID, Lesson] = {
                    s_lesson.id: s_lesson for student_lessons in lessons.values() for s_lesson in student_lessons.values()
                }
    
                similar_lessons = await self.find_similar_lesson_titles(flat_lessons, value)
            else:
                # We can get more specific!
                student_lessons: Dict[LESSON_ID, Lesson] = interaction.client.lessons_cache.get(interaction.guild.id, {}).get(
                    student.id, {}
                )
    
                similar_lessons = await self.find_similar_lesson_titles(student_lessons, value)
    
            return [
                app_commands.Choice(name=lesson.title, value=str(lesson_id)) for lesson_id, lesson in similar_lessons.items()
            ]
    
        async def transform(self, interaction: discord.Interaction[MyBot], value: str, /) -> Union[Lesson, LESSON_ID]:
            # Prerequisite: We know that this command can ONLY be invoked in a server (guild)
            assert interaction.guild is not None
    
            # It should be noted that autocompletes are mere SUGGESTIONS, we need to make sure the user entered okay data.
            # Find the lesson from the cache, where value should be a lesson id
            if not value.isdigit():
                raise Exception("Invalid lesson id")  # Can raise better exception here
    
            lesson_id = int(value)
            student: Optional[discord.Member] = interaction.namespace.get('student')
            if student is None:
                return lesson_id  # We will check that this lesson ID is to the actual student later
    
            # We can get more specific!
            lesson = interaction.client.lessons_cache.get(interaction.guild.id, {}).get(student.id, {}).get(lesson_id)
            if lesson is None:
                raise Exception("Invalid lesson id")
    
            return lesson
    
    
    class SomeCog(commands.Cog):
        @app_commands.command()
        @app_commands.guild_only()
        @app_commands.default_permissions(administrator=True)
        async def create_or_update_mark(
            self,
            interaction: discord.Interaction[MyBot],
            student: discord.Member,
            lesson: app_commands.Transform[Union[Lesson, LESSON_ID], LessonTransformer],
            logiks: app_commands.Range[int, 0, 8],
        ):
            # Prerequisite: We know that this command can ONLY be invoked in a server (guild)
            assert interaction.guild is not None
    
            reveal_type(lesson)  # Type of "lesson" is "Lesson | int"
    
            if isinstance(lesson, int):
                # This is a lesson ID, but we need to check that it belongs to the student (or complain)
                potential_lesson = interaction.client.lessons_cache.get(interaction.guild.id, {}).get(student.id, {}).get(lesson)
                if potential_lesson is None:
                    raise Exception("Invalid lesson id")
    
                lesson = potential_lesson
    
            reveal_type(lesson)  # Type of "lesson" is "Lesson"
    
            # ... perform our operations with the lesson object here
    

    So, to recap, this potential example:

    1. Moved all the autocomplete and transformation logic to a discord.py Transformer
    2. Moved out of your dependency on so many database calls and instead rely on a cache for our autocomplete/validation operations.
    3. Provided a front-facing Lesson type that represents the data stored in our Database.
    4. Updated how autocomplete suggestions are displayed based on what the user has already typed and if they've already specified a student as an argument.

    With these changes, your problem will be fixed. Note that I have not tested this code, it is just an example of how you could go about refactoring your setup. If you have any questions don't hesitate to ask. Also, I highly recommend you check out the discord.py Discord Server, which has help channels for faster responses.