From fbb07052d38453121e55cf19ef37f26d3b05abc4 Mon Sep 17 00:00:00 2001 From: Vladyslav Gaysyuk Date: Sat, 22 Aug 2026 03:31:18 +0200 Subject: [PATCH 01/11] Bind invokable commands that declare no options A #[Command] whose __invoke takes no options produced zero handlers: the handler map was seeded with an empty array and only filled from inside the options loop, so with no options the loop never ran and dot() dropped the empty branch. The command registered with Discord and then silently never fired. Handlers are now derived from whether the command has subcommands at all, rather than by looping over options and letting the last iteration win. That also stops a command declaring both options and subcommands from clobbering its subcommand handlers. Co-Authored-By: Claude Opus 5 (1M context) --- src/Attributes/Command.php | 36 ++++++++++++++++------ tests/Fixtures/BareCommand.php | 18 +++++++++++ tests/Unit/CommandHandlersTest.php | 49 ++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 tests/Fixtures/BareCommand.php create mode 100644 tests/Unit/CommandHandlersTest.php diff --git a/src/Attributes/Command.php b/src/Attributes/Command.php index 14d1c46..0a95cac 100644 --- a/src/Attributes/Command.php +++ b/src/Attributes/Command.php @@ -41,19 +41,37 @@ final class Command } } + /** + * Every handler this command binds, keyed by the dotted interaction name + * Discord will send back ("moderation.kick", or just "ping"). + * + * @var array + */ public array $handlers { get { - $keys[$this->name] = []; - foreach ($this->options as $option) { - if (in_array(get_class($option), [SubcommandGroup::class, Subcommand::class])) { - $keys[$this->name][$option->name] = $option->key; - } else { - $fakeSubcommand = new Subcommand(name: $option->name, description: $option->description); - $fakeSubcommand->reflector = $this->reflector->getMethod('__invoke'); - $keys[$this->name] = $fakeSubcommand->key; + $subcommands = array_filter( + $this->options, + static fn(object $option) => $option instanceof SubcommandGroup || $option instanceof Subcommand, + ); + + if ($subcommands !== []) { + $keys = []; + + foreach ($subcommands as $subcommand) { + $keys[$this->name][$subcommand->name] = $subcommand->key; } + + return dot($keys); } - return dot($keys); + + /* + * No subcommands means __invoke is the handler, and it is bound + * under the bare command name whether or not it takes options. + */ + $invokable = new Subcommand(name: $this->name, description: $this->description ?? $this->name); + $invokable->reflector = $this->reflector->getMethod('__invoke'); + + return [$this->name => $invokable->key]; } } diff --git a/tests/Fixtures/BareCommand.php b/tests/Fixtures/BareCommand.php new file mode 100644 index 0000000..4835ef8 --- /dev/null +++ b/tests/Fixtures/BareCommand.php @@ -0,0 +1,18 @@ +command(PingCommand::class)->handlers; + + $this->assertSame(['ping'], array_keys($handlers)); + } + + /** + * A command whose __invoke declares no options still has to be listened + * for — otherwise it registers with Discord and then silently never fires. + */ + public function test_an_invokable_command_without_options_is_still_bound(): void + { + $handlers = $this->command(BareCommand::class)->handlers; + + $this->assertSame(['bare'], array_keys($handlers)); + } + + public function test_subcommands_are_bound_under_dotted_paths(): void + { + $handlers = $this->command(ModerationCommand::class)->handlers; + + $this->assertSame(['moderation.kick'], array_keys($handlers)); + } + + public function test_grouped_subcommands_are_bound_under_the_group(): void + { + $handlers = $this->command(MusicCommand::class)->handlers; + + $this->assertSame( + ['music.playlist.play', 'music.playlist.stop'], + array_keys($handlers), + ); + } +} From 9d650cfd1336d032b52dfb3e8e791520534268c9 Mon Sep 17 00:00:00 2001 From: Vladyslav Gaysyuk Date: Sat, 22 Aug 2026 03:32:18 +0200 Subject: [PATCH 02/11] Stop fetching USER options from Discord twice mapValue() awaited rest->user->get() in a standalone if-block and then awaited the identical call again in the match arm below it, discarding the first result. Every user-typed option cost two REST round trips. No regression test yet: mapValue reaches Discord through get(Tempcord::class), and Tempcord's constructor needs a live gateway, so it cannot be exercised without a container. Covered once the dependency is injected. Co-Authored-By: Claude Opus 5 (1M context) --- src/Attributes/Option.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Attributes/Option.php b/src/Attributes/Option.php index 8a23eb5..a385ba6 100644 --- a/src/Attributes/Option.php +++ b/src/Attributes/Option.php @@ -103,11 +103,6 @@ public function mapValue(?ApplicationCommandInteractionDataOptionStructure $opti $tempcord = get(Tempcord::class); - - if ($option->type === ApplicationCommandOptionType::USER) { - await($tempcord->discord->rest->user->get($option->value)); - } - return match ($option->type) { ApplicationCommandOptionType::USER => await($tempcord->discord->rest->user->get($option->value)), ApplicationCommandOptionType::CHANNEL => await($tempcord->discord->rest->channel->get($option->value)), From b4172cebe1f396251f05a5bccd9ba0a5dc946b5a Mon Sep 17 00:00:00 2001 From: Vladyslav Gaysyuk Date: Sat, 22 Aug 2026 03:34:44 +0200 Subject: [PATCH 03/11] Fix autocomplete choice normalisation and unfocused interactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the autocomplete responder: The 25-choice cap never applied. array_map iterated $value rather than the capped $choices, so the array_slice result was computed and thrown away and every suggestion the Autocomplete returned went to Discord, which rejects responses over 25. For the same reason a non-array return — a bare scalar standing for a single suggestion — reached array_keys() and TypeError'd, and array_is_list was checked against the capped array while the labels came from the uncapped one. resolveFocusedAndParam returns null whenever nothing is focused or the focused option is not declared on the command, which Discord does send while the user is still typing. Destructuring that null read properties off null, emitting warnings and dropping the response. Choice building moves to CommandsRegistry::toChoices so it can be tested directly; the null is now guarded explicitly. Co-Authored-By: Claude Opus 5 (1M context) --- src/Registries/CommandsRegistry.php | 81 ++++++++++++++-------- tests/Unit/AutocompleteChoicesTest.php | 92 +++++++++++++++++++++++++ tests/Unit/AutocompleteDispatchTest.php | 91 ++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 27 deletions(-) create mode 100644 tests/Unit/AutocompleteChoicesTest.php create mode 100644 tests/Unit/AutocompleteDispatchTest.php diff --git a/src/Registries/CommandsRegistry.php b/src/Registries/CommandsRegistry.php index 0b3e714..35752a7 100644 --- a/src/Registries/CommandsRegistry.php +++ b/src/Registries/CommandsRegistry.php @@ -22,6 +22,11 @@ #[Singleton] final class CommandsRegistry { + /** + * Discord rejects an autocomplete response carrying more choices than this. + */ + private const int MAX_CHOICES = 25; + /** @var array */ private array $commands = []; @@ -117,43 +122,31 @@ public function listen(Console $console): void } }, autocomplete: function (CommandInteraction $interaction) use ($command) { - [$option, $interactionOption] = $this->resolveFocusedAndParam($interaction->interaction->data->options, $command); + $resolved = $this->resolveFocusedAndParam( + $interaction->interaction->data->options ?? [], + $command, + ); - if (!$option->autocomplete instanceof Autocomplete) { + // Nothing focused, or focused on an option this command does not declare. + if ($resolved === null) { return null; } - $value = $option->autocomplete->handle($interaction, $interactionOption->value); - - $choices = is_array($value) ? $value : [$value]; - - $choices = array_slice($choices, 0, 25); + [$option, $interactionOption] = $resolved; - $choices = array_map(function ($choice, $key) use ($choices) { - if ($choice instanceof ApplicationCommandOptionChoice) { - return $choice; - } - - if (array_is_list($choices)) { - $key = $choice; - } - - if (is_int($key)) { - $key = (string)$key; - } - - $applicationCommandOptionChoice = new ApplicationCommandOptionChoice(); - $applicationCommandOptionChoice->name = $key; - $applicationCommandOptionChoice->value = $choice; - - return $applicationCommandOptionChoice; - }, $value, array_keys($value)); + if (!$option->autocomplete instanceof Autocomplete) { + return null; + } $interaction->createInteractionResponse( InteractionCallbackBuilder::new() - ->setChoices($choices) + ->setChoices($this->toChoices( + $option->autocomplete->handle($interaction, $interactionOption->value), + )) ->setType(InteractionCallbackType::APPLICATION_COMMAND_AUTOCOMPLETE_RESULT) ); + + return null; } ); @@ -168,6 +161,40 @@ public function listen(Console $console): void } } + /** + * Normalises whatever an Autocomplete returned into the choice list Discord + * accepts. + * + * A bare scalar stands for a single suggestion. A list uses each entry as + * its own label; a map uses its keys as labels. Choices built by hand are + * passed through untouched. + * + * @return list + */ + private function toChoices(mixed $value): array + { + $choices = is_array($value) ? $value : [$value]; + $choices = array_slice($choices, 0, self::MAX_CHOICES, preserve_keys: true); + + $isList = array_is_list($choices); + + return array_map( + static function (mixed $choice, int|string $label) use ($isList): ApplicationCommandOptionChoice { + if ($choice instanceof ApplicationCommandOptionChoice) { + return $choice; + } + + $applicationCommandOptionChoice = new ApplicationCommandOptionChoice(); + $applicationCommandOptionChoice->name = (string) ($isList ? $choice : $label); + $applicationCommandOptionChoice->value = $choice; + + return $applicationCommandOptionChoice; + }, + $choices, + array_keys($choices), + ); + } + /** * @param array $interactionOptions — array of ApplicationCommandInteractionDataOptionStructure * @param Command|SubcommandGroup|Subcommand $definition — Tempcord definition diff --git a/tests/Unit/AutocompleteChoicesTest.php b/tests/Unit/AutocompleteChoicesTest.php new file mode 100644 index 0000000..e560ad4 --- /dev/null +++ b/tests/Unit/AutocompleteChoicesTest.php @@ -0,0 +1,92 @@ + */ + private function toChoices(mixed $value): array + { + return new ReflectionMethod(CommandsRegistry::class, 'toChoices') + ->invoke(new CommandsRegistry(new AllCommandExtension()), $value); + } + + /** @return list */ + private function pairs(array $choices): array + { + return array_map( + static fn(ApplicationCommandOptionChoice $choice) => [$choice->name, $choice->value], + $choices, + ); + } + + public function test_a_list_uses_each_entry_as_its_own_label(): void + { + $this->assertSame( + [['red', 'red'], ['green', 'green']], + $this->pairs($this->toChoices(['red', 'green'])), + ); + } + + public function test_a_map_uses_its_keys_as_labels(): void + { + $this->assertSame( + [['Red', 'r'], ['Green', 'g']], + $this->pairs($this->toChoices(['Red' => 'r', 'Green' => 'g'])), + ); + } + + public function test_an_already_built_choice_is_passed_through_untouched(): void + { + $choice = new ApplicationCommandOptionChoice(); + $choice->name = 'Prebuilt'; + $choice->value = 'p'; + + $this->assertSame([$choice], $this->toChoices([$choice])); + } + + /** + * Discord rejects an autocomplete response carrying more than 25 choices, + * so the cap has to actually survive into the returned list. + */ + public function test_it_caps_the_list_at_twenty_five_choices(): void + { + $this->assertCount(25, $this->toChoices(range(1, 40))); + } + + public function test_it_caps_a_map_at_twenty_five_choices(): void + { + $items = []; + for ($i = 0; $i < 40; $i++) { + $items['label' . $i] = $i; + } + + $this->assertCount(25, $this->toChoices($items)); + } + + /** + * An Autocomplete is free to return a bare scalar for a single suggestion. + */ + public function test_a_single_scalar_becomes_one_choice(): void + { + $this->assertSame([['solo', 'solo']], $this->pairs($this->toChoices('solo'))); + } + + public function test_integer_labels_are_cast_to_strings(): void + { + $choices = $this->toChoices([7 => 'seven']); + + $this->assertSame([['7', 'seven']], $this->pairs($choices)); + } +} diff --git a/tests/Unit/AutocompleteDispatchTest.php b/tests/Unit/AutocompleteDispatchTest.php new file mode 100644 index 0000000..5e14e80 --- /dev/null +++ b/tests/Unit/AutocompleteDispatchTest.php @@ -0,0 +1,91 @@ +name = 'ping'; + $data->options = $options; + + $interaction = new InteractionCreate(); + $interaction->id = '1'; + $interaction->token = 'token'; + $interaction->data = $data; + + return new CommandInteraction($interaction, $discord); + } + + /** + * Runs one autocomplete interaction against a listening PingCommand and + * returns every Discord endpoint it posted to. + * + * @return list + */ + private function dispatch(array $options): array + { + $http = new RecordingHttp(); + + $extension = new AllCommandExtension(); + $registry = new CommandsRegistry($extension); + $registry->add($this->command(PingCommand::class)); + $registry->listen($this->createStub(Console::class)); + + $extension->emit('ping.autocomplete', [ + $this->interaction(new FakeDiscord($http), $options), + ]); + + return $http->postedUrls(); + } + + private function option(string $name, bool $focused = false): ApplicationCommandInteractionDataOptionStructure + { + $option = new ApplicationCommandInteractionDataOptionStructure(); + $option->name = $name; + $option->type = ApplicationCommandOptionType::STRING; + $option->value = 'wh'; + $option->options = []; + $option->focused = $focused; + + return $option; + } + + /** + * Discord sends an autocomplete interaction while the user is still typing, + * and it can arrive with nothing focused. That must not take the bot down. + */ + public function test_an_autocomplete_with_nothing_focused_is_ignored(): void + { + $this->assertSame([], $this->dispatch([$this->option('name'), $this->option('times')])); + } + + public function test_an_autocomplete_focused_on_an_undeclared_option_is_ignored(): void + { + $this->assertSame([], $this->dispatch([$this->option('not_declared', focused: true)])); + } + + /** + * PingCommand declares no autocomplete on its options, so a focused option + * still produces no response — but it must get there without erroring. + */ + public function test_an_option_without_an_autocomplete_produces_no_response(): void + { + $this->assertSame([], $this->dispatch([$this->option('name', focused: true)])); + } +} From 79754549ed1def733422a9226e4e158376670825 Mon Sep 17 00:00:00 2001 From: Vladyslav Gaysyuk Date: Sat, 22 Aug 2026 03:36:30 +0200 Subject: [PATCH 04/11] Match subcommand option types by enum case, not magic ints resolveFocusedAndParam compared $option->type->value against a bare [1, 2], the one place in the codebase that reaches past ApplicationCommandOptionType instead of using it. Comparing the enum cases directly says what it means and survives any renumbering. Adds the nested coverage this path was missing: drilling through a group and a subcommand to the focused option, and a group the definition does not declare. Co-Authored-By: Claude Opus 5 (1M context) --- src/Registries/CommandsRegistry.php | 7 ++++-- tests/Unit/FocusedOptionTest.php | 34 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/Registries/CommandsRegistry.php b/src/Registries/CommandsRegistry.php index 35752a7..7b5b703 100644 --- a/src/Registries/CommandsRegistry.php +++ b/src/Registries/CommandsRegistry.php @@ -3,6 +3,7 @@ namespace Tempcord\Registries; use Ragnarok\Fenrir\Discord; +use Ragnarok\Fenrir\Enums\ApplicationCommandOptionType; use Ragnarok\Fenrir\Enums\InteractionCallbackType; use Ragnarok\Fenrir\Interaction\CommandInteraction; use Ragnarok\Fenrir\Parts\ApplicationCommandInteractionDataOptionStructure; @@ -204,11 +205,13 @@ private function resolveFocusedAndParam(array $interactionOptions, Command|Subco { /** @var ApplicationCommandInteractionDataOptionStructure $option */ foreach ($interactionOptions as $option) { - $type = $option->type->value; $name = $option->name; // If SUB_COMMAND_GROUP or SUB_COMMAND, go deeper - if (in_array($type, [1, 2], true)) { + if (in_array($option->type, [ + ApplicationCommandOptionType::SUB_COMMAND, + ApplicationCommandOptionType::SUB_COMMAND_GROUP, + ], true)) { // $definition->options[$name] should be the nested command/group $nextDefinition = $definition->options[$name] ?? null; diff --git a/tests/Unit/FocusedOptionTest.php b/tests/Unit/FocusedOptionTest.php index 3e0e068..47b17af 100644 --- a/tests/Unit/FocusedOptionTest.php +++ b/tests/Unit/FocusedOptionTest.php @@ -9,6 +9,7 @@ use Tempcord\AllCommandExtension; use Tempcord\Attributes\Command; use Tempcord\Registries\CommandsRegistry; +use Tempcord\Tests\Fixtures\MusicCommand; use Tempcord\Tests\Fixtures\PingCommand; #[CoversClass(CommandsRegistry::class)] @@ -66,4 +67,37 @@ public function test_it_returns_null_for_no_options_at_all(): void { $this->assertNull($this->resolve([], $this->command(PingCommand::class))); } + + /** + * Discord nests the focused option inside the subcommand group and then + * the subcommand, so resolution has to drill through both. + */ + public function test_it_drills_through_a_group_and_a_subcommand(): void + { + $command = $this->command(MusicCommand::class); + + $focused = $this->option('title', focused: true); + $play = $this->option('play'); + $play->type = ApplicationCommandOptionType::SUB_COMMAND; + $play->options = [$focused]; + $playlist = $this->option('playlist'); + $playlist->type = ApplicationCommandOptionType::SUB_COMMAND_GROUP; + $playlist->options = [$play]; + + $resolved = $this->resolve([$playlist], $command); + + $this->assertNotNull($resolved); + $this->assertSame('title', $resolved[0]->name); + $this->assertSame('Track title', $resolved[0]->description); + $this->assertSame($focused, $resolved[1]); + } + + public function test_a_subcommand_the_definition_does_not_know_resolves_to_null(): void + { + $unknown = $this->option('not_a_group'); + $unknown->type = ApplicationCommandOptionType::SUB_COMMAND_GROUP; + $unknown->options = [$this->option('title', focused: true)]; + + $this->assertNull($this->resolve([$unknown], $this->command(MusicCommand::class))); + } } From fb655859a13a0a3d6017827bcf449462d5fe7655 Mon Sep 17 00:00:00 2001 From: Vladyslav Gaysyuk Date: Sat, 22 Aug 2026 03:50:54 +0200 Subject: [PATCH 05/11] Split the framework into declare, compile and run layers The attributes were doing five jobs at once: declaring a command, walking the class to find its parts, building the Discord payload, reaching into the container, and invoking the handler. Everything awkward about the codebase followed from that. Because logic lived on objects PHP constructs for you, the reflector had to be assigned from outside after the fact, so every property was a landmine until discovery had run. Typed properties could not be used for the declared values, so HasAttributes stood in as an untyped bag. The scanning logic could not be called on its own, so Command::options built throwaway "fake" Subcommand and SubcommandGroup instances to borrow it. And since the getters re-ran reflection on every read, the tree had no stable identity at all: reading ->options twice returned different objects, and a stateful Autocomplete was reconstructed on every interaction, silently discarding anything it had cached. The lifecycle those getters implied is now explicit: Attributes inert readonly declarations, no reflection, no logic Compiler ClassReflector + attributes -> immutable definition tree, once Definitions CommandDefinition and friends, including handlers flattened to the dotted paths Discord reports, each carrying the option path it reads from Discord CommandBuilderFactory, the one place Fenrir's builders are used Runtime CommandDispatcher, ArgumentResolver, OptionValueResolver, AutocompleteResponder, ChoiceFactory Two dispatch defects fall out of moving argument binding into ArgumentResolver: An option renamed with #[Option(name: ...)] was keyed by the name Discord knows it under while parameters were matched by their PHP name, so the two never met and the handler failed with "Missing required parameter". Arguments are now keyed by the parameter they are destined for. An optional option the user left out was supplied as null rather than left absent, so the parameter default never applied and any non-nullable parameter raised a TypeError. Absent options are now skipped. Other consequences worth calling out: Compiling once at discovery means an unsupported option type now fails on boot rather than on the first interaction that happens to reach it. Because handlers know the path each of their options sits at, autocomplete resolution is a direct lookup and the recursive walk back down the subcommand tree is gone. Dependencies are injected rather than fetched: OptionValueResolver takes a Discord, the registries and dispatcher take a Container, and Tempcord takes its registries. Nothing in the command path calls get() any more, which is what makes the runtime testable without a gateway. A command that throws is now logged and contained rather than escaping into the gateway loop. Registries report Outcomes instead of printing, and BootCommand renders them, so the domain no longer owns presentation and tests assert on results rather than mocking Console. The user-facing API is unchanged: bot authors only ever write attributes, and those keep the same names and arguments. AllCommandExtension and InteractionCallbackBuilder move into Tempcord\Discord. Co-Authored-By: Claude Opus 5 (1M context) --- src/AllCommandExtension.php | 95 ------- src/Attributes/Command.php | 182 ++------------ src/Attributes/Event.php | 28 +-- src/Attributes/Option.php | 119 ++------- src/Attributes/Subcommand.php | 116 +-------- src/Attributes/SubcommandGroup.php | 75 +----- src/Compiler/CommandCompiler.php | 231 ++++++++++++++++++ src/Compiler/EventCompiler.php | 26 ++ src/ConsoleCommands/BootCommand.php | 13 +- src/Definitions/CommandDefinition.php | 71 ++++++ src/Definitions/EventDefinition.php | 17 ++ src/Definitions/HandlerDefinition.php | 37 +++ src/Definitions/OptionDefinition.php | 28 +++ src/Definitions/SubcommandDefinition.php | 21 ++ src/Definitions/SubcommandGroupDefinition.php | 19 ++ src/Discord/AllCommandExtension.php | 57 +++++ src/Discord/CommandBuilderFactory.php | 89 +++++++ .../InteractionCallbackBuilder.php | 2 +- src/Discoveries/CommandsDiscovery.php | 12 +- src/Discoveries/EventsDiscovery.php | 9 +- src/Registries/CommandsRegistry.php | 226 +++++------------ src/Registries/EventsRegistry.php | 52 ++-- src/Runtime/ArgumentResolver.php | 73 ++++++ src/Runtime/AutocompleteResponder.php | 64 +++++ src/Runtime/ChoiceFactory.php | 48 ++++ src/Runtime/CommandDispatcher.php | 45 ++++ src/Runtime/OptionValueResolver.php | 58 +++++ src/Runtime/Outcome.php | 32 +++ src/Runtime/OutcomeLevel.php | 10 + src/Runtime/OutcomeReporter.php | 29 +++ src/Tempcord.php | 51 ++-- src/TempcordInitializer.php | 10 +- src/Traits/HasAttributes.php | 25 -- tests/Doubles/FakeDiscord.php | 16 +- tests/Doubles/RecordingHttp.php | 5 + tests/Doubles/RecordingLogger.php | 20 ++ tests/Fixtures/HandlerlessListener.php | 11 + tests/Fixtures/OptionTypesCommand.php | 14 +- tests/Fixtures/ReadyListener.php | 17 ++ tests/Fixtures/RecordingCommand.php | 19 ++ tests/Fixtures/SearchCommand.php | 19 ++ tests/Fixtures/ThrowingCommand.php | 15 ++ tests/Fixtures/UnsupportedOptionCommand.php | 14 ++ tests/Fixtures/UntypedOptionCommand.php | 14 ++ .../ArrayAutocompleteTest.php | 5 +- tests/Unit/AutocompleteDispatchTest.php | 91 ------- tests/Unit/CommandBuildTest.php | 89 ------- tests/Unit/CommandHandlersTest.php | 49 ---- tests/Unit/CommandNameTest.php | 34 --- tests/Unit/CommandRegistrationTest.php | 160 ------------ tests/Unit/CommandsRegistryTest.php | 69 ------ tests/Unit/Compiler/CommandCompilerTest.php | 204 ++++++++++++++++ tests/Unit/Compiler/EventCompilerTest.php | 42 ++++ .../AllCommandExtensionTest.php} | 7 +- .../Discord/CommandBuilderFactoryTest.php | 104 ++++++++ .../InteractionCallbackBuilderTest.php | 7 +- .../Discoveries/CommandsDiscoveryTest.php | 102 ++++++++ tests/Unit/DiscoveryTest.php | 71 ------ tests/Unit/FocusedOptionTest.php | 103 -------- .../{ => Logging}/ConsoleLogHandlerTest.php | 5 +- tests/Unit/OptionTest.php | 91 ------- .../Registries/CommandRegistrationTest.php | 161 ++++++++++++ .../Unit/Registries/CommandsRegistryTest.php | 168 +++++++++++++ tests/Unit/Registries/EventsRegistryTest.php | 79 ++++++ tests/Unit/Runtime/ArgumentResolverTest.php | 122 +++++++++ .../Runtime/AutocompleteResponderTest.php | 162 ++++++++++++ .../ChoiceFactoryTest.php} | 14 +- tests/Unit/Runtime/CommandDispatcherTest.php | 98 ++++++++ .../Unit/Runtime/OptionValueResolverTest.php | 127 ++++++++++ tests/Unit/SubcommandTest.php | 75 ------ tests/Unit/TempcordTest.php | 82 +++++++ tests/Unit/TestCase.php | 14 +- 72 files changed, 2790 insertions(+), 1679 deletions(-) delete mode 100644 src/AllCommandExtension.php create mode 100644 src/Compiler/CommandCompiler.php create mode 100644 src/Compiler/EventCompiler.php create mode 100644 src/Definitions/CommandDefinition.php create mode 100644 src/Definitions/EventDefinition.php create mode 100644 src/Definitions/HandlerDefinition.php create mode 100644 src/Definitions/OptionDefinition.php create mode 100644 src/Definitions/SubcommandDefinition.php create mode 100644 src/Definitions/SubcommandGroupDefinition.php create mode 100644 src/Discord/AllCommandExtension.php create mode 100644 src/Discord/CommandBuilderFactory.php rename src/{ => Discord}/InteractionCallbackBuilder.php (95%) create mode 100644 src/Runtime/ArgumentResolver.php create mode 100644 src/Runtime/AutocompleteResponder.php create mode 100644 src/Runtime/ChoiceFactory.php create mode 100644 src/Runtime/CommandDispatcher.php create mode 100644 src/Runtime/OptionValueResolver.php create mode 100644 src/Runtime/Outcome.php create mode 100644 src/Runtime/OutcomeLevel.php create mode 100644 src/Runtime/OutcomeReporter.php delete mode 100644 src/Traits/HasAttributes.php create mode 100644 tests/Doubles/RecordingLogger.php create mode 100644 tests/Fixtures/HandlerlessListener.php create mode 100644 tests/Fixtures/ReadyListener.php create mode 100644 tests/Fixtures/RecordingCommand.php create mode 100644 tests/Fixtures/SearchCommand.php create mode 100644 tests/Fixtures/ThrowingCommand.php create mode 100644 tests/Fixtures/UnsupportedOptionCommand.php create mode 100644 tests/Fixtures/UntypedOptionCommand.php rename tests/Unit/{ => AutoCompletes}/ArrayAutocompleteTest.php (91%) delete mode 100644 tests/Unit/AutocompleteDispatchTest.php delete mode 100644 tests/Unit/CommandBuildTest.php delete mode 100644 tests/Unit/CommandHandlersTest.php delete mode 100644 tests/Unit/CommandNameTest.php delete mode 100644 tests/Unit/CommandRegistrationTest.php delete mode 100644 tests/Unit/CommandsRegistryTest.php create mode 100644 tests/Unit/Compiler/CommandCompilerTest.php create mode 100644 tests/Unit/Compiler/EventCompilerTest.php rename tests/Unit/{CommandNameResolutionTest.php => Discord/AllCommandExtensionTest.php} (93%) create mode 100644 tests/Unit/Discord/CommandBuilderFactoryTest.php rename tests/Unit/{ => Discord}/InteractionCallbackBuilderTest.php (89%) create mode 100644 tests/Unit/Discoveries/CommandsDiscoveryTest.php delete mode 100644 tests/Unit/DiscoveryTest.php delete mode 100644 tests/Unit/FocusedOptionTest.php rename tests/Unit/{ => Logging}/ConsoleLogHandlerTest.php (95%) delete mode 100644 tests/Unit/OptionTest.php create mode 100644 tests/Unit/Registries/CommandRegistrationTest.php create mode 100644 tests/Unit/Registries/CommandsRegistryTest.php create mode 100644 tests/Unit/Registries/EventsRegistryTest.php create mode 100644 tests/Unit/Runtime/ArgumentResolverTest.php create mode 100644 tests/Unit/Runtime/AutocompleteResponderTest.php rename tests/Unit/{AutocompleteChoicesTest.php => Runtime/ChoiceFactoryTest.php} (86%) create mode 100644 tests/Unit/Runtime/CommandDispatcherTest.php create mode 100644 tests/Unit/Runtime/OptionValueResolverTest.php delete mode 100644 tests/Unit/SubcommandTest.php create mode 100644 tests/Unit/TempcordTest.php diff --git a/src/AllCommandExtension.php b/src/AllCommandExtension.php deleted file mode 100644 index adee070..0000000 --- a/src/AllCommandExtension.php +++ /dev/null @@ -1,95 +0,0 @@ -commandListener = new FilteredEventEmitter( - $discord->gateway->events, - Events::INTERACTION_CREATE, - fn(InteractionCreate $interactionCreate) => isset($interactionCreate->type) - && ($interactionCreate->type === InteractionType::APPLICATION_COMMAND || $interactionCreate->type === InteractionType::APPLICATION_COMMAND_AUTOCOMPLETE) - && $this->emitInteraction($interactionCreate) - ); - - $this->commandListener->on(Events::INTERACTION_CREATE, function (InteractionCreate $interaction) use ($discord) { - - if ($interaction->type === InteractionType::APPLICATION_COMMAND) { - $this->handleInteraction($interaction, $discord); - return null; - } - - if ($interaction->type === InteractionType::APPLICATION_COMMAND_AUTOCOMPLETE) { - $this->handleInteractionAutocomplete($interaction, $discord); - return null; - } - - }); - - $this->commandListener->start(); - } - - public function bind(string $command, callable $listener, callable $autocomplete): void - { - $this->on($command, $listener); - $this->on($command . '.autocomplete', $autocomplete); - } - - - private function handleInteraction(InteractionCreate $interaction, Discord $discord): void - { - $commandName = $this->getFullNameByInteraction($interaction); - $firedCommand = new CommandInteraction($interaction, $discord); - - $this->emit($commandName, [$firedCommand]); - } - - private function handleInteractionAutocomplete(InteractionCreate $interaction, Discord $discord): void - { - $commandName = $this->getFullNameByInteraction($interaction); - $firedCommand = new CommandInteraction($interaction, $discord); - - $this->emit($commandName . '.autocomplete', [$firedCommand]); - } - - - protected function getFullNameByInteraction(InteractionCreate $command): string - { - $names = [$command->data->name]; - - $this->drillName($command->data->options ?? [], $names); - - return implode('.', $names); - } - - private function drillName(array $options, array &$names): void - { - /** @var ?ApplicationCommandInteractionDataOptionStructure $subCommand */ - $subCommand = array_find($options, function (ApplicationCommandInteractionDataOptionStructure $option) { - return in_array($option->type, [ - ApplicationCommandOptionType::SUB_COMMAND, - ApplicationCommandOptionType::SUB_COMMAND_GROUP, - ], true); - }); - - if (!is_null($subCommand)) { - $names[] = $subCommand->name; - - $this->drillName($subCommand->options ?? [], $names); - } - } - - -} \ No newline at end of file diff --git a/src/Attributes/Command.php b/src/Attributes/Command.php index 0a95cac..d3e6e90 100644 --- a/src/Attributes/Command.php +++ b/src/Attributes/Command.php @@ -5,174 +5,36 @@ use Attribute; use BackedEnum; use Ragnarok\Fenrir\Enums\ApplicationCommandTypes; -use Ragnarok\Fenrir\Exceptions\Rest\Helpers\Command\InvalidCommandNameException; -use Ragnarok\Fenrir\Rest\Helpers\Command\CommandBuilder; -use RuntimeException; -use Tempcord\Traits\HasAttributes; -use Tempest\Reflection\ClassReflector; -use function Tempest\Support\Arr\dot; -use function Tempest\Support\str; +/** + * Declares a class as a Discord application command. + * + * This is a plain declaration and nothing more. Everything it implies — the + * command's name when none is given, its options, the methods that handle it — + * is worked out by the CommandCompiler at discovery time. + */ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] -final class Command +final readonly class Command { - use HasAttributes; - - /** - * Computed getter for command name - * - * If none command is provided - it will take name from the classname - * - * @var string - */ - public string $name { - get { - if ($this->hasAttribute('name')) { - return $this->getAttribute('name') instanceof BackedEnum ? $this->getAttribute('name')->value : $this->getAttribute('name'); - } - - $commandName = str($this->reflector->getShortName()) - ->replaceEnd('Command', '') - ->replaceStart('Command', '') - ->snake('_') - ->lower(); - - return $commandName->toString(); - } - } - - /** - * Every handler this command binds, keyed by the dotted interaction name - * Discord will send back ("moderation.kick", or just "ping"). - * - * @var array - */ - public array $handlers { - get { - $subcommands = array_filter( - $this->options, - static fn(object $option) => $option instanceof SubcommandGroup || $option instanceof Subcommand, - ); - - if ($subcommands !== []) { - $keys = []; - - foreach ($subcommands as $subcommand) { - $keys[$this->name][$subcommand->name] = $subcommand->key; - } - - return dot($keys); - } - - /* - * No subcommands means __invoke is the handler, and it is bound - * under the bare command name whether or not it takes options. - */ - $invokable = new Subcommand(name: $this->name, description: $this->description ?? $this->name); - $invokable->reflector = $this->reflector->getMethod('__invoke'); - - return [$this->name => $invokable->key]; - } - } - - /** - * @throws InvalidCommandNameException - */ - public CommandBuilder $build { - get { - $command = CommandBuilder::new() - ->setName($this->name) - ->setNsfw($this->isNsfw) - ->setDmPermission($this->directMessage) - ->setType($this->type); - - if ($this->type === ApplicationCommandTypes::CHAT_INPUT) { - - if (!$this->description) { - throw new \LogicException("Description for command [$this->name] is required when type=CHAT_INPUT"); - } - - $command->setDescription($this->description); - } - - - foreach ($this->options as $option) { - $command->addOption($option->build); - } - - return $command; - } - } - - /** @var array */ - public array $options { - get { - // A command may legitimately declare no options at all, so start from a list. - $options = $this->getAttribute('options') ?? []; - - if ($this->reflector->hasAttribute(SubcommandGroup::class)) { - /** @var SubcommandGroup $subcommandGroup */ - $subcommandGroup = $this->reflector->getAttribute(SubcommandGroup::class); - $subcommandGroup->reflector = $this->reflector; - $options[$subcommandGroup->name] = $subcommandGroup; - } else { - // This is subcommands without a group - $fakeSubcommandGroup = new SubcommandGroup(name: 'fake', description: 'fake'); - $fakeSubcommandGroup->reflector = $this->reflector; - foreach ($fakeSubcommandGroup->options as $option) { - $options[$option->name] = $option; - } - - if (empty($options)) { - /* - * We assume that there is no Subcommands found and this is an invokable command - * User should provide the __invoke command, so we actually can read the method options (if there are some) - */ - if (!$this->reflector->getReflection()->hasMethod('__invoke')) { - throw new RuntimeException('Class [' . $this->reflector->getName() . '] should declare public sub-commands or have an __invoke method'); - } - - $fakeSubcommand = new Subcommand(name: 'fake', description: 'fake'); - $fakeSubcommand->reflector = $this->reflector->getMethod('__invoke'); - foreach ($fakeSubcommand->options as $option) { - $options[$option->name] = $option; - } - } - } - - $this->setAttribute('options', $options); - - return $this->getAttribute('options'); - } - set { - $this->setAttribute('options', array_merge($this->getAttribute('options') ?? [], $value)); - } - } - - public ClassReflector $reflector; - /** * The guild this command is scoped to, or null when it is registered globally. */ public ?string $guildId; + /** + * @param string|BackedEnum|null $name defaults to the class name, with a + * Command prefix or suffix stripped and the rest snake_cased + * @param list $permissions + */ public function __construct( - string|BackedEnum|null $name = null, - public ?string $description = null, - int|string|null $guildId = null, - public bool $isNsfw = false, - public array $permissions = [], - public bool $directMessage = true, - public ApplicationCommandTypes $type = ApplicationCommandTypes::CHAT_INPUT - ) - { + public string|BackedEnum|null $name = null, + public ?string $description = null, + int|string|null $guildId = null, + public bool $isNsfw = false, + public array $permissions = [], + public bool $directMessage = true, + public ApplicationCommandTypes $type = ApplicationCommandTypes::CHAT_INPUT, + ) { $this->guildId = $guildId === null ? null : (string) $guildId; - - $this->setAttribute('name', $name); - } - - public function mergeOptions(Command $command): void - { - $this->options = $command->options; } -} \ No newline at end of file +} diff --git a/src/Attributes/Event.php b/src/Attributes/Event.php index 5de1b7d..9561d54 100644 --- a/src/Attributes/Event.php +++ b/src/Attributes/Event.php @@ -3,28 +3,14 @@ namespace Tempcord\Attributes; use Attribute; -use RuntimeException; -use Tempest\Reflection\ClassReflector; -use Tempest\Reflection\MethodReflector; -#[Attribute] -class Event +/** + * Declares an invokable class as a listener for a Discord gateway event. + */ +#[Attribute(Attribute::TARGET_CLASS)] +final readonly class Event { - public ClassReflector $reflector; - - public MethodReflector $handler { - get { - if (!$this->reflector->getReflection()->hasMethod('__invoke')) { - throw new RuntimeException('Class [' . $this->reflector->getName() . '] should declare have an __invoke method'); - } - - return $this->reflector->getMethod('__invoke'); - } - } - public function __construct( public string $name, - ) - { - } -} \ No newline at end of file + ) {} +} diff --git a/src/Attributes/Option.php b/src/Attributes/Option.php index a385ba6..b48f868 100644 --- a/src/Attributes/Option.php +++ b/src/Attributes/Option.php @@ -3,113 +3,24 @@ namespace Tempcord\Attributes; use Attribute; -use LogicException; -use Ragnarok\Fenrir\Enums\ApplicationCommandOptionType; -use Ragnarok\Fenrir\Interaction\CommandInteraction; -use Ragnarok\Fenrir\Parts\ApplicationCommandInteractionDataOptionStructure; -use Ragnarok\Fenrir\Parts\Channel; -use Ragnarok\Fenrir\Parts\Role; -use Ragnarok\Fenrir\Parts\User; -use Ragnarok\Fenrir\Rest\Helpers\Command\CommandOptionBuilder; -use RuntimeException; use Tempcord\Interfaces\Autocomplete; -use Tempcord\Tempcord; -use Tempcord\Traits\HasAttributes; -use Tempest\Reflection\ParameterReflector; -use Throwable; -use function React\Async\await; -use function Tempest\Container\get; -use function Tempest\Support\str; +/** + * Declares a method parameter as a user-supplied command option. + * + * The option's Discord type comes from the parameter's PHP type and whether it + * is required comes from whether the parameter has a default, both resolved by + * the CommandCompiler. + */ #[Attribute(Attribute::TARGET_PARAMETER)] -final class Option +final readonly class Option { - use HasAttributes; - - public ParameterReflector $reflector; - - public string $name { - get { - if ($this->hasAttribute('name')) { - return $this->getAttribute('name'); - } - - return str($this->reflector->getName())->toString(); - } - } - - public ApplicationCommandOptionType $type { - get { - if (!$this->reflector->getReflection()->hasType()) { - throw new LogicException('Command option does not have type'); - } - - $type = $this->reflector->getType(); - - //array, bool, callable, float, int, null, object, false, iterable, mixed, never, true, void, - return match ($type->getName()) { - 'string' => ApplicationCommandOptionType::STRING, - 'int' => ApplicationCommandOptionType::INTEGER, - 'float' => ApplicationCommandOptionType::NUMBER, - 'bool' => ApplicationCommandOptionType::BOOLEAN, - User::class => ApplicationCommandOptionType::USER, - Channel::class => ApplicationCommandOptionType::CHANNEL, - Role::class => ApplicationCommandOptionType::ROLE, - //@todo Add more options: Mentionable - default => throw new LogicException('Command option type not supported'), - //@todo maybe add some DTO mapper!? To map modal data to an object - }; - } - } - - public bool $isRequired { - get { - return !$this->reflector->isOptional(); - } - } - - public CommandOptionBuilder $build { - get { - return CommandOptionBuilder::new() - ->setName($this->name) - ->setDescription($this->description) - ->setRequired($this->isRequired) - ->setType($this->type) - ->setAutoComplete($this->autocomplete !== null); - } - } - - public function __construct( - //@todo Add more options for building the Option (from DiscordPHP) - public string $description, - ?string $name = null, - public ?Autocomplete $autocomplete = null, - ) - { - $this->setAttribute('name', $name); - } - /** - * @param ApplicationCommandInteractionDataOptionStructure|null $option - * @param CommandInteraction $interaction - * @return mixed - * @throws Throwable + * @param string|null $name defaults to the parameter's own name */ - public function mapValue(?ApplicationCommandInteractionDataOptionStructure $option, CommandInteraction $interaction): mixed - { - if (!$option) { - return null; - } - - $tempcord = get(Tempcord::class); - - return match ($option->type) { - ApplicationCommandOptionType::USER => await($tempcord->discord->rest->user->get($option->value)), - ApplicationCommandOptionType::CHANNEL => await($tempcord->discord->rest->channel->get($option->value)), - ApplicationCommandOptionType::ROLE => await($tempcord->discord->rest->guild->getRole($interaction->interaction->guild_id, $option->value)), - //@todo need a proxy object that will proxy all props to Channel or User - ApplicationCommandOptionType::MENTIONABLE => throw new RuntimeException('Not implemented'), - default => $option->value, - }; - } -} \ No newline at end of file + public function __construct( + public string $description, + public ?string $name = null, + public ?Autocomplete $autocomplete = null, + ) {} +} diff --git a/src/Attributes/Subcommand.php b/src/Attributes/Subcommand.php index 80a66d2..aeee54f 100644 --- a/src/Attributes/Subcommand.php +++ b/src/Attributes/Subcommand.php @@ -4,115 +4,15 @@ use Attribute; use BackedEnum; -use InvalidArgumentException; -use Ragnarok\Fenrir\Enums\ApplicationCommandOptionType; -use Ragnarok\Fenrir\Interaction\CommandInteraction; -use Ragnarok\Fenrir\Rest\Helpers\Command\CommandOptionBuilder; -use React\Promise\PromiseInterface; -use Tempcord\Traits\HasAttributes; -use Tempest\Reflection\MethodReflector; -use function React\Async\async; -use function React\Async\await; -use function Tempest\Container\get; +/** + * Declares a public method as a subcommand of the command its class declares. + */ #[Attribute(Attribute::TARGET_METHOD)] -final class Subcommand +final readonly class Subcommand { - use HasAttributes; - - public string $name { - get { - $name = $this->getAttribute('name'); - return $name instanceof BackedEnum ? $name->value : $name; - } - } - public MethodReflector $reflector; - - public CommandOptionBuilder $build { - get { - $subcommand = new CommandOptionBuilder() - ->setName($this->name) - ->setDescription($this->description) - ->setType(ApplicationCommandOptionType::SUB_COMMAND); - - foreach ($this->options as $option) { - $subcommand->addOption($option->build); - } - - return $subcommand; - } - } - - /** - * @var array - */ - public array $options { - get { - $options = []; - foreach ($this->reflector->getParameters() as $parameter) { - if ($parameter->hasAttribute(Option::class)) { - /** @var Option $option */ - $option = $parameter->getAttribute(Option::class); - $option->reflector = $parameter; - $options[$parameter->getName()] = $option; - } - } - return $options; - } - } - public \Closure $key { - get { - return function (CommandInteraction $interaction) { - - - $subcommandName = $interaction->getSubCommandName() ? str_replace(':', '.', $interaction->getSubCommandName()) : null; - - $getArgs = async(function () use ($interaction, $subcommandName) { - $args = [ - 'interaction' => $interaction - ]; - foreach ($this->options as $option) { - $args[$option->name] = $option->mapValue($interaction->getOption( - path: $subcommandName ? $subcommandName . '.' . $option->name : $option->name - ), $interaction); - } - return $args; - }); - - - $getArgs()->then(function (array $args) { - $class = get($this->reflector->getDeclaringClass()->getName()); - $this->invokeNamedArgs($class, $args); - }); - - }; - } - } - public function __construct( - string|BackedEnum $name, - public string $description - ) - { - $this->setAttribute('name', $name); - } - - public function invokeNamedArgs(?object $object, array $namedArgs): mixed - { - $ordered = []; - - foreach ($this->reflector->getParameters() as $param) { - $name = $param->getName(); - - if (array_key_exists($name, $namedArgs)) { - $ordered[] = $namedArgs[$name]; - } elseif ($param->isOptional()) { - $ordered[] = $param->getDefaultValue(); - } else { - throw new InvalidArgumentException("Missing required parameter: $name in method {$this->reflector->getShortName()}"); - } - } - - return $this->reflector->invokeArgs($object, $ordered); - } -} \ No newline at end of file + public string|BackedEnum $name, + public string $description, + ) {} +} diff --git a/src/Attributes/SubcommandGroup.php b/src/Attributes/SubcommandGroup.php index 511c204..29054d4 100644 --- a/src/Attributes/SubcommandGroup.php +++ b/src/Attributes/SubcommandGroup.php @@ -4,74 +4,15 @@ use Attribute; use BackedEnum; -use Ragnarok\Fenrir\Enums\ApplicationCommandOptionType; -use Ragnarok\Fenrir\Rest\Helpers\Command\CommandOptionBuilder; -use Tempcord\Traits\HasAttributes; -use Tempest\Reflection\ClassReflector; -use function Tempest\Support\Arr\dot; +/** + * Groups every subcommand its class declares under one more level of nesting. + */ #[Attribute(Attribute::TARGET_CLASS)] -final class SubcommandGroup +final readonly class SubcommandGroup { - use HasAttributes; - - public string $name { - get { - $name = $this->getAttribute('name'); - return $name instanceof BackedEnum ? $name->value : $name; - } - } - - public array $key { - get { - $keys = []; - foreach ($this->options as $option) { - $keys[$option->name] = $option->key; - } - return $keys; - } - } - - public ClassReflector $reflector; - - public CommandOptionBuilder $build { - get { - $subcommandGroup = CommandOptionBuilder::new() - ->setName($this->name) - ->setDescription($this->description) - ->setType(ApplicationCommandOptionType::SUB_COMMAND_GROUP); - - foreach ($this->options as $option) { - $subcommandGroup->addOption($option->build); - } - - return $subcommandGroup; - } - } - - /** @var array */ - public array $options { - get { - $options = []; - foreach ($this->reflector->getPublicMethods() as $method) { - if ($method->hasAttribute(Subcommand::class)) { - /** @var Subcommand $subcommand */ - $subcommand = $method->getAttribute(Subcommand::class); - $subcommand->reflector = $method; - $options[$subcommand->name] = $subcommand; - } - } - return $options; - } - } - - public function __construct( - string|BackedEnum $name, - public string $description - ) - { - $this->setAttribute('name', $name); - } - -} \ No newline at end of file + public string|BackedEnum $name, + public string $description, + ) {} +} diff --git a/src/Compiler/CommandCompiler.php b/src/Compiler/CommandCompiler.php new file mode 100644 index 0000000..b4e2498 --- /dev/null +++ b/src/Compiler/CommandCompiler.php @@ -0,0 +1,231 @@ + ApplicationCommandOptionType::STRING, + 'int' => ApplicationCommandOptionType::INTEGER, + 'float' => ApplicationCommandOptionType::NUMBER, + 'bool' => ApplicationCommandOptionType::BOOLEAN, + User::class => ApplicationCommandOptionType::USER, + Channel::class => ApplicationCommandOptionType::CHANNEL, + Role::class => ApplicationCommandOptionType::ROLE, + ]; + + public function compile(ClassReflector $class, Command $command): CommandDefinition + { + $name = $this->nameOf($class, $command); + $options = []; + $handlers = []; + + $group = $this->groupOf($class); + $subcommands = $this->subcommandsOf($class); + + if ($group !== null) { + $options[$group->name] = $group; + + foreach ($group->subcommands as $subcommand) { + $path = $name . '.' . $group->name . '.' . $subcommand->name; + + $handlers[$path] = new HandlerDefinition( + path: $path, + method: $subcommand->method, + options: $subcommand->options, + optionPath: $group->name . '.' . $subcommand->name, + ); + } + } elseif ($subcommands !== []) { + foreach ($subcommands as $subcommand) { + $options[$subcommand->name] = $subcommand; + $path = $name . '.' . $subcommand->name; + + $handlers[$path] = new HandlerDefinition( + path: $path, + method: $subcommand->method, + options: $subcommand->options, + optionPath: $subcommand->name, + ); + } + } else { + /* + * No subcommands anywhere means __invoke is the command, and its + * parameters are the command's options. + */ + $invoke = $this->invokerOf($class); + $options = $this->optionsOf($invoke); + + $handlers[$name] = new HandlerDefinition( + path: $name, + method: $invoke, + options: $options, + ); + } + + if ($command->type === ApplicationCommandTypes::CHAT_INPUT && $command->description === null) { + throw new LogicException("Description for command [{$name}] is required when type=CHAT_INPUT"); + } + + return new CommandDefinition( + name: $name, + description: $command->description, + guildId: $command->guildId, + isNsfw: $command->isNsfw, + directMessage: $command->directMessage, + type: $command->type, + permissions: $command->permissions, + options: $options, + handlers: $handlers, + ); + } + + /** + * An explicit name wins; otherwise the class name carries it, with the + * conventional Command prefix or suffix stripped and the rest snake_cased. + */ + private function nameOf(ClassReflector $class, Command $command): string + { + if ($command->name !== null) { + return $command->name instanceof BackedEnum + ? (string) $command->name->value + : $command->name; + } + + return str($class->getShortName()) + ->replaceEnd('Command', '') + ->replaceStart('Command', '') + ->snake('_') + ->lower() + ->toString(); + } + + private function groupOf(ClassReflector $class): ?SubcommandGroupDefinition + { + if (!$class->hasAttribute(SubcommandGroup::class)) { + return null; + } + + /** @var SubcommandGroup $group */ + $group = $class->getAttribute(SubcommandGroup::class); + + return new SubcommandGroupDefinition( + name: $this->valueOf($group->name), + description: $group->description, + subcommands: $this->subcommandsOf($class), + ); + } + + /** + * @return array + */ + private function subcommandsOf(ClassReflector $class): array + { + $subcommands = []; + + foreach ($class->getPublicMethods() as $method) { + if (!$method->hasAttribute(Subcommand::class)) { + continue; + } + + /** @var Subcommand $subcommand */ + $subcommand = $method->getAttribute(Subcommand::class); + $name = $this->valueOf($subcommand->name); + + $subcommands[$name] = new SubcommandDefinition( + name: $name, + description: $subcommand->description, + options: $this->optionsOf($method), + method: $method, + ); + } + + return $subcommands; + } + + private function invokerOf(ClassReflector $class): MethodReflector + { + if (!$class->getReflection()->hasMethod('__invoke')) { + throw new RuntimeException( + 'Class [' . $class->getName() . '] should declare public sub-commands or have an __invoke method', + ); + } + + return $class->getMethod('__invoke'); + } + + /** + * @return array + */ + private function optionsOf(MethodReflector $method): array + { + $options = []; + + foreach ($method->getParameters() as $parameter) { + if (!$parameter->hasAttribute(Option::class)) { + continue; + } + + /** @var Option $option */ + $option = $parameter->getAttribute(Option::class); + $name = $option->name ?? $parameter->getName(); + + $options[$name] = new OptionDefinition( + name: $name, + description: $option->description, + type: $this->typeOf($parameter), + isRequired: !$parameter->isOptional(), + autocomplete: $option->autocomplete, + parameter: $parameter, + ); + } + + return $options; + } + + private function typeOf(ParameterReflector $parameter): ApplicationCommandOptionType + { + if (!$parameter->getReflection()->hasType()) { + throw new LogicException('Command option does not have type'); + } + + return self::OPTION_TYPES[$parameter->getType()->getName()] + ?? throw new LogicException('Command option type not supported'); + } + + private function valueOf(string|BackedEnum $name): string + { + return $name instanceof BackedEnum ? (string) $name->value : $name; + } +} diff --git a/src/Compiler/EventCompiler.php b/src/Compiler/EventCompiler.php new file mode 100644 index 0000000..47d53c1 --- /dev/null +++ b/src/Compiler/EventCompiler.php @@ -0,0 +1,26 @@ +getReflection()->hasMethod('__invoke')) { + throw new RuntimeException( + 'Class [' . $class->getName() . '] should declare an __invoke method', + ); + } + + return new EventDefinition( + name: $event->name, + listener: $class->getName(), + method: $class->getMethod('__invoke'), + ); + } +} diff --git a/src/ConsoleCommands/BootCommand.php b/src/ConsoleCommands/BootCommand.php index 3b213cf..77661de 100644 --- a/src/ConsoleCommands/BootCommand.php +++ b/src/ConsoleCommands/BootCommand.php @@ -2,6 +2,7 @@ namespace Tempcord\ConsoleCommands; +use Tempcord\Runtime\OutcomeReporter; use Tempcord\Tempcord; use Tempest\Console\Console; use Tempest\Console\ConsoleArgument; @@ -11,22 +12,26 @@ { public function __construct( private Tempcord $tempcord, - private Console $console + private Console $console, + private OutcomeReporter $reporter, ) {} #[ConsoleCommand(name: 'boot', description: 'Boots the bot')] public function __invoke( #[ConsoleArgument( description: 'Register bot commands', - aliases: ['r'] + aliases: ['r'], )] - bool $register = false + bool $register = false, ): void { if ($register) { $this->console->header('Registering commands...'); - $this->tempcord->registerCommands(); + $this->reporter->report($this->tempcord->registerCommands()); } + $this->console->header('Starting commands and events...'); + $this->reporter->report($this->tempcord->listen()); + $this->console->header('Booting up...'); $this->tempcord->boot(); } diff --git a/src/Definitions/CommandDefinition.php b/src/Definitions/CommandDefinition.php new file mode 100644 index 0000000..1981f54 --- /dev/null +++ b/src/Definitions/CommandDefinition.php @@ -0,0 +1,71 @@ + $options + * the command's direct children, in the shape Discord expects + * @param array $handlers keyed by dotted interaction path + * @param list $permissions + */ + public function __construct( + public string $name, + public ?string $description, + public ?string $guildId, + public bool $isNsfw, + public bool $directMessage, + public ApplicationCommandTypes $type, + public array $permissions, + public array $options, + public array $handlers, + ) {} + + public function isGlobal(): bool + { + return $this->guildId === null; + } + + /** + * Commands are keyed by name and scoped by guild, so a guild command + * collides neither with another command in the same guild nor with the + * global command of the same name. + * + * Discord command names cannot contain ":", so the two key shapes can + * never overlap. + */ + public function key(): string + { + return $this->guildId === null + ? $this->name + : $this->guildId . ':' . $this->name; + } + + /** + * Folds another declaration of the same command into this one, so a command + * may be split across several classes. Options and handlers from both sides + * survive; everything else is taken from the earlier declaration. + */ + public function mergedWith(self $other): self + { + return new self( + name: $this->name, + description: $this->description ?? $other->description, + guildId: $this->guildId, + isNsfw: $this->isNsfw, + directMessage: $this->directMessage, + type: $this->type, + permissions: $this->permissions, + options: [...$this->options, ...$other->options], + handlers: [...$this->handlers, ...$other->handlers], + ); + } +} diff --git a/src/Definitions/EventDefinition.php b/src/Definitions/EventDefinition.php new file mode 100644 index 0000000..9c8d2b5 --- /dev/null +++ b/src/Definitions/EventDefinition.php @@ -0,0 +1,17 @@ + $options keyed by option name + * @param string $optionPath the prefix getOption() needs to reach this + * handler's options, empty for an invokable command + */ + public function __construct( + public string $path, + public MethodReflector $method, + public array $options, + public string $optionPath = '', + ) {} + + /** + * Where in the interaction payload the given option will be found. + */ + public function pathTo(OptionDefinition $option): string + { + return $this->optionPath === '' + ? $option->name + : $this->optionPath . '.' . $option->name; + } +} diff --git a/src/Definitions/OptionDefinition.php b/src/Definitions/OptionDefinition.php new file mode 100644 index 0000000..591cc1e --- /dev/null +++ b/src/Definitions/OptionDefinition.php @@ -0,0 +1,28 @@ +autocomplete !== null; + } +} diff --git a/src/Definitions/SubcommandDefinition.php b/src/Definitions/SubcommandDefinition.php new file mode 100644 index 0000000..9762b7f --- /dev/null +++ b/src/Definitions/SubcommandDefinition.php @@ -0,0 +1,21 @@ + $options keyed by option name + */ + public function __construct( + public string $name, + public string $description, + public array $options, + public MethodReflector $method, + ) {} +} diff --git a/src/Definitions/SubcommandGroupDefinition.php b/src/Definitions/SubcommandGroupDefinition.php new file mode 100644 index 0000000..7b09b5e --- /dev/null +++ b/src/Definitions/SubcommandGroupDefinition.php @@ -0,0 +1,19 @@ + $subcommands keyed by subcommand name + */ + public function __construct( + public string $name, + public string $description, + public array $subcommands, + ) {} +} diff --git a/src/Discord/AllCommandExtension.php b/src/Discord/AllCommandExtension.php new file mode 100644 index 0000000..4d023f8 --- /dev/null +++ b/src/Discord/AllCommandExtension.php @@ -0,0 +1,57 @@ +commandListener = new FilteredEventEmitter( + $discord->gateway->events, + Events::INTERACTION_CREATE, + fn(InteractionCreate $interactionCreate) => isset($interactionCreate->type) + && in_array($interactionCreate->type, [ + InteractionType::APPLICATION_COMMAND, + InteractionType::APPLICATION_COMMAND_AUTOCOMPLETE, + ], true) + && $this->emitInteraction($interactionCreate), + ); + + $this->commandListener->on( + Events::INTERACTION_CREATE, + function (InteractionCreate $interaction) use ($discord): void { + $name = $this->getFullNameByInteraction($interaction); + + if ($interaction->type === InteractionType::APPLICATION_COMMAND_AUTOCOMPLETE) { + $name .= self::AUTOCOMPLETE_SUFFIX; + } + + $this->emit($name, [new CommandInteraction($interaction, $discord)]); + }, + ); + + $this->commandListener->start(); + } + + public function bind(string $command, callable $listener, callable $autocomplete): void + { + $this->on($command, $listener); + $this->on($command . self::AUTOCOMPLETE_SUFFIX, $autocomplete); + } +} diff --git a/src/Discord/CommandBuilderFactory.php b/src/Discord/CommandBuilderFactory.php new file mode 100644 index 0000000..d0d603f --- /dev/null +++ b/src/Discord/CommandBuilderFactory.php @@ -0,0 +1,89 @@ +setName($command->name) + ->setNsfw($command->isNsfw) + ->setDmPermission($command->directMessage) + ->setType($command->type); + + if ($command->type === ApplicationCommandTypes::CHAT_INPUT) { + // The compiler guarantees a description for chat input commands. + $builder->setDescription((string) $command->description); + } + + foreach ($command->options as $option) { + $builder->addOption($this->forOption($option)); + } + + return $builder; + } + + public function forOption( + SubcommandGroupDefinition|SubcommandDefinition|OptionDefinition $option, + ): CommandOptionBuilder { + return match (true) { + $option instanceof SubcommandGroupDefinition => $this->forGroup($option), + $option instanceof SubcommandDefinition => $this->forSubcommand($option), + default => $this->forParameter($option), + }; + } + + private function forGroup(SubcommandGroupDefinition $group): CommandOptionBuilder + { + $builder = CommandOptionBuilder::new() + ->setName($group->name) + ->setDescription($group->description) + ->setType(ApplicationCommandOptionType::SUB_COMMAND_GROUP); + + foreach ($group->subcommands as $subcommand) { + $builder->addOption($this->forSubcommand($subcommand)); + } + + return $builder; + } + + private function forSubcommand(SubcommandDefinition $subcommand): CommandOptionBuilder + { + $builder = CommandOptionBuilder::new() + ->setName($subcommand->name) + ->setDescription($subcommand->description) + ->setType(ApplicationCommandOptionType::SUB_COMMAND); + + foreach ($subcommand->options as $option) { + $builder->addOption($this->forParameter($option)); + } + + return $builder; + } + + private function forParameter(OptionDefinition $option): CommandOptionBuilder + { + return CommandOptionBuilder::new() + ->setName($option->name) + ->setDescription($option->description) + ->setRequired($option->isRequired) + ->setType($option->type) + ->setAutoComplete($option->hasAutocomplete()); + } +} diff --git a/src/InteractionCallbackBuilder.php b/src/Discord/InteractionCallbackBuilder.php similarity index 95% rename from src/InteractionCallbackBuilder.php rename to src/Discord/InteractionCallbackBuilder.php index a75955f..01a2e87 100644 --- a/src/InteractionCallbackBuilder.php +++ b/src/Discord/InteractionCallbackBuilder.php @@ -1,6 +1,6 @@ getAttributes(Command::class) as $attribute) { - $attribute->reflector = $class; - $this->discoveryItems->add($location, $attribute); + $this->discoveryItems->add($location, $this->compiler->compile($class, $attribute)); } } - /** - * @mago-expect best-practices/no-empty-loop - */ public function apply(): void { foreach ($this->discoveryItems as $command) { diff --git a/src/Discoveries/EventsDiscovery.php b/src/Discoveries/EventsDiscovery.php index ff8b354..78a38df 100644 --- a/src/Discoveries/EventsDiscovery.php +++ b/src/Discoveries/EventsDiscovery.php @@ -3,6 +3,7 @@ namespace Tempcord\Discoveries; use Tempcord\Attributes\Event; +use Tempcord\Compiler\EventCompiler; use Tempcord\Registries\EventsRegistry; use Tempest\Discovery\Discovery; use Tempest\Discovery\DiscoveryLocation; @@ -15,15 +16,13 @@ final class EventsDiscovery implements Discovery public function __construct( private readonly EventsRegistry $eventsRegistry, - ) - { - } + private readonly EventCompiler $compiler = new EventCompiler(), + ) {} public function discover(DiscoveryLocation $location, ClassReflector $class): void { foreach ($class->getAttributes(Event::class) as $attribute) { - $attribute->reflector = $class; - $this->discoveryItems->add($location, $attribute); + $this->discoveryItems->add($location, $this->compiler->compile($class, $attribute)); } } diff --git a/src/Registries/CommandsRegistry.php b/src/Registries/CommandsRegistry.php index 7b5b703..4ab82e7 100644 --- a/src/Registries/CommandsRegistry.php +++ b/src/Registries/CommandsRegistry.php @@ -3,232 +3,132 @@ namespace Tempcord\Registries; use Ragnarok\Fenrir\Discord; -use Ragnarok\Fenrir\Enums\ApplicationCommandOptionType; -use Ragnarok\Fenrir\Enums\InteractionCallbackType; use Ragnarok\Fenrir\Interaction\CommandInteraction; -use Ragnarok\Fenrir\Parts\ApplicationCommandInteractionDataOptionStructure; -use Ragnarok\Fenrir\Parts\ApplicationCommandOptionChoice; -use Tempcord\Attributes\Command; -use Tempcord\Attributes\Option; -use Tempcord\Attributes\Subcommand; -use Tempcord\Attributes\SubcommandGroup; -use Tempcord\Interfaces\Autocomplete; -use Tempcord\AllCommandExtension; -use Tempcord\InteractionCallbackBuilder; -use Tempest\Console\Console; +use Tempcord\Definitions\CommandDefinition; +use Tempcord\Definitions\HandlerDefinition; +use Tempcord\Discord\AllCommandExtension; +use Tempcord\Discord\CommandBuilderFactory; +use Tempcord\Runtime\AutocompleteResponder; +use Tempcord\Runtime\CommandDispatcher; +use Tempcord\Runtime\Outcome; use Tempest\Container\Singleton; use Throwable; use function React\Async\await; +/** + * Holds every compiled command and wires it up: registering it with Discord and + * binding its handlers to the interactions that come back. + */ #[Singleton] final class CommandsRegistry { - /** - * Discord rejects an autocomplete response carrying more choices than this. - */ - private const int MAX_CHOICES = 25; - - /** @var array */ + /** @var array */ private array $commands = []; public function __construct( - public readonly AllCommandExtension $extension + public readonly AllCommandExtension $extension, + private readonly CommandBuilderFactory $builders, + private readonly CommandDispatcher $dispatcher, + private readonly AutocompleteResponder $autocomplete, ) {} - public function add(Command $command): void + public function add(CommandDefinition $command): void { - $key = self::key($command); - - if (array_key_exists($key, $this->commands)) { - $command->mergeOptions($this->commands[$key]); - } + $key = $command->key(); - $this->commands[$key] = $command; + $this->commands[$key] = isset($this->commands[$key]) + ? $this->commands[$key]->mergedWith($command) + : $command; } /** - * Commands are keyed by name, scoped by guild so that a guild command - * neither collides with another command in the same guild nor with the - * global command of the same name. + * Pushes every command to Discord, reporting on each as it goes. * - * Discord command names cannot contain ":", so the two key shapes - * can never overlap. - */ - private static function key(Command $command): string - { - return $command->guildId === null - ? $command->name - : $command->guildId . ':' . $command->name; - } - - /** - * @throws Throwable + * @return list */ - public function register(Console $console, Discord $discord): void + public function register(Discord $discord): array { - if (empty($this->commands)) { - $console->warning('No commands to register.'); - return; + if ($this->commands === []) { + return [Outcome::warning('No commands to register.')]; } try { $application = await($discord->rest->application->getCurrent()); } catch (Throwable $throwable) { - $console->error($throwable->getMessage()); - return; + return [Outcome::error($throwable->getMessage())]; } + $outcomes = []; + foreach ($this->commands as $command) { try { /* * Guild commands go to a different endpoint that additionally * takes the guild id, so the two cannot share a call. */ - await($command->guildId === null + await($command->isGlobal() ? $discord->rest->globalCommand->createApplicationCommand( $application->id, - $command->build, + $this->builders->forCommand($command), ) : $discord->rest->guildCommand->createApplicationCommand( $application->id, $command->guildId, - $command->build, + $this->builders->forCommand($command), )); - $console->success($command->guildId === null + $outcomes[] = Outcome::success($command->isGlobal() ? 'Command "' . $command->name . '" registered globally.' : 'Command "' . $command->name . '" registered in guild ' . $command->guildId . '.'); } catch (Throwable $throwable) { - $console->error('Command "' . $command->name . '": ' . $throwable->getMessage()); - } - } - } - - public function listen(Console $console): void - { - $console->info('Starting Commands'); - - $count = 0; - - foreach ($this->commands as $command) { - foreach ($command->handlers as $key => $handler) { - - $this->extension->bind( - command: $key, - listener: function (CommandInteraction $interaction) use ($console, $handler) { - try { - $handler($interaction); - } catch (\Throwable $e) { - $console->error($e->getMessage()); - } - }, - autocomplete: function (CommandInteraction $interaction) use ($command) { - $resolved = $this->resolveFocusedAndParam( - $interaction->interaction->data->options ?? [], - $command, - ); - - // Nothing focused, or focused on an option this command does not declare. - if ($resolved === null) { - return null; - } - - [$option, $interactionOption] = $resolved; - - if (!$option->autocomplete instanceof Autocomplete) { - return null; - } - - $interaction->createInteractionResponse( - InteractionCallbackBuilder::new() - ->setChoices($this->toChoices( - $option->autocomplete->handle($interaction, $interactionOption->value), - )) - ->setType(InteractionCallbackType::APPLICATION_COMMAND_AUTOCOMPLETE_RESULT) - ); - - return null; - } + $outcomes[] = Outcome::error( + 'Command "' . $command->name . '": ' . $throwable->getMessage(), ); - - $count++; - - $console->success('Command "' . $key . '" listened.'); } } - if ($count <= 0) { - $console->warning('Listened ' . $count . ' commands. Maybe this behavior is not expected, or you just did not created any command yet.'); - } + return $outcomes; } /** - * Normalises whatever an Autocomplete returned into the choice list Discord - * accepts. - * - * A bare scalar stands for a single suggestion. A list uses each entry as - * its own label; a map uses its keys as labels. Choices built by hand are - * passed through untouched. + * Binds every handler to the interaction path it answers. * - * @return list + * @return list */ - private function toChoices(mixed $value): array + public function listen(): array { - $choices = is_array($value) ? $value : [$value]; - $choices = array_slice($choices, 0, self::MAX_CHOICES, preserve_keys: true); + $outcomes = []; - $isList = array_is_list($choices); - - return array_map( - static function (mixed $choice, int|string $label) use ($isList): ApplicationCommandOptionChoice { - if ($choice instanceof ApplicationCommandOptionChoice) { - return $choice; - } + foreach ($this->commands as $command) { + foreach ($command->handlers as $handler) { + $this->bind($handler, $outcomes); + } + } - $applicationCommandOptionChoice = new ApplicationCommandOptionChoice(); - $applicationCommandOptionChoice->name = (string) ($isList ? $choice : $label); - $applicationCommandOptionChoice->value = $choice; + if ($outcomes === []) { + return [Outcome::warning( + 'Listened 0 commands. Maybe this behavior is not expected, or you just did not created any command yet.', + )]; + } - return $applicationCommandOptionChoice; - }, - $choices, - array_keys($choices), - ); + return $outcomes; } /** - * @param array $interactionOptions — array of ApplicationCommandInteractionDataOptionStructure - * @param Command|SubcommandGroup|Subcommand $definition — Tempcord definition - * @return array{ Option, ApplicationCommandInteractionDataOptionStructure }|null + * @param list $outcomes */ - private function resolveFocusedAndParam(array $interactionOptions, Command|SubcommandGroup|Subcommand $definition): ?array + private function bind(HandlerDefinition $handler, array &$outcomes): void { - /** @var ApplicationCommandInteractionDataOptionStructure $option */ - foreach ($interactionOptions as $option) { - $name = $option->name; - - // If SUB_COMMAND_GROUP or SUB_COMMAND, go deeper - if (in_array($option->type, [ - ApplicationCommandOptionType::SUB_COMMAND, - ApplicationCommandOptionType::SUB_COMMAND_GROUP, - ], true)) { - // $definition->options[$name] should be the nested command/group - $nextDefinition = $definition->options[$name] ?? null; - - if ($nextDefinition && !empty($option->options)) { - $result = $this->resolveFocusedAndParam($option->options, $nextDefinition); - if ($result !== null) { - return $result; - } - } - } else if ((isset($option->focused) && $option->focused === true) && isset($definition->options[$name])) { - return [ - $definition->options[$name], - $option - ]; - } - } + $this->extension->bind( + command: $handler->path, + listener: function (CommandInteraction $interaction) use ($handler): void { + $this->dispatcher->dispatch($handler, $interaction); + }, + autocomplete: function (CommandInteraction $interaction) use ($handler): void { + $this->autocomplete->respond($handler, $interaction); + }, + ); - return null; + $outcomes[] = Outcome::success('Command "' . $handler->path . '" listened.'); } } diff --git a/src/Registries/EventsRegistry.php b/src/Registries/EventsRegistry.php index e05eeeb..605a1ca 100644 --- a/src/Registries/EventsRegistry.php +++ b/src/Registries/EventsRegistry.php @@ -2,37 +2,51 @@ namespace Tempcord\Registries; -use Tempcord\Attributes\Event; -use Tempcord\Tempcord; -use Tempest\Console\Console; +use Ragnarok\Fenrir\Discord; +use Tempcord\Definitions\EventDefinition; +use Tempcord\Runtime\Outcome; +use Tempest\Container\Container; use Tempest\Container\Singleton; -use function Tempest\Container\get; +/** + * Holds every compiled event listener and attaches it to the gateway. + */ #[Singleton] final class EventsRegistry { - /** @var array> */ - private array $eventListeners = []; + /** @var array> */ + private array $events = []; + public function __construct( + private readonly Container $container, + ) {} - public function add(Event $event): void + public function add(EventDefinition $event): void { - $this->eventListeners[$event->name][] = static fn(object $eventObject) => $event->handler->invokeArgs( - object: get($event->reflector->getName()), - args: [$eventObject] - ); + $this->events[$event->name][] = $event; } - public function listen(Console $console): void + /** + * @return list + */ + public function listen(Discord $discord): array { - $console->header('Starting Events'); - $tempcord = get(Tempcord::class); + $outcomes = []; - foreach ($this->eventListeners as $event => $eventListeners) { - foreach ($eventListeners as $eventListener) { - $tempcord->discord->gateway->events->on($event, $eventListener); - $console->success('Added listener for: ' . $event); + foreach ($this->events as $name => $events) { + foreach ($events as $event) { + $discord->gateway->events->on( + $name, + fn(object $payload) => $event->method->invokeArgs( + $this->container->get($event->listener), + [$payload], + ), + ); + + $outcomes[] = Outcome::success('Added listener for: ' . $name); } } + + return $outcomes; } -} \ No newline at end of file +} diff --git a/src/Runtime/ArgumentResolver.php b/src/Runtime/ArgumentResolver.php new file mode 100644 index 0000000..9bf55f6 --- /dev/null +++ b/src/Runtime/ArgumentResolver.php @@ -0,0 +1,73 @@ + ordered to match the handler's signature + * @throws Throwable + */ + public function resolve(HandlerDefinition $handler, CommandInteraction $interaction): array + { + $supplied = [self::INTERACTION_PARAMETER => $interaction]; + + foreach ($handler->options as $option) { + $structure = $interaction->getOption($handler->pathTo($option)); + + /* + * An option the user left out is not supplied at all, so the + * parameter's own default applies. Passing null instead would fail + * on any parameter that is not nullable. + */ + if ($structure === null) { + continue; + } + + $supplied[$option->parameter->getName()] = $this->values->resolve($structure, $interaction); + } + + $arguments = []; + + foreach ($handler->method->getParameters() as $parameter) { + $name = $parameter->getName(); + + if (array_key_exists($name, $supplied)) { + $arguments[] = $supplied[$name]; + continue; + } + + if ($parameter->isOptional()) { + $arguments[] = $parameter->getDefaultValue(); + continue; + } + + throw new InvalidArgumentException( + "Missing required parameter: {$name} for command \"{$handler->path}\"", + ); + } + + return $arguments; + } +} diff --git a/src/Runtime/AutocompleteResponder.php b/src/Runtime/AutocompleteResponder.php new file mode 100644 index 0000000..323b4ed --- /dev/null +++ b/src/Runtime/AutocompleteResponder.php @@ -0,0 +1,64 @@ +focused($handler, $interaction); + + // Discord sends autocomplete interactions with nothing focused too. + if ($focused === null) { + return; + } + + [$option, $structure] = $focused; + + if ($option->autocomplete === null) { + return; + } + + $interaction->createInteractionResponse( + InteractionCallbackBuilder::new() + ->setChoices($this->choices->from( + $option->autocomplete->handle($interaction, $structure->value), + )) + ->setType(InteractionCallbackType::APPLICATION_COMMAND_AUTOCOMPLETE_RESULT), + ); + } + + /** + * @return array{OptionDefinition, ApplicationCommandInteractionDataOptionStructure}|null + */ + private function focused(HandlerDefinition $handler, CommandInteraction $interaction): ?array + { + foreach ($handler->options as $option) { + $structure = $interaction->getOption($handler->pathTo($option)); + + if ($structure !== null && ($structure->focused ?? false) === true) { + return [$option, $structure]; + } + } + + return null; + } +} diff --git a/src/Runtime/ChoiceFactory.php b/src/Runtime/ChoiceFactory.php new file mode 100644 index 0000000..96ec083 --- /dev/null +++ b/src/Runtime/ChoiceFactory.php @@ -0,0 +1,48 @@ + + */ + public function from(mixed $value): array + { + $choices = is_array($value) ? $value : [$value]; + $choices = array_slice($choices, 0, self::MAX_CHOICES, preserve_keys: true); + + $isList = array_is_list($choices); + + return array_map( + static function (mixed $choice, int|string $label) use ($isList): ApplicationCommandOptionChoice { + if ($choice instanceof ApplicationCommandOptionChoice) { + return $choice; + } + + $applicationCommandOptionChoice = new ApplicationCommandOptionChoice(); + $applicationCommandOptionChoice->name = (string) ($isList ? $choice : $label); + $applicationCommandOptionChoice->value = $choice; + + return $applicationCommandOptionChoice; + }, + $choices, + array_keys($choices), + ); + } +} diff --git a/src/Runtime/CommandDispatcher.php b/src/Runtime/CommandDispatcher.php new file mode 100644 index 0000000..a660318 --- /dev/null +++ b/src/Runtime/CommandDispatcher.php @@ -0,0 +1,45 @@ +method->invokeArgs( + $this->container->get($handler->method->getDeclaringClass()->getName()), + $this->arguments->resolve($handler, $interaction), + ); + } catch (Throwable $throwable) { + $this->logger->error( + 'Command "' . $handler->path . '" failed: ' . $throwable->getMessage(), + ['exception' => $throwable], + ); + } + })(); + } +} diff --git a/src/Runtime/OptionValueResolver.php b/src/Runtime/OptionValueResolver.php new file mode 100644 index 0000000..6d58046 --- /dev/null +++ b/src/Runtime/OptionValueResolver.php @@ -0,0 +1,58 @@ +type) { + ApplicationCommandOptionType::USER => await( + $this->discord->rest->user->get($option->value), + ), + ApplicationCommandOptionType::CHANNEL => await( + $this->discord->rest->channel->get($option->value), + ), + ApplicationCommandOptionType::ROLE => await( + $this->discord->rest->guild->getRole( + $interaction->interaction->guild_id, + $option->value, + ), + ), + //@todo needs a proxy object that forwards to whichever of Channel or User was mentioned + ApplicationCommandOptionType::MENTIONABLE => throw new RuntimeException( + 'Mentionable options are not supported yet', + ), + default => $option->value, + }; + } +} diff --git a/src/Runtime/Outcome.php b/src/Runtime/Outcome.php new file mode 100644 index 0000000..bd34368 --- /dev/null +++ b/src/Runtime/Outcome.php @@ -0,0 +1,32 @@ + $outcomes + */ + public function report(iterable $outcomes): void + { + foreach ($outcomes as $outcome) { + match ($outcome->level) { + OutcomeLevel::Success => $this->console->success($outcome->message), + OutcomeLevel::Warning => $this->console->warning($outcome->message), + OutcomeLevel::Error => $this->console->error($outcome->message), + }; + } + } +} diff --git a/src/Tempcord.php b/src/Tempcord.php index d81ba01..7fdc23e 100644 --- a/src/Tempcord.php +++ b/src/Tempcord.php @@ -7,48 +7,49 @@ use Ragnarok\Fenrir\Gateway\Events\Ready; use Tempcord\Registries\CommandsRegistry; use Tempcord\Registries\EventsRegistry; -use Tempest\Console\Console; -use function Tempest\Container\get; +use Tempcord\Runtime\Outcome; +/** + * The bot itself: the Discord connection plus the registries that fill it. + */ final class Tempcord { - private CommandsRegistry $commandsRegistry; - private EventsRegistry $eventsRegistry; - public bool $booted = false; public function __construct( - public readonly Discord $discord, - private readonly Console $console, + public readonly Discord $discord, + private readonly CommandsRegistry $commandsRegistry, + private readonly EventsRegistry $eventsRegistry, ) { - //@todo: Maybe move to Interface - $this->commandsRegistry = get(CommandsRegistry::class); - $this->eventsRegistry = get(EventsRegistry::class); - - $this->discord->gateway->events->on(Events::READY, function (Ready $ready) { + $this->discord->gateway->events->on(Events::READY, function (Ready $ready): void { $this->discord->registerExtension($this->commandsRegistry->extension); $this->booted = true; }); } - public function registerCommands(): void + /** + * @return list + */ + public function registerCommands(): array { - $this->commandsRegistry->register( - console: $this->console, - discord: $this->discord, - ); + return $this->commandsRegistry->register($this->discord); } - public function boot(): void + /** + * Binds everything that has been discovered, then opens the gateway. + * + * @return list + */ + public function listen(): array { - $this->commandsRegistry->listen( - console: $this->console - ); - - $this->eventsRegistry->listen( - console: $this->console - ); + return [ + ...$this->commandsRegistry->listen(), + ...$this->eventsRegistry->listen($this->discord), + ]; + } + public function boot(): void + { $this->discord->gateway->open(); } } diff --git a/src/TempcordInitializer.php b/src/TempcordInitializer.php index 96537f8..4757423 100644 --- a/src/TempcordInitializer.php +++ b/src/TempcordInitializer.php @@ -3,7 +3,8 @@ namespace Tempcord; use Ragnarok\Fenrir\Discord; -use Tempest\Console\Console; +use Tempcord\Registries\CommandsRegistry; +use Tempcord\Registries\EventsRegistry; use Tempest\Container\Container; use Tempest\Container\Initializer; use Tempest\Container\Singleton; @@ -21,9 +22,10 @@ public function initialize(Container $container): Tempcord token: $config->token, logger: $container->get(Logger::class), )->withGateway( - intents: $config->intents + intents: $config->intents, )->withRest(), - console: $container->get(Console::class) + commandsRegistry: $container->get(CommandsRegistry::class), + eventsRegistry: $container->get(EventsRegistry::class), ); } -} \ No newline at end of file +} diff --git a/src/Traits/HasAttributes.php b/src/Traits/HasAttributes.php deleted file mode 100644 index aae3dda..0000000 --- a/src/Traits/HasAttributes.php +++ /dev/null @@ -1,25 +0,0 @@ -attributes[$name] = $value; - return $this; - } - - private function getAttribute(string $name): mixed - { - return $this->attributes[$name] ?? null; - } - - private function hasAttribute(string $name): bool - { - return array_key_exists($name, $this->attributes) && $this->attributes[$name] !== null; - } - -} \ No newline at end of file diff --git a/tests/Doubles/FakeDiscord.php b/tests/Doubles/FakeDiscord.php index b5207ca..f58e64f 100644 --- a/tests/Doubles/FakeDiscord.php +++ b/tests/Doubles/FakeDiscord.php @@ -5,16 +5,28 @@ use Psr\Log\NullLogger; use Ragnarok\Fenrir\DataMapper; use Ragnarok\Fenrir\Discord; +use Ragnarok\Fenrir\EventHandler; +use Ragnarok\Fenrir\Gateway\Connection; use Ragnarok\Fenrir\Rest\Rest; +use ReflectionClass; /** - * A Discord instance wired to a real Rest over a recording transport, so no - * gateway or network is involved. + * A Discord instance wired to a real Rest over a recording transport, and to a + * gateway connection that emits but never opens a socket. */ final class FakeDiscord extends Discord { public function __construct(public readonly RecordingHttp $http) { $this->rest = new Rest($this->http, new DataMapper(new NullLogger()), new NullLogger()); + + /* + * Connection's constructor builds a websocket shard, which a unit test + * has no use for. Only its event emitter matters here. + */ + $gateway = new ReflectionClass(Connection::class)->newInstanceWithoutConstructor(); + $gateway->events = new EventHandler(new DataMapper(new NullLogger())); + + $this->gateway = $gateway; } } diff --git a/tests/Doubles/RecordingHttp.php b/tests/Doubles/RecordingHttp.php index 70ff615..099e752 100644 --- a/tests/Doubles/RecordingHttp.php +++ b/tests/Doubles/RecordingHttp.php @@ -17,6 +17,9 @@ final class RecordingHttp extends Http /** @var list */ public array $posts = []; + /** @var list */ + public array $gets = []; + public function __construct( private readonly bool $failApplicationLookup = false, private readonly array $failPostsMatching = [], @@ -24,6 +27,8 @@ public function __construct( public function get($url, $content = null, array $headers = []): PromiseInterface { + $this->gets[] = (string) $url; + if ($this->failApplicationLookup) { return reject(new RuntimeException('401: Unauthorized')); } diff --git a/tests/Doubles/RecordingLogger.php b/tests/Doubles/RecordingLogger.php new file mode 100644 index 0000000..258092c --- /dev/null +++ b/tests/Doubles/RecordingLogger.php @@ -0,0 +1,20 @@ + */ + public array $messages = []; + + public function log($level, string|Stringable $message, array $context = []): void + { + $this->messages[] = (string) $message; + } +} diff --git a/tests/Fixtures/HandlerlessListener.php b/tests/Fixtures/HandlerlessListener.php new file mode 100644 index 0000000..2e9fe5c --- /dev/null +++ b/tests/Fixtures/HandlerlessListener.php @@ -0,0 +1,11 @@ + */ + public static array $received = []; + + public function __invoke(object $payload): void + { + self::$received[] = $payload; + } +} diff --git a/tests/Fixtures/RecordingCommand.php b/tests/Fixtures/RecordingCommand.php new file mode 100644 index 0000000..dfd0ed0 --- /dev/null +++ b/tests/Fixtures/RecordingCommand.php @@ -0,0 +1,19 @@ + */ + public static array $calls = []; + + public function __invoke( + #[Option(description: 'anything')] string $subject, + ): void { + self::$calls[] = $subject; + } +} diff --git a/tests/Fixtures/SearchCommand.php b/tests/Fixtures/SearchCommand.php new file mode 100644 index 0000000..c2317b6 --- /dev/null +++ b/tests/Fixtures/SearchCommand.php @@ -0,0 +1,19 @@ +name = 'ping'; - $data->options = $options; - - $interaction = new InteractionCreate(); - $interaction->id = '1'; - $interaction->token = 'token'; - $interaction->data = $data; - - return new CommandInteraction($interaction, $discord); - } - - /** - * Runs one autocomplete interaction against a listening PingCommand and - * returns every Discord endpoint it posted to. - * - * @return list - */ - private function dispatch(array $options): array - { - $http = new RecordingHttp(); - - $extension = new AllCommandExtension(); - $registry = new CommandsRegistry($extension); - $registry->add($this->command(PingCommand::class)); - $registry->listen($this->createStub(Console::class)); - - $extension->emit('ping.autocomplete', [ - $this->interaction(new FakeDiscord($http), $options), - ]); - - return $http->postedUrls(); - } - - private function option(string $name, bool $focused = false): ApplicationCommandInteractionDataOptionStructure - { - $option = new ApplicationCommandInteractionDataOptionStructure(); - $option->name = $name; - $option->type = ApplicationCommandOptionType::STRING; - $option->value = 'wh'; - $option->options = []; - $option->focused = $focused; - - return $option; - } - - /** - * Discord sends an autocomplete interaction while the user is still typing, - * and it can arrive with nothing focused. That must not take the bot down. - */ - public function test_an_autocomplete_with_nothing_focused_is_ignored(): void - { - $this->assertSame([], $this->dispatch([$this->option('name'), $this->option('times')])); - } - - public function test_an_autocomplete_focused_on_an_undeclared_option_is_ignored(): void - { - $this->assertSame([], $this->dispatch([$this->option('not_declared', focused: true)])); - } - - /** - * PingCommand declares no autocomplete on its options, so a focused option - * still produces no response — but it must get there without erroring. - */ - public function test_an_option_without_an_autocomplete_produces_no_response(): void - { - $this->assertSame([], $this->dispatch([$this->option('name', focused: true)])); - } -} diff --git a/tests/Unit/CommandBuildTest.php b/tests/Unit/CommandBuildTest.php deleted file mode 100644 index 9782696..0000000 --- a/tests/Unit/CommandBuildTest.php +++ /dev/null @@ -1,89 +0,0 @@ -command(PingCommand::class)->build; - $built = $builder->get(); - - $this->assertSame('ping', $built['name']); - $this->assertSame('Replies with pong', $built['description']); - $this->assertFalse($builder->getNsfw()); - $this->assertTrue($builder->getDmPermission()); - } - - public function test_a_chat_input_command_requires_a_description(): void - { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Description for command [descriptionless] is required when type=CHAT_INPUT'); - - $this->command(DescriptionlessCommand::class)->build; - } - - public function test_an_invokable_command_exposes_its_invoke_parameters_as_options(): void - { - $options = $this->command(PingCommand::class)->options; - - $this->assertSame(['name', 'times'], array_keys($options)); - $this->assertTrue($options['name']->isRequired); - $this->assertFalse($options['times']->isRequired); - } - - public function test_a_grouped_command_exposes_the_group_as_its_only_option(): void - { - $options = $this->command(MusicCommand::class)->options; - - $this->assertSame(['playlist'], array_keys($options)); - $this->assertSame( - ApplicationCommandOptionType::SUB_COMMAND_GROUP->value, - $options['playlist']->build->get()['type'], - ); - } - - public function test_an_ungrouped_command_exposes_its_subcommands_directly(): void - { - $options = $this->command(ModerationCommand::class)->options; - - $this->assertSame(['kick'], array_keys($options)); - $this->assertSame( - ApplicationCommandOptionType::SUB_COMMAND->value, - $options['kick']->build->get()['type'], - ); - } - - /** - * An option-less slash command is perfectly normal and must not blow up - * on the way to a builder. - */ - public function test_a_command_without_any_options_builds_cleanly(): void - { - $command = $this->command(NamedCommand::class); - - $this->assertSame([], $command->options); - $this->assertSame('explicit', $command->build->get()['name']); - } - - public function test_a_command_with_neither_subcommands_nor_invoke_is_rejected(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('should declare public sub-commands or have an __invoke method'); - - $this->command(NoHandlerCommand::class)->options; - } -} diff --git a/tests/Unit/CommandHandlersTest.php b/tests/Unit/CommandHandlersTest.php deleted file mode 100644 index bc321f5..0000000 --- a/tests/Unit/CommandHandlersTest.php +++ /dev/null @@ -1,49 +0,0 @@ -command(PingCommand::class)->handlers; - - $this->assertSame(['ping'], array_keys($handlers)); - } - - /** - * A command whose __invoke declares no options still has to be listened - * for — otherwise it registers with Discord and then silently never fires. - */ - public function test_an_invokable_command_without_options_is_still_bound(): void - { - $handlers = $this->command(BareCommand::class)->handlers; - - $this->assertSame(['bare'], array_keys($handlers)); - } - - public function test_subcommands_are_bound_under_dotted_paths(): void - { - $handlers = $this->command(ModerationCommand::class)->handlers; - - $this->assertSame(['moderation.kick'], array_keys($handlers)); - } - - public function test_grouped_subcommands_are_bound_under_the_group(): void - { - $handlers = $this->command(MusicCommand::class)->handlers; - - $this->assertSame( - ['music.playlist.play', 'music.playlist.stop'], - array_keys($handlers), - ); - } -} diff --git a/tests/Unit/CommandNameTest.php b/tests/Unit/CommandNameTest.php deleted file mode 100644 index f556729..0000000 --- a/tests/Unit/CommandNameTest.php +++ /dev/null @@ -1,34 +0,0 @@ -assertSame('ping', $this->command(PingCommand::class)->name); - } - - public function test_it_strips_a_command_prefix_as_well_as_a_suffix(): void - { - $this->assertSame('user_settings', $this->command(CommandUserSettings::class)->name); - } - - public function test_an_explicit_name_wins_over_the_class_name(): void - { - $this->assertSame('explicit', $this->command(NamedCommand::class)->name); - } - - public function test_a_backed_enum_name_is_unwrapped_to_its_value(): void - { - $this->assertSame('weather', $this->command(EnumNamedCommand::class)->name); - } -} diff --git a/tests/Unit/CommandRegistrationTest.php b/tests/Unit/CommandRegistrationTest.php deleted file mode 100644 index af16a74..0000000 --- a/tests/Unit/CommandRegistrationTest.php +++ /dev/null @@ -1,160 +0,0 @@ -add($this->command($class)); - } - - return $registry; - } - - /** @return array */ - private function storedCommands(CommandsRegistry $registry): array - { - return new ReflectionProperty(CommandsRegistry::class, 'commands')->getValue($registry); - } - - public function test_a_global_command_goes_to_the_global_endpoint(): void - { - $http = new RecordingHttp(); - - $this->registry(PingCommand::class) - ->register($this->createStub(Console::class), new FakeDiscord($http)); - - $this->assertSame([self::GLOBAL_ENDPOINT], $http->postedUrls()); - } - - public function test_a_guild_command_goes_to_the_guild_endpoint(): void - { - $http = new RecordingHttp(); - - $this->registry(GuildAlphaCommand::class) - ->register($this->createStub(Console::class), new FakeDiscord($http)); - - $this->assertSame([$this->guildEndpoint('111')], $http->postedUrls()); - } - - /** - * The original defect: guild commands were keyed by guild id alone, so a - * second command in the same guild silently replaced the first. - */ - public function test_two_commands_in_the_same_guild_are_both_registered(): void - { - $http = new RecordingHttp(); - $registry = $this->registry(GuildAlphaCommand::class, GuildBetaCommand::class); - - $this->assertCount(2, $this->storedCommands($registry)); - - $registry->register($this->createStub(Console::class), new FakeDiscord($http)); - - $this->assertSame( - [$this->guildEndpoint('111'), $this->guildEndpoint('111')], - $http->postedUrls(), - ); - } - - public function test_the_same_command_name_in_two_guilds_is_registered_in_each(): void - { - $http = new RecordingHttp(); - - $this->registry(GuildAlphaCommand::class, OtherGuildAlphaCommand::class) - ->register($this->createStub(Console::class), new FakeDiscord($http)); - - $this->assertSame( - [$this->guildEndpoint('111'), $this->guildEndpoint('222')], - $http->postedUrls(), - ); - } - - public function test_a_guild_command_does_not_displace_the_global_command_of_the_same_name(): void - { - $http = new RecordingHttp(); - - $this->registry(GlobalAlphaCommand::class, GuildAlphaCommand::class) - ->register($this->createStub(Console::class), new FakeDiscord($http)); - - $this->assertSame( - [self::GLOBAL_ENDPOINT, $this->guildEndpoint('111')], - $http->postedUrls(), - ); - } - - public function test_a_mixed_set_is_routed_per_command(): void - { - $http = new RecordingHttp(); - - $this->registry(PingCommand::class, GuildAlphaCommand::class, ModerationCommand::class) - ->register($this->createStub(Console::class), new FakeDiscord($http)); - - $this->assertSame( - [self::GLOBAL_ENDPOINT, $this->guildEndpoint('111'), self::GLOBAL_ENDPOINT], - $http->postedUrls(), - ); - } - - public function test_the_guild_id_is_reported_on_success(): void - { - $console = $this->createMock(Console::class); - $console->expects($this->once()) - ->method('success') - ->with('Command "alpha" registered in guild 111.'); - - $this->registry(GuildAlphaCommand::class)->register($console, new FakeDiscord(new RecordingHttp())); - } - - public function test_one_failing_command_does_not_stop_the_others(): void - { - $http = new RecordingHttp(failPostsMatching: ['/guilds/']); - $console = $this->createMock(Console::class); - $console->expects($this->once()) - ->method('error') - ->with($this->stringContains('Command "alpha"')); - $console->expects($this->exactly(2))->method('success'); - - $this->registry(PingCommand::class, GuildAlphaCommand::class, ModerationCommand::class) - ->register($console, new FakeDiscord($http)); - - $this->assertCount(3, $http->postedUrls()); - } - - public function test_nothing_is_registered_when_the_application_lookup_fails(): void - { - $http = new RecordingHttp(failApplicationLookup: true); - $console = $this->createMock(Console::class); - $console->expects($this->once())->method('error'); - $console->expects($this->never())->method('success'); - - $this->registry(PingCommand::class)->register($console, new FakeDiscord($http)); - - $this->assertSame([], $http->postedUrls()); - } -} diff --git a/tests/Unit/CommandsRegistryTest.php b/tests/Unit/CommandsRegistryTest.php deleted file mode 100644 index 8df6e91..0000000 --- a/tests/Unit/CommandsRegistryTest.php +++ /dev/null @@ -1,69 +0,0 @@ - - */ - private function storedCommands(CommandsRegistry $registry): array - { - return new ReflectionProperty(CommandsRegistry::class, 'commands')->getValue($registry); - } - - public function test_it_stores_a_global_command_under_its_name(): void - { - $registry = $this->registry(); - $registry->add($this->command(PingCommand::class)); - - $this->assertSame(['ping'], array_keys($this->storedCommands($registry))); - } - - public function test_it_keeps_distinct_commands_side_by_side(): void - { - $registry = $this->registry(); - $registry->add($this->command(PingCommand::class)); - $registry->add($this->command(ModerationCommand::class)); - - $this->assertSame(['ping', 'moderation'], array_keys($this->storedCommands($registry))); - } - - public function test_re_adding_the_same_command_name_merges_the_options(): void - { - $registry = $this->registry(); - $registry->add($this->command(PingCommand::class)); - $registry->add($this->command(PingCommand::class)); - - $stored = $this->storedCommands($registry); - - $this->assertCount(1, $stored); - $this->assertSame(['name', 'times'], array_keys($stored['ping']->options)); - } - - public function test_register_warns_instead_of_calling_discord_when_there_is_nothing_to_register(): void - { - $console = $this->createMock(Console::class); - $console->expects($this->once()) - ->method('warning') - ->with('No commands to register.'); - - // A null Discord would fatal if it were touched; it never should be. - $this->registry()->register($console, $this->createStub(\Ragnarok\Fenrir\Discord::class)); - } -} diff --git a/tests/Unit/Compiler/CommandCompilerTest.php b/tests/Unit/Compiler/CommandCompilerTest.php new file mode 100644 index 0000000..3b3486c --- /dev/null +++ b/tests/Unit/Compiler/CommandCompilerTest.php @@ -0,0 +1,204 @@ +definition($class); + } + + public function test_it_derives_the_name_from_the_class_name(): void + { + $this->assertSame('ping', $this->compile(PingCommand::class)->name); + } + + public function test_an_explicit_name_wins_over_the_class_name(): void + { + $this->assertSame('explicit', $this->compile(NamedCommand::class)->name); + } + + public function test_it_strips_a_command_prefix_as_well_as_a_suffix(): void + { + $this->assertSame('user_settings', $this->compile(CommandUserSettings::class)->name); + } + + public function test_a_backed_enum_name_is_unwrapped(): void + { + $this->assertSame('weather', $this->compile(EnumNamedCommand::class)->name); + } + + public function test_a_guild_id_is_normalised_to_a_string(): void + { + $definition = $this->compile(GuildAlphaCommand::class); + + $this->assertSame('111', $definition->guildId); + $this->assertFalse($definition->isGlobal()); + $this->assertSame('111:alpha', $definition->key()); + } + + public function test_a_global_command_is_keyed_by_name_alone(): void + { + $definition = $this->compile(PingCommand::class); + + $this->assertTrue($definition->isGlobal()); + $this->assertSame('ping', $definition->key()); + } + + public function test_an_invokable_command_takes_its_options_from_invoke(): void + { + $definition = $this->compile(PingCommand::class); + + $this->assertSame(['name', 'times'], array_keys($definition->options)); + $this->assertInstanceOf(OptionDefinition::class, $definition->options['name']); + $this->assertTrue($definition->options['name']->isRequired); + $this->assertFalse($definition->options['times']->isRequired); + } + + public function test_an_invokable_command_is_handled_under_its_bare_name(): void + { + $definition = $this->compile(PingCommand::class); + + $this->assertSame(['ping'], array_keys($definition->handlers)); + $this->assertSame('', $definition->handlers['ping']->optionPath); + $this->assertSame('name', $definition->handlers['ping']->pathTo($definition->options['name'])); + } + + public function test_an_invokable_command_without_options_still_has_a_handler(): void + { + $definition = $this->compile(BareCommand::class); + + $this->assertSame([], $definition->options); + $this->assertSame(['bare'], array_keys($definition->handlers)); + } + + public function test_ungrouped_subcommands_become_the_commands_options(): void + { + $definition = $this->compile(ModerationCommand::class); + + $this->assertSame(['kick'], array_keys($definition->options)); + $this->assertInstanceOf(SubcommandDefinition::class, $definition->options['kick']); + $this->assertSame(['moderation.kick'], array_keys($definition->handlers)); + $this->assertSame('kick', $definition->handlers['moderation.kick']->optionPath); + } + + public function test_a_group_nests_its_subcommands_one_level_deeper(): void + { + $definition = $this->compile(MusicCommand::class); + + $this->assertSame(['playlist'], array_keys($definition->options)); + $this->assertInstanceOf(SubcommandGroupDefinition::class, $definition->options['playlist']); + $this->assertSame(['play', 'stop'], array_keys($definition->options['playlist']->subcommands)); + + $this->assertSame( + ['music.playlist.play', 'music.playlist.stop'], + array_keys($definition->handlers), + ); + + $play = $definition->handlers['music.playlist.play']; + $this->assertSame('playlist.play', $play->optionPath); + $this->assertSame('playlist.play.title', $play->pathTo($play->options['title'])); + } + + public function test_it_maps_every_supported_parameter_type(): void + { + $options = $this->compile(OptionTypesCommand::class)->options['all']->options; + + $this->assertSame( + [ + 'text' => ApplicationCommandOptionType::STRING, + 'count' => ApplicationCommandOptionType::INTEGER, + 'ratio' => ApplicationCommandOptionType::NUMBER, + 'flag' => ApplicationCommandOptionType::BOOLEAN, + 'user' => ApplicationCommandOptionType::USER, + 'channel' => ApplicationCommandOptionType::CHANNEL, + 'role' => ApplicationCommandOptionType::ROLE, + ], + array_map(static fn(OptionDefinition $option) => $option->type, $options), + ); + } + + public function test_an_explicitly_named_option_keeps_that_name(): void + { + $options = $this->compile(OptionTypesCommand::class)->options['renamed']->options; + + $this->assertSame(['custom'], array_keys($options)); + } + + /** + * Compilation happens at discovery, so a command Discord could never accept + * fails on boot rather than on the first interaction that reaches it. + */ + public function test_an_unsupported_parameter_type_is_rejected(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Command option type not supported'); + + $this->compile(UnsupportedOptionCommand::class); + } + + public function test_an_untyped_parameter_is_rejected(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Command option does not have type'); + + $this->compile(UntypedOptionCommand::class); + } + + public function test_a_chat_input_command_requires_a_description(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Description for command [descriptionless] is required when type=CHAT_INPUT'); + + $this->compile(DescriptionlessCommand::class); + } + + public function test_a_command_with_neither_subcommands_nor_invoke_is_rejected(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('should declare public sub-commands or have an __invoke method'); + + $this->compile(NoHandlerCommand::class); + } + + /** + * Compilation happens once, so the tree it returns has stable identity — + * unlike reading the attribute properties, which rebuilt them per access. + */ + public function test_the_compiled_tree_is_stable(): void + { + $definition = $this->compile(MusicCommand::class); + + $this->assertSame($definition->options['playlist'], $definition->options['playlist']); + $this->assertSame( + $definition->options['playlist']->subcommands['play']->options['title'], + $definition->handlers['music.playlist.play']->options['title'], + ); + } +} diff --git a/tests/Unit/Compiler/EventCompilerTest.php b/tests/Unit/Compiler/EventCompilerTest.php new file mode 100644 index 0000000..9bde30a --- /dev/null +++ b/tests/Unit/Compiler/EventCompilerTest.php @@ -0,0 +1,42 @@ +getAttribute(Event::class); + + return new EventCompiler()->compile($reflector, $attribute); + } + + public function test_it_pairs_the_event_name_with_the_listener(): void + { + $definition = $this->compile(ReadyListener::class); + + $this->assertSame('READY', $definition->name); + $this->assertSame(ReadyListener::class, $definition->listener); + } + + public function test_a_listener_without_invoke_is_rejected(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('should declare an __invoke method'); + + $this->compile(HandlerlessListener::class); + } +} diff --git a/tests/Unit/CommandNameResolutionTest.php b/tests/Unit/Discord/AllCommandExtensionTest.php similarity index 93% rename from tests/Unit/CommandNameResolutionTest.php rename to tests/Unit/Discord/AllCommandExtensionTest.php index 8e0cf40..0c1ad78 100644 --- a/tests/Unit/CommandNameResolutionTest.php +++ b/tests/Unit/Discord/AllCommandExtensionTest.php @@ -1,17 +1,18 @@ factory = new CommandBuilderFactory(); + } + + /** @return array */ + private function build(string $class): array + { + return $this->factory->forCommand($this->definition($class))->get(); + } + + public function test_it_builds_a_chat_input_command(): void + { + $builder = $this->factory->forCommand($this->definition(PingCommand::class)); + $built = $builder->get(); + + $this->assertSame('ping', $built['name']); + $this->assertSame('Replies with pong', $built['description']); + $this->assertFalse($builder->getNsfw()); + $this->assertTrue($builder->getDmPermission()); + } + + public function test_a_command_without_options_builds_cleanly(): void + { + $built = $this->build(BareCommand::class); + + $this->assertSame('bare', $built['name']); + $this->assertSame([], $built['options'] ?? []); + } + + public function test_it_builds_each_invoke_parameter_as_an_option(): void + { + $built = $this->build(PingCommand::class); + + $this->assertSame(['name', 'times'], array_column($built['options'], 'name')); + $this->assertTrue($built['options'][0]['required']); + $this->assertFalse($built['options'][1]['required']); + } + + public function test_an_option_carries_its_type_and_description(): void + { + $option = $this->build(OptionTypesCommand::class)['options'][0]['options'][1]; + + $this->assertSame('count', $option['name']); + $this->assertSame('an int', $option['description']); + $this->assertSame(ApplicationCommandOptionType::INTEGER->value, $option['type']); + $this->assertTrue($option['required']); + $this->assertFalse($option['autocomplete']); + } + + public function test_an_ungrouped_command_builds_its_subcommands_directly(): void + { + $options = $this->build(ModerationCommand::class)['options']; + + $this->assertCount(1, $options); + $this->assertSame('kick', $options[0]['name']); + $this->assertSame(ApplicationCommandOptionType::SUB_COMMAND->value, $options[0]['type']); + $this->assertSame('reason', $options[0]['options'][0]['name']); + } + + public function test_a_grouped_command_nests_its_subcommands_under_the_group(): void + { + $options = $this->build(MusicCommand::class)['options']; + + $this->assertCount(1, $options); + $this->assertSame('playlist', $options[0]['name']); + $this->assertSame('Playlist controls', $options[0]['description']); + $this->assertSame(ApplicationCommandOptionType::SUB_COMMAND_GROUP->value, $options[0]['type']); + + $this->assertSame(['play', 'stop'], array_column($options[0]['options'], 'name')); + $this->assertSame( + ApplicationCommandOptionType::SUB_COMMAND->value, + $options[0]['options'][0]['type'], + ); + $this->assertSame('title', $options[0]['options'][0]['options'][0]['name']); + } + + public function test_a_subcommand_without_parameters_carries_no_options(): void + { + $stop = $this->build(MusicCommand::class)['options'][0]['options'][1]; + + $this->assertSame('stop', $stop['name']); + $this->assertSame([], $stop['options'] ?? []); + } +} diff --git a/tests/Unit/InteractionCallbackBuilderTest.php b/tests/Unit/Discord/InteractionCallbackBuilderTest.php similarity index 89% rename from tests/Unit/InteractionCallbackBuilderTest.php rename to tests/Unit/Discord/InteractionCallbackBuilderTest.php index 802c32b..a35db1a 100644 --- a/tests/Unit/InteractionCallbackBuilderTest.php +++ b/tests/Unit/Discord/InteractionCallbackBuilderTest.php @@ -1,14 +1,15 @@ location = new DiscoveryLocation( + namespace: 'Tempcord\\Tests\\Fixtures\\', + path: __DIR__ . '/../../Fixtures', + ); + } + + private function registry(): CommandsRegistry + { + $discord = new FakeDiscord(new RecordingHttp()); + + return new CommandsRegistry( + extension: new AllCommandExtension(), + builders: new CommandBuilderFactory(), + dispatcher: new CommandDispatcher( + new ArgumentResolver(new OptionValueResolver($discord)), + new GenericContainer(), + new NullLogger(), + ), + autocomplete: new AutocompleteResponder(new ChoiceFactory()), + ); + } + + private function discovery(CommandsRegistry $registry): CommandsDiscovery + { + $discovery = new CommandsDiscovery($registry); + $discovery->setItems(new DiscoveryItems()); + + return $discovery; + } + + public function test_it_discovers_command_classes_and_feeds_them_to_the_registry(): void + { + $registry = $this->registry(); + $discovery = $this->discovery($registry); + + $discovery->discover($this->location, new ClassReflector(PingCommand::class)); + $discovery->discover($this->location, new ClassReflector(ModerationCommand::class)); + $discovery->apply(); + + $stored = new ReflectionProperty(CommandsRegistry::class, 'commands')->getValue($registry); + + $this->assertSame(['ping', 'moderation'], array_keys($stored)); + } + + /** + * Discovery hands the registry finished definitions, not attributes that + * still need a reflector attached before they can be read. + */ + public function test_it_stores_compiled_definitions(): void + { + $discovery = $this->discovery($this->registry()); + $discovery->discover($this->location, new ClassReflector(PingCommand::class)); + + $items = iterator_to_array($discovery->getItems()); + + $this->assertCount(1, $items); + $this->assertInstanceOf(CommandDefinition::class, $items[0]); + $this->assertSame('ping', $items[0]->name); + } + + public function test_it_ignores_classes_without_the_command_attribute(): void + { + $discovery = $this->discovery($this->registry()); + $discovery->discover($this->location, new ClassReflector(CommandsDiscoveryTest::class)); + + $this->assertCount(0, iterator_to_array($discovery->getItems())); + } +} diff --git a/tests/Unit/DiscoveryTest.php b/tests/Unit/DiscoveryTest.php deleted file mode 100644 index d885ea5..0000000 --- a/tests/Unit/DiscoveryTest.php +++ /dev/null @@ -1,71 +0,0 @@ -location = new DiscoveryLocation( - namespace: 'Tempcord\\Tests\\Fixtures\\', - path: __DIR__ . '/../Fixtures', - ); - } - - private function discovery(CommandsRegistry $registry): CommandsDiscovery - { - $discovery = new CommandsDiscovery($registry); - $discovery->setItems(new DiscoveryItems()); - - return $discovery; - } - - public function test_it_discovers_command_classes_and_feeds_them_to_the_registry(): void - { - $registry = new CommandsRegistry(new AllCommandExtension()); - $discovery = $this->discovery($registry); - - $discovery->discover($this->location, new ClassReflector(PingCommand::class)); - $discovery->discover($this->location, new ClassReflector(ModerationCommand::class)); - $discovery->apply(); - - $stored = new ReflectionProperty(CommandsRegistry::class, 'commands')->getValue($registry); - - $this->assertSame(['ping', 'moderation'], array_keys($stored)); - } - - public function test_it_attaches_the_class_reflector_to_the_discovered_attribute(): void - { - $discovery = $this->discovery(new CommandsRegistry(new AllCommandExtension())); - $discovery->discover($this->location, new ClassReflector(PingCommand::class)); - - $items = iterator_to_array($discovery->getItems()); - - $this->assertCount(1, $items); - $this->assertSame(PingCommand::class, $items[0]->reflector->getName()); - } - - public function test_it_ignores_classes_without_the_command_attribute(): void - { - $discovery = $this->discovery(new CommandsRegistry(new AllCommandExtension())); - $discovery->discover($this->location, new ClassReflector(NoHandlerCommand::class)); - $discovery->discover($this->location, new ClassReflector(DiscoveryTest::class)); - - $this->assertCount(1, iterator_to_array($discovery->getItems())); - } -} diff --git a/tests/Unit/FocusedOptionTest.php b/tests/Unit/FocusedOptionTest.php deleted file mode 100644 index 47b17af..0000000 --- a/tests/Unit/FocusedOptionTest.php +++ /dev/null @@ -1,103 +0,0 @@ -name = $name; - $option->type = ApplicationCommandOptionType::STRING; - $option->value = 'whatever'; - $option->options = []; - $option->focused = $focused; - - return $option; - } - - private function resolve(array $interactionOptions, Command $definition): ?array - { - return new ReflectionMethod(CommandsRegistry::class, 'resolveFocusedAndParam') - ->invoke(new CommandsRegistry(new AllCommandExtension()), $interactionOptions, $definition); - } - - public function test_it_resolves_the_focused_option_against_the_definition(): void - { - $command = $this->command(PingCommand::class); - $focused = $this->option('name', focused: true); - - $resolved = $this->resolve([$this->option('times'), $focused], $command); - - $this->assertNotNull($resolved); - $this->assertSame($command->options['name'], $resolved[0]); - $this->assertSame($focused, $resolved[1]); - } - - public function test_it_returns_null_when_nothing_is_focused(): void - { - $this->assertNull( - $this->resolve([$this->option('name'), $this->option('times')], $this->command(PingCommand::class)), - ); - } - - /** - * A focused option the command does not declare must not raise an - * "Undefined array key" warning — it simply does not resolve. - */ - public function test_a_focused_option_unknown_to_the_definition_resolves_to_null(): void - { - $this->assertNull( - $this->resolve([$this->option('not_declared', focused: true)], $this->command(PingCommand::class)), - ); - } - - public function test_it_returns_null_for_no_options_at_all(): void - { - $this->assertNull($this->resolve([], $this->command(PingCommand::class))); - } - - /** - * Discord nests the focused option inside the subcommand group and then - * the subcommand, so resolution has to drill through both. - */ - public function test_it_drills_through_a_group_and_a_subcommand(): void - { - $command = $this->command(MusicCommand::class); - - $focused = $this->option('title', focused: true); - $play = $this->option('play'); - $play->type = ApplicationCommandOptionType::SUB_COMMAND; - $play->options = [$focused]; - $playlist = $this->option('playlist'); - $playlist->type = ApplicationCommandOptionType::SUB_COMMAND_GROUP; - $playlist->options = [$play]; - - $resolved = $this->resolve([$playlist], $command); - - $this->assertNotNull($resolved); - $this->assertSame('title', $resolved[0]->name); - $this->assertSame('Track title', $resolved[0]->description); - $this->assertSame($focused, $resolved[1]); - } - - public function test_a_subcommand_the_definition_does_not_know_resolves_to_null(): void - { - $unknown = $this->option('not_a_group'); - $unknown->type = ApplicationCommandOptionType::SUB_COMMAND_GROUP; - $unknown->options = [$this->option('title', focused: true)]; - - $this->assertNull($this->resolve([$unknown], $this->command(MusicCommand::class))); - } -} diff --git a/tests/Unit/ConsoleLogHandlerTest.php b/tests/Unit/Logging/ConsoleLogHandlerTest.php similarity index 95% rename from tests/Unit/ConsoleLogHandlerTest.php rename to tests/Unit/Logging/ConsoleLogHandlerTest.php index 05a8a1b..211f31b 100644 --- a/tests/Unit/ConsoleLogHandlerTest.php +++ b/tests/Unit/Logging/ConsoleLogHandlerTest.php @@ -1,16 +1,17 @@ - */ - private function optionsOf(string $method): array - { - $reflector = new ClassReflector(OptionTypesCommand::class)->getMethod($method); - - /** @var Subcommand $subcommand */ - $subcommand = $reflector->getAttribute(Subcommand::class); - $subcommand->reflector = $reflector; - - return $subcommand->options; - } - - public static function supportedTypes(): array - { - return [ - 'string' => ['text', ApplicationCommandOptionType::STRING], - 'int' => ['count', ApplicationCommandOptionType::INTEGER], - 'float' => ['ratio', ApplicationCommandOptionType::NUMBER], - 'bool' => ['flag', ApplicationCommandOptionType::BOOLEAN], - 'User' => ['user', ApplicationCommandOptionType::USER], - 'Channel' => ['channel', ApplicationCommandOptionType::CHANNEL], - 'Role' => ['role', ApplicationCommandOptionType::ROLE], - ]; - } - - #[DataProvider('supportedTypes')] - public function test_it_maps_php_types_to_discord_option_types(string $parameter, ApplicationCommandOptionType $expected): void - { - $this->assertSame($expected, $this->optionsOf('all')[$parameter]->type); - } - - public function test_it_rejects_an_unsupported_type(): void - { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Command option type not supported'); - - $this->optionsOf('unsupported')['values']->type; - } - - public function test_it_rejects_an_untyped_parameter(): void - { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Command option does not have type'); - - $this->optionsOf('untyped')['whatever']->type; - } - - public function test_the_name_defaults_to_the_parameter_name(): void - { - $this->assertSame('text', $this->optionsOf('all')['text']->name); - } - - public function test_an_explicit_name_wins_over_the_parameter_name(): void - { - $this->assertSame('custom', $this->optionsOf('renamed')['original']->name); - } - - public function test_a_parameter_without_a_default_is_required(): void - { - $this->assertTrue($this->optionsOf('all')['text']->isRequired); - } - - public function test_it_builds_a_command_option(): void - { - $built = $this->optionsOf('all')['count']->build->get(); - - $this->assertSame('count', $built['name']); - $this->assertSame('an int', $built['description']); - $this->assertTrue($built['required']); - $this->assertSame(ApplicationCommandOptionType::INTEGER->value, $built['type']); - $this->assertFalse($built['autocomplete']); - } -} diff --git a/tests/Unit/Registries/CommandRegistrationTest.php b/tests/Unit/Registries/CommandRegistrationTest.php new file mode 100644 index 0000000..868072e --- /dev/null +++ b/tests/Unit/Registries/CommandRegistrationTest.php @@ -0,0 +1,161 @@ +add($this->definition($class)); + } + + return $registry; + } + + /** @return list */ + private function levels(array $outcomes): array + { + return array_map(static fn(Outcome $outcome) => $outcome->level, $outcomes); + } + + public function test_a_global_command_goes_to_the_global_endpoint(): void + { + $http = new RecordingHttp(); + + $this->registry(PingCommand::class)->register(new FakeDiscord($http)); + + $this->assertSame([self::GLOBAL_ENDPOINT], $http->postedUrls()); + } + + public function test_a_guild_command_goes_to_the_guild_endpoint(): void + { + $http = new RecordingHttp(); + + $this->registry(GuildAlphaCommand::class)->register(new FakeDiscord($http)); + + $this->assertSame([$this->guildEndpoint('111')], $http->postedUrls()); + } + + /** + * The original defect: guild commands were keyed by guild id alone, so a + * second command in the same guild silently replaced the first. + */ + public function test_two_commands_in_the_same_guild_are_both_registered(): void + { + $http = new RecordingHttp(); + + $this->registry(GuildAlphaCommand::class, GuildBetaCommand::class) + ->register(new FakeDiscord($http)); + + $this->assertSame( + [$this->guildEndpoint('111'), $this->guildEndpoint('111')], + $http->postedUrls(), + ); + } + + public function test_the_same_command_name_in_two_guilds_is_registered_in_each(): void + { + $http = new RecordingHttp(); + + $this->registry(GuildAlphaCommand::class, OtherGuildAlphaCommand::class) + ->register(new FakeDiscord($http)); + + $this->assertSame( + [$this->guildEndpoint('111'), $this->guildEndpoint('222')], + $http->postedUrls(), + ); + } + + public function test_a_guild_command_does_not_collide_with_the_global_command_of_the_same_name(): void + { + $http = new RecordingHttp(); + + $this->registry(GuildAlphaCommand::class, GlobalAlphaCommand::class) + ->register(new FakeDiscord($http)); + + $this->assertSame( + [$this->guildEndpoint('111'), self::GLOBAL_ENDPOINT], + $http->postedUrls(), + ); + } + + public function test_it_warns_instead_of_calling_discord_when_there_is_nothing_to_register(): void + { + $http = new RecordingHttp(); + + $outcomes = $this->registry()->register(new FakeDiscord($http)); + + $this->assertSame([OutcomeLevel::Warning], $this->levels($outcomes)); + $this->assertSame('No commands to register.', $outcomes[0]->message); + $this->assertSame([], $http->postedUrls()); + } + + public function test_a_failed_application_lookup_stops_before_registering_anything(): void + { + $http = new RecordingHttp(failApplicationLookup: true); + + $outcomes = $this->registry(PingCommand::class)->register(new FakeDiscord($http)); + + $this->assertSame([OutcomeLevel::Error], $this->levels($outcomes)); + $this->assertSame([], $http->postedUrls()); + } + + /** + * One command Discord rejects must not stop the rest from registering. + */ + public function test_a_rejected_command_is_reported_and_the_others_continue(): void + { + $http = new RecordingHttp(failPostsMatching: ['guilds/111']); + + $outcomes = $this->registry(GuildAlphaCommand::class, GlobalAlphaCommand::class) + ->register(new FakeDiscord($http)); + + $this->assertSame([OutcomeLevel::Error, OutcomeLevel::Success], $this->levels($outcomes)); + $this->assertStringContainsString('Command "alpha":', $outcomes[0]->message); + $this->assertSame('Command "alpha" registered globally.', $outcomes[1]->message); + } +} diff --git a/tests/Unit/Registries/CommandsRegistryTest.php b/tests/Unit/Registries/CommandsRegistryTest.php new file mode 100644 index 0000000..6b4f5e5 --- /dev/null +++ b/tests/Unit/Registries/CommandsRegistryTest.php @@ -0,0 +1,168 @@ + */ + private function stored(CommandsRegistry $registry): array + { + return new ReflectionProperty(CommandsRegistry::class, 'commands')->getValue($registry); + } + + /** @return list */ + private function messages(array $outcomes): array + { + return array_map(static fn(Outcome $outcome) => $outcome->message, $outcomes); + } + + public function test_it_stores_a_global_command_under_its_name(): void + { + $registry = $this->registry(); + $registry->add($this->definition(PingCommand::class)); + + $this->assertSame(['ping'], array_keys($this->stored($registry))); + } + + public function test_it_scopes_a_guild_command_by_its_guild(): void + { + $registry = $this->registry(); + $registry->add($this->definition(GuildAlphaCommand::class)); + + $this->assertSame(['111:alpha'], array_keys($this->stored($registry))); + } + + public function test_it_keeps_distinct_commands_side_by_side(): void + { + $registry = $this->registry(); + $registry->add($this->definition(PingCommand::class)); + $registry->add($this->definition(ModerationCommand::class)); + + $this->assertSame(['ping', 'moderation'], array_keys($this->stored($registry))); + } + + public function test_re_adding_the_same_command_name_merges_it(): void + { + $registry = $this->registry(); + $registry->add($this->definition(PingCommand::class)); + $registry->add($this->definition(PingCommand::class)); + + $stored = $this->stored($registry); + + $this->assertCount(1, $stored); + $this->assertSame(['name', 'times'], array_keys($stored['ping']->options)); + $this->assertSame(['ping'], array_keys($stored['ping']->handlers)); + } + + public function test_listen_binds_one_handler_per_interaction_path(): void + { + $registry = $this->registry(); + $registry->add($this->definition(MusicCommand::class)); + + $this->assertSame( + ['Command "music.playlist.play" listened.', 'Command "music.playlist.stop" listened.'], + $this->messages($registry->listen()), + ); + } + + public function test_listen_warns_when_there_is_nothing_to_bind(): void + { + $outcomes = $this->registry()->listen(); + + $this->assertCount(1, $outcomes); + $this->assertSame(OutcomeLevel::Warning, $outcomes[0]->level); + } + + /** + * The whole path, end to end: a compiled command is bound, an interaction + * arrives under the name Discord reports, and the method runs with the + * option the user supplied. + */ + public function test_an_interaction_reaches_the_command_it_names(): void + { + RecordingCommand::$calls = []; + + $extension = new AllCommandExtension(); + $registry = $this->registry($extension); + $registry->add($this->definition(RecordingCommand::class)); + $registry->listen(); + + $data = new InteractionData(); + $data->name = 'recording'; + + $subject = new ApplicationCommandInteractionDataOptionStructure(); + $subject->name = 'subject'; + $subject->type = ApplicationCommandOptionType::STRING; + $subject->value = 'end to end'; + $subject->options = []; + $data->options = [$subject]; + + $interaction = new InteractionCreate(); + $interaction->id = '1'; + $interaction->token = 'token'; + $interaction->data = $data; + + $extension->emit('recording', [ + new CommandInteraction($interaction, new FakeDiscord(new RecordingHttp())), + ]); + + $this->assertSame(['end to end'], RecordingCommand::$calls); + } + + public function test_a_bound_command_answers_its_own_interaction_path(): void + { + $extension = new AllCommandExtension(); + $registry = $this->registry($extension); + $registry->add($this->definition(ModerationCommand::class)); + $registry->listen(); + + $this->assertCount(1, $extension->listeners('moderation.kick')); + $this->assertCount(1, $extension->listeners('moderation.kick.autocomplete')); + } +} diff --git a/tests/Unit/Registries/EventsRegistryTest.php b/tests/Unit/Registries/EventsRegistryTest.php new file mode 100644 index 0000000..e762a18 --- /dev/null +++ b/tests/Unit/Registries/EventsRegistryTest.php @@ -0,0 +1,79 @@ +getAttribute(Event::class); + + return new EventCompiler()->compile($reflector, $attribute); + } + + private function registry(): EventsRegistry + { + return new EventsRegistry(new GenericContainer()); + } + + public function test_it_reports_nothing_when_no_listeners_were_discovered(): void + { + $this->assertSame([], $this->registry()->listen(new FakeDiscord(new RecordingHttp()))); + } + + public function test_it_attaches_a_listener_to_the_gateway(): void + { + $registry = $this->registry(); + $registry->add($this->definition(ReadyListener::class)); + + $discord = new FakeDiscord(new RecordingHttp()); + $outcomes = $registry->listen($discord); + + $this->assertSame( + ['Added listener for: READY'], + array_map(static fn(Outcome $outcome) => $outcome->message, $outcomes), + ); + + $payload = new \stdClass(); + $discord->gateway->events->emit('READY', [$payload]); + + $this->assertSame([$payload], ReadyListener::$received); + } + + public function test_several_listeners_for_one_event_all_fire(): void + { + $registry = $this->registry(); + $registry->add($this->definition(ReadyListener::class)); + $registry->add($this->definition(ReadyListener::class)); + + $discord = new FakeDiscord(new RecordingHttp()); + + $this->assertCount(2, $registry->listen($discord)); + + $discord->gateway->events->emit('READY', [new \stdClass()]); + + $this->assertCount(2, ReadyListener::$received); + } +} diff --git a/tests/Unit/Runtime/ArgumentResolverTest.php b/tests/Unit/Runtime/ArgumentResolverTest.php new file mode 100644 index 0000000..531c0bf --- /dev/null +++ b/tests/Unit/Runtime/ArgumentResolverTest.php @@ -0,0 +1,122 @@ +name = $name; + $option->type = $type; + $option->value = $value; + $option->options = $children; + + return $option; + } + + private function interaction(string $command, array $options): CommandInteraction + { + $data = new InteractionData(); + $data->name = $command; + $data->options = $options; + + $interaction = new InteractionCreate(); + $interaction->id = '1'; + $interaction->token = 'token'; + $interaction->guild_id = '999'; + $interaction->data = $data; + + return new CommandInteraction($interaction, new FakeDiscord(new RecordingHttp())); + } + + private function handler(string $class, string $path): HandlerDefinition + { + return $this->definition($class)->handlers[$path]; + } + + public function test_it_orders_arguments_to_the_method_signature(): void + { + $handler = $this->handler(PingCommand::class, 'ping'); + $interaction = $this->interaction('ping', [ + $this->option('times', 3, type: ApplicationCommandOptionType::INTEGER), + $this->option('name', 'Ada'), + ]); + + $arguments = $this->resolver()->resolve($handler, $interaction); + + $this->assertSame([$interaction, 'Ada', 3], $arguments); + } + + public function test_an_omitted_optional_option_falls_back_to_its_default(): void + { + $handler = $this->handler(PingCommand::class, 'ping'); + $interaction = $this->interaction('ping', [$this->option('name', 'Ada')]); + + $this->assertSame([$interaction, 'Ada', 1], $this->resolver()->resolve($handler, $interaction)); + } + + /** + * An option renamed with #[Option(name: ...)] used to be keyed by the name + * Discord knows it under, which never matched the parameter it belonged to, + * so the handler was unreachable. + */ + public function test_a_renamed_option_still_reaches_its_parameter(): void + { + $handler = $this->handler(OptionTypesCommand::class, 'option_types.renamed'); + $interaction = $this->interaction('option_types', [ + $this->option('renamed', null, [$this->option('custom', 'value')], ApplicationCommandOptionType::SUB_COMMAND), + ]); + + $this->assertSame(['value'], $this->resolver()->resolve($handler, $interaction)); + } + + public function test_it_reads_options_nested_under_a_group_and_subcommand(): void + { + $handler = $this->handler(MusicCommand::class, 'music.playlist.play'); + $interaction = $this->interaction('music', [ + $this->option('playlist', null, [ + $this->option('play', null, [ + $this->option('title', 'Bohemian Rhapsody'), + ], ApplicationCommandOptionType::SUB_COMMAND), + ], ApplicationCommandOptionType::SUB_COMMAND_GROUP), + ]); + + $this->assertSame(['Bohemian Rhapsody'], $this->resolver()->resolve($handler, $interaction)); + } + + public function test_a_missing_required_option_is_reported_against_the_parameter(): void + { + $handler = $this->handler(MusicCommand::class, 'music.playlist.play'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing required parameter: title for command "music.playlist.play"'); + + $this->resolver()->resolve($handler, $this->interaction('music', [])); + } +} diff --git a/tests/Unit/Runtime/AutocompleteResponderTest.php b/tests/Unit/Runtime/AutocompleteResponderTest.php new file mode 100644 index 0000000..a4f720f --- /dev/null +++ b/tests/Unit/Runtime/AutocompleteResponderTest.php @@ -0,0 +1,162 @@ +http = new RecordingHttp(); + } + + private function option( + string $name, + mixed $value, + bool $focused = false, + array $children = [], + ApplicationCommandOptionType $type = ApplicationCommandOptionType::STRING, + ): ApplicationCommandInteractionDataOptionStructure { + $option = new ApplicationCommandInteractionDataOptionStructure(); + $option->name = $name; + $option->type = $type; + $option->value = $value; + $option->options = $children; + $option->focused = $focused; + + return $option; + } + + private function interaction(string $command, array $options): CommandInteraction + { + $data = new InteractionData(); + $data->name = $command; + $data->options = $options; + + $interaction = new InteractionCreate(); + $interaction->id = '1'; + $interaction->token = 'token'; + $interaction->data = $data; + + return new CommandInteraction($interaction, new FakeDiscord($this->http)); + } + + private function handler(string $class, string $path): HandlerDefinition + { + return $this->definition($class)->handlers[$path]; + } + + private function respond(HandlerDefinition $handler, CommandInteraction $interaction): void + { + new AutocompleteResponder(new ChoiceFactory())->respond($handler, $interaction); + } + + /** + * @return list each choice as a name/value pair + */ + private function sentChoices(): array + { + return array_map( + static fn(ApplicationCommandOptionChoice $choice) => [$choice->name, $choice->value], + $this->http->posts[0]['content']['data']['choices'] ?? [], + ); + } + + public function test_it_answers_the_focused_option_with_its_suggestions(): void + { + $this->respond( + $this->handler(SearchCommand::class, 'search'), + $this->interaction('search', [$this->option('query', 'a', focused: true)]), + ); + + $this->assertSame(['interactions/1/token/callback'], $this->http->postedUrls()); + $this->assertSame( + InteractionCallbackType::APPLICATION_COMMAND_AUTOCOMPLETE_RESULT->value, + $this->http->posts[0]['content']['type'], + ); + $this->assertSame( + [['alpha', 'alpha'], ['beta', 'beta'], ['gamma', 'gamma']], + $this->sentChoices(), + ); + } + + /** + * Discord sends autocomplete interactions while the user is still typing, + * and they can arrive with nothing focused at all. + */ + public function test_an_interaction_with_nothing_focused_is_ignored(): void + { + $this->respond( + $this->handler(SearchCommand::class, 'search'), + $this->interaction('search', [$this->option('query', 'a')]), + ); + + $this->assertSame([], $this->http->posts); + } + + public function test_an_interaction_with_no_options_at_all_is_ignored(): void + { + $this->respond( + $this->handler(SearchCommand::class, 'search'), + $this->interaction('search', []), + ); + + $this->assertSame([], $this->http->posts); + } + + public function test_a_focused_option_the_handler_does_not_declare_is_ignored(): void + { + $this->respond( + $this->handler(SearchCommand::class, 'search'), + $this->interaction('search', [$this->option('not_declared', 'a', focused: true)]), + ); + + $this->assertSame([], $this->http->posts); + } + + public function test_a_focused_option_without_an_autocomplete_is_ignored(): void + { + $this->respond( + $this->handler(SearchCommand::class, 'search'), + $this->interaction('search', [$this->option('note', 'a', focused: true)]), + ); + + $this->assertSame([], $this->http->posts); + } + + public function test_it_finds_an_option_focused_under_a_group_and_subcommand(): void + { + $this->respond( + $this->handler(MusicCommand::class, 'music.playlist.play'), + $this->interaction('music', [ + $this->option('playlist', null, children: [ + $this->option('play', null, children: [ + $this->option('title', 'boh', focused: true), + ], type: ApplicationCommandOptionType::SUB_COMMAND), + ], type: ApplicationCommandOptionType::SUB_COMMAND_GROUP), + ]), + ); + + // MusicCommand declares no autocomplete, so it resolves and then stops. + $this->assertSame([], $this->http->posts); + } +} diff --git a/tests/Unit/AutocompleteChoicesTest.php b/tests/Unit/Runtime/ChoiceFactoryTest.php similarity index 86% rename from tests/Unit/AutocompleteChoicesTest.php rename to tests/Unit/Runtime/ChoiceFactoryTest.php index e560ad4..cbef4df 100644 --- a/tests/Unit/AutocompleteChoicesTest.php +++ b/tests/Unit/Runtime/ChoiceFactoryTest.php @@ -1,25 +1,23 @@ */ private function toChoices(mixed $value): array { - return new ReflectionMethod(CommandsRegistry::class, 'toChoices') - ->invoke(new CommandsRegistry(new AllCommandExtension()), $value); + return new ChoiceFactory()->from($value); } /** @return list */ diff --git a/tests/Unit/Runtime/CommandDispatcherTest.php b/tests/Unit/Runtime/CommandDispatcherTest.php new file mode 100644 index 0000000..dc6647e --- /dev/null +++ b/tests/Unit/Runtime/CommandDispatcherTest.php @@ -0,0 +1,98 @@ +logger = new RecordingLogger(); + } + + private function dispatcher(): CommandDispatcher + { + return new CommandDispatcher( + new ArgumentResolver(new OptionValueResolver(new FakeDiscord(new RecordingHttp()))), + new GenericContainer(), + $this->logger, + ); + } + + private function interaction(string $command, array $options): CommandInteraction + { + $data = new InteractionData(); + $data->name = $command; + $data->options = $options; + + $interaction = new InteractionCreate(); + $interaction->id = '1'; + $interaction->token = 'token'; + $interaction->data = $data; + + return new CommandInteraction($interaction, new FakeDiscord(new RecordingHttp())); + } + + private function option(string $name, mixed $value): ApplicationCommandInteractionDataOptionStructure + { + $option = new ApplicationCommandInteractionDataOptionStructure(); + $option->name = $name; + $option->type = ApplicationCommandOptionType::STRING; + $option->value = $value; + $option->options = []; + + return $option; + } + + private function handler(string $class, string $path): HandlerDefinition + { + return $this->definition($class)->handlers[$path]; + } + + public function test_it_invokes_the_command_with_its_resolved_arguments(): void + { + $this->dispatcher()->dispatch( + $this->handler(RecordingCommand::class, 'recording'), + $this->interaction('recording', [$this->option('subject', 'world')]), + ); + + $this->assertSame(['world'], RecordingCommand::$calls); + $this->assertSame([], $this->logger->messages); + } + + /** + * A command that throws must be logged rather than allowed to take the + * gateway connection down with it. + */ + public function test_a_throwing_command_is_logged_and_contained(): void + { + $this->dispatcher()->dispatch( + $this->handler(ThrowingCommand::class, 'throwing'), + $this->interaction('throwing', []), + ); + + $this->assertCount(1, $this->logger->messages); + $this->assertStringContainsString('Command "throwing" failed: nope', $this->logger->messages[0]); + } +} diff --git a/tests/Unit/Runtime/OptionValueResolverTest.php b/tests/Unit/Runtime/OptionValueResolverTest.php new file mode 100644 index 0000000..0d72170 --- /dev/null +++ b/tests/Unit/Runtime/OptionValueResolverTest.php @@ -0,0 +1,127 @@ +name = 'subject'; + $option->type = $type; + $option->value = $value; + $option->options = []; + + return $option; + } + + private function interaction(FakeDiscord $discord): CommandInteraction + { + $data = new InteractionData(); + $data->name = 'whatever'; + $data->options = []; + + $interaction = new InteractionCreate(); + $interaction->id = '1'; + $interaction->token = 'token'; + $interaction->guild_id = '999'; + $interaction->data = $data; + + return new CommandInteraction($interaction, $discord); + } + + public function test_a_missing_option_resolves_to_null(): void + { + $http = new RecordingHttp(); + $discord = new FakeDiscord($http); + + $this->assertNull( + new OptionValueResolver($discord)->resolve(null, $this->interaction($discord)), + ); + $this->assertSame([], $http->gets); + } + + public function test_a_scalar_option_is_passed_through_without_touching_discord(): void + { + $http = new RecordingHttp(); + $discord = new FakeDiscord($http); + + $this->assertSame( + 'hello', + new OptionValueResolver($discord)->resolve( + $this->option(ApplicationCommandOptionType::STRING, 'hello'), + $this->interaction($discord), + ), + ); + $this->assertSame([], $http->gets); + } + + /** + * A user option used to be fetched twice: once in a standalone if-block + * whose result was discarded, then again in the match below it. + */ + public function test_a_user_option_is_fetched_exactly_once(): void + { + $http = new RecordingHttp(); + $discord = new FakeDiscord($http); + + new OptionValueResolver($discord)->resolve( + $this->option(ApplicationCommandOptionType::USER, '77'), + $this->interaction($discord), + ); + + $this->assertSame(['users/77'], $http->gets); + } + + public function test_a_channel_option_is_fetched_once(): void + { + $http = new RecordingHttp(); + $discord = new FakeDiscord($http); + + new OptionValueResolver($discord)->resolve( + $this->option(ApplicationCommandOptionType::CHANNEL, '88'), + $this->interaction($discord), + ); + + $this->assertSame(['channels/88'], $http->gets); + } + + public function test_a_role_option_is_looked_up_against_the_interactions_guild(): void + { + $http = new RecordingHttp(); + $discord = new FakeDiscord($http); + + new OptionValueResolver($discord)->resolve( + $this->option(ApplicationCommandOptionType::ROLE, '66'), + $this->interaction($discord), + ); + + $this->assertSame(['guilds/999/roles/66'], $http->gets); + } + + public function test_a_mentionable_option_is_reported_as_unsupported(): void + { + $discord = new FakeDiscord(new RecordingHttp()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Mentionable options are not supported yet'); + + new OptionValueResolver($discord)->resolve( + $this->option(ApplicationCommandOptionType::MENTIONABLE, '1'), + $this->interaction($discord), + ); + } +} diff --git a/tests/Unit/SubcommandTest.php b/tests/Unit/SubcommandTest.php deleted file mode 100644 index db1b466..0000000 --- a/tests/Unit/SubcommandTest.php +++ /dev/null @@ -1,75 +0,0 @@ -getAttribute(SubcommandGroup::class); - $group->reflector = $reflector; - - return $group; - } - - public function test_a_group_collects_only_annotated_methods(): void - { - $this->assertSame(['play', 'stop'], array_keys($this->group()->options)); - } - - public function test_a_group_builds_a_sub_command_group_option(): void - { - $built = $this->group()->build->get(); - - $this->assertSame('playlist', $built['name']); - $this->assertSame('Playlist controls', $built['description']); - $this->assertSame(ApplicationCommandOptionType::SUB_COMMAND_GROUP->value, $built['type']); - $this->assertCount(2, $built['options']); - } - - public function test_a_subcommand_builds_a_sub_command_option_carrying_its_own_options(): void - { - $built = $this->group()->options['play']->build->get(); - - $this->assertSame('play', $built['name']); - $this->assertSame(ApplicationCommandOptionType::SUB_COMMAND->value, $built['type']); - $this->assertCount(1, $built['options']); - $this->assertSame('title', $built['options'][0]['name']); - } - - public function test_a_subcommand_without_parameters_has_no_options(): void - { - $this->assertSame([], $this->group()->options['stop']->options); - } - - public function test_invoke_named_args_reorders_arguments_to_the_signature(): void - { - $result = $this->group()->options['play']->invokeNamedArgs( - new MusicCommand(), - ['unused' => 'ignored', 'title' => 'Bohemian Rhapsody'], - ); - - $this->assertSame('playing Bohemian Rhapsody', $result); - } - - public function test_invoke_named_args_rejects_a_missing_required_argument(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing required parameter: title'); - - $this->group()->options['play']->invokeNamedArgs(new MusicCommand(), []); - } -} diff --git a/tests/Unit/TempcordTest.php b/tests/Unit/TempcordTest.php new file mode 100644 index 0000000..da6f792 --- /dev/null +++ b/tests/Unit/TempcordTest.php @@ -0,0 +1,82 @@ +discord = new FakeDiscord(new RecordingHttp()); + $this->commands = new CommandsRegistry( + extension: new AllCommandExtension(), + builders: new CommandBuilderFactory(), + dispatcher: new CommandDispatcher( + new ArgumentResolver(new OptionValueResolver($this->discord)), + new GenericContainer(), + new NullLogger(), + ), + autocomplete: new AutocompleteResponder(new ChoiceFactory()), + ); + $this->events = new EventsRegistry(new GenericContainer()); + } + + private function tempcord(): Tempcord + { + return new Tempcord($this->discord, $this->commands, $this->events); + } + + public function test_it_registers_the_command_extension_once_the_gateway_is_ready(): void + { + $tempcord = $this->tempcord(); + + $this->assertFalse($tempcord->booted); + + $this->discord->gateway->events->emit(Events::READY, [new Ready()]); + + $this->assertTrue($tempcord->booted); + } + + /** + * Listening covers both registries, so a bot with commands and events gets + * one report describing everything that was wired up. + */ + public function test_listening_reports_commands_and_events_together(): void + { + $this->commands->add($this->definition(ModerationCommand::class)); + + $messages = array_map( + static fn(Outcome $outcome) => $outcome->message, + $this->tempcord()->listen(), + ); + + $this->assertSame(['Command "moderation.kick" listened.'], $messages); + } +} diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php index f48a102..a5c8354 100644 --- a/tests/Unit/TestCase.php +++ b/tests/Unit/TestCase.php @@ -4,22 +4,22 @@ use PHPUnit\Framework\TestCase as BaseTestCase; use Tempcord\Attributes\Command; +use Tempcord\Compiler\CommandCompiler; +use Tempcord\Definitions\CommandDefinition; use Tempest\Reflection\ClassReflector; abstract class TestCase extends BaseTestCase { /** - * Resolves the #[Command] attribute off a fixture class the same way - * CommandsDiscovery does: read the attribute, then attach the reflector. + * Compiles a fixture the same way CommandsDiscovery does. */ - protected function command(string $class): Command + protected function definition(string $class): CommandDefinition { $reflector = new ClassReflector($class); - /** @var Command $command */ - $command = $reflector->getAttribute(Command::class); - $command->reflector = $reflector; + /** @var Command $attribute */ + $attribute = $reflector->getAttribute(Command::class); - return $command; + return new CommandCompiler()->compile($reflector, $attribute); } } From 8221504cf318c071559f6ee334c8a0052470ca6b Mon Sep 17 00:00:00 2001 From: Vladyslav Gaysyuk Date: Sat, 22 Aug 2026 13:33:58 +0200 Subject: [PATCH 06/11] Register commands in one request per scope, and apply declared permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes that come from the same place. Registration used to POST each command individually. That costs a request per command, and Discord's create endpoint only ever adds: a command deleted from the code stayed registered forever and users kept seeing it. Registration now groups commands into the sets Discord replaces atomically — the global set, and one set per guild — and sends each as a single PUT. Anything absent from the set is removed, which is what makes a deleted command actually disappear. #[Command(permissions: [])] was collected all the way into CommandDefinition and then never read, so setting it did nothing. It is now typed as Fenrir's Permission enum rather than a list of strings, and reaches the payload. Two things are worth explaining rather than leaving to be discovered: The permission bit field is written onto the payload directly instead of going through CommandBuilder::setDefaultMemberPermissions, which sends the binary representation of the bit field where Discord expects the decimal one. Asking for ADMINISTRATOR that way grants five permissions nobody asked for. Fixed upstream in dc-Ragnarok/Fenrir#134; the direct write is correct either way, so it does not need reverting when that lands. Fenrir has no bulk overwrite method yet and keeps its HTTP client private on both Discord and Rest, so CommandRegistrar builds one for itself from the token already in TempcordConfig. Registration runs once, before the gateway opens, so the two clients never compete for a rate limit bucket. The method is added in dc-Ragnarok/Fenrir#133 and this can move over to it once released. --- src/Attributes/Command.php | 4 +- src/Definitions/CommandDefinition.php | 3 +- src/Discord/CommandBuilderFactory.php | 24 +++ src/Registries/CommandsRegistry.php | 49 +----- src/Runtime/CommandRegistrar.php | 131 ++++++++++++++ tests/Doubles/RecordingHttp.php | 24 +++ tests/Fixtures/RestrictedCommand.php | 15 ++ .../Discoveries/CommandsDiscoveryTest.php | 10 +- .../Registries/CommandRegistrationTest.php | 160 ++++++++++-------- .../Unit/Registries/CommandsRegistryTest.php | 10 +- tests/Unit/TempcordTest.php | 10 +- 11 files changed, 319 insertions(+), 121 deletions(-) create mode 100644 src/Runtime/CommandRegistrar.php create mode 100644 tests/Fixtures/RestrictedCommand.php diff --git a/src/Attributes/Command.php b/src/Attributes/Command.php index d3e6e90..e8fe891 100644 --- a/src/Attributes/Command.php +++ b/src/Attributes/Command.php @@ -5,6 +5,7 @@ use Attribute; use BackedEnum; use Ragnarok\Fenrir\Enums\ApplicationCommandTypes; +use Ragnarok\Fenrir\Enums\Permission; /** * Declares a class as a Discord application command. @@ -24,7 +25,8 @@ /** * @param string|BackedEnum|null $name defaults to the class name, with a * Command prefix or suffix stripped and the rest snake_cased - * @param list $permissions + * @param list $permissions the permissions a member needs by + * default; an empty list leaves the command unrestricted */ public function __construct( public string|BackedEnum|null $name = null, diff --git a/src/Definitions/CommandDefinition.php b/src/Definitions/CommandDefinition.php index 1981f54..6b0206d 100644 --- a/src/Definitions/CommandDefinition.php +++ b/src/Definitions/CommandDefinition.php @@ -3,6 +3,7 @@ namespace Tempcord\Definitions; use Ragnarok\Fenrir\Enums\ApplicationCommandTypes; +use Ragnarok\Fenrir\Enums\Permission; /** * A fully resolved command: everything the framework needs to register it with @@ -15,7 +16,7 @@ * @param array $options * the command's direct children, in the shape Discord expects * @param array $handlers keyed by dotted interaction path - * @param list $permissions + * @param list $permissions */ public function __construct( public string $name, diff --git a/src/Discord/CommandBuilderFactory.php b/src/Discord/CommandBuilderFactory.php index d0d603f..4eabe63 100644 --- a/src/Discord/CommandBuilderFactory.php +++ b/src/Discord/CommandBuilderFactory.php @@ -2,6 +2,7 @@ namespace Tempcord\Discord; +use Ragnarok\Fenrir\Bitwise\Bitwise; use Ragnarok\Fenrir\Enums\ApplicationCommandOptionType; use Ragnarok\Fenrir\Enums\ApplicationCommandTypes; use Ragnarok\Fenrir\Rest\Helpers\Command\CommandBuilder; @@ -19,6 +20,29 @@ */ final readonly class CommandBuilderFactory { + /** + * The payload for one command, ready to send. + * + * Default permissions are written here rather than through the builder's + * setDefaultMemberPermissions, which sends the binary representation of the + * bit field where Discord expects the decimal one. Asking for ADMINISTRATOR + * that way grants five permissions nobody asked for. + * + * @see https://github.com/dc-Ragnarok/Fenrir/pull/134 + * + * @return array + */ + public function payloadFor(CommandDefinition $command): array + { + $payload = $this->forCommand($command)->get(); + + if ($command->permissions !== []) { + $payload['default_member_permissions'] = (string) Bitwise::from(...$command->permissions)->get(); + } + + return $payload; + } + public function forCommand(CommandDefinition $command): CommandBuilder { $builder = CommandBuilder::new() diff --git a/src/Registries/CommandsRegistry.php b/src/Registries/CommandsRegistry.php index 4ab82e7..81ec7ee 100644 --- a/src/Registries/CommandsRegistry.php +++ b/src/Registries/CommandsRegistry.php @@ -7,13 +7,11 @@ use Tempcord\Definitions\CommandDefinition; use Tempcord\Definitions\HandlerDefinition; use Tempcord\Discord\AllCommandExtension; -use Tempcord\Discord\CommandBuilderFactory; use Tempcord\Runtime\AutocompleteResponder; use Tempcord\Runtime\CommandDispatcher; +use Tempcord\Runtime\CommandRegistrar; use Tempcord\Runtime\Outcome; use Tempest\Container\Singleton; -use Throwable; -use function React\Async\await; /** * Holds every compiled command and wires it up: registering it with Discord and @@ -27,7 +25,7 @@ final class CommandsRegistry public function __construct( public readonly AllCommandExtension $extension, - private readonly CommandBuilderFactory $builders, + private readonly CommandRegistrar $registrar, private readonly CommandDispatcher $dispatcher, private readonly AutocompleteResponder $autocomplete, ) {} @@ -42,52 +40,13 @@ public function add(CommandDefinition $command): void } /** - * Pushes every command to Discord, reporting on each as it goes. + * Pushes every command to Discord, reporting on each scope as it goes. * * @return list */ public function register(Discord $discord): array { - if ($this->commands === []) { - return [Outcome::warning('No commands to register.')]; - } - - try { - $application = await($discord->rest->application->getCurrent()); - } catch (Throwable $throwable) { - return [Outcome::error($throwable->getMessage())]; - } - - $outcomes = []; - - foreach ($this->commands as $command) { - try { - /* - * Guild commands go to a different endpoint that additionally - * takes the guild id, so the two cannot share a call. - */ - await($command->isGlobal() - ? $discord->rest->globalCommand->createApplicationCommand( - $application->id, - $this->builders->forCommand($command), - ) - : $discord->rest->guildCommand->createApplicationCommand( - $application->id, - $command->guildId, - $this->builders->forCommand($command), - )); - - $outcomes[] = Outcome::success($command->isGlobal() - ? 'Command "' . $command->name . '" registered globally.' - : 'Command "' . $command->name . '" registered in guild ' . $command->guildId . '.'); - } catch (Throwable $throwable) { - $outcomes[] = Outcome::error( - 'Command "' . $command->name . '": ' . $throwable->getMessage(), - ); - } - } - - return $outcomes; + return $this->registrar->register($discord, $this->commands); } /** diff --git a/src/Runtime/CommandRegistrar.php b/src/Runtime/CommandRegistrar.php new file mode 100644 index 0000000..94d6cb3 --- /dev/null +++ b/src/Runtime/CommandRegistrar.php @@ -0,0 +1,131 @@ + $commands + * + * @return list + */ + public function register(Discord $discord, array $commands): array + { + if ($commands === []) { + return [Outcome::warning('No commands to register.')]; + } + + try { + $application = await($discord->rest->application->getCurrent()); + } catch (Throwable $throwable) { + return [Outcome::error($throwable->getMessage())]; + } + + $http = $this->http ?? $this->buildHttp(); + $outcomes = []; + + foreach ($this->byScope($commands) as $guildId => $scoped) { + $outcomes[] = $this->overwrite( + $http, + $application->id, + $guildId === '' ? null : (string) $guildId, + $scoped, + ); + } + + return $outcomes; + } + + /** + * Commands grouped into the sets Discord replaces atomically: the global + * set, and one set per guild. + * + * @param array $commands + * + * @return array> + */ + private function byScope(array $commands): array + { + $scopes = []; + + foreach ($commands as $command) { + $scopes[$command->guildId ?? ''][] = $command; + } + + return $scopes; + } + + /** + * @param list $commands + */ + private function overwrite(Http $http, string $applicationId, ?string $guildId, array $commands): Outcome + { + $scope = $guildId === null ? 'globally' : 'in guild ' . $guildId; + + try { + await($http->put( + $guildId === null + ? Endpoint::bind(Endpoint::GLOBAL_APPLICATION_COMMANDS, $applicationId) + : Endpoint::bind(Endpoint::GUILD_APPLICATION_COMMANDS, $applicationId, $guildId), + array_map( + fn(CommandDefinition $command) => $this->builders->payloadFor($command), + $commands, + ), + )); + } catch (Throwable $throwable) { + return Outcome::error('Registering ' . count($commands) . ' commands ' . $scope . ': ' . $throwable->getMessage()); + } + + return Outcome::success('Registered ' . count($commands) . ' commands ' . $scope . '.'); + } + + /** + * Fenrir keeps its own HTTP client private and has no bulk overwrite method + * yet, so registration uses a client of its own. It runs once, before the + * gateway opens, so the two never compete for a rate limit bucket. + * + * @see https://github.com/dc-Ragnarok/Fenrir/pull/133 + */ + private function buildHttp(): Http + { + $loop = Loop::get(); + + return new Http( + TokenType::BOT->value . ' ' . $this->config->token, + $loop, + $this->logger, + new React($loop), + ); + } +} diff --git a/tests/Doubles/RecordingHttp.php b/tests/Doubles/RecordingHttp.php index 099e752..fec9fe2 100644 --- a/tests/Doubles/RecordingHttp.php +++ b/tests/Doubles/RecordingHttp.php @@ -20,6 +20,9 @@ final class RecordingHttp extends Http /** @var list */ public array $gets = []; + /** @var list */ + public array $puts = []; + public function __construct( private readonly bool $failApplicationLookup = false, private readonly array $failPostsMatching = [], @@ -56,9 +59,30 @@ public function post($url, $content = null, array $headers = []): PromiseInterfa return resolve((object) ['id' => '1', 'name' => 'registered']); } + public function put($url, $content = null, array $headers = []): PromiseInterface + { + $url = (string) $url; + + $this->puts[] = ['url' => $url, 'content' => $content]; + + foreach ($this->failPostsMatching as $needle) { + if (str_contains($url, $needle)) { + return reject(new RuntimeException('50035: Invalid Form Body')); + } + } + + return resolve([]); + } + /** @return list */ public function postedUrls(): array { return array_column($this->posts, 'url'); } + + /** @return list */ + public function putUrls(): array + { + return array_column($this->puts, 'url'); + } } diff --git a/tests/Fixtures/RestrictedCommand.php b/tests/Fixtures/RestrictedCommand.php new file mode 100644 index 0000000..6030227 --- /dev/null +++ b/tests/Fixtures/RestrictedCommand.php @@ -0,0 +1,15 @@ +http = new RecordingHttp(); + } + private function guildEndpoint(string $guildId): string { return 'applications/424242/guilds/' . $guildId . '/commands'; } - private function registry(string ...$commandClasses): CommandsRegistry + /** + * @return list + */ + private function register(string ...$commandClasses): array { - $discord = new FakeDiscord(new RecordingHttp()); - - $registry = new CommandsRegistry( - extension: new AllCommandExtension(), - builders: new CommandBuilderFactory(), - dispatcher: new CommandDispatcher( - new ArgumentResolver(new OptionValueResolver($discord)), - new GenericContainer(), - new NullLogger(), - ), - autocomplete: new AutocompleteResponder(new ChoiceFactory()), - ); + $commands = []; foreach ($commandClasses as $class) { - $registry->add($this->definition($class)); + $definition = $this->definition($class); + $commands[$definition->key()] = $definition; } - return $registry; + return new CommandRegistrar( + new CommandBuilderFactory(), + new TempcordConfig('::token::', new Bitwise()), + new NullLogger(), + $this->http, + )->register(new FakeDiscord($this->http), $commands); } /** @return list */ @@ -62,100 +64,116 @@ private function levels(array $outcomes): array return array_map(static fn(Outcome $outcome) => $outcome->level, $outcomes); } - public function test_a_global_command_goes_to_the_global_endpoint(): void + public function test_global_commands_go_to_the_global_endpoint(): void { - $http = new RecordingHttp(); + $this->register(PingCommand::class); - $this->registry(PingCommand::class)->register(new FakeDiscord($http)); - - $this->assertSame([self::GLOBAL_ENDPOINT], $http->postedUrls()); + $this->assertSame([self::GLOBAL_ENDPOINT], $this->http->putUrls()); } public function test_a_guild_command_goes_to_the_guild_endpoint(): void { - $http = new RecordingHttp(); - - $this->registry(GuildAlphaCommand::class)->register(new FakeDiscord($http)); + $this->register(GuildAlphaCommand::class); - $this->assertSame([$this->guildEndpoint('111')], $http->postedUrls()); + $this->assertSame([$this->guildEndpoint('111')], $this->http->putUrls()); } /** - * The original defect: guild commands were keyed by guild id alone, so a - * second command in the same guild silently replaced the first. + * Discord replaces a whole scope at once, so every command in a guild + * belongs to the same request rather than one request each. */ - public function test_two_commands_in_the_same_guild_are_both_registered(): void + public function test_commands_in_one_guild_are_sent_as_a_single_set(): void { - $http = new RecordingHttp(); + $this->register(GuildAlphaCommand::class, GuildBetaCommand::class); - $this->registry(GuildAlphaCommand::class, GuildBetaCommand::class) - ->register(new FakeDiscord($http)); + $this->assertSame([$this->guildEndpoint('111')], $this->http->putUrls()); + $this->assertCount(2, $this->http->puts[0]['content']); + $this->assertSame(['alpha', 'beta'], array_column($this->http->puts[0]['content'], 'name')); + } + + public function test_each_guild_gets_its_own_request(): void + { + $this->register(GuildAlphaCommand::class, OtherGuildAlphaCommand::class); $this->assertSame( - [$this->guildEndpoint('111'), $this->guildEndpoint('111')], - $http->postedUrls(), + [$this->guildEndpoint('111'), $this->guildEndpoint('222')], + $this->http->putUrls(), ); } - public function test_the_same_command_name_in_two_guilds_is_registered_in_each(): void + public function test_a_guild_command_does_not_collide_with_the_global_command_of_the_same_name(): void { - $http = new RecordingHttp(); - - $this->registry(GuildAlphaCommand::class, OtherGuildAlphaCommand::class) - ->register(new FakeDiscord($http)); + $this->register(GuildAlphaCommand::class, GlobalAlphaCommand::class); $this->assertSame( - [$this->guildEndpoint('111'), $this->guildEndpoint('222')], - $http->postedUrls(), + [$this->guildEndpoint('111'), self::GLOBAL_ENDPOINT], + $this->http->putUrls(), ); } - public function test_a_guild_command_does_not_collide_with_the_global_command_of_the_same_name(): void + /** + * The payload is a bare list of command objects; keys would make Discord + * read it as an object rather than an array. + */ + public function test_the_payload_is_a_list_of_command_objects(): void { - $http = new RecordingHttp(); + $this->register(GuildAlphaCommand::class, GuildBetaCommand::class); - $this->registry(GuildAlphaCommand::class, GlobalAlphaCommand::class) - ->register(new FakeDiscord($http)); + $sent = $this->http->puts[0]['content']; + + $this->assertSame([0, 1], array_keys($sent)); + $this->assertArrayHasKey('description', $sent[0]); + } + + /** + * Discord reads default_member_permissions as a decimal bit field. Fenrir's + * setDefaultMemberPermissions sends the binary representation, so the + * payload is written directly instead. + */ + public function test_permissions_are_sent_as_a_decimal_bit_field(): void + { + $this->register(RestrictedCommand::class); + + $sent = $this->http->puts[0]['content'][0]; $this->assertSame( - [$this->guildEndpoint('111'), self::GLOBAL_ENDPOINT], - $http->postedUrls(), + (string) (Permission::KICK_MEMBERS->value | Permission::BAN_MEMBERS->value), + $sent['default_member_permissions'], ); } - public function test_it_warns_instead_of_calling_discord_when_there_is_nothing_to_register(): void + public function test_a_command_without_permissions_is_left_unrestricted(): void { - $http = new RecordingHttp(); + $this->register(PingCommand::class); + + $this->assertArrayNotHasKey('default_member_permissions', $this->http->puts[0]['content'][0]); + } - $outcomes = $this->registry()->register(new FakeDiscord($http)); + public function test_it_warns_instead_of_calling_discord_when_there_is_nothing_to_register(): void + { + $outcomes = $this->register(); $this->assertSame([OutcomeLevel::Warning], $this->levels($outcomes)); - $this->assertSame('No commands to register.', $outcomes[0]->message); - $this->assertSame([], $http->postedUrls()); + $this->assertSame([], $this->http->putUrls()); } public function test_a_failed_application_lookup_stops_before_registering_anything(): void { - $http = new RecordingHttp(failApplicationLookup: true); - - $outcomes = $this->registry(PingCommand::class)->register(new FakeDiscord($http)); + $this->http = new RecordingHttp(failApplicationLookup: true); - $this->assertSame([OutcomeLevel::Error], $this->levels($outcomes)); - $this->assertSame([], $http->postedUrls()); + $this->assertSame([OutcomeLevel::Error], $this->levels($this->register(PingCommand::class))); + $this->assertSame([], $this->http->putUrls()); } /** - * One command Discord rejects must not stop the rest from registering. + * One scope Discord rejects must not stop the others from registering. */ - public function test_a_rejected_command_is_reported_and_the_others_continue(): void + public function test_a_rejected_scope_is_reported_and_the_others_continue(): void { - $http = new RecordingHttp(failPostsMatching: ['guilds/111']); + $this->http = new RecordingHttp(failPostsMatching: ['guilds/111']); - $outcomes = $this->registry(GuildAlphaCommand::class, GlobalAlphaCommand::class) - ->register(new FakeDiscord($http)); + $outcomes = $this->register(GuildAlphaCommand::class, GlobalAlphaCommand::class); $this->assertSame([OutcomeLevel::Error, OutcomeLevel::Success], $this->levels($outcomes)); - $this->assertStringContainsString('Command "alpha":', $outcomes[0]->message); - $this->assertSame('Command "alpha" registered globally.', $outcomes[1]->message); } } diff --git a/tests/Unit/Registries/CommandsRegistryTest.php b/tests/Unit/Registries/CommandsRegistryTest.php index 6b4f5e5..5f89a20 100644 --- a/tests/Unit/Registries/CommandsRegistryTest.php +++ b/tests/Unit/Registries/CommandsRegistryTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use Psr\Log\NullLogger; +use Ragnarok\Fenrir\Bitwise\Bitwise; use Ragnarok\Fenrir\Enums\ApplicationCommandOptionType; use Ragnarok\Fenrir\Gateway\Events\InteractionCreate; use Ragnarok\Fenrir\Interaction\CommandInteraction; @@ -18,9 +19,11 @@ use Tempcord\Runtime\AutocompleteResponder; use Tempcord\Runtime\ChoiceFactory; use Tempcord\Runtime\CommandDispatcher; +use Tempcord\Runtime\CommandRegistrar; use Tempcord\Runtime\OptionValueResolver; use Tempcord\Runtime\Outcome; use Tempcord\Runtime\OutcomeLevel; +use Tempcord\TempcordConfig; use Tempcord\Tests\Doubles\FakeDiscord; use Tempcord\Tests\Doubles\RecordingHttp; use Tempcord\Tests\Fixtures\GuildAlphaCommand; @@ -40,7 +43,12 @@ private function registry(?AllCommandExtension $extension = null): CommandsRegis return new CommandsRegistry( extension: $extension ?? new AllCommandExtension(), - builders: new CommandBuilderFactory(), + registrar: new CommandRegistrar( + new CommandBuilderFactory(), + new TempcordConfig('::token::', new Bitwise()), + new NullLogger(), + new RecordingHttp(), + ), dispatcher: new CommandDispatcher( new ArgumentResolver(new OptionValueResolver($discord)), new GenericContainer(), diff --git a/tests/Unit/TempcordTest.php b/tests/Unit/TempcordTest.php index da6f792..060d69f 100644 --- a/tests/Unit/TempcordTest.php +++ b/tests/Unit/TempcordTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use Psr\Log\NullLogger; +use Ragnarok\Fenrir\Bitwise\Bitwise; use Ragnarok\Fenrir\Constants\Events; use Ragnarok\Fenrir\Gateway\Events\Ready; use Tempcord\Discord\AllCommandExtension; @@ -14,9 +15,11 @@ use Tempcord\Runtime\AutocompleteResponder; use Tempcord\Runtime\ChoiceFactory; use Tempcord\Runtime\CommandDispatcher; +use Tempcord\Runtime\CommandRegistrar; use Tempcord\Runtime\OptionValueResolver; use Tempcord\Runtime\Outcome; use Tempcord\Tempcord; +use Tempcord\TempcordConfig; use Tempcord\Tests\Doubles\FakeDiscord; use Tempcord\Tests\Doubles\RecordingHttp; use Tempcord\Tests\Fixtures\ModerationCommand; @@ -37,7 +40,12 @@ protected function setUp(): void $this->discord = new FakeDiscord(new RecordingHttp()); $this->commands = new CommandsRegistry( extension: new AllCommandExtension(), - builders: new CommandBuilderFactory(), + registrar: new CommandRegistrar( + new CommandBuilderFactory(), + new TempcordConfig('::token::', new Bitwise()), + new NullLogger(), + new RecordingHttp(), + ), dispatcher: new CommandDispatcher( new ArgumentResolver(new OptionValueResolver($this->discord)), new GenericContainer(), From 10f52254d258de36309d4a7737330dbc36aec23e Mon Sep 17 00:00:00 2001 From: Vladyslav Gaysyuk Date: Sat, 22 Aug 2026 13:35:40 +0200 Subject: [PATCH 07/11] Expose the option constraints Discord already accepts Fenrir's CommandOptionBuilder has always supported choices, numeric bounds, string lengths and channel type filters. None of them were reachable through #[Option], so a bot author had to validate inside the handler what Discord would happily have enforced before the command ever fired. #[Option(description: 'Size', choices: ['Small' => 's', 'Large' => 'l'])] #[Option(description: 'Count', minValue: 1, maxValue: 10)] #[Option(description: 'Note', minLength: 2, maxLength: 32)] #[Option(description: 'Where', channelTypes: [ChannelType::GUILD_TEXT])] Choices accept either shape. A map uses its keys as the labels users see; a list has no labels of its own, so each value stands in as its own. That mirrors how ArrayAutocomplete already reads its items, so the two behave the same way. Constraints are omitted from the payload entirely when not declared, rather than sent empty. --- src/Attributes/Option.php | 17 ++++ src/Compiler/CommandCompiler.php | 31 ++++++++ src/Definitions/OptionDefinition.php | 11 +++ src/Discord/CommandBuilderFactory.php | 28 ++++++- tests/Fixtures/ConstrainedCommand.php | 25 ++++++ tests/Unit/Discord/OptionConstraintsTest.php | 83 ++++++++++++++++++++ 6 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 tests/Fixtures/ConstrainedCommand.php create mode 100644 tests/Unit/Discord/OptionConstraintsTest.php diff --git a/src/Attributes/Option.php b/src/Attributes/Option.php index b48f868..7b49b90 100644 --- a/src/Attributes/Option.php +++ b/src/Attributes/Option.php @@ -3,6 +3,7 @@ namespace Tempcord\Attributes; use Attribute; +use Ragnarok\Fenrir\Enums\ChannelType; use Tempcord\Interfaces\Autocomplete; /** @@ -17,10 +18,26 @@ { /** * @param string|null $name defaults to the parameter's own name + * @param array|list $choices + * the only values Discord will accept. A map uses its keys as the + * labels users see; a list shows each value as its own label. + * Mutually exclusive with autocomplete. + * @param int|float|null $minValue smallest accepted number + * @param int|float|null $maxValue largest accepted number + * @param int|null $minLength shortest accepted string + * @param int|null $maxLength longest accepted string + * @param list $channelTypes restricts which channels may be + * picked, for a Channel option */ public function __construct( public string $description, public ?string $name = null, public ?Autocomplete $autocomplete = null, + public array $choices = [], + public int|float|null $minValue = null, + public int|float|null $maxValue = null, + public ?int $minLength = null, + public ?int $maxLength = null, + public array $channelTypes = [], ) {} } diff --git a/src/Compiler/CommandCompiler.php b/src/Compiler/CommandCompiler.php index b4e2498..1345cd3 100644 --- a/src/Compiler/CommandCompiler.php +++ b/src/Compiler/CommandCompiler.php @@ -208,12 +208,43 @@ private function optionsOf(MethodReflector $method): array isRequired: !$parameter->isOptional(), autocomplete: $option->autocomplete, parameter: $parameter, + choices: $this->choicesOf($option), + minValue: $option->minValue, + maxValue: $option->maxValue, + minLength: $option->minLength, + maxLength: $option->maxLength, + channelTypes: $option->channelTypes, ); } return $options; } + /** + * A list of choices labels each entry with itself; a map uses its keys as + * the labels, matching how ArrayAutocomplete already reads its items. + * + * @return array + */ + private function choicesOf(Option $option): array + { + if ($option->choices === []) { + return []; + } + + if (!array_is_list($option->choices)) { + return $option->choices; + } + + $choices = []; + + foreach ($option->choices as $choice) { + $choices[(string) $choice] = $choice; + } + + return $choices; + } + private function typeOf(ParameterReflector $parameter): ApplicationCommandOptionType { if (!$parameter->getReflection()->hasType()) { diff --git a/src/Definitions/OptionDefinition.php b/src/Definitions/OptionDefinition.php index 591cc1e..897f3e5 100644 --- a/src/Definitions/OptionDefinition.php +++ b/src/Definitions/OptionDefinition.php @@ -3,6 +3,7 @@ namespace Tempcord\Definitions; use Ragnarok\Fenrir\Enums\ApplicationCommandOptionType; +use Ragnarok\Fenrir\Enums\ChannelType; use Tempcord\Interfaces\Autocomplete; use Tempest\Reflection\ParameterReflector; @@ -12,6 +13,10 @@ */ final readonly class OptionDefinition { + /** + * @param array $choices keyed by the label users see + * @param list $channelTypes + */ public function __construct( public string $name, public string $description, @@ -19,6 +24,12 @@ public function __construct( public bool $isRequired, public ?Autocomplete $autocomplete, public ParameterReflector $parameter, + public array $choices = [], + public int|float|null $minValue = null, + public int|float|null $maxValue = null, + public ?int $minLength = null, + public ?int $maxLength = null, + public array $channelTypes = [], ) {} public function hasAutocomplete(): bool diff --git a/src/Discord/CommandBuilderFactory.php b/src/Discord/CommandBuilderFactory.php index 4eabe63..d0e0281 100644 --- a/src/Discord/CommandBuilderFactory.php +++ b/src/Discord/CommandBuilderFactory.php @@ -103,11 +103,37 @@ private function forSubcommand(SubcommandDefinition $subcommand): CommandOptionB private function forParameter(OptionDefinition $option): CommandOptionBuilder { - return CommandOptionBuilder::new() + $builder = CommandOptionBuilder::new() ->setName($option->name) ->setDescription($option->description) ->setRequired($option->isRequired) ->setType($option->type) ->setAutoComplete($option->hasAutocomplete()); + + foreach ($option->choices as $label => $value) { + $builder->addChoice($label, $value); + } + + if (!is_null($option->minValue)) { + $builder->setMinValue($option->minValue); + } + + if (!is_null($option->maxValue)) { + $builder->setMaxValue($option->maxValue); + } + + if (!is_null($option->minLength)) { + $builder->setMinLength($option->minLength); + } + + if (!is_null($option->maxLength)) { + $builder->setMaxLength($option->maxLength); + } + + if ($option->channelTypes !== []) { + $builder->setChannelTypes(...$option->channelTypes); + } + + return $builder; } } diff --git a/tests/Fixtures/ConstrainedCommand.php b/tests/Fixtures/ConstrainedCommand.php new file mode 100644 index 0000000..b6dbc4e --- /dev/null +++ b/tests/Fixtures/ConstrainedCommand.php @@ -0,0 +1,25 @@ + 's', 'Large' => 'l'])] + string $size, + #[Option(description: 'A bare list', choices: ['red', 'green'])] + string $colour, + #[Option(description: 'Bounded number', minValue: 1, maxValue: 10)] + int $count, + #[Option(description: 'Bounded text', minLength: 2, maxLength: 32)] + string $note, + #[Option(description: 'Text channels only', channelTypes: [ChannelType::GUILD_TEXT])] + Channel $channel, + ): void {} +} diff --git a/tests/Unit/Discord/OptionConstraintsTest.php b/tests/Unit/Discord/OptionConstraintsTest.php new file mode 100644 index 0000000..37f7dcc --- /dev/null +++ b/tests/Unit/Discord/OptionConstraintsTest.php @@ -0,0 +1,83 @@ +> */ + private function options(string $class): array + { + $built = new CommandBuilderFactory()->payloadFor($this->definition($class)); + + return array_column($built['options'], null, 'name'); + } + + public function test_a_map_of_choices_uses_its_keys_as_labels(): void + { + $this->assertSame( + [ + ['name' => 'Small', 'value' => 's'], + ['name' => 'Large', 'value' => 'l'], + ], + $this->options(ConstrainedCommand::class)['size']['choices'], + ); + } + + /** + * A list has no labels of its own, so each value stands in as its own. + */ + public function test_a_list_of_choices_labels_each_value_with_itself(): void + { + $this->assertSame( + [ + ['name' => 'red', 'value' => 'red'], + ['name' => 'green', 'value' => 'green'], + ], + $this->options(ConstrainedCommand::class)['colour']['choices'], + ); + } + + public function test_numeric_bounds_reach_the_payload(): void + { + $count = $this->options(ConstrainedCommand::class)['count']; + + $this->assertSame(1, $count['min_value']); + $this->assertSame(10, $count['max_value']); + } + + public function test_string_bounds_reach_the_payload(): void + { + $note = $this->options(ConstrainedCommand::class)['note']; + + $this->assertSame(2, $note['min_length']); + $this->assertSame(32, $note['max_length']); + } + + public function test_channel_types_reach_the_payload(): void + { + $this->assertSame( + [ChannelType::GUILD_TEXT->value], + $this->options(ConstrainedCommand::class)['channel']['channel_types'], + ); + } + + /** + * An option that declares no constraints must not carry empty ones. + */ + public function test_an_unconstrained_option_carries_no_constraint_keys(): void + { + $name = $this->options(PingCommand::class)['name']; + + foreach (['choices', 'min_value', 'max_value', 'min_length', 'max_length', 'channel_types'] as $key) { + $this->assertArrayNotHasKey($key, $name); + } + } +} From 19a70267c15bda4f23abbac50534ae2267288290 Mon Sep 17 00:00:00 2001 From: Vladyslav Gaysyuk Date: Sat, 22 Aug 2026 14:57:11 +0200 Subject: [PATCH 08/11] Localize command names and descriptions through Tempest's translations Closes #2. The issue proposed adding descriptionLocalizations to the option attribute and wondered whether there was a better way to manage the translations. There is, and Tempest already had it: tempest/intl ships a translation catalog, so command translations can live in the same files as the rest of an application's rather than inline in PHP attributes, where translators cannot reach them and where they cannot be anything but constant expressions. A command declares one key and everything beneath it follows from position: #[Command(description: 'Music controls', translationKey: 'commands.music')] #[SubcommandGroup(name: 'playlist', description: 'Playlist controls')] final class MusicCommand { #[Subcommand(name: 'play', description: 'Play a track')] public function play( #[Option(description: 'Track title')] string $title, ): void {} } commands.music.description commands.music.playlist.description commands.music.playlist.play.description commands.music.playlist.play.title.description Names are localized the same way, on the same four levels, since Discord localizes both and solving one without the other would mean designing this twice. Only locales the catalog actually has are sent. A translator returns the key itself when a message is missing, so asking it directly would have shown "commands.music.description" to German users; the catalog is asked whether it has the message instead, and absent locales are left out so Discord falls back to the declared text. A command with no translationKey asks for nothing at all. tempest/intl is not made a dependency. It needs ext-intl and ext-dom, which is a lot to require of a bot that does not translate anything, so it is declared as a suggestion. Only CatalogLocalizations touches it, and an initializer returns NullLocalizations when the package is absent, keeping the class from being autoloaded at all. DiscordLocale carries the mapping between the two locale spellings, since Discord writes en-GB where Tempest writes en_GB. Three have no direct equivalent and are mapped deliberately: Tempest has no Latin America wide Spanish, so es-419 reads the unqualified Spanish catalog while es-ES reads Spain's, and Discord's two Chinese regions map onto Tempest's simplified and traditional scripts. A test pins all thirty-four against Tempest's enum so the mapping cannot rot silently. --- composer.json | 3 + src/Attributes/Command.php | 7 + src/Compiler/CommandCompiler.php | 56 ++++++-- src/Definitions/CommandDefinition.php | 6 + src/Definitions/OptionDefinition.php | 4 + src/Definitions/SubcommandDefinition.php | 4 + src/Definitions/SubcommandGroupDefinition.php | 4 + src/Discord/CommandBuilderFactory.php | 71 ++++++++-- src/Discoveries/CommandsDiscovery.php | 7 +- src/Enums/DiscordLocale.php | 69 +++++++++ src/Localization/CatalogLocalizations.php | 47 ++++++ src/Localization/LocalizationInitializer.php | 26 ++++ src/Localization/LocalizationProvider.php | 19 +++ src/Localization/NullLocalizations.php | 15 ++ tests/Doubles/FakeLocalizations.php | 30 ++++ tests/Fixtures/LocalizedCommand.php | 18 +++ tests/Fixtures/LocalizedInvokableCommand.php | 14 ++ .../Discoveries/CommandsDiscoveryTest.php | 3 +- .../Localization/CatalogLocalizationsTest.php | 91 ++++++++++++ tests/Unit/Localization/LocalizationTest.php | 134 ++++++++++++++++++ 20 files changed, 603 insertions(+), 25 deletions(-) create mode 100644 src/Enums/DiscordLocale.php create mode 100644 src/Localization/CatalogLocalizations.php create mode 100644 src/Localization/LocalizationInitializer.php create mode 100644 src/Localization/LocalizationProvider.php create mode 100644 src/Localization/NullLocalizations.php create mode 100644 tests/Doubles/FakeLocalizations.php create mode 100644 tests/Fixtures/LocalizedCommand.php create mode 100644 tests/Fixtures/LocalizedInvokableCommand.php create mode 100644 tests/Unit/Localization/CatalogLocalizationsTest.php create mode 100644 tests/Unit/Localization/LocalizationTest.php diff --git a/composer.json b/composer.json index 4a469a6..82462b3 100644 --- a/composer.json +++ b/composer.json @@ -49,5 +49,8 @@ "psr-4": { "Tempcord\\Tests\\": "tests/" } + }, + "suggest": { + "tempest/intl": "Enables name and description translations for commands via #[Command(translationKey: ...)]. Requires ext-intl." } } diff --git a/src/Attributes/Command.php b/src/Attributes/Command.php index e8fe891..4728b91 100644 --- a/src/Attributes/Command.php +++ b/src/Attributes/Command.php @@ -27,6 +27,12 @@ * Command prefix or suffix stripped and the rest snake_cased * @param list $permissions the permissions a member needs by * default; an empty list leaves the command unrestricted + * @param string|null $translationKey the catalog key this command's + * translations live under. Keys for everything beneath it are + * derived from position, so "commands.music" gives + * commands.music.description for the command, + * commands.music.playlist.play.description for a subcommand, and + * commands.music.playlist.play.title.description for its option. */ public function __construct( public string|BackedEnum|null $name = null, @@ -36,6 +42,7 @@ public function __construct( public array $permissions = [], public bool $directMessage = true, public ApplicationCommandTypes $type = ApplicationCommandTypes::CHAT_INPUT, + public ?string $translationKey = null, ) { $this->guildId = $guildId === null ? null : (string) $guildId; } diff --git a/src/Compiler/CommandCompiler.php b/src/Compiler/CommandCompiler.php index 1345cd3..4af9005 100644 --- a/src/Compiler/CommandCompiler.php +++ b/src/Compiler/CommandCompiler.php @@ -19,6 +19,8 @@ use Tempcord\Definitions\OptionDefinition; use Tempcord\Definitions\SubcommandDefinition; use Tempcord\Definitions\SubcommandGroupDefinition; +use Tempcord\Localization\LocalizationProvider; +use Tempcord\Localization\NullLocalizations; use Tempest\Reflection\ClassReflector; use Tempest\Reflection\MethodReflector; use Tempest\Reflection\ParameterReflector; @@ -32,6 +34,10 @@ */ final readonly class CommandCompiler { + public function __construct( + private LocalizationProvider $localizations = new NullLocalizations(), + ) {} + /** * PHP parameter types the framework knows how to ask Discord for. */ @@ -51,8 +57,9 @@ public function compile(ClassReflector $class, Command $command): CommandDefinit $options = []; $handlers = []; - $group = $this->groupOf($class); - $subcommands = $this->subcommandsOf($class); + $key = $command->translationKey; + $group = $this->groupOf($class, $key); + $subcommands = $this->subcommandsOf($class, $key); if ($group !== null) { $options[$group->name] = $group; @@ -85,7 +92,7 @@ public function compile(ClassReflector $class, Command $command): CommandDefinit * parameters are the command's options. */ $invoke = $this->invokerOf($class); - $options = $this->optionsOf($invoke); + $options = $this->optionsOf($invoke, $key); $handlers[$name] = new HandlerDefinition( path: $name, @@ -108,6 +115,8 @@ public function compile(ClassReflector $class, Command $command): CommandDefinit permissions: $command->permissions, options: $options, handlers: $handlers, + nameLocalizations: $this->translate($key, 'name'), + descriptionLocalizations: $this->translate($key, 'description'), ); } @@ -131,7 +140,7 @@ private function nameOf(ClassReflector $class, Command $command): string ->toString(); } - private function groupOf(ClassReflector $class): ?SubcommandGroupDefinition + private function groupOf(ClassReflector $class, ?string $key): ?SubcommandGroupDefinition { if (!$class->hasAttribute(SubcommandGroup::class)) { return null; @@ -139,18 +148,22 @@ private function groupOf(ClassReflector $class): ?SubcommandGroupDefinition /** @var SubcommandGroup $group */ $group = $class->getAttribute(SubcommandGroup::class); + $name = $this->valueOf($group->name); + $groupKey = $this->nest($key, $name); return new SubcommandGroupDefinition( - name: $this->valueOf($group->name), + name: $name, description: $group->description, - subcommands: $this->subcommandsOf($class), + subcommands: $this->subcommandsOf($class, $groupKey), + nameLocalizations: $this->translate($groupKey, 'name'), + descriptionLocalizations: $this->translate($groupKey, 'description'), ); } /** * @return array */ - private function subcommandsOf(ClassReflector $class): array + private function subcommandsOf(ClassReflector $class, ?string $key): array { $subcommands = []; @@ -163,11 +176,15 @@ private function subcommandsOf(ClassReflector $class): array $subcommand = $method->getAttribute(Subcommand::class); $name = $this->valueOf($subcommand->name); + $subcommandKey = $this->nest($key, $name); + $subcommands[$name] = new SubcommandDefinition( name: $name, description: $subcommand->description, - options: $this->optionsOf($method), + options: $this->optionsOf($method, $subcommandKey), method: $method, + nameLocalizations: $this->translate($subcommandKey, 'name'), + descriptionLocalizations: $this->translate($subcommandKey, 'description'), ); } @@ -188,7 +205,7 @@ private function invokerOf(ClassReflector $class): MethodReflector /** * @return array */ - private function optionsOf(MethodReflector $method): array + private function optionsOf(MethodReflector $method, ?string $key): array { $options = []; @@ -201,6 +218,8 @@ private function optionsOf(MethodReflector $method): array $option = $parameter->getAttribute(Option::class); $name = $option->name ?? $parameter->getName(); + $optionKey = $this->nest($key, $name); + $options[$name] = new OptionDefinition( name: $name, description: $option->description, @@ -214,6 +233,8 @@ private function optionsOf(MethodReflector $method): array minLength: $option->minLength, maxLength: $option->maxLength, channelTypes: $option->channelTypes, + nameLocalizations: $this->translate($optionKey, 'name'), + descriptionLocalizations: $this->translate($optionKey, 'description'), ); } @@ -255,6 +276,23 @@ private function typeOf(ParameterReflector $parameter): ApplicationCommandOption ?? throw new LogicException('Command option type not supported'); } + /** + * Extends a translation key one step down the command tree. Null stays + * null, so a command that declares no key localizes nothing. + */ + private function nest(?string $key, string $segment): ?string + { + return $key === null ? null : $key . '.' . $segment; + } + + /** + * @return array + */ + private function translate(?string $key, string $field): array + { + return $key === null ? [] : $this->localizations->forKey($key . '.' . $field); + } + private function valueOf(string|BackedEnum $name): string { return $name instanceof BackedEnum ? (string) $name->value : $name; diff --git a/src/Definitions/CommandDefinition.php b/src/Definitions/CommandDefinition.php index 6b0206d..0e61c40 100644 --- a/src/Definitions/CommandDefinition.php +++ b/src/Definitions/CommandDefinition.php @@ -17,6 +17,8 @@ * the command's direct children, in the shape Discord expects * @param array $handlers keyed by dotted interaction path * @param list $permissions + * @param array $nameLocalizations keyed by Discord locale + * @param array $descriptionLocalizations keyed by Discord locale */ public function __construct( public string $name, @@ -28,6 +30,8 @@ public function __construct( public array $permissions, public array $options, public array $handlers, + public array $nameLocalizations = [], + public array $descriptionLocalizations = [], ) {} public function isGlobal(): bool @@ -67,6 +71,8 @@ public function mergedWith(self $other): self permissions: $this->permissions, options: [...$this->options, ...$other->options], handlers: [...$this->handlers, ...$other->handlers], + nameLocalizations: $this->nameLocalizations, + descriptionLocalizations: $this->descriptionLocalizations, ); } } diff --git a/src/Definitions/OptionDefinition.php b/src/Definitions/OptionDefinition.php index 897f3e5..451737e 100644 --- a/src/Definitions/OptionDefinition.php +++ b/src/Definitions/OptionDefinition.php @@ -16,6 +16,8 @@ /** * @param array $choices keyed by the label users see * @param list $channelTypes + * @param array $nameLocalizations keyed by Discord locale + * @param array $descriptionLocalizations keyed by Discord locale */ public function __construct( public string $name, @@ -30,6 +32,8 @@ public function __construct( public ?int $minLength = null, public ?int $maxLength = null, public array $channelTypes = [], + public array $nameLocalizations = [], + public array $descriptionLocalizations = [], ) {} public function hasAutocomplete(): bool diff --git a/src/Definitions/SubcommandDefinition.php b/src/Definitions/SubcommandDefinition.php index 9762b7f..6d45923 100644 --- a/src/Definitions/SubcommandDefinition.php +++ b/src/Definitions/SubcommandDefinition.php @@ -11,11 +11,15 @@ { /** * @param array $options keyed by option name + * @param array $nameLocalizations keyed by Discord locale + * @param array $descriptionLocalizations keyed by Discord locale */ public function __construct( public string $name, public string $description, public array $options, public MethodReflector $method, + public array $nameLocalizations = [], + public array $descriptionLocalizations = [], ) {} } diff --git a/src/Definitions/SubcommandGroupDefinition.php b/src/Definitions/SubcommandGroupDefinition.php index 7b09b5e..9a12210 100644 --- a/src/Definitions/SubcommandGroupDefinition.php +++ b/src/Definitions/SubcommandGroupDefinition.php @@ -10,10 +10,14 @@ { /** * @param array $subcommands keyed by subcommand name + * @param array $nameLocalizations keyed by Discord locale + * @param array $descriptionLocalizations keyed by Discord locale */ public function __construct( public string $name, public string $description, public array $subcommands, + public array $nameLocalizations = [], + public array $descriptionLocalizations = [], ) {} } diff --git a/src/Discord/CommandBuilderFactory.php b/src/Discord/CommandBuilderFactory.php index d0e0281..0bc2810 100644 --- a/src/Discord/CommandBuilderFactory.php +++ b/src/Discord/CommandBuilderFactory.php @@ -54,6 +54,14 @@ public function forCommand(CommandDefinition $command): CommandBuilder if ($command->type === ApplicationCommandTypes::CHAT_INPUT) { // The compiler guarantees a description for chat input commands. $builder->setDescription((string) $command->description); + + if ($command->descriptionLocalizations !== []) { + $builder->setDescriptionLocalizations($command->descriptionLocalizations); + } + } + + if ($command->nameLocalizations !== []) { + $builder->setNameLocalizations($command->nameLocalizations); } foreach ($command->options as $option) { @@ -75,10 +83,14 @@ public function forOption( private function forGroup(SubcommandGroupDefinition $group): CommandOptionBuilder { - $builder = CommandOptionBuilder::new() - ->setName($group->name) - ->setDescription($group->description) - ->setType(ApplicationCommandOptionType::SUB_COMMAND_GROUP); + $builder = $this->localize( + CommandOptionBuilder::new() + ->setName($group->name) + ->setDescription($group->description) + ->setType(ApplicationCommandOptionType::SUB_COMMAND_GROUP), + $group->nameLocalizations, + $group->descriptionLocalizations, + ); foreach ($group->subcommands as $subcommand) { $builder->addOption($this->forSubcommand($subcommand)); @@ -89,10 +101,14 @@ private function forGroup(SubcommandGroupDefinition $group): CommandOptionBuilde private function forSubcommand(SubcommandDefinition $subcommand): CommandOptionBuilder { - $builder = CommandOptionBuilder::new() - ->setName($subcommand->name) - ->setDescription($subcommand->description) - ->setType(ApplicationCommandOptionType::SUB_COMMAND); + $builder = $this->localize( + CommandOptionBuilder::new() + ->setName($subcommand->name) + ->setDescription($subcommand->description) + ->setType(ApplicationCommandOptionType::SUB_COMMAND), + $subcommand->nameLocalizations, + $subcommand->descriptionLocalizations, + ); foreach ($subcommand->options as $option) { $builder->addOption($this->forParameter($option)); @@ -101,14 +117,41 @@ private function forSubcommand(SubcommandDefinition $subcommand): CommandOptionB return $builder; } + /** + * Localizations are left off entirely when there are none, rather than sent + * as empty maps. + * + * @param array $nameLocalizations + * @param array $descriptionLocalizations + */ + private function localize( + CommandOptionBuilder $builder, + array $nameLocalizations, + array $descriptionLocalizations, + ): CommandOptionBuilder { + if ($nameLocalizations !== []) { + $builder->setNameLocalizations($nameLocalizations); + } + + if ($descriptionLocalizations !== []) { + $builder->setDescriptionLocalizations($descriptionLocalizations); + } + + return $builder; + } + private function forParameter(OptionDefinition $option): CommandOptionBuilder { - $builder = CommandOptionBuilder::new() - ->setName($option->name) - ->setDescription($option->description) - ->setRequired($option->isRequired) - ->setType($option->type) - ->setAutoComplete($option->hasAutocomplete()); + $builder = $this->localize( + CommandOptionBuilder::new() + ->setName($option->name) + ->setDescription($option->description) + ->setRequired($option->isRequired) + ->setType($option->type) + ->setAutoComplete($option->hasAutocomplete()), + $option->nameLocalizations, + $option->descriptionLocalizations, + ); foreach ($option->choices as $label => $value) { $builder->addChoice($label, $value); diff --git a/src/Discoveries/CommandsDiscovery.php b/src/Discoveries/CommandsDiscovery.php index 4f40c5a..74f5e19 100644 --- a/src/Discoveries/CommandsDiscovery.php +++ b/src/Discoveries/CommandsDiscovery.php @@ -14,9 +14,14 @@ final class CommandsDiscovery implements Discovery { use IsDiscovery; + /** + * The compiler is injected rather than defaulted so it arrives carrying the + * container's localization provider; a default would silently compile every + * command without translations. + */ public function __construct( private readonly CommandsRegistry $commandRegistry, - private readonly CommandCompiler $compiler = new CommandCompiler(), + private readonly CommandCompiler $compiler, ) {} public function discover(DiscoveryLocation $location, ClassReflector $class): void diff --git a/src/Enums/DiscordLocale.php b/src/Enums/DiscordLocale.php new file mode 100644 index 0000000..caaef7a --- /dev/null +++ b/src/Enums/DiscordLocale.php @@ -0,0 +1,69 @@ + 'es', + self::CHINESE_CHINA => 'zh_Hans', + self::CHINESE_TAIWAN => 'zh_Hant', + default => str_replace('-', '_', $this->value), + }; + } +} diff --git a/src/Localization/CatalogLocalizations.php b/src/Localization/CatalogLocalizations.php new file mode 100644 index 0000000..2c3aab1 --- /dev/null +++ b/src/Localization/CatalogLocalizations.php @@ -0,0 +1,47 @@ +tempestLocale()); + + /* + * Asked rather than translated: a translator returns the key itself + * when a message is missing, which would show up in Discord as the + * translation. + */ + if ($locale === null || !$this->catalog->has($locale, $key)) { + continue; + } + + $message = $this->catalog->get($locale, $key); + + if ($message !== null) { + $translations[$discordLocale->value] = $message; + } + } + + return $translations; + } +} diff --git a/src/Localization/LocalizationInitializer.php b/src/Localization/LocalizationInitializer.php new file mode 100644 index 0000000..cf7eebd --- /dev/null +++ b/src/Localization/LocalizationInitializer.php @@ -0,0 +1,26 @@ +get(self::CATALOG)); + } +} diff --git a/src/Localization/LocalizationProvider.php b/src/Localization/LocalizationProvider.php new file mode 100644 index 0000000..0c17191 --- /dev/null +++ b/src/Localization/LocalizationProvider.php @@ -0,0 +1,19 @@ + keyed by Discord locale code + */ + public function forKey(string $key): array; +} diff --git a/src/Localization/NullLocalizations.php b/src/Localization/NullLocalizations.php new file mode 100644 index 0000000..941f5dd --- /dev/null +++ b/src/Localization/NullLocalizations.php @@ -0,0 +1,15 @@ + every key that was asked for, in order */ + public array $requested = []; + + /** + * @param array> $translations keyed by catalog + * key, then by Discord locale + */ + public function __construct( + private readonly array $translations = [], + ) {} + + public function forKey(string $key): array + { + $this->requested[] = $key; + + return $this->translations[$key] ?? []; + } +} diff --git a/tests/Fixtures/LocalizedCommand.php b/tests/Fixtures/LocalizedCommand.php new file mode 100644 index 0000000..cf03ad7 --- /dev/null +++ b/tests/Fixtures/LocalizedCommand.php @@ -0,0 +1,18 @@ +setItems(new DiscoveryItems()); return $discovery; diff --git a/tests/Unit/Localization/CatalogLocalizationsTest.php b/tests/Unit/Localization/CatalogLocalizationsTest.php new file mode 100644 index 0000000..cd6f564 --- /dev/null +++ b/tests/Unit/Localization/CatalogLocalizationsTest.php @@ -0,0 +1,91 @@ +markTestSkipped('tempest/intl is not installed'); + } + } + + public function test_it_reads_translations_out_of_the_catalog(): void + { + $catalog = new GenericCatalog() + ->add(Locale::GERMAN, 'commands.greet.description', 'Begrüßt jemanden') + ->add(Locale::FRENCH, 'commands.greet.description', 'Salue quelqu\'un'); + + $this->assertSame( + ['de' => 'Begrüßt jemanden', 'fr' => 'Salue quelqu\'un'], + new CatalogLocalizations($catalog)->forKey('commands.greet.description'), + ); + } + + /** + * A translator hands back the key itself when a message is missing, which + * would show up in Discord as the translation. Absent locales are left out + * so Discord falls back to the declared text. + */ + public function test_a_missing_translation_is_left_out_rather_than_filled_in(): void + { + $catalog = new GenericCatalog()->add(Locale::GERMAN, 'known', 'Bekannt'); + + $this->assertSame([], new CatalogLocalizations($catalog)->forKey('unknown')); + $this->assertSame(['de' => 'Bekannt'], new CatalogLocalizations($catalog)->forKey('known')); + } + + /** + * Discord writes en-GB where Tempest writes en_GB, and the result has to + * carry Discord's spelling. + */ + public function test_region_locales_are_keyed_by_discords_spelling(): void + { + $catalog = new GenericCatalog() + ->add(Locale::ENGLISH_UNITED_KINGDOM, 'k', 'Colour') + ->add(Locale::PORTUGUESE_BRAZIL, 'k', 'Cor'); + + $this->assertSame( + ['en-GB' => 'Colour', 'pt-BR' => 'Cor'], + new CatalogLocalizations($catalog)->forKey('k'), + ); + } + + /** + * Three Discord locales have no direct Tempest equivalent and are mapped + * deliberately rather than skipped. + */ + public function test_the_locales_without_a_direct_equivalent_are_mapped(): void + { + $catalog = new GenericCatalog() + ->add(Locale::SPANISH, 'k', 'Hola') + ->add(Locale::CHINESE_SIMPLIFIED, 'k', '简体') + ->add(Locale::CHINESE_TRADITIONAL, 'k', '繁體'); + + $translations = new CatalogLocalizations($catalog)->forKey('k'); + + $this->assertSame('Hola', $translations['es-419']); + $this->assertSame('简体', $translations['zh-CN']); + $this->assertSame('繁體', $translations['zh-TW']); + } + + public function test_every_discord_locale_maps_onto_a_real_tempest_locale(): void + { + foreach (DiscordLocale::cases() as $discordLocale) { + $this->assertNotNull( + Locale::tryFrom($discordLocale->tempestLocale()), + $discordLocale->value . ' maps to ' . $discordLocale->tempestLocale() . ', which Tempest does not define', + ); + } + } +} diff --git a/tests/Unit/Localization/LocalizationTest.php b/tests/Unit/Localization/LocalizationTest.php new file mode 100644 index 0000000..7d5fa65 --- /dev/null +++ b/tests/Unit/Localization/LocalizationTest.php @@ -0,0 +1,134 @@ +getAttribute(Command::class); + + return new CommandCompiler($localizations)->compile($reflector, $attribute); + } + + /** + * Keys follow the shape of the command tree, so one key on the command + * covers everything beneath it. + */ + public function test_keys_are_derived_from_position_in_the_tree(): void + { + $localizations = new FakeLocalizations(); + + $this->compile(LocalizedCommand::class, $localizations); + + $this->assertContains('commands.music.description', $localizations->requested); + $this->assertContains('commands.music.playlist.description', $localizations->requested); + $this->assertContains('commands.music.playlist.play.description', $localizations->requested); + $this->assertContains('commands.music.playlist.play.title.description', $localizations->requested); + } + + public function test_an_invokable_commands_options_hang_directly_off_its_key(): void + { + $localizations = new FakeLocalizations(); + + $this->compile(LocalizedInvokableCommand::class, $localizations); + + $this->assertContains('commands.greet.description', $localizations->requested); + $this->assertContains('commands.greet.name.description', $localizations->requested); + } + + public function test_names_are_localized_as_well_as_descriptions(): void + { + $localizations = new FakeLocalizations(); + + $this->compile(LocalizedInvokableCommand::class, $localizations); + + $this->assertContains('commands.greet.name', $localizations->requested); + $this->assertContains('commands.greet.name.name', $localizations->requested); + } + + public function test_translations_reach_the_definition(): void + { + $definition = $this->compile(LocalizedInvokableCommand::class, new FakeLocalizations([ + 'commands.greet.description' => ['de' => 'Begrüßt jemanden'], + 'commands.greet.name.description' => ['de' => 'Wen begrüßen'], + ])); + + $this->assertSame(['de' => 'Begrüßt jemanden'], $definition->descriptionLocalizations); + $this->assertSame(['de' => 'Wen begrüßen'], $definition->options['name']->descriptionLocalizations); + } + + /** + * A command that declares no key must not ask for translations at all, + * rather than asking for keys derived from its name. + */ + public function test_a_command_without_a_key_localizes_nothing(): void + { + $localizations = new FakeLocalizations(); + + $definition = $this->compile(MusicCommand::class, $localizations); + + $this->assertSame([], $localizations->requested); + $this->assertSame([], $definition->descriptionLocalizations); + $this->assertSame([], $definition->nameLocalizations); + } + + public function test_the_default_provider_returns_nothing(): void + { + $this->assertSame([], new NullLocalizations()->forKey('anything')); + } + + public function test_localizations_reach_the_payload(): void + { + $definition = $this->compile(LocalizedCommand::class, new FakeLocalizations([ + 'commands.music.description' => ['de' => 'Musiksteuerung'], + 'commands.music.playlist.description' => ['de' => 'Wiedergabeliste'], + 'commands.music.playlist.play.description' => ['de' => 'Titel abspielen'], + 'commands.music.playlist.play.title.description' => ['de' => 'Titelname'], + 'commands.music.playlist.play.title.name' => ['de' => 'titel'], + ])); + + $built = new CommandBuilderFactory()->payloadFor($definition); + + $this->assertSame(['de' => 'Musiksteuerung'], $built['description_localizations']); + + $group = $built['options'][0]; + $this->assertSame(['de' => 'Wiedergabeliste'], $group['description_localizations']); + + $play = $group['options'][0]; + $this->assertSame(['de' => 'Titel abspielen'], $play['description_localizations']); + + $title = $play['options'][0]; + $this->assertSame(['de' => 'Titelname'], $title['description_localizations']); + $this->assertSame(['de' => 'titel'], $title['name_localizations']); + } + + /** + * Nothing empty is sent; Discord falls back to the declared text itself. + */ + public function test_a_command_without_translations_sends_no_localization_keys(): void + { + $built = new CommandBuilderFactory()->payloadFor($this->definition(MusicCommand::class)); + + $this->assertArrayNotHasKey('description_localizations', $built); + $this->assertArrayNotHasKey('name_localizations', $built); + $this->assertArrayNotHasKey('description_localizations', $built['options'][0]); + } +} From 7e80e8c605614d22088106d774f1683c7442b265 Mon Sep 17 00:00:00 2001 From: Vladyslav Gaysyuk Date: Sat, 22 Aug 2026 15:10:13 +0200 Subject: [PATCH 09/11] Generate the documentation from the source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documentation in docs/ described a framework that does not exist. There was a 638 line page on a middleware layer the codebase has never had, an OptionType class that was never written, and #[Option] shown on methods rather than parameters. It came from the "AI Generated documents" commit and was never true. Publishing it on a website would mislead everyone who read it. So it is replaced, and split by what can be derived and what cannot. The reference is generated by reflecting over the public surface: the attributes, the Autocomplete interface and its array implementation, the config object, and the DiscordLocale enum. Parameter names, types, defaults and descriptions come from the constructor and its docblock, and which declarations an attribute may be written on comes from its #[Attribute] flags rather than from prose. It cannot describe something that is not there. The guides are hand written, because narrative cannot be derived — but they carry no code of their own. Every example is transcluded from a file under tests/Fixtures, which the suite already compiles and exercises, so an example that stops being true breaks the build. Including a file that does not exist is an error rather than a blank. Two tests hold the line: one regenerates everything and fails if what is committed no longer matches the source, and one checks every included path exists. That is the check the old documentation never had. Output is markdown plus an index.json carrying the same information structurally, so the website can build navigation and search without parsing prose, while the markdown stays readable on GitHub and reviewable in a diff. The generator lives in tools/ under autoload-dev, so it is not shipped to bots that install the framework. Run it with `composer docs`. phpstan now covers it. Fixing it turned up two things worth noting: a docblock type containing a space, as in array, defeated the parameter parser and silently dropped the description, and the README's own example passed a required: true argument that #[Option] has never accepted. --- README.md | 13 +- composer.json | 6 +- docs/README.md | 26 + docs/api-reference.md | 833 ------------------ docs/commands.md | 427 --------- docs/configuration.md | 652 -------------- docs/events.md | 544 ------------ docs/getting-started.md | 158 ---- docs/guides/01-getting-started.md | 83 ++ docs/guides/02-commands.md | 163 ++++ docs/guides/03-autocomplete.md | 69 ++ docs/guides/04-events.md | 57 ++ docs/guides/05-translations.md | 79 ++ docs/index.json | 503 +++++++++++ docs/middleware.md | 639 -------------- docs/reference/attributes/command.md | 25 + docs/reference/attributes/event.md | 18 + docs/reference/attributes/option.md | 26 + docs/reference/attributes/subcommand-group.md | 19 + docs/reference/attributes/subcommand.md | 19 + .../autocomplete/array-autocomplete.md | 15 + docs/reference/autocomplete/autocomplete.md | 12 + .../configuration/tempcord-config.md | 15 + docs/reference/enums/discord-locale.md | 55 ++ docs/reference/index.md | 25 + phpstan.neon | 1 + src/Attributes/Option.php | 3 + tests/Unit/Tools/DocsGeneratorTest.php | 172 ++++ tools/generate-docs.php | 21 + tools/guides/01-getting-started.md | 65 ++ tools/guides/02-commands.md | 71 ++ tools/guides/03-autocomplete.md | 51 ++ tools/guides/04-events.md | 41 + tools/guides/05-translations.md | 62 ++ tools/src/ApiReflector.php | 299 +++++++ tools/src/DocsGenerator.php | 91 ++ tools/src/GuideCompiler.php | 74 ++ tools/src/JsonWriter.php | 50 ++ tools/src/MarkdownWriter.php | 128 +++ tools/src/Parameter.php | 18 + tools/src/Symbol.php | 26 + 41 files changed, 2398 insertions(+), 3256 deletions(-) create mode 100644 docs/README.md delete mode 100644 docs/api-reference.md delete mode 100644 docs/commands.md delete mode 100644 docs/configuration.md delete mode 100644 docs/events.md delete mode 100644 docs/getting-started.md create mode 100644 docs/guides/01-getting-started.md create mode 100644 docs/guides/02-commands.md create mode 100644 docs/guides/03-autocomplete.md create mode 100644 docs/guides/04-events.md create mode 100644 docs/guides/05-translations.md create mode 100644 docs/index.json delete mode 100644 docs/middleware.md create mode 100644 docs/reference/attributes/command.md create mode 100644 docs/reference/attributes/event.md create mode 100644 docs/reference/attributes/option.md create mode 100644 docs/reference/attributes/subcommand-group.md create mode 100644 docs/reference/attributes/subcommand.md create mode 100644 docs/reference/autocomplete/array-autocomplete.md create mode 100644 docs/reference/autocomplete/autocomplete.md create mode 100644 docs/reference/configuration/tempcord-config.md create mode 100644 docs/reference/enums/discord-locale.md create mode 100644 docs/reference/index.md create mode 100644 tests/Unit/Tools/DocsGeneratorTest.php create mode 100644 tools/generate-docs.php create mode 100644 tools/guides/01-getting-started.md create mode 100644 tools/guides/02-commands.md create mode 100644 tools/guides/03-autocomplete.md create mode 100644 tools/guides/04-events.md create mode 100644 tools/guides/05-translations.md create mode 100644 tools/src/ApiReflector.php create mode 100644 tools/src/DocsGenerator.php create mode 100644 tools/src/GuideCompiler.php create mode 100644 tools/src/JsonWriter.php create mode 100644 tools/src/MarkdownWriter.php create mode 100644 tools/src/Parameter.php create mode 100644 tools/src/Symbol.php diff --git a/README.md b/README.md index abeb651..6b49c7d 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ class UserCommand { #[Subcommand(name: 'info', description: 'Get user information')] public function info( - #[Option(name: 'user', description: 'Target user', required: true)] + #[Option(description: 'Target user')] User $user ): void { // Handle user info command @@ -161,6 +161,17 @@ class UserCommand } ``` +Whether an option is required comes from whether its parameter has a default, so `$user` +above is required without saying so. + +## Documentation + +See [docs/](docs/README.md) — guides for getting started, commands, autocomplete, events and +translations, plus a reference generated from the source. + +Run `composer docs` after changing the public API; the test suite fails if the committed +documentation no longer matches the code. + ## Contributing We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to Tempcord. diff --git a/composer.json b/composer.json index 82462b3..f63abfa 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,8 @@ "scripts": { "test": "phpunit", "analyse": "phpstan analyse --memory-limit=1G", - "cs-fix": "php-cs-fixer fix" + "cs-fix": "php-cs-fixer fix", + "docs": "php tools/generate-docs.php" }, "config": { "sort-packages": true, @@ -47,7 +48,8 @@ }, "autoload-dev": { "psr-4": { - "Tempcord\\Tests\\": "tests/" + "Tempcord\\Tests\\": "tests/", + "Tempcord\\Tools\\": "tools/src/" } }, "suggest": { diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..aac11b5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,26 @@ + + +# Tempcord documentation + +Build Discord bots with PHP, on top of [Tempest](https://tempestphp.com). + +## Guides + +- [Getting started](guides/01-getting-started.md) +- [Commands and options](guides/02-commands.md) +- [Autocomplete](guides/03-autocomplete.md) +- [Events](guides/04-events.md) +- [Translations](guides/05-translations.md) + +## Reference + +Generated from the source, so it describes what the framework actually does. + +**Attributes** — [Command](reference/attributes/command.md), [SubcommandGroup](reference/attributes/subcommand-group.md), [Subcommand](reference/attributes/subcommand.md), [Option](reference/attributes/option.md), [Event](reference/attributes/event.md) + +**Autocomplete** — [Autocomplete](reference/autocomplete/autocomplete.md), [ArrayAutocomplete](reference/autocomplete/array-autocomplete.md) + +**Configuration** — [TempcordConfig](reference/configuration/tempcord-config.md) + +**Enums** — [DiscordLocale](reference/enums/discord-locale.md) + diff --git a/docs/api-reference.md b/docs/api-reference.md deleted file mode 100644 index 776c045..0000000 --- a/docs/api-reference.md +++ /dev/null @@ -1,833 +0,0 @@ -# API Reference - -This document provides a comprehensive reference for Tempcord's API classes, methods, and interfaces. - -## Core Classes - -### Tempcord\Discord\Interaction - -Represents a Discord interaction (slash command, button click, etc.). - -#### Properties - -```php -public readonly string $id; // Interaction ID -public readonly string $applicationId; // Application ID -public readonly int $type; // Interaction type -public readonly ?string $token; // Interaction token -public readonly int $version; // Version -public readonly ?string $commandName; // Command name -public readonly ?string $commandId; // Command ID -public readonly array $options; // Command options -public readonly User $user; // User who triggered -public readonly ?Member $member; // Guild member (if in guild) -public readonly ?Guild $guild; // Guild (if in guild) -public readonly Channel $channel; // Channel -public readonly ?Message $message; // Message (for components) -public readonly string $locale; // User locale -public readonly ?string $guildLocale; // Guild locale -``` - -#### Methods - -```php -// Response methods -public function reply(string $content, bool $ephemeral = false): void -public function editReply(string $content): void -public function deleteReply(): void -public function followUp(string $content, bool $ephemeral = false): void - -// Deferred responses -public function defer(bool $ephemeral = false): void -public function deferUpdate(): void - -// Modal responses -public function showModal(Modal $modal): void - -// Component responses -public function updateMessage(string $content, array $components = []): void - -// Option helpers -public function getOption(string $name, mixed $default = null): mixed -public function getStringOption(string $name, ?string $default = null): ?string -public function getIntegerOption(string $name, ?int $default = null): ?int -public function getBooleanOption(string $name, ?bool $default = null): ?bool -public function getUserOption(string $name): ?User -public function getChannelOption(string $name): ?Channel -public function getRoleOption(string $name): ?Role -public function getMentionableOption(string $name): User|Member|Role|null -public function getAttachmentOption(string $name): ?Attachment - -// Context methods -public function setContext(string $key, mixed $value): void -public function getContext(string $key, mixed $default = null): mixed -public function hasContext(string $key): bool - -// Permission checks -public function hasPermission(Permission $permission): bool -public function hasRole(string $roleId): bool -public function isOwner(): bool -public function isAdmin(): bool -``` - -### Tempcord\Discord\User - -Represents a Discord user. - -#### Properties - -```php -public readonly string $id; // User ID -public readonly string $username; // Username -public readonly string $discriminator; // Discriminator (legacy) -public readonly ?string $globalName; // Global display name -public readonly ?string $avatar; // Avatar hash -public readonly ?bool $bot; // Is bot -public readonly ?bool $system; // Is system user -public readonly ?bool $mfaEnabled; // MFA enabled -public readonly ?string $banner; // Banner hash -public readonly ?int $accentColor; // Accent color -public readonly ?string $locale; // User locale -public readonly ?bool $verified; // Email verified -public readonly ?string $email; // Email address -public readonly ?int $flags; // User flags -public readonly ?int $premiumType; // Nitro type -public readonly ?int $publicFlags; // Public flags -``` - -#### Methods - -```php -// Display methods -public function getDisplayName(): string // Get display name -public function getTag(): string // Get user#discriminator -public function getMention(): string // Get <@id> mention -public function getAvatarUrl(?int $size = null): ?string // Get avatar URL -public function getBannerUrl(?int $size = null): ?string // Get banner URL - -// Utility methods -public function isBot(): bool // Check if bot -public function hasNitro(): bool // Check if has Nitro -public function getCreatedAt(): DateTime // Get account creation date -``` - -### Tempcord\Discord\Guild - -Represents a Discord guild (server). - -#### Properties - -```php -public readonly string $id; // Guild ID -public readonly string $name; // Guild name -public readonly ?string $icon; // Icon hash -public readonly ?string $iconHash; // Icon hash (legacy) -public readonly ?string $splash; // Splash hash -public readonly ?string $discoverySplash; // Discovery splash -public readonly ?bool $owner; // Is owner -public readonly string $ownerId; // Owner ID -public readonly ?string $permissions; // Permissions -public readonly ?string $region; // Voice region (deprecated) -public readonly ?string $afkChannelId; // AFK channel ID -public readonly int $afkTimeout; // AFK timeout -public readonly ?bool $widgetEnabled; // Widget enabled -public readonly ?string $widgetChannelId; // Widget channel ID -public readonly int $verificationLevel; // Verification level -public readonly int $defaultMessageNotifications; // Default notifications -public readonly int $explicitContentFilter; // Content filter level -public readonly array $roles; // Guild roles -public readonly array $emojis; // Guild emojis -public readonly array $features; // Guild features -public readonly int $mfaLevel; // MFA level -public readonly ?string $applicationId; // Application ID -public readonly ?string $systemChannelId; // System channel ID -public readonly int $systemChannelFlags; // System channel flags -public readonly ?string $rulesChannelId; // Rules channel ID -public readonly ?int $maxPresences; // Max presences -public readonly ?int $maxMembers; // Max members -public readonly ?string $vanityUrlCode; // Vanity URL -public readonly ?string $description; // Description -public readonly ?string $banner; // Banner hash -public readonly int $premiumTier; // Boost tier -public readonly ?int $premiumSubscriptionCount; // Boost count -public readonly string $preferredLocale; // Preferred locale -public readonly ?string $publicUpdatesChannelId; // Updates channel -public readonly ?int $maxVideoChannelUsers; // Max video users -public readonly ?int $approximateMemberCount; // Approx members -public readonly ?int $approximatePresenceCount; // Approx online -``` - -#### Methods - -```php -// Display methods -public function getIconUrl(?int $size = null): ?string // Get icon URL -public function getSplashUrl(?int $size = null): ?string // Get splash URL -public function getBannerUrl(?int $size = null): ?string // Get banner URL - -// Member methods -public function getMember(string $userId): ?Member // Get member -public function getMembers(): array // Get all members -public function getMemberCount(): int // Get member count - -// Channel methods -public function getChannel(string $channelId): ?Channel // Get channel -public function getChannels(): array // Get all channels -public function getTextChannels(): array // Get text channels -public function getVoiceChannels(): array // Get voice channels - -// Role methods -public function getRole(string $roleId): ?Role // Get role -public function getRoles(): array // Get all roles -public function getEveryoneRole(): Role // Get @everyone role - -// Permission methods -public function getMemberPermissions(string $userId): array // Get permissions -public function hasFeature(string $feature): bool // Check feature - -// Utility methods -public function getCreatedAt(): DateTime // Get creation date -public function isOwner(string $userId): bool // Check if user is owner -``` - -### Tempcord\Discord\Channel - -Represents a Discord channel. - -#### Properties - -```php -public readonly string $id; // Channel ID -public readonly int $type; // Channel type -public readonly ?string $guildId; // Guild ID -public readonly ?int $position; // Position -public readonly ?array $permissionOverwrites; // Permission overwrites -public readonly ?string $name; // Channel name -public readonly ?string $topic; // Channel topic -public readonly ?bool $nsfw; // Is NSFW -public readonly ?string $lastMessageId; // Last message ID -public readonly ?int $bitrate; // Voice bitrate -public readonly ?int $userLimit; // Voice user limit -public readonly ?int $rateLimitPerUser; // Rate limit -public readonly ?array $recipients; // DM recipients -public readonly ?string $icon; // Channel icon -public readonly ?string $ownerId; // Channel owner -public readonly ?string $applicationId; // Application ID -public readonly ?string $parentId; // Parent category ID -public readonly ?DateTime $lastPinTimestamp; // Last pin timestamp -public readonly ?string $rtcRegion; // RTC region -public readonly ?int $videoQualityMode; // Video quality -public readonly ?int $messageCount; // Message count -public readonly ?int $memberCount; // Member count -public readonly ?int $defaultAutoArchiveDuration; // Auto archive -public readonly ?string $permissions; // Permissions -public readonly ?int $flags; // Channel flags -``` - -#### Methods - -```php -// Message methods -public function sendMessage(string $content, array $options = []): Message -public function getMessage(string $messageId): ?Message -public function getMessages(int $limit = 50): array -public function deleteMessage(string $messageId): void -public function editMessage(string $messageId, string $content): Message - -// Permission methods -public function hasPermission(string $userId, Permission $permission): bool -public function getPermissions(string $userId): array - -// Utility methods -public function getMention(): string // Get <#id> mention -public function isText(): bool // Is text channel -public function isVoice(): bool // Is voice channel -public function isCategory(): bool // Is category -public function isDM(): bool // Is DM channel -public function isThread(): bool // Is thread -public function getCreatedAt(): DateTime // Get creation date -``` - -## Attribute Classes - -### Tempcord\Attributes\Command - -Defines a slash command. - -```php -#[Command( - name: 'hello', - description: 'Say hello to someone', - defaultMemberPermissions: Permission::SEND_MESSAGES, - dmPermission: true, - nsfw: false -)] -``` - -#### Parameters - -- `name` (string): Command name (required) -- `description` (string): Command description (required) -- `defaultMemberPermissions` (Permission|null): Default permissions -- `dmPermission` (bool): Allow in DMs -- `nsfw` (bool): NSFW command - -### Tempcord\Attributes\Option - -Defines a command option. - -```php -#[Option( - name: 'user', - description: 'The user to greet', - type: OptionType::USER, - required: true, - autocomplete: false -)] -``` - -#### Parameters - -- `name` (string): Option name (required) -- `description` (string): Option description (required) -- `type` (OptionType): Option type (required) -- `required` (bool): Is required -- `autocomplete` (bool): Enable autocomplete -- `choices` (array): Predefined choices -- `minValue` (int|float): Minimum value -- `maxValue` (int|float): Maximum value -- `minLength` (int): Minimum string length -- `maxLength` (int): Maximum string length -- `channelTypes` (array): Allowed channel types - -### Tempcord\Attributes\EventListener - -Defines an event listener. - -```php -#[EventListener( - event: MessageCreateEvent::class, - priority: 100, - once: false -)] -``` - -#### Parameters - -- `event` (string): Event class (required) -- `priority` (int): Listener priority -- `once` (bool): Run only once - -### Tempcord\Attributes\Middleware - -Defines middleware. - -```php -#[Middleware( - priority: 100 -)] -``` - -#### Parameters - -- `priority` (int): Middleware priority - -### Tempcord\Attributes\UseMiddleware - -Applies middleware to commands. - -```php -#[UseMiddleware([ - AuthMiddleware::class, - RateLimitMiddleware::class -])] -``` - -#### Parameters - -- `middleware` (array): Array of middleware classes - -## Enums - -### Tempcord\Enums\OptionType - -Command option types. - -```php -OptionType::SUB_COMMAND // 1 -OptionType::SUB_COMMAND_GROUP // 2 -OptionType::STRING // 3 -OptionType::INTEGER // 4 -OptionType::BOOLEAN // 5 -OptionType::USER // 6 -OptionType::CHANNEL // 7 -OptionType::ROLE // 8 -OptionType::MENTIONABLE // 9 -OptionType::NUMBER // 10 -OptionType::ATTACHMENT // 11 -``` - -### Tempcord\Enums\Permission - -Discord permissions. - -```php -Permission::CREATE_INSTANT_INVITE // 1 << 0 -Permission::KICK_MEMBERS // 1 << 1 -Permission::BAN_MEMBERS // 1 << 2 -Permission::ADMINISTRATOR // 1 << 3 -Permission::MANAGE_CHANNELS // 1 << 4 -Permission::MANAGE_GUILD // 1 << 5 -Permission::ADD_REACTIONS // 1 << 6 -Permission::VIEW_AUDIT_LOG // 1 << 7 -Permission::PRIORITY_SPEAKER // 1 << 8 -Permission::STREAM // 1 << 9 -Permission::VIEW_CHANNEL // 1 << 10 -Permission::SEND_MESSAGES // 1 << 11 -Permission::SEND_TTS_MESSAGES // 1 << 12 -Permission::MANAGE_MESSAGES // 1 << 13 -Permission::EMBED_LINKS // 1 << 14 -Permission::ATTACH_FILES // 1 << 15 -Permission::READ_MESSAGE_HISTORY // 1 << 16 -Permission::MENTION_EVERYONE // 1 << 17 -Permission::USE_EXTERNAL_EMOJIS // 1 << 18 -Permission::VIEW_GUILD_INSIGHTS // 1 << 19 -Permission::CONNECT // 1 << 20 -Permission::SPEAK // 1 << 21 -Permission::MUTE_MEMBERS // 1 << 22 -Permission::DEAFEN_MEMBERS // 1 << 23 -Permission::MOVE_MEMBERS // 1 << 24 -Permission::USE_VAD // 1 << 25 -Permission::CHANGE_NICKNAME // 1 << 26 -Permission::MANAGE_NICKNAMES // 1 << 27 -Permission::MANAGE_ROLES // 1 << 28 -Permission::MANAGE_WEBHOOKS // 1 << 29 -Permission::MANAGE_EMOJIS_AND_STICKERS // 1 << 30 -Permission::USE_APPLICATION_COMMANDS // 1 << 31 -Permission::REQUEST_TO_SPEAK // 1 << 32 -Permission::MANAGE_EVENTS // 1 << 33 -Permission::MANAGE_THREADS // 1 << 34 -Permission::CREATE_PUBLIC_THREADS // 1 << 35 -Permission::CREATE_PRIVATE_THREADS // 1 << 36 -Permission::USE_EXTERNAL_STICKERS // 1 << 37 -Permission::SEND_MESSAGES_IN_THREADS // 1 << 38 -Permission::USE_EMBEDDED_ACTIVITIES // 1 << 39 -Permission::MODERATE_MEMBERS // 1 << 40 -``` - -### Tempcord\Enums\InteractionType - -Interaction types. - -```php -InteractionType::PING // 1 -InteractionType::APPLICATION_COMMAND // 2 -InteractionType::MESSAGE_COMPONENT // 3 -InteractionType::APPLICATION_COMMAND_AUTOCOMPLETE // 4 -InteractionType::MODAL_SUBMIT // 5 -``` - -### Tempcord\Enums\ChannelType - -Channel types. - -```php -ChannelType::GUILD_TEXT // 0 -ChannelType::DM // 1 -ChannelType::GUILD_VOICE // 2 -ChannelType::GROUP_DM // 3 -ChannelType::GUILD_CATEGORY // 4 -ChannelType::GUILD_NEWS // 5 -ChannelType::GUILD_STORE // 6 -ChannelType::GUILD_NEWS_THREAD // 10 -ChannelType::GUILD_PUBLIC_THREAD // 11 -ChannelType::GUILD_PRIVATE_THREAD // 12 -ChannelType::GUILD_STAGE_VOICE // 13 -ChannelType::GUILD_DIRECTORY // 14 -ChannelType::GUILD_FORUM // 15 -``` - -## Event Classes - -### Tempcord\Events\MessageCreateEvent - -Fired when a message is created. - -```php -public readonly Message $message; -public readonly ?Guild $guild; -public readonly Channel $channel; -public readonly User $author; -``` - -### Tempcord\Events\MessageUpdateEvent - -Fired when a message is updated. - -```php -public readonly Message $message; -public readonly ?Message $oldMessage; -public readonly ?Guild $guild; -public readonly Channel $channel; -``` - -### Tempcord\Events\MessageDeleteEvent - -Fired when a message is deleted. - -```php -public readonly string $messageId; -public readonly string $channelId; -public readonly ?string $guildId; -``` - -### Tempcord\Events\GuildCreateEvent - -Fired when the bot joins a guild. - -```php -public readonly Guild $guild; -public readonly bool $unavailable; -``` - -### Tempcord\Events\GuildUpdateEvent - -Fired when a guild is updated. - -```php -public readonly Guild $guild; -public readonly ?Guild $oldGuild; -``` - -### Tempcord\Events\GuildDeleteEvent - -Fired when the bot leaves a guild. - -```php -public readonly string $guildId; -public readonly bool $unavailable; -``` - -### Tempcord\Events\GuildMemberAddEvent - -Fired when a member joins a guild. - -```php -public readonly Member $member; -public readonly Guild $guild; -``` - -### Tempcord\Events\GuildMemberUpdateEvent - -Fired when a member is updated. - -```php -public readonly Member $member; -public readonly ?Member $oldMember; -public readonly Guild $guild; -``` - -### Tempcord\Events\GuildMemberRemoveEvent - -Fired when a member leaves a guild. - -```php -public readonly User $user; -public readonly string $guildId; -``` - -## Interface Classes - -### Tempcord\Middleware\MiddlewareInterface - -Interface for middleware classes. - -```php -interface MiddlewareInterface -{ - public function handle(Interaction $interaction, callable $next): mixed; -} -``` - -### Tempcord\Events\EventListenerInterface - -Interface for event listeners. - -```php -interface EventListenerInterface -{ - public function handle(object $event): void; -} -``` - -## Utility Classes - -### Tempcord\Utils\EmbedBuilder - -Builder for Discord embeds. - -```php -$embed = EmbedBuilder::create() - ->setTitle('Hello World') - ->setDescription('This is an embed') - ->setColor(0x00ff00) - ->addField('Field 1', 'Value 1', true) - ->addField('Field 2', 'Value 2', true) - ->setFooter('Footer text', 'https://example.com/icon.png') - ->setTimestamp() - ->build(); -``` - -#### Methods - -```php -public static function create(): self -public function setTitle(?string $title): self -public function setDescription(?string $description): self -public function setUrl(?string $url): self -public function setTimestamp(?DateTime $timestamp = null): self -public function setColor(?int $color): self -public function setFooter(?string $text, ?string $iconUrl = null): self -public function setImage(?string $url): self -public function setThumbnail(?string $url): self -public function setAuthor(?string $name, ?string $url = null, ?string $iconUrl = null): self -public function addField(string $name, string $value, bool $inline = false): self -public function setFields(array $fields): self -public function build(): array -``` - -### Tempcord\Utils\ComponentBuilder - -Builder for Discord components. - -```php -$components = ComponentBuilder::create() - ->addButton('primary', 'click_me', 'Click Me!') - ->addButton('secondary', 'cancel', 'Cancel') - ->addSelectMenu('select_option', 'Choose an option', [ - ['label' => 'Option 1', 'value' => 'opt1'], - ['label' => 'Option 2', 'value' => 'opt2'], - ]) - ->build(); -``` - -#### Methods - -```php -public static function create(): self -public function addButton(string $style, string $customId, string $label, ?string $emoji = null, bool $disabled = false): self -public function addLinkButton(string $url, string $label, ?string $emoji = null, bool $disabled = false): self -public function addSelectMenu(string $customId, string $placeholder, array $options, int $minValues = 1, int $maxValues = 1): self -public function addTextInput(string $customId, string $label, string $style = 'short', bool $required = true): self -public function newRow(): self -public function build(): array -``` - -### Tempcord\Utils\PermissionCalculator - -Utility for permission calculations. - -```php -$calculator = new PermissionCalculator(); - -// Check if user has permission -$hasPermission = $calculator->hasPermission($member, Permission::MANAGE_MESSAGES); - -// Calculate effective permissions -$permissions = $calculator->calculatePermissions($member, $channel); - -// Check multiple permissions -$hasAll = $calculator->hasAllPermissions($member, [ - Permission::SEND_MESSAGES, - Permission::EMBED_LINKS, -]); -``` - -#### Methods - -```php -public function hasPermission(Member $member, Permission $permission, ?Channel $channel = null): bool -public function hasAllPermissions(Member $member, array $permissions, ?Channel $channel = null): bool -public function hasAnyPermission(Member $member, array $permissions, ?Channel $channel = null): bool -public function calculatePermissions(Member $member, ?Channel $channel = null): array -public function isAdmin(Member $member): bool -``` - -## Helper Functions - -### Global Helpers - -```php -// Configuration -config(string $key, mixed $default = null): mixed - -// Logging -logger(): LoggerInterface - -// Cache -cache(): CacheInterface - -// Queue -queue(): QueueInterface - -// Application paths -app_path(string $path = ''): string -base_path(string $path = ''): string -config_path(string $path = ''): string -storage_path(string $path = ''): string -database_path(string $path = ''): string - -// Environment -env(string $key, mixed $default = null): mixed - -// Time -now(): DateTime -today(): DateTime - -// Strings -str_random(int $length = 16): string -str_slug(string $title, string $separator = '-'): string - -// Arrays -array_get(array $array, string $key, mixed $default = null): mixed -array_set(array &$array, string $key, mixed $value): void -array_forget(array &$array, string $key): void - -// Validation -validate(array $data, array $rules): array - -// Response helpers -response(): ResponseFactory -redirect(string $to = null): RedirectResponse - -// Discord helpers -discord(): DiscordClient -bot(): BotInstance -``` - -## Error Classes - -### Tempcord\Exceptions\TempcordException - -Base exception class. - -### Tempcord\Exceptions\CommandException - -Thrown when command execution fails. - -### Tempcord\Exceptions\ValidationException - -Thrown when validation fails. - -### Tempcord\Exceptions\PermissionException - -Thrown when permission check fails. - -### Tempcord\Exceptions\RateLimitException - -Thrown when rate limit is exceeded. - -### Tempcord\Exceptions\ConfigurationException - -Thrown when configuration is invalid. - -## Constants - -### Version Information - -```php -Tempcord::VERSION // Current version -Tempcord::DISCORD_API_VERSION // Discord API version -Tempcord::USER_AGENT // HTTP User-Agent -``` - -### Limits - -```php -Tempcord::MAX_EMBED_TITLE_LENGTH // 256 -Tempcord::MAX_EMBED_DESCRIPTION_LENGTH // 4096 -Tempcord::MAX_EMBED_FIELD_NAME_LENGTH // 256 -Tempcord::MAX_EMBED_FIELD_VALUE_LENGTH // 1024 -Tempcord::MAX_EMBED_FOOTER_LENGTH // 2048 -Tempcord::MAX_EMBED_AUTHOR_LENGTH // 256 -Tempcord::MAX_EMBED_FIELDS // 25 -Tempcord::MAX_EMBED_TOTAL_LENGTH // 6000 - -Tempcord::MAX_MESSAGE_LENGTH // 2000 -Tempcord::MAX_COMMAND_NAME_LENGTH // 32 -Tempcord::MAX_COMMAND_DESCRIPTION_LENGTH // 100 -Tempcord::MAX_OPTION_NAME_LENGTH // 32 -Tempcord::MAX_OPTION_DESCRIPTION_LENGTH // 100 -Tempcord::MAX_CHOICE_NAME_LENGTH // 100 -Tempcord::MAX_CHOICE_VALUE_LENGTH // 100 - -Tempcord::MAX_COMPONENTS_PER_ROW // 5 -Tempcord::MAX_ROWS_PER_MESSAGE // 5 -Tempcord::MAX_SELECT_OPTIONS // 25 -Tempcord::MAX_BUTTON_LABEL_LENGTH // 80 -Tempcord::MAX_SELECT_PLACEHOLDER_LENGTH // 150 -``` - -## Type Definitions - -### Command Handler - -```php -type CommandHandler = callable(Interaction): void -``` - -### Event Handler - -```php -type EventHandler = callable(object): void -``` - -### Middleware Handler - -```php -type MiddlewareHandler = callable(Interaction, callable): mixed -``` - -### Option Choice - -```php -type OptionChoice = [ - 'name' => string, - 'value' => string|int|float, - 'name_localizations' => ?array, -] -``` - -### Embed Field - -```php -type EmbedField = [ - 'name' => string, - 'value' => string, - 'inline' => ?bool, -] -``` - -### Component - -```php -type Component = [ - 'type' => int, - 'custom_id' => ?string, - 'disabled' => ?bool, - 'style' => ?int, - 'label' => ?string, - 'emoji' => ?array, - 'url' => ?string, - 'options' => ?array, - 'placeholder' => ?string, - 'min_values' => ?int, - 'max_values' => ?int, - 'min_length' => ?int, - 'max_length' => ?int, - 'required' => ?bool, - 'value' => ?string, -] -``` - -This API reference provides comprehensive documentation for all public classes, methods, and interfaces in Tempcord. For more detailed examples and usage patterns, refer to the other documentation files and the examples directory. \ No newline at end of file diff --git a/docs/commands.md b/docs/commands.md deleted file mode 100644 index f8c9e6d..0000000 --- a/docs/commands.md +++ /dev/null @@ -1,427 +0,0 @@ -# Commands - -Commands are the primary way users interact with your Discord bot. Tempcord provides a powerful and flexible command system that supports both slash commands and traditional text commands. - -## Basic Commands - -### Creating a Command - -Create a command by defining a class with the `#[Command]` attribute: - -```php -reply('Hello there! 👋'); - } -} -``` - -### Command Attributes - -- `#[Command('name')]` - Defines the command name -- `#[Description('text')]` - Provides a description for the command -- `#[Option()]` - Defines command options/parameters - -## Command Options - -Add parameters to your commands using the `#[Option]` attribute: - -```php -getOption('user'); - $message = $interaction->getOption('message') ?? 'Hello'; - - $interaction->reply("{$message}, {$user->mention()}!"); - } -} -``` - -### Option Types - -- `OptionType::STRING` - Text input -- `OptionType::INTEGER` - Whole numbers -- `OptionType::NUMBER` - Decimal numbers -- `OptionType::BOOLEAN` - True/false values -- `OptionType::USER` - Discord user -- `OptionType::CHANNEL` - Discord channel -- `OptionType::ROLE` - Discord role -- `OptionType::MENTIONABLE` - User, role, or channel -- `OptionType::ATTACHMENT` - File attachment - -### Option Parameters - -```php -#[Option( - name: 'option_name', - type: OptionType::STRING, - description: 'Option description', - required: true, - choices: ['choice1', 'choice2'], - minValue: 1, - maxValue: 100, - minLength: 1, - maxLength: 50 -)] -``` - -## Command Groups - -Organize related commands into groups: - -```php -getOption('key'); - $value = $interaction->getOption('value'); - - // Set configuration logic - $interaction->reply("Set {$key} to {$value}"); - } - - #[Subcommand('get')] - #[Description('Get a configuration value')] - #[Option('key', OptionType::STRING, 'Configuration key', required: true)] - public function get(Interaction $interaction): void - { - $key = $interaction->getOption('key'); - - // Get configuration logic - $interaction->reply("Value for {$key}: ..."); - } -} -``` - -## Response Types - -### Basic Responses - -```php -// Simple text response -$interaction->reply('Hello!'); - -// Ephemeral response (only visible to the user) -$interaction->reply('Secret message', ephemeral: true); - -// Deferred response (for long-running operations) -$interaction->defer(); -// ... do work ... -$interaction->followUp('Operation completed!'); -``` - -### Rich Responses - -```php -use Tempcord\Discord\Embed; -use Tempcord\Discord\Button; -use Tempcord\Discord\ActionRow; - -// Embed response -$embed = new Embed( - title: 'Command Result', - description: 'This is an embedded response', - color: 0x00ff00 -); - -$interaction->reply(embeds: [$embed]); - -// Response with buttons -$button = new Button( - style: ButtonStyle::PRIMARY, - label: 'Click me!', - customId: 'my_button' -); - -$actionRow = new ActionRow([$button]); - -$interaction->reply( - content: 'Choose an option:', - components: [$actionRow] -); -``` - -## Command Validation - -### Built-in Validation - -Tempcord automatically validates: -- Required options -- Option types -- Min/max values and lengths -- Choice constraints - -### Custom Validation - -```php -getOption('age'); - - if ($age < 13) { - throw new ValidationException('You must be at least 13 years old.'); - } - - if ($age > 120) { - throw new ValidationException('Please enter a realistic age.'); - } - - $interaction->reply("Your age has been set to {$age}."); - } -} -``` - -## Error Handling - -```php -performRiskyOperation(); - $interaction->reply('Operation successful!'); - } catch (Exception $e) { - $interaction->reply( - 'An error occurred: ' . $e->getMessage(), - ephemeral: true - ); - } - } - - private function performRiskyOperation(): void - { - // Implementation - } -} -``` - -## Command Permissions - -### Discord Permissions - -```php -isAdmin($interaction->user)) { - $interaction->reply('You do not have permission to use this command.', ephemeral: true); - return; - } - - // Admin-only logic - } - - private function isAdmin($user): bool - { - // Custom admin check logic - return in_array($user->id, config('admin_users')); - } -} -``` - -## Command Registration - -### Automatic Registration - -By default, Tempcord automatically discovers and registers commands in the `App\Commands` namespace. - -### Manual Registration - -```php -// In your bootstrap file -use Tempcord\CommandRegistry; -use App\Commands\MyCommand; - -$registry = new CommandRegistry(); -$registry->register(MyCommand::class); -``` - -### Conditional Registration - -```php - [ - AdminOnlyMiddleware::class, - AuditLogMiddleware::class, - ], - ], - - // Bot owners (can bypass certain restrictions) - owners: explode(',', env('BOT_OWNERS', '')), - - // Guild-specific settings - guilds: [ - 'default' => [ - 'prefix' => '!', - 'features' => ['commands', 'events'], - ], - ], - - // Cache configuration - cache: [ - 'driver' => env('CACHE_DRIVER', 'file'), - 'ttl' => 3600, - ], - - // Logging configuration - logging: [ - 'level' => env('LOG_LEVEL', 'info'), - 'channels' => ['single', 'discord'], - ], -); -``` - -## Environment Variables - -Create a `.env` file in your project root: - -```env -# Discord Configuration -DISCORD_TOKEN=your_bot_token_here -DISCORD_APPLICATION_ID=your_application_id_here -DISCORD_PUBLIC_KEY=your_public_key_here - -# Bot Configuration -BOT_OWNERS=123456789012345678,987654321098765432 -BOT_PREFIX=! - -# Environment -APP_ENV=local -APP_DEBUG=true -APP_URL=http://localhost - -# Database -DB_CONNECTION=mysql -DB_HOST=127.0.0.1 -DB_PORT=3306 -DB_DATABASE=tempcord -DB_USERNAME=root -DB_PASSWORD= - -# Cache -CACHE_DRIVER=redis -REDIS_HOST=127.0.0.1 -REDIS_PASSWORD=null -REDIS_PORT=6379 - -# Logging -LOG_CHANNEL=stack -LOG_LEVEL=debug - -# Queue -QUEUE_CONNECTION=redis - -# Session -SESSION_DRIVER=redis -SESSION_LIFETIME=120 -``` - -## Discord Application Setup - -### 1. Create Discord Application - -1. Go to [Discord Developer Portal](https://discord.com/developers/applications) -2. Click "New Application" -3. Give your application a name -4. Navigate to the "Bot" section -5. Click "Add Bot" -6. Copy the bot token to your `.env` file - -### 2. Configure Bot Permissions - -In the "Bot" section, configure: - -- **Privileged Gateway Intents** (if needed): - - Presence Intent - - Server Members Intent - - Message Content Intent - -- **Bot Permissions**: - - Send Messages - - Use Slash Commands - - Read Message History - - Add Reactions - - Embed Links - - Attach Files - -### 3. OAuth2 Setup - -In the "OAuth2" section: - -1. Select "bot" and "applications.commands" scopes -2. Select required permissions -3. Use the generated URL to invite your bot - -## Configuration Options - -### Core Settings - -```php -return new TempcordConfig( - // Required Discord credentials - token: env('DISCORD_TOKEN'), - applicationId: env('DISCORD_APPLICATION_ID'), - publicKey: env('DISCORD_PUBLIC_KEY'), - - // Optional settings - debug: env('APP_DEBUG', false), - environment: env('APP_ENV', 'production'), - timezone: env('APP_TIMEZONE', 'UTC'), -); -``` - -### Command Configuration - -```php -return new TempcordConfig( - // Command discovery paths - commandPaths: [ - app_path('Commands'), - app_path('Modules/*/Commands'), - ], - - // Command registration settings - commandRegistration: [ - 'auto_register' => true, - 'global_commands' => true, - 'guild_commands' => [], - 'delete_missing' => false, - ], - - // Command defaults - commandDefaults: [ - 'ephemeral' => false, - 'defer' => false, - 'timeout' => 30, - ], -); -``` - -### Event Configuration - -```php -return new TempcordConfig( - // Event listener discovery paths - eventPaths: [ - app_path('Events'), - app_path('Modules/*/Events'), - ], - - // Event settings - events: [ - 'auto_discover' => true, - 'async_processing' => true, - 'max_listeners' => 100, - ], - - // Gateway intents - intents: [ - 'guilds', - 'guild_messages', - 'guild_message_reactions', - 'direct_messages', - ], -); -``` - -### Middleware Configuration - -```php -return new TempcordConfig( - // Global middleware (applied to all commands) - globalMiddleware: [ - LoggingMiddleware::class, - RateLimitMiddleware::class, - MaintenanceModeMiddleware::class, - ], - - // Middleware groups - middlewareGroups: [ - 'admin' => [ - AdminOnlyMiddleware::class, - AuditLogMiddleware::class, - ], - 'public' => [ - CooldownMiddleware::class, - ValidationMiddleware::class, - ], - ], - - // Middleware settings - middleware: [ - 'auto_discover' => true, - 'priority_sorting' => true, - ], -); -``` - -### Database Configuration - -```php -return new TempcordConfig( - // Database settings - database: [ - 'connection' => env('DB_CONNECTION', 'mysql'), - 'migrations_path' => database_path('migrations'), - 'auto_migrate' => env('AUTO_MIGRATE', false), - ], - - // Model settings - models: [ - 'auto_discover' => true, - 'paths' => [ - app_path('Models'), - ], - ], -); -``` - -### Cache Configuration - -```php -return new TempcordConfig( - // Cache settings - cache: [ - 'driver' => env('CACHE_DRIVER', 'file'), - 'prefix' => env('CACHE_PREFIX', 'tempcord'), - 'ttl' => 3600, - - // Driver-specific settings - 'redis' => [ - 'host' => env('REDIS_HOST', '127.0.0.1'), - 'port' => env('REDIS_PORT', 6379), - 'password' => env('REDIS_PASSWORD'), - 'database' => env('REDIS_DB', 0), - ], - - 'file' => [ - 'path' => storage_path('cache'), - ], - ], -); -``` - -### Logging Configuration - -```php -return new TempcordConfig( - // Logging settings - logging: [ - 'default' => env('LOG_CHANNEL', 'stack'), - 'level' => env('LOG_LEVEL', 'info'), - - 'channels' => [ - 'stack' => [ - 'driver' => 'stack', - 'channels' => ['single', 'discord'], - ], - - 'single' => [ - 'driver' => 'single', - 'path' => storage_path('logs/tempcord.log'), - 'level' => 'debug', - ], - - 'discord' => [ - 'driver' => 'discord', - 'webhook_url' => env('DISCORD_LOG_WEBHOOK'), - 'level' => 'error', - ], - ], - ], -); -``` - -### Queue Configuration - -```php -return new TempcordConfig( - // Queue settings - queue: [ - 'default' => env('QUEUE_CONNECTION', 'sync'), - - 'connections' => [ - 'sync' => [ - 'driver' => 'sync', - ], - - 'redis' => [ - 'driver' => 'redis', - 'connection' => 'default', - 'queue' => 'default', - 'retry_after' => 90, - ], - ], - ], -); -``` - -## Guild-Specific Configuration - -```php -return new TempcordConfig( - // Guild-specific settings - guilds: [ - // Default settings for all guilds - 'default' => [ - 'prefix' => '!', - 'features' => ['commands', 'events', 'moderation'], - 'permissions' => [ - 'admin_roles' => [], - 'mod_roles' => [], - 'banned_users' => [], - ], - ], - - // Specific guild overrides - '123456789012345678' => [ - 'prefix' => '?', - 'features' => ['commands', 'events'], - 'disabled_commands' => ['admin', 'moderation'], - ], - - '987654321098765432' => [ - 'prefix' => '!', - 'features' => ['commands'], - 'custom_settings' => [ - 'welcome_channel' => '123456789012345678', - 'log_channel' => '987654321098765432', - ], - ], - ], -); -``` - -## Feature Flags - -```php -return new TempcordConfig( - // Feature flags - features: [ - 'slash_commands' => true, - 'context_menus' => true, - 'message_commands' => false, - 'auto_complete' => true, - 'modals' => true, - 'select_menus' => true, - 'buttons' => true, - - // Experimental features - 'experimental' => [ - 'voice_support' => false, - 'ai_integration' => false, - 'advanced_permissions' => true, - ], - ], -); -``` - -## Performance Configuration - -```php -return new TempcordConfig( - // Performance settings - performance: [ - 'command_cache' => true, - 'event_cache' => true, - 'middleware_cache' => true, - - 'limits' => [ - 'max_commands' => 100, - 'max_events' => 50, - 'max_middleware' => 20, - 'command_timeout' => 30, - 'event_timeout' => 10, - ], - - 'optimization' => [ - 'lazy_loading' => true, - 'preload_commands' => false, - 'compress_responses' => true, - ], - ], -); -``` - -## Security Configuration - -```php -return new TempcordConfig( - // Security settings - security: [ - 'verify_signatures' => true, - 'rate_limiting' => [ - 'enabled' => true, - 'max_requests' => 60, - 'window_seconds' => 60, - ], - - 'permissions' => [ - 'strict_mode' => true, - 'require_permissions' => true, - 'check_bot_permissions' => true, - ], - - 'validation' => [ - 'sanitize_input' => true, - 'validate_options' => true, - 'max_input_length' => 2000, - ], - ], -); -``` - -## Development Configuration - -```php -return new TempcordConfig( - // Development settings - development: [ - 'hot_reload' => env('APP_DEBUG', false), - 'debug_mode' => env('APP_DEBUG', false), - 'profiling' => env('APP_DEBUG', false), - - 'testing' => [ - 'mock_discord' => env('MOCK_DISCORD', false), - 'fake_interactions' => env('FAKE_INTERACTIONS', false), - ], - - 'debugging' => [ - 'log_all_interactions' => false, - 'dump_payloads' => false, - 'trace_middleware' => false, - ], - ], -); -``` - -## Configuration Validation - -Tempcord automatically validates your configuration on startup: - -```php -// Custom validation rules -return new TempcordConfig( - // ... your config - - validation: [ - 'strict' => true, - 'rules' => [ - 'token' => 'required|string|min:50', - 'applicationId' => 'required|string|size:18', - 'owners' => 'array', - 'owners.*' => 'string|size:18', - ], - ], -); -``` - -## Environment-Specific Configuration - -### Local Development - -```php -// config/tempcord.local.php -return [ - 'debug' => true, - 'logging' => [ - 'level' => 'debug', - ], - 'cache' => [ - 'driver' => 'file', - ], - 'development' => [ - 'hot_reload' => true, - 'profiling' => true, - ], -]; -``` - -### Production - -```php -// config/tempcord.production.php -return [ - 'debug' => false, - 'logging' => [ - 'level' => 'warning', - ], - 'cache' => [ - 'driver' => 'redis', - ], - 'performance' => [ - 'command_cache' => true, - 'optimization' => [ - 'lazy_loading' => true, - 'preload_commands' => true, - ], - ], -]; -``` - -## Configuration Helpers - -### Accessing Configuration - -```php -// Get configuration value -$token = config('tempcord.token'); -$debug = config('tempcord.debug', false); - -// Get nested configuration -$cacheDriver = config('tempcord.cache.driver'); -$logLevel = config('tempcord.logging.level'); - -// Check if feature is enabled -if (config('tempcord.features.slash_commands')) { - // Slash commands are enabled -} -``` - -### Dynamic Configuration - -```php -// Set configuration at runtime -config(['tempcord.debug' => true]); - -// Merge configuration -config()->merge('tempcord', [ - 'custom_setting' => 'value', -]); -``` - -## Best Practices - -### Security -- Never commit sensitive values to version control -- Use environment variables for secrets -- Validate all configuration values -- Use secure defaults - -### Performance -- Cache configuration in production -- Use lazy loading for large configurations -- Minimize configuration file size -- Profile configuration loading - -### Maintainability -- Use descriptive configuration keys -- Group related settings -- Document configuration options -- Use type hints and validation - -### Environment Management -- Use different configurations per environment -- Validate environment-specific settings -- Use feature flags for gradual rollouts -- Monitor configuration changes - -## Troubleshooting - -### Common Issues - -1. **Invalid Discord Token** - ``` - Error: Invalid token provided - Solution: Check your DISCORD_TOKEN in .env - ``` - -2. **Missing Permissions** - ``` - Error: Missing Access - Solution: Check bot permissions in Discord - ``` - -3. **Configuration Not Found** - ``` - Error: Configuration file not found - Solution: Ensure config/tempcord.php exists - ``` - -### Debug Configuration - -```php -// Enable debug mode -config(['tempcord.debug' => true]); - -// Dump configuration -dd(config('tempcord')); - -// Validate configuration -Tempcord::validateConfig(); -``` - -## Examples - -Check out the `examples/configuration/` directory for complete configuration examples and common setups. \ No newline at end of file diff --git a/docs/events.md b/docs/events.md deleted file mode 100644 index 15e9a76..0000000 --- a/docs/events.md +++ /dev/null @@ -1,544 +0,0 @@ -# Events - -Events allow your bot to respond to various activities that happen in Discord, such as messages being sent, users joining servers, or reactions being added to messages. - -## Event Listeners - -### Creating an Event Listener - -Create event listeners using the `#[EventListener]` attribute: - -```php -message; - - // Ignore bot messages - if ($message->author->bot) { - return; - } - - // Respond to specific content - if ($message->content === 'hello') { - $message->reply('Hello there!'); - } - } -} -``` - -### Event Method Naming - -Event listener methods should follow the pattern `on{EventName}`: - -- `onMessageCreate` for `MessageCreate` events -- `onGuildMemberAdd` for `GuildMemberAdd` events -- `onReactionAdd` for `ReactionAdd` events - -## Available Events - -### Message Events - -#### MessageCreate -Triggered when a message is sent: - -```php -use Tempcord\Events\MessageCreate; - -#[EventListener] -public function onMessageCreate(MessageCreate $event): void -{ - $message = $event->message; - $author = $message->author; - $content = $message->content; - $channel = $message->channel; - $guild = $message->guild; -} -``` - -#### MessageUpdate -Triggered when a message is edited: - -```php -use Tempcord\Events\MessageUpdate; - -#[EventListener] -public function onMessageUpdate(MessageUpdate $event): void -{ - $oldMessage = $event->oldMessage; // May be null if not cached - $newMessage = $event->newMessage; -} -``` - -#### MessageDelete -Triggered when a message is deleted: - -```php -use Tempcord\Events\MessageDelete; - -#[EventListener] -public function onMessageDelete(MessageDelete $event): void -{ - $message = $event->message; // May be null if not cached - $messageId = $event->messageId; - $channelId = $event->channelId; - $guildId = $event->guildId; -} -``` - -### Guild Events - -#### GuildMemberAdd -Triggered when a user joins a server: - -```php -use Tempcord\Events\GuildMemberAdd; - -#[EventListener] -public function onGuildMemberAdd(GuildMemberAdd $event): void -{ - $member = $event->member; - $guild = $member->guild; - $user = $member->user; - - // Send welcome message - $welcomeChannel = $guild->getChannel('welcome-channel-id'); - $welcomeChannel->send("Welcome {$user->mention()} to {$guild->name}!"); -} -``` - -#### GuildMemberRemove -Triggered when a user leaves a server: - -```php -use Tempcord\Events\GuildMemberRemove; - -#[EventListener] -public function onGuildMemberRemove(GuildMemberRemove $event): void -{ - $user = $event->user; - $guild = $event->guild; - - // Log the departure - $logChannel = $guild->getChannel('log-channel-id'); - $logChannel->send("{$user->tag} has left the server."); -} -``` - -#### GuildCreate -Triggered when the bot joins a new server: - -```php -use Tempcord\Events\GuildCreate; - -#[EventListener] -public function onGuildCreate(GuildCreate $event): void -{ - $guild = $event->guild; - - // Setup default configuration for new guild - $this->setupGuildDefaults($guild); -} -``` - -### Reaction Events - -#### ReactionAdd -Triggered when a reaction is added to a message: - -```php -use Tempcord\Events\ReactionAdd; - -#[EventListener] -public function onReactionAdd(ReactionAdd $event): void -{ - $reaction = $event->reaction; - $user = $event->user; - $message = $reaction->message; - $emoji = $reaction->emoji; - - // Role reactions - if ($message->id === 'role-message-id' && $emoji->name === '🎮') { - $role = $message->guild->getRole('gamer-role-id'); - $member = $message->guild->getMember($user->id); - $member->addRole($role); - } -} -``` - -#### ReactionRemove -Triggered when a reaction is removed from a message: - -```php -use Tempcord\Events\ReactionRemove; - -#[EventListener] -public function onReactionRemove(ReactionRemove $event): void -{ - $reaction = $event->reaction; - $user = $event->user; - $emoji = $reaction->emoji; - - // Remove role when reaction is removed - if ($emoji->name === '🎮') { - $role = $reaction->message->guild->getRole('gamer-role-id'); - $member = $reaction->message->guild->getMember($user->id); - $member->removeRole($role); - } -} -``` - -### Voice Events - -#### VoiceStateUpdate -Triggered when a user's voice state changes: - -```php -use Tempcord\Events\VoiceStateUpdate; - -#[EventListener] -public function onVoiceStateUpdate(VoiceStateUpdate $event): void -{ - $oldState = $event->oldState; - $newState = $event->newState; - $member = $newState->member; - - // User joined a voice channel - if (!$oldState->channel && $newState->channel) { - // Handle voice channel join - } - - // User left a voice channel - if ($oldState->channel && !$newState->channel) { - // Handle voice channel leave - } - - // User moved between channels - if ($oldState->channel && $newState->channel && $oldState->channel->id !== $newState->channel->id) { - // Handle voice channel move - } -} -``` - -## Event Filtering - -### Guild-Specific Listeners - -Listen to events only from specific guilds: - -```php -use Tempcord\Attributes\EventListener; -use Tempcord\Attributes\GuildOnly; -use Tempcord\Events\MessageCreate; - -class GuildMessageListener -{ - #[EventListener] - #[GuildOnly(['guild-id-1', 'guild-id-2'])] - public function onMessageCreate(MessageCreate $event): void - { - // Only processes messages from specified guilds - } -} -``` - -### Channel-Specific Listeners - -Listen to events only from specific channels: - -```php -use Tempcord\Attributes\EventListener; -use Tempcord\Attributes\ChannelOnly; -use Tempcord\Events\MessageCreate; - -class ChannelMessageListener -{ - #[EventListener] - #[ChannelOnly(['channel-id-1', 'channel-id-2'])] - public function onMessageCreate(MessageCreate $event): void - { - // Only processes messages from specified channels - } -} -``` - -### Conditional Listeners - -Use custom conditions to filter events: - -```php -use Tempcord\Attributes\EventListener; -use Tempcord\Events\MessageCreate; - -class ConditionalListener -{ - #[EventListener] - public function onMessageCreate(MessageCreate $event): void - { - $message = $event->message; - - // Only process messages with attachments - if (empty($message->attachments)) { - return; - } - - // Only process messages from non-bots - if ($message->author->bot) { - return; - } - - // Process the message - $this->processMessageWithAttachment($message); - } -} -``` - -## Event Priority - -Control the order in which event listeners are executed: - -```php -use Tempcord\Attributes\EventListener; -use Tempcord\Attributes\Priority; -use Tempcord\Events\MessageCreate; - -class HighPriorityListener -{ - #[EventListener] - #[Priority(100)] // Higher numbers = higher priority - public function onMessageCreate(MessageCreate $event): void - { - // This runs before lower priority listeners - } -} - -class LowPriorityListener -{ - #[EventListener] - #[Priority(10)] // Lower numbers = lower priority - public function onMessageCreate(MessageCreate $event): void - { - // This runs after higher priority listeners - } -} -``` - -## Async Event Handling - -For long-running event handlers, use async processing: - -```php -use Tempcord\Attributes\EventListener; -use Tempcord\Attributes\Async; -use Tempcord\Events\MessageCreate; - -class AsyncListener -{ - #[EventListener] - #[Async] - public function onMessageCreate(MessageCreate $event): void - { - // This runs asynchronously and won't block other events - $this->performLongRunningTask($event->message); - } - - private function performLongRunningTask($message): void - { - // Long-running operation like API calls, file processing, etc. - sleep(5); // This won't block other events - } -} -``` - -## Error Handling - -### Try-Catch in Listeners - -```php -use Tempcord\Attributes\EventListener; -use Tempcord\Events\MessageCreate; -use Exception; - -class SafeListener -{ - #[EventListener] - public function onMessageCreate(MessageCreate $event): void - { - try { - $this->processMessage($event->message); - } catch (Exception $e) { - // Log the error - logger()->error('Error processing message', [ - 'message_id' => $event->message->id, - 'error' => $e->getMessage(), - ]); - - // Optionally notify administrators - $this->notifyAdmins($e, $event->message); - } - } -} -``` - -### Global Error Handling - -Configure global error handling for events: - -```php -// In your configuration -use Tempcord\Config\TempcordConfig; - -return new TempcordConfig( - // ... other config - eventErrorHandler: function (Exception $e, $event) { - logger()->error('Event handler error', [ - 'event' => get_class($event), - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - } -); -``` - -## Event Data Access - -### Accessing Discord Objects - -```php -use Tempcord\Events\MessageCreate; - -#[EventListener] -public function onMessageCreate(MessageCreate $event): void -{ - $message = $event->message; - - // Message properties - $content = $message->content; - $id = $message->id; - $timestamp = $message->timestamp; - $editedTimestamp = $message->editedTimestamp; - $attachments = $message->attachments; - $embeds = $message->embeds; - $mentions = $message->mentions; - $reactions = $message->reactions; - - // Author information - $author = $message->author; - $authorId = $author->id; - $authorTag = $author->tag; - $authorAvatar = $author->avatar; - - // Channel information - $channel = $message->channel; - $channelId = $channel->id; - $channelName = $channel->name; - $channelType = $channel->type; - - // Guild information (if in a guild) - if ($message->guild) { - $guild = $message->guild; - $guildId = $guild->id; - $guildName = $guild->name; - $member = $message->member; // Guild member object - } -} -``` - -## Custom Events - -Create your own custom events: - -```php -getGuild($event->guildId); - $user = discord()->getUser($event->userId); - - $channel = $guild->getChannel('general'); - $channel->send("🎉 Congratulations {$user->mention()}! You reached level {$event->newLevel}!"); - } -} -``` - -## Best Practices - -### Performance -- Keep event handlers lightweight -- Use async processing for heavy operations -- Cache frequently accessed data -- Avoid blocking operations in event handlers - -### Error Handling -- Always wrap risky operations in try-catch blocks -- Log errors with sufficient context -- Don't let one event handler failure affect others -- Implement graceful degradation - -### Security -- Validate event data before processing -- Check permissions before performing actions -- Rate limit event-triggered actions -- Sanitize user input from events - -### Organization -- Group related listeners in the same class -- Use descriptive method names -- Keep listeners focused on single responsibilities -- Document complex event handling logic - -## Examples - -Check out the `examples/events/` directory for more event handling examples and patterns. \ No newline at end of file diff --git a/docs/getting-started.md b/docs/getting-started.md deleted file mode 100644 index 4d1f44e..0000000 --- a/docs/getting-started.md +++ /dev/null @@ -1,158 +0,0 @@ -# Getting Started with Tempcord - -Welcome to Tempcord! This guide will help you get up and running with your first Discord bot using the Tempcord framework. - -## Prerequisites - -Before you begin, make sure you have: - -- PHP 8.2 or higher -- Composer installed -- A Discord application and bot token -- Basic knowledge of PHP and Discord bots - -## Installation - -### Creating a New Project - -The easiest way to get started is by creating a new project using Composer: - -```bash -composer create-project tempcord/tempcord my-discord-bot -cd my-discord-bot -``` - -### Adding to Existing Project - -If you want to add Tempcord to an existing project: - -```bash -composer require tempcord/tempcord -``` - -## Discord Application Setup - -1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) -2. Click "New Application" and give it a name -3. Go to the "Bot" section and click "Add Bot" -4. Copy the bot token (you'll need this later) -5. Under "Privileged Gateway Intents", enable the intents your bot needs - -## Configuration - -### Environment Setup - -Copy the example environment file: - -```bash -cp .env.example .env -``` - -Edit the `.env` file with your Discord bot credentials: - -```env -DISCORD_TOKEN=your_bot_token_here -DISCORD_CLIENT_ID=your_client_id_here -``` - -### Basic Configuration - -Create or edit `app/config/tempcord.config.php`: - -```php -reply('🏓 Pong!'); - } -} -``` - -## Running Your Bot - -Start your bot with: - -```bash -php tempcord start -``` - -You should see output indicating that your bot is connecting to Discord. - -## Testing Your Bot - -1. Invite your bot to a Discord server using the OAuth2 URL generator in the Discord Developer Portal -2. In a channel where your bot has permissions, type `/ping` -3. Your bot should respond with "🏓 Pong!" - -## Next Steps - -Now that you have a basic bot running, you can: - -- [Learn about Commands](commands.md) -- [Explore Event Handling](events.md) -- [Add Middleware](middleware.md) -- [Configure Advanced Settings](configuration.md) - -## Troubleshooting - -### Common Issues - -**Bot doesn't respond to commands:** -- Check that your bot token is correct -- Ensure the bot has the necessary permissions in your Discord server -- Verify that the required intents are enabled - -**"Invalid token" error:** -- Double-check your bot token in the `.env` file -- Make sure there are no extra spaces or characters - -**Commands not registering:** -- Ensure `autoRegisterCommands` is set to `true` in your config -- Check that your command classes are in the correct namespace -- Verify that your command methods have the proper attributes - -### Getting Help - -If you're still having issues: - -1. Check the [API Reference](api-reference.md) -2. Look through existing [GitHub Issues](https://github.com/tempcord/tempcord/issues) -3. Join our Discord community for support -4. Create a new issue if you've found a bug - -## What's Next? - -Congratulations! You've successfully created your first Tempcord bot. Continue reading the documentation to learn about more advanced features and best practices. \ No newline at end of file diff --git a/docs/guides/01-getting-started.md b/docs/guides/01-getting-started.md new file mode 100644 index 0000000..81971ca --- /dev/null +++ b/docs/guides/01-getting-started.md @@ -0,0 +1,83 @@ +# Getting started + +Tempcord builds Discord bots on top of [Tempest](https://tempestphp.com). A command is a +class, its options are typed parameters, and the framework works out the rest at boot. + +## Requirements + +- PHP 8.5 or newer +- A Discord application with a bot token +- The intents your bot needs, enabled in the Discord developer portal + +## Installing + +```bash +composer create-project tempcord/tempcord my-bot +cd my-bot +``` + +Copy the environment file and add your token: + +```bash +cp .env.example .env +``` + +```env +DISCORD_TOKEN=your_bot_token_here +``` + +## Your first command + +A command is a class carrying `#[Command]`. If it declares an `__invoke` method, that method +handles it, and each parameter marked `#[Option]` becomes an option Discord shows the user. + +```php +use Ragnarok\Fenrir\Interaction\CommandInteraction; +use Tempcord\Attributes\Command; +use Tempcord\Attributes\Option; + +#[Command(description: 'Replies with pong')] +final class PingCommand +{ + public function __invoke( + CommandInteraction $interaction, + #[Option(description: 'Who to greet')] string $name, + #[Option(description: 'How many times')] int $times = 1, + ): string { + return $name . ':' . $times; + } +} +``` + +From [`tests/Fixtures/PingCommand.php`](../../tests/Fixtures/PingCommand.php) — compiled and exercised by the test suite. + +The name comes from the class — `PingCommand` becomes `/ping` — with a `Command` prefix or +suffix stripped and the rest snake_cased. Pass `name:` to choose it yourself. + +Whether an option is required comes from whether its parameter has a default. Here `name` is +required and `times` is not. + +## Registering and running + +Registration replaces the whole set of commands each time, so a command you delete from your +code disappears from Discord rather than lingering there: + +```bash +./tempcord boot --register +``` + +Once registered, boot without the flag: + +```bash +./tempcord boot +``` + +Registration needs a request to Discord, so run it when your commands change rather than on +every start. + +## Where to go next + +- [Commands and options](02-commands.md) — subcommands, groups, and every option constraint +- [Autocomplete](03-autocomplete.md) — suggesting values as the user types +- [Events](04-events.md) — reacting to what happens on the gateway +- [Translations](05-translations.md) — showing commands in the user's own language diff --git a/docs/guides/02-commands.md b/docs/guides/02-commands.md new file mode 100644 index 0000000..0736036 --- /dev/null +++ b/docs/guides/02-commands.md @@ -0,0 +1,163 @@ +# Commands and options + +## Subcommands + +A class whose methods carry `#[Subcommand]` exposes each one as a subcommand. There is no +`__invoke` in this case — the methods are the handlers. + +```php +use Tempcord\Attributes\Command; +use Tempcord\Attributes\Option; +use Tempcord\Attributes\Subcommand; + +#[Command(description: 'Moderation tools')] +final class ModerationCommand +{ + #[Subcommand(name: 'kick', description: 'Kick a member')] + public function kick( + #[Option(description: 'Reason for the kick')] string $reason, + ): string { + return 'kicked: ' . $reason; + } +} +``` + +From [`tests/Fixtures/ModerationCommand.php`](../../tests/Fixtures/ModerationCommand.php) — compiled and exercised by the test suite. + +That registers `/moderation kick`. + +## Grouping subcommands + +Adding `#[SubcommandGroup]` to the class nests everything one level deeper, which is how +Discord models `/command group subcommand`. + +```php +use Tempcord\Attributes\Command; +use Tempcord\Attributes\Option; +use Tempcord\Attributes\Subcommand; +use Tempcord\Attributes\SubcommandGroup; + +#[Command(description: 'Music controls')] +#[SubcommandGroup(name: 'playlist', description: 'Playlist controls')] +final class MusicCommand +{ + #[Subcommand(name: 'play', description: 'Play a track')] + public function play( + #[Option(description: 'Track title')] string $title, + ): string { + return 'playing ' . $title; + } + + #[Subcommand(name: 'stop', description: 'Stop playback')] + public function stop(): string + { + return 'stopped'; + } + + public function notASubcommand(): void {} +} +``` + +From [`tests/Fixtures/MusicCommand.php`](../../tests/Fixtures/MusicCommand.php) — compiled and exercised by the test suite. + +That registers `/music playlist play` and `/music playlist stop`. A method without +`#[Subcommand]` is ignored, so helpers can sit alongside handlers. + +## Option types + +The Discord option type is read from the parameter's PHP type: + +| PHP type | Discord option type | +| --- | --- | +| `string` | STRING | +| `int` | INTEGER | +| `float` | NUMBER | +| `bool` | BOOLEAN | +| `Ragnarok\Fenrir\Parts\User` | USER | +| `Ragnarok\Fenrir\Parts\Channel` | CHANNEL | +| `Ragnarok\Fenrir\Parts\Role` | ROLE | + +A parameter typed `User`, `Channel` or `Role` is fetched from Discord before your handler +runs, so you receive the entity rather than an id. + +An unsupported type fails at boot rather than on the first interaction that reaches it. + +## Receiving the interaction + +A parameter named `$interaction` receives the `CommandInteraction`, which is how you reply. +It needs no attribute. + +## Constraining what users may send + +Discord can enforce constraints before your handler is ever called, which is cheaper and +gives the user immediate feedback. + +```php +use Ragnarok\Fenrir\Enums\ChannelType; +use Ragnarok\Fenrir\Parts\Channel; +use Tempcord\Attributes\Command; +use Tempcord\Attributes\Option; + +#[Command(description: 'Every option constraint Discord accepts')] +final class ConstrainedCommand +{ + public function __invoke( + #[Option(description: 'A labelled set', choices: ['Small' => 's', 'Large' => 'l'])] + string $size, + #[Option(description: 'A bare list', choices: ['red', 'green'])] + string $colour, + #[Option(description: 'Bounded number', minValue: 1, maxValue: 10)] + int $count, + #[Option(description: 'Bounded text', minLength: 2, maxLength: 32)] + string $note, + #[Option(description: 'Text channels only', channelTypes: [ChannelType::GUILD_TEXT])] + Channel $channel, + ): void {} +} +``` + +From [`tests/Fixtures/ConstrainedCommand.php`](../../tests/Fixtures/ConstrainedCommand.php) — compiled and exercised by the test suite. + +Choices accept either shape. A map uses its keys as the labels users see; a list has no +labels of its own, so each value stands in as its own. + +## Restricting who may use a command + +`permissions` sets the default a member needs. An empty list leaves the command unrestricted. + +```php +use Ragnarok\Fenrir\Enums\Permission; +use Tempcord\Attributes\Command; + +#[Command( + description: 'Only for moderators', + permissions: [Permission::KICK_MEMBERS, Permission::BAN_MEMBERS], +)] +final class RestrictedCommand +{ + public function __invoke(): void {} +} +``` + +From [`tests/Fixtures/RestrictedCommand.php`](../../tests/Fixtures/RestrictedCommand.php) — compiled and exercised by the test suite. + +Server administrators can override this per guild, so treat it as a default rather than a +security boundary. + +## Scoping a command to one guild + +`guildId` registers a command in a single guild instead of globally. Guild commands appear +immediately, which makes them useful while developing; global commands can take up to an +hour to propagate. + +```php +use Tempcord\Attributes\Command; + +#[Command(name: 'alpha', description: 'Alpha, scoped to guild 111', guildId: 111)] +final class GuildAlphaCommand +{ + public function __invoke(): void {} +} +``` + +From [`tests/Fixtures/GuildAlphaCommand.php`](../../tests/Fixtures/GuildAlphaCommand.php) — compiled and exercised by the test suite. diff --git a/docs/guides/03-autocomplete.md b/docs/guides/03-autocomplete.md new file mode 100644 index 0000000..88fca59 --- /dev/null +++ b/docs/guides/03-autocomplete.md @@ -0,0 +1,69 @@ +# Autocomplete + +Autocomplete suggests values while the user is still typing, before they submit the command. + +## The built-in + +`ArrayAutocomplete` filters a fixed list by what has been typed so far. + +```php +use Ragnarok\Fenrir\Interaction\CommandInteraction; +use Tempcord\Attributes\Command; +use Tempcord\Attributes\Option; +use Tempcord\AutoCompletes\ArrayAutocomplete; + +#[Command(description: 'Suggests as you type')] +final class SearchCommand +{ + public function __invoke( + CommandInteraction $interaction, + #[Option(description: 'What to look for', autocomplete: new ArrayAutocomplete(['alpha', 'beta', 'gamma']))] + string $query, + #[Option(description: 'Plain option with no suggestions')] string $note = '', + ): void {} +} +``` + +From [`tests/Fixtures/SearchCommand.php`](../../tests/Fixtures/SearchCommand.php) — compiled and exercised by the test suite. + +## Writing your own + +Anything implementing `Autocomplete` can supply suggestions, which is what you want when +they come from a database or an API. + +```php +use Ragnarok\Fenrir\Interaction\CommandInteraction; +use Tempcord\Interfaces\Autocomplete; + +final readonly class TrackAutocomplete implements Autocomplete +{ + public function __construct( + private TrackRepository $tracks, + ) {} + + public function handle(CommandInteraction $interaction, mixed $value): array + { + return $this->tracks->matching((string) $value); + } +} +``` + +## What you may return + +| Return | Result | +| --- | --- | +| A list | Each value is shown as its own label | +| A map | Keys are the labels users see, values are what your handler receives | +| A single scalar | One suggestion | +| `ApplicationCommandOptionChoice` objects | Passed through untouched | + +Discord accepts at most 25 choices and rejects a response carrying more, so anything beyond +that is dropped before sending. + +## Timing + +Discord expects an autocomplete response within about three seconds. Keep the work small, +and cache rather than querying on every keystroke. + +Note that an autocomplete implementation is constructed as part of the attribute, so it is +rebuilt whenever the command tree is read. Hold configuration in it, not a warm cache. diff --git a/docs/guides/04-events.md b/docs/guides/04-events.md new file mode 100644 index 0000000..386c626 --- /dev/null +++ b/docs/guides/04-events.md @@ -0,0 +1,57 @@ +# Events + +Events let a bot react to what happens on the gateway rather than to a command. + +## Listening + +An invokable class carrying `#[Event]` becomes a listener. The event name is the gateway +event you want; the payload arrives as the single argument. + +```php +use Tempcord\Attributes\Event; + +#[Event(name: 'READY')] +final class ReadyListener +{ + /** @var list */ + public static array $received = []; + + public function __invoke(object $payload): void + { + self::$received[] = $payload; + } +} +``` + +From [`tests/Fixtures/ReadyListener.php`](../../tests/Fixtures/ReadyListener.php) — compiled and exercised by the test suite. + +The class is resolved from the container, so a listener may take constructor dependencies. + +## Intents + +Discord only sends events your bot subscribed to. Intents are set in your configuration: + +```php +use Ragnarok\Fenrir\Bitwise\Bitwise; +use Ragnarok\Fenrir\Enums\Intent; + +return new TempcordConfig( + token: env('DISCORD_TOKEN'), + intents: Bitwise::from( + Intent::GUILDS, + Intent::GUILD_MESSAGES, + Intent::MESSAGE_CONTENT, + ), +); +``` + +A listener for an event you have no intent for is registered and simply never fires, which is +a common reason for a listener that appears to do nothing. + +`MESSAGE_CONTENT`, `GUILD_MEMBERS` and `GUILD_PRESENCES` are privileged: they must also be +enabled in the Discord developer portal, and above a hundred guilds they need approval. + +## Errors + +A listener that throws is logged and contained; the gateway connection carries on rather than +the bot falling over. diff --git a/docs/guides/05-translations.md b/docs/guides/05-translations.md new file mode 100644 index 0000000..8012db3 --- /dev/null +++ b/docs/guides/05-translations.md @@ -0,0 +1,79 @@ +# Translations + +Discord can show a command's name and description in each user's own language. Tempcord reads +those from Tempest's translation catalog, so they live in the same files as the rest of your +application's translations rather than inline in PHP. + +## Setup + +Translations need `tempest/intl`, which is not installed by default because it requires the +`intl` and `dom` extensions: + +```bash +composer require tempest/intl +``` + +Without it, commands register with their declared names and descriptions and nothing else. + +## Declaring a key + +A command declares one key. Everything beneath it follows from its position in the tree. + +```php +use Tempcord\Attributes\Command; +use Tempcord\Attributes\Option; +use Tempcord\Attributes\Subcommand; +use Tempcord\Attributes\SubcommandGroup; + +#[Command(description: 'Music controls', translationKey: 'commands.music')] +#[SubcommandGroup(name: 'playlist', description: 'Playlist controls')] +final class LocalizedCommand +{ + #[Subcommand(name: 'play', description: 'Play a track')] + public function play( + #[Option(description: 'Track title')] string $title, + ): void {} +} +``` + +From [`tests/Fixtures/LocalizedCommand.php`](../../tests/Fixtures/LocalizedCommand.php) — compiled and exercised by the test suite. + +That reads these keys: + +``` +commands.music.name +commands.music.description +commands.music.playlist.name +commands.music.playlist.description +commands.music.playlist.play.name +commands.music.playlist.play.description +commands.music.playlist.play.title.name +commands.music.playlist.play.title.description +``` + +An option on an invokable command hangs directly off the command's key — +`commands.greet.name.description` for an option named `name`. + +## Providing the translations + +Anywhere Tempest reads translations from: + +```json +{ + "commands.music.description": "Musiksteuerung", + "commands.music.playlist.description": "Wiedergabeliste", + "commands.music.playlist.play.description": "Titel abspielen", + "commands.music.playlist.play.title.description": "Titelname" +} +``` + +## What gets sent + +Only locales that actually have a translation. A missing one is left out rather than filled +in, so Discord falls back to the declared text — which is why a partly translated command is +perfectly fine. + +Discord accepts 34 locales; see [DiscordLocale](../reference/enums/discord-locale.md) for the +full list and how each maps onto a Tempest locale. + +A command with no `translationKey` reads nothing and sends no localization fields at all. diff --git a/docs/index.json b/docs/index.json new file mode 100644 index 0000000..336d5a9 --- /dev/null +++ b/docs/index.json @@ -0,0 +1,503 @@ +{ + "guides": [ + { + "title": "Getting started", + "slug": "guides/01-getting-started" + }, + { + "title": "Commands and options", + "slug": "guides/02-commands" + }, + { + "title": "Autocomplete", + "slug": "guides/03-autocomplete" + }, + { + "title": "Events", + "slug": "guides/04-events" + }, + { + "title": "Translations", + "slug": "guides/05-translations" + } + ], + "reference": { + "attributes": [ + { + "name": "Command", + "fqcn": "Tempcord\\Attributes\\Command", + "kind": "attribute", + "target": "class, method", + "summary": "Declares a class as a Discord application command.", + "slug": "reference/attributes/command", + "parameters": [ + { + "name": "name", + "type": "BackedEnum|string|null", + "default": "null", + "required": false, + "summary": "defaults to the class name, with a Command prefix or suffix stripped and the rest snake_cased" + }, + { + "name": "description", + "type": "?string", + "default": "null", + "required": false, + "summary": "" + }, + { + "name": "guildId", + "type": "string|int|null", + "default": "null", + "required": false, + "summary": "" + }, + { + "name": "isNsfw", + "type": "bool", + "default": "false", + "required": false, + "summary": "" + }, + { + "name": "permissions", + "type": "array", + "default": "[]", + "required": false, + "summary": "the permissions a member needs by default; an empty list leaves the command unrestricted" + }, + { + "name": "directMessage", + "type": "bool", + "default": "true", + "required": false, + "summary": "" + }, + { + "name": "type", + "type": "ApplicationCommandTypes", + "default": "ApplicationCommandTypes::CHAT_INPUT", + "required": false, + "summary": "" + }, + { + "name": "translationKey", + "type": "?string", + "default": "null", + "required": false, + "summary": "the catalog key this command's translations live under. Keys for everything beneath it are derived from position, so \"commands.music\" gives commands.music.description for the command, commands.music.playlist.play.description for a subcommand, and commands.music.playlist.play.title.description for its option." + } + ], + "cases": [], + "methods": [] + }, + { + "name": "SubcommandGroup", + "fqcn": "Tempcord\\Attributes\\SubcommandGroup", + "kind": "attribute", + "target": "class", + "summary": "Groups every subcommand its class declares under one more level of nesting.", + "slug": "reference/attributes/subcommand-group", + "parameters": [ + { + "name": "name", + "type": "BackedEnum|string", + "default": null, + "required": true, + "summary": "" + }, + { + "name": "description", + "type": "string", + "default": null, + "required": true, + "summary": "" + } + ], + "cases": [], + "methods": [] + }, + { + "name": "Subcommand", + "fqcn": "Tempcord\\Attributes\\Subcommand", + "kind": "attribute", + "target": "method", + "summary": "Declares a public method as a subcommand of the command its class declares.", + "slug": "reference/attributes/subcommand", + "parameters": [ + { + "name": "name", + "type": "BackedEnum|string", + "default": null, + "required": true, + "summary": "" + }, + { + "name": "description", + "type": "string", + "default": null, + "required": true, + "summary": "" + } + ], + "cases": [], + "methods": [] + }, + { + "name": "Option", + "fqcn": "Tempcord\\Attributes\\Option", + "kind": "attribute", + "target": "parameter", + "summary": "Declares a method parameter as a user-supplied command option.", + "slug": "reference/attributes/option", + "parameters": [ + { + "name": "description", + "type": "string", + "default": null, + "required": true, + "summary": "shown beneath the option in Discord's picker" + }, + { + "name": "name", + "type": "?string", + "default": "null", + "required": false, + "summary": "defaults to the parameter's own name" + }, + { + "name": "autocomplete", + "type": "?Autocomplete", + "default": "null", + "required": false, + "summary": "suggests values as the user types; mutually exclusive with choices" + }, + { + "name": "choices", + "type": "array", + "default": "[]", + "required": false, + "summary": "the only values Discord will accept. A map uses its keys as the labels users see; a list shows each value as its own label. Mutually exclusive with autocomplete." + }, + { + "name": "minValue", + "type": "int|float|null", + "default": "null", + "required": false, + "summary": "smallest accepted number" + }, + { + "name": "maxValue", + "type": "int|float|null", + "default": "null", + "required": false, + "summary": "largest accepted number" + }, + { + "name": "minLength", + "type": "?int", + "default": "null", + "required": false, + "summary": "shortest accepted string" + }, + { + "name": "maxLength", + "type": "?int", + "default": "null", + "required": false, + "summary": "longest accepted string" + }, + { + "name": "channelTypes", + "type": "array", + "default": "[]", + "required": false, + "summary": "restricts which channels may be picked, for a Channel option" + } + ], + "cases": [], + "methods": [] + }, + { + "name": "Event", + "fqcn": "Tempcord\\Attributes\\Event", + "kind": "attribute", + "target": "class", + "summary": "Declares an invokable class as a listener for a Discord gateway event.", + "slug": "reference/attributes/event", + "parameters": [ + { + "name": "name", + "type": "string", + "default": null, + "required": true, + "summary": "" + } + ], + "cases": [], + "methods": [] + } + ], + "autocomplete": [ + { + "name": "Autocomplete", + "fqcn": "Tempcord\\Interfaces\\Autocomplete", + "kind": "interface", + "target": null, + "summary": "", + "slug": "reference/autocomplete/autocomplete", + "parameters": [], + "cases": [], + "methods": [ + { + "signature": "handle(CommandInteraction $interaction, mixed $value): mixed", + "summary": "" + } + ] + }, + { + "name": "ArrayAutocomplete", + "fqcn": "Tempcord\\AutoCompletes\\ArrayAutocomplete", + "kind": "class", + "target": null, + "summary": "", + "slug": "reference/autocomplete/array-autocomplete", + "parameters": [ + { + "name": "items", + "type": "array", + "default": null, + "required": true, + "summary": "" + }, + { + "name": "isList", + "type": "bool", + "default": "false", + "required": false, + "summary": "" + } + ], + "cases": [], + "methods": [] + } + ], + "configuration": [ + { + "name": "TempcordConfig", + "fqcn": "Tempcord\\TempcordConfig", + "kind": "class", + "target": null, + "summary": "", + "slug": "reference/configuration/tempcord-config", + "parameters": [ + { + "name": "token", + "type": "string", + "default": null, + "required": true, + "summary": "" + }, + { + "name": "intents", + "type": "Bitwise", + "default": null, + "required": true, + "summary": "" + } + ], + "cases": [], + "methods": [] + } + ], + "enums": [ + { + "name": "DiscordLocale", + "fqcn": "Tempcord\\Enums\\DiscordLocale", + "kind": "enum", + "target": null, + "summary": "The locales Discord accepts for name and description localizations.", + "slug": "reference/enums/discord-locale", + "parameters": [], + "cases": [ + { + "name": "INDONESIAN", + "value": "id", + "note": "" + }, + { + "name": "DANISH", + "value": "da", + "note": "" + }, + { + "name": "GERMAN", + "value": "de", + "note": "" + }, + { + "name": "ENGLISH_UK", + "value": "en-GB", + "note": "" + }, + { + "name": "ENGLISH_US", + "value": "en-US", + "note": "" + }, + { + "name": "SPANISH", + "value": "es-ES", + "note": "" + }, + { + "name": "SPANISH_LATAM", + "value": "es-419", + "note": "" + }, + { + "name": "FRENCH", + "value": "fr", + "note": "" + }, + { + "name": "CROATIAN", + "value": "hr", + "note": "" + }, + { + "name": "ITALIAN", + "value": "it", + "note": "" + }, + { + "name": "LITHUANIAN", + "value": "lt", + "note": "" + }, + { + "name": "HUNGARIAN", + "value": "hu", + "note": "" + }, + { + "name": "DUTCH", + "value": "nl", + "note": "" + }, + { + "name": "NORWEGIAN", + "value": "no", + "note": "" + }, + { + "name": "POLISH", + "value": "pl", + "note": "" + }, + { + "name": "PORTUGUESE_BR", + "value": "pt-BR", + "note": "" + }, + { + "name": "ROMANIAN", + "value": "ro", + "note": "" + }, + { + "name": "FINNISH", + "value": "fi", + "note": "" + }, + { + "name": "SWEDISH", + "value": "sv-SE", + "note": "" + }, + { + "name": "VIETNAMESE", + "value": "vi", + "note": "" + }, + { + "name": "TURKISH", + "value": "tr", + "note": "" + }, + { + "name": "CZECH", + "value": "cs", + "note": "" + }, + { + "name": "GREEK", + "value": "el", + "note": "" + }, + { + "name": "BULGARIAN", + "value": "bg", + "note": "" + }, + { + "name": "RUSSIAN", + "value": "ru", + "note": "" + }, + { + "name": "UKRAINIAN", + "value": "uk", + "note": "" + }, + { + "name": "HINDI", + "value": "hi", + "note": "" + }, + { + "name": "THAI", + "value": "th", + "note": "" + }, + { + "name": "CHINESE_CHINA", + "value": "zh-CN", + "note": "" + }, + { + "name": "JAPANESE", + "value": "ja", + "note": "" + }, + { + "name": "CHINESE_TAIWAN", + "value": "zh-TW", + "note": "" + }, + { + "name": "KOREAN", + "value": "ko", + "note": "" + }, + { + "name": "ARABIC", + "value": "ar", + "note": "" + }, + { + "name": "HEBREW", + "value": "he", + "note": "" + } + ], + "methods": [ + { + "signature": "tempestLocale(): string", + "summary": "The Tempest locale this maps to." + } + ] + } + ] + } +} diff --git a/docs/middleware.md b/docs/middleware.md deleted file mode 100644 index 5e466e6..0000000 --- a/docs/middleware.md +++ /dev/null @@ -1,639 +0,0 @@ -# Middleware - -Middleware provides a convenient mechanism for filtering and modifying requests before they reach your command handlers. You can use middleware for authentication, logging, rate limiting, and more. - -## Creating Middleware - -### Basic Middleware - -Create middleware by implementing the `MiddlewareInterface`: - -```php -info('Command executed', [ - 'command' => $interaction->commandName, - 'user' => $interaction->user->tag, - 'guild' => $interaction->guild?->name, - ]); - - // Execute the command - $result = $next($interaction); - - // After the command - logger()->info('Command completed', [ - 'command' => $interaction->commandName, - ]); - - return $result; - } -} -``` - -### Middleware with Parameters - -```php -user->id; - $key = "rate_limit:{$userId}"; - - $requests = cache()->get($key, 0); - - if ($requests >= $this->maxRequests) { - $interaction->reply( - '⚠️ You are being rate limited. Please try again later.', - ephemeral: true - ); - return; - } - - cache()->put($key, $requests + 1, $this->windowSeconds); - - return $next($interaction); - } -} -``` - -## Applying Middleware - -### Global Middleware - -Apply middleware to all commands: - -```php -// In your configuration -use Tempcord\Config\TempcordConfig; -use App\Middleware\LoggingMiddleware; -use App\Middleware\RateLimitMiddleware; - -return new TempcordConfig( - // ... other config - globalMiddleware: [ - LoggingMiddleware::class, - RateLimitMiddleware::class, - ] -); -``` - -### Command-Specific Middleware - -Apply middleware to specific commands: - -```php -guild; // Will never be null - } -} -``` - -### Owner Only Middleware - -Restrict commands to bot owners: - -```php -userService->findByDiscordId($interaction->user->id); - - if (!$user) { - $interaction->reply( - '❌ You need to register first. Use `/register` to get started.', - ephemeral: true - ); - return; - } - - // Add user to interaction context - $interaction->setContext('user', $user); - - return $next($interaction); - } -} -``` - -### Cooldown Middleware - -```php -user->id; - $commandName = $interaction->commandName; - $key = "cooldown:{$userId}:{$commandName}"; - - $lastUsed = cache()->get($key); - - if ($lastUsed && (time() - $lastUsed) < $this->cooldownSeconds) { - $remaining = $this->cooldownSeconds - (time() - $lastUsed); - $interaction->reply( - "⏰ Please wait {$remaining} seconds before using this command again.", - ephemeral: true - ); - return; - } - - cache()->put($key, time(), $this->cooldownSeconds); - - return $next($interaction); - } -} -``` - -### Maintenance Mode Middleware - -```php -user->id, config('bot.owners', []))) { - $interaction->reply( - '🔧 The bot is currently under maintenance. Please try again later.', - ephemeral: true - ); - return; - } - } - - return $next($interaction); - } -} -``` - -### Audit Log Middleware - -```php -auditLog->log([ - 'command' => $interaction->commandName, - 'user_id' => $interaction->user->id, - 'guild_id' => $interaction->guild?->id, - 'channel_id' => $interaction->channel->id, - 'options' => $interaction->options, - 'execution_time' => microtime(true) - $startTime, - 'status' => 'success', - 'timestamp' => now(), - ]); - - return $result; - } catch (Exception $e) { - $this->auditLog->log([ - 'command' => $interaction->commandName, - 'user_id' => $interaction->user->id, - 'guild_id' => $interaction->guild?->id, - 'channel_id' => $interaction->channel->id, - 'options' => $interaction->options, - 'execution_time' => microtime(true) - $startTime, - 'status' => 'error', - 'error' => $e->getMessage(), - 'timestamp' => now(), - ]); - - throw $e; - } - } -} -``` - -## Middleware Priority - -Control the order in which middleware is executed: - -```php -commandName, ['sensitive-command', 'admin-command'])) { - return $next($interaction); - } - - // Only apply in specific guilds - if (!in_array($interaction->guild?->id, ['guild-1', 'guild-2'])) { - return $next($interaction); - } - - // Apply middleware logic - return $this->applyMiddleware($interaction, $next); - } - - private function applyMiddleware(Interaction $interaction, callable $next): mixed - { - // Middleware logic here - return $next($interaction); - } -} -``` - -## Error Handling in Middleware - -```php -error('Command execution failed', [ - 'command' => $interaction->commandName, - 'user' => $interaction->user->tag, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - // Send user-friendly error message - $interaction->reply( - '❌ An error occurred while processing your command. Please try again later.', - ephemeral: true - ); - - // Optionally re-throw for global error handling - // throw $e; - } - } -} -``` - -## Middleware Groups - -Create reusable middleware groups: - -```php - [ - AdminOnlyMiddleware::class, - AuditLogMiddleware::class, - RateLimitMiddleware::class, - ], - 'public' => [ - LoggingMiddleware::class, - CooldownMiddleware::class, - ], - ] -); -``` - -Use middleware groups in commands: - -```php -user = (object) ['id' => '123']; - - $nextCalled = false; - $next = function () use (&$nextCalled) { - $nextCalled = true; - return 'success'; - }; - - $result = $middleware->handle($interaction, $next); - - $this->assertTrue($nextCalled); - $this->assertEquals('success', $result); - } - - public function test_blocks_request_over_limit(): void - { - // Test rate limiting logic - } -} -``` - -## Best Practices - -### Performance -- Keep middleware lightweight -- Cache expensive operations -- Use early returns when possible -- Avoid blocking operations - -### Security -- Validate input in middleware -- Implement proper authentication -- Use rate limiting to prevent abuse -- Log security-relevant events - -### Organization -- Keep middleware focused on single responsibilities -- Use descriptive names -- Group related middleware -- Document complex middleware logic - -### Error Handling -- Handle errors gracefully -- Provide user-friendly error messages -- Log errors with sufficient context -- Don't expose sensitive information - -## Examples - -Check out the `examples/middleware/` directory for more middleware examples and patterns. \ No newline at end of file diff --git a/docs/reference/attributes/command.md b/docs/reference/attributes/command.md new file mode 100644 index 0000000..ead988b --- /dev/null +++ b/docs/reference/attributes/command.md @@ -0,0 +1,25 @@ + + +# Command + +Declares a class as a Discord application command. + +```php +use Tempcord\Attributes\Command; +``` + +**Applies to:** class, method + +## Parameters + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `BackedEnum\|string\|null` | `null` | defaults to the class name, with a Command prefix or suffix stripped and the rest snake_cased | +| `description` | `?string` | `null` | | +| `guildId` | `string\|int\|null` | `null` | | +| `isNsfw` | `bool` | `false` | | +| `permissions` | `array` | `[]` | the permissions a member needs by default; an empty list leaves the command unrestricted | +| `directMessage` | `bool` | `true` | | +| `type` | `ApplicationCommandTypes` | `ApplicationCommandTypes::CHAT_INPUT` | | +| `translationKey` | `?string` | `null` | the catalog key this command's translations live under. Keys for everything beneath it are derived from position, so "commands.music" gives commands.music.description for the command, commands.music.playlist.play.description for a subcommand, and commands.music.playlist.play.title.description for its option. | + diff --git a/docs/reference/attributes/event.md b/docs/reference/attributes/event.md new file mode 100644 index 0000000..2dddacb --- /dev/null +++ b/docs/reference/attributes/event.md @@ -0,0 +1,18 @@ + + +# Event + +Declares an invokable class as a listener for a Discord gateway event. + +```php +use Tempcord\Attributes\Event; +``` + +**Applies to:** class + +## Parameters + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `string` | *required* | | + diff --git a/docs/reference/attributes/option.md b/docs/reference/attributes/option.md new file mode 100644 index 0000000..24d4a35 --- /dev/null +++ b/docs/reference/attributes/option.md @@ -0,0 +1,26 @@ + + +# Option + +Declares a method parameter as a user-supplied command option. + +```php +use Tempcord\Attributes\Option; +``` + +**Applies to:** parameter + +## Parameters + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `description` | `string` | *required* | shown beneath the option in Discord's picker | +| `name` | `?string` | `null` | defaults to the parameter's own name | +| `autocomplete` | `?Autocomplete` | `null` | suggests values as the user types; mutually exclusive with choices | +| `choices` | `array` | `[]` | the only values Discord will accept. A map uses its keys as the labels users see; a list shows each value as its own label. Mutually exclusive with autocomplete. | +| `minValue` | `int\|float\|null` | `null` | smallest accepted number | +| `maxValue` | `int\|float\|null` | `null` | largest accepted number | +| `minLength` | `?int` | `null` | shortest accepted string | +| `maxLength` | `?int` | `null` | longest accepted string | +| `channelTypes` | `array` | `[]` | restricts which channels may be picked, for a Channel option | + diff --git a/docs/reference/attributes/subcommand-group.md b/docs/reference/attributes/subcommand-group.md new file mode 100644 index 0000000..e1e60be --- /dev/null +++ b/docs/reference/attributes/subcommand-group.md @@ -0,0 +1,19 @@ + + +# SubcommandGroup + +Groups every subcommand its class declares under one more level of nesting. + +```php +use Tempcord\Attributes\SubcommandGroup; +``` + +**Applies to:** class + +## Parameters + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `BackedEnum\|string` | *required* | | +| `description` | `string` | *required* | | + diff --git a/docs/reference/attributes/subcommand.md b/docs/reference/attributes/subcommand.md new file mode 100644 index 0000000..e882865 --- /dev/null +++ b/docs/reference/attributes/subcommand.md @@ -0,0 +1,19 @@ + + +# Subcommand + +Declares a public method as a subcommand of the command its class declares. + +```php +use Tempcord\Attributes\Subcommand; +``` + +**Applies to:** method + +## Parameters + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `BackedEnum\|string` | *required* | | +| `description` | `string` | *required* | | + diff --git a/docs/reference/autocomplete/array-autocomplete.md b/docs/reference/autocomplete/array-autocomplete.md new file mode 100644 index 0000000..328df52 --- /dev/null +++ b/docs/reference/autocomplete/array-autocomplete.md @@ -0,0 +1,15 @@ + + +# ArrayAutocomplete + +```php +use Tempcord\AutoCompletes\ArrayAutocomplete; +``` + +## Parameters + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `items` | `array` | *required* | | +| `isList` | `bool` | `false` | | + diff --git a/docs/reference/autocomplete/autocomplete.md b/docs/reference/autocomplete/autocomplete.md new file mode 100644 index 0000000..bebf4cf --- /dev/null +++ b/docs/reference/autocomplete/autocomplete.md @@ -0,0 +1,12 @@ + + +# Autocomplete + +```php +use Tempcord\Interfaces\Autocomplete; +``` + +## Methods + +### `handle(CommandInteraction $interaction, mixed $value): mixed` + diff --git a/docs/reference/configuration/tempcord-config.md b/docs/reference/configuration/tempcord-config.md new file mode 100644 index 0000000..8c1d311 --- /dev/null +++ b/docs/reference/configuration/tempcord-config.md @@ -0,0 +1,15 @@ + + +# TempcordConfig + +```php +use Tempcord\TempcordConfig; +``` + +## Parameters + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `token` | `string` | *required* | | +| `intents` | `Bitwise` | *required* | | + diff --git a/docs/reference/enums/discord-locale.md b/docs/reference/enums/discord-locale.md new file mode 100644 index 0000000..cf2e93c --- /dev/null +++ b/docs/reference/enums/discord-locale.md @@ -0,0 +1,55 @@ + + +# DiscordLocale + +The locales Discord accepts for name and description localizations. + +```php +use Tempcord\Enums\DiscordLocale; +``` + +## Methods + +### `tempestLocale(): string` + +The Tempest locale this maps to. + +## Cases + +| Case | Value | +| --- | --- | +| `INDONESIAN` | `id` | +| `DANISH` | `da` | +| `GERMAN` | `de` | +| `ENGLISH_UK` | `en-GB` | +| `ENGLISH_US` | `en-US` | +| `SPANISH` | `es-ES` | +| `SPANISH_LATAM` | `es-419` | +| `FRENCH` | `fr` | +| `CROATIAN` | `hr` | +| `ITALIAN` | `it` | +| `LITHUANIAN` | `lt` | +| `HUNGARIAN` | `hu` | +| `DUTCH` | `nl` | +| `NORWEGIAN` | `no` | +| `POLISH` | `pl` | +| `PORTUGUESE_BR` | `pt-BR` | +| `ROMANIAN` | `ro` | +| `FINNISH` | `fi` | +| `SWEDISH` | `sv-SE` | +| `VIETNAMESE` | `vi` | +| `TURKISH` | `tr` | +| `CZECH` | `cs` | +| `GREEK` | `el` | +| `BULGARIAN` | `bg` | +| `RUSSIAN` | `ru` | +| `UKRAINIAN` | `uk` | +| `HINDI` | `hi` | +| `THAI` | `th` | +| `CHINESE_CHINA` | `zh-CN` | +| `JAPANESE` | `ja` | +| `CHINESE_TAIWAN` | `zh-TW` | +| `KOREAN` | `ko` | +| `ARABIC` | `ar` | +| `HEBREW` | `he` | + diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 0000000..a0f268d --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,25 @@ + + +# API reference + +## Attributes + +- [Command](attributes/command.md) — Declares a class as a Discord application command. +- [SubcommandGroup](attributes/subcommand-group.md) — Groups every subcommand its class declares under one more level of nesting. +- [Subcommand](attributes/subcommand.md) — Declares a public method as a subcommand of the command its class declares. +- [Option](attributes/option.md) — Declares a method parameter as a user-supplied command option. +- [Event](attributes/event.md) — Declares an invokable class as a listener for a Discord gateway event. + +## Autocomplete + +- [Autocomplete](autocomplete/autocomplete.md) +- [ArrayAutocomplete](autocomplete/array-autocomplete.md) + +## Configuration + +- [TempcordConfig](configuration/tempcord-config.md) + +## Enums + +- [DiscordLocale](enums/discord-locale.md) — The locales Discord accepts for name and description localizations. + diff --git a/phpstan.neon b/phpstan.neon index 9933d7c..b35a2d9 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,5 +3,6 @@ parameters: paths: - src - tests + - tools excludePaths: - tests/Fixtures diff --git a/src/Attributes/Option.php b/src/Attributes/Option.php index 7b49b90..57f8618 100644 --- a/src/Attributes/Option.php +++ b/src/Attributes/Option.php @@ -17,7 +17,10 @@ final readonly class Option { /** + * @param string $description shown beneath the option in Discord's picker * @param string|null $name defaults to the parameter's own name + * @param Autocomplete|null $autocomplete suggests values as the user types; + * mutually exclusive with choices * @param array|list $choices * the only values Discord will accept. A map uses its keys as the * labels users see; a list shows each value as its own label. diff --git a/tests/Unit/Tools/DocsGeneratorTest.php b/tests/Unit/Tools/DocsGeneratorTest.php new file mode 100644 index 0000000..21a47d8 --- /dev/null +++ b/tests/Unit/Tools/DocsGeneratorTest.php @@ -0,0 +1,172 @@ +generate() as $path => $expected) { + $committed = self::root() . '/docs/' . $path; + + if (!is_file($committed)) { + $stale[] = $path . ' (missing)'; + continue; + } + + if (file_get_contents($committed) !== $expected) { + $stale[] = $path . ' (differs)'; + } + } + + $this->assertSame([], $stale, "Run `composer docs` to bring the documentation back in line."); + } + + /** + * Guides carry no code of their own; every example is a real file the suite + * already compiles, so an example cannot silently stop being true. + */ + public function test_every_guide_example_comes_from_a_file_that_exists(): void + { + $compiler = new GuideCompiler(self::root()); + $missing = []; + $included = 0; + + foreach (glob(self::root() . '/tools/guides/*.md') ?: [] as $guide) { + foreach ($compiler->includedPaths((string) file_get_contents($guide)) as $path) { + $included++; + + if (!is_file(self::root() . '/' . $path)) { + $missing[] = basename($guide) . ' includes ' . $path; + } + } + } + + $this->assertSame([], $missing); + $this->assertGreaterThan(0, $included, 'Guides should show real code rather than describing it'); + } + + public function test_including_a_file_that_does_not_exist_is_an_error(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('which does not exist'); + + new GuideCompiler(self::root())->compile(''); + } + + /** + * Only what a bot author writes. The compiler, definitions and runtime are + * internal, and documenting them would invite people to depend on them. + */ + public function test_the_reference_covers_the_public_surface(): void + { + $names = []; + + foreach (new ApiReflector()->reflect() as $symbols) { + foreach ($symbols as $symbol) { + $names[] = $symbol->name; + } + } + + foreach (['Command', 'Subcommand', 'SubcommandGroup', 'Option', 'Event', 'Autocomplete'] as $expected) { + $this->assertContains($expected, $names); + } + + $this->assertNotContains('CommandCompiler', $names); + $this->assertNotContains('CommandDefinition', $names); + } + + public function test_attribute_targets_are_read_from_the_attribute_itself(): void + { + $byName = []; + + foreach (new ApiReflector()->reflect()['attributes'] as $symbol) { + $byName[$symbol->name] = $symbol; + } + + $this->assertSame('parameter', $byName['Option']->target); + $this->assertSame('method', $byName['Subcommand']->target); + $this->assertSame('class', $byName['SubcommandGroup']->target); + } + + public function test_parameter_defaults_and_types_are_reported(): void + { + $option = null; + + foreach (new ApiReflector()->reflect()['attributes'] as $symbol) { + if ($symbol->name === 'Option') { + $option = $symbol; + } + } + + $byName = []; + + foreach ($option->parameters as $parameter) { + $byName[$parameter->name] = $parameter; + } + + $this->assertTrue($byName['description']->isRequired()); + $this->assertFalse($byName['minLength']->isRequired()); + $this->assertSame('?int', $byName['minLength']->type); + $this->assertSame('null', $byName['minLength']->default); + } + + /** + * A type containing a space, as in array, used to defeat the + * docblock parser and silently drop the description. + */ + public function test_a_parameter_typed_with_a_space_keeps_its_description(): void + { + foreach (new ApiReflector()->reflect()['attributes'] as $symbol) { + if ($symbol->name !== 'Option') { + continue; + } + + foreach ($symbol->parameters as $parameter) { + if ($parameter->name === 'choices') { + $this->assertStringContainsString('the only values Discord will accept', $parameter->summary); + + return; + } + } + } + + $this->fail('Option should declare a choices parameter'); + } + + public function test_the_json_index_is_valid_and_carries_both_halves(): void + { + $files = new DocsGenerator(self::root())->generate(); + + $index = json_decode($files['index.json'], true, flags: JSON_THROW_ON_ERROR); + + $this->assertArrayHasKey('guides', $index); + $this->assertArrayHasKey('reference', $index); + $this->assertNotEmpty($index['guides']); + $this->assertSame('Getting started', $index['guides'][0]['title']); + $this->assertArrayHasKey('attributes', $index['reference']); + } +} diff --git a/tools/generate-docs.php b/tools/generate-docs.php new file mode 100644 index 0000000..4044ee2 --- /dev/null +++ b/tools/generate-docs.php @@ -0,0 +1,21 @@ +generate(); + +$generator->write($output); + +echo 'Wrote ' . count($files) . " files to docs/\n"; + +foreach (array_keys($files) as $path) { + echo ' ' . $path . "\n"; +} diff --git a/tools/guides/01-getting-started.md b/tools/guides/01-getting-started.md new file mode 100644 index 0000000..1cc74bd --- /dev/null +++ b/tools/guides/01-getting-started.md @@ -0,0 +1,65 @@ +# Getting started + +Tempcord builds Discord bots on top of [Tempest](https://tempestphp.com). A command is a +class, its options are typed parameters, and the framework works out the rest at boot. + +## Requirements + +- PHP 8.5 or newer +- A Discord application with a bot token +- The intents your bot needs, enabled in the Discord developer portal + +## Installing + +```bash +composer create-project tempcord/tempcord my-bot +cd my-bot +``` + +Copy the environment file and add your token: + +```bash +cp .env.example .env +``` + +```env +DISCORD_TOKEN=your_bot_token_here +``` + +## Your first command + +A command is a class carrying `#[Command]`. If it declares an `__invoke` method, that method +handles it, and each parameter marked `#[Option]` becomes an option Discord shows the user. + + + +The name comes from the class — `PingCommand` becomes `/ping` — with a `Command` prefix or +suffix stripped and the rest snake_cased. Pass `name:` to choose it yourself. + +Whether an option is required comes from whether its parameter has a default. Here `name` is +required and `times` is not. + +## Registering and running + +Registration replaces the whole set of commands each time, so a command you delete from your +code disappears from Discord rather than lingering there: + +```bash +./tempcord boot --register +``` + +Once registered, boot without the flag: + +```bash +./tempcord boot +``` + +Registration needs a request to Discord, so run it when your commands change rather than on +every start. + +## Where to go next + +- [Commands and options](02-commands.md) — subcommands, groups, and every option constraint +- [Autocomplete](03-autocomplete.md) — suggesting values as the user types +- [Events](04-events.md) — reacting to what happens on the gateway +- [Translations](05-translations.md) — showing commands in the user's own language diff --git a/tools/guides/02-commands.md b/tools/guides/02-commands.md new file mode 100644 index 0000000..664b1e8 --- /dev/null +++ b/tools/guides/02-commands.md @@ -0,0 +1,71 @@ +# Commands and options + +## Subcommands + +A class whose methods carry `#[Subcommand]` exposes each one as a subcommand. There is no +`__invoke` in this case — the methods are the handlers. + + + +That registers `/moderation kick`. + +## Grouping subcommands + +Adding `#[SubcommandGroup]` to the class nests everything one level deeper, which is how +Discord models `/command group subcommand`. + + + +That registers `/music playlist play` and `/music playlist stop`. A method without +`#[Subcommand]` is ignored, so helpers can sit alongside handlers. + +## Option types + +The Discord option type is read from the parameter's PHP type: + +| PHP type | Discord option type | +| --- | --- | +| `string` | STRING | +| `int` | INTEGER | +| `float` | NUMBER | +| `bool` | BOOLEAN | +| `Ragnarok\Fenrir\Parts\User` | USER | +| `Ragnarok\Fenrir\Parts\Channel` | CHANNEL | +| `Ragnarok\Fenrir\Parts\Role` | ROLE | + +A parameter typed `User`, `Channel` or `Role` is fetched from Discord before your handler +runs, so you receive the entity rather than an id. + +An unsupported type fails at boot rather than on the first interaction that reaches it. + +## Receiving the interaction + +A parameter named `$interaction` receives the `CommandInteraction`, which is how you reply. +It needs no attribute. + +## Constraining what users may send + +Discord can enforce constraints before your handler is ever called, which is cheaper and +gives the user immediate feedback. + + + +Choices accept either shape. A map uses its keys as the labels users see; a list has no +labels of its own, so each value stands in as its own. + +## Restricting who may use a command + +`permissions` sets the default a member needs. An empty list leaves the command unrestricted. + + + +Server administrators can override this per guild, so treat it as a default rather than a +security boundary. + +## Scoping a command to one guild + +`guildId` registers a command in a single guild instead of globally. Guild commands appear +immediately, which makes them useful while developing; global commands can take up to an +hour to propagate. + + diff --git a/tools/guides/03-autocomplete.md b/tools/guides/03-autocomplete.md new file mode 100644 index 0000000..80ee20d --- /dev/null +++ b/tools/guides/03-autocomplete.md @@ -0,0 +1,51 @@ +# Autocomplete + +Autocomplete suggests values while the user is still typing, before they submit the command. + +## The built-in + +`ArrayAutocomplete` filters a fixed list by what has been typed so far. + + + +## Writing your own + +Anything implementing `Autocomplete` can supply suggestions, which is what you want when +they come from a database or an API. + +```php +use Ragnarok\Fenrir\Interaction\CommandInteraction; +use Tempcord\Interfaces\Autocomplete; + +final readonly class TrackAutocomplete implements Autocomplete +{ + public function __construct( + private TrackRepository $tracks, + ) {} + + public function handle(CommandInteraction $interaction, mixed $value): array + { + return $this->tracks->matching((string) $value); + } +} +``` + +## What you may return + +| Return | Result | +| --- | --- | +| A list | Each value is shown as its own label | +| A map | Keys are the labels users see, values are what your handler receives | +| A single scalar | One suggestion | +| `ApplicationCommandOptionChoice` objects | Passed through untouched | + +Discord accepts at most 25 choices and rejects a response carrying more, so anything beyond +that is dropped before sending. + +## Timing + +Discord expects an autocomplete response within about three seconds. Keep the work small, +and cache rather than querying on every keystroke. + +Note that an autocomplete implementation is constructed as part of the attribute, so it is +rebuilt whenever the command tree is read. Hold configuration in it, not a warm cache. diff --git a/tools/guides/04-events.md b/tools/guides/04-events.md new file mode 100644 index 0000000..f26518f --- /dev/null +++ b/tools/guides/04-events.md @@ -0,0 +1,41 @@ +# Events + +Events let a bot react to what happens on the gateway rather than to a command. + +## Listening + +An invokable class carrying `#[Event]` becomes a listener. The event name is the gateway +event you want; the payload arrives as the single argument. + + + +The class is resolved from the container, so a listener may take constructor dependencies. + +## Intents + +Discord only sends events your bot subscribed to. Intents are set in your configuration: + +```php +use Ragnarok\Fenrir\Bitwise\Bitwise; +use Ragnarok\Fenrir\Enums\Intent; + +return new TempcordConfig( + token: env('DISCORD_TOKEN'), + intents: Bitwise::from( + Intent::GUILDS, + Intent::GUILD_MESSAGES, + Intent::MESSAGE_CONTENT, + ), +); +``` + +A listener for an event you have no intent for is registered and simply never fires, which is +a common reason for a listener that appears to do nothing. + +`MESSAGE_CONTENT`, `GUILD_MEMBERS` and `GUILD_PRESENCES` are privileged: they must also be +enabled in the Discord developer portal, and above a hundred guilds they need approval. + +## Errors + +A listener that throws is logged and contained; the gateway connection carries on rather than +the bot falling over. diff --git a/tools/guides/05-translations.md b/tools/guides/05-translations.md new file mode 100644 index 0000000..ffc4a1b --- /dev/null +++ b/tools/guides/05-translations.md @@ -0,0 +1,62 @@ +# Translations + +Discord can show a command's name and description in each user's own language. Tempcord reads +those from Tempest's translation catalog, so they live in the same files as the rest of your +application's translations rather than inline in PHP. + +## Setup + +Translations need `tempest/intl`, which is not installed by default because it requires the +`intl` and `dom` extensions: + +```bash +composer require tempest/intl +``` + +Without it, commands register with their declared names and descriptions and nothing else. + +## Declaring a key + +A command declares one key. Everything beneath it follows from its position in the tree. + + + +That reads these keys: + +``` +commands.music.name +commands.music.description +commands.music.playlist.name +commands.music.playlist.description +commands.music.playlist.play.name +commands.music.playlist.play.description +commands.music.playlist.play.title.name +commands.music.playlist.play.title.description +``` + +An option on an invokable command hangs directly off the command's key — +`commands.greet.name.description` for an option named `name`. + +## Providing the translations + +Anywhere Tempest reads translations from: + +```json +{ + "commands.music.description": "Musiksteuerung", + "commands.music.playlist.description": "Wiedergabeliste", + "commands.music.playlist.play.description": "Titel abspielen", + "commands.music.playlist.play.title.description": "Titelname" +} +``` + +## What gets sent + +Only locales that actually have a translation. A missing one is left out rather than filled +in, so Discord falls back to the declared text — which is why a partly translated command is +perfectly fine. + +Discord accepts 34 locales; see [DiscordLocale](../reference/enums/discord-locale.md) for the +full list and how each maps onto a Tempest locale. + +A command with no `translationKey` reads nothing and sends no localization fields at all. diff --git a/tools/src/ApiReflector.php b/tools/src/ApiReflector.php new file mode 100644 index 0000000..5449116 --- /dev/null +++ b/tools/src/ApiReflector.php @@ -0,0 +1,299 @@ +> + */ + private const array SURFACE = [ + 'attributes' => [ + \Tempcord\Attributes\Command::class, + \Tempcord\Attributes\SubcommandGroup::class, + \Tempcord\Attributes\Subcommand::class, + \Tempcord\Attributes\Option::class, + \Tempcord\Attributes\Event::class, + ], + 'autocomplete' => [ + \Tempcord\Interfaces\Autocomplete::class, + \Tempcord\AutoCompletes\ArrayAutocomplete::class, + ], + 'configuration' => [ + \Tempcord\TempcordConfig::class, + ], + 'enums' => [ + \Tempcord\Enums\DiscordLocale::class, + ], + ]; + + /** + * @return array> + */ + public function reflect(): array + { + $groups = []; + + foreach (self::SURFACE as $group => $classes) { + foreach ($classes as $class) { + $groups[$group][] = $this->symbolFor($group, $class); + } + } + + return $groups; + } + + private function symbolFor(string $group, string $class): Symbol + { + $reflection = new ReflectionClass($class); + + return new Symbol( + name: $reflection->getShortName(), + fqcn: $class, + kind: $this->kindOf($reflection), + summary: $this->summarize($reflection->getDocComment() ?: ''), + slug: 'reference/' . $group . '/' . $this->slugify($reflection->getShortName()), + parameters: $this->parametersOf($reflection), + cases: $this->casesOf($reflection), + methods: $this->methodsOf($reflection), + target: $this->targetOf($reflection), + ); + } + + private function kindOf(ReflectionClass $reflection): string + { + return match (true) { + $reflection->isEnum() => 'enum', + $reflection->isInterface() => 'interface', + $reflection->getAttributes(\Attribute::class) !== [] => 'attribute', + default => 'class', + }; + } + + /** + * Which declarations an attribute may be written on, read from the + * #[Attribute] flags rather than from prose. + */ + private function targetOf(ReflectionClass $reflection): ?string + { + $attribute = $reflection->getAttributes(\Attribute::class)[0] ?? null; + + if ($attribute === null) { + return null; + } + + $flags = $attribute->getArguments()[0] ?? \Attribute::TARGET_ALL; + + $targets = [ + \Attribute::TARGET_CLASS => 'class', + \Attribute::TARGET_METHOD => 'method', + \Attribute::TARGET_PARAMETER => 'parameter', + \Attribute::TARGET_PROPERTY => 'property', + \Attribute::TARGET_FUNCTION => 'function', + \Attribute::TARGET_CLASS_CONSTANT => 'class constant', + ]; + + $matched = []; + + foreach ($targets as $flag => $label) { + if (($flags & $flag) === $flag) { + $matched[] = $label; + } + } + + return $matched === [] ? null : implode(', ', $matched); + } + + /** + * @return list + */ + private function parametersOf(ReflectionClass $reflection): array + { + $constructor = $reflection->getConstructor(); + + if ($constructor === null) { + return []; + } + + $descriptions = $this->paramDescriptions($constructor->getDocComment() ?: ''); + $parameters = []; + + foreach ($constructor->getParameters() as $parameter) { + $parameters[] = new Parameter( + name: $parameter->getName(), + type: $this->renderType($parameter->getType()), + default: $this->defaultOf($parameter), + summary: $descriptions[$parameter->getName()] ?? '', + ); + } + + return $parameters; + } + + /** + * @return list + */ + private function casesOf(ReflectionClass $reflection): array + { + if (!$reflection->isEnum()) { + return []; + } + + $cases = []; + + foreach (new ReflectionEnum($reflection->getName())->getCases() as $case) { + /* + * A pure enum has no value to show, so only backed cases carry one. + */ + $value = $case instanceof ReflectionEnumBackedCase ? $case->getBackingValue() : ''; + + $cases[] = [ + 'name' => $case->getName(), + 'value' => is_string($value) ? $value : (string) $value, + 'note' => '', + ]; + } + + return $cases; + } + + /** + * @return list + */ + private function methodsOf(ReflectionClass $reflection): array + { + if (!$reflection->isInterface() && !$reflection->isEnum()) { + return []; + } + + $methods = []; + + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->isStatic() && $reflection->isEnum()) { + continue; + } + + $parameters = array_map( + fn(ReflectionParameter $p) => $this->renderType($p->getType()) . ' $' . $p->getName(), + $method->getParameters(), + ); + + $methods[] = [ + 'signature' => $method->getName() . '(' . implode(', ', $parameters) . '): ' + . $this->renderType($method->getReturnType()), + 'summary' => $this->summarize($method->getDocComment() ?: ''), + ]; + } + + return $methods; + } + + private function defaultOf(ReflectionParameter $parameter): ?string + { + if (!$parameter->isDefaultValueAvailable()) { + return null; + } + + $default = $parameter->getDefaultValue(); + + return match (true) { + $default === null => 'null', + $default === [] => '[]', + is_bool($default) => $default ? 'true' : 'false', + is_string($default) => "'" . $default . "'", + is_object($default) => new ReflectionClass($default)->getShortName() . '::' . ($default->name ?? ''), + default => var_export($default, true), + }; + } + + private function renderType(?ReflectionType $type): string + { + if ($type === null) { + return 'mixed'; + } + + if ($type instanceof ReflectionUnionType) { + return implode('|', array_map($this->renderType(...), $type->getTypes())); + } + + if (!$type instanceof ReflectionNamedType) { + return (string) $type; + } + + $name = $type->getName(); + + if (!$type->isBuiltin() && str_contains($name, '\\')) { + $name = substr($name, strrpos($name, '\\') + 1); + } + + return ($type->allowsNull() && $name !== 'mixed' && $name !== 'null' ? '?' : '') . $name; + } + + /** + * The first paragraph of a docblock, with the asterisks stripped. + */ + private function summarize(string $docblock): string + { + $lines = []; + + foreach (explode("\n", $docblock) as $line) { + $line = trim(preg_replace('#^\s*/?\*+/?#', '', $line) ?? ''); + + if (str_starts_with($line, '@')) { + break; + } + + if ($line === '' && $lines !== []) { + break; + } + + if ($line !== '') { + $lines[] = $line; + } + } + + return implode(' ', $lines); + } + + /** + * @return array + */ + private function paramDescriptions(string $docblock): array + { + $descriptions = []; + + // A type may contain spaces, as in array, so it is matched lazily + // up to the parameter name rather than as a run of non-whitespace. + if (!preg_match_all('/@param\s+[^\n]+?\s+\$(\w+)\s+(.+?)(?=\n\s*\*\s*@|\n\s*\*\/)/s', $docblock, $matches, PREG_SET_ORDER)) { + return $descriptions; + } + + foreach ($matches as $match) { + $text = preg_replace('#\s*\*\s*#', ' ', $match[2]) ?? ''; + $descriptions[$match[1]] = trim(preg_replace('/\s+/', ' ', $text) ?? ''); + } + + return $descriptions; + } + + private function slugify(string $name): string + { + return strtolower(preg_replace('/(? + */ + public function generate(): array + { + $groups = $this->reflector->reflect(); + $files = []; + + foreach ($groups as $symbols) { + foreach ($symbols as $symbol) { + $files[$symbol->slug . '.md'] = $this->markdown->render($symbol); + } + } + + $files['reference/index.md'] = $this->markdown->renderIndex($groups); + + $guides = $this->guides($files); + + $files['README.md'] = $this->markdown->renderLanding($groups, $guides); + $files['index.json'] = $this->json->render($groups, $guides); + + return $files; + } + + /** + * @param array $files + * @return list + */ + private function guides(array &$files): array + { + $compiler = new GuideCompiler($this->root); + $sources = glob($this->root . '/tools/guides/*.md') ?: []; + sort($sources); + + $guides = []; + + foreach ($sources as $source) { + $name = basename($source, '.md'); + $body = (string) file_get_contents($source); + + $files['guides/' . $name . '.md'] = $compiler->compile($body); + + $guides[] = [ + 'title' => $this->titleOf($body, $name), + 'slug' => 'guides/' . $name, + ]; + } + + return $guides; + } + + private function titleOf(string $body, string $fallback): string + { + return preg_match('/^#\s+(.+)$/m', $body, $match) === 1 ? trim($match[1]) : $fallback; + } + + public function write(string $outputDirectory): void + { + foreach ($this->generate() as $path => $contents) { + $absolute = $outputDirectory . '/' . $path; + $directory = dirname($absolute); + + if (!is_dir($directory) && !mkdir($directory, 0o755, true) && !is_dir($directory)) { + throw new RuntimeException('Could not create ' . $directory); + } + + file_put_contents($absolute, $contents); + } + } +} diff --git a/tools/src/GuideCompiler.php b/tools/src/GuideCompiler.php new file mode 100644 index 0000000..5028976 --- /dev/null +++ b/tools/src/GuideCompiler.php @@ -0,0 +1,74 @@ +$/m'; + + public function __construct( + private string $root, + ) {} + + /** + * @return list every path a guide includes + */ + public function includedPaths(string $source): array + { + preg_match_all(self::PATTERN, $source, $matches); + + return $matches[1]; + } + + public function compile(string $source): string + { + return preg_replace_callback( + self::PATTERN, + function (array $match): string { + $path = $match[1]; + $absolute = $this->root . '/' . $path; + + if (!is_file($absolute)) { + throw new RuntimeException( + 'Guide includes "' . $path . '", which does not exist. ' + . 'Examples must come from real files so they cannot go stale.', + ); + } + + $code = rtrim((string) file_get_contents($absolute)); + + if (isset($match[2], $match[3])) { + $lines = explode("\n", $code); + $code = implode("\n", array_slice($lines, (int) $match[2] - 1, (int) $match[3] - (int) $match[2] + 1)); + } + + return "```php\n" . $this->stripPreamble($code) . "\n```\n\n" + . 'From [`' . $path . '`](../../' . $path . ') — compiled and exercised by the test suite.'; + }, + $source, + ) ?? $source; + } + + /** + * The opening tag and namespace are noise in a documentation example; the + * imports are not, since they tell a reader what to write. + */ + private function stripPreamble(string $code): string + { + $code = preg_replace('/^<\?php\s*\n/', '', $code) ?? $code; + $code = preg_replace('/^namespace [^;]+;\s*\n/m', '', $code) ?? $code; + + return trim($code); + } +} diff --git a/tools/src/JsonWriter.php b/tools/src/JsonWriter.php new file mode 100644 index 0000000..6cd952c --- /dev/null +++ b/tools/src/JsonWriter.php @@ -0,0 +1,50 @@ +> $groups + * @param list $guides + */ + public function render(array $groups, array $guides): string + { + $reference = []; + + foreach ($groups as $group => $symbols) { + $reference[$group] = array_map( + static fn(Symbol $symbol) => [ + 'name' => $symbol->name, + 'fqcn' => $symbol->fqcn, + 'kind' => $symbol->kind, + 'target' => $symbol->target, + 'summary' => $symbol->summary, + 'slug' => $symbol->slug, + 'parameters' => array_map( + static fn(Parameter $parameter) => [ + 'name' => $parameter->name, + 'type' => $parameter->type, + 'default' => $parameter->default, + 'required' => $parameter->isRequired(), + 'summary' => $parameter->summary, + ], + $symbol->parameters, + ), + 'cases' => $symbol->cases, + 'methods' => $symbol->methods, + ], + $symbols, + ); + } + + return json_encode( + ['guides' => $guides, 'reference' => $reference], + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, + ) . "\n"; + } +} diff --git a/tools/src/MarkdownWriter.php b/tools/src/MarkdownWriter.php new file mode 100644 index 0000000..76d9d06 --- /dev/null +++ b/tools/src/MarkdownWriter.php @@ -0,0 +1,128 @@ +\n\n"; + + public function render(Symbol $symbol): string + { + $out = self::BANNER . '# ' . $symbol->name . "\n\n"; + + if ($symbol->summary !== '') { + $out .= $symbol->summary . "\n\n"; + } + + $out .= "```php\nuse " . $symbol->fqcn . ";\n```\n\n"; + + if ($symbol->target !== null) { + $out .= '**Applies to:** ' . $symbol->target . "\n\n"; + } + + if ($symbol->parameters !== []) { + $out .= "## Parameters\n\n"; + $out .= "| Name | Type | Default | Description |\n"; + $out .= "| --- | --- | --- | --- |\n"; + + foreach ($symbol->parameters as $parameter) { + $out .= sprintf( + "| `%s` | `%s` | %s | %s |\n", + $parameter->name, + $this->escape($parameter->type), + $parameter->isRequired() ? '*required*' : '`' . $this->escape($parameter->default) . '`', + $this->escape($parameter->summary), + ); + } + + $out .= "\n"; + } + + if ($symbol->methods !== []) { + $out .= "## Methods\n\n"; + + foreach ($symbol->methods as $method) { + $out .= '### `' . $method['signature'] . "`\n\n"; + + if ($method['summary'] !== '') { + $out .= $method['summary'] . "\n\n"; + } + } + } + + if ($symbol->cases !== []) { + $out .= "## Cases\n\n| Case | Value |\n| --- | --- |\n"; + + foreach ($symbol->cases as $case) { + $out .= sprintf("| `%s` | `%s` |\n", $case['name'], $case['value']); + } + + $out .= "\n"; + } + + return $out; + } + + /** + * @param array> $groups + */ + public function renderIndex(array $groups): string + { + $out = self::BANNER . "# API reference\n\n"; + + foreach ($groups as $group => $symbols) { + $out .= '## ' . ucfirst($group) . "\n\n"; + + foreach ($symbols as $symbol) { + $out .= sprintf( + "- [%s](%s.md)%s\n", + $symbol->name, + str_replace('reference/', '', $symbol->slug), + $symbol->summary === '' ? '' : ' — ' . $this->escape($symbol->summary), + ); + } + + $out .= "\n"; + } + + return $out; + } + + /** + * @param array> $groups + * @param list $guides + */ + public function renderLanding(array $groups, array $guides): string + { + $out = self::BANNER . "# Tempcord documentation\n\n" + . "Build Discord bots with PHP, on top of [Tempest](https://tempestphp.com).\n\n" + . "## Guides\n\n"; + + foreach ($guides as $guide) { + $out .= sprintf("- [%s](%s.md)\n", $guide['title'], $guide['slug']); + } + + $out .= "\n## Reference\n\n" + . "Generated from the source, so it describes what the framework actually does.\n\n"; + + foreach ($groups as $group => $symbols) { + $out .= '**' . ucfirst($group) . "** — "; + $out .= implode(', ', array_map( + static fn(Symbol $symbol) => '[' . $symbol->name . '](' . $symbol->slug . '.md)', + $symbols, + )); + $out .= "\n\n"; + } + + return $out; + } + + /** + * Pipes would break out of the table cell they sit in. + */ + private function escape(string $text): string + { + return str_replace('|', '\\|', $text); + } +} diff --git a/tools/src/Parameter.php b/tools/src/Parameter.php new file mode 100644 index 0000000..c534025 --- /dev/null +++ b/tools/src/Parameter.php @@ -0,0 +1,18 @@ +default === null; + } +} diff --git a/tools/src/Symbol.php b/tools/src/Symbol.php new file mode 100644 index 0000000..637a3bd --- /dev/null +++ b/tools/src/Symbol.php @@ -0,0 +1,26 @@ + $parameters + * @param list $cases + * @param list $methods + */ + public function __construct( + public string $name, + public string $fqcn, + public string $kind, + public string $summary, + public string $slug, + public array $parameters = [], + public array $cases = [], + public array $methods = [], + public ?string $target = null, + ) {} +} From 99e0defb479217a0044c1c5e6e5940627ca7479a Mon Sep 17 00:00:00 2001 From: Vladyslav Gaysyuk Date: Sat, 22 Aug 2026 15:34:21 +0200 Subject: [PATCH 10/11] Give plugins a place to boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1. The issue asks for discovery so that plugins can provide their own commands and events. Most of that already works, and not because of anything here: Tempest discovers any installed package that depends on a tempest/* package, and Tempcord's discovery never looks at where a class came from. A package shipping #[Command] or #[Event] classes is already picked up by the bot that installs it. A package that depends on nothing from Tempest can opt in with extra.tempest.can-discover instead. What discovery cannot do is give a package a moment to run — to register a Fenrir extension, start a timer, or open a connection of its own. That is the part that was missing, and it is why tempcord/tasks is written against a Tempcord\Plugins\Plugin interface that has never existed. So: a Plugin interface with a single boot method, a discovery that finds implementations, and a registry that boots them as part of listening, after commands and events are bound and before the gateway opens. Plugins are built by the container, so they may take dependencies. Implementing the interface is the whole registration. Two decisions worth stating. A plugin that throws while booting is logged and reported but does not stop the bot, matching how a command or event listener that throws is contained. And plugins are keyed by class, since discovery can reach the same class through more than one location and booting twice would register a plugin's extensions twice. The interface deliberately has no register hook. Tempest's container already handles service registration through discovery and initializers, and the one plugin written against the imagined API had an empty register method. --- docs/README.md | 3 + docs/guides/06-plugins.md | 104 +++++++++++++ docs/index.json | 22 +++ docs/reference/index.md | 4 + docs/reference/plugins/plugin.md | 16 ++ src/Discoveries/PluginsDiscovery.php | 42 ++++++ src/Plugins/Plugin.php | 30 ++++ src/Registries/PluginsRegistry.php | 76 ++++++++++ src/Tempcord.php | 8 +- src/TempcordInitializer.php | 2 + tests/Fixtures/RecordingPlugin.php | 17 +++ tests/Fixtures/ThrowingPlugin.php | 15 ++ .../Unit/Discoveries/PluginsDiscoveryTest.php | 72 +++++++++ tests/Unit/Plugins/PluginsRegistryTest.php | 141 ++++++++++++++++++ tests/Unit/TempcordTest.php | 5 +- tools/guides/06-plugins.md | 88 +++++++++++ tools/src/ApiReflector.php | 3 + 17 files changed, 646 insertions(+), 2 deletions(-) create mode 100644 docs/guides/06-plugins.md create mode 100644 docs/reference/plugins/plugin.md create mode 100644 src/Discoveries/PluginsDiscovery.php create mode 100644 src/Plugins/Plugin.php create mode 100644 src/Registries/PluginsRegistry.php create mode 100644 tests/Fixtures/RecordingPlugin.php create mode 100644 tests/Fixtures/ThrowingPlugin.php create mode 100644 tests/Unit/Discoveries/PluginsDiscoveryTest.php create mode 100644 tests/Unit/Plugins/PluginsRegistryTest.php create mode 100644 tools/guides/06-plugins.md diff --git a/docs/README.md b/docs/README.md index aac11b5..dd24d74 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ Build Discord bots with PHP, on top of [Tempest](https://tempestphp.com). - [Autocomplete](guides/03-autocomplete.md) - [Events](guides/04-events.md) - [Translations](guides/05-translations.md) +- [Plugins](guides/06-plugins.md) ## Reference @@ -24,3 +25,5 @@ Generated from the source, so it describes what the framework actually does. **Enums** — [DiscordLocale](reference/enums/discord-locale.md) +**Plugins** — [Plugin](reference/plugins/plugin.md) + diff --git a/docs/guides/06-plugins.md b/docs/guides/06-plugins.md new file mode 100644 index 0000000..3675a1e --- /dev/null +++ b/docs/guides/06-plugins.md @@ -0,0 +1,104 @@ +# Plugins + +A plugin is an ordinary Composer package that adds commands, events or behaviour to a bot. + +## Commands and events need no plugin API + +Tempest discovers any installed package that depends on a `tempest/*` package, and Tempcord's +discovery does not care whether a class came from your application or from a package. So a +package that ships this: + +```php +#[Command(description: 'Show scheduled tasks')] +final class TasksCommand +{ + public function __invoke(CommandInteraction $interaction): void + { + // ... + } +} +``` + +is registered by the bot that installs it, with no registration step. The same is true of +`#[Event]` listeners. + +If your package does not depend on anything from Tempest, opt in through composer instead: + +```json +{ + "extra": { + "tempest": { + "can-discover": true + } + } +} +``` + +## When a plugin needs to act + +Discovery covers declarations. What it cannot do is give a package a moment to *run* — to +register a Fenrir extension, start a timer, or open a connection. That is what `Plugin` is +for. + +```php +use Tempcord\Plugins\Plugin; +use Tempcord\Tempcord; + +final class RecordingPlugin implements Plugin +{ + /** @var list */ + public static array $booted = []; + + public function boot(Tempcord $tempcord): void + { + self::$booted[] = $tempcord; + } +} +``` + +From [`tests/Fixtures/RecordingPlugin.php`](../../tests/Fixtures/RecordingPlugin.php) — compiled and exercised by the test suite. + +`boot()` is called once, after commands and events are bound and before the gateway opens, so +anything that must exist before the first event arrives belongs there. + +Plugins are built by the container, so a plugin may take whatever it needs: + +```php +final readonly class TasksPlugin implements Plugin +{ + public function __construct( + private Registry $tasks, + ) {} + + public function boot(Tempcord $tempcord): void + { + $tempcord->discord->registerExtension($this->tasks); + } +} +``` + +No registration is needed for the plugin class either — implementing the interface is enough. + +## Failure + +A plugin that throws while booting is logged and reported, and the bot carries on without it. +One broken plugin does not stop the others, or the bot. + +## Writing one + +```json +{ + "name": "vendor/my-plugin", + "require": { + "tempcord/framework": "^0.6" + }, + "autoload": { + "psr-4": { + "Vendor\\MyPlugin\\": "src/" + } + } +} +``` + +Requiring `tempcord/framework` is enough for discovery, since the framework itself depends on +Tempest. diff --git a/docs/index.json b/docs/index.json index 336d5a9..501d9dd 100644 --- a/docs/index.json +++ b/docs/index.json @@ -19,6 +19,10 @@ { "title": "Translations", "slug": "guides/05-translations" + }, + { + "title": "Plugins", + "slug": "guides/06-plugins" } ], "reference": { @@ -498,6 +502,24 @@ } ] } + ], + "plugins": [ + { + "name": "Plugin", + "fqcn": "Tempcord\\Plugins\\Plugin", + "kind": "interface", + "target": null, + "summary": "A package that extends a bot with its own behaviour.", + "slug": "reference/plugins/plugin", + "parameters": [], + "cases": [], + "methods": [ + { + "signature": "boot(Tempcord $tempcord): void", + "summary": "Called once, after commands and events are bound and before the gateway opens." + } + ] + } ] } } diff --git a/docs/reference/index.md b/docs/reference/index.md index a0f268d..e01c499 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -23,3 +23,7 @@ - [DiscordLocale](enums/discord-locale.md) — The locales Discord accepts for name and description localizations. +## Plugins + +- [Plugin](plugins/plugin.md) — A package that extends a bot with its own behaviour. + diff --git a/docs/reference/plugins/plugin.md b/docs/reference/plugins/plugin.md new file mode 100644 index 0000000..687ceef --- /dev/null +++ b/docs/reference/plugins/plugin.md @@ -0,0 +1,16 @@ + + +# Plugin + +A package that extends a bot with its own behaviour. + +```php +use Tempcord\Plugins\Plugin; +``` + +## Methods + +### `boot(Tempcord $tempcord): void` + +Called once, after commands and events are bound and before the gateway opens. + diff --git a/src/Discoveries/PluginsDiscovery.php b/src/Discoveries/PluginsDiscovery.php new file mode 100644 index 0000000..379c1bc --- /dev/null +++ b/src/Discoveries/PluginsDiscovery.php @@ -0,0 +1,42 @@ +getReflection(); + + if (!$class->implements(Plugin::class) || $reflection->isAbstract() || $reflection->isInterface()) { + return; + } + + $this->discoveryItems->add($location, $class->getName()); + } + + public function apply(): void + { + foreach ($this->discoveryItems as $className) { + /** @var Plugin $plugin */ + $plugin = $this->container->get($className); + + $this->plugins->add($plugin); + } + } +} diff --git a/src/Plugins/Plugin.php b/src/Plugins/Plugin.php new file mode 100644 index 0000000..04d18b5 --- /dev/null +++ b/src/Plugins/Plugin.php @@ -0,0 +1,30 @@ +, Plugin> */ + private array $plugins = []; + + public function __construct( + private readonly LoggerInterface $logger, + ) {} + + public function add(Plugin $plugin): void + { + // Keyed by class so a plugin discovered twice boots once. + $this->plugins[$plugin::class] = $plugin; + } + + /** + * @return list + */ + public function all(): array + { + return array_values($this->plugins); + } + + /** + * @return list + */ + public function boot(Tempcord $tempcord): array + { + $outcomes = []; + + foreach ($this->plugins as $plugin) { + $name = $this->nameOf($plugin); + + try { + $plugin->boot($tempcord); + + $outcomes[] = Outcome::success('Plugin "' . $name . '" booted.'); + } catch (Throwable $throwable) { + /* + * A plugin that cannot start is worth reporting loudly, but not + * worth taking the bot down for. + */ + $this->logger->error( + 'Plugin "' . $name . '" failed to boot: ' . $throwable->getMessage(), + ['exception' => $throwable], + ); + + $outcomes[] = Outcome::error('Plugin "' . $name . '": ' . $throwable->getMessage()); + } + } + + return $outcomes; + } + + private function nameOf(Plugin $plugin): string + { + $class = $plugin::class; + + return substr($class, strrpos($class, '\\') + 1); + } +} diff --git a/src/Tempcord.php b/src/Tempcord.php index 7fdc23e..d15acf9 100644 --- a/src/Tempcord.php +++ b/src/Tempcord.php @@ -7,6 +7,7 @@ use Ragnarok\Fenrir\Gateway\Events\Ready; use Tempcord\Registries\CommandsRegistry; use Tempcord\Registries\EventsRegistry; +use Tempcord\Registries\PluginsRegistry; use Tempcord\Runtime\Outcome; /** @@ -20,6 +21,7 @@ public function __construct( public readonly Discord $discord, private readonly CommandsRegistry $commandsRegistry, private readonly EventsRegistry $eventsRegistry, + private readonly PluginsRegistry $pluginsRegistry, ) { $this->discord->gateway->events->on(Events::READY, function (Ready $ready): void { $this->discord->registerExtension($this->commandsRegistry->extension); @@ -36,7 +38,10 @@ public function registerCommands(): array } /** - * Binds everything that has been discovered, then opens the gateway. + * Binds everything that has been discovered. + * + * Plugins boot last, so whatever they do runs against a bot whose commands + * and events are already bound, and still before the gateway opens. * * @return list */ @@ -45,6 +50,7 @@ public function listen(): array return [ ...$this->commandsRegistry->listen(), ...$this->eventsRegistry->listen($this->discord), + ...$this->pluginsRegistry->boot($this), ]; } diff --git a/src/TempcordInitializer.php b/src/TempcordInitializer.php index 4757423..7494a28 100644 --- a/src/TempcordInitializer.php +++ b/src/TempcordInitializer.php @@ -5,6 +5,7 @@ use Ragnarok\Fenrir\Discord; use Tempcord\Registries\CommandsRegistry; use Tempcord\Registries\EventsRegistry; +use Tempcord\Registries\PluginsRegistry; use Tempest\Container\Container; use Tempest\Container\Initializer; use Tempest\Container\Singleton; @@ -26,6 +27,7 @@ public function initialize(Container $container): Tempcord )->withRest(), commandsRegistry: $container->get(CommandsRegistry::class), eventsRegistry: $container->get(EventsRegistry::class), + pluginsRegistry: $container->get(PluginsRegistry::class), ); } } diff --git a/tests/Fixtures/RecordingPlugin.php b/tests/Fixtures/RecordingPlugin.php new file mode 100644 index 0000000..1b2b817 --- /dev/null +++ b/tests/Fixtures/RecordingPlugin.php @@ -0,0 +1,17 @@ + */ + public static array $booted = []; + + public function boot(Tempcord $tempcord): void + { + self::$booted[] = $tempcord; + } +} diff --git a/tests/Fixtures/ThrowingPlugin.php b/tests/Fixtures/ThrowingPlugin.php new file mode 100644 index 0000000..53af07b --- /dev/null +++ b/tests/Fixtures/ThrowingPlugin.php @@ -0,0 +1,15 @@ +location = new DiscoveryLocation( + namespace: 'Tempcord\\Tests\\Fixtures\\', + path: __DIR__ . '/../../Fixtures', + ); + } + + private function discovery(PluginsRegistry $plugins): PluginsDiscovery + { + $discovery = new PluginsDiscovery(new GenericContainer(), $plugins); + $discovery->setItems(new DiscoveryItems()); + + return $discovery; + } + + public function test_it_discovers_plugins(): void + { + $plugins = new PluginsRegistry(new NullLogger()); + $discovery = $this->discovery($plugins); + + $discovery->discover($this->location, new ClassReflector(RecordingPlugin::class)); + $discovery->apply(); + + $this->assertCount(1, $plugins->all()); + $this->assertInstanceOf(Plugin::class, $plugins->all()[0]); + } + + public function test_it_ignores_classes_that_are_not_plugins(): void + { + $discovery = $this->discovery(new PluginsRegistry(new NullLogger())); + + $discovery->discover($this->location, new ClassReflector(PingCommand::class)); + + $this->assertCount(0, iterator_to_array($discovery->getItems())); + } + + /** + * The interface itself is reachable through discovery and must not be + * treated as a plugin to instantiate. + */ + public function test_it_ignores_the_interface_itself(): void + { + $discovery = $this->discovery(new PluginsRegistry(new NullLogger())); + + $discovery->discover($this->location, new ClassReflector(Plugin::class)); + + $this->assertCount(0, iterator_to_array($discovery->getItems())); + } +} diff --git a/tests/Unit/Plugins/PluginsRegistryTest.php b/tests/Unit/Plugins/PluginsRegistryTest.php new file mode 100644 index 0000000..09e1487 --- /dev/null +++ b/tests/Unit/Plugins/PluginsRegistryTest.php @@ -0,0 +1,141 @@ + */ + private function messages(array $outcomes): array + { + return array_map(static fn(Outcome $outcome) => $outcome->message, $outcomes); + } + + public function test_a_plugin_is_booted_with_the_bot(): void + { + $plugins = new PluginsRegistry(new NullLogger()); + $plugins->add(new RecordingPlugin()); + + $tempcord = $this->tempcord($plugins); + $outcomes = $plugins->boot($tempcord); + + $this->assertSame([$tempcord], RecordingPlugin::$booted); + $this->assertSame(['Plugin "RecordingPlugin" booted.'], $this->messages($outcomes)); + } + + /** + * Discovery can reach the same class through more than one location, and a + * plugin booting twice would register its extensions twice. + */ + public function test_the_same_plugin_added_twice_boots_once(): void + { + $plugins = new PluginsRegistry(new NullLogger()); + $plugins->add(new RecordingPlugin()); + $plugins->add(new RecordingPlugin()); + + $plugins->boot($this->tempcord($plugins)); + + $this->assertCount(1, $plugins->all()); + $this->assertCount(1, RecordingPlugin::$booted); + } + + /** + * One plugin that cannot start must not stop the bot, or the others. + */ + public function test_a_plugin_that_throws_is_reported_and_the_rest_still_boot(): void + { + $logger = new RecordingLogger(); + $plugins = new PluginsRegistry($logger); + $plugins->add(new ThrowingPlugin()); + $plugins->add(new RecordingPlugin()); + + $outcomes = $plugins->boot($this->tempcord($plugins)); + + $this->assertSame( + [OutcomeLevel::Error, OutcomeLevel::Success], + array_map(static fn(Outcome $outcome) => $outcome->level, $outcomes), + ); + $this->assertCount(1, RecordingPlugin::$booted); + $this->assertStringContainsString('could not reach the scheduler', $logger->messages[0]); + } + + public function test_no_plugins_reports_nothing(): void + { + $plugins = new PluginsRegistry(new NullLogger()); + + $this->assertSame([], $plugins->boot($this->tempcord($plugins))); + } + + /** + * Plugins boot as part of listening, after commands and events are bound + * and before the gateway opens. + */ + public function test_plugins_boot_as_part_of_listening(): void + { + $plugins = new PluginsRegistry(new NullLogger()); + $plugins->add(new RecordingPlugin()); + + $tempcord = $this->tempcord($plugins); + $messages = $this->messages($tempcord->listen()); + + $this->assertSame('Plugin "RecordingPlugin" booted.', end($messages)); + $this->assertSame([$tempcord], RecordingPlugin::$booted); + } +} diff --git a/tests/Unit/TempcordTest.php b/tests/Unit/TempcordTest.php index 060d69f..d202260 100644 --- a/tests/Unit/TempcordTest.php +++ b/tests/Unit/TempcordTest.php @@ -11,6 +11,7 @@ use Tempcord\Discord\CommandBuilderFactory; use Tempcord\Registries\CommandsRegistry; use Tempcord\Registries\EventsRegistry; +use Tempcord\Registries\PluginsRegistry; use Tempcord\Runtime\ArgumentResolver; use Tempcord\Runtime\AutocompleteResponder; use Tempcord\Runtime\ChoiceFactory; @@ -32,6 +33,7 @@ final class TempcordTest extends TestCase private FakeDiscord $discord; private CommandsRegistry $commands; private EventsRegistry $events; + private PluginsRegistry $plugins; protected function setUp(): void { @@ -54,11 +56,12 @@ protected function setUp(): void autocomplete: new AutocompleteResponder(new ChoiceFactory()), ); $this->events = new EventsRegistry(new GenericContainer()); + $this->plugins = new PluginsRegistry(new NullLogger()); } private function tempcord(): Tempcord { - return new Tempcord($this->discord, $this->commands, $this->events); + return new Tempcord($this->discord, $this->commands, $this->events, $this->plugins); } public function test_it_registers_the_command_extension_once_the_gateway_is_ready(): void diff --git a/tools/guides/06-plugins.md b/tools/guides/06-plugins.md new file mode 100644 index 0000000..117e037 --- /dev/null +++ b/tools/guides/06-plugins.md @@ -0,0 +1,88 @@ +# Plugins + +A plugin is an ordinary Composer package that adds commands, events or behaviour to a bot. + +## Commands and events need no plugin API + +Tempest discovers any installed package that depends on a `tempest/*` package, and Tempcord's +discovery does not care whether a class came from your application or from a package. So a +package that ships this: + +```php +#[Command(description: 'Show scheduled tasks')] +final class TasksCommand +{ + public function __invoke(CommandInteraction $interaction): void + { + // ... + } +} +``` + +is registered by the bot that installs it, with no registration step. The same is true of +`#[Event]` listeners. + +If your package does not depend on anything from Tempest, opt in through composer instead: + +```json +{ + "extra": { + "tempest": { + "can-discover": true + } + } +} +``` + +## When a plugin needs to act + +Discovery covers declarations. What it cannot do is give a package a moment to *run* — to +register a Fenrir extension, start a timer, or open a connection. That is what `Plugin` is +for. + + + +`boot()` is called once, after commands and events are bound and before the gateway opens, so +anything that must exist before the first event arrives belongs there. + +Plugins are built by the container, so a plugin may take whatever it needs: + +```php +final readonly class TasksPlugin implements Plugin +{ + public function __construct( + private Registry $tasks, + ) {} + + public function boot(Tempcord $tempcord): void + { + $tempcord->discord->registerExtension($this->tasks); + } +} +``` + +No registration is needed for the plugin class either — implementing the interface is enough. + +## Failure + +A plugin that throws while booting is logged and reported, and the bot carries on without it. +One broken plugin does not stop the others, or the bot. + +## Writing one + +```json +{ + "name": "vendor/my-plugin", + "require": { + "tempcord/framework": "^0.6" + }, + "autoload": { + "psr-4": { + "Vendor\\MyPlugin\\": "src/" + } + } +} +``` + +Requiring `tempcord/framework` is enough for discovery, since the framework itself depends on +Tempest. diff --git a/tools/src/ApiReflector.php b/tools/src/ApiReflector.php index 5449116..b4612bf 100644 --- a/tools/src/ApiReflector.php +++ b/tools/src/ApiReflector.php @@ -41,6 +41,9 @@ 'enums' => [ \Tempcord\Enums\DiscordLocale::class, ], + 'plugins' => [ + \Tempcord\Plugins\Plugin::class, + ], ]; /** From 814a0243e3c124c53c80fd71998771615fbb774c Mon Sep 17 00:00:00 2001 From: Vladyslav Gaysyuk Date: Sat, 22 Aug 2026 18:26:24 +0200 Subject: [PATCH 11/11] Make the framework actually boot in an application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything here was found by installing the framework into the starter application and running it. The test suite passed throughout, because unit tests hand collaborators in directly and never exercise the container assembling the graph for real. Registries no longer depend on anything. Discovery builds them while the container is still being assembled, before the initializers that provide services like the logger have themselves been discovered — so a registry reaching for a logger meant the application could not boot at all. Storage and behaviour are now separate: CommandsRegistry and PluginsRegistry hold definitions and nothing else, while CommandBinder and PluginBooter do the work and are built later, when the container is complete. Psr\Log\LoggerInterface is not resolvable. Tempest binds Tempest\Log\Logger, which extends it, so that is what the runtime asks for now. Discord is a container singleton rather than something Tempcord alone holds. OptionValueResolver needs it to fetch users and channels, and a plugin may want it too; autowiring it previously tried to construct a fresh Discord and failed on its token argument. tempest/log and tempest/event-bus are now required explicitly. The first was already used and only arrived transitively; the second is needed because tempest/log's own initializer resolves an EventBus without declaring it as a dependency. Finally, console logging could kill the process. Tempest's console parses