diff --git a/docs/README.md b/docs/README.md index 53a3ba6..0c1762a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ Build Discord bots with PHP, on top of [Tempest](https://tempestphp.com). - [Plugins](guides/06-plugins.md) - [Components](guides/07-components.md) - [Cache](guides/08-cache.md) +- [Middleware](guides/09-middleware.md) ## Reference diff --git a/docs/guides/09-middleware.md b/docs/guides/09-middleware.md new file mode 100644 index 0000000..5938bb9 --- /dev/null +++ b/docs/guides/09-middleware.md @@ -0,0 +1,202 @@ +# Middleware + +A middleware runs before a handler and may decide it never runs. It is the answer to +"may this person do this", asked in one place instead of restated at the top of every +handler that needs it. + +```php +use Tempcord\Interfaces\Middleware; + +final readonly class ModerationOnly implements Middleware +{ + public function __invoke($interaction, callable $next): void + { + // check, then either call $next($interaction) or answer instead + } +} +``` + +Calling `$next` lets the handler run. Answering the interaction and returning without +calling it stops there — which is the whole point for a guard: the handler is never +reached, so it cannot half-do the work it was asked for. + +## Why not just `#[Command(permissions: ...)]` + +Discord's own permissions are the right tool most of the time — they hide a command from +anyone who may not use it, before it is ever typed. They stop being enough in two places: + +- They are a **default**. A guild administrator can rewrite them in Server Settings, so + they are not something to hang an irreversible action on. +- They are scoped to the **whole command**. A command whose subcommands do not share an + audience cannot be described with them at all. + +That second case is the one to reach for middleware over: + +```php +use Tempcord\Attributes\Command; +use Tempcord\Attributes\Subcommand; +use Tempcord\Discord\Interaction\CommandInteraction; + +/** + * One command, two audiences: anybody may make a suggestion, only moderation + * may act on the ones that are in. + * + * Discord scopes a command's permissions to the whole command, so this is not + * something #[Command(permissions: ...)] can describe at all. + */ +#[Command(description: 'Suggestions from the server')] +final class SuggestionCommand +{ + #[Subcommand(name: 'add', description: 'Suggest something.')] + public function add(CommandInteraction $interaction): void {} + + #[Subcommand( + name: 'close', + description: 'Close a suggestion.', + middleware: [ModerationOnly::class], + )] + public function close(CommandInteraction $interaction): void {} +} +``` + +From [`tests/Fixtures/SuggestionCommand.php`](../../tests/Fixtures/SuggestionCommand.php) — compiled and exercised by the test suite. + +Everybody sees `/suggestion` and everybody may `add`. `close` refuses anyone who is not +moderation, at the moment they use it. + +## Writing one + +Two shapes are accepted, and which one you use decides how it is built. + +A **class name** is built by the container, so the middleware may take whatever it needs +— the configuration holding a guild's roles, a clock, a repository: + +```php +use Tempcord\Discord\Interaction\ButtonInteraction; +use Tempcord\Discord\Interaction\CommandInteraction; +use Tempcord\Discord\Interaction\ComponentInteraction; +use Tempcord\Discord\Interaction\ModalSubmitInteraction; +use Tempcord\Interfaces\Middleware; + +/** + * Lets only a member holding the moderation role through. + * + * Named by class rather than written inline, so the container builds it and it + * can take the configuration that knows which role that is. + */ +final readonly class ModerationOnly implements Middleware +{ + public function __construct( + private string $moderatorRole = '::moderator::', + ) {} + + public function __invoke( + CommandInteraction|ButtonInteraction|ComponentInteraction|ModalSubmitInteraction $interaction, + callable $next, + ): void { + $roles = $interaction->interaction->member->roles ?? []; + + if (!in_array($this->moderatorRole, $roles, true)) { + $interaction->reply('Only moderation may do that.', ephemeral: true); + + return; + } + + $next($interaction); + } +} +``` + +From [`tests/Fixtures/ModerationOnly.php`](../../tests/Fixtures/ModerationOnly.php) — compiled and exercised by the test suite. + +An **object written inside the attribute** takes no dependencies, since the container is +not involved in reading attributes. That is the right shape for a check that only needs +its own arguments: + +```php +use Tempcord\Attributes\Command; +use Tempcord\Discord\Enums\Permission; +use Tempcord\Middleware\RequiresPermissions; + +/** + * Middleware written as an object inside the attribute, which is the shape a + * check that only needs its own arguments takes. + */ +#[Command( + description: 'Guarded inline', + middleware: [new RequiresPermissions([Permission::MANAGE_GUILD], 'Not for you.')], +)] +final class InlineGuardedCommand +{ + public function __invoke(): void {} +} +``` + +From [`tests/Fixtures/InlineGuardedCommand.php`](../../tests/Fixtures/InlineGuardedCommand.php) — compiled and exercised by the test suite. + +Either way, the interaction arrives as whichever shape answered it — a `CommandInteraction` +for a command, a `ButtonInteraction` for a button, a `ComponentInteraction` for a select +menu, a `ModalSubmitInteraction` for a modal. All four carry the gateway event as +`$interaction->interaction` and can reply, which is all a guard needs, and they are one +union so the same guard can sit on a subcommand and on the button that does the same thing. + +## Where it goes + +On a command, on a subcommand group, on a subcommand, and on any component attribute: + +```php +#[Command(name: 'petition', description: '…', middleware: [/* every handler under it */])] +#[SubcommandGroup(name: 'keys', description: '…', middleware: [/* every subcommand in the group */])] +#[Subcommand(name: 'close', description: '…', middleware: [/* this one */])] +#[Button(id: 'petition.accept.{petition}', middleware: [/* this button */])] +``` + +What is declared around a handler is flattened into one chain at discovery time, outermost +first: the command's, then the group's, then the subcommand's own. The first middleware +listed sees the interaction first and decides whether anything after it happens at all. + +## What it costs + +Nothing, until it is reached. Each middleware is built at the moment the chain gets to it, +so a refusal never constructs the ones behind it. + +A command's options are resolved **inside** the chain rather than before it. Resolving an +option can cost a REST call — Discord sends the id of a `User` option, not the user — and a +command a middleware is about to refuse should not pay for one. + +A middleware that throws is logged and contained, exactly as a handler that throws is. The +handler does not run. + +## Deferring + +Middleware runs before the handler, so nothing has been deferred yet and a refusal can +answer with an ordinary `reply(..., ephemeral: true)`. Keep the `defer()` inside the +handler, where the slow work is. + +## What ships with the framework + +`RequiresPermissions` checks the permissions Discord has already computed for the channel +the interaction came from — no roles are read back and nothing is cached. An administrator +holds everything by definition, and an interaction with no member behind it (a direct +message) holds nothing: + +```php +use Tempcord\Discord\Enums\Permission; +use Tempcord\Middleware\RequiresPermissions; + +#[Subcommand( + name: 'panel', + description: 'Publishes the panel.', + middleware: [new RequiresPermissions([Permission::MANAGE_GUILD], 'Not for you.')], +)] +``` + +Anything that asks about **roles** rather than permissions belongs in your own bot: role +ids are a particular server's answer to who moderation is, and the framework has no +opinion about them. + +## When a class is not a middleware + +Naming a class that does not implement `Middleware` fails at discovery, which is start-up. +A guard that turns out not to be a guard should stop the bot booting — not surface the +first time somebody uses the thing it was meant to protect. diff --git a/docs/index.json b/docs/index.json index 7adb743..a0d74b7 100644 --- a/docs/index.json +++ b/docs/index.json @@ -31,6 +31,10 @@ { "title": "Cache", "slug": "guides/08-cache" + }, + { + "title": "Middleware", + "slug": "guides/09-middleware" } ], "reference": { @@ -310,6 +314,13 @@ "default": "null", "required": false, "summary": "the button's custom id. It may carry {placeholders}, as in \"tournament.accept.{team}\", which are matched out of the incoming id and passed to same-named parameters. Defaults to the class name with a Button prefix or suffix stripped and the rest snake_cased." + }, + { + "name": "middleware", + "type": "array", + "default": "[]", + "required": false, + "summary": "run in the order given, outermost first; any of them may answer instead of letting the handler run" } ], "cases": [], @@ -329,6 +340,13 @@ "default": "null", "required": false, "summary": "the menu's custom id, which may carry {placeholders}. Defaults to the class name with a SelectMenu prefix or suffix stripped and the rest snake_cased." + }, + { + "name": "middleware", + "type": "array", + "default": "[]", + "required": false, + "summary": "run in the order given, outermost first; any of them may answer instead of letting the handler run" } ], "cases": [], @@ -348,6 +366,13 @@ "default": "null", "required": false, "summary": "the modal's custom id, which may carry {placeholders}. Defaults to the class name with a ModalSubmit or Modal prefix or suffix stripped and the rest snake_cased." + }, + { + "name": "middleware", + "type": "array", + "default": "[]", + "required": false, + "summary": "run in the order given, outermost first; any of them may answer instead of letting the handler run" } ], "cases": [], @@ -747,13 +772,13 @@ "fqcn": "Tempcord\\Interfaces\\Middleware", "kind": "interface", "target": null, - "summary": "Something that runs before a command, and may decide it never runs.", + "summary": "Something that runs before a handler, and may decide it never runs.", "slug": "reference/middleware/middleware", "parameters": [], "cases": [], "methods": [ { - "signature": "__invoke(CommandInteraction $interaction, callable $next): void", + "signature": "__invoke(CommandInteraction|ButtonInteraction|ComponentInteraction|ModalSubmitInteraction $interaction, callable $next): void", "summary": "" } ] diff --git a/docs/reference/attributes/button.md b/docs/reference/attributes/button.md index ce961b9..2b9a990 100644 --- a/docs/reference/attributes/button.md +++ b/docs/reference/attributes/button.md @@ -15,4 +15,5 @@ use Tempcord\Attributes\Button; | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `BackedEnum\|string\|null` | `null` | the button's custom id. It may carry {placeholders}, as in "tournament.accept.{team}", which are matched out of the incoming id and passed to same-named parameters. Defaults to the class name with a Button prefix or suffix stripped and the rest snake_cased. | +| `middleware` | `array` | `[]` | run in the order given, outermost first; any of them may answer instead of letting the handler run | diff --git a/docs/reference/attributes/modal-submit.md b/docs/reference/attributes/modal-submit.md index 6844535..e6c6800 100644 --- a/docs/reference/attributes/modal-submit.md +++ b/docs/reference/attributes/modal-submit.md @@ -15,4 +15,5 @@ use Tempcord\Attributes\ModalSubmit; | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `BackedEnum\|string\|null` | `null` | the modal's custom id, which may carry {placeholders}. Defaults to the class name with a ModalSubmit or Modal prefix or suffix stripped and the rest snake_cased. | +| `middleware` | `array` | `[]` | run in the order given, outermost first; any of them may answer instead of letting the handler run | diff --git a/docs/reference/attributes/select-menu.md b/docs/reference/attributes/select-menu.md index 3c35b6c..cb9cca5 100644 --- a/docs/reference/attributes/select-menu.md +++ b/docs/reference/attributes/select-menu.md @@ -15,4 +15,5 @@ use Tempcord\Attributes\SelectMenu; | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `BackedEnum\|string\|null` | `null` | the menu's custom id, which may carry {placeholders}. Defaults to the class name with a SelectMenu prefix or suffix stripped and the rest snake_cased. | +| `middleware` | `array` | `[]` | run in the order given, outermost first; any of them may answer instead of letting the handler run | diff --git a/docs/reference/index.md b/docs/reference/index.md index 37a8f60..919ae2b 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -49,6 +49,6 @@ ## Middleware -- [Middleware](middleware/middleware.md) — Something that runs before a command, and may decide it never runs. +- [Middleware](middleware/middleware.md) — Something that runs before a handler, and may decide it never runs. - [RequiresPermissions](middleware/requires-permissions.md) — Refuses anyone whose permissions in the channel fall short. diff --git a/docs/reference/middleware/middleware.md b/docs/reference/middleware/middleware.md index d254754..8d6f81d 100644 --- a/docs/reference/middleware/middleware.md +++ b/docs/reference/middleware/middleware.md @@ -2,7 +2,7 @@ # Middleware -Something that runs before a command, and may decide it never runs. +Something that runs before a handler, and may decide it never runs. ```php use Tempcord\Interfaces\Middleware; @@ -10,5 +10,5 @@ use Tempcord\Interfaces\Middleware; ## Methods -### `__invoke(CommandInteraction $interaction, callable $next): void` +### `__invoke(CommandInteraction|ButtonInteraction|ComponentInteraction|ModalSubmitInteraction $interaction, callable $next): void` diff --git a/src/Attributes/Button.php b/src/Attributes/Button.php index fa7aba6..42eb6e9 100644 --- a/src/Attributes/Button.php +++ b/src/Attributes/Button.php @@ -21,8 +21,12 @@ * out of the incoming id and passed to same-named parameters. * Defaults to the class name with a Button prefix or suffix stripped * and the rest snake_cased. + * @param list<\Tempcord\Interfaces\Middleware|class-string<\Tempcord\Interfaces\Middleware>> $middleware + * run in the order given, outermost first; any of them may + * answer instead of letting the handler run */ public function __construct( public string|BackedEnum|null $id = null, + public array $middleware = [], ) {} } diff --git a/src/Attributes/ModalSubmit.php b/src/Attributes/ModalSubmit.php index 552b331..efb64f3 100644 --- a/src/Attributes/ModalSubmit.php +++ b/src/Attributes/ModalSubmit.php @@ -18,8 +18,12 @@ * @param string|BackedEnum|null $id the modal's custom id, which may carry * {placeholders}. Defaults to the class name with a ModalSubmit or * Modal prefix or suffix stripped and the rest snake_cased. + * @param list<\Tempcord\Interfaces\Middleware|class-string<\Tempcord\Interfaces\Middleware>> $middleware + * run in the order given, outermost first; any of them may + * answer instead of letting the handler run */ public function __construct( public string|BackedEnum|null $id = null, + public array $middleware = [], ) {} } diff --git a/src/Attributes/SelectMenu.php b/src/Attributes/SelectMenu.php index b49b65e..9c41f36 100644 --- a/src/Attributes/SelectMenu.php +++ b/src/Attributes/SelectMenu.php @@ -19,8 +19,12 @@ * @param string|BackedEnum|null $id the menu's custom id, which may carry * {placeholders}. Defaults to the class name with a SelectMenu prefix * or suffix stripped and the rest snake_cased. + * @param list<\Tempcord\Interfaces\Middleware|class-string<\Tempcord\Interfaces\Middleware>> $middleware + * run in the order given, outermost first; any of them may + * answer instead of letting the handler run */ public function __construct( public string|BackedEnum|null $id = null, + public array $middleware = [], ) {} } diff --git a/src/Compiler/CommandCompiler.php b/src/Compiler/CommandCompiler.php index 7826097..0286b9c 100644 --- a/src/Compiler/CommandCompiler.php +++ b/src/Compiler/CommandCompiler.php @@ -23,7 +23,6 @@ use Tempcord\Definitions\SubcommandDefinition; use Tempcord\Definitions\SubcommandGroupDefinition; use Tempcord\Interfaces\Autocomplete; -use Tempcord\Interfaces\Middleware; use Tempcord\Localization\LocalizationProvider; use Tempcord\Localization\NullLocalizations; use ReflectionEnum; @@ -68,7 +67,7 @@ public function compile(ClassReflector $class, Command $command): CommandDefinit $handlers = []; $key = $command->translationKey; - $around = $this->middlewareOf($command->middleware, 'Command [' . $name . ']'); + $around = DeclaredMiddleware::checked($command->middleware, 'Command [' . $name . ']'); $group = $this->groupOf($class, $key); $subcommands = $this->subcommandsOf($class, $key); @@ -216,7 +215,7 @@ private function groupOf(ClassReflector $class, ?string $key): ?SubcommandGroupD subcommands: $this->subcommandsOf($class, $groupKey), nameLocalizations: $this->translate($groupKey, 'name'), descriptionLocalizations: $this->translate($groupKey, 'description'), - middleware: $this->middlewareOf($group->middleware, 'Subcommand group [' . $name . ']'), + middleware: DeclaredMiddleware::checked($group->middleware, 'Subcommand group [' . $name . ']'), ); } @@ -245,7 +244,7 @@ private function subcommandsOf(ClassReflector $class, ?string $key): array method: $method, nameLocalizations: $this->translate($subcommandKey, 'name'), descriptionLocalizations: $this->translate($subcommandKey, 'description'), - middleware: $this->middlewareOf($subcommand->middleware, 'Subcommand [' . $name . ']'), + middleware: DeclaredMiddleware::checked($subcommand->middleware, 'Subcommand [' . $name . ']'), ); } @@ -353,43 +352,6 @@ private function autocompleteFor(Option $option, ?MethodReflector $completer): ? return null; } - /** - * Middleware as declared, checked before anything is built out of it. - * - * A class name that turns out not to be middleware is a mistake worth - * catching here: discovery runs at start-up, so the bot refuses to boot - * rather than failing the first time somebody uses the command it was meant - * to guard — which, for a guard, is the worst moment to find out. - * - * @param array $declared - * - * @return list> - */ - private function middlewareOf(array $declared, string $where): array - { - $middleware = []; - - foreach ($declared as $entry) { - if ($entry instanceof Middleware) { - $middleware[] = $entry; - continue; - } - - if (is_string($entry) && is_subclass_of($entry, Middleware::class)) { - $middleware[] = $entry; - continue; - } - - throw new LogicException( - $where . ' declares middleware [' - . (is_string($entry) ? $entry : get_debug_type($entry)) - . '], which does not implement ' . Middleware::class, - ); - } - - return $middleware; - } - /** * The command's own methods that complete an option, keyed by the option * each one answers for. diff --git a/src/Compiler/ComponentCompiler.php b/src/Compiler/ComponentCompiler.php index 1459377..2bc0e9f 100644 --- a/src/Compiler/ComponentCompiler.php +++ b/src/Compiler/ComponentCompiler.php @@ -55,12 +55,24 @@ public function compile(ClassReflector $class): array foreach (self::ATTRIBUTES as $attribute => $kind) { foreach ($class->getAttributes($attribute) as $declaration) { - $definitions[] = $this->definition($class, $this->invokerOf($class, $kind), $kind, $declaration->id); + $definitions[] = $this->definition( + $class, + $this->invokerOf($class, $kind), + $kind, + $declaration->id, + $declaration->middleware, + ); } foreach ($class->getPublicMethods() as $method) { foreach ($method->getAttributes($attribute) as $declaration) { - $definitions[] = $this->definition($class, $method, $kind, $declaration->id); + $definitions[] = $this->definition( + $class, + $method, + $kind, + $declaration->id, + $declaration->middleware, + ); } } } @@ -68,17 +80,27 @@ public function compile(ClassReflector $class): array return $definitions; } + /** + * @param array $middleware + */ private function definition( ClassReflector $class, MethodReflector $method, ComponentKind $kind, string|BackedEnum|null $id, + array $middleware, ): ComponentDefinition { + $customId = CustomId::compile($this->idOf($class, $method, $kind, $id)); + return new ComponentDefinition( kind: $kind, - customId: CustomId::compile($this->idOf($class, $method, $kind, $id)), + customId: $customId, handler: $class->getName(), method: $method, + middleware: DeclaredMiddleware::checked( + $middleware, + ucfirst($kind->value) . ' "' . $customId->pattern . '"', + ), ); } diff --git a/src/Compiler/DeclaredMiddleware.php b/src/Compiler/DeclaredMiddleware.php new file mode 100644 index 0000000..1ece19e --- /dev/null +++ b/src/Compiler/DeclaredMiddleware.php @@ -0,0 +1,52 @@ + $declared + * @param string $where what is being compiled, named for the error + * + * @return list> + */ + public static function checked(array $declared, string $where): array + { + $middleware = []; + + foreach ($declared as $entry) { + if ($entry instanceof Middleware) { + $middleware[] = $entry; + continue; + } + + if (is_string($entry) && is_subclass_of($entry, Middleware::class)) { + $middleware[] = $entry; + continue; + } + + throw new LogicException( + $where . ' declares middleware [' + . (is_string($entry) ? $entry : get_debug_type($entry)) + . '], which does not implement ' . Middleware::class, + ); + } + + return $middleware; + } +} diff --git a/src/Definitions/ComponentDefinition.php b/src/Definitions/ComponentDefinition.php index cfb413c..20182bb 100644 --- a/src/Definitions/ComponentDefinition.php +++ b/src/Definitions/ComponentDefinition.php @@ -3,6 +3,7 @@ namespace Tempcord\Definitions; use Tempcord\Enums\ComponentKind; +use Tempcord\Interfaces\Middleware; use Tempcord\Runtime\CustomId; use Tempest\Reflection\MethodReflector; @@ -12,11 +13,16 @@ */ final readonly class ComponentDefinition { + /** + * @param list> $middleware run around + * this handler, outermost first + */ public function __construct( public ComponentKind $kind, public CustomId $customId, public string $handler, public MethodReflector $method, + public array $middleware = [], ) {} /** diff --git a/src/Interfaces/Middleware.php b/src/Interfaces/Middleware.php index d6d851e..502996f 100644 --- a/src/Interfaces/Middleware.php +++ b/src/Interfaces/Middleware.php @@ -2,26 +2,38 @@ namespace Tempcord\Interfaces; +use Tempcord\Discord\Interaction\ButtonInteraction; use Tempcord\Discord\Interaction\CommandInteraction; +use Tempcord\Discord\Interaction\ComponentInteraction; +use Tempcord\Discord\Interaction\ModalSubmitInteraction; /** - * Something that runs before a command, and may decide it never runs. + * Something that runs before a handler, and may decide it never runs. * * Built by the container when it is named by class, so a middleware may take * whatever it needs — the configuration holding a guild's roles, a clock, a * logger. Written as an instance inside the attribute it takes nothing, which * is the right shape for a check that only needs its own arguments. * - * Answering the interaction instead of calling $next stops the command there. + * Answering the interaction instead of calling $next stops the handler there. * That is the whole point for anything that checks a caller's right to be * asking: the handler is never reached, so it cannot half-do the work it was - * asked for — and its options are never resolved, which for an option Discord - * only sends the id of means a request that is never made. + * asked for — and a command's options are never resolved, which for an option + * Discord only sends the id of means a request that is never made. + * + * The parameter is a union rather than one type because the same guard belongs + * on a subcommand and on the button that does the same thing: "may this person + * do this" is one question, and having to write it twice is how the two answers + * drift apart. Every interaction in the union carries the gateway event as + * $interaction and can answer, which is all a guard needs. */ interface Middleware { /** - * @param callable(CommandInteraction): void $next + * @param callable(CommandInteraction|ButtonInteraction|ComponentInteraction|ModalSubmitInteraction): void $next */ - public function __invoke(CommandInteraction $interaction, callable $next): void; + public function __invoke( + CommandInteraction|ButtonInteraction|ComponentInteraction|ModalSubmitInteraction $interaction, + callable $next, + ): void; } diff --git a/src/Middleware/RequiresPermissions.php b/src/Middleware/RequiresPermissions.php index 8b4cce6..b57a89b 100644 --- a/src/Middleware/RequiresPermissions.php +++ b/src/Middleware/RequiresPermissions.php @@ -3,7 +3,10 @@ namespace Tempcord\Middleware; use Tempcord\Discord\Enums\Permission; +use Tempcord\Discord\Interaction\ButtonInteraction; use Tempcord\Discord\Interaction\CommandInteraction; +use Tempcord\Discord\Interaction\ComponentInteraction; +use Tempcord\Discord\Interaction\ModalSubmitInteraction; use Tempcord\Interfaces\Middleware; /** @@ -39,7 +42,10 @@ public function __construct( public string $refusal = 'You are not allowed to use this command.', ) {} - public function __invoke(CommandInteraction $interaction, callable $next): void + public function __invoke( + CommandInteraction|ButtonInteraction|ComponentInteraction|ModalSubmitInteraction $interaction, + callable $next, + ): void { if (!$this->allows($interaction)) { $interaction->reply($this->refusal, ephemeral: true); @@ -50,7 +56,9 @@ public function __invoke(CommandInteraction $interaction, callable $next): void $next($interaction); } - private function allows(CommandInteraction $interaction): bool + private function allows( + CommandInteraction|ButtonInteraction|ComponentInteraction|ModalSubmitInteraction $interaction, + ): bool { $held = $interaction->interaction->member?->permissions; diff --git a/src/Runtime/ComponentDispatcher.php b/src/Runtime/ComponentDispatcher.php index 904447f..5d247b9 100644 --- a/src/Runtime/ComponentDispatcher.php +++ b/src/Runtime/ComponentDispatcher.php @@ -2,8 +2,13 @@ namespace Tempcord\Runtime; +use Tempcord\Discord\Discord; use Tempcord\Discord\Gateway\Events\InteractionCreate; +use Tempcord\Discord\Interaction\ButtonInteraction; +use Tempcord\Discord\Interaction\ComponentInteraction; +use Tempcord\Discord\Interaction\ModalSubmitInteraction; use Tempcord\Definitions\ComponentDefinition; +use Tempcord\Enums\ComponentKind; use Tempest\Container\Container; use Tempest\Log\Logger; use Throwable; @@ -19,6 +24,8 @@ public function __construct( private ComponentArgumentResolver $arguments, private Container $container, private Logger $logger, + private MiddlewarePipeline $middleware, + private Discord $discord, ) {} /** @@ -35,9 +42,15 @@ public function dispatch( */ async(function () use ($definition, $interaction, $parameters): void { try { - $definition->method->invokeArgs( - $this->container->get($definition->handler), - $this->arguments->resolve($definition, $interaction, $parameters), + $this->middleware->run( + $definition->middleware, + $this->wrap($definition->kind, $interaction), + function () use ($definition, $interaction, $parameters): void { + $definition->method->invokeArgs( + $this->container->get($definition->handler), + $this->arguments->resolve($definition, $interaction, $parameters), + ); + }, ); } catch (Throwable $throwable) { $this->logger->error( @@ -47,4 +60,24 @@ public function dispatch( } })(); } + + /** + * The interaction in the shape this kind of component is answered with, so + * a middleware can reply without knowing which pipeline it was reached + * through. + * + * Built here rather than taken from the handler's arguments: a middleware + * that refuses must be able to say so even when the handler it is guarding + * never asked for an interaction at all. + */ + private function wrap( + ComponentKind $kind, + InteractionCreate $interaction, + ): ButtonInteraction|ComponentInteraction|ModalSubmitInteraction { + return match ($kind) { + ComponentKind::Button => new ButtonInteraction($interaction, $this->discord), + ComponentKind::SelectMenu => new ComponentInteraction($interaction, $this->discord), + ComponentKind::ModalSubmit => new ModalSubmitInteraction($interaction, $this->discord), + }; + } } diff --git a/src/Runtime/MiddlewarePipeline.php b/src/Runtime/MiddlewarePipeline.php index dbffd3e..c17db92 100644 --- a/src/Runtime/MiddlewarePipeline.php +++ b/src/Runtime/MiddlewarePipeline.php @@ -2,7 +2,10 @@ namespace Tempcord\Runtime; +use Tempcord\Discord\Interaction\ButtonInteraction; use Tempcord\Discord\Interaction\CommandInteraction; +use Tempcord\Discord\Interaction\ComponentInteraction; +use Tempcord\Discord\Interaction\ModalSubmitInteraction; use Tempcord\Interfaces\Middleware; use Tempest\Container\Container; @@ -25,16 +28,22 @@ public function __construct( /** * @param list> $middleware - * @param callable(CommandInteraction): void $handler + * @param CommandInteraction|ButtonInteraction|ComponentInteraction|ModalSubmitInteraction $interaction + * @param callable(CommandInteraction|ButtonInteraction|ComponentInteraction|ModalSubmitInteraction): void $handler */ - public function run(array $middleware, CommandInteraction $interaction, callable $handler): void - { + public function run( + array $middleware, + CommandInteraction|ButtonInteraction|ComponentInteraction|ModalSubmitInteraction $interaction, + callable $handler, + ): void { $next = $handler; foreach (array_reverse($middleware) as $entry) { $inner = $next; - $next = function (CommandInteraction $interaction) use ($entry, $inner): void { + $next = function ( + CommandInteraction|ButtonInteraction|ComponentInteraction|ModalSubmitInteraction $interaction, + ) use ($entry, $inner): void { ($this->resolve($entry))($interaction, $inner); }; } diff --git a/tests/Fixtures/GuardedButtons.php b/tests/Fixtures/GuardedButtons.php new file mode 100644 index 0000000..08d3226 --- /dev/null +++ b/tests/Fixtures/GuardedButtons.php @@ -0,0 +1,60 @@ + */ + public static array $calls = []; + + #[Button(id: 'guarded.press', middleware: [OuterMiddleware::class, InnerMiddleware::class])] + public function press(ButtonInteraction $interaction): void + { + self::$calls[] = 'press'; + } + + #[Button(id: 'guarded.refused', middleware: [RefusingMiddleware::class])] + public function refused(ButtonInteraction $interaction): void + { + self::$calls[] = 'refused'; + } + + #[Button(id: 'guarded.open')] + public function open(ButtonInteraction $interaction): void + { + self::$calls[] = 'open'; + } + + /** + * Takes no interaction at all, so a refusal has to answer with one the + * dispatcher built rather than one the handler asked for. + */ + #[Button(id: 'guarded.blind.{team}', middleware: [RecordingInteractionMiddleware::class])] + public function blind(string $team): void + { + self::$calls[] = 'blind:' . $team; + } + + #[SelectMenu(id: 'guarded.pick', middleware: [RecordingInteractionMiddleware::class])] + public function pick(ComponentInteraction $interaction): void + { + self::$calls[] = 'pick'; + } + + #[ModalSubmit(id: 'guarded.submit', middleware: [RecordingInteractionMiddleware::class])] + public function submit(ModalSubmitInteraction $interaction): void + { + self::$calls[] = 'submit'; + } +} diff --git a/tests/Fixtures/ModerationOnly.php b/tests/Fixtures/ModerationOnly.php new file mode 100644 index 0000000..8dcaa00 --- /dev/null +++ b/tests/Fixtures/ModerationOnly.php @@ -0,0 +1,37 @@ +interaction->member->roles ?? []; + + if (!in_array($this->moderatorRole, $roles, true)) { + $interaction->reply('Only moderation may do that.', ephemeral: true); + + return; + } + + $next($interaction); + } +} diff --git a/tests/Fixtures/NotMiddlewareButton.php b/tests/Fixtures/NotMiddlewareButton.php new file mode 100644 index 0000000..8917183 --- /dev/null +++ b/tests/Fixtures/NotMiddlewareButton.php @@ -0,0 +1,12 @@ + */ + public static array $seen = []; + + public function __invoke( + CommandInteraction|ButtonInteraction|ComponentInteraction|ModalSubmitInteraction $interaction, + callable $next, + ): void { + self::$seen[] = $interaction; + + $next($interaction); + } +} diff --git a/tests/Fixtures/RefusingMiddleware.php b/tests/Fixtures/RefusingMiddleware.php index 3148464..7f1dbac 100644 --- a/tests/Fixtures/RefusingMiddleware.php +++ b/tests/Fixtures/RefusingMiddleware.php @@ -2,7 +2,10 @@ namespace Tempcord\Tests\Fixtures; +use Tempcord\Discord\Interaction\ButtonInteraction; use Tempcord\Discord\Interaction\CommandInteraction; +use Tempcord\Discord\Interaction\ComponentInteraction; +use Tempcord\Discord\Interaction\ModalSubmitInteraction; use Tempcord\Interfaces\Middleware; /** @@ -10,7 +13,7 @@ */ final class RefusingMiddleware implements Middleware { - public function __invoke(CommandInteraction $interaction, callable $next): void + public function __invoke(CommandInteraction|ButtonInteraction|ComponentInteraction|ModalSubmitInteraction $interaction, callable $next): void { TrailMiddleware::$trail[] = 'refused'; } diff --git a/tests/Fixtures/SuggestionCommand.php b/tests/Fixtures/SuggestionCommand.php new file mode 100644 index 0000000..15eab93 --- /dev/null +++ b/tests/Fixtures/SuggestionCommand.php @@ -0,0 +1,28 @@ +label; diff --git a/tests/Unit/Compiler/CommandMiddlewareTest.php b/tests/Unit/Compiler/CommandMiddlewareTest.php index 43d4318..80d35e6 100644 --- a/tests/Unit/Compiler/CommandMiddlewareTest.php +++ b/tests/Unit/Compiler/CommandMiddlewareTest.php @@ -13,7 +13,9 @@ use Tempcord\Tests\Fixtures\NotMiddlewareCommand; use Tempcord\Tests\Fixtures\OuterMiddleware; use Tempcord\Tests\Fixtures\PingCommand; +use Tempcord\Tests\Fixtures\ModerationOnly; use Tempcord\Tests\Fixtures\RefusingMiddleware; +use Tempcord\Tests\Fixtures\SuggestionCommand; use Tempcord\Tests\Unit\TestCase; /** @@ -60,6 +62,19 @@ public function test_middleware_written_inline_is_kept_as_the_object_it_is(): vo $this->assertSame('Not for you.', $middleware[0]->refusal); } + /** + * The case the whole thing exists for: one command whose subcommands do not + * share an audience, which Discord's own permissions cannot express because + * they are scoped to the command as a whole. + */ + public function test_one_command_can_guard_some_subcommands_and_not_others(): void + { + $handlers = $this->definition(SuggestionCommand::class)->handlers; + + $this->assertSame([], $handlers['suggestion.add']->middleware); + $this->assertSame([ModerationOnly::class], $handlers['suggestion.close']->middleware); + } + public function test_a_command_declaring_none_carries_none(): void { $this->assertSame([], $this->definition(PingCommand::class)->handlers['ping']->middleware); diff --git a/tests/Unit/Runtime/ComponentBinderTest.php b/tests/Unit/Runtime/ComponentBinderTest.php index 78439ab..59cb6ae 100644 --- a/tests/Unit/Runtime/ComponentBinderTest.php +++ b/tests/Unit/Runtime/ComponentBinderTest.php @@ -12,6 +12,7 @@ use Tempcord\Runtime\ComponentArgumentResolver; use Tempcord\Runtime\ComponentBinder; use Tempcord\Runtime\ComponentDispatcher; +use Tempcord\Runtime\MiddlewarePipeline; use Tempcord\Runtime\Outcome; use Tempcord\Tests\Doubles\FakeDiscord; use Tempcord\Tests\Doubles\Interactions; @@ -59,6 +60,8 @@ protected function setUp(): void new ComponentArgumentResolver($this->discord), new GenericContainer(), $this->logger, + new MiddlewarePipeline(new GenericContainer()), + $this->discord, ), ); } diff --git a/tests/Unit/Runtime/ComponentMiddlewareTest.php b/tests/Unit/Runtime/ComponentMiddlewareTest.php new file mode 100644 index 0000000..87a1152 --- /dev/null +++ b/tests/Unit/Runtime/ComponentMiddlewareTest.php @@ -0,0 +1,144 @@ +discord = new FakeDiscord(new RecordingHttp()); + $this->registry = new ComponentsRegistry(); + + $extension = new ComponentExtension($this->registry); + $extension->initialize($this->discord); + + $this->binder = new ComponentBinder( + $extension, + new ComponentDispatcher( + new ComponentArgumentResolver($this->discord), + new GenericContainer(), + new RecordingLogger(), + new MiddlewarePipeline(new GenericContainer()), + $this->discord, + ), + ); + } + + private function bind(string $class): void + { + foreach (new ComponentCompiler()->compile(new ClassReflector($class)) as $definition) { + $this->registry->add($definition); + } + + $this->binder->bindAll($this->registry->all()); + } + + private function arrive(InteractionCreate $interaction): void + { + $this->discord->gateway->events->emit(Events::INTERACTION_CREATE, [$interaction]); + } + + public function test_the_first_middleware_listed_is_the_outermost(): void + { + $this->bind(GuardedButtons::class); + + $this->arrive(Interactions::button('guarded.press')); + + $this->assertSame(['outer', 'inner'], TrailMiddleware::$trail); + $this->assertSame(['press'], GuardedButtons::$calls); + } + + public function test_a_middleware_that_does_not_continue_stops_the_handler(): void + { + $this->bind(GuardedButtons::class); + + $this->arrive(Interactions::button('guarded.refused')); + + $this->assertSame(['refused'], TrailMiddleware::$trail); + $this->assertSame([], GuardedButtons::$calls); + } + + public function test_a_component_declaring_none_still_runs(): void + { + $this->bind(GuardedButtons::class); + + $this->arrive(Interactions::button('guarded.open')); + + $this->assertSame([], TrailMiddleware::$trail); + $this->assertSame(['open'], GuardedButtons::$calls); + } + + /** + * Each kind of component is answered with its own shape of interaction, and + * the middleware is handed that shape whether or not the handler it guards + * ever asked for one — a guard has to be able to say no. + */ + public function test_the_middleware_is_handed_the_interaction_for_that_kind(): void + { + $this->bind(GuardedButtons::class); + + $this->arrive(Interactions::button('guarded.blind.alpha')); + $this->arrive(Interactions::selectMenu('guarded.pick', ['one'])); + $this->arrive(Interactions::modal('guarded.submit')); + + $this->assertInstanceOf(ButtonInteraction::class, RecordingInteractionMiddleware::$seen[0]); + $this->assertInstanceOf(ComponentInteraction::class, RecordingInteractionMiddleware::$seen[1]); + $this->assertInstanceOf(ModalSubmitInteraction::class, RecordingInteractionMiddleware::$seen[2]); + + $this->assertSame(['blind:alpha', 'pick', 'submit'], GuardedButtons::$calls); + } + + public function test_a_class_that_is_not_middleware_is_refused(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('does not implement'); + + new ComponentCompiler()->compile(new ClassReflector(NotMiddlewareButton::class)); + } +} diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php index dc2324f..12970b5 100644 --- a/tests/Unit/TestCase.php +++ b/tests/Unit/TestCase.php @@ -94,6 +94,8 @@ protected function tempcord( new ComponentArgumentResolver($discord), new GenericContainer(), new RecordingLogger(), + new MiddlewarePipeline(new GenericContainer()), + $discord, ), ), pluginBooter: new PluginBooter($logger), diff --git a/tools/guides/09-middleware.md b/tools/guides/09-middleware.md new file mode 100644 index 0000000..b8bfe39 --- /dev/null +++ b/tools/guides/09-middleware.md @@ -0,0 +1,120 @@ +# Middleware + +A middleware runs before a handler and may decide it never runs. It is the answer to +"may this person do this", asked in one place instead of restated at the top of every +handler that needs it. + +```php +use Tempcord\Interfaces\Middleware; + +final readonly class ModerationOnly implements Middleware +{ + public function __invoke($interaction, callable $next): void + { + // check, then either call $next($interaction) or answer instead + } +} +``` + +Calling `$next` lets the handler run. Answering the interaction and returning without +calling it stops there — which is the whole point for a guard: the handler is never +reached, so it cannot half-do the work it was asked for. + +## Why not just `#[Command(permissions: ...)]` + +Discord's own permissions are the right tool most of the time — they hide a command from +anyone who may not use it, before it is ever typed. They stop being enough in two places: + +- They are a **default**. A guild administrator can rewrite them in Server Settings, so + they are not something to hang an irreversible action on. +- They are scoped to the **whole command**. A command whose subcommands do not share an + audience cannot be described with them at all. + +That second case is the one to reach for middleware over: + + + +Everybody sees `/suggestion` and everybody may `add`. `close` refuses anyone who is not +moderation, at the moment they use it. + +## Writing one + +Two shapes are accepted, and which one you use decides how it is built. + +A **class name** is built by the container, so the middleware may take whatever it needs +— the configuration holding a guild's roles, a clock, a repository: + + + +An **object written inside the attribute** takes no dependencies, since the container is +not involved in reading attributes. That is the right shape for a check that only needs +its own arguments: + + + +Either way, the interaction arrives as whichever shape answered it — a `CommandInteraction` +for a command, a `ButtonInteraction` for a button, a `ComponentInteraction` for a select +menu, a `ModalSubmitInteraction` for a modal. All four carry the gateway event as +`$interaction->interaction` and can reply, which is all a guard needs, and they are one +union so the same guard can sit on a subcommand and on the button that does the same thing. + +## Where it goes + +On a command, on a subcommand group, on a subcommand, and on any component attribute: + +```php +#[Command(name: 'petition', description: '…', middleware: [/* every handler under it */])] +#[SubcommandGroup(name: 'keys', description: '…', middleware: [/* every subcommand in the group */])] +#[Subcommand(name: 'close', description: '…', middleware: [/* this one */])] +#[Button(id: 'petition.accept.{petition}', middleware: [/* this button */])] +``` + +What is declared around a handler is flattened into one chain at discovery time, outermost +first: the command's, then the group's, then the subcommand's own. The first middleware +listed sees the interaction first and decides whether anything after it happens at all. + +## What it costs + +Nothing, until it is reached. Each middleware is built at the moment the chain gets to it, +so a refusal never constructs the ones behind it. + +A command's options are resolved **inside** the chain rather than before it. Resolving an +option can cost a REST call — Discord sends the id of a `User` option, not the user — and a +command a middleware is about to refuse should not pay for one. + +A middleware that throws is logged and contained, exactly as a handler that throws is. The +handler does not run. + +## Deferring + +Middleware runs before the handler, so nothing has been deferred yet and a refusal can +answer with an ordinary `reply(..., ephemeral: true)`. Keep the `defer()` inside the +handler, where the slow work is. + +## What ships with the framework + +`RequiresPermissions` checks the permissions Discord has already computed for the channel +the interaction came from — no roles are read back and nothing is cached. An administrator +holds everything by definition, and an interaction with no member behind it (a direct +message) holds nothing: + +```php +use Tempcord\Discord\Enums\Permission; +use Tempcord\Middleware\RequiresPermissions; + +#[Subcommand( + name: 'panel', + description: 'Publishes the panel.', + middleware: [new RequiresPermissions([Permission::MANAGE_GUILD], 'Not for you.')], +)] +``` + +Anything that asks about **roles** rather than permissions belongs in your own bot: role +ids are a particular server's answer to who moderation is, and the framework has no +opinion about them. + +## When a class is not a middleware + +Naming a class that does not implement `Middleware` fails at discovery, which is start-up. +A guard that turns out not to be a guard should stop the bot booting — not surface the +first time somebody uses the thing it was meant to protect.