diff --git a/composer.json b/composer.json index 2e922b1c..d632a371 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,8 @@ "spatie/regex": "^3.1", "react/async": "^4.0.0", "exan/eventer": "^1.0.3", - "exan/reactphp-retrier": "^1.0" + "exan/reactphp-retrier": "^1.0", + "freezemage0/array_find": "^1.0" }, "require-dev": { "monolog/monolog": "^3.2", diff --git a/fakes/DiscordFake.php b/fakes/DiscordFake.php index a9e9c83b..005697e8 100644 --- a/fakes/DiscordFake.php +++ b/fakes/DiscordFake.php @@ -16,7 +16,6 @@ public static function get(): Mock|Discord $discord->rest = RestFake::get(); $discord->gateway = GatewayFake::get(); - $discord->interaction = InteractionHandlerFake::get(); return $discord; } diff --git a/src/Command/CommandExtension.php b/src/Command/CommandExtension.php new file mode 100644 index 00000000..0d462665 --- /dev/null +++ b/src/Command/CommandExtension.php @@ -0,0 +1,89 @@ +loadExistingCommands($discord); + + $this->registerListener($discord); + } + + private function registerListener(Discord $discord): void + { + $this->commandListener = new FilteredEventEmitter( + $discord->gateway->events, + Events::INTERACTION_CREATE, + fn (InteractionCreate $interactionCreate) => + isset($interactionCreate->type) + && $interactionCreate->type === InteractionType::APPLICATION_COMMAND + && isset($this->commandMappings[$interactionCreate->data->id]) + ); + + $this->commandListener->on(Events::INTERACTION_CREATE, function (InteractionCreate $interaction) use ($discord) { + $this->handleInteraction($interaction, $discord); + }); + + $this->commandListener->start(); + } + + private function handleInteraction(InteractionCreate $interaction, Discord $discord) + { + $firedCommand = new CommandInteraction($interaction, $discord); + + $this->emit($this->commandMappings[$interaction->data->id], [$firedCommand]); + } + + protected function getFullNameByCommand(ApplicationCommand $command): string + { + $names = [$command->name]; + + $this->drillName($command->options ?? [], $names); + + return implode('.', $names); + } + + private function drillName(array $options, array &$names) + { + /** @var ?ApplicationCommandOptionStructure */ + $subCommand = find($options ?? [], function (ApplicationCommandOptionStructure $option) { + return in_array( + $option->type, + [ + ApplicationCommandOptionType::SUB_COMMAND, + ApplicationCommandOptionType::SUB_COMMAND_GROUP, + ] + ); + }); + + if (!is_null($subCommand)) { + $names[] = $subCommand->name; + + $this->drillName($subCommand->options ?? [], $names); + } + } +} diff --git a/src/Command/GlobalCommandExtension.php b/src/Command/GlobalCommandExtension.php new file mode 100644 index 00000000..3b1e021c --- /dev/null +++ b/src/Command/GlobalCommandExtension.php @@ -0,0 +1,26 @@ +rest->globalCommand->getCommands($this->applicationId) + ->then(function (array $commands) { + /** @var ApplicationCommand $command */ + foreach ($commands as $command) { + $this->commandMappings[$command->id] = $this->getFullNameByCommand($command); + } + }); + } +} diff --git a/src/Command/GuildCommandExtension.php b/src/Command/GuildCommandExtension.php new file mode 100644 index 00000000..16283781 --- /dev/null +++ b/src/Command/GuildCommandExtension.php @@ -0,0 +1,26 @@ +rest->guildCommand->getCommands($this->guildId, $this->applicationId) + ->then(function (array $commands) { + /** @var ApplicationCommand $command */ + foreach ($commands as $command) { + $this->commandMappings[$command->id] = $this->getFullNameByCommand($command); + } + }); + } +} diff --git a/src/InteractionHandler.php b/src/InteractionHandler.php index c0a9b7f4..4abecf78 100644 --- a/src/InteractionHandler.php +++ b/src/InteractionHandler.php @@ -91,6 +91,14 @@ private function handleButtonInteraction(InteractionCreate $interactionCreate): } } + /** + * @deprecated + * + * use \Ragnarok\Fenrir\Command\GlobalCommandExtension or \Ragnarok\Fenrir\Command\GuildCommandExtension instead + * + * This implementation is flawed in terms of rate limiting and should not be used. + * It will be removed in a later version + */ public function registerCommand(CommandBuilder $commandBuilder, callable $handler): void { if ($this->devMode) { @@ -102,6 +110,14 @@ public function registerCommand(CommandBuilder $commandBuilder, callable $handle $this->registerGlobalCommand($commandBuilder, $handler); } + /** + * @deprecated + * + * use \Ragnarok\Fenrir\Command\GlobalCommandExtension or \Ragnarok\Fenrir\Command\GuildCommandExtension instead + * + * This implementation is flawed in terms of rate limiting and should not be used. + * It will be removed in a later version + */ public function registerGuildCommand(CommandBuilder $commandBuilder, string $guildId, callable $handler): void { /** Ready event includes Application ID */ @@ -119,6 +135,14 @@ function (Ready $ready) use ($guildId, $commandBuilder, $handler) { ); } + /** + * @deprecated + * + * use \Ragnarok\Fenrir\Command\GlobalCommandExtension or \Ragnarok\Fenrir\Command\GuildCommandExtension instead + * + * This implementation is flawed in terms of rate limiting and should not be used. + * It will be removed in a later version + */ public function registerGlobalCommand(CommandBuilder $commandBuilder, callable $handler): void { /** Ready event includes Application ID */ diff --git a/src/Rest/GlobalCommand.php b/src/Rest/GlobalCommand.php index 57e1b194..d7df403e 100644 --- a/src/Rest/GlobalCommand.php +++ b/src/Rest/GlobalCommand.php @@ -14,6 +14,19 @@ */ class GlobalCommand extends HttpResource { + public function getCommands(string $applicationId, bool $withLocalizations = false): ExtendedPromiseInterface + { + $endpoint = Endpoint::bind(Endpoint::GLOBAL_APPLICATION_COMMANDS, $applicationId); + $endpoint->addQuery('with_localizations', $withLocalizations); + + return $this->mapArrayPromise( + $this->http->get( + $endpoint + ), + ApplicationCommand::class + ); + } + /** * @see https://discord.com/developers/docs/interactions/application-commands#making-a-global-command * diff --git a/src/Rest/GuildCommand.php b/src/Rest/GuildCommand.php index 3dde2433..26de3b4d 100644 --- a/src/Rest/GuildCommand.php +++ b/src/Rest/GuildCommand.php @@ -14,6 +14,19 @@ */ class GuildCommand extends HttpResource { + public function getCommands(string $guildId, string $applicationId, bool $withLocalizations = false): ExtendedPromiseInterface + { + $endpoint = Endpoint::bind(Endpoint::GUILD_APPLICATION_COMMANDS, $applicationId, $guildId); + $endpoint->addQuery('with_localizations', $withLocalizations); + + return $this->mapArrayPromise( + $this->http->get( + $endpoint + ), + ApplicationCommand::class + ); + } + /** * @see https://discord.com/developers/docs/interactions/application-commands#create-guild-application-command * diff --git a/tests/Command/CommandExtensionTest.php b/tests/Command/CommandExtensionTest.php deleted file mode 100644 index 47176141..00000000 --- a/tests/Command/CommandExtensionTest.php +++ /dev/null @@ -1,19 +0,0 @@ -discord = DiscordFake::get(); - } -} diff --git a/tests/Command/GlobalCommandExtensionTest.php b/tests/Command/GlobalCommandExtensionTest.php new file mode 100644 index 00000000..dd293e48 --- /dev/null +++ b/tests/Command/GlobalCommandExtensionTest.php @@ -0,0 +1,213 @@ +discord = DiscordFake::get(); + } + + public function testItEmitsEventsForApplicationCommands() + { + $commands = [new ApplicationCommand(), new ApplicationCommand()]; + + $commands[0]->id = '::application command 1::'; + $commands[0]->name = 'command-1'; + + $commands[1]->id = '::application command 2::'; + $commands[1]->name = 'command-2'; + + $this->discord->rest->globalCommand->shouldReceive() + ->getCommands('::application id::') + ->andReturns(PromiseFake::get($commands)); + + $extension = new GlobalCommandExtension('::application id::'); + $extension->initialize($this->discord); + + $hasRun = [false, false]; + + $extension->on('command-1', function (CommandInteraction $firedCommand) use (&$hasRun) { + $hasRun[0] = true; + }); + + $extension->on('command-2', function (CommandInteraction $firedCommand) use (&$hasRun) { + $hasRun[1] = true; + }); + + $interaction = new InteractionCreate(); + $interaction->type = InteractionType::APPLICATION_COMMAND; + $interaction->data = new InteractionData(); + $interaction->data->id = '::application command 1::'; + + $this->discord->gateway->events->emit( + Events::INTERACTION_CREATE, + [$interaction] + ); + + $interaction->data->id = '::application command 2::'; + + $this->discord->gateway->events->emit( + Events::INTERACTION_CREATE, + [$interaction] + ); + + $this->assertTrue($hasRun[0], 'Command 1 did not run'); + $this->assertTrue($hasRun[1], 'Command 2 did not run'); + } + + public function testItDoesNotEmitCommandIfDifferentInteractionOccured() + { + $command = new ApplicationCommand(); + + $command->id = '::application command::'; + $command->name = 'command'; + + $this->discord->rest->globalCommand->shouldReceive() + ->getCommands('::application id::') + ->andReturns(PromiseFake::get([$command])); + + $extension = new GlobalCommandExtension('::application id::'); + $extension->initialize($this->discord); + + $hasRun = false; + + $extension->on('command', function (CommandInteraction $firedCommand) use (&$hasRun) { + $hasRun = true; + }); + + $interaction = new InteractionCreate(); + $interaction->type = InteractionType::PING; + $interaction->data = new InteractionData(); + $interaction->data->id = '::application command::'; + + $this->discord->gateway->events->emit( + Events::INTERACTION_CREATE, + [$interaction] + ); + + $this->assertFalse($hasRun, 'Command was emitted wrongfully'); + } + + /** + * @dataProvider nameMappingProvider + * @depends testItEmitsEventsForApplicationCommands + */ + public function testItMapsNamesCorrectly(ApplicationCommand $command, string $expectedName) + { + $command->id = '::application command::'; + + $this->discord->rest->globalCommand->shouldReceive() + ->getCommands('::application id::') + ->andReturns(PromiseFake::get([$command])); + + $extension = new GlobalCommandExtension('::application id::'); + $extension->initialize($this->discord); + + $hasRun = false; + + $extension->on($expectedName, function (CommandInteraction $firedCommand) use (&$hasRun) { + $hasRun = true; + }); + + $interaction = new InteractionCreate(); + $interaction->type = InteractionType::APPLICATION_COMMAND; + $interaction->data = new InteractionData(); + $interaction->data->id = '::application command::'; + + $this->discord->gateway->events->emit( + Events::INTERACTION_CREATE, + [$interaction] + ); + + $this->assertTrue($hasRun, 'Command was emitted wrongfully'); + } + + public static function nameMappingProvider(): array + { + return [ + 'Plain name' => [ + 'command' => (function () { + $command = new ApplicationCommand(); + $command->name = 'command-name'; + + return $command; + })(), + 'expectedName' => 'command-name' + ], + + 'Nested 1 layer' => [ + 'command' => (function () { + $command = new ApplicationCommand(); + $command->name = 'command-name'; + + $command->options = [new ApplicationCommandOptionStructure()]; + $command->options[0]->name = 'sub'; + $command->options[0]->type = ApplicationCommandOptionType::SUB_COMMAND; + + return $command; + })(), + 'expectedName' => 'command-name.sub' + ], + + 'Nested 2 layer' => [ + 'command' => (function () { + $command = new ApplicationCommand(); + $command->name = 'command-name'; + + $command->options = [new ApplicationCommandOptionStructure()]; + $command->options[0]->name = 'double'; + $command->options[0]->type = ApplicationCommandOptionType::SUB_COMMAND_GROUP; + + $command->options[0]->options = [new ApplicationCommandOptionStructure()]; + $command->options[0]->options[0]->name = 'sub'; + $command->options[0]->options[0]->type = ApplicationCommandOptionType::SUB_COMMAND_GROUP; + + return $command; + })(), + 'expectedName' => 'command-name.double.sub' + ], + + 'Nested 3 layer' => [ // NOTE: Not supported by Discord + 'command' => (function () { + $command = new ApplicationCommand(); + $command->name = 'command-name'; + + $command->options = [new ApplicationCommandOptionStructure()]; + $command->options[0]->name = 'double'; + $command->options[0]->type = ApplicationCommandOptionType::SUB_COMMAND_GROUP; + + $command->options[0]->options = [new ApplicationCommandOptionStructure()]; + $command->options[0]->options[0]->name = 'sub'; + $command->options[0]->options[0]->type = ApplicationCommandOptionType::SUB_COMMAND_GROUP; + + $command->options[0]->options[0]->options = [new ApplicationCommandOptionStructure()]; + $command->options[0]->options[0]->options[0]->name = 'dub'; + $command->options[0]->options[0]->options[0]->type = ApplicationCommandOptionType::SUB_COMMAND_GROUP; + + return $command; + })(), + 'expectedName' => 'command-name.double.sub.dub' + ], + ]; + } +} diff --git a/tests/Command/GuildCommandExtensionTest.php b/tests/Command/GuildCommandExtensionTest.php new file mode 100644 index 00000000..4eebc7ce --- /dev/null +++ b/tests/Command/GuildCommandExtensionTest.php @@ -0,0 +1,213 @@ +discord = DiscordFake::get(); + } + + public function testItEmitsEventsForApplicationCommands() + { + $commands = [new ApplicationCommand(), new ApplicationCommand()]; + + $commands[0]->id = '::application command 1::'; + $commands[0]->name = 'command-1'; + + $commands[1]->id = '::application command 2::'; + $commands[1]->name = 'command-2'; + + $this->discord->rest->guildCommand->shouldReceive() + ->getCommands('::guild id::', '::application id::') + ->andReturns(PromiseFake::get($commands)); + + $extension = new GuildCommandExtension('::application id::', '::guild id::'); + $extension->initialize($this->discord); + + $hasRun = [false, false]; + + $extension->on('command-1', function (CommandInteraction $firedCommand) use (&$hasRun) { + $hasRun[0] = true; + }); + + $extension->on('command-2', function (CommandInteraction $firedCommand) use (&$hasRun) { + $hasRun[1] = true; + }); + + $interaction = new InteractionCreate(); + $interaction->type = InteractionType::APPLICATION_COMMAND; + $interaction->data = new InteractionData(); + $interaction->data->id = '::application command 1::'; + + $this->discord->gateway->events->emit( + Events::INTERACTION_CREATE, + [$interaction] + ); + + $interaction->data->id = '::application command 2::'; + + $this->discord->gateway->events->emit( + Events::INTERACTION_CREATE, + [$interaction] + ); + + $this->assertTrue($hasRun[0], 'Command 1 did not run'); + $this->assertTrue($hasRun[1], 'Command 2 did not run'); + } + + public function testItDoesNotEmitCommandIfDifferentInteractionOccured() + { + $command = new ApplicationCommand(); + + $command->id = '::application command::'; + $command->name = 'command'; + + $this->discord->rest->guildCommand->shouldReceive() + ->getCommands('::guild id::', '::application id::') + ->andReturns(PromiseFake::get([$command])); + + $extension = new GuildCommandExtension('::application id::', '::guild id::'); + $extension->initialize($this->discord); + + $hasRun = false; + + $extension->on('command', function (CommandInteraction $firedCommand) use (&$hasRun) { + $hasRun = true; + }); + + $interaction = new InteractionCreate(); + $interaction->type = InteractionType::PING; + $interaction->data = new InteractionData(); + $interaction->data->id = '::application command::'; + + $this->discord->gateway->events->emit( + Events::INTERACTION_CREATE, + [$interaction] + ); + + $this->assertFalse($hasRun, 'Command was emitted wrongfully'); + } + + /** + * @dataProvider nameMappingProvider + * @depends testItEmitsEventsForApplicationCommands + */ + public function testItMapsNamesCorrectly(ApplicationCommand $command, string $expectedName) + { + $command->id = '::application command::'; + + $this->discord->rest->guildCommand->shouldReceive() + ->getCommands('::guild id::', '::application id::') + ->andReturns(PromiseFake::get([$command])); + + $extension = new GuildCommandExtension('::application id::', '::guild id::'); + $extension->initialize($this->discord); + + $hasRun = false; + + $extension->on($expectedName, function (CommandInteraction $firedCommand) use (&$hasRun) { + $hasRun = true; + }); + + $interaction = new InteractionCreate(); + $interaction->type = InteractionType::APPLICATION_COMMAND; + $interaction->data = new InteractionData(); + $interaction->data->id = '::application command::'; + + $this->discord->gateway->events->emit( + Events::INTERACTION_CREATE, + [$interaction] + ); + + $this->assertTrue($hasRun, 'Command was emitted wrongfully'); + } + + public static function nameMappingProvider(): array + { + return [ + 'Plain name' => [ + 'command' => (function () { + $command = new ApplicationCommand(); + $command->name = 'command-name'; + + return $command; + })(), + 'expectedName' => 'command-name' + ], + + 'Nested 1 layer' => [ + 'command' => (function () { + $command = new ApplicationCommand(); + $command->name = 'command-name'; + + $command->options = [new ApplicationCommandOptionStructure()]; + $command->options[0]->name = 'sub'; + $command->options[0]->type = ApplicationCommandOptionType::SUB_COMMAND; + + return $command; + })(), + 'expectedName' => 'command-name.sub' + ], + + 'Nested 2 layer' => [ + 'command' => (function () { + $command = new ApplicationCommand(); + $command->name = 'command-name'; + + $command->options = [new ApplicationCommandOptionStructure()]; + $command->options[0]->name = 'double'; + $command->options[0]->type = ApplicationCommandOptionType::SUB_COMMAND_GROUP; + + $command->options[0]->options = [new ApplicationCommandOptionStructure()]; + $command->options[0]->options[0]->name = 'sub'; + $command->options[0]->options[0]->type = ApplicationCommandOptionType::SUB_COMMAND_GROUP; + + return $command; + })(), + 'expectedName' => 'command-name.double.sub' + ], + + 'Nested 3 layer' => [ // NOTE: Not supported by Discord + 'command' => (function () { + $command = new ApplicationCommand(); + $command->name = 'command-name'; + + $command->options = [new ApplicationCommandOptionStructure()]; + $command->options[0]->name = 'double'; + $command->options[0]->type = ApplicationCommandOptionType::SUB_COMMAND_GROUP; + + $command->options[0]->options = [new ApplicationCommandOptionStructure()]; + $command->options[0]->options[0]->name = 'sub'; + $command->options[0]->options[0]->type = ApplicationCommandOptionType::SUB_COMMAND_GROUP; + + $command->options[0]->options[0]->options = [new ApplicationCommandOptionStructure()]; + $command->options[0]->options[0]->options[0]->name = 'dub'; + $command->options[0]->options[0]->options[0]->type = ApplicationCommandOptionType::SUB_COMMAND_GROUP; + + return $command; + })(), + 'expectedName' => 'command-name.double.sub.dub' + ], + ]; + } +}