diff --git a/src/Commands/AbstractCommand.php b/src/Commands/AbstractCommand.php index a22dce0..de897d3 100644 --- a/src/Commands/AbstractCommand.php +++ b/src/Commands/AbstractCommand.php @@ -98,6 +98,14 @@ public static function make(Laracord $bot): self return new static($bot); } + /** + * The command interaction routes. + */ + public function interactions(): array + { + return []; + } + /** * Build an embed for use in a Discord message. * @@ -106,7 +114,7 @@ public static function make(Laracord $bot): self */ public function message($content = '') { - return $this->bot()->message($content); + return $this->bot()->message($content)->routePrefix($this->getName()); } /** diff --git a/src/Commands/Command.php b/src/Commands/Command.php index 616079a..16f8941 100644 --- a/src/Commands/Command.php +++ b/src/Commands/Command.php @@ -3,7 +3,6 @@ namespace Laracord\Commands; use Laracord\Commands\Contracts\Command as CommandContract; -use Laracord\Discord\Message; abstract class Command extends AbstractCommand implements CommandContract { diff --git a/src/Console/Commands/stubs/command.stub b/src/Console/Commands/stubs/command.stub index 82fb9a5..c2123ed 100644 --- a/src/Console/Commands/stubs/command.stub +++ b/src/Console/Commands/stubs/command.stub @@ -2,6 +2,7 @@ namespace {{ namespace }}; +use Discord\Parts\Interactions\Interaction; use Laracord\Commands\Command; class {{ class }} extends Command @@ -47,6 +48,17 @@ class {{ class }} extends Command ->message() ->title('{{ class }}') ->content('Hello world!') + ->button('👋', route: 'wave') ->send($message); } + + /** + * The command interaction routes. + */ + public function interactions(): array + { + return [ + 'wave' => fn (Interaction $interaction) => $this->message('👋')->reply($interaction), + ]; + } } diff --git a/src/Console/Commands/stubs/slash-command.stub b/src/Console/Commands/stubs/slash-command.stub index 5680569..bb58c70 100644 --- a/src/Console/Commands/stubs/slash-command.stub +++ b/src/Console/Commands/stubs/slash-command.stub @@ -2,6 +2,7 @@ namespace {{ namespace }}; +use Discord\Parts\Interactions\Interaction; use Laracord\Commands\SlashCommand; class {{ class }} extends SlashCommand @@ -61,7 +62,18 @@ class {{ class }} extends SlashCommand ->message() ->title('{{ class }}') ->content('Hello world!') + ->button('👋', route: 'wave') ->build() ); } + + /** + * The command interaction routes. + */ + public function interactions(): array + { + return [ + 'wave' => fn (Interaction $interaction) => $this->message('👋')->reply($interaction), + ]; + } } diff --git a/src/Discord/Message.php b/src/Discord/Message.php index ec0513a..07b7aea 100644 --- a/src/Discord/Message.php +++ b/src/Discord/Message.php @@ -7,6 +7,7 @@ use Discord\Builders\MessageBuilder; use Discord\Parts\Channel\Channel; use Discord\Parts\Channel\Message as ChannelMessage; +use Discord\Parts\Interactions\Interaction; use Discord\Parts\User\User; use Exception; use Illuminate\Support\Carbon; @@ -138,6 +139,11 @@ class Message 'info' => 3447003, ]; + /** + * The interaction route prefix. + */ + protected ?string $routePrefix = null; + /** * Create a new Discord message instance. * @@ -230,6 +236,18 @@ public function sendTo(mixed $user): ?ExtendedPromiseInterface return $user->sendMessage($this->build()); } + /** + * Reply to a message or interaction. + */ + public function reply(Interaction|ChannelMessage $message, bool $ephemeral = false): ExtendedPromiseInterface + { + if ($message instanceof Interaction) { + return $message->respondWithMessage($this->build(), ephemeral: $ephemeral); + } + + return $message->reply($this->build()); + } + /** * Get the embed. */ @@ -630,7 +648,7 @@ public function components(array $components): self /** * Add a URL button to the message. */ - public function button(string $label, mixed $value, mixed $emoji = null, ?string $style = null, bool $disabled = false, ?string $id = null, array $options = []): self + public function button(string $label, mixed $value = null, mixed $emoji = null, ?string $style = null, bool $disabled = false, ?string $id = null, ?string $route = null, array $options = []): self { $style = match ($style) { 'link' => Button::STYLE_LINK, @@ -652,6 +670,12 @@ public function button(string $label, mixed $value, mixed $emoji = null, ?string $button = $button->setCustomId($id); } + if ($route) { + $button = $this->getRoutePrefix() + ? $button->setCustomId("{$this->getRoutePrefix()}@{$route}") + : $button->setCustomId($route); + } + if ($options) { foreach ($options as $key => $option) { $key = Str::of($key)->camel()->ucfirst()->start('set')->toString(); @@ -668,9 +692,15 @@ public function button(string $label, mixed $value, mixed $emoji = null, ?string $button = match ($style) { Button::STYLE_LINK => $button->setUrl($value), - default => $button->setListener($value, $this->bot->discord()), + default => $value ? $button->setListener($value, $this->bot->discord()) : $button, }; + if (! $value && ! $route && ! $id) { + throw new Exception('Message buttons must contain a valid `value`, `route`, or `id`.'); + + return $this; + } + $this->buttons[] = $button; return $this; @@ -701,4 +731,22 @@ public function clearButtons(): self return $this; } + + /** + * Set the interaction route prefix. + */ + public function routePrefix(?string $routePrefix): self + { + $this->routePrefix = Str::slug($routePrefix); + + return $this; + } + + /** + * Retrieve the interaction route prefix. + */ + public function getRoutePrefix(): ?string + { + return $this->routePrefix; + } } diff --git a/src/Laracord.php b/src/Laracord.php index f77af7c..2fbc1e6 100644 --- a/src/Laracord.php +++ b/src/Laracord.php @@ -3,6 +3,8 @@ namespace Laracord; use Discord\DiscordCommandClient as Discord; +use Discord\Parts\Interactions\Interaction; +use Discord\WebSockets\Event as DiscordEvent; use Discord\WebSockets\Intents; use Exception; use Illuminate\Contracts\Foundation\Application; @@ -113,6 +115,11 @@ class Laracord */ protected array $services = []; + /** + * The bot interaction routes. + */ + protected array $interactions = []; + /** * The console input stream. * @@ -195,7 +202,8 @@ public function boot(): void ->registerEvents() ->bootServices() ->bootHttpServer() - ->registerSlashCommands(); + ->registerSlashCommands() + ->handleInteractions(); $this->afterBoot(); @@ -229,7 +237,7 @@ public function boot(): void /** * Boot the Discord client. */ - public function bootDiscord(): void + protected function bootDiscord(): void { $this->discord = new Discord([ 'token' => $this->getToken(), @@ -243,7 +251,7 @@ public function bootDiscord(): void /** * Register the input and output streams. */ - public function registerStream(): self + protected function registerStream(): self { if (windows_os()) { return $this; @@ -264,7 +272,7 @@ public function registerStream(): self /** * Handle the input stream. */ - public function handleStream(string $data): void + protected function handleStream(string $data): void { $command = trim($data); @@ -393,6 +401,8 @@ protected function registerCommands(): self ); $this->registeredCommands[] = $command; + + $this->registerInteractions($command->getName(), $command->interactions()); } return $this; @@ -401,7 +411,7 @@ protected function registerCommands(): self /** * Handle the bot slash commands. */ - protected function registerSlashCommands() + protected function registerSlashCommands(): self { $existing = cache()->get('laracord.slash-commands'); @@ -527,15 +537,18 @@ protected function registerSlashCommands() } if ($registered->isEmpty()) { - return; + return $this; } - $registered->each(fn ($command, $name) => $this->discord()->listenCommand( - $name, - fn ($interaction) => $this->handleSafe($name, fn () => $command['state']->maybeHandle($interaction)) - )); + $registered->each(function ($command, $name) { + $this->discord()->listenCommand($name, fn ($interaction) => $this->handleSafe($name, fn () => $command['state']->maybeHandle($interaction))); + + $this->registerInteractions($name, $command['state']->interactions()); + }); $this->registeredCommands = array_merge($this->registeredCommands, $registered->pluck('state')->all()); + + return $this; } /** @@ -586,10 +599,26 @@ public function unregisterSlashCommand(string $id, ?string $guildId = null): voi $this->discord()->application->commands->delete($id)->done(); } + /** + * Register the interaction routes. + */ + protected function registerInteractions(string $name, array $routes = []): void + { + $routes = collect($routes) + ->mapWithKeys(fn ($value, $route) => ["{$name}@{$route}" => $value]) + ->all(); + + if (! $routes) { + return; + } + + $this->interactions = array_merge($this->interactions, $routes); + } + /** * Register the Discord events. */ - public function registerEvents(): self + protected function registerEvents(): self { foreach ($this->getEvents() as $event) { $this->handleSafe($event, function () use ($event) { @@ -611,7 +640,7 @@ public function registerEvents(): self /** * Boot the bot services. */ - public function bootServices(): self + protected function bootServices(): self { foreach ($this->getServices() as $service) { $this->handleSafe($service, function () use ($service) { @@ -631,24 +660,62 @@ public function bootServices(): self } /** - * Safely handle the provided callback. + * Handle the interaction routes. */ - public function handleSafe(string $name, callable $callback): mixed + protected function handleInteractions(): self { - try { - return $callback(); - } catch (Throwable $e) { - $this->console()->error("An error occurred in {$name}."); - $this->console()->outputComponents()->bulletList([$e->getMessage()]); - } + $this->discord()->on(DiscordEvent::INTERACTION_CREATE, function (Interaction $interaction) { + $id = $interaction->data->custom_id; - return null; + $handlers = collect($this->getInteractions()) + ->partition(fn ($route, $name) => ! Str::contains($name, '{')); + + $static = $handlers[0]; + $dynamic = $handlers[1]; + + if ($route = $static->get($id)) { + return $this->handleSafe($id, fn () => $route($interaction)); + } + + if (! $route) { + $route = $dynamic->first(fn ($route, $name) => Str::before($name, ':') === Str::before($id, ':')); + } + + if (! $route) { + return; + } + + $parameters = []; + $requiredParameters = []; + + if (Str::contains($id, ':')) { + $parameters = explode(':', Str::after($id, ':')); + } + + $routeName = $dynamic->keys()->first(fn ($name) => Str::before($name, ':') === Str::before($id, ':')); + + if ($routeName && preg_match_all('/\{(.*?)\}/', $routeName, $matches)) { + $requiredParameters = $matches[1]; + } + + foreach ($requiredParameters as $index => $param) { + if (! Str::endsWith($param, '?') && (! isset($parameters[$index]) || $parameters[$index] === '')) { + $this->console()->error("Missing required parameter `{$param}` for interaction route `{$routeName}`."); + + return; + } + } + + $this->handleSafe($id, fn () => $route($interaction, ...$parameters)); + }); + + return $this; } /** * Boot the HTTP server. */ - public function bootHttpServer(): self + protected function bootHttpServer(): self { if ($this->httpServer) { return $this; @@ -871,6 +938,14 @@ public function getSlashCommands(): array return $this->slashCommands = $slashCommands; } + /** + * Get the bot interaction routes. + */ + public function getInteractions(): array + { + return $this->interactions; + } + /** * Extract classes from the provided application path. */ @@ -1026,6 +1101,21 @@ public function getApplication(): Application return $this->app; } + /** + * Safely handle the provided callback. + */ + public function handleSafe(string $name, callable $callback): mixed + { + try { + return $callback(); + } catch (Throwable $e) { + $this->console()->error("An error occurred in {$name}."); + $this->console()->outputComponents()->bulletList([$e->getMessage()]); + } + + return null; + } + /** * Build an embed for use in a Discord message. *