Skip to content
This repository was archived by the owner on Feb 18, 2023. It is now read-only.

Commit 133b5a4

Browse files
committed
Implemented HTTP server, added the ability to register commands with server, updated readme with examples
1 parent 5af4ad6 commit 133b5a4

File tree

9 files changed

+603
-9
lines changed

9 files changed

+603
-9
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
vendor
22
composer.lock
33
test.php
4+
.vscode

README.md

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,177 @@ $ composer require discordphp/slash
1515

1616
## Usage
1717

18-
To be written
18+
There are two "clients" in the library:
19+
- `Discord\Slash\RegisterClient` used for registering commands with Discord.
20+
- `Discord\Slash\Client` used for listening for HTTP requests and responding.
21+
22+
### `Discord\Slash\RegisterClient`
23+
24+
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.
25+
26+
```php
27+
<?php
28+
29+
include 'vendor/autoload.php';
30+
31+
use Discord\Slash\RegisterClient;
32+
33+
$client = new RegisterClient('your-bot-token-here');
34+
35+
/// GETTING COMMANDS
36+
37+
// gets a list of all GLOBAL comamnds (not guild-specific)
38+
$commands = $client->getCommands();
39+
// gets a list of all guild-specific commands to the given guild
40+
$guildCommands = $client->getCommands('guild_id_here');
41+
// gets a specific command with command id - if you are getting a guild-specific command you must provide a guild id
42+
$command = $client->getCommand('command_id', 'optionally_guild_id');
43+
44+
/// CREATING COMMANDS
45+
46+
// creates a global command
47+
$command = $client->createGlobalCommand('command_name', 'command_description', [
48+
// optional array of options
49+
]);
50+
51+
// creates a guild specific command
52+
$command = $client->createGuildSpecificCommand('guild_id', 'command_name', 'command_description', [
53+
// optional array of options
54+
]);
55+
56+
/// UPDATING COMMANDS
57+
58+
// change the command name etc.....
59+
$command->name = 'newcommandname';
60+
$client->updateCommand($command);
61+
62+
/// DELETING COMMANDS
63+
64+
$client->deleteCommand($command);
65+
```
66+
67+
### `Discord\Slash\Client`
68+
69+
Now that you have registered commands, you can set up an HTTP server to listen for requests from Discord.
70+
71+
There are a few ways to set up an HTTP server to listen for requests:
72+
- The built-in ReactPHP HTTP server.
73+
- Using an external HTTP server such as Apache or nginx (documentation to come).
74+
- Using the built-in ReactPHP HTTP server without HTTPS and using Apache or nginx as a reverse proxy.
75+
76+
Whatever path you choose, the server **must** be protected with HTTPS - Discord will not accept regular HTTP.
77+
78+
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.
79+
80+
Setting up a basic `Client`:
81+
82+
```php
83+
<?php
84+
85+
include 'vendor/autoload.php';
86+
87+
use Discord\Slash\Client;
88+
use Discord\Slash\Parts\Interaction;
89+
use Discord\Slash\Parts\Choices;
90+
91+
$client = new Client([
92+
// required options
93+
'public_key' => 'your_public_key_from_discord_here',
94+
95+
// optional options, defaults are shown
96+
'uri' => '0.0.0.0:80', // if you want the client to listen on a different URI
97+
'logger' => $logger, // different logger, default will write to stdout
98+
'loop' => $loop, // reactphp event loop, default creates a new loop
99+
'socket_options' => [], // options to pass to the react/socket instance, default empty array
100+
]);
101+
102+
// register a command `/hello`
103+
$client->registerCommand('hello', function (Interaction $interaction, Choices $choices) {
104+
// do some cool stuff here
105+
// good idea to var_dump interaction and choices to see what they contain
106+
107+
// once finished, you MUST either acknowledge or reply to a message
108+
$interaction->acknowledge(); // acknowledges the message, doesn't show source message
109+
$interaction->acknowledge(true); // acknowledges the message and shows the source message
110+
111+
// to reply to the message
112+
$interaction->reply('Hello, world!'); // replies to the message, doesn't show source message
113+
$interaction->replyWithSource('Hello, world!'); // replies to the message and shows the source message
114+
115+
// the `reply` methods take 4 parameters: content, tts, embed and allowed_mentions
116+
// all but content are optional.
117+
// read the discord developer documentation to see what to pass to these options:
118+
// https://discord.com/developers/docs/resources/channel#create-message
119+
});
120+
121+
// starts the ReactPHP event loop
122+
$client->run();
123+
```
124+
125+
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. In the future, there will be more integration between the libraries. Here is an example:
126+
127+
```php
128+
<?php
129+
130+
include 'vendor/autoload.php';
131+
132+
// make sure you have included DiscordPHP into your project - `composer require team-reflex/discord-php`
133+
134+
use Discord\Discord;
135+
use Discord\Slash\Client;
136+
use Discord\Slash\Parts\Interaction;
137+
use Discord\Slash\Parts\Choices;
138+
139+
$discord = new Discord([
140+
'token' => '##################',
141+
]);
142+
143+
$client = new Client([
144+
'public_key' => '???????????????',
145+
'loop' => $discord->getLoop(),
146+
]);
147+
148+
$discord->on('ready', function (Discord $discord) {
149+
// DiscordPHP is ready
150+
});
151+
152+
$client->registerCommand('my_cool_command', function (Interaction $interaction, Choices $choices) use ($discord) {
153+
$discord->guilds->get('id', 'coolguild')->members->ban(); // do something ?
154+
$interaction->acknowledge();
155+
});
156+
157+
$discord->run();
158+
```
159+
160+
### Setting up Apache2 as a reverse proxy
161+
162+
Assuming you already have Apache2 installed and the SSL certificates on your server:
163+
164+
1. Enable the required Apache mods:
165+
```shell
166+
$ sudo a2enmod proxy
167+
$ sudo a2enmod proxy_http
168+
$ sudo a2enmod ssl
169+
```
170+
2. Create a new site or modify the existing default site to listen on port `443`:
171+
```sh
172+
$ vim /etc/apache2/sites-available/000-default.conf # default site
173+
174+
# change contents to the following
175+
<VirtualHost *:443> # listen on 443
176+
ProxyPreserveHost On # preserve the host header from Discord
177+
ProxyPass / http://127.0.0.1:8080/ # pass-through to the HTTP server on port 8080
178+
ProxyPassReverse / http://127.0.0.1:8080/
179+
180+
SSLEngine On # enable SSL
181+
SSLCertificateFile /path/to/ssl/cert.crt # change to your cert path
182+
SSLCertificateKeyFile /path/to/ssl/cert.key # change to your key path
183+
</VirtualHost>
184+
```
185+
3. Restart apache - the code below works on Debian-based systems:
186+
```shell
187+
$ sudo service apache2 restart
188+
```
19189

