Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Managed application commands #454

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
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
154 changes: 77 additions & 77 deletions docs/static/Application Commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,62 +18,78 @@ documentation](https://discord.com/developers/docs/interactions/application-comm
## Getting started

Discord differentiates between **global** and **guild-specific** slash
commands. Global commands will be distributed across all guilds that your bot
is in within an hour. Guild-specific commands slash commands will be available
instantly, which is why we will use guild-specific commands for testing.
commands. Global commands used to take up to an hour to update across all
guilds, but that is not the case anymore.

We will create a command that will allow the user to assign or remove a role of
choice. The `guild_id` parameter is the ID of the guild on which the command
will be created.
choice. Because each command resides in its own module, we'll have to write one:

Our command definition looks as follows:
```elixir
defmodule MyBot.Command.Role do
alias Nostrum.{Command, Command.Spec.Option}
use Command, %Command.Spec{
name: "role",
desc: "assign or remove a role",
options: [
%Option{
name: "name",
desc: "role to assign or remove",
type: :role
},
%Option{
name: "action",
desc: "whether to assign or remove the role",
type: :string,
choices: [
%{name: "assign", value: "assign"},
%{name: "remove", value: "remove"}
]
}
]
}
end
```

To register this command globally, we need to specify it in `config/config.exs`:
```elixir
command = %{
name: "role",
description: "assign or remove a role",
options: [
%{
# ApplicationCommandType::ROLE
type: 8,
name: "name",
description: "role to assign or remove",
required: true
},
%{
# ApplicationCommandType::STRING
type: 3,
name: "action",
description: "whether to assign or remove the role",
required: true,
choices: [
%{
name: "assign",
value: "assign"
},
%{
name: "remove",
value: "remove"
}
]
}
import Config
# ...
config :nostrum,
token: "YouЯ.T0.k3n",
managed_commands: [
MyBot.Command.Role
]
}
```

To register this command on the guild, we simply pass it to
`Nostrum.Api.create_guild_application_command/2`:
## Receiving interactions

When Nostrum receives an interaction, it invokes the function ``handle/2`` of
your command module with the interaction and options it extracted from that
interaction. Let's write a function with two clauses to handle our command:

```elixir
Nostrum.Api.create_guild_application_command(guild_id, command)
```
defmodule MyBot.Command.Role do
alias Nostrum.{Command, Command.Spec.Option, Api} # note the new alias!
use Command, %Command.Spec{
# ... spec from before
}

You can register the command in the ``:READY`` gateway event handler.
def handle(interaction, %{"action" => "assign", "name" => role_id}) do
Api.add_guild_member_role(interaction.guild_id, interaction.member.user.id, role_id)
:ignore
end

## Receiving interactions
def handle(interaction, %{"action" => "remove", "name" => role_id}) do
Api.remove_guild_member_role(interaction.guild_id, interaction.member.user.id, role_id)
:ignore
end
end
```

Try starting your bot running the command. Does it work?

Set up a gateway event handler for ``:INTERACTION_CREATE``. On command
invocation the interaction payload will look something like the following:
Behind the hood, an ``:INTERACTION_CREATE`` event was received by a built-in
Nostrum consumer with an interaction that looked something like the following:

```elixir
%Nostrum.Struct.Interaction{
Expand All @@ -89,49 +105,33 @@ invocation the interaction payload will look something like the following:
# ...
```

Note that Discord already converted the user-supplied role to a snowflake.
Convenient!
Nostrum figured out which module to call to handle that command and converted the
option list to a map.

Let's match on the retrieved event and create two function heads for the
separate operation modes:
## Responding to interactions

```elixir
alias Nostrum.Api
alias Nostrum.Struct.Interaction
Did you notice the ``:ignore`` at the end of our function clauses? It's telling
Nostrum to not send anything back to the user. Although our command performs
its job, the lack of a response forces Discord to display "YourBot is thinking"
to the user for 15 minutes until eventually giving up and switching that to
"The application did not respond".

defp manage_role(%Interaction{data: %{options: [%{value: role_id}, %{value: "assign"}]}} = interaction) do
Api.add_guild_member_role(interaction.guild_id, interaction.member.user.id, role_id)
end
To respond with something more meaningful, simply return a map like the
following:

defp manage_role(%Interaction{data: %{options: [%{value: role_id}, %{value: "remove"}]}} = interaction) do
Api.remove_guild_member_role(interaction.guild_id, interaction.member.user.id, role_id)
```elixir
def handle(interaction, %{"action" => "assign", "name" => role_id}) do
# ...
%{content: ":white_check_mark: **role assigned**"}
end

def handle_event({:INTERACTION_CREATE, %Interaction{data: %{name: "role"}} = interaction, _ws_state}) do
manage_role(interaction)
def handle(interaction, %{"action" => "remove", "name" => role_id}) do
# ...
%{content: ":white_check_mark: **role removed**"}
end
```

Okay, we now have our handling code done. This is pretty much the same code
that you would use for regular commands.


## Responding to interactions

To respond to interactions, use ``Nostrum.Api.create_interaction_response/2``:

```elixir
defp manage_role(%Interaction{data: %{options: [%{value: role_id}, %{value: "assign"}]}} = interaction) do
Api.add_guild_member_role(interaction.guild_id, interaction.member.user.id, role_id)
response = %{
type: 4, # ChannelMessageWithSource
data: %{
content: "role assigned"
}
}
Api.create_interaction_response(interaction, response)
end
```
Aside from `content`, you can also specify `embeds` and `components`.

We have now built a simple command using slash commands, with argument
conversion delegated to Discords side of things. Further actions on the
Expand Down
12 changes: 9 additions & 3 deletions lib/nostrum/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,15 @@ defmodule Nostrum.Application do
Nostrum.Voice.Supervisor
]

if Application.get_env(:nostrum, :dev),
do: Supervisor.start_link(children ++ [DummySupervisor], strategy: :one_for_one),
else: Supervisor.start_link(children, strategy: :one_for_one)
children = if Application.get_env(:nostrum, :managed_commands) do
children ++ [Nostrum.Command]
else children end

children = if Application.get_env(:nostrum, :dev) do
children ++ [DummySupervisor]
else children end

Supervisor.start_link(children, strategy: :one_for_one, name: Nostrum.Supervisor)
end

@doc false
Expand Down
171 changes: 171 additions & 0 deletions lib/nostrum/command.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
defmodule Nostrum.Command do
alias Nostrum.{Api, Consumer, Struct.Interaction}
use Consumer
require Logger

@moduledoc ~S"""
Reduces boilerplate when implementing application commands.

Here's an example module that responds with "pong!" to /ping:
```Elixir
defmodule MyBot.PingCommand do
use Nostrum.Command, %Nostrum.Command.Spec{
name: "ping",
desc: "sends back pong"
}

def handle(interaction, _options) do
%{content: "pong!"}
end
end
```

This one calculates the sum of two integers:
```Elixir
defmodule MyBot.SumCommand do
use Nostrum.Command, %Nostrum.Command.Spec{
name: "sum",
desc: "adds two integers together",
options: [
%Nostrum.Command.Spec.Option{
name: "a",
desc: "first number",
type: :integer
},
%Nostrum.Command.Spec.Option{
name: "b",
desc: "second number",
type: :integer
}
]
}

def handle(_interaction, options = %{"a" => a, "b" => b}) do
%{content: "The sum of #{a} and #{b} is #{a + b}"}
end
end
```

And this one evaluates an Elixir expression (WARNING: very unsafe):
```Elixir
defmodule MyBot.EvalCommand do
use Nostrum.Command, %Nostrum.Command.Spec{
name: "eval",
desc: "evaluates an Elixir expression",
options: [
%Nostrum.Command.Spec.Option{
name: "expression",
desc: "expression to evaluate",
type: :string
}
]
}

def handle(_interaction, options = %{"expression" => expr}) do
{result, _} = Code.eval_string(expr)
%{content: "`#{inspect(result)}`"}
end
end
```

Note that in order for these commands to work, you should tell Nostrum about
them:
```Elixir
config :nostrum,
managed_commands: [
MyBot.PingCommand,
MyBot.SumCommand,
MyBot.EvalCommand
]
```
"""

defmacro __using__(specification) do
quote do
@behaviour Nostrum.Command
def spec, do: unquote(specification)
end
end

@doc """
Should return the specification of the command as a `Nostrum.Command.Spec`
struct
"""
@callback spec() :: __MODULE__.Spec.t

@doc """
Gets called when the command is invoked. If `mode` in the spec is set to
`:unmanaged`, the return value is ignored. Other values for this setting
(`:managed` and `:ephemeral`) do consider the return value:
- `:ignore` does nothing. The user will continue to see the "Your Bot is
thinking" message for the next 15 minutes, after which it will be replaced
with "The application did not respond"
- `:delete` deletes the "Your Bot is thinking" message
- `{:post, :delete, post}` deletes the "Your Bot is thinking" message and
invokes `module.post_handle(post)` ignoring its return values
- `{:post, data, post}` edits the response to `data` and invokes
`module.post_handle(post)` ignoring its return values
- `data` edits the response to `data`
"""
@callback handle(Interaction.t, %{String.t => String.t | number()}) ::
:ignore | :delete | map() | {:post, map() | :delete, term()}

@doc """
Gets called with the argument `post` after calling `handle` if it returned
`{:post, _, post}` in `:managed` or `:ephemeral` mode
"""
@callback post_handle(term()) :: term()

# TODO: autocomplete
@callback handle_autocomplete(term()) :: term()
@optional_callbacks post_handle: 1, handle_autocomplete: 1

def start_link(), do: Consumer.start_link(__MODULE__)

def handle_event({:READY, _, _}) do
Logger.debug("command consumer started")
{:ok, _} = Supervisor.start_child(Nostrum.Supervisor, __MODULE__.Holder)
end

def handle_event({:INTERACTION_CREATE, %Interaction{data: %{name: command}} = interaction, _}) when command != nil do
Logger.debug("received /#{command} invocation")

case __MODULE__.Holder.get_command(command) do
{:ok, {module, mode}} ->
defer = mode == :managed || mode == :ephemeral
if defer do
flags = if mode == :ephemeral do 64 else 0 end
Api.create_interaction_response!(interaction, %{type: 5, data: %{flags: flags}})
end

options = if interaction.data.options do
Enum.map(interaction.data.options, fn %{name: name, value: value} -> {name, value} end)
|> Enum.into(%{})
else [] end

case module.handle(interaction, options) do
_ when not defer -> :ok
:ignore -> :ok

:delete ->
Api.delete_interaction_response!(interaction)

data when is_map(data) ->
Api.edit_interaction_response!(interaction, data)

{:post, :delete, post} ->
Api.delete_interaction_response!(interaction)
module.post_handle(post)

{:post, data, post} ->
Api.edit_interaction_response!(interaction, data)
module.post_handle(post)
end

{:error, :unknown} ->
Logger.warning("unknown command /#{command} invoked. is it listed under :managed_commands?")
end
end

def handle_event(_), do: :ok
end
Loading