diff --git a/composer.json b/composer.json index 1b362aa..3e25506 100644 --- a/composer.json +++ b/composer.json @@ -15,25 +15,30 @@ "require": { "php": "^8.2", "guzzlehttp/guzzle": "^7.5", - "illuminate/cookie": "^11.0", - "illuminate/database": "^11.0", - "illuminate/encryption": "^11.0", - "illuminate/hashing": "^11.0", - "illuminate/http": "^11.0", - "illuminate/queue": "^11.0", - "illuminate/routing": "^11.0", - "illuminate/session": "^11.0", - "illuminate/translation": "^11.0", - "illuminate/validation": "^11.0", - "illuminate/view": "^11.0", + "illuminate/auth": "^12.0", + "illuminate/cookie": "^12.0", + "illuminate/database": "^12.0", + "illuminate/encryption": "^12.0", + "illuminate/hashing": "^12.0", + "illuminate/http": "^12.0", + "illuminate/log": "^12.0", + "illuminate/mail": "^12.0", + "illuminate/queue": "^12.0", + "illuminate/routing": "^12.0", + "illuminate/session": "^12.0", + "illuminate/translation": "^12.0", + "illuminate/validation": "^12.0", + "illuminate/view": "^12.0", "intonate/tinker-zero": "^1.2", - "laravel-zero/framework": "^11.0", + "laravel-zero/framework": "^12.0", "laravel/sanctum": "^4.0", "react/async": "^4.2", + "react/filesystem": "0.2.x-dev", "react/http": "^1.9", "react/promise": "^3.0", + "sebastian/environment": "*", "symfony/psr-http-message-bridge": "^7.0", - "team-reflex/discord-php": "^10.1" + "team-reflex/discord-php": "^10.3" }, "require-dev": { "laravel/pint": "^1.15" diff --git a/config/commands.php b/config/commands.php index b286e24..046cdde 100644 --- a/config/commands.php +++ b/config/commands.php @@ -1,5 +1,16 @@ Laracord\Console\Commands\BootCommand::class, + 'default' => BootCommand::class, /* |-------------------------------------------------------------------------- @@ -57,14 +68,14 @@ */ 'hidden' => [ - NunoMaduro\LaravelConsoleSummary\SummaryCommand::class, - Symfony\Component\Console\Command\DumpCompletionCommand::class, - Symfony\Component\Console\Command\HelpCommand::class, - Illuminate\Console\Scheduling\ScheduleRunCommand::class, - Illuminate\Console\Scheduling\ScheduleListCommand::class, - Illuminate\Console\Scheduling\ScheduleFinishCommand::class, - Illuminate\Foundation\Console\VendorPublishCommand::class, - LaravelZero\Framework\Commands\StubPublishCommand::class, + SummaryCommand::class, + DumpCompletionCommand::class, + HelpCommand::class, + ScheduleRunCommand::class, + ScheduleListCommand::class, + ScheduleFinishCommand::class, + VendorPublishCommand::class, + StubPublishCommand::class, ], /* @@ -79,7 +90,7 @@ */ 'remove' => [ - LaravelZero\Framework\Commands\MakeCommand::class, + MakeCommand::class, ], ]; diff --git a/config/discord.php b/config/discord.php index 9f7277a..4de2fd3 100644 --- a/config/discord.php +++ b/config/discord.php @@ -4,19 +4,6 @@ return [ - /* - |-------------------------------------------------------------------------- - | Discord Bot Description - |-------------------------------------------------------------------------- - | - | Here you may specify the description of your Discord bot. This will be - | used when the bot is mentioned in chat, or when you run the "servers" - | command. Change this to anything you like. - | - */ - - 'description' => env('DISCORD_BOT_DESCRIPTION', 'The Laracord Discord Bot.'), - /* |-------------------------------------------------------------------------- | Discord Token @@ -74,6 +61,8 @@ 'options' => [ 'loadAllMembers' => true, + 'storeMessages' => false, + 'retrieveBans' => false, ], /* @@ -121,65 +110,4 @@ // ], - /* - |-------------------------------------------------------------------------- - | Additional Commands - |-------------------------------------------------------------------------- - | - | Here you may specify any additional commands for the Discord bot. These - | commands will be loaded in addition to the commands automatically loaded - | in your project. By default, the Laracord-provided help command is - | is registered here. - | - */ - - 'commands' => [ - Laracord\Commands\HelpCommand::class, - ], - - /* - |-------------------------------------------------------------------------- - | Additional Context Menus - |-------------------------------------------------------------------------- - | - | Here you may specify any additional context menus for the Discord bot. - | These context menus will be loaded in addition to the context menus - | automatically loaded in your project. - | - */ - - 'menus' => [ - // - ], - - /* - |-------------------------------------------------------------------------- - | Additional Services - |-------------------------------------------------------------------------- - | - | Here you may specify any additional services to run asynchronously - | alongside the Discord bot. These services will be loaded in addition - | to the services automatically loaded from your project. - | - */ - - 'services' => [ - // - ], - - /* - |-------------------------------------------------------------------------- - | Additional Events - |-------------------------------------------------------------------------- - | - | Here you may specify any additional events to listen for in your - | Discord bot. These events will be registered in addition to the - | events automatically registered from your project. - | - */ - - 'events' => [ - // - ], - ]; diff --git a/config/filesystems.php b/config/filesystems.php index 082153f..8579a88 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -32,7 +32,7 @@ 'local' => [ 'driver' => 'local', - 'root' => env('STORAGE_PATH', laracord_path('storage', basePath: false)), + 'root' => storage_path(), 'throw' => false, ], diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..9df61c1 --- /dev/null +++ b/config/logging.php @@ -0,0 +1,145 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', env('LOG_STACK', 'laracord')), + 'ignore_exceptions' => false, + ], + + 'laracord' => [ + 'driver' => 'monolog', + 'handler' => LoggingHandler::class, + 'with' => [ + 'path' => env('LOG_PATH', laracord_path('logs/laracord.log')), + 'level' => env('LOG_LEVEL', 'debug'), + 'maxSize' => env('LOG_MAX_SIZE', 10), + 'maxFiles' => env('LOG_MAX_FILES', 5), + 'flushInterval' => env('LOG_FLUSH_INTERVAL', 30), + ], + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'with' => [ + 'stream' => 'php://stderr', + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laracord.log'), + ], + + ], + +]; diff --git a/config/session.php b/config/session.php index bdcfe12..bc468c9 100644 --- a/config/session.php +++ b/config/session.php @@ -18,7 +18,7 @@ | */ - 'driver' => env('SESSION_DRIVER', 'file'), + 'driver' => env('SESSION_DRIVER', 'cookie'), /* |-------------------------------------------------------------------------- diff --git a/src/Bot/Concerns/HasApplicationCommands.php b/src/Bot/Concerns/HasApplicationCommands.php new file mode 100644 index 0000000..b7e8073 --- /dev/null +++ b/src/Bot/Concerns/HasApplicationCommands.php @@ -0,0 +1,277 @@ +discord->application->commands->freshen(); + + foreach ($this->discord->guilds as $guild) { + $existing[] = $guild->commands->freshen(); + } + + $existing = all($existing)->then(fn ($commands) => collect($commands) + ->flatMap(fn ($command) => $command->toArray()) + ->map(fn ($command) => collect($command->getCreatableAttributes()) + ->merge([ + 'id' => $command->id, + 'guild_id' => $command->guild_id ?? null, + 'dm_permission' => $command->guild_id ? null : ($command->dm_permission ?? false), + 'default_permission' => $command->default_permission ?? true, + ]) + ->all() + ) + ->map(fn ($command) => array_merge($command, [ + 'options' => json_decode(json_encode($command['options'] ?? []), true), + ])) + ->filter(fn ($command) => filled($command)) + ->keyBy('name') + ); + + $existing = await($existing); + $existing = collect($existing); + + $registered = collect($this->slashCommands) + ->merge($this->contextMenus) + ->filter(fn ($command) => $command->isEnabled()) + ->mapWithKeys(function ($command) { + $attributes = $command->create()->getCreatableAttributes(); + + $attributes = collect($attributes) + ->merge([ + 'guild_id' => $command->getGuild() ?? null, + 'dm_permission' => ! $command->getGuild() ? $command->canDirectMessage() : null, + 'nsfw' => $command->isNsfw(), + ]) + ->sortKeys() + ->all(); + + return [$command->getName() => [ + 'state' => $command, + 'attributes' => $attributes, + ]]; + }); + + $created = $registered->reject(fn ($command, $name) => $existing->has($name))->filter(); + $deleted = $existing->reject(fn ($command, $name) => $registered->has($name))->filter(); + + $updated = $registered + ->map(function ($command) { + $attributes = collect($command['attributes']) + ->reject(fn ($value) => blank($value)) + ->all(); + + return array_merge($command, ['attributes' => $attributes]); + }) + ->filter(function ($command, $name) use ($existing, $normalize) { + if (! $existing->has($name)) { + return false; + } + + $current = collect($existing->get($name)) + ->forget('id') + ->reject(fn ($value) => blank($value)); + + $attributes = collect($command['attributes']) + ->reject(fn ($value) => blank($value)); + + $keys = collect($current->keys()) + ->merge($attributes->keys()) + ->unique(); + + foreach ($keys as $key) { + $attribute = $current->get($key); + $value = $attributes->get($key); + + $attribute = $normalize($attribute); + $value = $normalize($value); + + if ($attribute === $value) { + continue; + } + + return true; + } + + return false; + }) + ->each(function ($command) use ($existing) { + $state = $existing->get($command['state']->getName()); + + $current = Arr::get($command, 'attributes.guild_id'); + $existing = Arr::get($state, 'guild_id'); + + if ($current && ! $existing) { + $this->unregisterApplicationCommand($state['id']); + } + + if ((! $current && $existing) || $current !== $existing) { + $this->unregisterApplicationCommand($state['id'], $existing); + } + }); + + if ($updated->isNotEmpty()) { + $this->logger->warning("Updating {$updated->count()} application command(s)."); + + $updated->each(function ($command) { + $state = $command['state']; + + $this->registerApplicationCommand($state); + }); + } + + if ($deleted->isNotEmpty()) { + $this->logger->warning("Deleting {$deleted->count()} application command(s)."); + + $deleted->each(fn ($command) => $this->unregisterApplicationCommand($command['id'], $command['guild_id'] ?? null)); + } + + if ($created->isNotEmpty()) { + $this->logger->info("Creating {$created->count()} new application command(s)."); + + $created->each(fn ($command) => $this->registerApplicationCommand($command['state'])); + } + + if ($registered->isEmpty()) { + return $this; + } + + $registered->each(function ($command, $name) { + $this->registerInteractions($name, $command['state']->interactions()); + + if ($command['state'] instanceof ContextMenu) { + $menu = $command['state']; + + $this->discord->listenCommand( + $name, + fn ($interaction) => rescue(fn () => $menu->maybeHandle($interaction)) + ); + + $this->contextMenus[$menu::class] = $menu; + + return; + } + + $command = $command['state']; + + $subcommands = collect($command->getRegisteredOptions()) + ->filter(fn (Option $option) => $option->type === Option::SUB_COMMAND) + ->map(fn (Option $subcommand) => [$name, $subcommand->name]); + + $subcommandGroups = collect($command->getRegisteredOptions()) + ->filter(fn (Option $option) => $option->type === Option::SUB_COMMAND_GROUP) + ->flatMap(fn (Option $group) => collect($group->options) + ->filter(fn (Option $subcommand) => $subcommand->type === Option::SUB_COMMAND) + ->map(fn (Option $subcommand) => [$name, $group->name, $subcommand->name]) + ); + + $subcommands = $subcommands->merge($subcommandGroups); + + if ($subcommands->isNotEmpty()) { + $subcommands->each(fn ($names) => $this->discord->listenCommand( + $names, + fn ($interaction) => rescue(fn () => $command->maybeHandle($interaction)), + fn ($interaction) => rescue(fn () => $command->maybeHandleAutocomplete($interaction)) + )); + + return; + } + + $this->discord->listenCommand( + $name, + fn ($interaction) => rescue(fn () => $command->maybeHandle($interaction)), + fn ($interaction) => rescue(fn () => $command->maybeHandleAutocomplete($interaction)) + ); + + $this->slashCommands[$command::class] = $command; + }); + + $this->callHook(Hook::AFTER_APPLICATION_COMMANDS_REGISTERED); + + return $this; + } + + /** + * Register the specified application command. + */ + protected function registerApplicationCommand(ApplicationCommand $command): void + { + if ($command->getGuild()) { + $guild = $this->discord->guilds->get('id', $command->getGuild()); + + if (! $guild) { + $this->logger->warning("The {$command->getName()} command failed to register because the guild {$command->getGuild()} could not be found."); + + return; + } + + $guild->commands->save($command->create()); + + return; + } + + $this->discord->application->commands->save($command->create()); + } + + /** + * Unregister the specified application command. + */ + protected function unregisterApplicationCommand(string $id, ?string $guildId = null): void + { + if ($guildId) { + $guild = $this->discord->guilds->get('id', $guildId); + + if (! $guild) { + $this->logger->warning("The command with ID {$id} failed to unregister because the guild {$guildId} could not be found."); + + return; + } + + $guild->commands->delete($id); + + return; + } + + $this->discord->application->commands->delete($id); + } +} diff --git a/src/Concerns/CanAsync.php b/src/Bot/Concerns/HasAsync.php similarity index 62% rename from src/Concerns/CanAsync.php rename to src/Bot/Concerns/HasAsync.php index f1cd4c3..839f09c 100644 --- a/src/Concerns/CanAsync.php +++ b/src/Bot/Concerns/HasAsync.php @@ -1,26 +1,27 @@ getLoop()) { - throw new Exception('The Laracord event loop is not available.'); + if (! $loop = app(LoopInterface::class)) { + throw new Exception('The event loop is not available.'); } $loop->futureTick(function () use ($callback, $resolve, $reject) { try { - $result = $callback(); - $resolve($result); + $resolve($callback()); } catch (Exception $e) { $reject($e); } @@ -31,7 +32,7 @@ public static function handleAsync(callable $callback): Promise /** * Perform an asynchronous operation. */ - public function async(callable $callback): Promise + public function async(callable $callback): PromiseInterface { return static::handleAsync($callback); } diff --git a/src/Bot/Concerns/HasCommandMiddleware.php b/src/Bot/Concerns/HasCommandMiddleware.php new file mode 100644 index 0000000..8176323 --- /dev/null +++ b/src/Bot/Concerns/HasCommandMiddleware.php @@ -0,0 +1,93 @@ +commandMiddleware[] = $middleware; + + return $this; + } + + /** + * Register multiple global command middleware. + */ + public function registerCommandMiddlewares(array $middlewares): self + { + foreach ($middlewares as $middleware) { + $this->registerCommandMiddleware($middleware); + } + + return $this; + } + + /** + * Get the global command middleware. + */ + public function getCommandMiddleware(): array + { + return $this->commandMiddleware; + } + + /** + * Parse middleware string to get the name and parameters. + */ + protected function parseMiddlewareString(string $middleware): array + { + [$name, $parameters] = array_pad(explode(':', $middleware, 2), 2, null); + + if (is_null($parameters)) { + return [$name, []]; + } + + return [$name, explode(',', $parameters)]; + } + + /** + * Get all middleware for a command, including global middleware. + */ + public function resolveCommandMiddleware(array $commandMiddleware = []): array + { + $resolveMiddleware = function ($middleware) { + if ($middleware instanceof Middleware) { + return $middleware; + } + + [$name, $parameters] = $this->parseMiddlewareString($middleware); + + if (empty($parameters)) { + return new $name; + } + + return new $name(...$parameters); + }; + + return array_map( + $resolveMiddleware, + array_merge($this->getCommandMiddleware(), $commandMiddleware) + ); + } +} diff --git a/src/Bot/Concerns/HasCommands.php b/src/Bot/Concerns/HasCommands.php new file mode 100644 index 0000000..e92312d --- /dev/null +++ b/src/Bot/Concerns/HasCommands.php @@ -0,0 +1,187 @@ +prefixes) { + return $this->prefixes; + } + + $prefixes = collect(config('discord.prefix', '!')) + ->map(fn ($prefix) => Str::of($prefix)->replace(['@mention', '@self'], (string) $this->discord->user)->trim()->toString()) + ->reject(fn ($prefix) => Str::startsWith($prefix, '/')) + ->filter(); + + if ($prefixes->isEmpty()) { + throw new Exception('You must provide a valid command prefix.'); + } + + return $this->prefixes = $prefixes; + } + + /** + * Retrieve the primary prefix. + */ + public function getPrefix(): string + { + return $this->getPrefixes()->first(); + } + + /** + * Boot the chat commands. + */ + protected function bootCommands(): self + { + $this->handleCommands(); + + $this->callHook(Hook::AFTER_COMMANDS_REGISTERED); + + return $this; + } + + /** + * Handle the chat commands. + */ + protected function handleCommands(): void + { + $this->discord->on('message', function (Message $message) { + if ($message->author->id === $this->discord->id) { + return; + } + + $prefix = $this->getPrefixes()->first(fn ($prefix) => Str::startsWith($message->content, $prefix)); + + if (! $prefix) { + return; + } + + $after = Str::substr($message->content, Str::length($prefix), 1); + + if ($after === ' ') { + return; + } + + $parts = Str::of($message->content) + ->after($prefix) + ->explode(' '); + + $command = $parts->shift(); + + if (! $command) { + return; + } + + $command = $this->getCommand($command); + + if (! $command) { + return; + } + + rescue(fn () => $command->maybeHandle($message, $parts->all())); + }); + } + + /** + * Register a command. + */ + public function registerCommand(Command|string $command): self + { + if (is_string($command)) { + $command = $command::make(); + } + + if (! is_subclass_of($command, Command::class)) { + throw new InvalidArgumentException("Class [{$command}] is not a valid command."); + } + + if (! $command->isEnabled()) { + return $this; + } + + $this->commands[$command::class] = $command; + + $this->commandMap[$command->getName()] = $command::class; + + foreach ($command->getAliases() as $alias) { + $this->commandMap[$alias] = $command::class; + } + + $this->registerInteractions($command->getName(), $command->interactions()); + + return $this; + } + + /** + * Register multiple commands. + */ + public function registerCommands(array $commands): self + { + foreach ($commands as $command) { + $this->registerCommand($command); + } + + return $this; + } + + /** + * Discover commands in a path. + */ + public function discoverCommands(string $in, string $for): self + { + foreach ($this->discover(Command::class, $in, $for) as $command) { + $this->registerCommand($command); + } + + return $this; + } + + /** + * Get the registered commands. + */ + public function getCommands(): array + { + return $this->commands; + } + + /** + * Get a registered command by name. + */ + public function getCommand(string $name): ?Command + { + $command = $this->commandMap[$name] ?? null; + + if (! $command) { + return null; + } + + return $this->commands[$command] ?? null; + } +} diff --git a/src/Bot/Concerns/HasComponents.php b/src/Bot/Concerns/HasComponents.php new file mode 100644 index 0000000..5bb56f7 --- /dev/null +++ b/src/Bot/Concerns/HasComponents.php @@ -0,0 +1,61 @@ +exists($directory)) && (! str($directory)->contains('*'))) { + return []; + } + + $namespace = str($namespace); + $discovered = []; + + foreach ($filesystem->allFiles($directory) as $file) { + $variableNamespace = $namespace->contains('*') ? str_ireplace( + ['\\'.$namespace->before('*'), $namespace->after('*')], + ['', ''], + str_replace([DIRECTORY_SEPARATOR], ['\\'], (string) str($file->getPath())->after(base_path())), + ) : null; + + if (is_string($variableNamespace)) { + $variableNamespace = (string) str($variableNamespace)->before('\\'); + } + + $class = (string) $namespace + ->append('\\', $file->getRelativePathname()) + ->replace('*', $variableNamespace ?? '') + ->replace([DIRECTORY_SEPARATOR, '.php'], ['\\', '']); + + if (! class_exists($class)) { + continue; + } + + if ((new ReflectionClass($class))->isAbstract()) { + continue; + } + + if (! is_subclass_of($class, $baseClass)) { + continue; + } + + $discovered[] = $class; + } + + return $discovered; + } +} diff --git a/src/Bot/Concerns/HasConsole.php b/src/Bot/Concerns/HasConsole.php new file mode 100644 index 0000000..9f60bd9 --- /dev/null +++ b/src/Bot/Concerns/HasConsole.php @@ -0,0 +1,149 @@ +console) { + return; + } + + $this->app->make(LoggerInterface::class)->pushHandler(new ConsoleHandler); + + $this->console = $this->app->make(Console::class); + + foreach ($this->getPrompts() as $prompt) { + $this->console->addCommand($prompt); + } + } + + /** + * Register a console prompt. + */ + public function registerPrompt(Prompt|string $prompt): self + { + if (is_string($prompt)) { + $prompt = $prompt::make(); + } + + if (! is_subclass_of($prompt, Prompt::class)) { + throw new InvalidArgumentException("Class [{$prompt}] is not a valid prompt."); + } + + $this->prompts[$prompt::class] = $prompt; + + return $this; + } + + /** + * Register multiple console prompts. + */ + public function registerPrompts(array $prompts): self + { + foreach ($prompts as $prompt) { + $this->registerPrompt($prompt); + } + + return $this; + } + + /** + * Print the registered commands to console. + */ + public function showCommands(): self + { + if (! $this->showCommands) { + return $this; + } + + $commands = [ + ...$this->commands, + ...$this->slashCommands, + ]; + + $this->console->table( + ['Command', 'Description'], + collect($commands)->map(fn ($command) => [ + $command->getSignature(), + $command->getDescription(), + ])->all(), + tableStyle: 'box', + ); + + return $this; + } + + /** + * Show the invite link if the bot is not in any guilds. + */ + public function showInvite(bool $force = false): self + { + if (! $force && (! $this->showInvite || $this->discord->guilds->count() > 0)) { + return $this; + } + + if (! $force) { + $this->logger->warning("{$this->getName()} is currently not in any guilds."); + } + + $query = Arr::query([ + 'client_id' => $this->discord->id, + 'permissions' => 281600, + 'scope' => 'bot applications.commands', + ]); + + $invite = "https://discord.com/oauth2/authorize?{$query}"; + + $this->logger->info("You can invite {$this->getName()} using the following link: {$invite}"); + + return $this; + } + + /** + * Get the registered prompts. + */ + public function getPrompts(): array + { + return $this->prompts; + } + + /** + * Get the console instance. + */ + public function console(): ?Console + { + return $this->console; + } +} diff --git a/src/Bot/Concerns/HasContextMenus.php b/src/Bot/Concerns/HasContextMenus.php new file mode 100644 index 0000000..09d9236 --- /dev/null +++ b/src/Bot/Concerns/HasContextMenus.php @@ -0,0 +1,74 @@ +contextMenus[$menu::class] = $menu; + + return $this; + } + + /** + * Register multiple context menus. + */ + public function registerContextMenus(array $menus): self + { + foreach ($menus as $menu) { + $this->registerContextMenu($menu); + } + + return $this; + } + + /** + * Discover context menus in a path. + */ + public function discoverContextMenus(string $in, string $for): self + { + foreach ($this->discover(ContextMenu::class, $in, $for) as $menu) { + $this->registerContextMenu($menu); + } + + return $this; + } + + /** + * Get the registered context menus. + */ + public function getContextMenus(): array + { + return $this->contextMenus; + } + + /** + * Get a registered context menu by name. + */ + public function getContextMenu(string $name): ?ContextMenu + { + return $this->contextMenus[$name] ?? collect($this->contextMenus)->first(fn (ContextMenu $menu): bool => $menu->getName() === $name); + } +} diff --git a/src/Bot/Concerns/HasDiscord.php b/src/Bot/Concerns/HasDiscord.php new file mode 100644 index 0000000..e1e3fe5 --- /dev/null +++ b/src/Bot/Concerns/HasDiscord.php @@ -0,0 +1,203 @@ +discord = new Discord($this->getOptions()); + $this->admins = config('discord.admins', $this->admins); + } + + /** + * Get the bot name. + */ + public function getName(): string + { + if ($this->name) { + return $this->name; + } + + return $this->name = config('app.name'); + } + + /** + * Set the bot token. + */ + public function setToken(string $token): self + { + $this->token = $token; + + return $this; + } + + /** + * Get the bot token. + */ + public function getToken(): string + { + if ($this->token) { + return $this->token; + } + + $token = config('discord.token'); + + if (! $token) { + $this->logger->error('You must provide a Discord bot token.'); + + exit(1); + } + + return $this->token = $token; + } + + /** + * Get the bot intents. + */ + public function getIntents(): ?int + { + if ($this->intents) { + return $this->intents; + } + + return $this->intents = config('discord.intents', Intents::getDefaultIntents()); + } + + /** + * Set the bot shard ID. + */ + public function setShard(int $id, int $count): self + { + $this->shardId = $id; + $this->shardCount = $count; + + return $this; + } + + /** + * Determine if the bot is a shard. + */ + public function isShard(): bool + { + return filled($this->shardId) && filled($this->shardCount); + } + + /** + * Get the current shard ID. + */ + public function getShardId(): ?int + { + return $this->shardId; + } + + /** + * Get the shard count. + */ + public function getShardCount(): ?int + { + return $this->shardCount; + } + + /** + * Get the bot options. + */ + public function getOptions(): array + { + if ($this->options) { + return $this->options; + } + + $options = [ + 'token' => $this->getToken(), + 'intents' => $this->getIntents(), + 'logger' => $this->getLogger(), + 'loop' => $this->getLoop(), + ]; + + if ($this->isShard()) { + $options = [ + ...$options, + 'shardId' => $this->shardId, + 'shardCount' => $this->shardCount, + ]; + } + + return $this->options = [ + ...config('discord.options', []), + ...$options, + ]; + } + + /** + * Get the Discord admins. + */ + public function getAdmins(): array + { + return $this->admins; + } + + /** + * Retrieve the Discord instance. + */ + public function discord(): ?Discord + { + return $this->discord; + } + + /** + * Build a mesage for Discord. + */ + public function message(string $content = ''): Message + { + return clone app(Message::class) + ->content($content); + } +} diff --git a/src/Bot/Concerns/HasEvents.php b/src/Bot/Concerns/HasEvents.php new file mode 100644 index 0000000..10ddc4b --- /dev/null +++ b/src/Bot/Concerns/HasEvents.php @@ -0,0 +1,95 @@ +events as $event) { + if (! $event->isEnabled()) { + continue; + } + + $this->events[$event::class] = $event->register(); + + $this->logger->info("The {$event->getName()} event has been registered to {$event->getHandler()}."); + } + + $this->callHook(Hook::AFTER_EVENTS_REGISTERED); + + return $this; + } + + /** + * Register an event. + */ + public function registerEvent(Event|string $event): self + { + if (is_string($event)) { + $event = $event::make(); + } + + if (! is_subclass_of($event, Event::class)) { + $class = $event::class; + + throw new InvalidArgumentException("Class [{$class}] is not a valid event."); + } + + $this->events[$event::class] = $event; + + return $this; + } + + /** + * Register multiple events. + */ + public function registerEvents(array $events): self + { + foreach ($events as $event) { + $this->registerEvent($event); + } + + return $this; + } + + /** + * Discover events in a path. + */ + public function discoverEvents(string $in, string $for): self + { + foreach ($this->discover(Event::class, $in, $for) as $event) { + $this->registerEvent($event); + } + + return $this; + } + + /** + * Get the registered events. + */ + public function getEvents(): array + { + return $this->events; + } + + /** + * Get a registered event by name. + */ + public function getEvent(string $name): ?Event + { + return $this->events[$name] ?? null; + } +} diff --git a/src/Bot/Concerns/HasHooks.php b/src/Bot/Concerns/HasHooks.php new file mode 100644 index 0000000..74a0168 --- /dev/null +++ b/src/Bot/Concerns/HasHooks.php @@ -0,0 +1,47 @@ +> + */ + protected array $hooks = []; + + /** + * Register a hook callback. + */ + public function registerHook(Hook|string $hook, callable $callback): self + { + $hook = $hook instanceof Hook + ? $hook->value + : $hook; + + if (! isset($this->hooks[$hook])) { + $this->hooks[$hook] = []; + } + + $this->hooks[$hook][] = $callback; + + return $this; + } + + /** + * Call all registered callbacks for a hook. + */ + public function callHook(Hook|string $hook): void + { + $hook = $hook instanceof Hook + ? $hook->value + : $hook; + + foreach ($this->hooks[$hook] ?? [] as $callback) { + $this->app->call($callback); + } + } +} diff --git a/src/Bot/Concerns/HasHttpServer.php b/src/Bot/Concerns/HasHttpServer.php new file mode 100644 index 0000000..8be34a2 --- /dev/null +++ b/src/Bot/Concerns/HasHttpServer.php @@ -0,0 +1,167 @@ +app->make('router'); + + if (! is_null($callback)) { + $callback($router); + } + + return $this; + } + + /** + * The HTTP middleware. + */ + public function withMiddleware(?callable $callback = null): self + { + /** @var \Laracord\Http\Kernel $kernel */ + $kernel = $this->app->make(Kernel::class); + + /** @var Middleware $middleware */ + $middleware = $this->app->make(Middleware::class); + + if (! is_null($callback)) { + $callback($middleware); + } + + $kernel->setGlobalMiddleware($middleware->getGlobalMiddleware()); + $kernel->setMiddlewareGroups($middleware->getMiddlewareGroups()); + $kernel->setMiddlewareAliases($middleware->getMiddlewareAliases()); + + if ($priorities = $middleware->getMiddlewarePriority()) { + $kernel->setMiddlewarePriority($priorities); + } + + return $this; + } + + /** + * Boot the HTTP server. + */ + protected function bootHttpServer(): self + { + if ($this->httpServer || ! $this->isHttpEnabled()) { + return $this; + } + + rescue(function () { + $this->app->booted(function () { + $this->app['router']->getRoutes()->refreshNameLookups(); + $this->app['router']->getRoutes()->refreshActionLookups(); + }); + + $this->httpServer = HttpServer::make($this) + ->setAddress($this->getHttpAddress()) + ->boot(); + + if ($this->httpServer->isBooted()) { + $this->logger->info("HTTP server started on {$this->httpServer->getAddress()}."); + + $this->callHook(Hook::AFTER_HTTP_SERVER_START); + } + }); + + return $this; + } + + /** + * Disable the HTTP server. + */ + public function disableHttpServer(): self + { + $this->httpEnabled = false; + + return $this; + } + + /** + * Determine if the HTTP server is enabled. + */ + public function isHttpEnabled(): bool + { + return $this->httpEnabled && $this->getHttpAddress(); + } + + /** + * Set the HTTP server address. + */ + public function setHttpAddress(string $address): self + { + $this->httpAddress = $address; + + return $this; + } + + /** + * Get the HTTP server address. + */ + public function getHttpAddress(): ?string + { + $this->httpAddress ??= config('discord.http'); + + if (! $this->httpAddress) { + return null; + } + + if (Str::startsWith($this->httpAddress, ':')) { + $this->httpAddress = Str::start($this->httpAddress, '0.0.0.0'); + } + + $host = Str::before($this->httpAddress, ':'); + $port = Str::after($this->httpAddress, ':'); + + if (! filter_var($host, FILTER_VALIDATE_IP)) { + $this->logger->error('Invalid HTTP server address'); + + return null; + } + + if ($port > 65535 || $port < 1) { + $this->logger->error('Invalid HTTP server port'); + + return null; + } + + return $this->httpAddress; + } + + /** + * Get the HTTP server instance. + */ + public function httpServer(): ?HttpServer + { + return $this->httpServer; + } +} diff --git a/src/Bot/Concerns/HasInteractions.php b/src/Bot/Concerns/HasInteractions.php new file mode 100644 index 0000000..4063aab --- /dev/null +++ b/src/Bot/Concerns/HasInteractions.php @@ -0,0 +1,159 @@ +mapWithKeys(fn ($value, $route) => ["{$name}@{$route}" => $value]) + ->all(); + + if (! $routes) { + return; + } + + $this->interactions = [ + ...$this->interactions, + ...$routes, + ]; + } + + /** + * Get the registered interactions. + */ + public function getInteractions(): array + { + return $this->interactions; + } + + /** + * Register an interaction middleware. + */ + public function registerInteractionMiddleware(string|Middleware $middleware): self + { + if (is_string($middleware)) { + if (! class_exists($middleware)) { + throw new InvalidArgumentException("Middleware class [{$middleware}] does not exist."); + } + + if (! is_subclass_of($middleware, Middleware::class)) { + throw new InvalidArgumentException("Middleware class [{$middleware}] must implement the Middleware interface."); + } + } + + $this->interactionMiddleware[] = $middleware; + + return $this; + } + + /** + * Register multiple interaction middleware. + */ + public function registerInteractionMiddlewares(array $middlewares): self + { + foreach ($middlewares as $middleware) { + $this->registerInteractionMiddleware($middleware); + } + + return $this; + } + + /** + * Get the interaction middleware. + */ + public function getInteractionMiddleware(): array + { + return $this->resolveCommandMiddleware($this->interactionMiddleware); + } + + /** + * Process the interaction through its middleware stack. + */ + protected function processInteractionMiddleware(Interaction $interaction, callable $handler): mixed + { + $context = new Context(source: $interaction); + + return (new Pipeline($this->app)) + ->send($context) + ->through($this->getInteractionMiddleware()) + ->then(fn (Context $context) => $handler($context->source)); + } + + /** + * Handle the interaction routes. + */ + protected function handleInteractions(): self + { + $this->discord->on(Event::INTERACTION_CREATE, function (Interaction $interaction) { + $id = $interaction->data->custom_id; + + $handlers = collect($this->getInteractions()) + ->partition(fn ($route, $name) => ! Str::contains($name, '{')); + + $static = $handlers[0]; + $dynamic = $handlers[1]; + + if ($route = $static->get($id)) { + return rescue(fn () => $this->processInteractionMiddleware($interaction, fn ($interaction) => $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->bot->logger->error("Missing required parameter `{$param}` for interaction route `{$routeName}`."); + + return; + } + } + + rescue(fn () => $this->processInteractionMiddleware( + $interaction, + fn ($interaction) => $route($interaction, ...$parameters) + )); + }); + + return $this; + } +} diff --git a/src/Bot/Concerns/HasLogger.php b/src/Bot/Concerns/HasLogger.php new file mode 100644 index 0000000..9873631 --- /dev/null +++ b/src/Bot/Concerns/HasLogger.php @@ -0,0 +1,34 @@ +logger) { + return; + } + + $this->logger = $this->app->make(LoggerInterface::class); + } + + /** + * Get the logger instance. + */ + public function getLogger(): ?LogManager + { + return $this->logger; + } +} diff --git a/src/Bot/Concerns/HasLoop.php b/src/Bot/Concerns/HasLoop.php new file mode 100644 index 0000000..abe3511 --- /dev/null +++ b/src/Bot/Concerns/HasLoop.php @@ -0,0 +1,48 @@ +logger->warning('The pcntl extension is not loaded. Signal handling is disabled.'); + + return; + } + + $loop = $this->getLoop(); + + $loop->addSignal(SIGINT, function () { + $this->logger->info('Received shutdown signal (SIGINT).'); + + $this->shutdown(); + }); + + $loop->addSignal(SIGTERM, function () { + $this->logger->info('Received shutdown signal (SIGTERM).'); + + $this->shutdown(); + }); + } + + /** + * Get the event loop. + */ + public function getLoop(): LoopInterface + { + return $this->app->make(LoopInterface::class); + } +} diff --git a/src/Bot/Concerns/HasPlugins.php b/src/Bot/Concerns/HasPlugins.php new file mode 100644 index 0000000..11a55e7 --- /dev/null +++ b/src/Bot/Concerns/HasPlugins.php @@ -0,0 +1,59 @@ + + */ + protected array $plugins = []; + + /** + * Register a plugin. + */ + public function plugin(Plugin $plugin): static + { + $plugin->register($this); + + $this->plugins[$plugin::class] = $plugin; + + return $this; + } + + /** + * Register multiple plugins. + * + * @param array $plugins + */ + public function plugins(array $plugins): static + { + foreach ($plugins as $plugin) { + $this->plugin($plugin); + } + + return $this; + } + + /** + * Get the registered plugins. + * + * @return array + */ + public function getPlugins(): array + { + return $this->plugins; + } + + /** + * Retrieve a registered plugin. + */ + public function getPlugin(string $plugin): ?Plugin + { + return $this->plugins[$plugin] ?? null; + } +} diff --git a/src/Bot/Concerns/HasSlashCommands.php b/src/Bot/Concerns/HasSlashCommands.php new file mode 100644 index 0000000..7847afe --- /dev/null +++ b/src/Bot/Concerns/HasSlashCommands.php @@ -0,0 +1,74 @@ +slashCommands[$command::class] = $command; + + return $this; + } + + /** + * Register multiple slash commands. + */ + public function registerSlashCommands(array $commands): self + { + foreach ($commands as $command) { + $this->registerSlashCommand($command); + } + + return $this; + } + + /** + * Discover slash commands in a path. + */ + public function discoverSlashCommands(string $in, string $for): self + { + foreach ($this->discover(SlashCommand::class, $in, $for) as $command) { + $this->registerSlashCommand($command); + } + + return $this; + } + + /** + * Get a registered slash command by name. + */ + public function getSlashCommand(string $name): ?SlashCommand + { + return $this->slashCommands[$name] ?? collect($this->slashCommands)->first(fn (SlashCommand $command): bool => $command->getName() === $name); + } + + /** + * Get the registered slash commands. + */ + public function getSlashCommands(): array + { + return $this->slashCommands; + } +} diff --git a/src/Bot/Concerns/HasTasks.php b/src/Bot/Concerns/HasTasks.php new file mode 100644 index 0000000..9e7c203 --- /dev/null +++ b/src/Bot/Concerns/HasTasks.php @@ -0,0 +1,93 @@ +tasks as $task) { + if (! $task->isEnabled()) { + continue; + } + + $this->tasks[$task::class] = $task->boot(); + } + + $this->callHook(Hook::AFTER_TASKS_REGISTERED); + + return $this; + } + + /** + * Register a task. + */ + public function registerTask(Task|string $task): self + { + if (is_string($task)) { + $task = $task::make(); + } + + if (! is_subclass_of($task, Task::class)) { + $class = $task::class; + + throw new InvalidArgumentException("Class [{$class}] is not a valid task."); + } + + $this->tasks[$task::class] = $task; + + return $this; + } + + /** + * Register multiple tasks. + */ + public function registerTasks(array $tasks): self + { + foreach ($tasks as $task) { + $this->registerTask($task); + } + + return $this; + } + + /** + * Discover tasks in a path. + */ + public function discoverTasks(string $in, string $for): self + { + foreach ($this->discover(Task::class, $in, $for) as $task) { + $this->registerTask($task); + } + + return $this; + } + + /** + * Get the registered tasks. + */ + public function getTasks(): array + { + return $this->tasks; + } + + /** + * Get a registered task by name. + */ + public function getTask(string $name): ?Task + { + return $this->tasks[$name] ?? collect($this->tasks)->first(fn (Task $task): bool => $task->getName() === $name); + } +} diff --git a/src/Bot/Concerns/HasUserModel.php b/src/Bot/Concerns/HasUserModel.php new file mode 100644 index 0000000..e9d407b --- /dev/null +++ b/src/Bot/Concerns/HasUserModel.php @@ -0,0 +1,51 @@ +userModel = $model; + + return $this; + } + + /** + * Get the user model class. + */ + public function getUserModel(): ?string + { + if ($this->userModel) { + return $this->userModel; + } + + $model = Str::start($this->app->getNamespace(), '\\').'Models\\User'; + + if (! class_exists($model)) { + return null; + } + + if (! is_subclass_of($model, Model::class)) { + throw new Exception('The user model must extend '.Model::class); + } + + return $this->userModel = $model; + } +} diff --git a/src/Bot/Hook.php b/src/Bot/Hook.php new file mode 100644 index 0000000..5d1b38b --- /dev/null +++ b/src/Bot/Hook.php @@ -0,0 +1,66 @@ +bot = $bot; - $this->console = $bot->console(); - $this->discord = $bot->discord(); + return $this->bot()->resolveCommandMiddleware($this->middleware); } /** * Make a new command instance. */ - public static function make(Laracord $bot): self + public static function make(): self { - return new static($bot); + return new static; } /** @@ -123,33 +108,33 @@ public function interactions(): array } /** - * Build an embed for use in a Discord message. - * - * @param string $content - * @return \Laracord\Discord\Message + * {@inheritdoc} */ public function message($content = '') { - return $this->bot()->message($content)->routePrefix($this->getName()); + return clone app(Message::class) + ->content($content) + ->routePrefix($this->getName()); } /** * Determine if the Discord user is an admin. - * - * @param string|\Discord\Parts\User\User $user - * @return bool */ - public function isAdmin($user) + public function isAdmin(User|string $user): bool { if (! $user instanceof User) { - $user = $this->discord()->users->get('id', $user); + $user = $this->discord->users->get('id', $user); } - if ($this->bot()->getAdmins()) { - return in_array($user->id, $this->bot()->getAdmins()); + if ($this->bot->getAdmins()) { + return in_array($user->id, $this->bot->getAdmins()); } - return $this->getUser($user)->is_admin; + if (! $this->bot->getUserModel()) { + return false; + } + + return $this->bot->getUserModel()::where(['discord_id' => $user->id])->first()?->is_admin ?? false; } /** @@ -160,68 +145,30 @@ public function canDirectMessage(): bool return $this->directMessage; } - /** - * Resolve a Discord user. - */ - public function resolveUser(string $username): ?User - { - return ! empty($username) ? $this->discord()->users->filter(function ($user) use ($username) { - $username = str_replace(['<', '@', '>'], '', strtolower($username)); - - return ($user->username === $username || $user->id === $username) && ! $user->bot; - })->first() : null; - } - - /** - * Get the command user. - * - * @param \Discord\Parts\User\User $user - * @return \App\Models\User|null - */ - public function getUser($user) - { - $model = Str::start(app()->getNamespace(), '\\').'Models\\User'; - - if (! class_exists($model)) { - throw new Exception('The user model could not be found.'); - } - - return $this->user = $model::firstOrCreate(['discord_id' => $user->id], [ - 'discord_id' => $user->id, - 'username' => $user->username, - ]) ?? null; - } - /** * Retrieve the command name. - * - * @return string */ - public function getName() + public function getName(): string { return $this->name; } /** * Retrieve the command signature. - * - * @return string */ - public function getSignature() + public function getSignature(): string { return $this->getName(); } /** * Retrieve the full command syntax. - * - * @return string */ - public function getSyntax() + public function getSyntax(): string { $command = $this->getSignature(); - if (! empty($this->usage)) { + if (filled($this->usage)) { $command .= " `{$this->usage}`"; } @@ -230,10 +177,8 @@ public function getSyntax() /** * Retrieve the command description. - * - * @return string */ - public function getDescription() + public function getDescription(): string { return $this->description; } @@ -257,41 +202,49 @@ public function getGuild(): ?string } /** - * Retrieve the bot instance. - * - * @return \Laracord\Laracord + * Determine if the command requires admin permissions. */ - public function bot() + public function isAdminCommand(): bool { - return $this->bot; + return $this->admin; } /** - * Retrieve the console instance. - * - * @return \Laracord\Console\Commands\Command + * Determine if the user is on cooldown. */ - public function console() + public function isOnCooldown(User $user, ?Guild $guild = null): bool { - return $this->console; - } + if ($this->getCooldown() === 0) { + return false; + } - /** - * Retrieve the Discord instance. - * - * @return \Discord\DiscordCommandClient - */ - public function discord() - { - return $this->discord; + $suffix = $guild + ? $guild->id + : 'direct'; + + $key = "{$user->id}.{$suffix}"; + + if (! isset($this->cooldowns[$key])) { + $this->cooldowns[$key] = time(); + + return false; + } + + if (time() - $this->cooldowns[$key] < $this->cooldown) { + return true; + } + + $this->cooldowns[$key] = time(); + + return false; } /** - * Determine if the command requires admin permissions. + * Retrieve the command cooldown. */ - public function isAdminCommand(): bool + public function getCooldown(): int { - return $this->admin; + return $this->cooldown; } /** diff --git a/src/Commands/ApplicationCommand.php b/src/Commands/ApplicationCommand.php index e0f27ca..d07076d 100644 --- a/src/Commands/ApplicationCommand.php +++ b/src/Commands/ApplicationCommand.php @@ -2,10 +2,17 @@ namespace Laracord\Commands; +use Closure; +use Discord\Parts\Interactions\Interaction; use Discord\Parts\Permissions\RolePermission; abstract class ApplicationCommand extends AbstractCommand { + /** + * The denied handler callback. + */ + protected static ?Closure $deniedHandler = null; + /** * The permissions required to use the command. * @@ -41,4 +48,30 @@ public function isNsfw(): bool { return $this->nsfw; } + + /** + * Set a handler for denied commands. + */ + public static function deniedHandler(Closure $handler): void + { + static::$deniedHandler = $handler; + } + + /** + * Handle the denied command. + */ + public function handleDenied(Interaction $interaction): void + { + if (static::$deniedHandler) { + call_user_func(static::$deniedHandler, $interaction); + + return; + } + + $this + ->message('You do not have permission to use this command.') + ->title('Permission Denied') + ->error() + ->reply($interaction, ephemeral: true); + } } diff --git a/src/Commands/Command.php b/src/Commands/Command.php index d21ba39..9305ac4 100644 --- a/src/Commands/Command.php +++ b/src/Commands/Command.php @@ -2,8 +2,11 @@ namespace Laracord\Commands; +use Discord\Parts\Channel\Message; +use Illuminate\Pipeline\Pipeline; use Illuminate\Support\Str; use Laracord\Commands\Contracts\Command as CommandContract; +use Laracord\Commands\Middleware\Context; abstract class Command extends AbstractCommand implements CommandContract { @@ -14,20 +17,6 @@ abstract class Command extends AbstractCommand implements CommandContract */ protected $aliases = []; - /** - * The command cooldown. - * - * @var int - */ - protected $cooldown = 0; - - /** - * The command cooldown message. - * - * @var string - */ - protected $cooldownMessage = ''; - /** * The command usage. * @@ -37,12 +26,8 @@ abstract class Command extends AbstractCommand implements CommandContract /** * Maybe handle the command. - * - * @param \Discord\Parts\Channel\Message $message - * @param array $args - * @return mixed */ - public function maybeHandle($message, $args) + public function maybeHandle(Message $message, array $args): void { if (! $this->canDirectMessage() && ! $message->guild_id) { return; @@ -52,8 +37,12 @@ public function maybeHandle($message, $args) return; } + if ($this->isOnCooldown($message->author, $message->guild ?? null)) { + return; + } + if (! $this->isAdminCommand()) { - $this->handle($message, $args); + $this->processMiddleware($message, $args); return; } @@ -62,65 +51,50 @@ public function maybeHandle($message, $args) return; } - $this->handle($message, $args); + $this->processMiddleware($message, $args); } /** - * Handle the command. - * - * @param \Discord\Parts\Channel\Message $message - * @param array $args - * @return mixed - */ - abstract public function handle($message, $args); - - /** - * Retrieve the command cooldown. - * - * @return int + * Process the command through its middleware stack. */ - public function getCooldown() + protected function processMiddleware(Message $message, array $args): mixed { - return $this->cooldown; - } - - /** - * Retrieve the command cooldown message. - * - * @return string - */ - public function getCooldownMessage() - { - return $this->cooldownMessage; + $context = new Context( + source: $message, + args: $args, + command: $this + ); + + return (new Pipeline($this->bot()->app)) + ->send($context) + ->through($this->getMiddleware()) + ->then(fn (Context $context) => $this->resolveHandler([ + 'message' => $context->source, + 'args' => $context->args, + ])); } /** * Retrieve the command signature. - * - * @return string */ - public function getSignature() + public function getSignature(): string { return Str::start($this->getName(), $this->bot()->getPrefix()); } /** - * Retrieve the command usage. - * - * @return string + * Retrieve the command aliases. */ - public function getUsage() + public function getAliases(): array { - return $this->usage; + return $this->aliases; } /** - * Retrieve the command aliases. - * - * @return array + * Retrieve the command usage. */ - public function getAliases() + public function getUsage(): string { - return $this->aliases; + return $this->usage; } } diff --git a/src/Commands/ContextMenu.php b/src/Commands/ContextMenu.php index 37de1fd..84c8e1a 100644 --- a/src/Commands/ContextMenu.php +++ b/src/Commands/ContextMenu.php @@ -2,11 +2,11 @@ namespace Laracord\Commands; -use Discord\Parts\Channel\Message; use Discord\Parts\Interactions\Command\Command as DiscordCommand; use Discord\Parts\Interactions\Interaction; -use Discord\Parts\User\User; +use Illuminate\Pipeline\Pipeline; use Laracord\Commands\Contracts\ContextMenu as ContextMenuContract; +use Laracord\Commands\Middleware\Context; abstract class ContextMenu extends ApplicationCommand implements ContextMenuContract { @@ -34,17 +34,29 @@ public function create(): DiscordCommand } /** - * Handle the context menu interaction. + * Process the command through its middleware stack. */ - abstract public function handle(Interaction $interaction, Message|User|null $target): mixed; + protected function processMiddleware(Interaction $interaction, mixed $target = null): mixed + { + $context = new Context( + source: $interaction, + target: $target, + command: $this, + ); + + return (new Pipeline($this->bot()->app)) + ->send($context) + ->through($this->getMiddleware()) + ->then(fn (Context $context) => $this->resolveHandler([ + 'interaction' => $context->source, + 'target' => $context->target, + ])); + } /** * Maybe handle the context menu interaction. - * - * @param \Discord\Parts\Interactions\Interaction $interaction - * @return mixed */ - public function maybeHandle($interaction) + public function maybeHandle(Interaction $interaction): void { $target = match ($this->getType()) { DiscordCommand::USER => $interaction->data->resolved->users?->first(), @@ -53,23 +65,18 @@ public function maybeHandle($interaction) }; if (! $this->isAdminCommand()) { - $this->handle($interaction, $target); + $this->processMiddleware($interaction, $target); return; } if ($this->isAdminCommand() && ! $this->isAdmin($interaction->member->user)) { - return $interaction->respondWithMessage( - $this - ->message('You do not have permission to run this command.') - ->title('Permission Denied') - ->error() - ->build(), - ephemeral: true - ); + $this->handleDenied($interaction); + + return; } - $this->handle($interaction, $target); + $this->processMiddleware($interaction, $target); } /** diff --git a/src/Commands/Contracts/Command.php b/src/Commands/Contracts/Command.php index f07a934..72bf4f4 100644 --- a/src/Commands/Contracts/Command.php +++ b/src/Commands/Contracts/Command.php @@ -2,12 +2,7 @@ namespace Laracord\Commands\Contracts; -use Discord\Parts\Channel\Message; - interface Command { - /** - * Handle the command. - */ - public function handle(Message $message, array $args); + // } diff --git a/src/Commands/Contracts/ContextMenu.php b/src/Commands/Contracts/ContextMenu.php index d45c664..229dd45 100644 --- a/src/Commands/Contracts/ContextMenu.php +++ b/src/Commands/Contracts/ContextMenu.php @@ -2,14 +2,7 @@ namespace Laracord\Commands\Contracts; -use Discord\Parts\Channel\Message; -use Discord\Parts\Interactions\Interaction; -use Discord\Parts\User\User; - interface ContextMenu { - /** - * Handle the context menu interaction. - */ - public function handle(Interaction $interaction, Message|User|null $target): mixed; + // } diff --git a/src/Commands/Contracts/SlashCommand.php b/src/Commands/Contracts/SlashCommand.php index 2796534..6ee422e 100644 --- a/src/Commands/Contracts/SlashCommand.php +++ b/src/Commands/Contracts/SlashCommand.php @@ -2,12 +2,7 @@ namespace Laracord\Commands\Contracts; -use Discord\Parts\Interactions\Interaction; - interface SlashCommand { - /** - * Handle the slash command. - */ - public function handle(Interaction $interaction); + // } diff --git a/src/Commands/HelpCommand.php b/src/Commands/HelpCommand.php index 957f33a..93176f9 100644 --- a/src/Commands/HelpCommand.php +++ b/src/Commands/HelpCommand.php @@ -2,6 +2,9 @@ namespace Laracord\Commands; +use Discord\Parts\Channel\Message; +use Discord\Parts\Interactions\Interaction; + class HelpCommand extends Command { /** @@ -26,37 +29,70 @@ class HelpCommand extends Command protected $hidden = true; /** - * The response title. - * - * @var string + * The help title. */ - protected $title = 'Command Help'; + protected static string $title = 'Command Help'; /** - * The response message. - * - * @var string + * The help message content. */ - protected $message = 'Here is a list of all available commands.'; + protected static string $message = 'Showing a list of %s available command(s):'; + + /** + * The maximum commands per page. + */ + protected static int $perPage = 12; + + /** + * Set the help title. + */ + public static function setTitle(string $title): void + { + static::$title = $title; + } + + /** + * Set the help message content. + */ + public static function setMessage(string $message): void + { + static::$message = $message; + } + + /** + * Set the maximum commands per page. + */ + public static function setPerPage(int $perPage): void + { + static::$perPage = max($perPage, 25) ?: static::$perPage; + } /** * Handle the command. - * - * @param \Discord\Parts\Channel\Message $message - * @param array $args - * @return mixed */ - public function handle($message, $args) + public function handle(Message $message, array $args): void + { + $this->show($message, $args[0] ?? 1); + } + + /** + * Show the help command. + */ + public function show(Message|Interaction $context, int $page = 1): void { - $commands = collect($this->bot()->getRegisteredCommands()) + $commands = collect($this->bot->getCommands()) ->filter(fn ($command) => ! $command->isHidden()) - ->filter(fn ($command) => $command->getGuild() ? $message->guild_id === $command->getGuild() : true) + ->filter(fn ($command) => $command->getGuild() ? $context->guild_id === $command->getGuild() : true) ->sortBy('name'); + $page = max(1, $page); + + $items = $commands->forPage($page, static::$perPage); + $fields = []; - foreach ($commands as $command) { - $fields[$command->getSyntax()] = $command->getDescription(); + foreach ($items as $item) { + $fields[$item->getSyntax()] = $item->getDescription(); } if (count($fields) % 3 !== 0) { @@ -67,10 +103,34 @@ public function handle($message, $args) $fields[' '] = ''; } - return $this->message() - ->title($this->title) - ->content($this->message) - ->fields($fields) - ->send($message->channel); + $pages = max(1, ceil($commands->count() / static::$perPage)); + $previous = max(1, $page - 1); + $next = min($pages, $page + 1); + + $message = sprintf(static::$message, $commands->count()); + + $embed = $this + ->message($message) + ->title(static::$title) + ->fields($fields); + + if ($pages > 1) { + $embed + ->button('←', route: "show:{$previous}", style: 'secondary', disabled: $page <= 1) + ->button('→', route: "show:{$next}", style: 'secondary', disabled: $page >= $pages) + ->footerText("Page {$page} of {$pages}"); + } + + $embed->editOrReply($context); + } + + /** + * The command interaction routes. + */ + public function interactions(): array + { + return [ + 'show:{page}' => fn (Interaction $interaction, string $page) => $this->show($interaction, (int) $page), + ]; } } diff --git a/src/Commands/Middleware/Context.php b/src/Commands/Middleware/Context.php new file mode 100644 index 0000000..40a5b8d --- /dev/null +++ b/src/Commands/Middleware/Context.php @@ -0,0 +1,92 @@ +source instanceof Message; + } + + /** + * Determine if the context is from an interaction. + */ + public function isInteraction(): bool + { + return $this->source instanceof Interaction; + } + + /** + * Determine if the command is a slash command. + */ + public function isSlashCommand(): bool + { + return $this->command instanceof SlashCommand; + } + + /** + * Determine if the command is a context menu. + */ + public function isContextMenu(): bool + { + return $this->command instanceof ContextMenu; + } + + /** + * Determine if the command is a message command. + */ + public function isCommand(): bool + { + return $this->command instanceof Command; + } + + /** + * Determine if this is a raw interaction (no command). + */ + public function isRawInteraction(): bool + { + return $this->isInteraction() && $this->command === null; + } + + /** + * Get the user from the context. + */ + public function getUser(): ?User + { + if ($this->isMessage()) { + return $this->source->author; + } + + return $this->source->user ?? $this->source->member?->user; + } + + /** + * Get the guild ID from the context. + */ + public function getGuildId(): ?string + { + return $this->source->guild_id; + } +} diff --git a/src/Commands/Middleware/Middleware.php b/src/Commands/Middleware/Middleware.php new file mode 100644 index 0000000..6a551f3 --- /dev/null +++ b/src/Commands/Middleware/Middleware.php @@ -0,0 +1,15 @@ +resolveRequestSignature($context); + + if ($this->tooManyAttempts($key)) { + Message::content('You are being rate limited. Please try again later.') + ->error() + ->reply($context->source); + + return; + } + + $this->incrementAttempts($key); + + return $next($context); + } + + /** + * Resolve the unique request signature for the rate limiter. + */ + protected function resolveRequestSignature(Context $context): string + { + return sha1($context->getUser()->id.'|'.$context->getGuildId().'|'.class_basename($context->command ?? $context->source)); + } + + /** + * Determine if the user has too many attempts. + */ + protected function tooManyAttempts(string $key): bool + { + return Cache::get($key, 0) >= $this->maxAttempts; + } + + /** + * Increment the attempts for the given key. + */ + protected function incrementAttempts(string $key): void + { + $attempts = Cache::get($key, 0) + 1; + + Cache::put($key, $attempts, now()->addMinutes($this->decayMinutes)); + } +} diff --git a/src/Commands/SlashCommand.php b/src/Commands/SlashCommand.php index b0c4dc4..4c90978 100644 --- a/src/Commands/SlashCommand.php +++ b/src/Commands/SlashCommand.php @@ -8,9 +8,11 @@ use Discord\Parts\Interactions\Command\Command as DiscordCommand; use Discord\Parts\Interactions\Command\Option; use Discord\Parts\Interactions\Interaction; +use Illuminate\Pipeline\Pipeline; use Illuminate\Support\Arr; use Illuminate\Support\Str; use Laracord\Commands\Contracts\SlashCommand as SlashCommandContract; +use Laracord\Commands\Middleware\Context; abstract class SlashCommand extends ApplicationCommand implements SlashCommandContract { @@ -21,13 +23,6 @@ abstract class SlashCommand extends ApplicationCommand implements SlashCommandCo */ protected $options = []; - /** - * The registered command options. - * - * @var array - */ - protected $registeredOptions = []; - /** * The parsed command options. * @@ -51,8 +46,8 @@ public function create(): DiscordCommand $command = $command->setDefaultMemberPermissions($permissions); } - if ($this->getRegisteredOptions()) { - foreach ($this->getRegisteredOptions() as $option) { + if ($options = $this->getRegisteredOptions()) { + foreach ($options as $option) { $command = $command->addOption($option); } } @@ -66,25 +61,33 @@ public function create(): DiscordCommand } /** - * Handle the slash command. - * - * @param \Discord\Parts\Interactions\Interaction $interaction - * @return mixed + * Process the command through its middleware stack. */ - abstract public function handle($interaction); + protected function processMiddleware(Interaction $interaction): mixed + { + $context = new Context( + source: $interaction, + options: $this->getOptions(), + command: $this, + ); + + return (new Pipeline($this->bot()->app)) + ->send($context) + ->through($this->getMiddleware()) + ->then(fn (Context $context) => $this->resolveHandler([ + 'interaction' => $context->source, + ])); + } /** * Maybe handle the slash command. - * - * @param \Discord\Parts\Interactions\Interaction $interaction - * @return mixed */ - public function maybeHandle($interaction) + public function maybeHandle(Interaction $interaction): void { if (! $this->isAdminCommand()) { $this->parseOptions($interaction); - $this->handle($interaction); + $this->processMiddleware($interaction); $this->clearOptions(); @@ -92,19 +95,14 @@ public function maybeHandle($interaction) } if ($this->isAdminCommand() && ! $this->isAdmin($interaction->member->user)) { - return $interaction->respondWithMessage( - $this - ->message('You do not have permission to run this command.') - ->title('Permission Denied') - ->error() - ->build(), - ephemeral: true - ); + $this->handleDenied($interaction); + + return; } $this->parseOptions($interaction); - $this->handle($interaction); + $this->processMiddleware($interaction); $this->clearOptions(); } @@ -230,10 +228,8 @@ public function autocomplete(): array /** * Retrieve the command signature. - * - * @return string */ - public function getSignature() + public function getSignature(): string { return Str::start($this->getName(), '/'); } @@ -243,17 +239,13 @@ public function getSignature() */ public function getRegisteredOptions(): ?array { - if ($this->registeredOptions) { - return $this->registeredOptions; - } - $options = collect($this->options())->merge($this->options); if ($options->isEmpty()) { - return $this->registeredOptions = null; + return null; } - return $this->registeredOptions = $options->map(fn ($option) => $option instanceof Option + return $options->map(fn ($option) => $option instanceof Option ? $option : new Option($this->discord(), $option) )->map(fn ($option) => $option->setName(Str::slug($option->name)))->all(); diff --git a/src/Concerns/HasHandler.php b/src/Concerns/HasHandler.php new file mode 100644 index 0000000..f7542bb --- /dev/null +++ b/src/Concerns/HasHandler.php @@ -0,0 +1,22 @@ + $this->bot->app->call([$this, 'handle'], $parameters)); + } +} diff --git a/src/Console/Commands/AdminCommand.php b/src/Console/Commands/AdminCommand.php index 1598df4..297eae9 100644 --- a/src/Console/Commands/AdminCommand.php +++ b/src/Console/Commands/AdminCommand.php @@ -2,7 +2,8 @@ namespace Laracord\Console\Commands; -use Laracord\Console\Concerns\ResolvesUser; +use Illuminate\Database\Eloquent\Model; +use Laracord\Console\Commands\Concerns\ResolvesUser; class AdminCommand extends Command { @@ -27,7 +28,7 @@ class AdminCommand extends Command /** * The user model. * - * @var \Illuminate\Database\Eloquent\Model|null + * @var Model|null */ protected $user; diff --git a/src/Console/Commands/BootCommand.php b/src/Console/Commands/BootCommand.php index 2c01427..8dd6b9a 100644 --- a/src/Console/Commands/BootCommand.php +++ b/src/Console/Commands/BootCommand.php @@ -2,7 +2,7 @@ namespace Laracord\Console\Commands; -use Illuminate\Support\Str; +use Laracord\Laracord; class BootCommand extends Command { @@ -12,6 +12,9 @@ class BootCommand extends Command * @var string */ protected $signature = 'bot:boot + {--token= : The Discord bot token (insecure)} + {--shard-id= : The Discord bot shard ID} + {--shard-count= : The Discord bot shard count} {--no-migrate : Boot without running database migrations}'; /** @@ -23,27 +26,30 @@ class BootCommand extends Command /** * Execute the console command. - * - * @return mixed */ - public function handle() + public function handle(Laracord $bot): void { if (! $this->option('no-migrate')) { $this->callSilent('migrate', ['--force' => true]); } - $this->app->singleton('bot', fn () => $this->getClass()::make($this)); + if ($this->option('token')) { + $this->components->alert('Using --token is insecure. Consider using --env instead.'); - $this->app->make('bot')->boot(); - } + $bot->setToken($this->option('token')); + } - /** - * Get the bot class. - */ - protected function getClass(): string - { - $class = Str::start($this->app->getNamespace(), '\\').'Bot'; + if (filled($this->option('shard-id')) && filled($this->option('shard-count'))) { + $bot->setShard( + id: $this->option('shard-id'), + count: $this->option('shard-count') + ); + + if ($bot->getShardId() > 0) { + $bot->disableHttpServer(); + } + } - return class_exists($class) ? $class : 'Laracord'; + $bot->boot(); } } diff --git a/src/Console/Commands/Command.php b/src/Console/Commands/Command.php index f71ac66..4b50243 100644 --- a/src/Console/Commands/Command.php +++ b/src/Console/Commands/Command.php @@ -2,10 +2,9 @@ namespace Laracord\Console\Commands; -use Laracord\Console\Concerns\WithLog; use LaravelZero\Framework\Commands\Command as LaravelCommand; abstract class Command extends LaravelCommand { - use WithLog; + // } diff --git a/src/Console/Concerns/ResolvesUser.php b/src/Console/Commands/Concerns/ResolvesUser.php similarity index 50% rename from src/Console/Concerns/ResolvesUser.php rename to src/Console/Commands/Concerns/ResolvesUser.php index 0f2d218..36cdd32 100644 --- a/src/Console/Concerns/ResolvesUser.php +++ b/src/Console/Commands/Concerns/ResolvesUser.php @@ -1,17 +1,17 @@ getUserModel()::where('username', $user)->first(); + $model = Laracord::getUserModel()::where('username', $user)->first(); if (! $model) { $this->components->error("The user {$user} does not exist."); @@ -31,10 +31,10 @@ protected function resolveUser(?string $user = null) } } - $model = $model ?? $this->getUserModel()::where('discord_id', $user)->first(); + $model = $model ?? Laracord::getUserModel()::where('discord_id', $user)->first(); if (! $model) { - $token = $this->getBotClass()::make($this)->getToken(); + $token = Laracord::getToken(); $request = Http::withHeaders([ 'Authorization' => "Bot {$token}", @@ -48,35 +48,11 @@ protected function resolveUser(?string $user = null) $user = $request->json(); - $model = $this->getUserModel()::updateOrCreate(['discord_id' => $user['id']], [ + $model = Laracord::getUserModel()::updateOrCreate(['discord_id' => $user['id']], [ 'username' => $user['username'], ]); } return $model; } - - /** - * Get the bot class. - */ - protected function getBotClass(): string - { - $class = Str::start($this->app->getNamespace(), '\\').'Bot'; - - return class_exists($class) ? $class : 'Laracord'; - } - - /** - * Get the user model class. - */ - protected function getUserModel(): string - { - $model = Str::start(app()->getNamespace(), '\\').'Models\\User'; - - if (! class_exists($model)) { - throw new Exception('The user model could not be found.'); - } - - return $model; - } } diff --git a/src/Console/Commands/ControllerMakeCommand.php b/src/Console/Commands/ControllerMakeCommand.php index 6ec7981..2d97586 100644 --- a/src/Console/Commands/ControllerMakeCommand.php +++ b/src/Console/Commands/ControllerMakeCommand.php @@ -112,7 +112,7 @@ protected function buildModelReplacements(array $replace) * @param string $model * @return string * - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ protected function parseModel($model) { diff --git a/src/Console/Commands/MakeCommandMiddlewareCommand.php b/src/Console/Commands/MakeCommandMiddlewareCommand.php new file mode 100644 index 0000000..99b7a3a --- /dev/null +++ b/src/Console/Commands/MakeCommandMiddlewareCommand.php @@ -0,0 +1,46 @@ +laravel->basePath(trim($relativePath, '/'))) + ? $customPath + : __DIR__.$relativePath; + } + + /** + * {@inheritdoc} + */ + protected function getDefaultNamespace($rootNamespace): string + { + return $rootNamespace.'\Commands\Middleware'; + } +} diff --git a/src/Console/Commands/PromptMakeCommand.php b/src/Console/Commands/PromptMakeCommand.php new file mode 100644 index 0000000..f18dff4 --- /dev/null +++ b/src/Console/Commands/PromptMakeCommand.php @@ -0,0 +1,46 @@ +laravel->basePath(trim($relativePath, '/'))) + ? $customPath + : __DIR__.$relativePath; + } + + /** + * {@inheritdoc} + */ + protected function getDefaultNamespace($rootNamespace): string + { + return $rootNamespace.'\Console\Prompts'; + } +} diff --git a/src/Console/Commands/ServiceMakeCommand.php b/src/Console/Commands/TaskMakeCommand.php similarity index 77% rename from src/Console/Commands/ServiceMakeCommand.php rename to src/Console/Commands/TaskMakeCommand.php index 443f3d7..ad2d7a7 100644 --- a/src/Console/Commands/ServiceMakeCommand.php +++ b/src/Console/Commands/TaskMakeCommand.php @@ -6,28 +6,28 @@ use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; -class ServiceMakeCommand extends GeneratorCommand +class TaskMakeCommand extends GeneratorCommand { /** * The command name. * * @var string */ - protected $name = 'make:service'; + protected $name = 'make:task'; /** * The command description. * * @var string */ - protected $description = 'Create a new bot service'; + protected $description = 'Create a new bot task'; /** * The type of class being generated. * * @var string */ - protected $type = 'Service'; + protected $type = 'Task'; /** * Get the stub file for the generator. @@ -36,7 +36,7 @@ class ServiceMakeCommand extends GeneratorCommand */ protected function getStub() { - $relativePath = '/stubs/service.stub'; + $relativePath = '/stubs/task.stub'; return file_exists($customPath = $this->laravel->basePath(trim($relativePath, '/'))) ? $customPath @@ -51,7 +51,7 @@ protected function getStub() */ protected function getDefaultNamespace($rootNamespace) { - return $rootNamespace.'\Services'; + return $rootNamespace.'\Tasks'; } /** @@ -62,7 +62,7 @@ protected function getDefaultNamespace($rootNamespace) protected function getArguments() { return [ - ['name', InputArgument::REQUIRED, 'The name of the service'], + ['name', InputArgument::REQUIRED, 'The name of the task'], ]; } @@ -74,7 +74,7 @@ protected function getArguments() protected function getOptions() { return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the service already exists'], + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the task already exists'], ]; } } diff --git a/src/Console/Commands/TokenMakeCommand.php b/src/Console/Commands/TokenMakeCommand.php index 995273f..428e701 100644 --- a/src/Console/Commands/TokenMakeCommand.php +++ b/src/Console/Commands/TokenMakeCommand.php @@ -2,8 +2,9 @@ namespace Laracord\Console\Commands; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; -use Laracord\Console\Concerns\ResolvesUser; +use Laracord\Console\Commands\Concerns\ResolvesUser; class TokenMakeCommand extends Command { @@ -29,7 +30,7 @@ class TokenMakeCommand extends Command /** * The user model. * - * @var \Illuminate\Database\Eloquent\Model|null + * @var Model|null */ protected $user; diff --git a/src/Console/Commands/UpgradeCommand.php b/src/Console/Commands/UpgradeCommand.php new file mode 100644 index 0000000..87e5e28 --- /dev/null +++ b/src/Console/Commands/UpgradeCommand.php @@ -0,0 +1,137 @@ +components->info('Starting the Laracord upgrade process...'); + + if (! $this->components->confirm('These actions are irreversible. Do you wish to continue?', true)) { + $this->components->error('The upgrade has been cancelled.'); + + return; + } + + $this->handleServices(); + + $this->components->info('Upgrade process completed successfully!'); + } + + /** + * Handle the Service -> Task upgrade. + */ + protected function handleServices(): void + { + $replacements = [ + 'namespace App\Services' => 'namespace App\Tasks', + 'use App\Services' => 'use App\Tasks', + '@var \App\Services' => '@var \App\Tasks', + 'App\Services\\' => 'App\Tasks\\', + 'use Laracord\Services\Service;' => 'use Laracord\Tasks\Task;', + 'extends Service' => 'extends Task', + 'The service' => 'The task', + 'the service' => 'the task', + ]; + + $this->components->info('Checking for Service classes to migrate...'); + + $services = app_path('Services'); + $tasks = app_path('Tasks'); + + if (! file_exists($services)) { + return; + } + + if (! file_exists($tasks)) { + mkdir($tasks, 0755, true); + } + + $files = glob($services.'/*.php'); + + if (empty($files)) { + return; + } + + $fileNames = collect($files) + ->map(fn ($file) => basename($file, '.php')) + ->map(fn ($class) => "{$class}") + ->all(); + + $this->components->bulletList($fileNames); + + if (! $this->components->confirm('Found '.count($files).' Service classes to migrate. Do you wish to continue?', true)) { + $this->components->error('The upgrade has been cancelled.'); + + exit(0); + } + + foreach ($files as $file) { + $name = basename($file, '.php'); + $newPath = str_replace('/Services/', '/Tasks/', $file); + + $this->components->task( + "Migrating {$name} class", + fn () => $this->handleUpgrade($file, $newPath, $replacements) + ); + } + + if (count(glob($services.'/*')) === 0) { + rmdir($services); + } + + $this->newLine(); + + $this->components->info('Successfully migrated '.count($files).' Service classes to Tasks.'); + } + + /** + * Upgrade the Service class file. + */ + protected function handleUpgrade(string $oldPath, string $newPath, array $replacements): bool + { + $content = file_get_contents($oldPath); + + foreach ($replacements as $pattern => $replacement) { + $content = str_replace($pattern, $replacement, $content); + } + + $result = file_put_contents($newPath, $content) !== false; + + if ($result) { + unlink($oldPath); + } + + return $result; + } +} diff --git a/src/Console/Commands/stubs/command-middleware.stub b/src/Console/Commands/stubs/command-middleware.stub new file mode 100644 index 0000000..bc124ef --- /dev/null +++ b/src/Console/Commands/stubs/command-middleware.stub @@ -0,0 +1,20 @@ +message() ->title('{{ title }}') ->content('Hello world!') ->button('👋', route: 'wave') - ->send($message); + ->reply($message); } /** diff --git a/src/Console/Commands/stubs/context-menu.stub b/src/Console/Commands/stubs/context-menu.stub index 2f5dbc9..a9a9d6b 100644 --- a/src/Console/Commands/stubs/context-menu.stub +++ b/src/Console/Commands/stubs/context-menu.stub @@ -38,16 +38,14 @@ class {{ class }} extends ContextMenu /** * Handle the context menu interaction. */ - public function handle(Interaction $interaction, Message|User|null $target): mixed + public function handle(Interaction $interaction, Message|User|null $target): void { - $interaction->respondWithMessage( - $this - ->message() - ->title('{{ title }}') - ->content('Hello world!') - ->button('👋', route: 'wave') - ->build() - ); + $this + ->message() + ->title('{{ title }}') + ->content('Hello world!') + ->button('👋', route: 'wave') + ->reply($interaction); } /** @@ -59,4 +57,4 @@ class {{ class }} extends ContextMenu 'wave' => fn (Interaction $interaction) => $this->message('👋')->reply($interaction), ]; } -} +} \ No newline at end of file diff --git a/src/Console/Commands/stubs/event.stub b/src/Console/Commands/stubs/event.stub index 0f03934..3009106 100644 --- a/src/Console/Commands/stubs/event.stub +++ b/src/Console/Commands/stubs/event.stub @@ -17,8 +17,8 @@ class {{ class }} extends Event /** * Handle the event. */ - public function handle({{ attributes }}) + public function handle({{ attributes }}): void { - $this->console()->log('The {{ eventName }} event has fired!'); + $this->logger()->info('The {{ eventName }} event has fired!'); } } diff --git a/src/Console/Commands/stubs/prompt.stub b/src/Console/Commands/stubs/prompt.stub new file mode 100644 index 0000000..78820b8 --- /dev/null +++ b/src/Console/Commands/stubs/prompt.stub @@ -0,0 +1,32 @@ +console()->log('Hello world.'); - } -} diff --git a/src/Console/Commands/stubs/slash-command.stub b/src/Console/Commands/stubs/slash-command.stub index 81e4467..6a78454 100644 --- a/src/Console/Commands/stubs/slash-command.stub +++ b/src/Console/Commands/stubs/slash-command.stub @@ -51,20 +51,15 @@ class {{ class }} extends SlashCommand /** * Handle the slash command. - * - * @param \Discord\Parts\Interactions\Interaction $interaction - * @return mixed */ - public function handle($interaction) + public function handle(Interaction $interaction): void { - $interaction->respondWithMessage( - $this - ->message() - ->title('{{ title }}') - ->content('Hello world!') - ->button('👋', route: 'wave') - ->build() - ); + $this + ->message() + ->title('{{ title }}') + ->content('Hello world!') + ->button('👋', route: 'wave') + ->reply($interaction); } /** @@ -76,4 +71,4 @@ class {{ class }} extends SlashCommand 'wave' => fn (Interaction $interaction) => $this->message('👋')->reply($interaction), ]; } -} +} \ No newline at end of file diff --git a/src/Console/Commands/stubs/task.stub b/src/Console/Commands/stubs/task.stub new file mode 100644 index 0000000..f757ddb --- /dev/null +++ b/src/Console/Commands/stubs/task.stub @@ -0,0 +1,26 @@ +logger()->info('Hello world.'); + } +} diff --git a/src/Console/Components/Log.php b/src/Console/Components/Log.php index a7b72fc..029ddb8 100644 --- a/src/Console/Components/Log.php +++ b/src/Console/Components/Log.php @@ -5,6 +5,7 @@ use Illuminate\Console\Contracts\NewLineAware; use Illuminate\Console\View\Components\Component; use Illuminate\Console\View\Components\Mutators; +use Illuminate\Contracts\Support\Arrayable; use Symfony\Component\Console\Output\OutputInterface; use function Termwind\render; @@ -38,7 +39,7 @@ public function render($style, $string, $verbosity = OutputInterface::VERBOSITY_ * Renders the given view. * * @param string $view - * @param \Illuminate\Contracts\Support\Arrayable|array $data + * @param Arrayable|array $data * @param int $verbosity * @return void */ diff --git a/src/Console/Concerns/WithLog.php b/src/Console/Concerns/WithLog.php index bfda096..f8b763a 100644 --- a/src/Console/Concerns/WithLog.php +++ b/src/Console/Concerns/WithLog.php @@ -2,60 +2,88 @@ namespace Laracord\Console\Concerns; +use DateTimeInterface; +use Illuminate\Console\OutputStyle; use Laracord\Console\Components\Log; +use Psr\Log\LogLevel; +use Stringable; trait WithLog { /** - * Send a message to the console. + * The output style implementation. + * + * @var OutputStyle */ - public function log(string $message, string $type = 'info'): void - { - $message = trim($message); + protected $output; - if (empty($message)) { - return; - } + /** + * The log level colors. + */ + protected array $colors = [ + LogLevel::EMERGENCY => 'red', + LogLevel::ALERT => 'red', + LogLevel::CRITICAL => 'red', + LogLevel::ERROR => 'red', + LogLevel::WARNING => 'yellow', + LogLevel::NOTICE => 'cyan', + LogLevel::INFO => 'blue', + LogLevel::DEBUG => 'green', + ]; - $color = match ($type) { - 'error' => 'red', - 'warn' => 'yellow', - default => 'blue', - }; + /** + * Render a log message. + */ + public function log($level, string|Stringable $message, array $context = []): void + { + $message = trim($message); $timestamp = config('discord.timestamp'); $config = [ - 'bgColor' => $color, + 'bgColor' => $this->color($level), 'fgColor' => 'white', - 'title' => $type, + 'title' => $level, 'timestamp' => $timestamp ? now()->format($timestamp) : null, ]; - with(new Log($this->getOutput()))->render($config, $message); + with(new Log($this->output))->render($config, $this->interpolate($level, $message, $context)); } /** - * Send a warning log to console. - * - * @param string $string - * @param string|null $verbosity - * @return void + * Interpolate the message. */ - public function warn($string, $verbosity = null) + private function interpolate(string $level, string $message, array $context): string { - return $this->log($string, 'warn'); + $color = $this->color($level); + + $replacements = []; + + foreach ($context as $key => $val) { + $replacements["{{$key}}"] = $this->colorize($color, match (true) { + $val === null, is_scalar($val), $val instanceof Stringable => "{$val}", + $val instanceof DateTimeInterface => $val->format(DateTimeInterface::RFC3339), + is_object($val) => '[object '.$val::class.']', + default => '['.gettype($val).']', + }); + } + + return strtr($message, $replacements); } /** - * Send an error log to console. - * - * @param string $string - * @param string|null $verbosity - * @return void + * Retrieve the color for the log level. + */ + private function color(string $level): string + { + return $this->colors[$level] ?? $this->colors[LogLevel::INFO]; + } + + /** + * Colorize the message. */ - public function error($string, $verbosity = null) + private function colorize(string $color, string $message) { - return $this->log($string, 'error'); + return "{$message}"; } } diff --git a/src/Console/Console.php b/src/Console/Console.php new file mode 100644 index 0000000..a8125ab --- /dev/null +++ b/src/Console/Console.php @@ -0,0 +1,220 @@ + '; + + /** + * The registered commands. + */ + protected array $commands = []; + + /** + * The command aliases. + */ + protected array $aliases = []; + + /** + * The resolved commands. + */ + protected array $resolvedCommands = []; + + /** + * Initialize the console instance. + */ + public function __construct( + public readonly DuplexStreamInterface|WritableStreamInterface $stdio, + public readonly Container $laravel, + ConsoleOutputInterface $output, + InputInterface $input, + ) { + if (! $output instanceof OutputStyle) { + $output = new OutputStyle($input, $output); + } + + $this->components = $this->laravel->make(Factory::class, ['output' => $this->output]); + + $this->output = $output; + $this->input = $input; + + $this->stdio->on('data', fn (string $data) => $this->handle(trim($data))); + $this->stdio->on('end', fn () => $this->handle('shutdown')); + } + + /** + * Make a new console instance. + */ + public static function make(): self + { + return app('bot.console'); + } + + /** + * Handle the console input. + */ + public function handle(string $command): void + { + if (empty($command)) { + $this->showPrompt(); + + return; + } + + [$command, $input] = explode(' ', $command, 2) + [1 => '']; + + $input = new StringInput($input); + + try { + $command = $this->resolveCommand($command); + } catch (InvalidArgumentException $e) { + logger()->error($e->getMessage()); + + $this->showPrompt(); + + return; + } + + $command->run($input, $this->output); + + $this->showPrompt(); + } + + /** + * Resolve the the specified command. + */ + protected function resolveCommand($command): Command + { + if (isset($this->resolvedCommands[$command])) { + return $this->resolvedCommands[$command]; + } + + if (isset($this->aliases[$command])) { + $command = $this->aliases[$command]; + } + + $instance = $this->commands[$command] ?? null; + + if (is_null($instance)) { + throw new InvalidArgumentException("Command not found: {$command}"); + } + + return $this->resolvedCommands[$command] = $instance; + } + + /** + * Add a command to the console. + */ + public function addCommand(Command|string $signature, ?Closure $command = null, string $description = '', array $aliases = []): void + { + if ($signature instanceof Command) { + $command = $signature; + $name = $command->getName(); + $aliases = $command->getAliases(); + + $command->setLaravel($this->laravel); + } elseif (is_null($command)) { + throw new InvalidArgumentException('If command is a string, a callable must be provided.'); + } + + if ($command instanceof Closure) { + $command = tap(new ClosureCommand($signature, $command), fn ($command) => $command + ->setDescription($description) + ->setAliases($aliases) + ->setLaravel($this->laravel) + ); + + $name = $command->getName(); + } + + $this->commands[$name] = $command; + + foreach ($aliases as $alias) { + $this->aliases[$alias] = $name; + } + } + + /** + * Get all available commands. + * + * @return \Symfony\Component\Console\Command\Command[] + */ + public function getCommands(): array + { + return $this->commands; + } + + /** + * Show the prompt. + */ + public function showPrompt(): void + { + if (windows_os()) { + return; + } + + $this->output->write($this->prompt); + } + + /** + * Returns true if the stream supports colorization. + * + * @copyright Fabien Potencier + * + * @see https://github.com/symfony/symfony/blob/b61353801c0229d67b7bfeef4b56b270b1f818eb/src/Symfony/Component/Console/Output/StreamOutput.php#L90-L121 + */ + public function hasColorSupport(): bool + { + // Follow https://no-color.org/ + if (isset($_SERVER['NO_COLOR']) || getenv('NO_COLOR')) { + return false; + } + + // Detect msysgit/mingw and assume this is a tty because detection + // does not work correctly, see https://github.com/composer/composer/issues/9690 + if (is_resource($this->stdio) && ! @stream_isatty($this->stdio) && ! in_array(strtoupper((string) getenv('MSYSTEM')), ['MINGW32', 'MINGW64'], true)) { + return false; + } + + if (is_resource($this->stdio) && windows_os() && @sapi_windows_vt100_support($this->stdio)) { + return true; + } + + if (getenv('TERM_PROGRAM') === 'Hyper' || getenv('COLORTERM') || getenv('ANSICON') || getenv('ConEmuANSI') === 'ON') { + return true; + } + + if (($term = (string) getenv('TERM')) === 'dumb') { + return false; + } + + // See https://github.com/chalk/supports-color/blob/d4f413efaf8da045c5ab440ed418ef02dbb28bf1/index.js#L157 + return preg_match('/^((screen|xterm|vt100|vt220|putty|rxvt|ansi|cygwin|linux).*)|(.*-256(color)?(-bce)?)$/', $term); + } +} diff --git a/src/Console/Prompts/ClearPrompt.php b/src/Console/Prompts/ClearPrompt.php new file mode 100644 index 0000000..e710e7c --- /dev/null +++ b/src/Console/Prompts/ClearPrompt.php @@ -0,0 +1,37 @@ +line("\033[2J\033[3J\033[H"); + } +} diff --git a/src/Console/Prompts/ExitPrompt.php b/src/Console/Prompts/ExitPrompt.php new file mode 100644 index 0000000..bf735d1 --- /dev/null +++ b/src/Console/Prompts/ExitPrompt.php @@ -0,0 +1,37 @@ +shutdown(); + } +} diff --git a/src/Console/Prompts/HelpPrompt.php b/src/Console/Prompts/HelpPrompt.php new file mode 100644 index 0000000..ad91f22 --- /dev/null +++ b/src/Console/Prompts/HelpPrompt.php @@ -0,0 +1,46 @@ +getCommands()); + + $console->table( + ['Name', 'Description'], + $commands->map(fn ($command) => [ + $command->getName(), + $command->getDescription(), + ])->all(), + tableStyle: 'box', + ); + } +} diff --git a/src/Console/Prompts/InvitePrompt.php b/src/Console/Prompts/InvitePrompt.php new file mode 100644 index 0000000..a9e3b2a --- /dev/null +++ b/src/Console/Prompts/InvitePrompt.php @@ -0,0 +1,31 @@ +showInvite(force: true); + } +} diff --git a/src/Console/Prompts/Prompt.php b/src/Console/Prompts/Prompt.php new file mode 100644 index 0000000..576e54a --- /dev/null +++ b/src/Console/Prompts/Prompt.php @@ -0,0 +1,16 @@ +restart(); + } +} diff --git a/src/Console/Prompts/StatusPrompt.php b/src/Console/Prompts/StatusPrompt.php new file mode 100644 index 0000000..42635af --- /dev/null +++ b/src/Console/Prompts/StatusPrompt.php @@ -0,0 +1,44 @@ +getUptime()->diffForHumans(null, true); + + $status = $bot->getStatus()->merge([ + 'user' => $bot->discord->users->count(), + 'guild' => $bot->discord->guilds->count(), + ])->mapWithKeys(fn ($count, $type) => [Str::plural($type, $count) => $count]); + + $status = $status + ->prepend($uptime, 'uptime') + ->prepend("{$bot->discord->username} ({$bot->discord->id})", 'bot') + ->map(fn ($count, $type) => Str::of($type)->title()->finish(": {$count}")->toString()); + + $console->outputComponents()->bulletList($status->all()); + } +} diff --git a/src/Contracts/Plugin.php b/src/Contracts/Plugin.php new file mode 100644 index 0000000..c180fe4 --- /dev/null +++ b/src/Contracts/Plugin.php @@ -0,0 +1,13 @@ + 3066993, + 'success' => 3066993, + 'error' => 15158332, + 'warning' => 15105570, + 'info' => 3447003, + ]; + + /** + * The current container being built. + */ + protected ?Container $currentContainer = null; + + /** + * The current section being built. + */ + protected ?Section $currentSection = null; + + /** + * The components being built. + */ + protected array $components = []; + + /** + * The interaction route prefix. + */ + protected ?string $routePrefix = null; + + /** + * The current action row being built. + */ + protected ?ActionRow $currentActionRow = null; + + /** + * The buttons in the current action row. + */ + protected array $rowButtons = []; + + /** + * Create a new components instance. + */ + public function __construct(?Laracord $bot = null) + { + $this->bot = $bot ?: app('bot'); + } + + /** + * Make a new components instance. + */ + public static function make(?Laracord $bot = null): self + { + return new static($bot); + } + + /** + * Start a new container. + */ + public function container(string|int|null $accentColor = null, bool $spoiler = false): self + { + $this->currentContainer = Container::new(); + + if ($accentColor) { + $this->currentContainer->setAccentColor($this->parseColor($accentColor)); + } + + if ($spoiler) { + $this->currentContainer->setSpoiler(); + } + + $this->components[] = $this->currentContainer; + + return $this; + } + + /** + * Parse a color value into its integer representation. + */ + protected function parseColor(int|string $color): int + { + if (is_int($color)) { + return $color; + } + + $color = match ($color) { + 'success' => $this->colors['success'], + 'error' => $this->colors['error'], + 'warning' => $this->colors['warning'], + 'info' => $this->colors['info'], + default => $color, + }; + + if (str_starts_with($color, '#')) { + $color = hexdec( + Str::of($color)->replace('#', '')->limit(6, '')->toString() + ); + } + + return (int) $color; + } + + /** + * Set the current container's color to success. + */ + public function success(): self + { + $this->ensureContainer(); + + $this->currentContainer->setAccentColor($this->colors['success']); + + return $this; + } + + /** + * Set the current container's color to error. + */ + public function error(): self + { + $this->ensureContainer(); + + $this->currentContainer->setAccentColor($this->colors['error']); + + return $this; + } + + /** + * Set the current container's color to warning. + */ + public function warning(): self + { + $this->ensureContainer(); + + $this->currentContainer->setAccentColor($this->colors['warning']); + + return $this; + } + + /** + * Set the current container's color to info. + */ + public function info(): self + { + $this->ensureContainer(); + + $this->currentContainer->setAccentColor($this->colors['info']); + + return $this; + } + + /** + * Set the current container's color. + */ + public function color(int|string $color): self + { + $this->ensureContainer(); + + $this->currentContainer->setAccentColor($this->parseColor($color)); + + return $this; + } + + /** + * Get or create the current container. + */ + protected function ensureContainer(): void + { + if (! $this->currentContainer) { + $this->container(); + } + } + + /** + * Add a text display to the current container or section. + */ + public function text(string|array $text): self + { + $this->ensureContainer(); + + $text = is_string($text) + ? [$text] + : $text; + + $texts = array_slice($text, 0, 3); + + foreach ($texts as $content) { + $textDisplay = TextDisplay::new($content); + + $this->currentSection + ? $this->currentSection->addComponent($textDisplay) + : $this->currentContainer->addComponent($textDisplay); + } + + return $this; + } + + /** + * Add a thumbnail to the current section. + */ + public function thumbnail(string $url, ?string $description = null, bool $spoiler = false): self + { + if (! $this->currentSection) { + throw new Exception('You must create a section before adding a thumbnail.'); + } + + $thumbnail = Thumbnail::new($url); + + if ($description) { + $thumbnail->setDescription($description); + } + + if ($spoiler) { + $thumbnail->setSpoiler(); + } + + $this->currentSection->setAccessory($thumbnail); + $this->currentSection = null; + + return $this; + } + + /** + * Add a button to the current section or action row. + */ + public function button( + string $label, + mixed $value = null, + mixed $emoji = null, + ?string $style = null, + bool $disabled = false, + bool $hidden = false, + ?string $id = null, + ?string $route = null, + array $options = [] + ): self { + if ($hidden) { + return $this; + } + + $button = $this->createButton($label, $value, $emoji, $style, $disabled, $hidden, $id, $route, $options); + + if (! $button) { + return $this; + } + + if ($this->currentSection) { + $this->currentSection->setAccessory($button); + $this->currentSection = null; + + return $this; + } + + if (! $this->currentActionRow || count($this->rowButtons) >= 5) { + $this->row(); + } + + $this->rowButtons[] = $button; + $this->currentActionRow->addComponent($button); + + return $this; + } + + /** + * Add multiple buttons. + */ + public function buttons(array $buttons): self + { + foreach ($buttons as $key => $value) { + if (is_string($key) && is_string($value)) { + $value = [$key, $value]; + } + + $this->button(...$value); + } + + return $this; + } + + /** + * Add a new section. + */ + public function section(): self + { + $this->ensureContainer(); + + $this->currentSection = Section::new(); + + $this->currentContainer->addComponent($this->currentSection); + + return $this; + } + + /** + * Close the current section. + */ + public function endSection(): self + { + $this->currentSection = null; + + return $this; + } + + /** + * Add a custom accessory to the current section. + */ + public function accessory(mixed $accessory): self + { + if (! $this->currentSection) { + throw new Exception('You must create a section before adding an accessory.'); + } + + $this->currentSection->setAccessory($accessory); + $this->currentSection = null; + + return $this; + } + + /** + * Add a separator to the current container. + */ + public function separator(bool $divider = true, bool $large = false): self + { + $this->ensureContainer(); + + $separator = Separator::new() + ->setDivider($divider) + ->setSpacing($large ? Separator::SPACING_LARGE : Separator::SPACING_SMALL); + + $this->currentContainer->addComponent($separator); + + return $this; + } + + /** + * Add a file to the current container. + */ + public function file(string $filename, bool $spoiler = false): self + { + $this->ensureContainer(); + + $file = File::new($filename); + + if ($spoiler) { + $file->setSpoiler(); + } + + $this->currentContainer->addComponent($file); + + return $this; + } + + /** + * Add a media gallery to the current container. + */ + public function gallery(array $items): self + { + $this->ensureContainer(); + + $gallery = MediaGallery::new(); + + $this->currentContainer->addComponent($gallery); + + foreach ($items as $item) { + $gallery->addItem($item); + } + + return $this; + } + + /** + * Get the built components. + */ + public function getComponents(): array + { + return $this->components; + } + + /** + * Create a button component. + */ + protected function createButton( + string $label, + mixed $value = null, + mixed $emoji = null, + ?string $style = null, + bool $disabled = false, + bool $hidden = false, + ?string $id = null, + ?string $route = null, + array $options = [] + ): ?Button { + if ($hidden) { + return null; + } + + $style = match ($style) { + 'link' => Button::STYLE_LINK, + 'primary' => Button::STYLE_PRIMARY, + 'secondary' => Button::STYLE_SECONDARY, + 'success' => Button::STYLE_SUCCESS, + 'danger' => Button::STYLE_DANGER, + default => $style, + }; + + $style = $style ?? (is_string($value) ? Button::STYLE_LINK : Button::STYLE_PRIMARY); + + $button = Button::new($style) + ->setLabel($label) + ->setEmoji($emoji) + ->setDisabled($disabled); + + if ($id) { + $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(); + + try { + $button = $button->{$key}($option); + } catch (Throwable) { + $this->bot->logger->error("Invalid button option {$key}"); + + continue; + } + } + } + + $button = match ($style) { + Button::STYLE_LINK => $button->setUrl($value), + 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 $button; + } + + /** + * 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; + } + + /** + * Start a new action row. + */ + public function row(): self + { + $this->ensureContainer(); + + $this->currentActionRow = ActionRow::new(); + + $this->rowButtons = []; + + $this->currentContainer->addComponent($this->currentActionRow); + + return $this; + } + + /** + * Add a select menu to the current action row. + */ + public function select( + array $items = [], + ?callable $listener = null, + ?string $placeholder = null, + ?string $id = null, + bool $disabled = false, + bool $hidden = false, + int $minValues = 1, + int $maxValues = 1, + ?string $type = null, + ?string $route = null, + ?array $options = [] + ): self { + if ($hidden) { + return $this; + } + + $select = match ($type) { + 'channel' => ChannelSelect::new(), + 'mentionable' => MentionableSelect::new(), + 'role' => RoleSelect::new(), + 'user' => UserSelect::new(), + default => StringSelect::new(), + }; + + $select = $select + ->setPlaceholder($placeholder) + ->setMinValues($minValues) + ->setMaxValues($maxValues) + ->setDisabled($disabled); + + if ($id) { + $select = $select->setCustomId($id); + } + + if ($route) { + $select = $this->getRoutePrefix() + ? $select->setCustomId("{$this->getRoutePrefix()}@{$route}") + : $select->setCustomId($route); + } + + if ($listener) { + $select = $select->setListener($listener, $this->bot->discord()); + } + + if ($options) { + foreach ($options as $key => $option) { + $key = Str::of($key)->camel()->ucfirst()->start('set')->toString(); + + try { + $select = $select->{$key}($option); + } catch (Throwable) { + $this->bot->logger->error("Invalid select menu option {$key}"); + + continue; + } + } + } + + foreach ($items as $key => $value) { + if (! is_array($value)) { + $select->addOption(Option::new(is_int($key) ? $value : $key, $value)); + + continue; + } + + $option = Option::new($value['label'] ?? $key, $value['value'] ?? $key) + ->setDescription($value['description'] ?? null) + ->setEmoji($value['emoji'] ?? null) + ->setDefault($value['default'] ?? false); + + $select->addOption($option); + } + + $this->currentContainer->addComponent($select); + + return $this; + } +} diff --git a/src/Discord/Facades/Message.php b/src/Discord/Facades/Message.php new file mode 100644 index 0000000..0fa4447 --- /dev/null +++ b/src/Discord/Facades/Message.php @@ -0,0 +1,88 @@ +setTts($this->tts) ->setContent($this->body) ->setStickers($this->stickers) - ->setComponents($this->getComponents()); + ->setFlags($this->flags); if ($this->hasContent() || $this->hasFields()) { $message->addEmbed($this->getEmbed()); @@ -238,6 +251,12 @@ public function build(): MessageBuilder } } + if ($this->hasComponents()) { + foreach ($this->components as $component) { + $message->addComponent($component); + } + } + if ($this->hasSelects()) { foreach ($this->selects as $select) { $message->addComponent($select); @@ -288,7 +307,7 @@ public function sendTo(mixed $user): ?PromiseInterface $member = $this->bot->discord()->users->get('id', $user); if (! $member) { - $this->bot->console()->error("Could not find user {$user} to send message"); + $this->bot->logger->error("Could not find user {$user} to send message"); return null; } @@ -301,7 +320,7 @@ public function sendTo(mixed $user): ?PromiseInterface } if (! $user instanceof User) { - $this->bot->console()->error('You must provide a valid Discord user.'); + $this->bot->logger->error('You must provide a valid Discord user.'); return null; } @@ -314,17 +333,25 @@ public function sendTo(mixed $user): ?PromiseInterface */ protected function handleWebhook(): ?PromiseInterface { + $channel = $this->getChannel()->id; + try { - /** @var WebhookRepository $webhooks */ - $webhooks = await($this->getChannel()->webhooks->freshen()); + if (! isset(static::$webhookCache[$channel])) { + /** @var WebhookRepository $webhooks */ + $webhooks = await($this->getChannel()->webhooks->freshen()); + + static::$webhookCache[$channel] = $webhooks; + } + + $webhooks = static::$webhookCache[$channel]; } catch (NoPermissionsException) { - $this->bot->console()->error("\nMissing permission to fetch channel webhooks."); + $this->bot->logger->error("\nMissing permission to fetch channel webhooks."); return null; } if (! $webhooks) { - $this->bot->console()->error('Failed to fetch channel webhooks.'); + $this->bot->logger->error('Failed to fetch channel webhooks.'); return null; } @@ -336,18 +363,22 @@ protected function handleWebhook(): ?PromiseInterface return $webhooks->save(new Webhook($this->bot->discord(), [ 'name' => $this->bot->discord()->username, ]))->then( - fn (Webhook $webhook) => $webhook->execute($this->build()), - fn () => $this->bot->console()->error('Failed to create message webhook.') + function (Webhook $webhook) use ($channel) { + static::$webhookCache[$channel]->push($webhook); + + return $webhook->execute($this->build()); + }, + fn () => $this->bot->logger->error('Failed to create message webhook.') ); } return $webhook->execute($this->build()); } - $webhook = $this->getChannel()->webhooks->get('url', $this->webhook); + $webhook = $webhooks->get('url', $this->webhook); if (! $webhook) { - $this->bot->console()->error("Could not find webhook {$this->webhook} on channel to send message."); + $this->bot->logger->error("Could not find webhook {$this->webhook} on channel to send message."); return null; } @@ -612,7 +643,7 @@ public function file(string $input = '', ?string $filename = null): self public function filePath(string $path, ?string $filename = null): self { if (! file_exists($path)) { - $this->bot->console()->error("File {$path} does not exist"); + $this->bot->logger->error("File {$path} does not exist"); return $this; } @@ -956,7 +987,8 @@ public function select( int $maxValues = 1, ?string $type = null, ?string $route = null, - ?array $options = [] + ?array $options = [], + ?array $defaults = null ): self { if ($hidden) { return $this; @@ -976,6 +1008,14 @@ public function select( ->setMaxValues($maxValues) ->setDisabled($disabled); + $defaults = $items + ? collect($defaults)->mapWithKeys(fn ($value) => [$value => true])->all() + : collect($defaults)->map(fn ($value) => ['id' => $value, 'type' => $type])->all(); + + if ($defaults && ! $select instanceof StringSelect) { + $select = $select->setDefaultValues($defaults); + } + if ($id) { $select = $select->setCustomId($id); } @@ -997,7 +1037,7 @@ public function select( try { $select = $select->{$key}($option); } catch (Throwable) { - $this->bot->console()->error("Invalid select menu option {$key}"); + $this->bot->logger->error("Invalid select menu option {$key}"); continue; } @@ -1008,6 +1048,7 @@ public function select( if (! is_array($value)) { $select->addOption( Option::new(is_int($key) ? $value : $key, $value) + ->setDefault($defaults[$value] ?? false) ); continue; @@ -1016,7 +1057,7 @@ public function select( $option = Option::new($value['label'] ?? $key, $value['value'] ?? $key) ->setDescription($value['description'] ?? null) ->setEmoji($value['emoji'] ?? null) - ->setDefault($value['default'] ?? false); + ->setDefault($value['default'] ?? $defaults[$value['value'] ?? $key] ?? false); $select->addOption($option); } @@ -1095,7 +1136,7 @@ public function button( try { $button = $button->{$key}($option); } catch (Throwable) { - $this->bot->console()->error("Invalid button option {$key}"); + $this->bot->logger->error("Invalid button option {$key}"); continue; } @@ -1244,6 +1285,75 @@ public function clearEmbeds(): self return $this; } + /** + * Add a component to the message. + */ + public function addComponent(mixed $component): self + { + $this->components[] = $component; + + return $this; + } + + /** + * Add multiple components to the message. + */ + public function addComponents(array $components): self + { + foreach ($components as $component) { + $this->addComponent($component); + } + + return $this; + } + + /** + * Add components using the Components instance. + */ + public function withComponents(Components|callable $components): self + { + if (is_callable($components)) { + $instance = Components::make($this->bot) + ->routePrefix($this->routePrefix); + + $components($instance); + + $components = $instance; + } + + $this->addComponents($components->getComponents()); + + return $this; + } + + /** + * Determine if the message has components. + */ + public function hasComponents(): bool + { + return ! empty($this->components); + } + + /** + * Clear the components from the message. + */ + public function clearComponents(): self + { + $this->components = []; + + return $this; + } + + /** + * Set the message flags. + */ + public function flags(int $flags): self + { + $this->flags = $flags; + + return $this; + } + /** * Set the interaction route prefix. */ diff --git a/src/Events/Event.php b/src/Events/Event.php index 2fda648..f44446e 100644 --- a/src/Events/Event.php +++ b/src/Events/Event.php @@ -3,15 +3,15 @@ namespace Laracord\Events; use Composer\InstalledVersions; -use Discord\DiscordCommandClient as Discord; use Discord\WebSockets\Event as DiscordEvent; use Illuminate\Support\Facades\File; use Illuminate\Support\Str; -use Laracord\Console\Commands\BootCommand as Console; -use Laracord\Laracord; +use Laracord\HasLaracord; abstract class Event { + use HasLaracord; + /** * The event name. * @@ -33,45 +33,12 @@ abstract class Event */ protected $enabled = true; - /** - * The bot instance. - * - * @var \Laracord\Laracord - */ - protected $bot; - - /** - * The console instance. - * - * @var \Laracord\Console\Commands\BootCommand - */ - protected $console; - - /** - * The Discord instance. - * - * @var \Discord\DiscordCommandClient; - */ - protected $discord; - - /** - * Create a new event instance. - * - * @return void - */ - public function __construct(Laracord $bot) - { - $this->bot = $bot; - $this->console = $bot->console(); - $this->discord = $bot->discord(); - } - /** * Make a new event instance. */ - public static function make(Laracord $bot): self + public static function make(): self { - return new static($bot); + return new static; } /** @@ -80,13 +47,13 @@ public static function make(Laracord $bot): self public function register(): self { if (! $this->getHandler() || ! array_key_exists($this->getHandler(), $this->getEvents())) { - $this->console()->error("The {$this->getName()} event handler {$this->getHandler()} is invalid."); + $this->logger()->error("The {$this->getName()} event handler {$this->getHandler()} is invalid."); return $this; } if (! method_exists($this, 'handle')) { - $this->console()->error("The {$this->getName()} event handler does not have a handle method."); + $this->logger()->error("The {$this->getName()} event handler does not have a handle method."); return $this; } @@ -96,41 +63,6 @@ public function register(): self return $this; } - /** - * Build an embed for use in a Discord message. - * - * @param string $content - * @return \Laracord\Discord\Message - */ - public function message($content = '') - { - return $this->bot()->message($content); - } - - /** - * Get the Discord client. - */ - public function discord(): Discord - { - return $this->discord; - } - - /** - * Get the bot instance. - */ - public function bot(): Laracord - { - return $this->bot; - } - - /** - * Get the console instance. - */ - public function console(): Console - { - return $this->console; - } - /** * Get the available events. */ diff --git a/src/Facades/Laracord.php b/src/Facades/Laracord.php new file mode 100644 index 0000000..6844880 --- /dev/null +++ b/src/Facades/Laracord.php @@ -0,0 +1,81 @@ +bot()->console(); } + /** + * Retrieve the logger instance. + */ + public function logger(): LogManager + { + return $this->bot()->getLogger(); + } + /** * Build an embed for use in a Discord message. * * @param string $content - * @return \Laracord\Discord\Message + * @return Message */ public function message($content = '') { - return Message::make($this->bot()) + return clone app(Message::class) ->content($content); } } diff --git a/src/Http/Handlers/StaticFileHandler.php b/src/Http/Handlers/StaticFileHandler.php new file mode 100644 index 0000000..3e2e755 --- /dev/null +++ b/src/Http/Handlers/StaticFileHandler.php @@ -0,0 +1,277 @@ + 'audio/aac', + 'abw' => 'application/x-abiword', + 'apng' => 'image/apng', + 'arc' => 'application/x-freearc', + 'avif' => 'image/avif', + 'avi' => 'video/x-msvideo', + 'azw' => 'application/vnd.amazon.ebook', + 'bin' => 'application/octet-stream', + 'bmp' => 'image/bmp', + 'bz' => 'application/x-bzip', + 'bz2' => 'application/x-bzip2', + 'cda' => 'application/x-cdf', + 'csh' => 'application/x-csh', + 'css' => 'text/css', + 'csv' => 'text/csv', + 'doc' => 'application/msword', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'eot' => 'application/vnd.ms-fontobject', + 'epub' => 'application/epub+zip', + 'gz' => 'application/gzip', + 'gif' => 'image/gif', + 'htm' => 'text/html', + 'html' => 'text/html', + 'ico' => 'image/vnd.microsoft.icon', + 'ics' => 'text/calendar', + 'jar' => 'application/java-archive', + 'jpeg' => 'image/jpeg', + 'jpg' => 'image/jpeg', + 'js' => 'text/javascript', + 'json' => 'application/json', + 'jsonld' => 'application/ld+json', + 'md' => 'text/markdown', + 'mid' => 'audio/midi', + 'midi' => 'audio/midi', + 'mjs' => 'text/javascript', + 'mp3' => 'audio/mpeg', + 'mp4' => 'video/mp4', + 'mpeg' => 'video/mpeg', + 'mpkg' => 'application/vnd.apple.installer+xml', + 'odp' => 'application/vnd.oasis.opendocument.presentation', + 'ods' => 'application/vnd.oasis.opendocument.spreadsheet', + 'odt' => 'application/vnd.oasis.opendocument.text', + 'oga' => 'audio/ogg', + 'ogv' => 'video/ogg', + 'ogx' => 'application/ogg', + 'opus' => 'audio/ogg', + 'otf' => 'font/otf', + 'png' => 'image/png', + 'pdf' => 'application/pdf', + 'php' => 'application/x-httpd-php', + 'ppt' => 'application/vnd.ms-powerpoint', + 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'rar' => 'application/vnd.rar', + 'rtf' => 'application/rtf', + 'sh' => 'application/x-sh', + 'svg' => 'image/svg+xml', + 'tar' => 'application/x-tar', + 'tif' => 'image/tiff', + 'tiff' => 'image/tiff', + 'ts' => 'video/mp2t', + 'ttf' => 'font/ttf', + 'txt' => 'text/plain', + 'vsd' => 'application/vnd.visio', + 'wav' => 'audio/wav', + 'weba' => 'audio/webm', + 'webm' => 'video/webm', + 'webmanifest' => 'application/manifest+json', + 'webp' => 'image/webp', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + 'xhtml' => 'application/xhtml+xml', + 'xls' => 'application/vnd.ms-excel', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'xml' => 'application/xml', + 'xul' => 'application/vnd.mozilla.xul+xml', + 'zip' => 'application/zip', + '3gp' => 'video/3gpp', + '3g2' => 'video/3gpp2', + '7z' => 'application/x-7z-compressed', + ]; + + /** + * The index files to check for. + */ + protected array $indexFiles = [ + 'index.html', + 'index.htm', + ]; + + /** + * The filesystem instance. + */ + protected $filesystem; + + /** + * Cache for content types. + */ + protected static array $contentTypeCache = []; + + /** + * Create a new static file handler instance. + */ + public function __construct() + { + $this->filesystem = Factory::create(); + } + + /** + * Handle the static file request. + */ + public function handle(ServerRequestInterface $request): PromiseInterface + { + $path = $this->resolvePath($request->getUri()->getPath()); + + if (! $path) { + return resolve(null); + } + + return $this->filesystem->detect($path) + ->then(fn (NodeInterface $node) => $this->handleNode($node, $path)) + ->otherwise(function (Throwable $e) { + report($e); + + return $this->createErrorResponse(); + }); + } + + /** + * Handle a filesystem node (file or directory). + */ + protected function handleNode(NodeInterface $node, string $path): ?PromiseInterface + { + if ($node instanceof NotExistInterface) { + return resolve(null); + } + + return $node instanceof FileInterface + ? $this->handleFile($node, $path) + : $this->handleDirectory($node, $path); + } + + /** + * Handle a file request. + */ + protected function handleFile(FileInterface $file, string $path): PromiseInterface + { + return $file + ->getContents() + ->then(function (string $contents) use ($path) { + $headers = [ + 'Content-Type' => $this->getContentType($path), + 'Content-Length' => strlen($contents), + 'Cache-Control' => 'public, max-age=3600', + 'ETag' => '"'.md5($contents).'"', + ]; + + if ($this->isWebAsset($path)) { + $headers['Access-Control-Allow-Origin'] = '*'; + } + + return new Response(200, $headers, $contents); + }) + ->otherwise(fn () => null); + } + + /** + * Handle a directory request by looking for index files. + */ + protected function handleDirectory(NodeInterface $directory, string $path): PromiseInterface + { + return $this->findIndexFile($path, 0); + } + + /** + * Recursively find and serve index files. + */ + protected function findIndexFile(string $path, int $index): PromiseInterface + { + if ($index >= count($this->indexFiles)) { + return resolve(null); + } + + $indexPath = rtrim($path, '/').'/'.$this->indexFiles[$index]; + + return $this->filesystem->detect($indexPath) + ->then(function (NodeInterface $node) use ($indexPath, $path, $index) { + if ($node instanceof FileInterface) { + return $this->handleFile($node, $indexPath); + } + + return $this->findIndexFile($path, $index + 1); + }) + ->otherwise(fn () => $this->findIndexFile($path, $index + 1)); + } + + /** + * Resolve the file path. + */ + protected function resolvePath(string $requestPath): ?string + { + $requestPath = ltrim($requestPath, '/'); + + if ( + str_contains($requestPath, '..') || + str_contains($requestPath, '\\') || + str_starts_with(basename($requestPath), '.') + ) { + return null; + } + + $path = public_path($requestPath); + + return $path ?: null; + } + + /** + * Get the content type for a file. + */ + protected function getContentType(string $path): string + { + if (! isset(static::$contentTypeCache[$path])) { + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + + static::$contentTypeCache[$path] = static::$mimeTypes[$extension] ?? 'application/octet-stream'; + } + + return static::$contentTypeCache[$path]; + } + + /** + * Check if the file is a common web asset that should have CORS headers. + */ + protected function isWebAsset(string $path): bool + { + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + + return in_array($extension, [ + 'css', 'js', 'mjs', 'json', 'woff', 'woff2', 'ttf', 'otf', 'eot', + 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', + ]); + } + + /** + * Create a standardized error response. + */ + protected function createErrorResponse(int $status = 500, string $message = 'Internal Server Error'): Response + { + return new Response( + $status, + ['Content-Type' => 'text/plain'], + $message + ); + } +} diff --git a/src/Http/HttpServer.php b/src/Http/HttpServer.php new file mode 100644 index 0000000..86e6519 --- /dev/null +++ b/src/Http/HttpServer.php @@ -0,0 +1,266 @@ +getAddress() || ! Route::getRoutes()->getRoutes()) { + return $this; + } + + $this->socket = new SocketServer($this->getAddress(), [], $this->bot->getLoop()); + + $this->getServer()->listen($this->socket); + + $this->booted = true; + + return $this; + } + + /** + * Shutdown the HTTP server. + */ + public function shutdown(): void + { + if (! $this->isBooted()) { + return; + } + + $this->bot->callHook(Hook::BEFORE_HTTP_SERVER_STOP); + + $this->getServer()->removeAllListeners(); + $this->getSocket()->close(); + + $this->booted = false; + + $this->bot->logger->info('The HTTP server has been shutdown'); + } + + /** + * Retrieve the HTTP server instance. + */ + public function getServer(): Server + { + if ($this->server) { + return $this->server; + } + + return $this->server = new Server($this->bot->getLoop(), function (ServerRequestInterface $request) { + return $this->handleStaticFile($request) + ->then(function ($response) use ($request) { + if ($response !== null) { + return $response; + } + + return $this->handleLaravelRequest($request); + }) + ->otherwise(fn (Throwable $e) => $this->handleError($e)); + }); + } + + /** + * Handle a Laravel request through the kernel. + */ + protected function handleLaravelRequest(ServerRequestInterface $request): Response + { + $headers = $request->getHeaders(); + + $request = Request::create( + $request->getUri()->getPath(), + $request->getMethod(), + $request->getQueryParams(), + $request->getCookieParams(), + [], + $request->getServerParams(), + $request->getBody()->getContents() + ); + $request->headers->replace($headers); + + $this->bot->app->instance('request', $request); + + $this->configureMiddleware(); + + /** @var \Laracord\Http\Kernel $kernel */ + $kernel = $this->bot->app->make(Kernel::class); + + try { + $response = $kernel->handle($request); + $kernel->terminate($request, $response); + + return new Response( + $response->getStatusCode(), + $response->headers->allPreserveCase(), + $this->getResponseContent($response) + ); + } catch (Throwable $e) { + return $this->handleError($e); + } + } + + /** + * Configure the Laravel middleware for the request. + */ + protected function configureMiddleware(): void + { + $this->bot->withMiddleware(function (Middleware $middleware) { + $middleware + ->remove([PreventRequestsDuringMaintenance::class]) + ->append([FlushState::class]) + ->api([ + AuthorizeToken::class, + ]) + ->alias([ + 'auth.token' => AuthorizeToken::class, + ]); + }); + } + + /** + * Get the response content from a Laravel response. + */ + protected function getResponseContent(SymfonyResponse $response): string + { + if ($response->getContent()) { + return $response->getContent(); + } + + if ($response instanceof BinaryFileResponse) { + return $response->getFile()->getContent(); + } + + return ''; + } + + /** + * Handle a static file request. + */ + protected function handleStaticFile(ServerRequestInterface $request): PromiseInterface + { + if (! $this->staticFileHandler) { + $this->staticFileHandler = new StaticFileHandler; + } + + return $this->staticFileHandler->handle($request) + ->otherwise(fn () => null); + } + + /** + * Handle an error response. + */ + protected function handleError(Throwable $e): Response + { + $message = 'Internal Server Error'; + + if (! app()->isProduction()) { + $message = Str::finish($message, ": {$e->getMessage()}"); + } + + report($e); + + return new Response( + 500, + ['Content-Type' => 'application/json'], + json_encode(['code' => 500, 'message' => $message]) + ); + } + + /** + * Retrieve the socket server instance. + */ + public function getSocket(): SocketServer + { + return $this->socket; + } + + /** + * Set the server address. + */ + public function setAddress(string $address): self + { + $this->address = $address; + + return $this; + } + + /** + * Retrieve the server address. + */ + public function getAddress(): ?string + { + return $this->address; + } + + /** + * Determine if the server is booted. + */ + public function isBooted(): bool + { + return $this->booted; + } +} diff --git a/src/Http/Kernel.php b/src/Http/Kernel.php index 22fbda2..f7c4301 100644 --- a/src/Http/Kernel.php +++ b/src/Http/Kernel.php @@ -6,55 +6,5 @@ class Kernel extends HttpKernel { - /** - * The application's global HTTP middleware stack. - * - * These middleware are run during every request to your application. - * - * @var array - */ - protected $middleware = [ - \Laracord\Http\Middleware\FlushState::class, - \Illuminate\Http\Middleware\HandleCors::class, - \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, - \Laracord\Http\Middleware\TrimStrings::class, - \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, - ]; - - /** - * The application's route middleware groups. - * - * @var array> - */ - protected $middlewareGroups = [ - 'web' => [ - \Illuminate\Cookie\Middleware\EncryptCookies::class, - \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, - \Illuminate\Session\Middleware\StartSession::class, - \Illuminate\View\Middleware\ShareErrorsFromSession::class, - \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class, - \Illuminate\Routing\Middleware\SubstituteBindings::class, - ], - - 'api' => [ - \Illuminate\Routing\Middleware\ThrottleRequests::class.':api', - \Illuminate\Routing\Middleware\SubstituteBindings::class, - \Laracord\Http\Middleware\AuthorizeToken::class, - ], - ]; - - /** - * The application's middleware aliases. - * - * Aliases may be used instead of class names to conveniently assign middleware to routes and groups. - * - * @var array - */ - protected $middlewareAliases = [ - 'auth' => \Laracord\Http\Middleware\AuthorizeToken::class, - 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, - 'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class, - 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, - 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, - ]; + // } diff --git a/src/Http/Middleware/AuthorizeToken.php b/src/Http/Middleware/AuthorizeToken.php index fa42c3f..c33c1d6 100644 --- a/src/Http/Middleware/AuthorizeToken.php +++ b/src/Http/Middleware/AuthorizeToken.php @@ -27,9 +27,7 @@ public function handle(Request $request, Closure $next) return response()->json(['message' => 'You are not authorized.'], 403); } - $user = $token->tokenable; - - $request->merge(['user' => $user]); + $request->setUserResolver(static fn () => $token->tokenable); return $next($request); } diff --git a/src/Http/Middleware/FlushState.php b/src/Http/Middleware/FlushState.php index 43a838b..86e46ec 100644 --- a/src/Http/Middleware/FlushState.php +++ b/src/Http/Middleware/FlushState.php @@ -3,24 +3,18 @@ namespace Laracord\Http\Middleware; use Closure; +use Illuminate\Contracts\Auth\Factory as Auth; use Illuminate\Contracts\Foundation\Application; use Illuminate\Http\Request; class FlushState { - /** - * The application instance. - */ - protected Application $app; - /** * Create a new middleware instance. - * - * @return void */ - public function __construct(Application $app) + public function __construct(protected Application $app, protected ?Auth $auth = null) { - $this->app = $app; + // } /** @@ -28,6 +22,12 @@ public function __construct(Application $app) */ public function handle(Request $request, Closure $next) { + if ($this->auth) { + foreach (array_keys(config('auth.guards', [])) as $guard) { + $this->auth->guard($guard)->forgetUser(); + } + } + if ($this->app->resolved('cookie')) { $this->app->make('cookie')->flushQueuedCookies(); } diff --git a/src/Http/Middleware/TrimStrings.php b/src/Http/Middleware/TrimStrings.php deleted file mode 100644 index cfaea1e..0000000 --- a/src/Http/Middleware/TrimStrings.php +++ /dev/null @@ -1,19 +0,0 @@ - - */ - protected $except = [ - 'current_password', - 'password', - 'password_confirmation', - ]; -} diff --git a/src/Providers/RouteServiceProvider.php b/src/Http/Providers/RouteServiceProvider.php similarity index 94% rename from src/Providers/RouteServiceProvider.php rename to src/Http/Providers/RouteServiceProvider.php index 151cb4f..c6ec7f7 100644 --- a/src/Providers/RouteServiceProvider.php +++ b/src/Http/Providers/RouteServiceProvider.php @@ -1,6 +1,6 @@ bot = $bot; - $this->app = $bot->getApplication(); - } - - /** - * Make a new server instance. - */ - public static function make(Laracord $bot): self - { - return new static($bot); - } - - /** - * Boot the HTTP server. - */ - public function boot(): self - { - if (! $this->getAddress() || ! Route::getRoutes()->getRoutes()) { - return $this; - } - - $this->socket = new SocketServer($this->getAddress(), [], $this->bot->getLoop()); - - $this->getServer()->listen($this->socket); - - $this->booted = true; - - return $this; - } - - /** - * Shutdown the HTTP server. - */ - public function shutdown(): void - { - if (! $this->isBooted()) { - return; - } - - $this->getServer()->removeAllListeners(); - $this->getSocket()->close(); - - $this->booted = false; - - $this->bot->console()->log('The HTTP server has been shutdown'); - } - - /** - * Retrieve the HTTP server instance. - * - * @return \React\Http\HttpServer - */ - public function getServer() - { - if ($this->server) { - return $this->server; - } - - return $this->server = new HttpServer($this->bot->getLoop(), function (ServerRequestInterface $request) { - $headers = $request->getHeaders(); - - $request = Request::create( - $request->getUri()->getPath(), - $request->getMethod(), - $request->getQueryParams(), - $request->getCookieParams(), - [], - $request->getServerParams(), - $request->getBody()->getContents() - ); - - $request->headers->replace($headers); - - $this->app->instance('request', $request); - - /** @var \Laracord\Http\Kernel $kernel */ - $kernel = $this->app->make(Kernel::class); - - $kernel = $this->attachMiddleware($kernel); - - try { - $kernel->terminate($request, $response = $kernel->handle($request)); - } catch (Throwable $e) { - return $this->handleError($e); - } - - return new Response( - $response->getStatusCode(), - $response->headers->allPreserveCase(), - $response->getContent() ?: ($response instanceof BinaryFileResponse ? $response->getFile()->getContent() : false) ?: '' - ); - }); - } - - /** - * Handle an error response. - */ - protected function handleError(Throwable $e): Response - { - $response = 'Internal Server Error'; - - if (! app()->isProduction()) { - $response = Str::finish($response, ": {$e->getMessage()}"); - } - - $this->bot->console()->error($e->getMessage()); - - return new Response( - 500, - ['Content-Type' => 'application/json'], - json_encode(['code' => 500, 'message' => $response]) - ); - } - - /** - * Attach the middleware to the kernel. - */ - protected function attachMiddleware(Kernel $kernel): Kernel - { - if ($this->bot->prependMiddleware()) { - foreach ($this->bot->prependMiddleware() as $middleware) { - $kernel->prependMiddleware($middleware); - } - } - - if ($this->bot->middleware()) { - foreach ($this->bot->middleware() as $middleware) { - $kernel->pushMiddleware($middleware); - } - } - - return $kernel; - } - - /** - * Retrieve the socket server instance. - * - * @return \React\Socket\SocketServer - */ - public function getSocket() - { - return $this->socket; - } - - /** - * Retrieve the server address. - */ - public function getAddress(): ?string - { - if ($this->address) { - return $this->address; - } - - $address = config('discord.http'); - - if (! $address) { - return null; - } - - if (Str::startsWith($address, ':')) { - $address = Str::start($address, '0.0.0.0'); - } - - $host = Str::before($address, ':'); - $port = Str::after($address, ':'); - - if (! filter_var($host, FILTER_VALIDATE_IP)) { - throw new Exception('Invalid HTTP server address'); - } - - if ($port > 65535 || $port < 1) { - throw new Exception('Invalid HTTP server port'); - } - - return $this->address = $address; - } - - /** - * Determine if the server is booted. - */ - public function isBooted(): bool - { - return $this->booted; - } -} diff --git a/src/Laracord.php b/src/Laracord.php index 5e16620..d930bf9 100644 --- a/src/Laracord.php +++ b/src/Laracord.php @@ -3,209 +3,56 @@ namespace Laracord; use Carbon\Carbon; -use Discord\DiscordCommandClient as Discord; -use Discord\Parts\Interactions\Command\Option; -use Discord\Parts\Interactions\Interaction; -use Discord\WebSockets\Event as DiscordEvent; -use Discord\WebSockets\Intents; -use Exception; use Illuminate\Contracts\Foundation\Application; -use Illuminate\Support\Arr; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Route; use Illuminate\Support\Str; -use Laracord\Commands\ApplicationCommand; -use Laracord\Commands\Command; -use Laracord\Commands\ContextMenu; -use Laracord\Commands\SlashCommand; -use Laracord\Concerns\CanAsync; -use Laracord\Console\Commands\Command as ConsoleCommand; -use Laracord\Discord\Message; -use Laracord\Events\Event; -use Laracord\Http\Server; -use Laracord\Logging\Logger; -use Laracord\Services\Service; -use React\EventLoop\Loop; -use React\EventLoop\LoopInterface; -use React\Stream\ReadableResourceStream; -use React\Stream\WritableResourceStream; -use ReflectionClass; +use Laracord\Bot\Concerns; +use Laracord\Bot\Hook; use Throwable; -use function React\Async\await; -use function React\Promise\all; use function React\Promise\set_rejection_handler; class Laracord { - use CanAsync; + use Concerns\HasApplicationCommands, + Concerns\HasAsync, + Concerns\HasCommandMiddleware, + Concerns\HasCommands, + Concerns\HasComponents, + Concerns\HasConsole, + Concerns\HasContextMenus, + Concerns\HasDiscord, + Concerns\HasEvents, + Concerns\HasHooks, + Concerns\HasHttpServer, + Concerns\HasInteractions, + Concerns\HasLogger, + Concerns\HasLoop, + Concerns\HasPlugins, + Concerns\HasSlashCommands, + Concerns\HasTasks, + Concerns\HasUserModel; /** - * The event loop. + * The boot state. */ - protected ?LoopInterface $loop = null; + protected bool $booted = false; /** - * The application instance. - * - * @var \Illuminate\Contracts\Foundation\Application + * Initialize the Laracord instance. */ - protected $app; - - /** - * The console instance. - * - * @var \Laracord\Console\Commands\Command - */ - protected $console; - - /** - * The Discord instance. - * - * @var \Discord\DiscordCommandClient - */ - protected $discord; - - /** - * The Discord bot name. - */ - protected string $name = ''; - - /** - * The Discord bot description. - */ - protected string $description = ''; - - /** - * The Discord bot token. - */ - protected string $token = ''; - - /** - * The Discord bot command prefix. - */ - protected ?Collection $prefixes = null; - - /** - * The Discord bot intents. - */ - protected ?int $intents = null; - - /** - * The DiscordPHP options. - */ - protected array $options = []; - - /** - * The Discord bot admins. - */ - protected array $admins = []; - - /** - * The Discord bot commands. - */ - protected array $commands = []; - - /** - * The Discord bot slash commands. - */ - protected array $slashCommands = []; - - /** - * The Discord bot context menus. - */ - protected array $contextMenus = []; - - /** - * The Discord events. - */ - protected array $events = []; - - /** - * The bot services. - */ - protected array $services = []; - - /** - * The console input stream. - * - * @var \React\Stream\ReadableResourceStream - */ - protected $inputStream; - - /** - * The console output stream. - * - * @var \React\Stream\WritableResourceStream - */ - protected $outputStream; - - /** - * The bot HTTP server. - * - * @var \Laracord\Http\Server - */ - protected $httpServer; - - /** - * The logger instance. - * - * @var \Laracord\Logging\Logger - */ - protected $logger; - - /** - * The registered bot commands. - */ - protected array $registeredCommands = []; - - /** - * The registered context menus. - */ - protected array $registeredContextMenus = []; - - /** - * The registered Discord events. - */ - protected array $registeredEvents = []; - - /** - * The registered bot services. - */ - protected array $registeredServices = []; - - /** - * The registered bot interaction routes. - */ - protected array $registeredInteractions = []; - - /** - * Determine whether to show the commands on boot. - */ - protected bool $showCommands = true; - - /** - * Show the invite link if the bot is not in any guilds. - */ - protected bool $showInvite = true; - - /** - * Initialize the Discord Bot. - */ - public function __construct(ConsoleCommand $console) + public function __construct(public Application $app) { - $this->console = $console; - $this->app = $console->getLaravel(); - $this->admins = config('discord.admins', $this->admins); + set_rejection_handler(fn (Throwable $e) => report($e)); } /** - * Make the Bot instance. + * Make a new Laracord instance. */ - public static function make(ConsoleCommand $console): self + public static function make(Application $app): self { - return new static($console); + return new static($app); } /** @@ -213,25 +60,38 @@ public static function make(ConsoleCommand $console): self */ public function boot(): void { - set_rejection_handler(fn (Throwable $e) => $this->console()->error($e->getTraceAsString())); + if ($this->isBooted()) { + return; + } - $this->beforeBoot(); + $this->registerConsole(); + $this->registerLogger(); + $this->registerSignalHandlers(); - $this->bootDiscord(); + rescue(fn () => $this->handleBoot()); - $this->registerStream(); + $this->booted = true; + } + + /** + * Handle the boot process. + */ + protected function handleBoot(): void + { + $this->registerDiscord(); - $this->registerCommands(); + $this->callHook(Hook::BEFORE_BOOT); - $this->discord()->on('init', function () { + $this->discord->on('init', function () { $this - ->registerEvents() - ->bootServices() + ->bootApplicationCommands() + ->bootCommands() + ->bootEvents() + ->bootTasks() ->bootHttpServer() - ->registerApplicationCommands() ->handleInteractions(); - $this->afterBoot(); + $this->callHook(Hook::AFTER_BOOT); $this->getLoop()->addTimer(1, function () { $status = $this @@ -241,95 +101,41 @@ public function boot(): void $status = Str::replaceLast(', ', ', and ', $status); - $this->console()->log("Successfully booted {$this->getName()} with {$status}."); + $name = "{$this->getName()}"; + + if ($this->isShard()) { + $name .= " (Shard {$this->getShardId()})"; + } + + $this->logger->info("Successfully booted {$name} with {$status}."); $this ->showCommands() ->showInvite(); + + $this->console->newLine()->showPrompt(); }); }); - $this->discord()->run(); - } - - /** - * Boot the Discord client. - */ - protected function bootDiscord(): void - { - $this->discord = new Discord([ - 'token' => $this->getToken(), - 'prefixes' => $this->getPrefixes()->all(), - 'description' => $this->getDescription(), - 'discordOptions' => $this->getOptions(), - 'defaultHelpCommand' => false, - ]); - } - - /** - * Register the input and output streams. - */ - protected function registerStream(): self - { - if (windows_os()) { - return $this; - } - - if ($this->inputStream && $this->outputStream) { - return $this; - } - - $this->inputStream = new ReadableResourceStream(STDIN, $this->getLoop()); - $this->outputStream = new WritableResourceStream(STDOUT, $this->getLoop()); - - $this->inputStream->on('data', fn ($data) => $this->handleStream($data)); - - return $this; + $this->discord->run(); } /** - * Handle the input stream. + * Shutdown the bot. */ - protected function handleStream(string $data): void + public function shutdown(int $code = 0): void { - $command = trim($data); - - if (! $command) { - $this->outputStream->write('> '); + $this->callHook(Hook::BEFORE_SHUTDOWN); - return; + if ($this->httpServer) { + $this->httpServer()->shutdown(); } - $this->console()->newLine(); - - match ($command) { - 'shutdown', 'exit', 'quit', 'stop' => $this->shutdown(), - 'restart' => $this->restart(), - 'invite' => $this->showInvite(force: true), - 'commands' => $this->showCommands(), - 'status' => $this->showStatus(), - '?' => $this->console()->table(['Command', 'Description'], [ - ['shutdown', 'Shutdown the bot.'], - ['restart', 'Restart the bot.'], - ['invite', 'Show the invite link.'], - ['commands', 'Show the registered commands.'], - ['status', 'Show the bot status.'], - ]), - default => $this->console()->error("Unknown command: {$command}"), - }; - - $this->outputStream->write('> '); - } + $this->discord?->close(closeLoop: false); - /** - * Shutdown the bot. - */ - public function shutdown(int $code = 0): void - { - $this->console()->log("Shutting down {$this->getName()}."); + $this->logger->info("{$this->getName()} is shutting down."); - $this->httpServer()->shutdown(); - $this->discord()->close(); + $this->getLoop()?->stop(); exit($code); } @@ -339,999 +145,51 @@ public function shutdown(int $code = 0): void */ public function restart(): void { - $this->console()->log("{$this->getName()} is restarting."); - - $this->httpServer()->shutdown(); - $this->discord()->close(); - - $this->httpServer = null; - $this->discord = null; - - $this->registeredCommands = []; - $this->registeredContextMenus = []; - $this->registeredEvents = []; - $this->registeredServices = []; - - $this->boot(); - } - - /** - * Actions to run before booting the bot. - */ - public function beforeBoot(): void - { - // - } - - /** - * Actions to run after booting the bot. - */ - public function afterBoot(): void - { - // - } - - /** - * The HTTP routes. - */ - public function routes(): void - { - // - } - - /** - * The HTTP middleware. - */ - public function middleware(): array - { - return []; - } - - /** - * The prepended HTTP middleware. - */ - public function prependMiddleware(): array - { - return []; - } - - /** - * Register the bot commands. - */ - protected function registerCommands(): self - { - foreach ($this->getCommands() as $command) { - $command = $command::make($this); - - if (! $command->isEnabled()) { - continue; - } - - $options = [ - 'cooldown' => $command->getCooldown() ?: 0, - 'cooldownMessage' => $command->getCooldownMessage() ?: '', - 'description' => $command->getDescription() ?: '', - 'usage' => $command->getUsage() ?: '', - 'aliases' => $command->getAliases(), - ]; - - $this->discord->registerCommand( - $command->getName(), - fn ($message, $args) => $this->handleSafe($command->getName(), fn () => $command->maybeHandle($message, $args)), - $options - ); - - $this->registeredCommands[] = $command; - - $this->registerInteractions($command->getName(), $command->interactions()); - } - - return $this; - } - - /** - * Register the bot application commands. - */ - protected function registerApplicationCommands(): self - { - $normalize = function ($data) use (&$normalize) { - if (is_object($data)) { - $data = (array) $data; - } - - if (is_array($data)) { - ksort($data); - - return array_map($normalize, $data); - } - - return $data; - }; - - $existing = cache()->get('laracord.application-commands', []); - - if (! $existing) { - $existing[] = $this->discord->application->commands->freshen(); - - foreach ($this->discord->guilds as $guild) { - $existing[] = $guild->commands->freshen(); - } - - $existing = all($existing)->then(fn ($commands) => collect($commands) - ->flatMap(fn ($command) => $command->toArray()) - ->map(fn ($command) => collect($command->getCreatableAttributes()) - ->merge([ - 'id' => $command->id, - 'guild_id' => $command->guild_id ?? null, - 'dm_permission' => $command->guild_id ? null : ($command->dm_permission ?? false), - 'default_permission' => $command->default_permission ?? true, - ]) - ->all() - ) - ->map(fn ($command) => array_merge($command, [ - 'options' => json_decode(json_encode($command['options'] ?? []), true), - ])) - ->filter(fn ($command) => ! blank($command)) - ->keyBy('name') - ); - - $existing = await($existing); - - cache()->forever('laracord.application-commands', $existing); - } - - $existing = collect($existing); - - $registered = collect($this->getSlashCommands()) - ->merge($this->getContextMenus()) - ->map(fn ($command) => $command::make($this)) - ->filter(fn ($command) => $command->isEnabled()) - ->mapWithKeys(function ($command) { - $attributes = $command->create()->getCreatableAttributes(); - - $attributes = collect($attributes) - ->merge([ - 'guild_id' => $command->getGuild() ?? null, - 'dm_permission' => ! $command->getGuild() ? $command->canDirectMessage() : null, - 'nsfw' => $command->isNsfw(), - ]) - ->sortKeys() - ->all(); - - return [$command->getName() => [ - 'state' => $command, - 'attributes' => $attributes, - ]]; - }); - - $created = $registered->reject(fn ($command, $name) => $existing->has($name))->filter(); - $deleted = $existing->reject(fn ($command, $name) => $registered->has($name))->filter(); - - $updated = $registered - ->map(function ($command) { - $attributes = collect($command['attributes']) - ->reject(fn ($value) => blank($value)) - ->all(); - - return array_merge($command, ['attributes' => $attributes]); - }) - ->filter(function ($command, $name) use ($existing, $normalize) { - if (! $existing->has($name)) { - return false; - } - - $current = collect($existing->get($name)) - ->forget('id') - ->reject(fn ($value) => blank($value)); - - $attributes = collect($command['attributes']) - ->reject(fn ($value) => blank($value)); - - $keys = collect($current->keys()) - ->merge($attributes->keys()) - ->unique(); - - foreach ($keys as $key) { - $attribute = $current->get($key); - $value = $attributes->get($key); - - $attribute = $normalize($attribute); - $value = $normalize($value); - - if ($attribute === $value) { - continue; - } - - return true; - } + $this->logger->info("{$this->getName()} is restarting."); - return false; - }) - ->each(function ($command) use ($existing) { - $state = $existing->get($command['state']->getName()); + $this->callHook(Hook::BEFORE_RESTART); - $current = Arr::get($command, 'attributes.guild_id'); - $existing = Arr::get($state, 'guild_id'); - - if ($current && ! $existing) { - $this->unregisterApplicationCommand($state['id']); - } - - if ((! $current && $existing) || $current !== $existing) { - $this->unregisterApplicationCommand($state['id'], $existing); - } - }); - - if ($updated->isNotEmpty()) { - $this->console()->warn("Updating {$updated->count()} application command(s)."); - - $updated->each(function ($command) { - $state = $command['state']; - - $this->registerApplicationCommand($state); - }); - } - - if ($deleted->isNotEmpty()) { - $this->console()->warn("Deleting {$deleted->count()} application command(s)."); - - $deleted->each(fn ($command) => $this->unregisterApplicationCommand($command['id'], $command['guild_id'] ?? null)); - } - - if ($created->isNotEmpty()) { - $this->console()->log("Creating {$created->count()} new application command(s)."); - - $created->each(fn ($command) => $this->registerApplicationCommand($command['state'])); - } - - if ($registered->isEmpty()) { - return $this; + foreach ($this->tasks as $task) { + $task->stop(); } - $registered->each(function ($command, $name) { - $this->registerInteractions($name, $command['state']->interactions()); - - if ($command['state'] instanceof ContextMenu) { - $this->discord()->listenCommand( - $name, - fn ($interaction) => $this->handleSafe($name, fn () => $command['state']->maybeHandle($interaction)) - ); - - $this->registeredContextMenus[] = $command['state']; - - return; - } - - $subcommands = collect($command['state']->getRegisteredOptions()) - ->filter(fn (Option $option) => $option->type === Option::SUB_COMMAND) - ->map(fn (Option $subcommand) => [$name, $subcommand->name]); - - $subcommandGroups = collect($command['state']->getRegisteredOptions()) - ->filter(fn (Option $option) => $option->type === Option::SUB_COMMAND_GROUP) - ->flatMap(fn (Option $group) => collect($group->options) - ->filter(fn (Option $subcommand) => $subcommand->type === Option::SUB_COMMAND) - ->map(fn (Option $subcommand) => [$name, $group->name, $subcommand->name]) - ); - - $subcommands = $subcommands->merge($subcommandGroups); - - if ($subcommands->isNotEmpty()) { - $subcommands->each(function ($names) use ($command, $name) { - $this->discord()->listenCommand( - $names, - fn ($interaction) => $this->handleSafe($name, fn () => $command['state']->maybeHandle($interaction)), - fn ($interaction) => $this->handleSafe($name, fn () => $command['state']->maybeHandleAutocomplete($interaction)) - ); - }); - - return; - } - - $this->discord()->listenCommand( - $name, - fn ($interaction) => $this->handleSafe($name, fn () => $command['state']->maybeHandle($interaction)), - fn ($interaction) => $this->handleSafe($name, fn () => $command['state']->maybeHandleAutocomplete($interaction)) - ); - }); - - $this->registeredCommands = array_merge( - $this->registeredCommands, - $registered->pluck('state')->reject(fn ($command) => $command instanceof ContextMenu)->all() - ); - - return $this; - } - - /** - * Register the specified application command. - */ - public function registerApplicationCommand(ApplicationCommand $command): void - { - cache()->forget('laracord.application-commands'); - - if ($command->getGuild()) { - $guild = $this->discord()->guilds->get('id', $command->getGuild()); - - if (! $guild) { - $this->console()->warn("The {$command->getName()} command failed to register because the guild {$command->getGuild()} could not be found."); - - return; - } - - $guild->commands->save($command->create()); + $this->discord?->close(closeLoop: false); + $this->discord = null; - return; - } + $this->handleBoot(); - $this->discord()->application->commands->save($command->create()); + $this->callHook(Hook::AFTER_RESTART); } /** - * Unregister the specified application command. + * Retrieve the bot status collection. */ - public function unregisterApplicationCommand(string $id, ?string $guildId = null): void + public function getStatus(): Collection { - cache()->forget('laracord.application-commands'); - - if ($guildId) { - $guild = $this->discord()->guilds->get('id', $guildId); - - if (! $guild) { - $this->console()->warn("The command with ID {$id} failed to unregister because the guild {$guildId} could not be found."); - - return; - } - - $guild->commands->delete($id); - - return; - } - - $this->discord()->application->commands->delete($id); + return collect([ + 'command' => count($this->commands), + 'slash command' => count($this->slashCommands), + 'menu' => count($this->contextMenus), + 'event' => count($this->events), + 'task' => count($this->tasks), + 'interaction' => count($this->interactions), + 'route' => count(Route::getRoutes()->getRoutes()), + ])->filter()->mapWithKeys(fn ($count, $type) => [Str::plural($type, $count) => $count]); } /** - * Register the interaction routes. + * Retrieve the bot uptime. */ - protected function registerInteractions(string $name, array $routes = []): void - { - $routes = collect($routes) - ->mapWithKeys(fn ($value, $route) => ["{$name}@{$route}" => $value]) - ->all(); - - if (! $routes) { - return; - } - - $this->registeredInteractions = array_merge($this->registeredInteractions, $routes); - } - - /** - * Register the Discord events. - */ - protected function registerEvents(): self - { - foreach ($this->getEvents() as $event) { - $this->handleSafe($event, function () use ($event) { - $event = $event::make($this); - - if (! $event->isEnabled()) { - return; - } - - $this->registeredEvents[] = $event->register(); - - $this->console()->log("The {$event->getName()} event has been registered to {$event->getHandler()}."); - }); - } - - return $this; - } - - /** - * Boot the bot services. - */ - protected function bootServices(): self - { - foreach ($this->getServices() as $service) { - $this->handleSafe($service, function () use ($service) { - $service = $service::make($this); - - if (! $service->isEnabled()) { - return; - } - - $this->registeredServices[] = $service->boot(); - - $this->console()->log("The {$service->getName()} service has been booted."); - }); - } - - return $this; - } - - /** - * Handle the interaction routes. - */ - protected function handleInteractions(): self - { - $this->discord()->on(DiscordEvent::INTERACTION_CREATE, function (Interaction $interaction) { - $id = $interaction->data->custom_id; - - $handlers = collect($this->getRegisteredInteractions()) - ->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. - */ - protected function bootHttpServer(): self - { - if ($this->httpServer) { - return $this; - } - - $this->handleSafe(Server::class, function () { - $this->routes(); - - $this->app->booted(function () { - $this->app['router']->getRoutes()->refreshNameLookups(); - $this->app['router']->getRoutes()->refreshActionLookups(); - }); - - $this->httpServer = Server::make($this)->boot(); - - if ($this->httpServer->isBooted()) { - $this->console()->log("HTTP server started on {$this->httpServer->getAddress()}."); - } - }); - - return $this; - } - - /** - * Print the registered commands to console. - */ - public function showCommands(): self - { - if (! $this->showCommands) { - return $this; - } - - $this->console()->table( - ['Command', 'Description'], - collect($this->getRegisteredCommands())->map(fn ($command) => [ - $command->getSignature(), - $command->getDescription(), - ])->toArray() - ); - - return $this; - } - - /** - * Print the bot status to console. - */ - public function showStatus(): self - { - $uptime = now()->createFromTimestamp(LARAVEL_START)->diffForHumans(); - - $status = $this->getStatus() - ->prepend($this->discord()->users->count(), 'user') - ->prepend($this->discord()->guilds->count(), 'guild') - ->mapWithKeys(fn ($count, $type) => [Str::plural($type, $count) => $count]) - ->prepend($uptime, 'uptime') - ->prepend("{$this->discord()->username} ({$this->discord()->id})", 'bot') - ->map(fn ($count, $type) => Str::of($type)->title()->finish(": {$count}")->toString()); - - $this->console()->line(' Status'); - $this->console()->outputComponents()->bulletList($status->all()); - - return $this; - } - - /** - * Show the invite link if the bot is not in any guilds. - */ - public function showInvite(bool $force = false): self - { - if (! $force && (! $this->showInvite || $this->discord()->guilds->count() > 0)) { - return $this; - } - - if (! $force) { - $this->console()->warn("{$this->getName()} is currently not in any guilds."); - } - - $query = Arr::query([ - 'client_id' => $this->discord()->id, - 'permissions' => 281600, - 'scope' => 'bot applications.commands', - ]); - - $this->console()->log("You can invite {$this->getName()} using the following link:"); - - $this->console()->outputComponents()->bulletList(["https://discord.com/api/oauth2/authorize?{$query}"]); - - return $this; - } - - /** - * Get the bot name. - */ - public function getName(): string - { - if ($this->name) { - return $this->name; - } - - return $this->name = config('app.name'); - } - - /** - * Get the bot description. - */ - public function getDescription(): string - { - if ($this->description) { - return $this->description; - } - - return $this->description = config('discord.description'); - } - - /** - * Get the bot token. - */ - public function getToken(): string - { - if ($this->token) { - return $this->token; - } - - $token = config('discord.token'); - - if (! $token) { - throw new Exception('You must provide a Discord bot token.'); - } - - return $this->token = $token; - } - - /** - * Get the bot intents. - */ - public function getIntents(): ?int - { - if ($this->intents) { - return $this->intents; - } - - return $this->intents = config('discord.intents', Intents::getDefaultIntents()); - } - - /** - * Get the bot options. - */ - public function getOptions(): array - { - if ($this->options) { - return $this->options; - } - - $defaultOptions = [ - 'intents' => $this->getIntents(), - 'logger' => $this->getLogger(), - 'loop' => $this->getLoop(), - ]; - - return $this->options = [ - ...config('discord.options', []), - ...$defaultOptions, - ]; - } - - /** - * Get the logger instance. - */ - public function getLogger(): Logger - { - return $this->logger ??= Logger::make($this->console); - } - - /** - * Get the Discord admins. - */ - public function getAdmins(): array - { - return $this->admins; - } - - /** - * Get the Discord events. - */ - public function getEvents(): array - { - if ($this->events) { - return $this->events; - } - - $events = $this->extractClasses($this->getEventPath()) - ->merge(config('discord.events', [])) - ->unique() - ->filter(fn ($event) => $this->handleSafe($event, fn () => is_subclass_of($event, Event::class) && ! (new ReflectionClass($event))->isAbstract())) - ->all(); - - return $this->events = $events; - } - - /** - * Get the bot services. - */ - public function getServices(): array - { - if ($this->services) { - return $this->services; - } - - $services = $this->extractClasses($this->getServicePath()) - ->merge(config('discord.services', [])) - ->unique() - ->filter(fn ($service) => $this->handleSafe($service, fn () => is_subclass_of($service, Service::class) && ! (new ReflectionClass($service))->isAbstract())) - ->all(); - - return $this->services = $services; - } - - /** - * Get the bot commands. - */ - public function getCommands(): array - { - if ($this->commands) { - return $this->commands; - } - - $commands = $this->extractClasses($this->getCommandPath()) - ->merge(config('discord.commands', [])) - ->unique() - ->filter(fn ($command) => $this->handleSafe($command, fn () => is_subclass_of($command, Command::class) && ! (new ReflectionClass($command))->isAbstract())) - ->all(); - - return $this->commands = $commands; - } - - /** - * Get the bot slash commands. - */ - public function getSlashCommands(): array - { - if ($this->slashCommands) { - return $this->slashCommands; - } - - $slashCommands = $this->extractClasses($this->getSlashCommandPath()) - ->merge(config('discord.commands', [])) - ->unique() - ->filter(fn ($command) => $this->handleSafe($command, fn () => is_subclass_of($command, SlashCommand::class) && ! (new ReflectionClass($command))->isAbstract())) - ->all(); - - return $this->slashCommands = $slashCommands; - } - - /** - * Get the bot context menus. - */ - public function getContextMenus(): array - { - if ($this->contextMenus) { - return $this->contextMenus; - } - - $contextMenus = $this->extractClasses($this->getContextMenuPath()) - ->merge(config('discord.menus', [])) - ->unique() - ->filter(fn ($contextMenu) => $this->handleSafe($contextMenu, fn () => is_subclass_of($contextMenu, ContextMenu::class) && ! (new ReflectionClass($contextMenu))->isAbstract())) - ->all(); - - return $this->contextMenus = $contextMenus; - } - - /** - * Extract classes from the provided application path. - */ - protected function extractClasses(string $path): Collection - { - if (! File::isDirectory($path)) { - return collect(); - } - - return collect(File::allFiles($path)) - ->map(function ($file) { - $relativePath = str_replace( - Str::finish(app_path(), DIRECTORY_SEPARATOR), - '', - $file->getPathname() - ); - - $folders = Str::beforeLast( - $relativePath, - DIRECTORY_SEPARATOR - ).DIRECTORY_SEPARATOR; - - $className = Str::after($relativePath, $folders); - - $class = app()->getNamespace().str_replace( - ['/', '.php'], - ['\\', ''], - $folders.$className - ); - - return $class; - }); - } - - /** - * Get the registered commands. - */ - public function getRegisteredCommands(): array - { - return $this->registeredCommands; - } - - /** - * Get the registered context menus. - */ - public function getRegisteredContextMenus(): array - { - return $this->registeredContextMenus; - } - - /** - * Get the registered events. - */ - public function getRegisteredEvents(): array - { - return $this->registeredEvents; - } - - /** - * Get the registered services. - */ - public function getRegisteredServices(): array - { - return $this->registeredServices; - } - - /** - * Get the registered interactions. - */ - public function getRegisteredInteractions(): array - { - return $this->registeredInteractions; - } - - /** - * Get a registered command by name. - */ - public function getCommand(string $name): Command|SlashCommand|null - { - return collect($this->getRegisteredCommands()) - ->first(fn ($command) => $command->getName() === $name); - } - - /** - * Get a registered context menu by name. - */ - public function getContextMenu(string $name): ?ContextMenu - { - return collect($this->getRegisteredContextMenus()) - ->first(fn ($contextMenu) => $contextMenu->getName() === $name); - } - - /** - * Get the path to the Discord commands. - */ - public function getCommandPath(): string - { - return app_path('Commands'); - } - - /** - * Get the path to the Discord slash commands. - */ - public function getSlashCommandPath(): string - { - return app_path('SlashCommands'); - } - - /** - * Get the path to the Discord context menus. - */ - public function getContextMenuPath(): string - { - return app_path('Menus'); - } - - /** - * Get the path to the Discord events. - */ - public function getEventPath(): string - { - return app_path('Events'); - } - - /** - * Get the path to the bot services. - */ - public function getServicePath(): string - { - return app_path('Services'); - } - - /** - * Retrieve the prefixes. - */ - public function getPrefixes(): Collection - { - if ($this->prefixes) { - return $this->prefixes; - } - - $prefixes = collect(config('discord.prefix', '!')) - ->filter() - ->reject(fn ($prefix) => Str::startsWith($prefix, '/')); - - if ($prefixes->isEmpty()) { - throw new Exception('You must provide a valid command prefix.'); - } - - return $this->prefixes = $prefixes; - } - - /** - * Retrieve the primary prefix. - */ - public function getPrefix(): string - { - return $this->getPrefixes()->first(); - } - - /** - * Get the event loop. - */ - public function getLoop(): LoopInterface - { - if ($this->loop) { - return $this->loop; - } - - return $this->loop = Loop::get(); - } - - /** - * Get the Discord instance. - */ - public function discord(): ?Discord - { - return $this->discord; - } - - /** - * Get the console instance. - */ - public function console(): ConsoleCommand - { - return $this->console; - } - - /** - * Get the HTTP server instance. - */ - public function httpServer(): ?Server - { - return $this->httpServer; - } - - /** - * Get the Application instance. - */ - public function getApplication(): Application - { - return $this->app; - } - - /** - * Retrieve the bot status collection. - */ - public function getStatus(): Collection - { - return collect([ - 'command' => count($this->getRegisteredCommands()), - 'menu' => count($this->getRegisteredContextMenus()), - 'event' => count($this->getRegisteredEvents()), - 'service' => count($this->getRegisteredServices()), - 'interaction' => count($this->getRegisteredInteractions()), - 'route' => count(Route::getRoutes()->getRoutes()), - ])->filter()->mapWithKeys(fn ($count, $type) => [Str::plural($type, $count) => $count]); - } - - /** - * Retrieve the bot uptime. - */ - public function getUptime(): Carbon + public function getUptime(): Carbon { return now()->createFromTimestamp(LARAVEL_START); } /** - * 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([ - sprintf('%s in %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), - ]); - } - - return null; - } - - /** - * Build an embed for use in a Discord message. - * - * @param string $content - * @return \Laracord\Discord\Message + * Determine if the bot is booted. */ - public function message($content = '') + public function isBooted(): bool { - return Message::make($this) - ->content($content); + return $this->booted; } } diff --git a/src/LaracordServiceProvider.php b/src/LaracordServiceProvider.php index 2cd6401..47ccd6e 100644 --- a/src/LaracordServiceProvider.php +++ b/src/LaracordServiceProvider.php @@ -2,38 +2,79 @@ namespace Laracord; +use Illuminate\Auth\AuthServiceProvider; +use Illuminate\Console\Command; +use Illuminate\Contracts\Console\Kernel as ConsoleKernel; use Illuminate\Contracts\Http\Kernel as KernelContract; +use Illuminate\Cookie\CookieServiceProvider; +use Illuminate\Encryption\EncryptionServiceProvider; +use Illuminate\Filesystem\Filesystem; +use Illuminate\Foundation\AliasLoader; +use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Foundation\Console\PackageDiscoverCommand; +use Illuminate\Foundation\PackageManifest as BasePackageManifest; +use Illuminate\Hashing\HashServiceProvider; +use Illuminate\Log\Context\ContextServiceProvider; +use Illuminate\Mail\MailServiceProvider; +use Illuminate\Queue\QueueServiceProvider; +use Illuminate\Routing\RoutingServiceProvider; +use Illuminate\Session\SessionServiceProvider; +use Illuminate\Support\AggregateServiceProvider; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; -use Illuminate\Support\ServiceProvider; +use Illuminate\Translation\TranslationServiceProvider; +use Illuminate\Validation\ValidationServiceProvider; +use Illuminate\View\ViewServiceProvider; +use Intonate\TinkerZero\TinkerZeroServiceProvider; +use Laracord\Console\Commands; +use Laracord\Console\Console; +use Laracord\Console\Prompts; +use Laracord\Discord\Message; use Laracord\Http\Kernel; +use Laracord\Http\Providers\RouteServiceProvider; use LaravelZero\Framework\Components\Database\Provider as DatabaseProvider; +use LaravelZero\Framework\Components\Log\Provider as LogProvider; +use React\EventLoop\Loop; +use React\EventLoop\LoopInterface; +use React\Stream\CompositeStream; +use React\Stream\ReadableResourceStream; +use React\Stream\ReadableStreamInterface; +use React\Stream\ThroughStream; +use React\Stream\WritableResourceStream; +use React\Stream\WritableStreamInterface; +use Symfony\Component\Console\Input\StringInput; +use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Finder\Finder; -class LaracordServiceProvider extends ServiceProvider +abstract class LaracordServiceProvider extends AggregateServiceProvider { /** - * The default providers. + * The provider class names. * * @var array */ protected $providers = [ - \Illuminate\Encryption\EncryptionServiceProvider::class, - \Illuminate\Hashing\HashServiceProvider::class, - \Illuminate\Queue\QueueServiceProvider::class, - \Illuminate\Routing\RoutingServiceProvider::class, - \Illuminate\Translation\TranslationServiceProvider::class, - \Illuminate\Validation\ValidationServiceProvider::class, - \Illuminate\View\ViewServiceProvider::class, - \Illuminate\Cookie\CookieServiceProvider::class, - \Illuminate\Session\SessionServiceProvider::class, - \Laracord\Providers\RouteServiceProvider::class, - \Intonate\TinkerZero\TinkerZeroServiceProvider::class, + EncryptionServiceProvider::class, + HashServiceProvider::class, + QueueServiceProvider::class, + RoutingServiceProvider::class, + TranslationServiceProvider::class, + ValidationServiceProvider::class, + ViewServiceProvider::class, + CookieServiceProvider::class, + SessionServiceProvider::class, + MailServiceProvider::class, + AuthServiceProvider::class, + ContextServiceProvider::class, + RouteServiceProvider::class, + TinkerZeroServiceProvider::class, ]; + abstract public function bot(Laracord $bot): Laracord; + /** * Register any application services. * @@ -41,16 +82,59 @@ class LaracordServiceProvider extends ServiceProvider */ public function register() { + $storage = env('STORAGE_PATH', laracord_path('storage', basePath: false)); + + $this->app->useStoragePath($storage); + $this->mergeConfigs(); $this->createDirectories(); - foreach ($this->providers as $provider) { + $this->app->singleton(BasePackageManifest::class, fn () => new PackageManifest( + new Filesystem, + laracord_path(basePath: false), + laracord_path('cache/bootstrap/packages.php'), + )); + + parent::register(); + + foreach ($this->app->make(BasePackageManifest::class)->providers() as $provider) { + if (! class_exists($provider)) { + continue; + } + $this->app->register($provider); } + AliasLoader::getInstance([ + $this->app->make(BasePackageManifest::class)->aliases(), + ]); + $this->registerDatabase(); + $this->registerLoop(); + $this->registerConsole(); + $this->registerLogger(); $this->app->singleton(KernelContract::class, Kernel::class); + $this->app->singleton(Middleware::class, fn () => new Middleware); + + $this->app->singleton(Laracord::class, fn () => tap(Laracord::make($this->app), function (Laracord $bot) { + $this + ->registerDefaultComponents($bot) + ->registerDefaultPrompts($bot); + + $user = config('auth.providers.users.model'); + + if (! class_exists($user)) { + config(['auth.providers.users.model' => $bot->getUserModel()]); + } + + $this->app->singleton(Message::class, fn () => Message::make($bot)); + + return $this->bot($bot); + })); + + $this->app->alias(Laracord::class, 'bot'); + $this->app->alias(Message::class, 'bot.message'); } /** @@ -61,23 +145,145 @@ public function register() public function boot() { $this->commands([ - Console\Commands\AdminCommand::class, - Console\Commands\BootCommand::class, - Console\Commands\ConsoleMakeCommand::class, - Console\Commands\ControllerMakeCommand::class, - Console\Commands\EventMakeCommand::class, - Console\Commands\KeyGenerateCommand::class, - Console\Commands\MakeCommand::class, - Console\Commands\MakeSlashCommand::class, - Console\Commands\MakeMenuCommand::class, - Console\Commands\ModelMakeCommand::class, - Console\Commands\ServiceMakeCommand::class, - Console\Commands\TokenMakeCommand::class, + Commands\AdminCommand::class, + Commands\BootCommand::class, + Commands\ConsoleMakeCommand::class, + Commands\ControllerMakeCommand::class, + Commands\EventMakeCommand::class, + Commands\KeyGenerateCommand::class, + Commands\MakeCommand::class, + Commands\MakeCommandMiddlewareCommand::class, + Commands\MakeMenuCommand::class, + Commands\MakeSlashCommand::class, + Commands\ModelMakeCommand::class, + Commands\PromptMakeCommand::class, + Commands\TaskMakeCommand::class, + Commands\TokenMakeCommand::class, + Commands\UpgradeCommand::class, + PackageDiscoverCommand::class, ]); $this->registerMacros(); } + /** + * Register the event loop. + */ + protected function registerLoop(): void + { + $this->app->singleton(LoopInterface::class, fn () => Loop::get()); + $this->app->alias(LoopInterface::class, 'bot.loop'); + } + + /** + * Register the console. + */ + protected function registerConsole(): void + { + $this->app->singleton(Console::class, function () { + $loop = $this->app->make(LoopInterface::class); + + $stdin = $this->createInputStream($loop); + $stdout = $this->createOutputStream($loop); + + $console = new Console( + stdio: ! stream_isatty(STDIN) ? $stdout : new CompositeStream($stdin, $stdout), + laravel: $this->app, + output: new ConsoleOutput, + input: new StringInput(''), + ); + + if ($console->hasColorSupport()) { + $console->getOutput()->setDecorated(true); + } + + // foreach ($this->app->make(ConsoleKernel::class)->all() as $command) { + // if ($command instanceof Command) { + // $console->addCommand($command); + // } + // } + + $this->app->instance('bot.console', $console); + + return $console; + }); + } + + /** + * Create an input stream. + */ + protected function createInputStream(LoopInterface $loop): ReadableStreamInterface + { + if (! windows_os()) { + return new ReadableResourceStream(STDIN, $loop); + } + + $stream = new ThroughStream; + + $stream->end(); + + return $stream; + } + + /** + * Create an output stream. + */ + protected function createOutputStream(LoopInterface $loop): WritableStreamInterface + { + if (! windows_os()) { + return new WritableResourceStream(STDOUT, $loop); + } + + $stream = new ThroughStream; + + $stream->on('data', function ($data) { + fwrite(STDOUT, $data); + }); + + return $stream; + } + + /** + * Register the logger. + */ + protected function registerLogger(): void + { + $this->app->booting(fn () => $this->app->register(LogProvider::class)); + } + + /** + * Register the default components. + */ + protected function registerDefaultComponents(Laracord $bot): self + { + $bot + ->discoverCommands(in: app_path('Commands'), for: 'App\\Commands') + ->discoverSlashCommands(in: app_path('SlashCommands'), for: 'App\\SlashCommands') + ->discoverContextMenus(in: app_path('Menus'), for: 'App\\Menus') + ->discoverEvents(in: app_path('Events'), for: 'App\\Events') + ->discoverTasks(in: app_path('Tasks'), for: 'App\\Tasks') + ->discoverTasks(in: app_path('Services'), for: 'App\\Services'); + + return $this; + } + + /** + * Register the default console prompts. + */ + protected function registerDefaultPrompts(Laracord $bot): self + { + $bot->registerPrompts([ + Prompts\ExitPrompt::class, + Prompts\HelpPrompt::class, + Prompts\InvitePrompt::class, + Prompts\RestartPrompt::class, + Prompts\StatusPrompt::class, + Prompts\ClearPrompt::class, + ]); + + return $this; + } + /** * Retrieve configuration files from the specified path. */ @@ -111,6 +317,7 @@ protected function mergeConfigs(): void protected function createDirectories(): void { $paths = [ + 'bootstrap' => laracord_path('cache/bootstrap'), 'cache' => $this->app['config']->get('cache.stores.file.path'), 'sessions' => $this->app['config']->get('session.files'), 'view' => $this->app['config']->get('view.compiled'), @@ -152,7 +359,7 @@ protected function registerMacros(): void Storage::macro('getAsync', fn (string $path) => Laracord::handleAsync(fn () => Storage::get($path))); Storage::macro('putAsync', fn (string $path, mixed $contents) => Laracord::handleAsync(fn () => Storage::put($path, $contents))); - Date::macro('toDiscord', function ($format = 'R') { + Date::macro('toDiscord', function (string $format = 'R') { $format = match ($format) { 't', 'T', 'd', 'D', 'f', 'F', 'R' => $format, default => 'R', diff --git a/src/Logging/ConsoleHandler.php b/src/Logging/ConsoleHandler.php new file mode 100755 index 0000000..36a6d68 --- /dev/null +++ b/src/Logging/ConsoleHandler.php @@ -0,0 +1,33 @@ +logger = $logger ?? Logger::make(); + } + + /** + * {@inheritdoc} + */ + protected function write(LogRecord $record): void + { + $this->logger->handle($record->message, $record->context, $record->level->getName()); + } +} diff --git a/src/Logging/Logger.php b/src/Logging/Logger.php index 4ad7183..2734c3a 100644 --- a/src/Logging/Logger.php +++ b/src/Logging/Logger.php @@ -3,17 +3,20 @@ namespace Laracord\Logging; use Illuminate\Support\Str; -use LaravelZero\Framework\Commands\Command; +use Laracord\Console\Console; +use NunoMaduro\Collision\Writer; use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; +use Stringable; +use Throwable; +use Whoops\Exception\Inspector; class Logger implements LoggerInterface { /** * Log messages that should be ignored. - * - * @var array */ - protected $except = [ + protected array $except = [ 'sending heartbeat', 'received heartbeat', 'http not checking', @@ -22,17 +25,13 @@ class Logger implements LoggerInterface /** * The console instance. - * - * @var \LaravelZero\Framework\Commands\Command */ - protected $console; + protected Console $console; /** * Initialize the logger. - * - * @return void */ - public function __construct(Command $console) + public function __construct(Console $console) { $this->console = $console; } @@ -40,140 +39,125 @@ public function __construct(Command $console) /** * Make a new logger instance. */ - public static function make(Command $console): Logger + public static function make(?Console $console = null): static { - return new static($console); + return new static($console ?? app(Console::class)); } /** * {@inheritdoc} */ - public function emergency(string|\Stringable $message, array $context = []): void + public function emergency(string|Stringable $message, array $context = []): void { - $this->error($message, $context); + $this->handle($message, $context, LogLevel::EMERGENCY); } /** * {@inheritdoc} */ - public function alert(string|\Stringable $message, array $context = []): void + public function alert(string|Stringable $message, array $context = []): void { - $this->error($message, $context); + $this->handle($message, $context, LogLevel::ALERT); } /** * {@inheritdoc} */ - public function critical(string|\Stringable $message, array $context = []): void + public function critical(string|Stringable $message, array $context = []): void { - $this->error($message, $context); + $this->handle($message, $context, LogLevel::CRITICAL); } /** * {@inheritdoc} */ - public function error(string|\Stringable $message, array $context = []): void + public function error(string|Stringable $message, array $context = []): void { - $this->handle($message, $context, 'error'); + $this->handle($message, $context, LogLevel::ERROR); } /** * {@inheritdoc} */ - public function warning(string|\Stringable $message, array $context = []): void + public function warning(string|Stringable $message, array $context = []): void { - $this->handle($message, $context, 'warn'); + $this->handle($message, $context, LogLevel::WARNING); } /** * {@inheritdoc} */ - public function notice(string|\Stringable $message, array $context = []): void + public function notice(string|Stringable $message, array $context = []): void { - $this->info($message, $context); + $this->info($message, $context, LogLevel::NOTICE); } /** * {@inheritdoc} */ - public function info(string|\Stringable $message, array $context = []): void + public function info(string|Stringable $message, array $context = []): void { - $this->handle($message, $context); + $this->handle($message, $context, LogLevel::INFO); } /** * {@inheritdoc} */ - public function debug(string|\Stringable $message, array $context = []): void + public function debug(string|Stringable $message, array $context = []): void { - if (app()->environment('production')) { - return; - } - - $this->info($message, $context); + $this->handle($message, $context, LogLevel::DEBUG); } /** * {@inheritdoc} */ - public function log($level, string|\Stringable $message, array $context = []): void + public function log($level, string|Stringable $message, array $context = []): void { - $this->info($message, $context); + $this->handle($message, $context, $level); } /** * Handle the log message. */ - public function handle(string|\Stringable $message, array $context = [], string $type = 'info'): void + public function handle(string|Stringable $message, array $context = [], string $type = 'info'): void { - $type = match ($type) { - 'error' => 'error', - 'warn' => 'warn', - default => 'info', + $type = match (strtolower($type)) { + 'alert' => LogLevel::ALERT, + 'critical' => LogLevel::CRITICAL, + 'debug' => LogLevel::DEBUG, + 'emergency' => LogLevel::EMERGENCY, + 'error' => LogLevel::ERROR, + 'info' => LogLevel::INFO, + 'warning' => LogLevel::WARNING, + default => LogLevel::INFO, }; - if (Str::of($message)->lower()->contains($this->except)) { + if (app()->isProduction() && $type === LogLevel::DEBUG) { return; } - $message = ucfirst($message); - - if (method_exists($this->console, 'log')) { - $this->console->log($message, $type); - } else { - $this->console->outputComponents()->{$type}($message); - } - - $this->handleContext($context, $type); - } - - /** - * Handle the log context. - * - * @return array - */ - protected function handleContext(array $context = [], string $type = 'info'): void - { - if (! Str::is('error', $type)) { + if (Str::of($message)->lower()->contains($this->except)) { return; } - $context = collect($context)->filter(); + if (isset($context['exception']) && $context['exception'] instanceof Throwable) { + tap(new Writer, fn (Writer $writer) => $writer->write(new Inspector($context['exception']))); - if ($context->isEmpty()) { return; } - $type = match ($type) { - 'error' => 'red', - 'warn' => 'yellow', - default => 'blue', - }; + $message = ucfirst($message); - $context = $context - ->mapWithKeys(fn ($value, $key) => [Str::is('e', $key) ? 'Error' : $key => $value]) - ->map(fn ($value, $key) => sprintf('%s: %s', $type, Str::headline($key), $value)); + if (method_exists($this->console, 'log')) { + $this->console->log($type, $message, $context); + } else { + $component = match ($type) { + LogLevel::ALERT, LogLevel::CRITICAL, LogLevel::ERROR => 'error', + LogLevel::WARNING => 'warn', + default => 'info', + }; - $this->console->outputComponents()->bulletList($context->all()); + $this->console->outputComponents()->{$component}($message); + } } } diff --git a/src/Logging/LoggingHandler.php b/src/Logging/LoggingHandler.php new file mode 100644 index 0000000..805f32a --- /dev/null +++ b/src/Logging/LoggingHandler.php @@ -0,0 +1,197 @@ +maxSize *= 1024 * 1024; + + $this->filesystem = Factory::create(); + + $this->filesystem->detect(dirname($this->path)) + ->then(fn (NodeInterface $node) => $node instanceof NotExistInterface + ? $node->createDirectory() + : $node + ); + + $this->initializeStream(); + + $this->flushTimer = Laracord::getLoop()->addPeriodicTimer($this->flushInterval, fn () => $this->flush()); + } + + /** + * Initialize the stream. + */ + protected function initializeStream(): void + { + $this->filesystem->detect($this->path) + ->then(function (NodeInterface $node) { + if ($node instanceof NotExistInterface) { + return $node->createFile(); + } + + return $node; + }) + ->then(fn (FileInterface $node) => $this->handle = $node); + } + + /** + * Flush the buffer to disk. + */ + protected function flush(): void + { + if (! $this->handle || blank($this->buffer)) { + return; + } + + $buffer = implode('', $this->buffer); + + $this->buffer = []; + + $this->handle + ->putContents($buffer, FILE_APPEND) + ->then(fn () => $this->rotate()); + } + + /** + * Rotate the log file. + */ + protected function rotate(): void + { + $this->filesystem + ->file($this->path) + ->stat() + ->then(function (?Stat $stat) { + if (! $stat || $stat->size() < $this->maxSize) { + return; + } + + $promises = []; + + $oldest = "{$this->path}.{$this->maxFiles}"; + + $promises[] = $this->filesystem->detect($oldest) + ->then(function (NodeInterface $node) { + if (! ($node instanceof NotExistInterface)) { + return $node->unlink(); + } + }); + + for ($i = $this->maxFiles - 1; $i >= 1; $i--) { + $existing = "{$this->path}.{$i}"; + $latest = "{$this->path}.".($i + 1); + + $promises[] = $this->filesystem->detect($existing) + ->then(function (NodeInterface $node) use ($latest) { + if ($node instanceof NotExistInterface) { + return; + } + + return $node->getContents() + ->then(fn (string $contents) => $this->filesystem->detect($latest) + ->then(fn (NodeInterface $file) => $file instanceof NotExistInterface + ? $file->createFile() + : $file + ) + ->then(fn (FileInterface $file) => $file + ->putContents($contents) + ->then(fn () => $node->unlink()) + ) + ); + }); + } + + $promises[] = $this->filesystem->detect($this->path) + ->then(function (NodeInterface $node) { + if ($node instanceof NotExistInterface) { + return; + } + + $latest = "{$this->path}.1"; + + return $node->getContents() + ->then(fn (string $contents) => $this->filesystem->detect($latest) + ->then(fn (NodeInterface $file) => $file instanceof NotExistInterface + ? $file->createFile() + : $file + ) + ->then(fn (FileInterface $file) => $file + ->putContents($contents) + ->then(fn () => $node->unlink()) + ) + ); + }); + + return all($promises); + }) + ->then(fn () => $this->initializeStream()); + } + + /** + * {@inheritdoc} + */ + protected function write(LogRecord $record): void + { + $this->buffer[] = $record->formatted; + } + + /** + * Close the stream. + */ + public function close(): void + { + if ($this->flushTimer) { + Laracord::getLoop()->cancelTimer($this->flushTimer); + } + + $this->flush(); + } +} diff --git a/src/PackageManifest.php b/src/PackageManifest.php new file mode 100644 index 0000000..81f4f8a --- /dev/null +++ b/src/PackageManifest.php @@ -0,0 +1,50 @@ +files->exists($path = $this->vendorPath.'/composer/installed.json')) { + $installed = json_decode($this->files->get($path), true); + + $packages = $installed['packages'] ?? $installed; + } + + $ignoreAll = in_array('*', $ignore = $this->packagesToIgnore()); + + $this->write(collect($packages)->mapWithKeys(function ($package) { + return [$this->format($package['name']) => $package['extra']['laracord'] ?? []]; + })->each(function ($configuration) use (&$ignore) { + $ignore = array_merge($ignore, $configuration['dont-discover'] ?? []); + })->reject(function ($configuration, $package) use ($ignore, $ignoreAll) { + return $ignoreAll || in_array($package, $ignore); + })->filter()->all()); + } + + /** + * Get all of the package names that should be ignored. + * + * @return array + */ + protected function packagesToIgnore() + { + if (! is_file($this->basePath.'/composer.json')) { + return []; + } + + return json_decode(file_get_contents( + $this->basePath.'/composer.json' + ), true)['extra']['laracord']['dont-discover'] ?? []; + } +} diff --git a/src/Services/Contracts/Service.php b/src/Services/Contracts/Service.php deleted file mode 100644 index 1d03229..0000000 --- a/src/Services/Contracts/Service.php +++ /dev/null @@ -1,13 +0,0 @@ -bot = $bot; - $this->console = $bot->console(); - $this->discord = $bot->discord(); - } - - /** - * Make a new service instance. - */ - public static function make(Laracord $bot): self - { - return new static($bot); - } - - /** - * Handle the service. - * - * @return mixed - */ - abstract public function handle(); - - /** - * Boot the service. - */ - public function boot(): self - { - if ($this->getInterval() < 1) { - throw new InvalidServiceInterval($this->getName()); - } - - $this->bot->getLoop()->addPeriodicTimer( - $this->getInterval(), - fn () => $this->bot->handleSafe($this->getName(), fn () => $this->handle()) - ); - - return $this; - } - - /** - * Get the loop instance. - */ - public function getLoop() - { - return $this->bot()->getLoop(); - } - - /** - * Get the loop interval. - */ - public function getInterval(): int - { - return $this->interval; - } - - /** - * Set the loop interval. - */ - public function interval(int $interval): self - { - $this->interval = $interval; - - return $this; - } - - /** - * Get the service name. - */ - public function getName(): string - { - if ($this->name) { - return $this->name; - } - - return $this->name = class_basename(static::class); - } - - /** - * Determine if the service is enabled. - */ - public function isEnabled(): bool - { - return $this->enabled; - } - - /** - * Get the Discord client. - */ - public function discord(): Discord - { - return $this->discord; - } - - /** - * Get the bot instance. - */ - public function bot(): Laracord - { - return $this->bot; - } - - /** - * Get the console instance. - */ - public function console(): Console - { - return $this->console; - } - - /** - * Build an embed for use in a Discord message. - * - * @param string $content - * @return \Laracord\Discord\Message - */ - public function message($content = '') - { - return $this->bot()->message($content); - } + // } diff --git a/src/Tasks/Contracts/Task.php b/src/Tasks/Contracts/Task.php new file mode 100644 index 0000000..8cdfc1f --- /dev/null +++ b/src/Tasks/Contracts/Task.php @@ -0,0 +1,8 @@ +booted) { + return $this; + } + + if ($this->getInterval() < 1) { + throw new InvalidTaskInterval($this->getName()); + } + + $this->timer = $this->bot()->getLoop()->addPeriodicTimer( + $this->getInterval(), + fn () => $this->resolveHandler() + ); + + if ($this->eager) { + $this->bot->getLoop()->futureTick(fn () => $this->resolveHandler()); + } + + $this->bot()->logger->info("The {$this->getName()} task has been booted."); + + $this->booted = true; + + return $this; + } + + /** + * Get the loop instance. + */ + public function getLoop() + { + return $this->bot()->getLoop(); + } + + /** + * Get the loop interval. + */ + public function getInterval(): int + { + return $this->interval; + } + + /** + * Set the loop interval. + */ + public function interval(int $interval): self + { + $this->interval = $interval; + + return $this; + } + + /** + * Get the task name. + */ + public function getName(): string + { + if (filled($this->name)) { + return $this->name; + } + + return $this->name = class_basename(static::class); + } + + /** + * Determine if the task is enabled. + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * Determine if the task is booted. + */ + public function isBooted(): bool + { + return $this->booted; + } + + /** + * Stop the task. + */ + public function stop(): void + { + if (! $this->booted) { + return; + } + + $this->getLoop()->cancelTimer($this->timer); + + $this->bot()->logger->info("The {$this->getName()} task has been stopped."); + + $this->timer = null; + + $this->booted = false; + } +} diff --git a/src/helpers.php b/src/helpers.php index 7c830b6..94a8358 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -1,12 +1,22 @@