Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
202 changes: 202 additions & 0 deletions docs/guides/09-middleware.md
Original file line number Diff line number Diff line change
@@ -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 {}
}
```

<small>From [`tests/Fixtures/SuggestionCommand.php`](../../tests/Fixtures/SuggestionCommand.php) — compiled and exercised by the test suite.</small>

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);
}
}
```

<small>From [`tests/Fixtures/ModerationOnly.php`](../../tests/Fixtures/ModerationOnly.php) — compiled and exercised by the test suite.</small>

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 {}
}
```

<small>From [`tests/Fixtures/InlineGuardedCommand.php`](../../tests/Fixtures/InlineGuardedCommand.php) — compiled and exercised by the test suite.</small>

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.
29 changes: 27 additions & 2 deletions docs/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@
{
"title": "Cache",
"slug": "guides/08-cache"
},
{
"title": "Middleware",
"slug": "guides/09-middleware"
}
],
"reference": {
Expand Down Expand Up @@ -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": [],
Expand All @@ -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": [],
Expand All @@ -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": [],
Expand Down Expand Up @@ -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": ""
}
]
Expand Down
1 change: 1 addition & 0 deletions docs/reference/attributes/button.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

1 change: 1 addition & 0 deletions docs/reference/attributes/modal-submit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

1 change: 1 addition & 0 deletions docs/reference/attributes/select-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

2 changes: 1 addition & 1 deletion docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

4 changes: 2 additions & 2 deletions docs/reference/middleware/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

# 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;
```

## Methods

### `__invoke(CommandInteraction $interaction, callable $next): void`
### `__invoke(CommandInteraction|ButtonInteraction|ComponentInteraction|ModalSubmitInteraction $interaction, callable $next): void`

4 changes: 4 additions & 0 deletions src/Attributes/Button.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [],
) {}
}
4 changes: 4 additions & 0 deletions src/Attributes/ModalSubmit.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [],
) {}
}
4 changes: 4 additions & 0 deletions src/Attributes/SelectMenu.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [],
) {}
}
Loading