> For the complete documentation index, see [llms.txt](https://docs.nighty.one/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.nighty.one/scripting/custom-slash-commands.md).

# Custom Slash Commands

Scripts can add their own slash commands to Nighty's app with `@slashCommand`, and answer them with `slashReply()`.

## `slashCommand()`

```python
@slashCommand(name="roll", description="Roll a dice", group=None, group_description=None, nsfw=False)
```

| Argument            | Description                                                                    |
| ------------------- | ------------------------------------------------------------------------------ |
| `name`              | Command name: lowercase, 1–32 chars, no spaces. Defaults to the function name. |
| `description`       | Max 100 chars.                                                                 |
| `group`             | Makes it a subcommand: `group="dice"`, `name="roll"` → `/dice roll`.           |
| `group_description` | Group description, set it on the first command of the group.                   |
| `nsfw`              | Only usable in age-restricted channels.                                        |

* The callback must be `async def` and take `interaction` as its first parameter.
* Declare commands while the script loads, not later from events or tasks.
* Names already used by Nighty or another script raise an error.
* Returns a regular `app_commands.Command`, so `app_commands.describe/choices/rename` and `.autocomplete()` work as usual. `@slashCommand` must be the top decorator.

## `slashReply()` (recommended)

```python
ok = await slashReply(interaction, text, *, title=None, image=None, thumbnail=None, show_thumbnail=True)
```

| Argument         | Description                                                             |
| ---------------- | ----------------------------------------------------------------------- |
| `text`           | Message text (markdown). Non-strings are converted with `str()`.        |
| `title`          | Max 256 chars. Defaults to the command, e.g. `/dice roll`.              |
| `image`          | Large image URL (http/https). Optional. Defaults to the theme settings. |
| `thumbnail`      | Thumbnail URL (http/https). Optional. Defaults to the theme settings.   |
| `show_thumbnail` | `False` hides the thumbnail.                                            |

This is the recommended way to answer script commands. It:

* uses the user's theme (including the V2 theme layout) and follows Private, Force-private and Forward mode like built-in commands.
* handles slow commands. Nighty auto-defers after 1.5s, and `slashReply` turns the "thinking…" placeholder into the reply.
* sends later calls as followups and splits text over 4000 chars into several messages.

**Errors:** bad arguments raise `TypeError`/`ValueError`, and the user sees `Error: ...`. Delivery failures (expired interaction, Discord/network errors) are printed to the console and return `False` instead of raising. Returns `True` on success.

## Examples

### Single command

```python
@nightyScript(name="Hello", author="me", description="Hello command", usage="/hello")
def hello_script():

    @slashCommand(name="hello", description="Say hello")
    async def hello(interaction: discord_bot.Interaction):
        await slashReply(interaction, f"Hello {bot.user.mention}!")

hello_script()
```

### Options

No default = required, default = optional.

```python
@nightyScript(name="Options", author="me", description="Option examples", usage="/repeat, /avatar, /dice")
def options_script():
    import random

    @slashCommand(name="repeat", description="Repeat text")
    @app_commands.describe(text="What to repeat", times="How many times", upper="Uppercase it")
    async def repeat(interaction: discord_bot.Interaction, text: str, times: int = 2, upper: bool = False):
        await slashReply(interaction, "\n".join([text.upper() if upper else text] * times))

    @slashCommand(name="avatar", description="Show a user's avatar")
    async def avatar(interaction: discord_bot.Interaction, user: discord_bot.User = None):
        user = user or interaction.user
        await slashReply(interaction, user.mention, title=user.name, image=str(user.display_avatar.url))

    @slashCommand(name="dice", description="Roll dice")
    async def dice(interaction: discord_bot.Interaction, sides: app_commands.Range[int, 2, 100] = 6):
        await slashReply(interaction, random.randint(1, sides), title=f"d{sides}")

options_script()
```

Supported types: `str`, `int`, `float`, `bool`, `discord_bot.User`, `discord_bot.Member`, `discord_bot.Role`, `discord_bot.abc.GuildChannel`, `discord_bot.Attachment`, `app_commands.Range[...]`, `Literal[...]`, `app_commands.Choice[...]`.

### Choices and autocomplete

```python
@nightyScript(name="Pickers", author="me", description="Choices and autocomplete", usage="/rps, /fruit")
def pickers_script():
    import random
    FRUITS = ["Apple", "Banana", "Cherry", "Grape", "Mango", "Orange", "Peach"]

    @slashCommand(name="rps", description="Rock paper scissors")
    @app_commands.choices(pick=[
        app_commands.Choice(name="Rock", value="rock"),
        app_commands.Choice(name="Paper", value="paper"),
        app_commands.Choice(name="Scissors", value="scissors"),
    ])
    async def rps(interaction: discord_bot.Interaction, pick: app_commands.Choice[str]):
        mine = random.choice(["rock", "paper", "scissors"])
        await slashReply(interaction, f"You: **{pick.value}**, me: **{mine}**")

    @slashCommand(name="fruit", description="Pick a fruit")
    async def fruit(interaction: discord_bot.Interaction, name: str):
        await slashReply(interaction, f"You picked **{name}**")

    @fruit.autocomplete("name")
    async def fruit_autocomplete(interaction: discord_bot.Interaction, current: str):
        return [app_commands.Choice(name=f, value=f) for f in FRUITS if current.lower() in f.lower()][:25]

pickers_script()
```

### Subcommands

```python
@nightyScript(name="Notes", author="me", description="Notes", usage="/notes add | list | clear")
def notes_script():
    notes = []

    @slashCommand(name="add", description="Add a note", group="notes", group_description="Quick notes")
    async def notes_add(interaction: discord_bot.Interaction, text: str):
        notes.append(text)
        await slashReply(interaction, f"Saved note #{len(notes)}")

    @slashCommand(name="list", description="List notes", group="notes")
    async def notes_list(interaction: discord_bot.Interaction):
        if not notes:
            return await slashReply(interaction, "No notes yet.")
        await slashReply(interaction, "\n".join(f"**{i}.** {n}" for i, n in enumerate(notes, 1)), title="Notes")

    @slashCommand(name="clear", description="Clear notes", group="notes")
    async def notes_clear(interaction: discord_bot.Interaction):
        notes.clear()
        await slashReply(interaction, "Cleared.")

notes_script()
```

Only one level of grouping is supported (`/group sub`). A name can't be both a group and a command.

## Components V2

`slashReply()` always uses the theme layout, so for a custom V2 layout (containers, sections, galleries, buttons, selects) send a `LayoutView` yourself. In that case you handle these yourself:

* **Auto-defer:** if the command took over 1.5s, the interaction is already deferred and `response.send_message` fails. Use the helper below.
* **Private:** pass `ephemeral` yourself. Force-private and Forward mode are not applied.
* **Button/select callbacks** must answer within 3s themselves. Nighty only auto-defers slash commands.

```python
@nightyScript(name="V2 counter", author="me", description="Components V2 example", usage="/counter")
def v2_counter_script():

    async def sendV2(interaction, view, private=None):
        if private is None:
            private = bool(getConfigData().get("private"))
        if not interaction.response.is_done():
            return await interaction.response.send_message(view=view, ephemeral=private)
        if (interaction.response.type == discord_bot.InteractionResponseType.deferred_channel_message
                and not interaction.extras.get("v2_placeholder_used")):
            interaction.extras["v2_placeholder_used"] = True
            return await interaction.edit_original_response(view=view)  # replace "thinking..."
        return await interaction.followup.send(view=view, ephemeral=private)

    class CounterView(discord_bot.ui.LayoutView):
        def __init__(self):
            super().__init__(timeout=300)
            self.count = 0
            self.render()

        def render(self):
            self.clear_items()
            plus = discord_bot.ui.Button(label="+1", style=discord_bot.ButtonStyle.success)
            reset = discord_bot.ui.Button(label="Reset", style=discord_bot.ButtonStyle.secondary)
            plus.callback = self.on_plus
            reset.callback = self.on_reset

            container = discord_bot.ui.Container(accent_color=0x5865F2)
            container.add_item(discord_bot.ui.Section(
                discord_bot.ui.TextDisplay(f"## Counter: {self.count}"),
                accessory=discord_bot.ui.Thumbnail(str(bot.user.display_avatar.url)),
            ))
            container.add_item(discord_bot.ui.Separator())
            container.add_item(discord_bot.ui.ActionRow(plus, reset))
            self.add_item(container)

        async def on_plus(self, interaction):
            self.count += 1
            self.render()
            await interaction.response.edit_message(view=self)

        async def on_reset(self, interaction):
            self.count = 0
            self.render()
            await interaction.response.edit_message(view=self)

    @slashCommand(name="counter", description="Counter with buttons")
    async def counter(interaction: discord_bot.Interaction):
        await sendV2(interaction, CounterView())

v2_counter_script()
```

## Notes

* Keep imports and helpers inside the script function. Names defined at the top level of the file aren't visible inside command callbacks.
* Uncaught exceptions in a command are logged to the console and answered with an ephemeral `Error: ...`.
* Use `await asyncio.sleep()` / `await runBlocking(requests.get, url, timeout=10)`, never `time.sleep()` or bare `requests`.
* Modals (`interaction.response.send_modal`) must be sent immediately, before the 1.5s auto-defer.
* Changes sync to Discord a few seconds after the script loads. Press Ctrl+R in Discord if a command doesn't show up.
