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.