├── .editorconfig ├── .gitignore ├── .php_cs.dist ├── LICENSE.md ├── README.md ├── composer.json └── src └── Discord ├── Client.php ├── Enums └── ApplicationCommandOptionType.php ├── Parts ├── Choices.php ├── Command.php ├── Interaction.php ├── Part.php └── RegisteredCommand.php └── RegisterClient.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [*.yml] 12 | indent_size = 2 13 | 14 | [*.js] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock 3 | test.php 4 | .vscode 5 | .php_cs.cache 6 | -------------------------------------------------------------------------------- /.php_cs.dist: -------------------------------------------------------------------------------- 1 | 7 | 8 | This source file is subject to the MIT license which is 9 | bundled with this source code in the LICENSE.md file. 10 | EOF; 11 | 12 | $fixers = [ 13 | 'blank_line_after_namespace', 14 | 'braces', 15 | 'class_definition', 16 | 'elseif', 17 | 'encoding', 18 | 'full_opening_tag', 19 | 'function_declaration', 20 | 'lowercase_constants', 21 | 'lowercase_keywords', 22 | 'method_argument_space', 23 | 'no_closing_tag', 24 | 'no_spaces_after_function_name', 25 | 'no_spaces_inside_parenthesis', 26 | 'no_trailing_whitespace', 27 | 'no_trailing_whitespace_in_comment', 28 | 'single_blank_line_at_eof', 29 | 'single_class_element_per_statement', 30 | 'single_import_per_statement', 31 | 'single_line_after_imports', 32 | 'switch_case_semicolon_to_colon', 33 | 'switch_case_space', 34 | 'visibility_required', 35 | 'blank_line_after_opening_tag', 36 | 'no_multiline_whitespace_around_double_arrow', 37 | 'no_empty_statement', 38 | 'no_extra_consecutive_blank_lines', 39 | 'include', 40 | 'no_trailing_comma_in_list_call', 41 | 'not_operator_with_successor_space', 42 | 'trailing_comma_in_multiline_array', 43 | 'no_multiline_whitespace_before_semicolons', 44 | 'no_leading_namespace_whitespace', 45 | 'no_blank_lines_after_class_opening', 46 | 'no_blank_lines_after_phpdoc', 47 | 'object_operator_without_whitespace', 48 | 'binary_operator_spaces', 49 | 'phpdoc_indent', 50 | 'phpdoc_inline_tag', 51 | 'phpdoc_no_access', 52 | 'phpdoc_no_package', 53 | 'phpdoc_scalar', 54 | 'phpdoc_summary', 55 | 'phpdoc_to_comment', 56 | 'phpdoc_trim', 57 | 'phpdoc_var_without_name', 58 | 'no_leading_import_slash', 59 | 'blank_line_before_return', 60 | 'no_short_echo_tag', 61 | 'no_trailing_comma_in_singleline_array', 62 | 'single_blank_line_before_namespace', 63 | 'single_quote', 64 | 'no_singleline_whitespace_before_semicolons', 65 | 'cast_spaces', 66 | 'standardize_not_equals', 67 | 'ternary_operator_spaces', 68 | 'trim_array_spaces', 69 | 'unary_operator_spaces', 70 | 'no_unused_imports', 71 | 'no_useless_else', 72 | 'no_useless_return', 73 | 'phpdoc_no_empty_return', 74 | ]; 75 | 76 | $rules = [ 77 | 'concat_space' => ['spacing' => 'none'], 78 | 'phpdoc_no_alias_tag' => ['type' => 'var'], 79 | 'array_syntax' => ['syntax' => 'short'], 80 | 'binary_operator_spaces' => ['align_double_arrow' => true, 'align_equals' => true], 81 | 'header_comment' => ['header' => $header], 82 | 'indentation_type' => true, 83 | 'phpdoc_align' => [ 84 | 'align' => 'vertical' 85 | ] 86 | ]; 87 | 88 | foreach ($fixers as $fix) { 89 | $rules[$fix] = true; 90 | } 91 | 92 | return PhpCsFixer\Config::create() 93 | ->setRules($rules) 94 | ->setFinder( 95 | PhpCsFixer\Finder::create() 96 | ->in(__DIR__) 97 | ); 98 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 David Cole and all contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DiscordPHP-Slash 2 | 3 | PHP server and client for Discord slash commands. Please read the [Discord slash command documentation](https://discord.com/developers/docs/interactions/slash-commands) before using this library. 4 | 5 | > **If you are already using [DiscordPHP](https://github.com/discord-php/DiscordPHP) v7+ you DO NOT need this DiscordPHP-Slash library**. 6 | > Read more here: https://github.com/discord-php/DiscordPHP/wiki/Slash-Command 7 | 8 | ## Warning 9 | 10 | Discord slash commands are still in beta. Expect the way these commands work to change at any time without notice. Same goes for this library. 11 | 12 | ## Requirements 13 | 14 | - PHP >=7.3 15 | - Composer 16 | 17 | ## Installation 18 | 19 | ``` 20 | $ composer require discord-php/slash 21 | ``` 22 | 23 | ## Usage 24 | 25 | There are two "clients" in the library: 26 | - `Discord\Slash\RegisterClient` used for registering commands with Discord. 27 | - `Discord\Slash\Client` used for listening for HTTP requests and responding. 28 | 29 | ### `Discord\Slash\RegisterClient` 30 | 31 | You should read up on how commands are registered in the [Discord Developer Documentation](https://discord.com/developers/docs/interactions/slash-commands#registering-a-command), specifically the `options` array when creating and updating commands. 32 | 33 | ```php 34 | getCommands(); 46 | // gets a list of all guild-specific commands to the given guild 47 | $guildCommands = $client->getCommands('guild_id_here'); 48 | // gets a specific command with command id - if you are getting a guild-specific command you must provide a guild id 49 | $command = $client->getCommand('command_id', 'optionally_guild_id'); 50 | 51 | /// CREATING COMMANDS 52 | 53 | // creates a global command 54 | $command = $client->createGlobalCommand('command_name', 'command_description', [ 55 | // optional array of options 56 | ]); 57 | 58 | // creates a guild specific command 59 | $command = $client->createGuildSpecificCommand('guild_id', 'command_name', 'command_description', [ 60 | // optional array of options 61 | ]); 62 | 63 | /// UPDATING COMMANDS 64 | 65 | // change the command name etc..... 66 | $command->name = 'newcommandname'; 67 | $client->updateCommand($command); 68 | 69 | /// DELETING COMMANDS 70 | 71 | $client->deleteCommand($command); 72 | ``` 73 | 74 | ### `Discord\Slash\Client` 75 | 76 | There are two ways to set up the slash client: 77 | - Webhook method 78 | - Gateway method (deprecated) 79 | 80 | Please read both sections as both have important information and both have advantages/disadvantages. 81 | 82 | #### Webhook method 83 | 84 | Now that you have registered commands, you can set up an HTTP server to listen for requests from Discord. 85 | 86 | There are a few ways to set up an HTTP server to listen for requests: 87 | - The built-in ReactPHP HTTP server. 88 | - Using the built-in ReactPHP HTTP server without HTTPS and using Apache or nginx as a reverse proxy (recommended). 89 | - Using an external HTTP server such as Apache or nginx. 90 | 91 | Whatever path you choose, the server **must** be protected with HTTPS - Discord will not accept regular HTTP. 92 | 93 | At the moment for testing, I am running the built-in ReactPHP HTTP server on port `8080` with no HTTPS. I then have an Apache2 web server *with HTTPS* that acts as a reverse proxy to the ReactPHP server. An example of setting this up on Linux is below. 94 | 95 | Setting up a basic `Client`: 96 | 97 | ```php 98 | 'your_public_key_from_discord_here', 109 | 110 | // optional options, defaults are shown 111 | 'uri' => '0.0.0.0:80', // if you want the client to listen on a different URI 112 | 'logger' => $logger, // different logger, default will write to stdout 113 | 'loop' => $loop, // reactphp event loop, default creates a new loop 114 | 'socket_options' => [], // options to pass to the react/socket instance, default empty array 115 | ]); 116 | 117 | // register a command `/hello` 118 | $client->registerCommand('hello', function (Interaction $interaction, Choices $choices) { 119 | // do some cool stuff here 120 | // good idea to var_dump interaction and choices to see what they contain 121 | 122 | // once finished, you MUST either acknowledge or reply to a message 123 | $interaction->acknowledge(); // acknowledges the message, doesn't show source message 124 | $interaction->acknowledge(true); // acknowledges the message and shows the source message 125 | 126 | // to reply to the message 127 | $interaction->reply('Hello, world!'); // replies to the message, doesn't show source message 128 | $interaction->replyWithSource('Hello, world!'); // replies to the message and shows the source message 129 | 130 | // the `reply` methods take 4 parameters: content, tts, embed and allowed_mentions 131 | // all but content are optional. 132 | // read the discord developer documentation to see what to pass to these options: 133 | // https://discord.com/developers/docs/resources/channel#create-message 134 | }); 135 | 136 | // starts the ReactPHP event loop 137 | $client->run(); 138 | ``` 139 | 140 | Please note that you must always acknowledge the interaction within 3 seconds, otherwise Discord will cancel the interaction. 141 | If you are going to do something that takes time, call the `acknowledge()` function and then add a follow up message using `sendFollowUpMessage()` when ready. 142 | 143 | This library only handles slash commands, and there is no support for any other interactions with Discord such as creating channels, sending other messages etc. You can easily combine the DiscordPHP library with this library to have a much larger collection of tools. All you must do is ensure both clients share the same ReactPHP event loop. Here is an example: 144 | 145 | ```php 146 | '##################', 159 | ]); 160 | 161 | $client = new Client([ 162 | 'public_key' => '???????????????', 163 | 'loop' => $discord->getLoop(), 164 | ]); 165 | 166 | $client->linkDiscord($discord, false); // false signifies that we still want to use the HTTP server - default is true, which will use gateway 167 | 168 | $discord->on('ready', function (Discord $discord) { 169 | // DiscordPHP is ready 170 | }); 171 | 172 | $client->registerCommand('my_cool_command', function (Interaction $interaction, Choices $choices) use ($discord) { 173 | // there are a couple fields in $interaction that will return DiscordPHP parts: 174 | $interaction->guild; 175 | $interaction->channel; 176 | $interaction->member; 177 | 178 | // if you don't link DiscordPHP, it will simply return raw arrays 179 | 180 | $discord->guilds->get('id', 'coolguild')->members->ban(); // do something ? 181 | $interaction->acknowledge(); 182 | }); 183 | 184 | $discord->run(); 185 | ``` 186 | 187 | ### Running behing PHP-CGI/PHP-FPM 188 | 189 | To run behind CGI/FPM and a webserver, the `kambo/httpmessage` package is required: 190 | 191 | ```sh 192 | $ composer require kambo/httpmessage 193 | ``` 194 | 195 | The syntax is then exactly the same as if you were running with the ReactPHP http server, except for the last line: 196 | 197 | ```php 198 | '???????', 206 | 'uri' => null, // note the null uri - signifies to not register the socket 207 | ]); 208 | 209 | // register your commands like normal 210 | $client->registerCommand(...); 211 | 212 | // note the difference here - runCgi instead of run 213 | $client->runCgi(); 214 | ``` 215 | 216 | Do note that the regular DiscordPHP client will not run on CGI or FPM, so your mileage may vary. 217 | 218 | ### Setting up Apache2 as a reverse proxy 219 | 220 | Assuming you already have Apache2 installed and the SSL certificates on your server: 221 | 222 | 1. Enable the required Apache mods: 223 | ```shell 224 | $ sudo a2enmod proxy 225 | $ sudo a2enmod proxy_http 226 | $ sudo a2enmod ssl 227 | ``` 228 | 2. Create a new site or modify the existing default site to listen on port `443`: 229 | ```sh 230 | $ vim /etc/apache2/sites-available/000-default.conf # default site 231 | 232 | # change contents to the following 233 | # listen on 443 234 | ProxyPreserveHost On # preserve the host header from Discord 235 | ProxyPass / http://127.0.0.1:8080/ # pass-through to the HTTP server on port 8080 236 | ProxyPassReverse / http://127.0.0.1:8080/ 237 | 238 | SSLEngine On # enable SSL 239 | SSLCertificateFile /path/to/ssl/cert.crt # change to your cert path 240 | SSLCertificateKeyFile /path/to/ssl/cert.key # change to your key path 241 | 242 | ``` 243 | 3. Restart apache - the code below works on Debian-based systems: 244 | ```shell 245 | $ sudo service apache2 restart 246 | ``` 247 | 248 | #### Gateway method (Deprecated) 249 | 250 | > Starting with [DiscordPHP](https://github.com/discord-php/DiscordPHP) v7.0.0, slash commands are now integrated into the main library. **You no longer need this DiscordPHP-Slash library anymore**! 251 | > Read more here: https://github.com/discord-php/DiscordPHP/blob/master/V7_CONVERSION.md#slash-commands 252 | 253 | The client can connect with a regular [DiscordPHP](https://github.com/discord-php/DiscordPHP) client to listen for interactions over gateway. 254 | To use this method, make sure there is no interactions endpoint set in your Discord developer application. 255 | 256 | Make sure you have included DiscordPHP into your project (at the time of writing, only DiscordPHP 6.x is supported): 257 | 258 | ```sh 259 | $ composer require team-reflex/discord-php 260 | ``` 261 | 262 | You can then create both clients and link them: 263 | 264 | ```php 265 | 'abcd.efdgh.asdas', 274 | ]); 275 | 276 | $client = new Client([ 277 | 'loop' => $discord->getLoop(), // Discord and Client MUST share event loops 278 | ]); 279 | 280 | $client->linkDiscord($discord); 281 | 282 | $client->registerCommand(...); 283 | 284 | $discord->run(); 285 | ``` 286 | 287 | The gateway method is much easier to set up as you do not have to worry about SSL certificates. 288 | 289 | ## License 290 | 291 | This software is licensed under the MIT license which can be viewed in the LICENSE.md file. 292 | 293 | ## Credits 294 | 295 | - [David Cole](mailto:david.cole1340@gmail.com) 296 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-php/slash", 3 | "description": "HTTP server for Discord slash commands", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "David Cole", 9 | "email": "david.cole1340@gmail.com" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=7.3|>=8.0", 14 | "react/http": "^1.2", 15 | "symfony/options-resolver": "^5.2", 16 | "monolog/monolog": "^2.2", 17 | "guzzlehttp/guzzle": "^7.2", 18 | "discord/interactions": "^1|^2", 19 | "simplito/elliptic-php": "^1", 20 | "discord-php/http": "^8|^9" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "Discord\\Slash\\": "src/Discord" 25 | } 26 | }, 27 | "require-dev": { 28 | "symfony/var-dumper": "^5.2", 29 | "team-reflex/discord-php": "^6", 30 | "friendsofphp/php-cs-fixer": "^2.18" 31 | }, 32 | "suggest": { 33 | "kambo/httpmessage": "Required for hosting the command server behind a CGI/FPM server.", 34 | "team-reflex/discord-php": "Provides an easier interface, where interactions are sent via websocket rather than a webhook." 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Discord/Client.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This source file is subject to the MIT license which is 9 | * bundled with this source code in the LICENSE.md file. 10 | */ 11 | 12 | namespace Discord\Slash; 13 | 14 | use Discord\Discord; 15 | use Discord\Http\Drivers\React; 16 | use Discord\Http\Http; 17 | use Discord\Interaction as DiscordInteraction; 18 | use Discord\InteractionResponseType; 19 | use Discord\InteractionType; 20 | use Discord\Slash\Parts\Interaction; 21 | use Discord\Slash\Parts\RegisteredCommand; 22 | use Discord\WebSockets\Event; 23 | use Exception; 24 | use InvalidArgumentException; 25 | use Kambo\Http\Message\Environment\Environment; 26 | use Kambo\Http\Message\Factories\Environment\ServerRequestFactory; 27 | use React\Http\Message\Response; 28 | use Monolog\Handler\StreamHandler; 29 | use Monolog\Logger; 30 | use Psr\Http\Message\ServerRequestInterface; 31 | use Psr\Log\LoggerInterface; 32 | use React\EventLoop\Factory; 33 | use React\EventLoop\LoopInterface; 34 | use React\Http\Server as HttpServer; 35 | use React\Promise\ExtendedPromiseInterface; 36 | use React\Promise\Promise; 37 | use React\Socket\Server as SocketServer; 38 | use Symfony\Component\OptionsResolver\OptionsResolver; 39 | use Throwable; 40 | 41 | /** 42 | * The Client class acts as an HTTP web server to handle requests from Discord when a command 43 | * is triggered. The class can also be used as a request handler by mocking a ServerRequestInterface 44 | * to allow it to be used with another webserver such as Apache or nginx. 45 | * 46 | * @author David Cole 47 | */ 48 | class Client 49 | { 50 | const API_BASE_URI = 'https://discord.com/api/v8/'; 51 | 52 | /** 53 | * Array of options for the client. 54 | * 55 | * @var array 56 | */ 57 | private $options; 58 | 59 | /** 60 | * HTTP server. 61 | * 62 | * @var HttpServer 63 | */ 64 | private $server; 65 | 66 | /** 67 | * Socket listening for connections. 68 | * 69 | * @var SocketServer 70 | */ 71 | private $socket; 72 | 73 | /** 74 | * An array of registered commands. 75 | * 76 | * @var RegisteredCommand[] 77 | */ 78 | private $commands; 79 | 80 | /** 81 | * ReactPHP event loop. 82 | * 83 | * @var LoopInterface 84 | */ 85 | private $loop; 86 | 87 | /** 88 | * Logger for client. 89 | * 90 | * @var LoggerInterface 91 | */ 92 | private $logger; 93 | 94 | /** 95 | * Optional Discord client. 96 | * 97 | * @var \Discord\Discord 98 | */ 99 | private $discord; 100 | 101 | /** 102 | * Will we listen for gateway events or start HTTP server? 103 | * 104 | * @var bool 105 | */ 106 | private $interactionsOverGateway = false; 107 | 108 | /** 109 | * HTTP client. 110 | * 111 | * @var Http 112 | */ 113 | private $http; 114 | 115 | public function __construct(array $options = []) 116 | { 117 | $this->options = $this->resolveOptions($options); 118 | $this->loop = $this->options['loop']; 119 | $this->logger = $this->options['logger']; 120 | 121 | $this->loop->futureTick(function () { 122 | if ($this->interactionsOverGateway) { 123 | $this->logger->info('not starting http server - will wait for gateway events'); 124 | 125 | return; 126 | } 127 | 128 | $this->registerServer(); 129 | }); 130 | } 131 | 132 | /** 133 | * Links the slash command client with a DiscordPHP client. 134 | * This will do a couple things: 135 | * - Interactions will be provided as "rich", meaning that the properties will be parts from DiscordPHP. 136 | * - If the `$interactionsOverGateway` parameter is true, the client will listen for interactions via 137 | * gateway and the HTTP server will not be started. 138 | * 139 | * @param Discord $discord 140 | * @param bool $interactionsOverGateway 141 | */ 142 | public function linkDiscord(Discord $discord, bool $interactionsOverGateway = true) 143 | { 144 | $this->discord = $discord; 145 | $this->interactionsOverGateway = $interactionsOverGateway; 146 | 147 | if ($this->discord->getLoop() !== $this->loop) { 148 | throw new \RuntimeException('The Discord and slash client do not share the same event loop.'); 149 | } 150 | 151 | $this->http = $discord->getHttpClient(); 152 | 153 | if ($interactionsOverGateway) { 154 | $discord->on(Event::INTERACTION_CREATE, function ($interaction) { 155 | // possibly the laziest thing ive ever done - stdClass -> array 156 | $interaction = json_decode(json_encode($interaction), true); 157 | $interaction = new Interaction($interaction, $this->discord, $this->http, $this->options['application_id'] ?? null); 158 | 159 | $this->handleInteraction($interaction)->done(function ($response) use ($interaction) { 160 | $this->handleGatewayInteractionResponse($response, $interaction); 161 | }); 162 | }); 163 | } 164 | } 165 | 166 | /** 167 | * Resolves the options for the client. 168 | * 169 | * @param array $options 170 | * 171 | * @return array 172 | */ 173 | private function resolveOptions(array $options): array 174 | { 175 | $resolver = new OptionsResolver(); 176 | 177 | $resolver 178 | ->setDefined([ 179 | 'uri', 180 | 'logger', 181 | 'loop', 182 | 'public_key', 183 | 'socket_options', 184 | 'application_id', 185 | 'token', 186 | ]) 187 | ->setDefaults([ 188 | 'uri' => '0.0.0.0:80', 189 | 'loop' => Factory::create(), 190 | 'socket_options' => [], 191 | 'public_key' => null, 192 | 'application_id' => null, 193 | 'token' => null, 194 | ]); 195 | 196 | $options = $resolver->resolve($options); 197 | 198 | if (! isset($options['logger'])) { 199 | $options['logger'] = (new Logger('DiscordPHP/Slash'))->pushHandler(new StreamHandler('php://stdout')); 200 | } 201 | 202 | return $options; 203 | } 204 | 205 | /** 206 | * Sets up the ReactPHP HTTP server. 207 | */ 208 | private function registerServer() 209 | { 210 | // no uri => cgi/fpm 211 | if (is_null($this->options['uri'])) { 212 | $this->logger->info('running in CGI/FPM mode - follow up messages will not work'); 213 | 214 | return; 215 | } 216 | 217 | $this->server = new HttpServer($this->getLoop(), function (ServerRequestInterface $request) { 218 | $identifier = sprintf('%s %s %s', $request->getMethod(), $request->getRequestTarget(), $request->getHeaderLine('User-Agent')); 219 | 220 | return $this->handleRequest($request)->then(function (Response $response) use ($identifier) { 221 | $this->logger->info("{$identifier} {$response->getStatusCode()} {$response->getReasonPhrase()}"); 222 | 223 | return $response; 224 | }, function (Throwable $e) use ($identifier) { 225 | $this->logger->warning("{$identifier} {$e->getMessage()}"); 226 | }); 227 | }); 228 | $this->socket = new SocketServer($this->options['uri'], $this->getLoop(), $this->options['socket_options']); 229 | $this->server->listen($this->socket); 230 | 231 | // already provided HTTP client through DiscordPHP 232 | if (! is_null($this->http)) { 233 | $this->logger->info('using DiscordPHP http client'); 234 | 235 | return; 236 | } 237 | 238 | if (! isset($this->options['token'])) { 239 | $this->logger->warning('no token provided - http client will not work'); 240 | } 241 | 242 | if (! isset($this->options['application_id'])) { 243 | $this->logger->warning('no application id provided - some methods may not work'); 244 | } 245 | 246 | $this->http = new Http('Bot '.$this->options['token'], $this->loop, $this->logger, new React($this->loop, $this->options['socket_options'])); 247 | } 248 | 249 | /** 250 | * Handles an HTTP request to the server. 251 | * 252 | * @param ServerRequestInterface $request 253 | */ 254 | public function handleRequest(ServerRequestInterface $request) 255 | { 256 | if (! isset($this->options['public_key'])) { 257 | $this->logger->warning('A public key was not given to the slash client. Unable to validate request.'); 258 | 259 | return \React\Promise\Resolve(new Response(401, [0], 'Not verified')); 260 | } 261 | 262 | // validate request with public key 263 | $signature = $request->getHeaderLine('X-Signature-Ed25519'); 264 | $timestamp = $request->getHeaderLine('X-Signature-Timestamp'); 265 | 266 | if (empty($signature) || empty($timestamp) || ! DiscordInteraction::verifyKey((string) $request->getBody(), $signature, $timestamp, $this->options['public_key'])) { 267 | return \React\Promise\Resolve(new Response(401, [0], 'Not verified')); 268 | } 269 | 270 | $interaction = new Interaction(json_decode($request->getBody(), true), $this->discord, $this->http, $this->options['application_id'] ?? null); 271 | 272 | $this->logger->info('received interaction', $interaction->jsonSerialize()); 273 | 274 | return $this->handleInteraction($interaction)->then(function ($result) { 275 | $this->logger->info('responding to interaction', $result); 276 | 277 | return new Response(200, ['Content-Type' => 'application/json'], json_encode($result)); 278 | }); 279 | } 280 | 281 | /** 282 | * Handles an interaction from Discord. 283 | * 284 | * @param Interaction $interaction 285 | * 286 | * @return ExtendedPromiseInterface 287 | */ 288 | private function handleInteraction(Interaction $interaction): ExtendedPromiseInterface 289 | { 290 | return new Promise(function ($resolve, $reject) use ($interaction) { 291 | switch ($interaction->type) { 292 | case InteractionType::PING: 293 | return $resolve([ 294 | 'type' => InteractionResponseType::PONG, 295 | ]); 296 | case InteractionType::APPLICATION_COMMAND: 297 | $interaction->setResolve($resolve); 298 | 299 | return $this->handleApplicationCommand($interaction); 300 | } 301 | }); 302 | } 303 | 304 | /** 305 | * Handles an application command interaction from Discord. 306 | * 307 | * @param Interaction $interaction 308 | */ 309 | private function handleApplicationCommand(Interaction $interaction): void 310 | { 311 | $checkCommand = function ($command) use ($interaction, &$checkCommand) { 312 | if (isset($this->commands[$command['name']])) { 313 | if ($this->commands[$command['name']]->execute($command['options'] ?? [], $interaction)) { 314 | return true; 315 | } 316 | } 317 | 318 | foreach ($command['options'] ?? [] as $option) { 319 | if ($checkCommand($option)) { 320 | return true; 321 | } 322 | } 323 | }; 324 | 325 | $checkCommand($interaction->data); 326 | } 327 | 328 | /** 329 | * Handles the user response from the command when the interaction 330 | * originates from the gateway. 331 | * 332 | * @param array $response 333 | * @param Interaction $interaction 334 | */ 335 | public function handleGatewayInteractionResponse(array $response, Interaction $interaction) 336 | { 337 | $this->discord->getHttpClient()->post("interactions/{$interaction->id}/{$interaction->token}/callback", $response)->done(); 338 | } 339 | 340 | /** 341 | * Registeres a command with the client. 342 | * 343 | * @param string|array $name 344 | * @param callable $callback 345 | * 346 | * @return RegisteredCommand 347 | */ 348 | public function registerCommand($name, callable $callback = null): RegisteredCommand 349 | { 350 | if (is_array($name) && count($name) == 1) { 351 | $name = array_shift($name); 352 | } 353 | 354 | // registering base command 355 | if (! is_array($name) || count($name) == 1) { 356 | if (isset($this->commands[$name])) { 357 | throw new InvalidArgumentException("The command `{$name}` already exists."); 358 | } 359 | 360 | return $this->commands[$name] = new RegisteredCommand($name, $callback); 361 | } 362 | 363 | $baseCommand = array_shift($name); 364 | 365 | if (! isset($this->commands[$baseCommand])) { 366 | $this->registerCommand($baseCommand); 367 | } 368 | 369 | return $this->commands[$baseCommand]->addSubCommand($name, $callback); 370 | } 371 | 372 | /** 373 | * Runs the client on a CGI/FPM server. 374 | */ 375 | public function runCgi() 376 | { 377 | if (empty($_SERVER['REMOTE_ADDR'])) { 378 | throw new Exception('The `runCgi()` method must only be called from PHP-CGI/FPM.'); 379 | } 380 | 381 | if (! class_exists(Environment::class)) { 382 | throw new Exception('The `kambo/httpmessage` package must be installed to handle slash command interactions with a CGI/FPM server.'); 383 | } 384 | 385 | $environment = new Environment($_SERVER, fopen('php://input', 'w+'), $_POST, $_COOKIE, $_FILES); 386 | $serverRequest = (new ServerRequestFactory())->create($environment); 387 | 388 | $this->handleRequest($serverRequest)->then(function (Response $response) { 389 | http_response_code($response->getStatusCode()); 390 | echo (string) $response->getBody(); 391 | }); 392 | } 393 | 394 | /** 395 | * Starts the ReactPHP event loop. 396 | */ 397 | public function run() 398 | { 399 | $this->getLoop()->run(); 400 | } 401 | 402 | /** 403 | * Gets the ReactPHP event loop. 404 | * 405 | * @return LoopInterface 406 | */ 407 | public function getLoop(): LoopInterface 408 | { 409 | return $this->loop; 410 | } 411 | } 412 | -------------------------------------------------------------------------------- /src/Discord/Enums/ApplicationCommandOptionType.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This source file is subject to the MIT license which is 9 | * bundled with this source code in the LICENSE.md file. 10 | */ 11 | 12 | namespace Discord\Slash\Enums; 13 | 14 | /** 15 | * @link https://discord.com/developers/docs/interactions/slash-commands#applicationcommandoptiontype 16 | * @author David Cole 17 | */ 18 | final class ApplicationCommandOptionType 19 | { 20 | public const SUB_COMMAND = 1; 21 | public const SUB_COMMAND_GROUP = 2; 22 | public const STRING = 3; 23 | public const INTEGER = 4; 24 | public const BOOLEAN = 5; 25 | public const USER = 6; 26 | public const CHANNEL = 7; 27 | public const ROLE = 8; 28 | } 29 | -------------------------------------------------------------------------------- /src/Discord/Parts/Choices.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This source file is subject to the MIT license which is 9 | * bundled with this source code in the LICENSE.md file. 10 | */ 11 | 12 | namespace Discord\Slash\Parts; 13 | 14 | /** 15 | * Choices represents an array of choices that can be given to a command. 16 | * 17 | * @author David Cole 18 | */ 19 | class Choices 20 | { 21 | /** 22 | * Array of choices. 23 | * 24 | * @var array[] 25 | */ 26 | private $choices; 27 | 28 | /** 29 | * Choices constructor. 30 | * 31 | * @param array $choices 32 | */ 33 | public function __construct(array $choices) 34 | { 35 | $this->choices = $choices; 36 | } 37 | 38 | /** 39 | * Handles dynamic get requests to the class. 40 | * 41 | * @param string $name 42 | * 43 | * @return string|int|null 44 | */ 45 | public function __get($name) 46 | { 47 | foreach ($this->choices as $choice) { 48 | if ($choice['name'] == $name) { 49 | return $choice['value'] ?? null; 50 | } 51 | } 52 | 53 | return null; 54 | } 55 | 56 | /** 57 | * Returns the info to appear when the class is `var_dump`'d. 58 | * 59 | * @return array 60 | */ 61 | public function __debugInfo() 62 | { 63 | $response = []; 64 | 65 | foreach ($this->choices as $choice) { 66 | $response[$choice['name']] = $choice['value']; 67 | } 68 | 69 | return $response; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Discord/Parts/Command.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This source file is subject to the MIT license which is 9 | * bundled with this source code in the LICENSE.md file. 10 | */ 11 | 12 | namespace Discord\Slash\Parts; 13 | 14 | /** 15 | * Represents a command registered on the Discord servers. 16 | * 17 | * @author David Cole 18 | */ 19 | class Command extends Part 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /src/Discord/Parts/Interaction.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This source file is subject to the MIT license which is 9 | * bundled with this source code in the LICENSE.md file. 10 | */ 11 | 12 | namespace Discord\Slash\Parts; 13 | 14 | use Discord\Discord; 15 | use Discord\Http\Http; 16 | use Discord\InteractionResponseType; 17 | use Discord\Parts\Embed\Embed; 18 | use React\Promise\ExtendedPromiseInterface; 19 | use Symfony\Component\OptionsResolver\OptionsResolver; 20 | 21 | /** 22 | * An interaction sent from Discord servers. 23 | * 24 | * @property string $id 25 | * @property string $type 26 | * @property array $data 27 | * @property string|null $guild_id 28 | * @property string $channel_id 29 | * @property \Discord\Parts\Member\Member|array $member 30 | * @property string $token 31 | * @property int $version 32 | * 33 | * The following properties are only present when a DiscordPHP client is given to 34 | * the slash command client: 35 | * @property \Discord\Parts\Guild\Guild|null $guild 36 | * @property \Discord\Parts\Channel\Channel|null $channel 37 | */ 38 | class Interaction extends Part 39 | { 40 | /** 41 | * {@inheritdoc} 42 | */ 43 | protected $fillable = ['id', 'type', 'data', 'guild_id', 'channel_id', 'member', 'token', 'version', 'guild', 'channel']; 44 | 45 | /** 46 | * The resolve function for the response promise. 47 | * 48 | * @var callable 49 | */ 50 | private $resolve; 51 | 52 | /** 53 | * DiscordPHP instance. 54 | * 55 | * @var Discord 56 | */ 57 | private $discord; 58 | 59 | /** 60 | * HTTP instance. 61 | * 62 | * @var Http 63 | */ 64 | private $http; 65 | 66 | /** 67 | * Application ID. 68 | * 69 | * @var string 70 | */ 71 | private $application_id; 72 | 73 | /** 74 | * {@inheritdoc} 75 | * 76 | * @param Discord $discord 77 | */ 78 | public function __construct($attributes = [], Discord $discord = null, Http $http = null, string $application_id = null) 79 | { 80 | parent::__construct($attributes); 81 | $this->discord = $discord; 82 | $this->http = $http; 83 | $this->application_id = $application_id; 84 | 85 | if (is_null($this->http) && ! is_null($this->discord)) { 86 | $this->http = $this->discord->getHttpClient(); 87 | } 88 | 89 | if (is_null($this->application_id) && ! is_null($this->discord)) { 90 | $this->application_id = $this->discord->application->id; 91 | } 92 | } 93 | 94 | /** 95 | * Sets the resolve function for the response promise. 96 | * 97 | * @param callable $resolve 98 | */ 99 | public function setResolve(callable $resolve) 100 | { 101 | $this->resolve = $resolve; 102 | } 103 | 104 | /** 105 | * Acknowledges the interaction. At a bare minimum, 106 | * you should always acknowledge. 107 | * 108 | * Source is unused 109 | * @see https://discord.com/developers/docs/change-log#changes-to-slash-command-response-types-and-flags 110 | */ 111 | public function acknowledge(?bool $source = true) 112 | { 113 | ($this->resolve)([ 114 | 'type' => InteractionResponseType::DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, 115 | ]); 116 | 117 | } 118 | 119 | /** 120 | * Replies to the interaction with a message. 121 | * 122 | * @see https://discord.com/developers/docs/interactions/slash-commands#interaction-response-interactionapplicationcommandcallbackdata 123 | * 124 | * @param string $content String content for the message. Required. 125 | * @param bool $tts Whether the message should be text-to-speech. 126 | * @param array[]|Embed[]|null $embeds An array of up to 10 embeds. Can also be an array of DiscordPHP embeds. 127 | * @param array|null $allowed_mentions Allowed mentions object. See Discord developer docs. 128 | * @param int|null $flags Set to 64 to make your response ephemeral 129 | * 130 | * Source is unused 131 | * @see https://discord.com/developers/docs/change-log#changes-to-slash-command-response-types-and-flags 132 | */ 133 | public function reply(string $content, bool $tts = false, array $embeds = [], ?array $allowed_mentions = null, ?bool $source = false, ?int $flags = null) 134 | { 135 | $embeds = array_map(function ($e) { 136 | if ($e instanceof Embed) { 137 | return $e->getRawAttributes(); 138 | } 139 | 140 | return $e; 141 | }, $embeds); 142 | 143 | $response = [ 144 | 'content' => $content, 145 | 'tts' => $tts, 146 | 'embeds' => $embeds, 147 | 'allowed_mentions' => $allowed_mentions, 148 | 'flags' => $flags, 149 | ]; 150 | 151 | ($this->resolve)([ 152 | 'type' => InteractionResponseType::CHANNEL_MESSAGE_WITH_SOURCE, 153 | 'data' => $response, 154 | ]); 155 | } 156 | 157 | /** 158 | * Replies to the interaction with a message and shows the source message. 159 | * Alias for `reply()` with source = true. 160 | * 161 | * @see reply() 162 | * 163 | * @param string $content 164 | * @param bool $tts 165 | * @param array|null $embeds 166 | * @param array|null $allowed_mentions 167 | * @param int|null $flags 168 | */ 169 | public function replyWithSource(string $content, bool $tts = false, ?array $embeds = null, ?array $allowed_mentions = null, ?int $flags = null) 170 | { 171 | $this->reply($content, $tts, $embeds, $allowed_mentions, true, $flags); 172 | } 173 | 174 | /** 175 | * Updates the original response to the interaction. 176 | * Must have already used `reply` or `replyWithSource`. 177 | * 178 | * Requires the ReactPHP event loop to be running and a token 179 | * to be passed to the slash client. 180 | * Also requires the slash client to be linked to a DiscordPHP client 181 | * OR an application ID given in the options array. 182 | * 183 | * @param string $content Content of the message. 184 | * @param array[]|Embed[]|null $embeds An array of up to 10 embeds. Can also be an array of DiscordPHP embeds. 185 | * @param array|null $allowed_mentions Allowed mentions object. See Discord developer docs. 186 | * 187 | * @return ExtendedPromiseInterface 188 | */ 189 | public function updateInitialResponse(?string $content = null, ?array $embeds = null, ?array $allowed_mentions = null): ExtendedPromiseInterface 190 | { 191 | return $this->http->patch("webhooks/{$this->application_id}/{$this->token}/messages/@original", [ 192 | 'content' => $content, 193 | 'embeds' => $embeds, 194 | 'allowed_mentions' => $allowed_mentions, 195 | ]); 196 | } 197 | 198 | /** 199 | * Deletes the original response to the interaction. 200 | * Must have already used `reply` or `replyToSource`. 201 | * 202 | * Requires the ReactPHP event loop to be running and a token 203 | * to be passed to the slash client. 204 | * Also requires the slash client to be linked to a DiscordPHP client 205 | * OR an application ID given in the options array. 206 | * 207 | * @return ExtendedPromiseInterface 208 | */ 209 | public function deleteInitialResponse(): ExtendedPromiseInterface 210 | { 211 | return $this->http->delete("webhooks/{$this->application_id}/{$this->token}/messages/@original"); 212 | } 213 | 214 | /** 215 | * Creates a follow up message to the interaction. 216 | * Takes an array of options similar to a webhook - see the Discord developer documentation 217 | * for more information. 218 | * Returns an object representing the message in a promise. 219 | * To send files, use the `sendFollowUpFile` method. 220 | * 221 | * Requires the ReactPHP event loop to be running and a token 222 | * to be passed to the slash client. 223 | * Also requires the slash client to be linked to a DiscordPHP client 224 | * OR an application ID given in the options array. 225 | * 226 | * @see https://discord.com/developers/docs/resources/webhook#execute-webhook 227 | * 228 | * @param array $options 229 | * 230 | * @return ExtendedPromiseInterface 231 | */ 232 | public function sendFollowUpMessage(array $options): ExtendedPromiseInterface 233 | { 234 | return $this->http->post("webhooks/{$this->application_id}/{$this->token}", $this->validateFollowUpMessage($options)); 235 | } 236 | 237 | /** 238 | * Updates a follow up message. 239 | * 240 | * Requires the ReactPHP event loop to be running and a token 241 | * to be passed to the slash client. 242 | * Also requires the slash client to be linked to a DiscordPHP client 243 | * OR an application ID given in the options array. 244 | * 245 | * @param string $message_id 246 | * @param string|null $content 247 | * @param array[]|Embed[]|null $embeds 248 | * @param array|null $allowed_mentions 249 | * 250 | * @return ExtendedPromiseInterface 251 | */ 252 | public function updateFollowUpMessage(string $message_id, string $content = null, array $embeds = null, array $allowed_mentions = null) 253 | { 254 | return $this->http->patch("webhooks/{$this->application_id}/{$this->token}/messages/{$message_id}", [ 255 | 'content' => $content, 256 | 'embeds' => $embeds, 257 | 'allowed_mentions' => $allowed_mentions, 258 | ]); 259 | } 260 | 261 | /** 262 | * Deletes a follow up message. 263 | * 264 | * Requires the ReactPHP event loop to be running and a token 265 | * to be passed to the slash client. 266 | * Also requires the slash client to be linked to a DiscordPHP client 267 | * OR an application ID given in the options array. 268 | * 269 | * @param string $message_id 270 | * 271 | * @return ExtendedPromiseInterface 272 | */ 273 | public function deleteFollowUpMessage(string $message_id) 274 | { 275 | return $this->http->delete("webhooks/{$this->application_id}/{$this->token}/messages/{$message_id}"); 276 | } 277 | /** 278 | * Validates a follow up message content. 279 | * 280 | * @param array $options 281 | * 282 | * @return array 283 | */ 284 | private function validateFollowUpMessage(array $options) 285 | { 286 | $resolver = new OptionsResolver(); 287 | 288 | $resolver 289 | ->setDefined([ 290 | 'content', 'username', 'avatar_url', 291 | 'tts', 'embeds', 'allowed_mentions', 292 | ]) 293 | ->setAllowedTypes('content', 'string') 294 | ->setAllowedTypes('username', 'string') 295 | ->setAllowedTypes('avatar_url', 'string') 296 | ->setAllowedTypes('tts', 'bool') 297 | ->setAllowedTypes('embeds', 'array') 298 | ->setAllowedTypes('allowed_mentions', 'array'); 299 | 300 | $options = $resolver->resolve($options); 301 | 302 | if (! isset($options['content']) && ! isset($options['embeds'])) { 303 | throw new \RuntimeException('One of content, embeds is required.'); 304 | } 305 | 306 | return $options; 307 | } 308 | 309 | /** 310 | * Returns the guild attribute. 311 | * 312 | * @return \Discord\Parts\Guild\Guild 313 | */ 314 | private function guild() 315 | { 316 | return $this->discord->guilds->get('id', $this->guild_id); 317 | } 318 | 319 | /** 320 | * Returns the channel attribute. 321 | * 322 | * @return \Discord\Parts\Channel\Channel 323 | */ 324 | private function channel() 325 | { 326 | return $this->guild->channels->get('id', $this->channel_id); 327 | } 328 | 329 | /** 330 | * Returns the member attribute. 331 | * 332 | * @return \Discord\Parts\User\Member 333 | */ 334 | private function member() 335 | { 336 | return $this->guild->members->get('id', $this->attributes['member']['user']['id']); 337 | } 338 | 339 | /** 340 | * {@inheritdoc} 341 | */ 342 | public function __get(string $key) 343 | { 344 | if (! is_null($this->discord) && is_callable([$this, $key])) { 345 | return $this->{$key}(); 346 | } 347 | 348 | return parent::__get($key); 349 | } 350 | } 351 | -------------------------------------------------------------------------------- /src/Discord/Parts/Part.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This source file is subject to the MIT license which is 9 | * bundled with this source code in the LICENSE.md file. 10 | */ 11 | 12 | namespace Discord\Slash\Parts; 13 | 14 | use JsonSerializable; 15 | 16 | /** 17 | * Represents a part in the Discord servers. 18 | * 19 | * @author David Cole 20 | */ 21 | class Part implements JsonSerializable 22 | { 23 | /** 24 | * Array of fillable fields. 25 | * 26 | * @var array 27 | */ 28 | protected $fillable = []; 29 | 30 | /** 31 | * Array of attributes. 32 | * 33 | * @var array 34 | */ 35 | protected $attributes; 36 | 37 | /** 38 | * Part constructor. 39 | * 40 | * @param array $attributes 41 | */ 42 | public function __construct($attributes = []) 43 | { 44 | $this->attributes = (array) $attributes; 45 | } 46 | 47 | /** 48 | * Returns the parts attributes. 49 | * 50 | * @return array 51 | */ 52 | public function getAttributes(): array 53 | { 54 | return $this->attributes; 55 | } 56 | 57 | /** 58 | * Gets an attribute from the part. 59 | * 60 | * @param string $key 61 | */ 62 | public function __get(string $key) 63 | { 64 | return $this->attributes[$key] ?? null; 65 | } 66 | 67 | /** 68 | * Sets an attribute in the part. 69 | * 70 | * @param string $key 71 | * @param mixed $value 72 | */ 73 | public function __set(string $key, $value) 74 | { 75 | $this->attributes[$key] = $value; 76 | } 77 | 78 | /** 79 | * Returns the part in JSON serializable format. 80 | * 81 | * @return array 82 | */ 83 | public function jsonSerialize(): array 84 | { 85 | return $this->attributes; 86 | } 87 | 88 | /** 89 | * Provides debugging information to PHP. 90 | * 91 | * @return array 92 | */ 93 | public function __debugInfo() 94 | { 95 | $result = []; 96 | 97 | foreach ($this->fillable as $field) { 98 | $result[$field] = $this->{$field}; 99 | } 100 | 101 | return $result; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Discord/Parts/RegisteredCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This source file is subject to the MIT license which is 9 | * bundled with this source code in the LICENSE.md file. 10 | */ 11 | 12 | namespace Discord\Slash\Parts; 13 | 14 | use InvalidArgumentException; 15 | 16 | /** 17 | * RegisteredCommand represents a command that has been registered 18 | * with the Discord servers and has a handler to handle when the 19 | * command is triggered. 20 | * 21 | * @author David Cole 22 | */ 23 | class RegisteredCommand 24 | { 25 | /** 26 | * The name of the command. 27 | * 28 | * @var string 29 | */ 30 | private $name; 31 | 32 | /** 33 | * The callback to be called when the command is triggered. 34 | * 35 | * @var callable 36 | */ 37 | private $callback; 38 | 39 | /** 40 | * Array of sub-commands. 41 | * 42 | * @var RegisteredCommand[] 43 | */ 44 | private $subCommands; 45 | 46 | /** 47 | * RegisteredCommand represents a command that has been registered 48 | * with the Discord servers and has a handler to handle when the 49 | * command is triggered. 50 | * 51 | * @param string $name 52 | * @param callable $callback 53 | */ 54 | public function __construct(string $name, callable $callback = null) 55 | { 56 | $this->name = $name; 57 | $this->callback = $callback; 58 | } 59 | 60 | /** 61 | * Executes the command. Will search for a sub-command if given, 62 | * otherwise executes the callback, if given. 63 | * 64 | * @param array $options 65 | * @param Interaction $interaction 66 | * 67 | * @return bool Whether the command successfully executed. 68 | */ 69 | public function execute(array $options, Interaction $interaction): bool 70 | { 71 | foreach ($options as $option) { 72 | if (isset($this->subCommands[$option['name']])) { 73 | if ($this->subCommands[$option['name']]->execute($option['options'] ?? [], $interaction)) { 74 | return true; 75 | } 76 | } 77 | } 78 | 79 | if (! is_null($this->callback)) { 80 | ($this->callback)($interaction, new Choices($options)); 81 | 82 | return true; 83 | } 84 | 85 | return false; 86 | } 87 | 88 | /** 89 | * Sets the callback for the command. 90 | * 91 | * @param callable $callback 92 | */ 93 | public function setCallback(callable $callback) 94 | { 95 | $this->callback = $callback; 96 | } 97 | 98 | /** 99 | * Tries to get a sub-command if exists. 100 | * 101 | * @param string $name 102 | * 103 | * @return RegisteredCommand|null 104 | */ 105 | public function getSubCommand(string $name): ?RegisteredCommand 106 | { 107 | return $this->subCommands[$name] ?? null; 108 | } 109 | 110 | /** 111 | * Adds a sub-command to the command. 112 | * 113 | * @param string|array $name 114 | * @param callable $callback 115 | * 116 | * @return RegisteredCommand 117 | */ 118 | public function addSubCommand($name, callable $callback = null): RegisteredCommand 119 | { 120 | if (is_array($name) && count($name) == 1) { 121 | $name = array_shift($name); 122 | } 123 | 124 | if (! is_array($name) || count($name) == 1) { 125 | if (isset($this->subCommands[$name])) { 126 | throw new InvalidArgumentException("The command `{$name}` already exists."); 127 | } 128 | 129 | return $this->subCommands[$name] = new static($name, $callback); 130 | } 131 | 132 | $baseCommand = array_shift($name); 133 | 134 | if (! isset($this->subCommands[$baseCommand])) { 135 | $this->addSubCommand($baseCommand); 136 | } 137 | 138 | return $this->subCommands[$baseCommand]->addSubCommand($name, $callback); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Discord/RegisterClient.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This source file is subject to the MIT license which is 9 | * bundled with this source code in the LICENSE.md file. 10 | */ 11 | 12 | namespace Discord\Slash; 13 | 14 | use Discord\Slash\Enums\ApplicationCommandOptionType; 15 | use Discord\Slash\Parts\Command; 16 | use Discord\Slash\Parts\Part; 17 | use GuzzleHttp\Client as GuzzleClient; 18 | use GuzzleHttp\Exception\RequestException; 19 | use ReflectionClass; 20 | use Symfony\Component\OptionsResolver\OptionsResolver; 21 | 22 | /** 23 | * This class is used to register commands with Discord. 24 | * You should only need to use this class once, from thereon you can use the Client 25 | * class to start a webserver to listen for slash command requests. 26 | * 27 | * @author David Cole 28 | */ 29 | class RegisterClient 30 | { 31 | /** 32 | * Discord application. 33 | * 34 | * @var object 35 | */ 36 | private $application; 37 | 38 | /** 39 | * HTTP client. 40 | * 41 | * @var GuzzleClient 42 | */ 43 | private $http; 44 | 45 | /** 46 | * HTTP client constructor. 47 | * 48 | * @param string $token 49 | */ 50 | public function __construct(string $token) 51 | { 52 | $this->http = new GuzzleClient([ 53 | 'base_uri' => Client::API_BASE_URI, 54 | 'headers' => [ 55 | 'User-Agent' => $this->getUserAgent(), 56 | 'Authorization' => 'Bot '.$token, 57 | ], 58 | ]); 59 | 60 | $this->application = new Part($this->request('GET', 'oauth2/applications/@me')); 61 | } 62 | 63 | /** 64 | * Returns a list of commands. 65 | * 66 | * @param string|null $guild_id The guild ID to get commands for. 67 | * 68 | * @return Command[] 69 | */ 70 | public function getCommands(?string $guild_id = null) 71 | { 72 | $endpoint = "applications/{$this->application->id}"; 73 | 74 | if (! is_null($guild_id)) { 75 | $endpoint .= "/guilds/{$guild_id}"; 76 | } 77 | 78 | $response = $this->request('GET', $endpoint.'/commands'); 79 | $commands = []; 80 | 81 | foreach ($response as $command) { 82 | if (! is_null($guild_id)) { 83 | $command['guild_id'] = $guild_id; 84 | } 85 | 86 | $commands[] = new Command($command); 87 | } 88 | 89 | return $commands; 90 | } 91 | 92 | /** 93 | * Tries to get a command. 94 | * 95 | * @param string $command_id 96 | * @param string $guild_id 97 | * 98 | * @return Command 99 | */ 100 | public function getCommand(string $command_id, ?string $guild_id = null) 101 | { 102 | $endpoint = "applications/{$this->application->id}"; 103 | 104 | if (! is_null($guild_id)) { 105 | $endpoint .= "/guilds/{$guild_id}"; 106 | } 107 | 108 | $response = $this->request('GET', "{$endpoint}/commands/{$command_id}"); 109 | 110 | if (! is_null($guild_id)) { 111 | $response['guild_id'] = $guild_id; 112 | } 113 | 114 | return new Command($response); 115 | } 116 | 117 | /** 118 | * Creates a global command. 119 | * 120 | * @param string $name 121 | * @param string $description 122 | * @param array $options 123 | * 124 | * @return Command 125 | */ 126 | public function createGlobalCommand(string $name, string $description, array $options = []) 127 | { 128 | foreach ($options as $key => $option) { 129 | $options[$key] = $this->resolveApplicationCommandOption($option); 130 | } 131 | 132 | $response = $this->request('POST', "applications/{$this->application->id}/commands", [ 133 | 'name' => $name, 134 | 'description' => $description, 135 | 'options' => $options, 136 | ]); 137 | 138 | return new Command($response); 139 | } 140 | 141 | /** 142 | * Creates a guild-specific command. 143 | * 144 | * @param string $guild_id 145 | * @param string $name 146 | * @param string $description 147 | * @param array $options 148 | * 149 | * @return Command 150 | */ 151 | public function createGuildSpecificCommand(string $guild_id, string $name, string $description, array $options = []) 152 | { 153 | foreach ($options as $key => $option) { 154 | $options[$key] = $this->resolveApplicationCommandOption($option); 155 | } 156 | 157 | $response = $this->request('POST', "applications/{$this->application->id}/guilds/{$guild_id}/commands", [ 158 | 'name' => $name, 159 | 'description' => $description, 160 | 'options' => $options, 161 | ]); 162 | 163 | $response['guild_id'] = $guild_id; 164 | 165 | return new Command($response); 166 | } 167 | 168 | /** 169 | * Updates the Discord servers with the changes done to the given command. 170 | * 171 | * @param Command $command 172 | * 173 | * @return Command 174 | */ 175 | public function updateCommand(Command $command) 176 | { 177 | $raw = $command->getAttributes(); 178 | 179 | foreach ($raw['options'] ?? [] as $key => $option) { 180 | $raw['options'][$key] = $this->resolveApplicationCommandOption($option); 181 | } 182 | 183 | $endpoint = "applications/{$this->application->id}"; 184 | 185 | if ($command->guild_id) { 186 | unset($raw['guild_id']); 187 | $endpoint .= "/guilds/{$command->guild_id}"; 188 | } 189 | 190 | $this->request('PATCH', "{$endpoint}/commands/{$command->id}", $raw); 191 | 192 | return $command; 193 | } 194 | 195 | /** 196 | * Deletes a command from the Discord servers. 197 | * 198 | * @param Command $command 199 | */ 200 | public function deleteCommand(Command $command) 201 | { 202 | $endpoint = "applications/{$this->application->id}"; 203 | 204 | if ($command->guild_id) { 205 | $endpoint .= "/guilds/{$command->guild_id}"; 206 | } 207 | 208 | $this->request('DELETE', "{$endpoint}/commands/{$command->id}"); 209 | } 210 | 211 | /** 212 | * Resolves an `ApplicationCommandOption` part. 213 | * 214 | * @param array $options 215 | * 216 | * @return array 217 | */ 218 | private function resolveApplicationCommandOption(array $options): array 219 | { 220 | $resolver = new OptionsResolver(); 221 | 222 | $resolver 223 | ->setDefined([ 224 | 'type', 225 | 'name', 226 | 'description', 227 | 'default', 228 | 'required', 229 | 'choices', 230 | 'options', 231 | ]) 232 | ->setAllowedTypes('type', 'int') 233 | ->setAllowedValues('type', array_values((new ReflectionClass(ApplicationCommandOptionType::class))->getConstants())) 234 | ->setAllowedTypes('name', 'string') 235 | ->setAllowedTypes('description', 'string') 236 | ->setAllowedTypes('default', 'bool') 237 | ->setAllowedTypes('required', 'bool') 238 | ->setAllowedTypes('choices', 'array') 239 | ->setAllowedTypes('options', 'array'); 240 | 241 | $options = $resolver->resolve($options); 242 | 243 | foreach ($options['choices'] ?? [] as $key => $choice) { 244 | $options['choices'][$key] = $this->resolveApplicationCommandOptionChoice($choice); 245 | } 246 | 247 | foreach ($options['options'] ?? [] as $key => $option) { 248 | $options['options'][$key] = $this->resolveApplicationCommandOption($option); 249 | } 250 | 251 | return $options; 252 | } 253 | 254 | /** 255 | * Resolves an `ApplicationCommandOption` part. 256 | * 257 | * @param array $options 258 | * 259 | * @return array 260 | */ 261 | private function resolveApplicationCommandOptionChoice(array $options): array 262 | { 263 | $resolver = new OptionsResolver(); 264 | 265 | $resolver 266 | ->setDefined([ 267 | 'name', 268 | 'value', 269 | ]) 270 | ->setAllowedTypes('name', 'string') 271 | ->setAllowedTypes('value', ['string', 'int']); 272 | 273 | return $resolver->resolve($options); 274 | } 275 | 276 | /** 277 | * Runs an HTTP request and decodes the JSON. 278 | * 279 | * @param string $method 280 | * @param string $endpoint 281 | * @param array $content 282 | * 283 | * @return array 284 | */ 285 | private function request(string $method, string $endpoint, ?array $content = null) 286 | { 287 | $options = []; 288 | 289 | if (! is_null($content)) { 290 | $options['json'] = $content; 291 | } 292 | 293 | try { 294 | $response = $this->http->request($method, $endpoint, $options); 295 | } catch (RequestException $e) { 296 | switch ($e->getResponse()->getStatusCode()) { 297 | case 429: 298 | $resetAfter = (float) $e->getResponse()->getheaderLine('X-RateLimit-Reset-After'); 299 | usleep($resetAfter * 1000000); 300 | 301 | return $this->request($method, $endpoint, $content); 302 | default: 303 | throw $e; 304 | } 305 | } 306 | 307 | return json_decode($response->getBody(), true); 308 | } 309 | /** 310 | * Returns the User-Agent of the application. 311 | * 312 | * @return string 313 | */ 314 | private function getUserAgent() 315 | { 316 | return 'DiscordBot (https://github.com/davidcole1340/DiscordPHP-Slash, v0.0.1)'; 317 | } 318 | } 319 | --------------------------------------------------------------------------------