20190
## License
21191

composer.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
}
1111
],
1212
"require": {
13+
"php": ">=7.3|>=8.0",
1314
"react/http": "^1.2",
1415
"symfony/options-resolver": "^5.2",
1516
"monolog/monolog": "^2.2",
16-
"guzzlehttp/guzzle": "^7.2"
17+
"guzzlehttp/guzzle": "^7.2",
18+
"discord/interactions": "^1.0"
1719
},
1820
"autoload": {
1921
"psr-4": {

src/Discord/Client.php

Lines changed: 128 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,22 @@
22

33
namespace Discord\Slash;
44

5+
use Discord\Interaction as DiscordInteraction;
6+
use Discord\InteractionResponseType;
7+
use Discord\InteractionType;
8+
use Discord\Slash\Parts\Interaction;
9+
use Discord\Slash\Parts\RegisteredCommand;
10+
use InvalidArgumentException;
11+
use React\Http\MEssage\Response;
512
use Monolog\Handler\StreamHandler;
613
use Monolog\Logger;
714
use Psr\Http\Message\ServerRequestInterface;
15+
use Psr\Log\LoggerInterface;
816
use React\EventLoop\Factory;
917
use React\EventLoop\LoopInterface;
1018
use React\Http\Server as HttpServer;
11-
use React\Promise\Deferred;
19+
use React\Promise\ExtendedPromiseInterface;
20+
use React\Promise\Promise;
1221
use React\Socket\Server as SocketServer;
1322
use Symfony\Component\OptionsResolver\OptionsResolver;
1423

@@ -44,9 +53,24 @@ class Client
4453
*/
4554
private $socket;
4655

56+
/**
57+
* An array of registered commands.
58+
*
59+
* @var RegisteredCommand[]
60+
*/
61+
private $commands;
62+
63+
/**
64+
* Logger for client.
65+
*
66+
* @var LoggerInterface
67+
*/
68+
private $logger;
69+
4770
public function __construct(array $options = [])
4871
{
4972
$this->options = $this->resolveOptions($options);
73+
$this->logger = $this->options['logger'];
5074
$this->registerServer();
5175
}
5276

@@ -66,10 +90,16 @@ private function resolveOptions(array $options): array
6690
'uri',
6791
'logger',
6892
'loop',
93+
'public_key',
94+
'socket_options',
6995
])
7096
->setDefaults([
7197
'uri' => '0.0.0.0:80',
7298
'loop' => Factory::create(),
99+
'socket_options' => [],
100+
])
101+
->setRequired([
102+
'public_key',
73103
]);
74104

