From fa40a194693746ab18e2518d44cbea905beb2122 Mon Sep 17 00:00:00 2001 From: "Vladyslav G." Date: Thu, 3 Sep 2026 13:30:07 +0200 Subject: [PATCH] feat(middleware)!: guard a component the way a command is guarded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same question — may this person do this — is asked by a subcommand and by the button that does the same thing, and until now only the subcommand could answer it with middleware. A button restated the check at the top of its handler, which is how the two answers drift apart. #[Button], #[SelectMenu] and #[ModalSubmit] now take middleware, compiled and validated by the same rule the command path uses, and ComponentDispatcher runs the chain before resolving anything. The middleware is handed the interaction in the shape that kind of component is answered with, built by the dispatcher rather than taken from the handler's arguments — a guard has to be able to refuse even when the handler it guards never asked for an interaction at all. Also documents the whole thing, with every example pulled from a fixture the suite compiles and exercises. BREAKING CHANGE: Middleware::__invoke now takes CommandInteraction|ButtonInteraction|ComponentInteraction|ModalSubmitInteraction rather than CommandInteraction. A middleware typed for the old signature has to widen its parameter; nothing else about it changes, and the interaction it receives for a command is the same object as before. Co-Authored-By: Claude Opus 5 (1M context) --- docs/README.md | 1 + docs/guides/09-middleware.md | 202 ++++++++++++++++++ docs/index.json | 29 ++- docs/reference/attributes/button.md | 1 + docs/reference/attributes/modal-submit.md | 1 + docs/reference/attributes/select-menu.md | 1 + docs/reference/index.md | 2 +- docs/reference/middleware/middleware.md | 4 +- src/Attributes/Button.php | 4 + src/Attributes/ModalSubmit.php | 4 + src/Attributes/SelectMenu.php | 4 + src/Compiler/CommandCompiler.php | 44 +--- src/Compiler/ComponentCompiler.php | 28 ++- src/Compiler/DeclaredMiddleware.php | 52 +++++ src/Definitions/ComponentDefinition.php | 6 + src/Interfaces/Middleware.php | 24 ++- src/Middleware/RequiresPermissions.php | 12 +- src/Runtime/ComponentDispatcher.php | 39 +++- src/Runtime/MiddlewarePipeline.php | 17 +- tests/Fixtures/GuardedButtons.php | 60 ++++++ tests/Fixtures/ModerationOnly.php | 37 ++++ tests/Fixtures/NotMiddlewareButton.php | 12 ++ .../RecordingInteractionMiddleware.php | 28 +++ tests/Fixtures/RefusingMiddleware.php | 5 +- tests/Fixtures/SuggestionCommand.php | 28 +++ tests/Fixtures/TrailMiddleware.php | 5 +- tests/Unit/Compiler/CommandMiddlewareTest.php | 15 ++ tests/Unit/Runtime/ComponentBinderTest.php | 3 + .../Unit/Runtime/ComponentMiddlewareTest.php | 144 +++++++++++++ tests/Unit/TestCase.php | 2 + tools/guides/09-middleware.md | 120 +++++++++++ 31 files changed, 868 insertions(+), 66 deletions(-) create mode 100644 docs/guides/09-middleware.md create mode 100644 src/Compiler/DeclaredMiddleware.php create mode 100644 tests/Fixtures/GuardedButtons.php create mode 100644 tests/Fixtures/ModerationOnly.php create mode 100644 tests/Fixtures/NotMiddlewareButton.php create mode 100644 tests/Fixtures/RecordingInteractionMiddleware.php create mode 100644 tests/Fixtures/SuggestionCommand.php create mode 100644 tests/Unit/Runtime/ComponentMiddlewareTest.php create mode 100644 tools/guides/09-middleware.md 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.