75105
$options = $resolver->resolve($options);
@@ -87,16 +117,109 @@ private function resolveOptions(array $options): array
87117
private function registerServer()
88118
{
89119
$this->server = new HttpServer($this->getLoop(), [$this, 'handleRequest']);
120+
$this->socket = new SocketServer($this->options['uri'], $this->getLoop(), $this->options['socket_options']);
90121

91-
$this->socket = new SocketServer($this->options['uri'], $this->getLoop());
92-
$this->server->listen($this->socket);
122+
// future tick so that the socket won't listen
123+
// when running in CGI mode
124+
$this->getLoop()->futureTick(function () {
125+
$this->server->listen($this->socket);
126+
});
93127
}
94128

129+
/**
130+
* Handles an HTTP request to the server.
131+
*
132+
* @param ServerRequestInterface $request
133+
*/
95134
public function handleRequest(ServerRequestInterface $request)
96135
{
97-
$deferred = new Deferred();
136+
// validate request with public key
137+
$signature = $request->getHeaderLine('X-Signature-Ed25519');
138+
$timestamp = $request->getHeaderLine('X-Signature-Timestamp');
139+
140+
if (empty($signature) || empty($timestamp) || ! DiscordInteraction::verifyKey((string) $request->getBody(), $signature, $timestamp, $this->options['public_key'])) {
141+
return new Response(401, [0], 'Not verified');
142+
}
143+
144+
$interaction = new Interaction(json_decode($request->getBody(), true));
145+
146+
dump($interaction->getAttributes());
147+
$this->logger->info('received interaction', $interaction->jsonSerialize());
148+
149+
return $this->handleInteraction($interaction)->then(function ($result) {
150+
$this->logger->info('responding to interaction', $result);
151+
return new Response(200, [], json_encode($result));
152+
});
153+
}
154+
155+
/**
156+
* Handles an interaction from Discord.
157+
*
158+
* @param Interaction $interaction
159+
*
160+
* @return ExtendedPromiseInterface
161+
*/
162+
private function handleInteraction(Interaction $interaction): ExtendedPromiseInterface
163+
{
164+
return new Promise(function ($resolve, $reject) use ($interaction) {
165+
switch ($interaction->type) {
166+
case InteractionType::PING:
167+
return $resolve([
168+
'type' => InteractionResponseType::PONG,
169+
]);
170+
case InteractionType::APPLICATION_COMMAND:
171+
$interaction->setResolve($resolve);
172+
return $this->handleApplicationCommand($interaction);
173+
}
174+
});
175+
}
176+
177+
/**
178+
* Handles an application command interaction from Discord.
179+
*
180+
* @param Interaction $interaction
181+
*/
182+
private function handleApplicationCommand(Interaction $interaction): void
183+
{
184+
$checkCommand = function ($command) use ($interaction, &$checkCommand) {
185+
if (isset($this->commands[$command['name']])) {
186+
if ($this->commands[$command['name']]->execute($command['options'], $interaction)) return true;
187+
}
188+
189+
foreach ($command['options'] ?? [] as $option) {
190+
if ($checkCommand($option)) return true;
191+
}
192+
};
193+
194+
$checkCommand($interaction->data);
195+
}
196+
197+
/**
198+
* Registeres a command with the client.
199+
*
200+
* @param string|array $name
201+
* @param callable $callback
202+
*
203+
* @return RegisteredCommand
204+
*/
205+
public function registerCommand($name, callable $callback = null): RegisteredCommand
206+
{
207+
if (is_array($name) && count($name) == 1) $name = array_shift($name);
208+
209+
// registering base command
210+
if (! is_array($name) || count($name) == 1) {
211+
if (isset($this->commands[$name])) throw new InvalidArgumentException("The command `{$name}` already exists.");
212+
213+
return $this->commands[$name] = new RegisteredCommand($name, $callback);
214+
}
215+
216+
$baseCommand = array_shift($name);
217+
218+
if (! isset($this->commands[$baseCommand])) {
219+
$this->registerCommand($baseCommand);
220+
}
98221

99-
return $deferred->promise();
222+
return $this->commands[$baseCommand]->addSubCommand($name, $callback);
100223
}
101224

102225
/**

0 commit comments

Comments
 (0)