diff --git a/README.md b/README.md index abeb651..6b49c7d 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ class UserCommand { #[Subcommand(name: 'info', description: 'Get user information')] public function info( - #[Option(name: 'user', description: 'Target user', required: true)] + #[Option(description: 'Target user')] User $user ): void { // Handle user info command @@ -161,6 +161,17 @@ class UserCommand } ``` +Whether an option is required comes from whether its parameter has a default, so `$user` +above is required without saying so. + +## Documentation + +See [docs/](docs/README.md) — guides for getting started, commands, autocomplete, events and +translations, plus a reference generated from the source. + +Run `composer docs` after changing the public API; the test suite fails if the committed +documentation no longer matches the code. + ## Contributing We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to Tempcord. diff --git a/composer.json b/composer.json index 4a469a6..fd2f5c0 100644 --- a/composer.json +++ b/composer.json @@ -5,9 +5,11 @@ "type": "library", "require": { "php": "^8.5", - "tempest/console": "^3.18", "ragnarok/fenrir": "^1.0", - "tempest/core": "^3.18" + "tempest/console": "^3.18", + "tempest/core": "^3.18", + "tempest/event-bus": "^3.18", + "tempest/log": "^3.18" }, "license": "MIT", "authors": [ @@ -28,7 +30,8 @@ "scripts": { "test": "phpunit", "analyse": "phpstan analyse --memory-limit=1G", - "cs-fix": "php-cs-fixer fix" + "cs-fix": "php-cs-fixer fix", + "docs": "php tools/generate-docs.php" }, "config": { "sort-packages": true, @@ -47,7 +50,11 @@ }, "autoload-dev": { "psr-4": { - "Tempcord\\Tests\\": "tests/" + "Tempcord\\Tests\\": "tests/", + "Tempcord\\Tools\\": "tools/src/" } + }, + "suggest": { + "tempest/intl": "Enables name and description translations for commands via #[Command(translationKey: ...)]. Requires ext-intl." } } diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..dd24d74 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,29 @@ + + +# Tempcord documentation + +Build Discord bots with PHP, on top of [Tempest](https://tempestphp.com). + +## Guides + +- [Getting started](guides/01-getting-started.md) +- [Commands and options](guides/02-commands.md) +- [Autocomplete](guides/03-autocomplete.md) +- [Events](guides/04-events.md) +- [Translations](guides/05-translations.md) +- [Plugins](guides/06-plugins.md) + +## Reference + +Generated from the source, so it describes what the framework actually does. + +**Attributes** — [Command](reference/attributes/command.md), [SubcommandGroup](reference/attributes/subcommand-group.md), [Subcommand](reference/attributes/subcommand.md), [Option](reference/attributes/option.md), [Event](reference/attributes/event.md) + +**Autocomplete** — [Autocomplete](reference/autocomplete/autocomplete.md), [ArrayAutocomplete](reference/autocomplete/array-autocomplete.md) + +**Configuration** — [TempcordConfig](reference/configuration/tempcord-config.md) + +**Enums** — [DiscordLocale](reference/enums/discord-locale.md) + +**Plugins** — [Plugin](reference/plugins/plugin.md) + diff --git a/docs/api-reference.md b/docs/api-reference.md deleted file mode 100644 index 776c045..0000000 --- a/docs/api-reference.md +++ /dev/null @@ -1,833 +0,0 @@ -# API Reference - -This document provides a comprehensive reference for Tempcord's API classes, methods, and interfaces. - -## Core Classes - -### Tempcord\Discord\Interaction - -Represents a Discord interaction (slash command, button click, etc.). - -#### Properties - -```php -public readonly string $id; // Interaction ID -public readonly string $applicationId; // Application ID -public readonly int $type; // Interaction type -public readonly ?string $token; // Interaction token -public readonly int $version; // Version -public readonly ?string $commandName; // Command name -public readonly ?string $commandId; // Command ID -public readonly array $options; // Command options -public readonly User $user; // User who triggered -public readonly ?Member $member; // Guild member (if in guild) -public readonly ?Guild $guild; // Guild (if in guild) -public readonly Channel $channel; // Channel -public readonly ?Message $message; // Message (for components) -public readonly string $locale; // User locale -public readonly ?string $guildLocale; // Guild locale -``` - -#### Methods - -```php -// Response methods -public function reply(string $content, bool $ephemeral = false): void -public function editReply(string $content): void -public function deleteReply(): void -public function followUp(string $content, bool $ephemeral = false): void - -// Deferred responses -public function defer(bool $ephemeral = false): void -public function deferUpdate(): void - -// Modal responses -public function showModal(Modal $modal): void - -// Component responses -public function updateMessage(string $content, array $components = []): void - -// Option helpers -public function getOption(string $name, mixed $default = null): mixed -public function getStringOption(string $name, ?string $default = null): ?string -public function getIntegerOption(string $name, ?int $default = null): ?int -public function getBooleanOption(string $name, ?bool $default = null): ?bool -public function getUserOption(string $name): ?User -public function getChannelOption(string $name): ?Channel -public function getRoleOption(string $name): ?Role -public function getMentionableOption(string $name): User|Member|Role|null -public function getAttachmentOption(string $name): ?Attachment - -// Context methods -public function setContext(string $key, mixed $value): void -public function getContext(string $key, mixed $default = null): mixed -public function hasContext(string $key): bool - -// Permission checks -public function hasPermission(Permission $permission): bool -public function hasRole(string $roleId): bool -public function isOwner(): bool -public function isAdmin(): bool -``` - -### Tempcord\Discord\User - -Represents a Discord user. - -#### Properties - -```php -public readonly string $id; // User ID -public readonly string $username; // Username -public readonly string $discriminator; // Discriminator (legacy) -public readonly ?string $globalName; // Global display name -public readonly ?string $avatar; // Avatar hash -public readonly ?bool $bot; // Is bot -public readonly ?bool $system; // Is system user -public readonly ?bool $mfaEnabled; // MFA enabled -public readonly ?string $banner; // Banner hash -public readonly ?int $accentColor; // Accent color -public readonly ?string $locale; // User locale -public readonly ?bool $verified; // Email verified -public readonly ?string $email; // Email address -public readonly ?int $flags; // User flags -public readonly ?int $premiumType; // Nitro type -public readonly ?int $publicFlags; // Public flags -``` - -#### Methods - -```php -// Display methods -public function getDisplayName(): string // Get display name -public function getTag(): string // Get user#discriminator -public function getMention(): string // Get <@id> mention -public function getAvatarUrl(?int $size = null): ?string // Get avatar URL -public function getBannerUrl(?int $size = null): ?string // Get banner URL - -// Utility methods -public function isBot(): bool // Check if bot -public function hasNitro(): bool // Check if has Nitro -public function getCreatedAt(): DateTime // Get account creation date -``` - -### Tempcord\Discord\Guild - -Represents a Discord guild (server). - -#### Properties - -```php -public readonly string $id; // Guild ID -public readonly string $name; // Guild name -public readonly ?string $icon; // Icon hash -public readonly ?string $iconHash; // Icon hash (legacy) -public readonly ?string $splash; // Splash hash -public readonly ?string $discoverySplash; // Discovery splash -public readonly ?bool $owner; // Is owner -public readonly string $ownerId; // Owner ID -public readonly ?string $permissions; // Permissions -public readonly ?string $region; // Voice region (deprecated) -public readonly ?string $afkChannelId; // AFK channel ID -public readonly int $afkTimeout; // AFK timeout -public readonly ?bool $widgetEnabled; // Widget enabled -public readonly ?string $widgetChannelId; // Widget channel ID -public readonly int $verificationLevel; // Verification level -public readonly int $defaultMessageNotifications; // Default notifications -public readonly int $explicitContentFilter; // Content filter level -public readonly array $roles; // Guild roles -public readonly array $emojis; // Guild emojis -public readonly array $features; // Guild features -public readonly int $mfaLevel; // MFA level -public readonly ?string $applicationId; // Application ID -public readonly ?string $systemChannelId; // System channel ID -public readonly int $systemChannelFlags; // System channel flags -public readonly ?string $rulesChannelId; // Rules channel ID -public readonly ?int $maxPresences; // Max presences -public readonly ?int $maxMembers; // Max members -public readonly ?string $vanityUrlCode; // Vanity URL -public readonly ?string $description; // Description -public readonly ?string $banner; // Banner hash -public readonly int $premiumTier; // Boost tier -public readonly ?int $premiumSubscriptionCount; // Boost count -public readonly string $preferredLocale; // Preferred locale -public readonly ?string $publicUpdatesChannelId; // Updates channel -public readonly ?int $maxVideoChannelUsers; // Max video users -public readonly ?int $approximateMemberCount; // Approx members -public readonly ?int $approximatePresenceCount; // Approx online -``` - -#### Methods - -```php -// Display methods -public function getIconUrl(?int $size = null): ?string // Get icon URL -public function getSplashUrl(?int $size = null): ?string // Get splash URL -public function getBannerUrl(?int $size = null): ?string // Get banner URL - -// Member methods -public function getMember(string $userId): ?Member // Get member -public function getMembers(): array // Get all members -public function getMemberCount(): int // Get member count - -// Channel methods -public function getChannel(string $channelId): ?Channel // Get channel -public function getChannels(): array // Get all channels -public function getTextChannels(): array // Get text channels -public function getVoiceChannels(): array // Get voice channels - -// Role methods -public function getRole(string $roleId): ?Role // Get role -public function getRoles(): array // Get all roles -public function getEveryoneRole(): Role // Get @everyone role - -// Permission methods -public function getMemberPermissions(string $userId): array // Get permissions -public function hasFeature(string $feature): bool // Check feature - -// Utility methods -public function getCreatedAt(): DateTime // Get creation date -public function isOwner(string $userId): bool // Check if user is owner -``` - -### Tempcord\Discord\Channel - -Represents a Discord channel. - -#### Properties - -```php -public readonly string $id; // Channel ID -public readonly int $type; // Channel type -public readonly ?string $guildId; // Guild ID -public readonly ?int $position; // Position -public readonly ?array $permissionOverwrites; // Permission overwrites -public readonly ?string $name; // Channel name -public readonly ?string $topic; // Channel topic -public readonly ?bool $nsfw; // Is NSFW -public readonly ?string $lastMessageId; // Last message ID -public readonly ?int $bitrate; // Voice bitrate -public readonly ?int $userLimit; // Voice user limit -public readonly ?int $rateLimitPerUser; // Rate limit -public readonly ?array $recipients; // DM recipients -public readonly ?string $icon; // Channel icon -public readonly ?string $ownerId; // Channel owner -public readonly ?string $applicationId; // Application ID -public readonly ?string $parentId; // Parent category ID -public readonly ?DateTime $lastPinTimestamp; // Last pin timestamp -public readonly ?string $rtcRegion; // RTC region -public readonly ?int $videoQualityMode; // Video quality -public readonly ?int $messageCount; // Message count -public readonly ?int $memberCount; // Member count -public readonly ?int $defaultAutoArchiveDuration; // Auto archive -public readonly ?string $permissions; // Permissions -public readonly ?int $flags; // Channel flags -``` - -#### Methods - -```php -// Message methods -public function sendMessage(string $content, array $options = []): Message -public function getMessage(string $messageId): ?Message -public function getMessages(int $limit = 50): array -public function deleteMessage(string $messageId): void -public function editMessage(string $messageId, string $content): Message - -// Permission methods -public function hasPermission(string $userId, Permission $permission): bool -public function getPermissions(string $userId): array - -// Utility methods -public function getMention(): string // Get <#id> mention -public function isText(): bool // Is text channel -public function isVoice(): bool // Is voice channel -public function isCategory(): bool // Is category -public function isDM(): bool // Is DM channel -public function isThread(): bool // Is thread -public function getCreatedAt(): DateTime // Get creation date -``` - -## Attribute Classes - -### Tempcord\Attributes\Command - -Defines a slash command. - -```php -#[Command( - name: 'hello', - description: 'Say hello to someone', - defaultMemberPermissions: Permission::SEND_MESSAGES, - dmPermission: true, - nsfw: false -)] -``` - -#### Parameters - -- `name` (string): Command name (required) -- `description` (string): Command description (required) -- `defaultMemberPermissions` (Permission|null): Default permissions -- `dmPermission` (bool): Allow in DMs -- `nsfw` (bool): NSFW command - -### Tempcord\Attributes\Option - -Defines a command option. - -```php -#[Option( - name: 'user', - description: 'The user to greet', - type: OptionType::USER, - required: true, - autocomplete: false -)] -``` - -#### Parameters - -- `name` (string): Option name (required) -- `description` (string): Option description (required) -- `type` (OptionType): Option type (required) -- `required` (bool): Is required -- `autocomplete` (bool): Enable autocomplete -- `choices` (array): Predefined choices -- `minValue` (int|float): Minimum value -- `maxValue` (int|float): Maximum value -- `minLength` (int): Minimum string length -- `maxLength` (int): Maximum string length -- `channelTypes` (array): Allowed channel types - -### Tempcord\Attributes\EventListener - -Defines an event listener. - -```php -#[EventListener( - event: MessageCreateEvent::class, - priority: 100, - once: false -)] -``` - -#### Parameters - -- `event` (string): Event class (required) -- `priority` (int): Listener priority -- `once` (bool): Run only once - -### Tempcord\Attributes\Middleware - -Defines middleware. - -```php -#[Middleware( - priority: 100 -)] -``` - -#### Parameters - -- `priority` (int): Middleware priority - -### Tempcord\Attributes\UseMiddleware - -Applies middleware to commands. - -```php -#[UseMiddleware([ - AuthMiddleware::class, - RateLimitMiddleware::class -])] -``` - -#### Parameters - -- `middleware` (array): Array of middleware classes - -## Enums - -### Tempcord\Enums\OptionType - -Command option types. - -```php -OptionType::SUB_COMMAND // 1 -OptionType::SUB_COMMAND_GROUP // 2 -OptionType::STRING // 3 -OptionType::INTEGER // 4 -OptionType::BOOLEAN // 5 -OptionType::USER // 6 -OptionType::CHANNEL // 7 -OptionType::ROLE // 8 -OptionType::MENTIONABLE // 9 -OptionType::NUMBER // 10 -OptionType::ATTACHMENT // 11 -``` - -### Tempcord\Enums\Permission - -Discord permissions. - -```php -Permission::CREATE_INSTANT_INVITE // 1 << 0 -Permission::KICK_MEMBERS // 1 << 1 -Permission::BAN_MEMBERS // 1 << 2 -Permission::ADMINISTRATOR // 1 << 3 -Permission::MANAGE_CHANNELS // 1 << 4 -Permission::MANAGE_GUILD // 1 << 5 -Permission::ADD_REACTIONS // 1 << 6 -Permission::VIEW_AUDIT_LOG // 1 << 7 -Permission::PRIORITY_SPEAKER // 1 << 8 -Permission::STREAM // 1 << 9 -Permission::VIEW_CHANNEL // 1 << 10 -Permission::SEND_MESSAGES // 1 << 11 -Permission::SEND_TTS_MESSAGES // 1 << 12 -Permission::MANAGE_MESSAGES // 1 << 13 -Permission::EMBED_LINKS // 1 << 14 -Permission::ATTACH_FILES // 1 << 15 -Permission::READ_MESSAGE_HISTORY // 1 << 16 -Permission::MENTION_EVERYONE // 1 << 17 -Permission::USE_EXTERNAL_EMOJIS // 1 << 18 -Permission::VIEW_GUILD_INSIGHTS // 1 << 19 -Permission::CONNECT // 1 << 20 -Permission::SPEAK // 1 << 21 -Permission::MUTE_MEMBERS // 1 << 22 -Permission::DEAFEN_MEMBERS // 1 << 23 -Permission::MOVE_MEMBERS // 1 << 24 -Permission::USE_VAD // 1 << 25 -Permission::CHANGE_NICKNAME // 1 << 26 -Permission::MANAGE_NICKNAMES // 1 << 27 -Permission::MANAGE_ROLES // 1 << 28 -Permission::MANAGE_WEBHOOKS // 1 << 29 -Permission::MANAGE_EMOJIS_AND_STICKERS // 1 << 30 -Permission::USE_APPLICATION_COMMANDS // 1 << 31 -Permission::REQUEST_TO_SPEAK // 1 << 32 -Permission::MANAGE_EVENTS // 1 << 33 -Permission::MANAGE_THREADS // 1 << 34 -Permission::CREATE_PUBLIC_THREADS // 1 << 35 -Permission::CREATE_PRIVATE_THREADS // 1 << 36 -Permission::USE_EXTERNAL_STICKERS // 1 << 37 -Permission::SEND_MESSAGES_IN_THREADS // 1 << 38 -Permission::USE_EMBEDDED_ACTIVITIES // 1 << 39 -Permission::MODERATE_MEMBERS // 1 << 40 -``` - -### Tempcord\Enums\InteractionType - -Interaction types. - -```php -InteractionType::PING // 1 -InteractionType::APPLICATION_COMMAND // 2 -InteractionType::MESSAGE_COMPONENT // 3 -InteractionType::APPLICATION_COMMAND_AUTOCOMPLETE // 4 -InteractionType::MODAL_SUBMIT // 5 -``` - -### Tempcord\Enums\ChannelType - -Channel types. - -```php -ChannelType::GUILD_TEXT // 0 -ChannelType::DM // 1 -ChannelType::GUILD_VOICE // 2 -ChannelType::GROUP_DM // 3 -ChannelType::GUILD_CATEGORY // 4 -ChannelType::GUILD_NEWS // 5 -ChannelType::GUILD_STORE // 6 -ChannelType::GUILD_NEWS_THREAD // 10 -ChannelType::GUILD_PUBLIC_THREAD // 11 -ChannelType::GUILD_PRIVATE_THREAD // 12 -ChannelType::GUILD_STAGE_VOICE // 13 -ChannelType::GUILD_DIRECTORY // 14 -ChannelType::GUILD_FORUM // 15 -``` - -## Event Classes - -### Tempcord\Events\MessageCreateEvent - -Fired when a message is created. - -```php -public readonly Message $message; -public readonly ?Guild $guild; -public readonly Channel $channel; -public readonly User $author; -``` - -### Tempcord\Events\MessageUpdateEvent - -Fired when a message is updated. - -```php -public readonly Message $message; -public readonly ?Message $oldMessage; -public readonly ?Guild $guild; -public readonly Channel $channel; -``` - -### Tempcord\Events\MessageDeleteEvent - -Fired when a message is deleted. - -```php -public readonly string $messageId; -public readonly string $channelId; -public readonly ?string $guildId; -``` - -### Tempcord\Events\GuildCreateEvent - -Fired when the bot joins a guild. - -```php -public readonly Guild $guild; -public readonly bool $unavailable; -``` - -### Tempcord\Events\GuildUpdateEvent - -Fired when a guild is updated. - -```php -public readonly Guild $guild; -public readonly ?Guild $oldGuild; -``` - -### Tempcord\Events\GuildDeleteEvent - -Fired when the bot leaves a guild. - -```php -public readonly string $guildId; -public readonly bool $unavailable; -``` - -### Tempcord\Events\GuildMemberAddEvent - -Fired when a member joins a guild. - -```php -public readonly Member $member; -public readonly Guild $guild; -``` - -### Tempcord\Events\GuildMemberUpdateEvent - -Fired when a member is updated. - -```php -public readonly Member $member; -public readonly ?Member $oldMember; -public readonly Guild $guild; -``` - -### Tempcord\Events\GuildMemberRemoveEvent - -Fired when a member leaves a guild. - -```php -public readonly User $user; -public readonly string $guildId; -``` - -## Interface Classes - -### Tempcord\Middleware\MiddlewareInterface - -Interface for middleware classes. - -```php -interface MiddlewareInterface -{ - public function handle(Interaction $interaction, callable $next): mixed; -} -``` - -### Tempcord\Events\EventListenerInterface - -Interface for event listeners. - -```php -interface EventListenerInterface -{ - public function handle(object $event): void; -} -``` - -## Utility Classes - -### Tempcord\Utils\EmbedBuilder - -Builder for Discord embeds. - -```php -$embed = EmbedBuilder::create() - ->setTitle('Hello World') - ->setDescription('This is an embed') - ->setColor(0x00ff00) - ->addField('Field 1', 'Value 1', true) - ->addField('Field 2', 'Value 2', true) - ->setFooter('Footer text', 'https://example.com/icon.png') - ->setTimestamp() - ->build(); -``` - -#### Methods - -```php -public static function create(): self -public function setTitle(?string $title): self -public function setDescription(?string $description): self -public function setUrl(?string $url): self -public function setTimestamp(?DateTime $timestamp = null): self -public function setColor(?int $color): self -public function setFooter(?string $text, ?string $iconUrl = null): self -public function setImage(?string $url): self -public function setThumbnail(?string $url): self -public function setAuthor(?string $name, ?string $url = null, ?string $iconUrl = null): self -public function addField(string $name, string $value, bool $inline = false): self -public function setFields(array $fields): self -public function build(): array -``` - -### Tempcord\Utils\ComponentBuilder - -Builder for Discord components. - -```php -$components = ComponentBuilder::create() - ->addButton('primary', 'click_me', 'Click Me!') - ->addButton('secondary', 'cancel', 'Cancel') - ->addSelectMenu('select_option', 'Choose an option', [ - ['label' => 'Option 1', 'value' => 'opt1'], - ['label' => 'Option 2', 'value' => 'opt2'], - ]) - ->build(); -``` - -#### Methods - -```php -public static function create(): self -public function addButton(string $style, string $customId, string $label, ?string $emoji = null, bool $disabled = false): self -public function addLinkButton(string $url, string $label, ?string $emoji = null, bool $disabled = false): self -public function addSelectMenu(string $customId, string $placeholder, array $options, int $minValues = 1, int $maxValues = 1): self -public function addTextInput(string $customId, string $label, string $style = 'short', bool $required = true): self -public function newRow(): self -public function build(): array -``` - -### Tempcord\Utils\PermissionCalculator - -Utility for permission calculations. - -```php -$calculator = new PermissionCalculator(); - -// Check if user has permission -$hasPermission = $calculator->hasPermission($member, Permission::MANAGE_MESSAGES); - -// Calculate effective permissions -$permissions = $calculator->calculatePermissions($member, $channel); - -// Check multiple permissions -$hasAll = $calculator->hasAllPermissions($member, [ - Permission::SEND_MESSAGES, - Permission::EMBED_LINKS, -]); -``` - -#### Methods - -```php -public function hasPermission(Member $member, Permission $permission, ?Channel $channel = null): bool -public function hasAllPermissions(Member $member, array $permissions, ?Channel $channel = null): bool -public function hasAnyPermission(Member $member, array $permissions, ?Channel $channel = null): bool -public function calculatePermissions(Member $member, ?Channel $channel = null): array -public function isAdmin(Member $member): bool -``` - -## Helper Functions - -### Global Helpers - -```php -// Configuration -config(string $key, mixed $default = null): mixed - -// Logging -logger(): LoggerInterface - -// Cache -cache(): CacheInterface - -// Queue -queue(): QueueInterface - -// Application paths -app_path(string $path = ''): string -base_path(string $path = ''): string -config_path(string $path = ''): string -storage_path(string $path = ''): string -database_path(string $path = ''): string - -// Environment -env(string $key, mixed $default = null): mixed - -// Time -now(): DateTime -today(): DateTime - -// Strings -str_random(int $length = 16): string -str_slug(string $title, string $separator = '-'): string - -// Arrays -array_get(array $array, string $key, mixed $default = null): mixed -array_set(array &$array, string $key, mixed $value): void -array_forget(array &$array, string $key): void - -// Validation -validate(array $data, array $rules): array - -// Response helpers -response(): ResponseFactory -redirect(string $to = null): RedirectResponse - -// Discord helpers -discord(): DiscordClient -bot(): BotInstance -``` - -## Error Classes - -### Tempcord\Exceptions\TempcordException - -Base exception class. - -### Tempcord\Exceptions\CommandException - -Thrown when command execution fails. - -### Tempcord\Exceptions\ValidationException - -Thrown when validation fails. - -### Tempcord\Exceptions\PermissionException - -Thrown when permission check fails. - -### Tempcord\Exceptions\RateLimitException - -Thrown when rate limit is exceeded. - -### Tempcord\Exceptions\ConfigurationException - -Thrown when configuration is invalid. - -## Constants - -### Version Information - -```php -Tempcord::VERSION // Current version -Tempcord::DISCORD_API_VERSION // Discord API version -Tempcord::USER_AGENT // HTTP User-Agent -``` - -### Limits - -```php -Tempcord::MAX_EMBED_TITLE_LENGTH // 256 -Tempcord::MAX_EMBED_DESCRIPTION_LENGTH // 4096 -Tempcord::MAX_EMBED_FIELD_NAME_LENGTH // 256 -Tempcord::MAX_EMBED_FIELD_VALUE_LENGTH // 1024 -Tempcord::MAX_EMBED_FOOTER_LENGTH // 2048 -Tempcord::MAX_EMBED_AUTHOR_LENGTH // 256 -Tempcord::MAX_EMBED_FIELDS // 25 -Tempcord::MAX_EMBED_TOTAL_LENGTH // 6000 - -Tempcord::MAX_MESSAGE_LENGTH // 2000 -Tempcord::MAX_COMMAND_NAME_LENGTH // 32 -Tempcord::MAX_COMMAND_DESCRIPTION_LENGTH // 100 -Tempcord::MAX_OPTION_NAME_LENGTH // 32 -Tempcord::MAX_OPTION_DESCRIPTION_LENGTH // 100 -Tempcord::MAX_CHOICE_NAME_LENGTH // 100 -Tempcord::MAX_CHOICE_VALUE_LENGTH // 100 - -Tempcord::MAX_COMPONENTS_PER_ROW // 5 -Tempcord::MAX_ROWS_PER_MESSAGE // 5 -Tempcord::MAX_SELECT_OPTIONS // 25 -Tempcord::MAX_BUTTON_LABEL_LENGTH // 80 -Tempcord::MAX_SELECT_PLACEHOLDER_LENGTH // 150 -``` - -## Type Definitions - -### Command Handler - -```php -type CommandHandler = callable(Interaction): void -``` - -### Event Handler - -```php -type EventHandler = callable(object): void -``` - -### Middleware Handler - -```php -type MiddlewareHandler = callable(Interaction, callable): mixed -``` - -### Option Choice - -```php -type OptionChoice = [ - 'name' => string, - 'value' => string|int|float, - 'name_localizations' => ?array, -] -``` - -### Embed Field - -```php -type EmbedField = [ - 'name' => string, - 'value' => string, - 'inline' => ?bool, -] -``` - -### Component - -```php -type Component = [ - 'type' => int, - 'custom_id' => ?string, - 'disabled' => ?bool, - 'style' => ?int, - 'label' => ?string, - 'emoji' => ?array, - 'url' => ?string, - 'options' => ?array, - 'placeholder' => ?string, - 'min_values' => ?int, - 'max_values' => ?int, - 'min_length' => ?int, - 'max_length' => ?int, - 'required' => ?bool, - 'value' => ?string, -] -``` - -This API reference provides comprehensive documentation for all public classes, methods, and interfaces in Tempcord. For more detailed examples and usage patterns, refer to the other documentation files and the examples directory. \ No newline at end of file diff --git a/docs/commands.md b/docs/commands.md deleted file mode 100644 index f8c9e6d..0000000 --- a/docs/commands.md +++ /dev/null @@ -1,427 +0,0 @@ -# Commands - -Commands are the primary way users interact with your Discord bot. Tempcord provides a powerful and flexible command system that supports both slash commands and traditional text commands. - -## Basic Commands - -### Creating a Command - -Create a command by defining a class with the `#[Command]` attribute: - -```php -reply('Hello there! 👋'); - } -} -``` - -### Command Attributes - -- `#[Command('name')]` - Defines the command name -- `#[Description('text')]` - Provides a description for the command -- `#[Option()]` - Defines command options/parameters - -## Command Options - -Add parameters to your commands using the `#[Option]` attribute: - -```php -getOption('user'); - $message = $interaction->getOption('message') ?? 'Hello'; - - $interaction->reply("{$message}, {$user->mention()}!"); - } -} -``` - -### Option Types - -- `OptionType::STRING` - Text input -- `OptionType::INTEGER` - Whole numbers -- `OptionType::NUMBER` - Decimal numbers -- `OptionType::BOOLEAN` - True/false values -- `OptionType::USER` - Discord user -- `OptionType::CHANNEL` - Discord channel -- `OptionType::ROLE` - Discord role -- `OptionType::MENTIONABLE` - User, role, or channel -- `OptionType::ATTACHMENT` - File attachment - -### Option Parameters - -```php -#[Option( - name: 'option_name', - type: OptionType::STRING, - description: 'Option description', - required: true, - choices: ['choice1', 'choice2'], - minValue: 1, - maxValue: 100, - minLength: 1, - maxLength: 50 -)] -``` - -## Command Groups - -Organize related commands into groups: - -```php -getOption('key'); - $value = $interaction->getOption('value'); - - // Set configuration logic - $interaction->reply("Set {$key} to {$value}"); - } - - #[Subcommand('get')] - #[Description('Get a configuration value')] - #[Option('key', OptionType::STRING, 'Configuration key', required: true)] - public function get(Interaction $interaction): void - { - $key = $interaction->getOption('key'); - - // Get configuration logic - $interaction->reply("Value for {$key}: ..."); - } -} -``` - -## Response Types - -### Basic Responses - -```php -// Simple text response -$interaction->reply('Hello!'); - -// Ephemeral response (only visible to the user) -$interaction->reply('Secret message', ephemeral: true); - -// Deferred response (for long-running operations) -$interaction->defer(); -// ... do work ... -$interaction->followUp('Operation completed!'); -``` - -### Rich Responses - -```php -use Tempcord\Discord\Embed; -use Tempcord\Discord\Button; -use Tempcord\Discord\ActionRow; - -// Embed response -$embed = new Embed( - title: 'Command Result', - description: 'This is an embedded response', - color: 0x00ff00 -); - -$interaction->reply(embeds: [$embed]); - -// Response with buttons -$button = new Button( - style: ButtonStyle::PRIMARY, - label: 'Click me!', - customId: 'my_button' -); - -$actionRow = new ActionRow([$button]); - -$interaction->reply( - content: 'Choose an option:', - components: [$actionRow] -); -``` - -## Command Validation - -### Built-in Validation - -Tempcord automatically validates: -- Required options -- Option types -- Min/max values and lengths -- Choice constraints - -### Custom Validation - -```php -getOption('age'); - - if ($age < 13) { - throw new ValidationException('You must be at least 13 years old.'); - } - - if ($age > 120) { - throw new ValidationException('Please enter a realistic age.'); - } - - $interaction->reply("Your age has been set to {$age}."); - } -} -``` - -## Error Handling - -```php -performRiskyOperation(); - $interaction->reply('Operation successful!'); - } catch (Exception $e) { - $interaction->reply( - 'An error occurred: ' . $e->getMessage(), - ephemeral: true - ); - } - } - - private function performRiskyOperation(): void - { - // Implementation - } -} -``` - -## Command Permissions - -### Discord Permissions - -```php -isAdmin($interaction->user)) { - $interaction->reply('You do not have permission to use this command.', ephemeral: true); - return; - } - - // Admin-only logic - } - - private function isAdmin($user): bool - { - // Custom admin check logic - return in_array($user->id, config('admin_users')); - } -} -``` - -## Command Registration - -### Automatic Registration - -By default, Tempcord automatically discovers and registers commands in the `App\Commands` namespace. - -### Manual Registration - -```php -// In your bootstrap file -use Tempcord\CommandRegistry; -use App\Commands\MyCommand; - -$registry = new CommandRegistry(); -$registry->register(MyCommand::class); -``` - -### Conditional Registration - -```php - [ - AdminOnlyMiddleware::class, - AuditLogMiddleware::class, - ], - ], - - // Bot owners (can bypass certain restrictions) - owners: explode(',', env('BOT_OWNERS', '')), - - // Guild-specific settings - guilds: [ - 'default' => [ - 'prefix' => '!', - 'features' => ['commands', 'events'], - ], - ], - - // Cache configuration - cache: [ - 'driver' => env('CACHE_DRIVER', 'file'), - 'ttl' => 3600, - ], - - // Logging configuration - logging: [ - 'level' => env('LOG_LEVEL', 'info'), - 'channels' => ['single', 'discord'], - ], -); -``` - -## Environment Variables - -Create a `.env` file in your project root: - -```env -# Discord Configuration -DISCORD_TOKEN=your_bot_token_here -DISCORD_APPLICATION_ID=your_application_id_here -DISCORD_PUBLIC_KEY=your_public_key_here - -# Bot Configuration -BOT_OWNERS=123456789012345678,987654321098765432 -BOT_PREFIX=! - -# Environment -APP_ENV=local -APP_DEBUG=true -APP_URL=http://localhost - -# Database -DB_CONNECTION=mysql -DB_HOST=127.0.0.1 -DB_PORT=3306 -DB_DATABASE=tempcord -DB_USERNAME=root -DB_PASSWORD= - -# Cache -CACHE_DRIVER=redis -REDIS_HOST=127.0.0.1 -REDIS_PASSWORD=null -REDIS_PORT=6379 - -# Logging -LOG_CHANNEL=stack -LOG_LEVEL=debug - -# Queue -QUEUE_CONNECTION=redis - -# Session -SESSION_DRIVER=redis -SESSION_LIFETIME=120 -``` - -## Discord Application Setup - -### 1. Create Discord Application - -1. Go to [Discord Developer Portal](https://discord.com/developers/applications) -2. Click "New Application" -3. Give your application a name -4. Navigate to the "Bot" section -5. Click "Add Bot" -6. Copy the bot token to your `.env` file - -### 2. Configure Bot Permissions - -In the "Bot" section, configure: - -- **Privileged Gateway Intents** (if needed): - - Presence Intent - - Server Members Intent - - Message Content Intent - -- **Bot Permissions**: - - Send Messages - - Use Slash Commands - - Read Message History - - Add Reactions - - Embed Links - - Attach Files - -### 3. OAuth2 Setup - -In the "OAuth2" section: - -1. Select "bot" and "applications.commands" scopes -2. Select required permissions -3. Use the generated URL to invite your bot - -## Configuration Options - -### Core Settings - -```php -return new TempcordConfig( - // Required Discord credentials - token: env('DISCORD_TOKEN'), - applicationId: env('DISCORD_APPLICATION_ID'), - publicKey: env('DISCORD_PUBLIC_KEY'), - - // Optional settings - debug: env('APP_DEBUG', false), - environment: env('APP_ENV', 'production'), - timezone: env('APP_TIMEZONE', 'UTC'), -); -``` - -### Command Configuration - -```php -return new TempcordConfig( - // Command discovery paths - commandPaths: [ - app_path('Commands'), - app_path('Modules/*/Commands'), - ], - - // Command registration settings - commandRegistration: [ - 'auto_register' => true, - 'global_commands' => true, - 'guild_commands' => [], - 'delete_missing' => false, - ], - - // Command defaults - commandDefaults: [ - 'ephemeral' => false, - 'defer' => false, - 'timeout' => 30, - ], -); -``` - -### Event Configuration - -```php -return new TempcordConfig( - // Event listener discovery paths - eventPaths: [ - app_path('Events'), - app_path('Modules/*/Events'), - ], - - // Event settings - events: [ - 'auto_discover' => true, - 'async_processing' => true, - 'max_listeners' => 100, - ], - - // Gateway intents - intents: [ - 'guilds', - 'guild_messages', - 'guild_message_reactions', - 'direct_messages', - ], -); -``` - -### Middleware Configuration - -```php -return new TempcordConfig( - // Global middleware (applied to all commands) - globalMiddleware: [ - LoggingMiddleware::class, - RateLimitMiddleware::class, - MaintenanceModeMiddleware::class, - ], - - // Middleware groups - middlewareGroups: [ - 'admin' => [ - AdminOnlyMiddleware::class, - AuditLogMiddleware::class, - ], - 'public' => [ - CooldownMiddleware::class, - ValidationMiddleware::class, - ], - ], - - // Middleware settings - middleware: [ - 'auto_discover' => true, - 'priority_sorting' => true, - ], -); -``` - -### Database Configuration - -```php -return new TempcordConfig( - // Database settings - database: [ - 'connection' => env('DB_CONNECTION', 'mysql'), - 'migrations_path' => database_path('migrations'), - 'auto_migrate' => env('AUTO_MIGRATE', false), - ], - - // Model settings - models: [ - 'auto_discover' => true, - 'paths' => [ - app_path('Models'), - ], - ], -); -``` - -### Cache Configuration - -```php -return new TempcordConfig( - // Cache settings - cache: [ - 'driver' => env('CACHE_DRIVER', 'file'), - 'prefix' => env('CACHE_PREFIX', 'tempcord'), - 'ttl' => 3600, - - // Driver-specific settings - 'redis' => [ - 'host' => env('REDIS_HOST', '127.0.0.1'), - 'port' => env('REDIS_PORT', 6379), - 'password' => env('REDIS_PASSWORD'), - 'database' => env('REDIS_DB', 0), - ], - - 'file' => [ - 'path' => storage_path('cache'), - ], - ], -); -``` - -### Logging Configuration - -```php -return new TempcordConfig( - // Logging settings - logging: [ - 'default' => env('LOG_CHANNEL', 'stack'), - 'level' => env('LOG_LEVEL', 'info'), - - 'channels' => [ - 'stack' => [ - 'driver' => 'stack', - 'channels' => ['single', 'discord'], - ], - - 'single' => [ - 'driver' => 'single', - 'path' => storage_path('logs/tempcord.log'), - 'level' => 'debug', - ], - - 'discord' => [ - 'driver' => 'discord', - 'webhook_url' => env('DISCORD_LOG_WEBHOOK'), - 'level' => 'error', - ], - ], - ], -); -``` - -### Queue Configuration - -```php -return new TempcordConfig( - // Queue settings - queue: [ - 'default' => env('QUEUE_CONNECTION', 'sync'), - - 'connections' => [ - 'sync' => [ - 'driver' => 'sync', - ], - - 'redis' => [ - 'driver' => 'redis', - 'connection' => 'default', - 'queue' => 'default', - 'retry_after' => 90, - ], - ], - ], -); -``` - -## Guild-Specific Configuration - -```php -return new TempcordConfig( - // Guild-specific settings - guilds: [ - // Default settings for all guilds - 'default' => [ - 'prefix' => '!', - 'features' => ['commands', 'events', 'moderation'], - 'permissions' => [ - 'admin_roles' => [], - 'mod_roles' => [], - 'banned_users' => [], - ], - ], - - // Specific guild overrides - '123456789012345678' => [ - 'prefix' => '?', - 'features' => ['commands', 'events'], - 'disabled_commands' => ['admin', 'moderation'], - ], - - '987654321098765432' => [ - 'prefix' => '!', - 'features' => ['commands'], - 'custom_settings' => [ - 'welcome_channel' => '123456789012345678', - 'log_channel' => '987654321098765432', - ], - ], - ], -); -``` - -## Feature Flags - -```php -return new TempcordConfig( - // Feature flags - features: [ - 'slash_commands' => true, - 'context_menus' => true, - 'message_commands' => false, - 'auto_complete' => true, - 'modals' => true, - 'select_menus' => true, - 'buttons' => true, - - // Experimental features - 'experimental' => [ - 'voice_support' => false, - 'ai_integration' => false, - 'advanced_permissions' => true, - ], - ], -); -``` - -## Performance Configuration - -```php -return new TempcordConfig( - // Performance settings - performance: [ - 'command_cache' => true, - 'event_cache' => true, - 'middleware_cache' => true, - - 'limits' => [ - 'max_commands' => 100, - 'max_events' => 50, - 'max_middleware' => 20, - 'command_timeout' => 30, - 'event_timeout' => 10, - ], - - 'optimization' => [ - 'lazy_loading' => true, - 'preload_commands' => false, - 'compress_responses' => true, - ], - ], -); -``` - -## Security Configuration - -```php -return new TempcordConfig( - // Security settings - security: [ - 'verify_signatures' => true, - 'rate_limiting' => [ - 'enabled' => true, - 'max_requests' => 60, - 'window_seconds' => 60, - ], - - 'permissions' => [ - 'strict_mode' => true, - 'require_permissions' => true, - 'check_bot_permissions' => true, - ], - - 'validation' => [ - 'sanitize_input' => true, - 'validate_options' => true, - 'max_input_length' => 2000, - ], - ], -); -``` - -## Development Configuration - -```php -return new TempcordConfig( - // Development settings - development: [ - 'hot_reload' => env('APP_DEBUG', false), - 'debug_mode' => env('APP_DEBUG', false), - 'profiling' => env('APP_DEBUG', false), - - 'testing' => [ - 'mock_discord' => env('MOCK_DISCORD', false), - 'fake_interactions' => env('FAKE_INTERACTIONS', false), - ], - - 'debugging' => [ - 'log_all_interactions' => false, - 'dump_payloads' => false, - 'trace_middleware' => false, - ], - ], -); -``` - -## Configuration Validation - -Tempcord automatically validates your configuration on startup: - -```php -// Custom validation rules -return new TempcordConfig( - // ... your config - - validation: [ - 'strict' => true, - 'rules' => [ - 'token' => 'required|string|min:50', - 'applicationId' => 'required|string|size:18', - 'owners' => 'array', - 'owners.*' => 'string|size:18', - ], - ], -); -``` - -## Environment-Specific Configuration - -### Local Development - -```php -// config/tempcord.local.php -return [ - 'debug' => true, - 'logging' => [ - 'level' => 'debug', - ], - 'cache' => [ - 'driver' => 'file', - ], - 'development' => [ - 'hot_reload' => true, - 'profiling' => true, - ], -]; -``` - -### Production - -```php -// config/tempcord.production.php -return [ - 'debug' => false, - 'logging' => [ - 'level' => 'warning', - ], - 'cache' => [ - 'driver' => 'redis', - ], - 'performance' => [ - 'command_cache' => true, - 'optimization' => [ - 'lazy_loading' => true, - 'preload_commands' => true, - ], - ], -]; -``` - -## Configuration Helpers - -### Accessing Configuration - -```php -// Get configuration value -$token = config('tempcord.token'); -$debug = config('tempcord.debug', false); - -// Get nested configuration -$cacheDriver = config('tempcord.cache.driver'); -$logLevel = config('tempcord.logging.level'); - -// Check if feature is enabled -if (config('tempcord.features.slash_commands')) { - // Slash commands are enabled -} -``` - -### Dynamic Configuration - -```php -// Set configuration at runtime -config(['tempcord.debug' => true]); - -// Merge configuration -config()->merge('tempcord', [ - 'custom_setting' => 'value', -]); -``` - -## Best Practices - -### Security -- Never commit sensitive values to version control -- Use environment variables for secrets -- Validate all configuration values -- Use secure defaults - -### Performance -- Cache configuration in production -- Use lazy loading for large configurations -- Minimize configuration file size -- Profile configuration loading - -### Maintainability -- Use descriptive configuration keys -- Group related settings -- Document configuration options -- Use type hints and validation - -### Environment Management -- Use different configurations per environment -- Validate environment-specific settings -- Use feature flags for gradual rollouts -- Monitor configuration changes - -## Troubleshooting - -### Common Issues - -1. **Invalid Discord Token** - ``` - Error: Invalid token provided - Solution: Check your DISCORD_TOKEN in .env - ``` - -2. **Missing Permissions** - ``` - Error: Missing Access - Solution: Check bot permissions in Discord - ``` - -3. **Configuration Not Found** - ``` - Error: Configuration file not found - Solution: Ensure config/tempcord.php exists - ``` - -### Debug Configuration - -```php -// Enable debug mode -config(['tempcord.debug' => true]); - -// Dump configuration -dd(config('tempcord')); - -// Validate configuration -Tempcord::validateConfig(); -``` - -## Examples - -Check out the `examples/configuration/` directory for complete configuration examples and common setups. \ No newline at end of file diff --git a/docs/events.md b/docs/events.md deleted file mode 100644 index 15e9a76..0000000 --- a/docs/events.md +++ /dev/null @@ -1,544 +0,0 @@ -# Events - -Events allow your bot to respond to various activities that happen in Discord, such as messages being sent, users joining servers, or reactions being added to messages. - -## Event Listeners - -### Creating an Event Listener - -Create event listeners using the `#[EventListener]` attribute: - -```php -message; - - // Ignore bot messages - if ($message->author->bot) { - return; - } - - // Respond to specific content - if ($message->content === 'hello') { - $message->reply('Hello there!'); - } - } -} -``` - -### Event Method Naming - -Event listener methods should follow the pattern `on{EventName}`: - -- `onMessageCreate` for `MessageCreate` events -- `onGuildMemberAdd` for `GuildMemberAdd` events -- `onReactionAdd` for `ReactionAdd` events - -## Available Events - -### Message Events - -#### MessageCreate -Triggered when a message is sent: - -```php -use Tempcord\Events\MessageCreate; - -#[EventListener] -public function onMessageCreate(MessageCreate $event): void -{ - $message = $event->message; - $author = $message->author; - $content = $message->content; - $channel = $message->channel; - $guild = $message->guild; -} -``` - -#### MessageUpdate -Triggered when a message is edited: - -```php -use Tempcord\Events\MessageUpdate; - -#[EventListener] -public function onMessageUpdate(MessageUpdate $event): void -{ - $oldMessage = $event->oldMessage; // May be null if not cached - $newMessage = $event->newMessage; -} -``` - -#### MessageDelete -Triggered when a message is deleted: - -```php -use Tempcord\Events\MessageDelete; - -#[EventListener] -public function onMessageDelete(MessageDelete $event): void -{ - $message = $event->message; // May be null if not cached - $messageId = $event->messageId; - $channelId = $event->channelId; - $guildId = $event->guildId; -} -``` - -### Guild Events - -#### GuildMemberAdd -Triggered when a user joins a server: - -```php -use Tempcord\Events\GuildMemberAdd; - -#[EventListener] -public function onGuildMemberAdd(GuildMemberAdd $event): void -{ - $member = $event->member; - $guild = $member->guild; - $user = $member->user; - - // Send welcome message - $welcomeChannel = $guild->getChannel('welcome-channel-id'); - $welcomeChannel->send("Welcome {$user->mention()} to {$guild->name}!"); -} -``` - -#### GuildMemberRemove -Triggered when a user leaves a server: - -```php -use Tempcord\Events\GuildMemberRemove; - -#[EventListener] -public function onGuildMemberRemove(GuildMemberRemove $event): void -{ - $user = $event->user; - $guild = $event->guild; - - // Log the departure - $logChannel = $guild->getChannel('log-channel-id'); - $logChannel->send("{$user->tag} has left the server."); -} -``` - -#### GuildCreate -Triggered when the bot joins a new server: - -```php -use Tempcord\Events\GuildCreate; - -#[EventListener] -public function onGuildCreate(GuildCreate $event): void -{ - $guild = $event->guild; - - // Setup default configuration for new guild - $this->setupGuildDefaults($guild); -} -``` - -### Reaction Events - -#### ReactionAdd -Triggered when a reaction is added to a message: - -```php -use Tempcord\Events\ReactionAdd; - -#[EventListener] -public function onReactionAdd(ReactionAdd $event): void -{ - $reaction = $event->reaction; - $user = $event->user; - $message = $reaction->message; - $emoji = $reaction->emoji; - - // Role reactions - if ($message->id === 'role-message-id' && $emoji->name === '🎮') { - $role = $message->guild->getRole('gamer-role-id'); - $member = $message->guild->getMember($user->id); - $member->addRole($role); - } -} -``` - -#### ReactionRemove -Triggered when a reaction is removed from a message: - -```php -use Tempcord\Events\ReactionRemove; - -#[EventListener] -public function onReactionRemove(ReactionRemove $event): void -{ - $reaction = $event->reaction; - $user = $event->user; - $emoji = $reaction->emoji; - - // Remove role when reaction is removed - if ($emoji->name === '🎮') { - $role = $reaction->message->guild->getRole('gamer-role-id'); - $member = $reaction->message->guild->getMember($user->id); - $member->removeRole($role); - } -} -``` - -### Voice Events - -#### VoiceStateUpdate -Triggered when a user's voice state changes: - -```php -use Tempcord\Events\VoiceStateUpdate; - -#[EventListener] -public function onVoiceStateUpdate(VoiceStateUpdate $event): void -{ - $oldState = $event->oldState; - $newState = $event->newState; - $member = $newState->member; - - // User joined a voice channel - if (!$oldState->channel && $newState->channel) { - // Handle voice channel join - } - - // User left a voice channel - if ($oldState->channel && !$newState->channel) { - // Handle voice channel leave - } - - // User moved between channels - if ($oldState->channel && $newState->channel && $oldState->channel->id !== $newState->channel->id) { - // Handle voice channel move - } -} -``` - -## Event Filtering - -### Guild-Specific Listeners - -Listen to events only from specific guilds: - -```php -use Tempcord\Attributes\EventListener; -use Tempcord\Attributes\GuildOnly; -use Tempcord\Events\MessageCreate; - -class GuildMessageListener -{ - #[EventListener] - #[GuildOnly(['guild-id-1', 'guild-id-2'])] - public function onMessageCreate(MessageCreate $event): void - { - // Only processes messages from specified guilds - } -} -``` - -### Channel-Specific Listeners - -Listen to events only from specific channels: - -```php -use Tempcord\Attributes\EventListener; -use Tempcord\Attributes\ChannelOnly; -use Tempcord\Events\MessageCreate; - -class ChannelMessageListener -{ - #[EventListener] - #[ChannelOnly(['channel-id-1', 'channel-id-2'])] - public function onMessageCreate(MessageCreate $event): void - { - // Only processes messages from specified channels - } -} -``` - -### Conditional Listeners - -Use custom conditions to filter events: - -```php -use Tempcord\Attributes\EventListener; -use Tempcord\Events\MessageCreate; - -class ConditionalListener -{ - #[EventListener] - public function onMessageCreate(MessageCreate $event): void - { - $message = $event->message; - - // Only process messages with attachments - if (empty($message->attachments)) { - return; - } - - // Only process messages from non-bots - if ($message->author->bot) { - return; - } - - // Process the message - $this->processMessageWithAttachment($message); - } -} -``` - -## Event Priority - -Control the order in which event listeners are executed: - -```php -use Tempcord\Attributes\EventListener; -use Tempcord\Attributes\Priority; -use Tempcord\Events\MessageCreate; - -class HighPriorityListener -{ - #[EventListener] - #[Priority(100)] // Higher numbers = higher priority - public function onMessageCreate(MessageCreate $event): void - { - // This runs before lower priority listeners - } -} - -class LowPriorityListener -{ - #[EventListener] - #[Priority(10)] // Lower numbers = lower priority - public function onMessageCreate(MessageCreate $event): void - { - // This runs after higher priority listeners - } -} -``` - -## Async Event Handling - -For long-running event handlers, use async processing: - -```php -use Tempcord\Attributes\EventListener; -use Tempcord\Attributes\Async; -use Tempcord\Events\MessageCreate; - -class AsyncListener -{ - #[EventListener] - #[Async] - public function onMessageCreate(MessageCreate $event): void - { - // This runs asynchronously and won't block other events - $this->performLongRunningTask($event->message); - } - - private function performLongRunningTask($message): void - { - // Long-running operation like API calls, file processing, etc. - sleep(5); // This won't block other events - } -} -``` - -## Error Handling - -### Try-Catch in Listeners - -```php -use Tempcord\Attributes\EventListener; -use Tempcord\Events\MessageCreate; -use Exception; - -class SafeListener -{ - #[EventListener] - public function onMessageCreate(MessageCreate $event): void - { - try { - $this->processMessage($event->message); - } catch (Exception $e) { - // Log the error - logger()->error('Error processing message', [ - 'message_id' => $event->message->id, - 'error' => $e->getMessage(), - ]); - - // Optionally notify administrators - $this->notifyAdmins($e, $event->message); - } - } -} -``` - -### Global Error Handling - -Configure global error handling for events: - -```php -// In your configuration -use Tempcord\Config\TempcordConfig; - -return new TempcordConfig( - // ... other config - eventErrorHandler: function (Exception $e, $event) { - logger()->error('Event handler error', [ - 'event' => get_class($event), - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - } -); -``` - -## Event Data Access - -### Accessing Discord Objects - -```php -use Tempcord\Events\MessageCreate; - -#[EventListener] -public function onMessageCreate(MessageCreate $event): void -{ - $message = $event->message; - - // Message properties - $content = $message->content; - $id = $message->id; - $timestamp = $message->timestamp; - $editedTimestamp = $message->editedTimestamp; - $attachments = $message->attachments; - $embeds = $message->embeds; - $mentions = $message->mentions; - $reactions = $message->reactions; - - // Author information - $author = $message->author; - $authorId = $author->id; - $authorTag = $author->tag; - $authorAvatar = $author->avatar; - - // Channel information - $channel = $message->channel; - $channelId = $channel->id; - $channelName = $channel->name; - $channelType = $channel->type; - - // Guild information (if in a guild) - if ($message->guild) { - $guild = $message->guild; - $guildId = $guild->id; - $guildName = $guild->name; - $member = $message->member; // Guild member object - } -} -``` - -## Custom Events - -Create your own custom events: - -```php -getGuild($event->guildId); - $user = discord()->getUser($event->userId); - - $channel = $guild->getChannel('general'); - $channel->send("🎉 Congratulations {$user->mention()}! You reached level {$event->newLevel}!"); - } -} -``` - -## Best Practices - -### Performance -- Keep event handlers lightweight -- Use async processing for heavy operations -- Cache frequently accessed data -- Avoid blocking operations in event handlers - -### Error Handling -- Always wrap risky operations in try-catch blocks -- Log errors with sufficient context -- Don't let one event handler failure affect others -- Implement graceful degradation - -### Security -- Validate event data before processing -- Check permissions before performing actions -- Rate limit event-triggered actions -- Sanitize user input from events - -### Organization -- Group related listeners in the same class -- Use descriptive method names -- Keep listeners focused on single responsibilities -- Document complex event handling logic - -## Examples - -Check out the `examples/events/` directory for more event handling examples and patterns. \ No newline at end of file diff --git a/docs/getting-started.md b/docs/getting-started.md deleted file mode 100644 index 4d1f44e..0000000 --- a/docs/getting-started.md +++ /dev/null @@ -1,158 +0,0 @@ -# Getting Started with Tempcord - -Welcome to Tempcord! This guide will help you get up and running with your first Discord bot using the Tempcord framework. - -## Prerequisites - -Before you begin, make sure you have: - -- PHP 8.2 or higher -- Composer installed -- A Discord application and bot token -- Basic knowledge of PHP and Discord bots - -## Installation - -### Creating a New Project - -The easiest way to get started is by creating a new project using Composer: - -```bash -composer create-project tempcord/tempcord my-discord-bot -cd my-discord-bot -``` - -### Adding to Existing Project - -If you want to add Tempcord to an existing project: - -```bash -composer require tempcord/tempcord -``` - -## Discord Application Setup - -1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) -2. Click "New Application" and give it a name -3. Go to the "Bot" section and click "Add Bot" -4. Copy the bot token (you'll need this later) -5. Under "Privileged Gateway Intents", enable the intents your bot needs - -## Configuration - -### Environment Setup - -Copy the example environment file: - -```bash -cp .env.example .env -``` - -Edit the `.env` file with your Discord bot credentials: - -```env -DISCORD_TOKEN=your_bot_token_here -DISCORD_CLIENT_ID=your_client_id_here -``` - -### Basic Configuration - -Create or edit `app/config/tempcord.config.php`: - -```php -reply('🏓 Pong!'); - } -} -``` - -## Running Your Bot - -Start your bot with: - -```bash -php tempcord start -``` - -You should see output indicating that your bot is connecting to Discord. - -## Testing Your Bot - -1. Invite your bot to a Discord server using the OAuth2 URL generator in the Discord Developer Portal -2. In a channel where your bot has permissions, type `/ping` -3. Your bot should respond with "🏓 Pong!" - -## Next Steps - -Now that you have a basic bot running, you can: - -- [Learn about Commands](commands.md) -- [Explore Event Handling](events.md) -- [Add Middleware](middleware.md) -- [Configure Advanced Settings](configuration.md) - -## Troubleshooting - -### Common Issues - -**Bot doesn't respond to commands:** -- Check that your bot token is correct -- Ensure the bot has the necessary permissions in your Discord server -- Verify that the required intents are enabled - -**"Invalid token" error:** -- Double-check your bot token in the `.env` file -- Make sure there are no extra spaces or characters - -**Commands not registering:** -- Ensure `autoRegisterCommands` is set to `true` in your config -- Check that your command classes are in the correct namespace -- Verify that your command methods have the proper attributes - -### Getting Help - -If you're still having issues: - -1. Check the [API Reference](api-reference.md) -2. Look through existing [GitHub Issues](https://github.com/tempcord/tempcord/issues) -3. Join our Discord community for support -4. Create a new issue if you've found a bug - -## What's Next? - -Congratulations! You've successfully created your first Tempcord bot. Continue reading the documentation to learn about more advanced features and best practices. \ No newline at end of file diff --git a/docs/guides/01-getting-started.md b/docs/guides/01-getting-started.md new file mode 100644 index 0000000..81971ca --- /dev/null +++ b/docs/guides/01-getting-started.md @@ -0,0 +1,83 @@ +# Getting started + +Tempcord builds Discord bots on top of [Tempest](https://tempestphp.com). A command is a +class, its options are typed parameters, and the framework works out the rest at boot. + +## Requirements + +- PHP 8.5 or newer +- A Discord application with a bot token +- The intents your bot needs, enabled in the Discord developer portal + +## Installing + +```bash +composer create-project tempcord/tempcord my-bot +cd my-bot +``` + +Copy the environment file and add your token: + +```bash +cp .env.example .env +``` + +```env +DISCORD_TOKEN=your_bot_token_here +``` + +## Your first command + +A command is a class carrying `#[Command]`. If it declares an `__invoke` method, that method +handles it, and each parameter marked `#[Option]` becomes an option Discord shows the user. + +```php +use Ragnarok\Fenrir\Interaction\CommandInteraction; +use Tempcord\Attributes\Command; +use Tempcord\Attributes\Option; + +#[Command(description: 'Replies with pong')] +final class PingCommand +{ + public function __invoke( + CommandInteraction $interaction, + #[Option(description: 'Who to greet')] string $name, + #[Option(description: 'How many times')] int $times = 1, + ): string { + return $name . ':' . $times; + } +} +``` + +From [`tests/Fixtures/PingCommand.php`](../../tests/Fixtures/PingCommand.php) — compiled and exercised by the test suite. + +The name comes from the class — `PingCommand` becomes `/ping` — with a `Command` prefix or +suffix stripped and the rest snake_cased. Pass `name:` to choose it yourself. + +Whether an option is required comes from whether its parameter has a default. Here `name` is +required and `times` is not. + +## Registering and running + +Registration replaces the whole set of commands each time, so a command you delete from your +code disappears from Discord rather than lingering there: + +```bash +./tempcord boot --register +``` + +Once registered, boot without the flag: + +```bash +./tempcord boot +``` + +Registration needs a request to Discord, so run it when your commands change rather than on +every start. + +## Where to go next + +- [Commands and options](02-commands.md) — subcommands, groups, and every option constraint +- [Autocomplete](03-autocomplete.md) — suggesting values as the user types +- [Events](04-events.md) — reacting to what happens on the gateway +- [Translations](05-translations.md) — showing commands in the user's own language diff --git a/docs/guides/02-commands.md b/docs/guides/02-commands.md new file mode 100644 index 0000000..0736036 --- /dev/null +++ b/docs/guides/02-commands.md @@ -0,0 +1,163 @@ +# Commands and options + +## Subcommands + +A class whose methods carry `#[Subcommand]` exposes each one as a subcommand. There is no +`__invoke` in this case — the methods are the handlers. + +```php +use Tempcord\Attributes\Command; +use Tempcord\Attributes\Option; +use Tempcord\Attributes\Subcommand; + +#[Command(description: 'Moderation tools')] +final class ModerationCommand +{ + #[Subcommand(name: 'kick', description: 'Kick a member')] + public function kick( + #[Option(description: 'Reason for the kick')] string $reason, + ): string { + return 'kicked: ' . $reason; + } +} +``` + +From [`tests/Fixtures/ModerationCommand.php`](../../tests/Fixtures/ModerationCommand.php) — compiled and exercised by the test suite. + +That registers `/moderation kick`. + +## Grouping subcommands + +Adding `#[SubcommandGroup]` to the class nests everything one level deeper, which is how +Discord models `/command group subcommand`. + +```php +use Tempcord\Attributes\Command; +use Tempcord\Attributes\Option; +use Tempcord\Attributes\Subcommand; +use Tempcord\Attributes\SubcommandGroup; + +#[Command(description: 'Music controls')] +#[SubcommandGroup(name: 'playlist', description: 'Playlist controls')] +final class MusicCommand +{ + #[Subcommand(name: 'play', description: 'Play a track')] + public function play( + #[Option(description: 'Track title')] string $title, + ): string { + return 'playing ' . $title; + } + + #[Subcommand(name: 'stop', description: 'Stop playback')] + public function stop(): string + { + return 'stopped'; + } + + public function notASubcommand(): void {} +} +``` + +From [`tests/Fixtures/MusicCommand.php`](../../tests/Fixtures/MusicCommand.php) — compiled and exercised by the test suite. + +That registers `/music playlist play` and `/music playlist stop`. A method without +`#[Subcommand]` is ignored, so helpers can sit alongside handlers. + +## Option types + +The Discord option type is read from the parameter's PHP type: + +| PHP type | Discord option type | +| --- | --- | +| `string` | STRING | +| `int` | INTEGER | +| `float` | NUMBER | +| `bool` | BOOLEAN | +| `Ragnarok\Fenrir\Parts\User` | USER | +| `Ragnarok\Fenrir\Parts\Channel` | CHANNEL | +| `Ragnarok\Fenrir\Parts\Role` | ROLE | + +A parameter typed `User`, `Channel` or `Role` is fetched from Discord before your handler +runs, so you receive the entity rather than an id. + +An unsupported type fails at boot rather than on the first interaction that reaches it. + +## Receiving the interaction + +A parameter named `$interaction` receives the `CommandInteraction`, which is how you reply. +It needs no attribute. + +## Constraining what users may send + +Discord can enforce constraints before your handler is ever called, which is cheaper and +gives the user immediate feedback. + +```php +use Ragnarok\Fenrir\Enums\ChannelType; +use Ragnarok\Fenrir\Parts\Channel; +use Tempcord\Attributes\Command; +use Tempcord\Attributes\Option; + +#[Command(description: 'Every option constraint Discord accepts')] +final class ConstrainedCommand +{ + public function __invoke( + #[Option(description: 'A labelled set', choices: ['Small' => 's', 'Large' => 'l'])] + string $size, + #[Option(description: 'A bare list', choices: ['red', 'green'])] + string $colour, + #[Option(description: 'Bounded number', minValue: 1, maxValue: 10)] + int $count, + #[Option(description: 'Bounded text', minLength: 2, maxLength: 32)] + string $note, + #[Option(description: 'Text channels only', channelTypes: [ChannelType::GUILD_TEXT])] + Channel $channel, + ): void {} +} +``` + +From [`tests/Fixtures/ConstrainedCommand.php`](../../tests/Fixtures/ConstrainedCommand.php) — compiled and exercised by the test suite. + +Choices accept either shape. A map uses its keys as the labels users see; a list has no +labels of its own, so each value stands in as its own. + +## Restricting who may use a command + +`permissions` sets the default a member needs. An empty list leaves the command unrestricted. + +```php +use Ragnarok\Fenrir\Enums\Permission; +use Tempcord\Attributes\Command; + +#[Command( + description: 'Only for moderators', + permissions: [Permission::KICK_MEMBERS, Permission::BAN_MEMBERS], +)] +final class RestrictedCommand +{ + public function __invoke(): void {} +} +``` + +From [`tests/Fixtures/RestrictedCommand.php`](../../tests/Fixtures/RestrictedCommand.php) — compiled and exercised by the test suite. + +Server administrators can override this per guild, so treat it as a default rather than a +security boundary. + +## Scoping a command to one guild + +`guildId` registers a command in a single guild instead of globally. Guild commands appear +immediately, which makes them useful while developing; global commands can take up to an +hour to propagate. + +```php +use Tempcord\Attributes\Command; + +#[Command(name: 'alpha', description: 'Alpha, scoped to guild 111', guildId: 111)] +final class GuildAlphaCommand +{ + public function __invoke(): void {} +} +``` + +From [`tests/Fixtures/GuildAlphaCommand.php`](../../tests/Fixtures/GuildAlphaCommand.php) — compiled and exercised by the test suite. diff --git a/docs/guides/03-autocomplete.md b/docs/guides/03-autocomplete.md new file mode 100644 index 0000000..88fca59 --- /dev/null +++ b/docs/guides/03-autocomplete.md @@ -0,0 +1,69 @@ +# Autocomplete + +Autocomplete suggests values while the user is still typing, before they submit the command. + +## The built-in + +`ArrayAutocomplete` filters a fixed list by what has been typed so far. + +```php +use Ragnarok\Fenrir\Interaction\CommandInteraction; +use Tempcord\Attributes\Command; +use Tempcord\Attributes\Option; +use Tempcord\AutoCompletes\ArrayAutocomplete; + +#[Command(description: 'Suggests as you type')] +final class SearchCommand +{ + public function __invoke( + CommandInteraction $interaction, + #[Option(description: 'What to look for', autocomplete: new ArrayAutocomplete(['alpha', 'beta', 'gamma']))] + string $query, + #[Option(description: 'Plain option with no suggestions')] string $note = '', + ): void {} +} +``` + +From [`tests/Fixtures/SearchCommand.php`](../../tests/Fixtures/SearchCommand.php) — compiled and exercised by the test suite. + +## Writing your own + +Anything implementing `Autocomplete` can supply suggestions, which is what you want when +they come from a database or an API. + +```php +use Ragnarok\Fenrir\Interaction\CommandInteraction; +use Tempcord\Interfaces\Autocomplete; + +final readonly class TrackAutocomplete implements Autocomplete +{ + public function __construct( + private TrackRepository $tracks, + ) {} + + public function handle(CommandInteraction $interaction, mixed $value): array + { + return $this->tracks->matching((string) $value); + } +} +``` + +## What you may return + +| Return | Result | +| --- | --- | +| A list | Each value is shown as its own label | +| A map | Keys are the labels users see, values are what your handler receives | +| A single scalar | One suggestion | +| `ApplicationCommandOptionChoice` objects | Passed through untouched | + +Discord accepts at most 25 choices and rejects a response carrying more, so anything beyond +that is dropped before sending. + +## Timing + +Discord expects an autocomplete response within about three seconds. Keep the work small, +and cache rather than querying on every keystroke. + +Note that an autocomplete implementation is constructed as part of the attribute, so it is +rebuilt whenever the command tree is read. Hold configuration in it, not a warm cache. diff --git a/docs/guides/04-events.md b/docs/guides/04-events.md new file mode 100644 index 0000000..386c626 --- /dev/null +++ b/docs/guides/04-events.md @@ -0,0 +1,57 @@ +# Events + +Events let a bot react to what happens on the gateway rather than to a command. + +## Listening + +An invokable class carrying `#[Event]` becomes a listener. The event name is the gateway +event you want; the payload arrives as the single argument. + +```php +use Tempcord\Attributes\Event; + +#[Event(name: 'READY')] +final class ReadyListener +{ + /** @var list */ + public static array $received = []; + + public function __invoke(object $payload): void + { + self::$received[] = $payload; + } +} +``` + +From [`tests/Fixtures/ReadyListener.php`](../../tests/Fixtures/ReadyListener.php) — compiled and exercised by the test suite. + +The class is resolved from the container, so a listener may take constructor dependencies. + +## Intents + +Discord only sends events your bot subscribed to. Intents are set in your configuration: + +```php +use Ragnarok\Fenrir\Bitwise\Bitwise; +use Ragnarok\Fenrir\Enums\Intent; + +return new TempcordConfig( + token: env('DISCORD_TOKEN'), + intents: Bitwise::from( + Intent::GUILDS, + Intent::GUILD_MESSAGES, + Intent::MESSAGE_CONTENT, + ), +); +``` + +A listener for an event you have no intent for is registered and simply never fires, which is +a common reason for a listener that appears to do nothing. + +`MESSAGE_CONTENT`, `GUILD_MEMBERS` and `GUILD_PRESENCES` are privileged: they must also be +enabled in the Discord developer portal, and above a hundred guilds they need approval. + +## Errors + +A listener that throws is logged and contained; the gateway connection carries on rather than +the bot falling over. diff --git a/docs/guides/05-translations.md b/docs/guides/05-translations.md new file mode 100644 index 0000000..8012db3 --- /dev/null +++ b/docs/guides/05-translations.md @@ -0,0 +1,79 @@ +# Translations + +Discord can show a command's name and description in each user's own language. Tempcord reads +those from Tempest's translation catalog, so they live in the same files as the rest of your +application's translations rather than inline in PHP. + +## Setup + +Translations need `tempest/intl`, which is not installed by default because it requires the +`intl` and `dom` extensions: + +```bash +composer require tempest/intl +``` + +Without it, commands register with their declared names and descriptions and nothing else. + +## Declaring a key + +A command declares one key. Everything beneath it follows from its position in the tree. + +```php +use Tempcord\Attributes\Command; +use Tempcord\Attributes\Option; +use Tempcord\Attributes\Subcommand; +use Tempcord\Attributes\SubcommandGroup; + +#[Command(description: 'Music controls', translationKey: 'commands.music')] +#[SubcommandGroup(name: 'playlist', description: 'Playlist controls')] +final class LocalizedCommand +{ + #[Subcommand(name: 'play', description: 'Play a track')] + public function play( + #[Option(description: 'Track title')] string $title, + ): void {} +} +``` + +From [`tests/Fixtures/LocalizedCommand.php`](../../tests/Fixtures/LocalizedCommand.php) — compiled and exercised by the test suite. + +That reads these keys: + +``` +commands.music.name +commands.music.description +commands.music.playlist.name +commands.music.playlist.description +commands.music.playlist.play.name +commands.music.playlist.play.description +commands.music.playlist.play.title.name +commands.music.playlist.play.title.description +``` + +An option on an invokable command hangs directly off the command's key — +`commands.greet.name.description` for an option named `name`. + +## Providing the translations + +Anywhere Tempest reads translations from: + +```json +{ + "commands.music.description": "Musiksteuerung", + "commands.music.playlist.description": "Wiedergabeliste", + "commands.music.playlist.play.description": "Titel abspielen", + "commands.music.playlist.play.title.description": "Titelname" +} +``` + +## What gets sent + +Only locales that actually have a translation. A missing one is left out rather than filled +in, so Discord falls back to the declared text — which is why a partly translated command is +perfectly fine. + +Discord accepts 34 locales; see [DiscordLocale](../reference/enums/discord-locale.md) for the +full list and how each maps onto a Tempest locale. + +A command with no `translationKey` reads nothing and sends no localization fields at all. diff --git a/docs/guides/06-plugins.md b/docs/guides/06-plugins.md new file mode 100644 index 0000000..3675a1e --- /dev/null +++ b/docs/guides/06-plugins.md @@ -0,0 +1,104 @@ +# Plugins + +A plugin is an ordinary Composer package that adds commands, events or behaviour to a bot. + +## Commands and events need no plugin API + +Tempest discovers any installed package that depends on a `tempest/*` package, and Tempcord's +discovery does not care whether a class came from your application or from a package. So a +package that ships this: + +```php +#[Command(description: 'Show scheduled tasks')] +final class TasksCommand +{ + public function __invoke(CommandInteraction $interaction): void + { + // ... + } +} +``` + +is registered by the bot that installs it, with no registration step. The same is true of +`#[Event]` listeners. + +If your package does not depend on anything from Tempest, opt in through composer instead: + +```json +{ + "extra": { + "tempest": { + "can-discover": true + } + } +} +``` + +## When a plugin needs to act + +Discovery covers declarations. What it cannot do is give a package a moment to *run* — to +register a Fenrir extension, start a timer, or open a connection. That is what `Plugin` is +for. + +```php +use Tempcord\Plugins\Plugin; +use Tempcord\Tempcord; + +final class RecordingPlugin implements Plugin +{ + /** @var list */ + public static array $booted = []; + + public function boot(Tempcord $tempcord): void + { + self::$booted[] = $tempcord; + } +} +``` + +From [`tests/Fixtures/RecordingPlugin.php`](../../tests/Fixtures/RecordingPlugin.php) — compiled and exercised by the test suite. + +`boot()` is called once, after commands and events are bound and before the gateway opens, so +anything that must exist before the first event arrives belongs there. + +Plugins are built by the container, so a plugin may take whatever it needs: + +```php +final readonly class TasksPlugin implements Plugin +{ + public function __construct( + private Registry $tasks, + ) {} + + public function boot(Tempcord $tempcord): void + { + $tempcord->discord->registerExtension($this->tasks); + } +} +``` + +No registration is needed for the plugin class either — implementing the interface is enough. + +## Failure + +A plugin that throws while booting is logged and reported, and the bot carries on without it. +One broken plugin does not stop the others, or the bot. + +## Writing one + +```json +{ + "name": "vendor/my-plugin", + "require": { + "tempcord/framework": "^0.6" + }, + "autoload": { + "psr-4": { + "Vendor\\MyPlugin\\": "src/" + } + } +} +``` + +Requiring `tempcord/framework` is enough for discovery, since the framework itself depends on +Tempest. diff --git a/docs/index.json b/docs/index.json new file mode 100644 index 0000000..501d9dd --- /dev/null +++ b/docs/index.json @@ -0,0 +1,525 @@ +{ + "guides": [ + { + "title": "Getting started", + "slug": "guides/01-getting-started" + }, + { + "title": "Commands and options", + "slug": "guides/02-commands" + }, + { + "title": "Autocomplete", + "slug": "guides/03-autocomplete" + }, + { + "title": "Events", + "slug": "guides/04-events" + }, + { + "title": "Translations", + "slug": "guides/05-translations" + }, + { + "title": "Plugins", + "slug": "guides/06-plugins" + } + ], + "reference": { + "attributes": [ + { + "name": "Command", + "fqcn": "Tempcord\\Attributes\\Command", + "kind": "attribute", + "target": "class, method", + "summary": "Declares a class as a Discord application command.", + "slug": "reference/attributes/command", + "parameters": [ + { + "name": "name", + "type": "BackedEnum|string|null", + "default": "null", + "required": false, + "summary": "defaults to the class name, with a Command prefix or suffix stripped and the rest snake_cased" + }, + { + "name": "description", + "type": "?string", + "default": "null", + "required": false, + "summary": "" + }, + { + "name": "guildId", + "type": "string|int|null", + "default": "null", + "required": false, + "summary": "" + }, + { + "name": "isNsfw", + "type": "bool", + "default": "false", + "required": false, + "summary": "" + }, + { + "name": "permissions", + "type": "array", + "default": "[]", + "required": false, + "summary": "the permissions a member needs by default; an empty list leaves the command unrestricted" + }, + { + "name": "directMessage", + "type": "bool", + "default": "true", + "required": false, + "summary": "" + }, + { + "name": "type", + "type": "ApplicationCommandTypes", + "default": "ApplicationCommandTypes::CHAT_INPUT", + "required": false, + "summary": "" + }, + { + "name": "translationKey", + "type": "?string", + "default": "null", + "required": false, + "summary": "the catalog key this command's translations live under. Keys for everything beneath it are derived from position, so \"commands.music\" gives commands.music.description for the command, commands.music.playlist.play.description for a subcommand, and commands.music.playlist.play.title.description for its option." + } + ], + "cases": [], + "methods": [] + }, + { + "name": "SubcommandGroup", + "fqcn": "Tempcord\\Attributes\\SubcommandGroup", + "kind": "attribute", + "target": "class", + "summary": "Groups every subcommand its class declares under one more level of nesting.", + "slug": "reference/attributes/subcommand-group", + "parameters": [ + { + "name": "name", + "type": "BackedEnum|string", + "default": null, + "required": true, + "summary": "" + }, + { + "name": "description", + "type": "string", + "default": null, + "required": true, + "summary": "" + } + ], + "cases": [], + "methods": [] + }, + { + "name": "Subcommand", + "fqcn": "Tempcord\\Attributes\\Subcommand", + "kind": "attribute", + "target": "method", + "summary": "Declares a public method as a subcommand of the command its class declares.", + "slug": "reference/attributes/subcommand", + "parameters": [ + { + "name": "name", + "type": "BackedEnum|string", + "default": null, + "required": true, + "summary": "" + }, + { + "name": "description", + "type": "string", + "default": null, + "required": true, + "summary": "" + } + ], + "cases": [], + "methods": [] + }, + { + "name": "Option", + "fqcn": "Tempcord\\Attributes\\Option", + "kind": "attribute", + "target": "parameter", + "summary": "Declares a method parameter as a user-supplied command option.", + "slug": "reference/attributes/option", + "parameters": [ + { + "name": "description", + "type": "string", + "default": null, + "required": true, + "summary": "shown beneath the option in Discord's picker" + }, + { + "name": "name", + "type": "?string", + "default": "null", + "required": false, + "summary": "defaults to the parameter's own name" + }, + { + "name": "autocomplete", + "type": "?Autocomplete", + "default": "null", + "required": false, + "summary": "suggests values as the user types; mutually exclusive with choices" + }, + { + "name": "choices", + "type": "array", + "default": "[]", + "required": false, + "summary": "the only values Discord will accept. A map uses its keys as the labels users see; a list shows each value as its own label. Mutually exclusive with autocomplete." + }, + { + "name": "minValue", + "type": "int|float|null", + "default": "null", + "required": false, + "summary": "smallest accepted number" + }, + { + "name": "maxValue", + "type": "int|float|null", + "default": "null", + "required": false, + "summary": "largest accepted number" + }, + { + "name": "minLength", + "type": "?int", + "default": "null", + "required": false, + "summary": "shortest accepted string" + }, + { + "name": "maxLength", + "type": "?int", + "default": "null", + "required": false, + "summary": "longest accepted string" + }, + { + "name": "channelTypes", + "type": "array", + "default": "[]", + "required": false, + "summary": "restricts which channels may be picked, for a Channel option" + } + ], + "cases": [], + "methods": [] + }, + { + "name": "Event", + "fqcn": "Tempcord\\Attributes\\Event", + "kind": "attribute", + "target": "class", + "summary": "Declares an invokable class as a listener for a Discord gateway event.", + "slug": "reference/attributes/event", + "parameters": [ + { + "name": "name", + "type": "string", + "default": null, + "required": true, + "summary": "" + } + ], + "cases": [], + "methods": [] + } + ], + "autocomplete": [ + { + "name": "Autocomplete", + "fqcn": "Tempcord\\Interfaces\\Autocomplete", + "kind": "interface", + "target": null, + "summary": "", + "slug": "reference/autocomplete/autocomplete", + "parameters": [], + "cases": [], + "methods": [ + { + "signature": "handle(CommandInteraction $interaction, mixed $value): mixed", + "summary": "" + } + ] + }, + { + "name": "ArrayAutocomplete", + "fqcn": "Tempcord\\AutoCompletes\\ArrayAutocomplete", + "kind": "class", + "target": null, + "summary": "", + "slug": "reference/autocomplete/array-autocomplete", + "parameters": [ + { + "name": "items", + "type": "array", + "default": null, + "required": true, + "summary": "" + }, + { + "name": "isList", + "type": "bool", + "default": "false", + "required": false, + "summary": "" + } + ], + "cases": [], + "methods": [] + } + ], + "configuration": [ + { + "name": "TempcordConfig", + "fqcn": "Tempcord\\TempcordConfig", + "kind": "class", + "target": null, + "summary": "", + "slug": "reference/configuration/tempcord-config", + "parameters": [ + { + "name": "token", + "type": "string", + "default": null, + "required": true, + "summary": "" + }, + { + "name": "intents", + "type": "Bitwise", + "default": null, + "required": true, + "summary": "" + } + ], + "cases": [], + "methods": [] + } + ], + "enums": [ + { + "name": "DiscordLocale", + "fqcn": "Tempcord\\Enums\\DiscordLocale", + "kind": "enum", + "target": null, + "summary": "The locales Discord accepts for name and description localizations.", + "slug": "reference/enums/discord-locale", + "parameters": [], + "cases": [ + { + "name": "INDONESIAN", + "value": "id", + "note": "" + }, + { + "name": "DANISH", + "value": "da", + "note": "" + }, + { + "name": "GERMAN", + "value": "de", + "note": "" + }, + { + "name": "ENGLISH_UK", + "value": "en-GB", + "note": "" + }, + { + "name": "ENGLISH_US", + "value": "en-US", + "note": "" + }, + { + "name": "SPANISH", + "value": "es-ES", + "note": "" + }, + { + "name": "SPANISH_LATAM", + "value": "es-419", + "note": "" + }, + { + "name": "FRENCH", + "value": "fr", + "note": "" + }, + { + "name": "CROATIAN", + "value": "hr", + "note": "" + }, + { + "name": "ITALIAN", + "value": "it", + "note": "" + }, + { + "name": "LITHUANIAN", + "value": "lt", + "note": "" + }, + { + "name": "HUNGARIAN", + "value": "hu", + "note": "" + }, + { + "name": "DUTCH", + "value": "nl", + "note": "" + }, + { + "name": "NORWEGIAN", + "value": "no", + "note": "" + }, + { + "name": "POLISH", + "value": "pl", + "note": "" + }, + { + "name": "PORTUGUESE_BR", + "value": "pt-BR", + "note": "" + }, + { + "name": "ROMANIAN", + "value": "ro", + "note": "" + }, + { + "name": "FINNISH", + "value": "fi", + "note": "" + }, + { + "name": "SWEDISH", + "value": "sv-SE", + "note": "" + }, + { + "name": "VIETNAMESE", + "value": "vi", + "note": "" + }, + { + "name": "TURKISH", + "value": "tr", + "note": "" + }, + { + "name": "CZECH", + "value": "cs", + "note": "" + }, + { + "name": "GREEK", + "value": "el", + "note": "" + }, + { + "name": "BULGARIAN", + "value": "bg", + "note": "" + }, + { + "name": "RUSSIAN", + "value": "ru", + "note": "" + }, + { + "name": "UKRAINIAN", + "value": "uk", + "note": "" + }, + { + "name": "HINDI", + "value": "hi", + "note": "" + }, + { + "name": "THAI", + "value": "th", + "note": "" + }, + { + "name": "CHINESE_CHINA", + "value": "zh-CN", + "note": "" + }, + { + "name": "JAPANESE", + "value": "ja", + "note": "" + }, + { + "name": "CHINESE_TAIWAN", + "value": "zh-TW", + "note": "" + }, + { + "name": "KOREAN", + "value": "ko", + "note": "" + }, + { + "name": "ARABIC", + "value": "ar", + "note": "" + }, + { + "name": "HEBREW", + "value": "he", + "note": "" + } + ], + "methods": [ + { + "signature": "tempestLocale(): string", + "summary": "The Tempest locale this maps to." + } + ] + } + ], + "plugins": [ + { + "name": "Plugin", + "fqcn": "Tempcord\\Plugins\\Plugin", + "kind": "interface", + "target": null, + "summary": "A package that extends a bot with its own behaviour.", + "slug": "reference/plugins/plugin", + "parameters": [], + "cases": [], + "methods": [ + { + "signature": "boot(Tempcord $tempcord): void", + "summary": "Called once, after commands and events are bound and before the gateway opens." + } + ] + } + ] + } +} diff --git a/docs/middleware.md b/docs/middleware.md deleted file mode 100644 index 5e466e6..0000000 --- a/docs/middleware.md +++ /dev/null @@ -1,639 +0,0 @@ -# Middleware - -Middleware provides a convenient mechanism for filtering and modifying requests before they reach your command handlers. You can use middleware for authentication, logging, rate limiting, and more. - -## Creating Middleware - -### Basic Middleware - -Create middleware by implementing the `MiddlewareInterface`: - -```php -info('Command executed', [ - 'command' => $interaction->commandName, - 'user' => $interaction->user->tag, - 'guild' => $interaction->guild?->name, - ]); - - // Execute the command - $result = $next($interaction); - - // After the command - logger()->info('Command completed', [ - 'command' => $interaction->commandName, - ]); - - return $result; - } -} -``` - -### Middleware with Parameters - -```php -user->id; - $key = "rate_limit:{$userId}"; - - $requests = cache()->get($key, 0); - - if ($requests >= $this->maxRequests) { - $interaction->reply( - '⚠️ You are being rate limited. Please try again later.', - ephemeral: true - ); - return; - } - - cache()->put($key, $requests + 1, $this->windowSeconds); - - return $next($interaction); - } -} -``` - -## Applying Middleware - -### Global Middleware - -Apply middleware to all commands: - -```php -// In your configuration -use Tempcord\Config\TempcordConfig; -use App\Middleware\LoggingMiddleware; -use App\Middleware\RateLimitMiddleware; - -return new TempcordConfig( - // ... other config - globalMiddleware: [ - LoggingMiddleware::class, - RateLimitMiddleware::class, - ] -); -``` - -### Command-Specific Middleware - -Apply middleware to specific commands: - -```php -guild; // Will never be null - } -} -``` - -### Owner Only Middleware - -Restrict commands to bot owners: - -```php -userService->findByDiscordId($interaction->user->id); - - if (!$user) { - $interaction->reply( - '❌ You need to register first. Use `/register` to get started.', - ephemeral: true - ); - return; - } - - // Add user to interaction context - $interaction->setContext('user', $user); - - return $next($interaction); - } -} -``` - -### Cooldown Middleware - -```php -user->id; - $commandName = $interaction->commandName; - $key = "cooldown:{$userId}:{$commandName}"; - - $lastUsed = cache()->get($key); - - if ($lastUsed && (time() - $lastUsed) < $this->cooldownSeconds) { - $remaining = $this->cooldownSeconds - (time() - $lastUsed); - $interaction->reply( - "⏰ Please wait {$remaining} seconds before using this command again.", - ephemeral: true - ); - return; - } - - cache()->put($key, time(), $this->cooldownSeconds); - - return $next($interaction); - } -} -``` - -### Maintenance Mode Middleware - -```php -user->id, config('bot.owners', []))) { - $interaction->reply( - '🔧 The bot is currently under maintenance. Please try again later.', - ephemeral: true - ); - return; - } - } - - return $next($interaction); - } -} -``` - -### Audit Log Middleware - -```php -auditLog->log([ - 'command' => $interaction->commandName, - 'user_id' => $interaction->user->id, - 'guild_id' => $interaction->guild?->id, - 'channel_id' => $interaction->channel->id, - 'options' => $interaction->options, - 'execution_time' => microtime(true) - $startTime, - 'status' => 'success', - 'timestamp' => now(), - ]); - - return $result; - } catch (Exception $e) { - $this->auditLog->log([ - 'command' => $interaction->commandName, - 'user_id' => $interaction->user->id, - 'guild_id' => $interaction->guild?->id, - 'channel_id' => $interaction->channel->id, - 'options' => $interaction->options, - 'execution_time' => microtime(true) - $startTime, - 'status' => 'error', - 'error' => $e->getMessage(), - 'timestamp' => now(), - ]); - - throw $e; - } - } -} -``` - -## Middleware Priority - -Control the order in which middleware is executed: - -```php -commandName, ['sensitive-command', 'admin-command'])) { - return $next($interaction); - } - - // Only apply in specific guilds - if (!in_array($interaction->guild?->id, ['guild-1', 'guild-2'])) { - return $next($interaction); - } - - // Apply middleware logic - return $this->applyMiddleware($interaction, $next); - } - - private function applyMiddleware(Interaction $interaction, callable $next): mixed - { - // Middleware logic here - return $next($interaction); - } -} -``` - -## Error Handling in Middleware - -```php -error('Command execution failed', [ - 'command' => $interaction->commandName, - 'user' => $interaction->user->tag, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - // Send user-friendly error message - $interaction->reply( - '❌ An error occurred while processing your command. Please try again later.', - ephemeral: true - ); - - // Optionally re-throw for global error handling - // throw $e; - } - } -} -``` - -## Middleware Groups - -Create reusable middleware groups: - -```php - [ - AdminOnlyMiddleware::class, - AuditLogMiddleware::class, - RateLimitMiddleware::class, - ], - 'public' => [ - LoggingMiddleware::class, - CooldownMiddleware::class, - ], - ] -); -``` - -Use middleware groups in commands: - -```php -user = (object) ['id' => '123']; - - $nextCalled = false; - $next = function () use (&$nextCalled) { - $nextCalled = true; - return 'success'; - }; - - $result = $middleware->handle($interaction, $next); - - $this->assertTrue($nextCalled); - $this->assertEquals('success', $result); - } - - public function test_blocks_request_over_limit(): void - { - // Test rate limiting logic - } -} -``` - -## Best Practices - -### Performance -- Keep middleware lightweight -- Cache expensive operations -- Use early returns when possible -- Avoid blocking operations - -### Security -- Validate input in middleware -- Implement proper authentication -- Use rate limiting to prevent abuse -- Log security-relevant events - -### Organization -- Keep middleware focused on single responsibilities -- Use descriptive names -- Group related middleware -- Document complex middleware logic - -### Error Handling -- Handle errors gracefully -- Provide user-friendly error messages -- Log errors with sufficient context -- Don't expose sensitive information - -## Examples - -Check out the `examples/middleware/` directory for more middleware examples and patterns. \ No newline at end of file diff --git a/docs/reference/attributes/command.md b/docs/reference/attributes/command.md new file mode 100644 index 0000000..ead988b --- /dev/null +++ b/docs/reference/attributes/command.md @@ -0,0 +1,25 @@ + + +# Command + +Declares a class as a Discord application command. + +```php +use Tempcord\Attributes\Command; +``` + +**Applies to:** class, method + +## Parameters + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `BackedEnum\|string\|null` | `null` | defaults to the class name, with a Command prefix or suffix stripped and the rest snake_cased | +| `description` | `?string` | `null` | | +| `guildId` | `string\|int\|null` | `null` | | +| `isNsfw` | `bool` | `false` | | +| `permissions` | `array` | `[]` | the permissions a member needs by default; an empty list leaves the command unrestricted | +| `directMessage` | `bool` | `true` | | +| `type` | `ApplicationCommandTypes` | `ApplicationCommandTypes::CHAT_INPUT` | | +| `translationKey` | `?string` | `null` | the catalog key this command's translations live under. Keys for everything beneath it are derived from position, so "commands.music" gives commands.music.description for the command, commands.music.playlist.play.description for a subcommand, and commands.music.playlist.play.title.description for its option. | + diff --git a/docs/reference/attributes/event.md b/docs/reference/attributes/event.md new file mode 100644 index 0000000..2dddacb --- /dev/null +++ b/docs/reference/attributes/event.md @@ -0,0 +1,18 @@ + + +# Event + +Declares an invokable class as a listener for a Discord gateway event. + +```php +use Tempcord\Attributes\Event; +``` + +**Applies to:** class + +## Parameters + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `string` | *required* | | + diff --git a/docs/reference/attributes/option.md b/docs/reference/attributes/option.md new file mode 100644 index 0000000..24d4a35 --- /dev/null +++ b/docs/reference/attributes/option.md @@ -0,0 +1,26 @@ + + +# Option + +Declares a method parameter as a user-supplied command option. + +```php +use Tempcord\Attributes\Option; +``` + +**Applies to:** parameter + +## Parameters + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `description` | `string` | *required* | shown beneath the option in Discord's picker | +| `name` | `?string` | `null` | defaults to the parameter's own name | +| `autocomplete` | `?Autocomplete` | `null` | suggests values as the user types; mutually exclusive with choices | +| `choices` | `array` | `[]` | the only values Discord will accept. A map uses its keys as the labels users see; a list shows each value as its own label. Mutually exclusive with autocomplete. | +| `minValue` | `int\|float\|null` | `null` | smallest accepted number | +| `maxValue` | `int\|float\|null` | `null` | largest accepted number | +| `minLength` | `?int` | `null` | shortest accepted string | +| `maxLength` | `?int` | `null` | longest accepted string | +| `channelTypes` | `array` | `[]` | restricts which channels may be picked, for a Channel option | + diff --git a/docs/reference/attributes/subcommand-group.md b/docs/reference/attributes/subcommand-group.md new file mode 100644 index 0000000..e1e60be --- /dev/null +++ b/docs/reference/attributes/subcommand-group.md @@ -0,0 +1,19 @@ + + +# SubcommandGroup + +Groups every subcommand its class declares under one more level of nesting. + +```php +use Tempcord\Attributes\SubcommandGroup; +``` + +**Applies to:** class + +## Parameters + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `BackedEnum\|string` | *required* | | +| `description` | `string` | *required* | | + diff --git a/docs/reference/attributes/subcommand.md b/docs/reference/attributes/subcommand.md new file mode 100644 index 0000000..e882865 --- /dev/null +++ b/docs/reference/attributes/subcommand.md @@ -0,0 +1,19 @@ + + +# Subcommand + +Declares a public method as a subcommand of the command its class declares. + +```php +use Tempcord\Attributes\Subcommand; +``` + +**Applies to:** method + +## Parameters + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `BackedEnum\|string` | *required* | | +| `description` | `string` | *required* | | + diff --git a/docs/reference/autocomplete/array-autocomplete.md b/docs/reference/autocomplete/array-autocomplete.md new file mode 100644 index 0000000..328df52 --- /dev/null +++ b/docs/reference/autocomplete/array-autocomplete.md @@ -0,0 +1,15 @@ + + +# ArrayAutocomplete + +```php +use Tempcord\AutoCompletes\ArrayAutocomplete; +``` + +## Parameters + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `items` | `array` | *required* | | +| `isList` | `bool` | `false` | | + diff --git a/docs/reference/autocomplete/autocomplete.md b/docs/reference/autocomplete/autocomplete.md new file mode 100644 index 0000000..bebf4cf --- /dev/null +++ b/docs/reference/autocomplete/autocomplete.md @@ -0,0 +1,12 @@ + + +# Autocomplete + +```php +use Tempcord\Interfaces\Autocomplete; +``` + +## Methods + +### `handle(CommandInteraction $interaction, mixed $value): mixed` + diff --git a/docs/reference/configuration/tempcord-config.md b/docs/reference/configuration/tempcord-config.md new file mode 100644 index 0000000..8c1d311 --- /dev/null +++ b/docs/reference/configuration/tempcord-config.md @@ -0,0 +1,15 @@ + + +# TempcordConfig + +```php +use Tempcord\TempcordConfig; +``` + +## Parameters + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `token` | `string` | *required* | | +| `intents` | `Bitwise` | *required* | | + diff --git a/docs/reference/enums/discord-locale.md b/docs/reference/enums/discord-locale.md new file mode 100644 index 0000000..cf2e93c --- /dev/null +++ b/docs/reference/enums/discord-locale.md @@ -0,0 +1,55 @@ + + +# DiscordLocale + +The locales Discord accepts for name and description localizations. + +```php +use Tempcord\Enums\DiscordLocale; +``` + +## Methods + +### `tempestLocale(): string` + +The Tempest locale this maps to. + +## Cases + +| Case | Value | +| --- | --- | +| `INDONESIAN` | `id` | +| `DANISH` | `da` | +| `GERMAN` | `de` | +| `ENGLISH_UK` | `en-GB` | +| `ENGLISH_US` | `en-US` | +| `SPANISH` | `es-ES` | +| `SPANISH_LATAM` | `es-419` | +| `FRENCH` | `fr` | +| `CROATIAN` | `hr` | +| `ITALIAN` | `it` | +| `LITHUANIAN` | `lt` | +| `HUNGARIAN` | `hu` | +| `DUTCH` | `nl` | +| `NORWEGIAN` | `no` | +| `POLISH` | `pl` | +| `PORTUGUESE_BR` | `pt-BR` | +| `ROMANIAN` | `ro` | +| `FINNISH` | `fi` | +| `SWEDISH` | `sv-SE` | +| `VIETNAMESE` | `vi` | +| `TURKISH` | `tr` | +| `CZECH` | `cs` | +| `GREEK` | `el` | +| `BULGARIAN` | `bg` | +| `RUSSIAN` | `ru` | +| `UKRAINIAN` | `uk` | +| `HINDI` | `hi` | +| `THAI` | `th` | +| `CHINESE_CHINA` | `zh-CN` | +| `JAPANESE` | `ja` | +| `CHINESE_TAIWAN` | `zh-TW` | +| `KOREAN` | `ko` | +| `ARABIC` | `ar` | +| `HEBREW` | `he` | + diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 0000000..e01c499 --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,29 @@ + + +# API reference + +## Attributes + +- [Command](attributes/command.md) — Declares a class as a Discord application command. +- [SubcommandGroup](attributes/subcommand-group.md) — Groups every subcommand its class declares under one more level of nesting. +- [Subcommand](attributes/subcommand.md) — Declares a public method as a subcommand of the command its class declares. +- [Option](attributes/option.md) — Declares a method parameter as a user-supplied command option. +- [Event](attributes/event.md) — Declares an invokable class as a listener for a Discord gateway event. + +## Autocomplete + +- [Autocomplete](autocomplete/autocomplete.md) +- [ArrayAutocomplete](autocomplete/array-autocomplete.md) + +## Configuration + +- [TempcordConfig](configuration/tempcord-config.md) + +## Enums + +- [DiscordLocale](enums/discord-locale.md) — The locales Discord accepts for name and description localizations. + +## Plugins + +- [Plugin](plugins/plugin.md) — A package that extends a bot with its own behaviour. + diff --git a/docs/reference/plugins/plugin.md b/docs/reference/plugins/plugin.md new file mode 100644 index 0000000..687ceef --- /dev/null +++ b/docs/reference/plugins/plugin.md @@ -0,0 +1,16 @@ + + +# Plugin + +A package that extends a bot with its own behaviour. + +```php +use Tempcord\Plugins\Plugin; +``` + +## Methods + +### `boot(Tempcord $tempcord): void` + +Called once, after commands and events are bound and before the gateway opens. + diff --git a/phpstan.neon b/phpstan.neon index 9933d7c..b35a2d9 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,5 +3,6 @@ parameters: paths: - src - tests + - tools excludePaths: - tests/Fixtures diff --git a/src/AllCommandExtension.php b/src/AllCommandExtension.php deleted file mode 100644 index adee070..0000000 --- a/src/AllCommandExtension.php +++ /dev/null @@ -1,95 +0,0 @@ -commandListener = new FilteredEventEmitter( - $discord->gateway->events, - Events::INTERACTION_CREATE, - fn(InteractionCreate $interactionCreate) => isset($interactionCreate->type) - && ($interactionCreate->type === InteractionType::APPLICATION_COMMAND || $interactionCreate->type === InteractionType::APPLICATION_COMMAND_AUTOCOMPLETE) - && $this->emitInteraction($interactionCreate) - ); - - $this->commandListener->on(Events::INTERACTION_CREATE, function (InteractionCreate $interaction) use ($discord) { - - if ($interaction->type === InteractionType::APPLICATION_COMMAND) { - $this->handleInteraction($interaction, $discord); - return null; - } - - if ($interaction->type === InteractionType::APPLICATION_COMMAND_AUTOCOMPLETE) { - $this->handleInteractionAutocomplete($interaction, $discord); - return null; - } - - }); - - $this->commandListener->start(); - } - - public function bind(string $command, callable $listener, callable $autocomplete): void - { - $this->on($command, $listener); - $this->on($command . '.autocomplete', $autocomplete); - } - - - private function handleInteraction(InteractionCreate $interaction, Discord $discord): void - { - $commandName = $this->getFullNameByInteraction($interaction); - $firedCommand = new CommandInteraction($interaction, $discord); - - $this->emit($commandName, [$firedCommand]); - } - - private function handleInteractionAutocomplete(InteractionCreate $interaction, Discord $discord): void - { - $commandName = $this->getFullNameByInteraction($interaction); - $firedCommand = new CommandInteraction($interaction, $discord); - - $this->emit($commandName . '.autocomplete', [$firedCommand]); - } - - - protected function getFullNameByInteraction(InteractionCreate $command): string - { - $names = [$command->data->name]; - - $this->drillName($command->data->options ?? [], $names); - - return implode('.', $names); - } - - private function drillName(array $options, array &$names): void - { - /** @var ?ApplicationCommandInteractionDataOptionStructure $subCommand */ - $subCommand = array_find($options, function (ApplicationCommandInteractionDataOptionStructure $option) { - return in_array($option->type, [ - ApplicationCommandOptionType::SUB_COMMAND, - ApplicationCommandOptionType::SUB_COMMAND_GROUP, - ], true); - }); - - if (!is_null($subCommand)) { - $names[] = $subCommand->name; - - $this->drillName($subCommand->options ?? [], $names); - } - } - - -} \ No newline at end of file diff --git a/src/Attributes/Command.php b/src/Attributes/Command.php index 14d1c46..4728b91 100644 --- a/src/Attributes/Command.php +++ b/src/Attributes/Command.php @@ -5,156 +5,45 @@ use Attribute; use BackedEnum; use Ragnarok\Fenrir\Enums\ApplicationCommandTypes; -use Ragnarok\Fenrir\Exceptions\Rest\Helpers\Command\InvalidCommandNameException; -use Ragnarok\Fenrir\Rest\Helpers\Command\CommandBuilder; -use RuntimeException; -use Tempcord\Traits\HasAttributes; -use Tempest\Reflection\ClassReflector; -use function Tempest\Support\Arr\dot; -use function Tempest\Support\str; - +use Ragnarok\Fenrir\Enums\Permission; + +/** + * Declares a class as a Discord application command. + * + * This is a plain declaration and nothing more. Everything it implies — the + * command's name when none is given, its options, the methods that handle it — + * is worked out by the CommandCompiler at discovery time. + */ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] -final class Command +final readonly class Command { - use HasAttributes; - - /** - * Computed getter for command name - * - * If none command is provided - it will take name from the classname - * - * @var string - */ - public string $name { - get { - if ($this->hasAttribute('name')) { - return $this->getAttribute('name') instanceof BackedEnum ? $this->getAttribute('name')->value : $this->getAttribute('name'); - } - - $commandName = str($this->reflector->getShortName()) - ->replaceEnd('Command', '') - ->replaceStart('Command', '') - ->snake('_') - ->lower(); - - return $commandName->toString(); - } - } - - public array $handlers { - get { - $keys[$this->name] = []; - foreach ($this->options as $option) { - if (in_array(get_class($option), [SubcommandGroup::class, Subcommand::class])) { - $keys[$this->name][$option->name] = $option->key; - } else { - $fakeSubcommand = new Subcommand(name: $option->name, description: $option->description); - $fakeSubcommand->reflector = $this->reflector->getMethod('__invoke'); - $keys[$this->name] = $fakeSubcommand->key; - } - } - return dot($keys); - } - } - - /** - * @throws InvalidCommandNameException - */ - public CommandBuilder $build { - get { - $command = CommandBuilder::new() - ->setName($this->name) - ->setNsfw($this->isNsfw) - ->setDmPermission($this->directMessage) - ->setType($this->type); - - if ($this->type === ApplicationCommandTypes::CHAT_INPUT) { - - if (!$this->description) { - throw new \LogicException("Description for command [$this->name] is required when type=CHAT_INPUT"); - } - - $command->setDescription($this->description); - } - - - foreach ($this->options as $option) { - $command->addOption($option->build); - } - - return $command; - } - } - - /** @var array */ - public array $options { - get { - // A command may legitimately declare no options at all, so start from a list. - $options = $this->getAttribute('options') ?? []; - - if ($this->reflector->hasAttribute(SubcommandGroup::class)) { - /** @var SubcommandGroup $subcommandGroup */ - $subcommandGroup = $this->reflector->getAttribute(SubcommandGroup::class); - $subcommandGroup->reflector = $this->reflector; - $options[$subcommandGroup->name] = $subcommandGroup; - } else { - // This is subcommands without a group - $fakeSubcommandGroup = new SubcommandGroup(name: 'fake', description: 'fake'); - $fakeSubcommandGroup->reflector = $this->reflector; - foreach ($fakeSubcommandGroup->options as $option) { - $options[$option->name] = $option; - } - - if (empty($options)) { - /* - * We assume that there is no Subcommands found and this is an invokable command - * User should provide the __invoke command, so we actually can read the method options (if there are some) - */ - if (!$this->reflector->getReflection()->hasMethod('__invoke')) { - throw new RuntimeException('Class [' . $this->reflector->getName() . '] should declare public sub-commands or have an __invoke method'); - } - - $fakeSubcommand = new Subcommand(name: 'fake', description: 'fake'); - $fakeSubcommand->reflector = $this->reflector->getMethod('__invoke'); - foreach ($fakeSubcommand->options as $option) { - $options[$option->name] = $option; - } - } - } - - $this->setAttribute('options', $options); - - return $this->getAttribute('options'); - } - set { - $this->setAttribute('options', array_merge($this->getAttribute('options') ?? [], $value)); - } - } - - public ClassReflector $reflector; - /** * The guild this command is scoped to, or null when it is registered globally. */ public ?string $guildId; + /** + * @param string|BackedEnum|null $name defaults to the class name, with a + * Command prefix or suffix stripped and the rest snake_cased + * @param list $permissions the permissions a member needs by + * default; an empty list leaves the command unrestricted + * @param string|null $translationKey the catalog key this command's + * translations live under. Keys for everything beneath it are + * derived from position, so "commands.music" gives + * commands.music.description for the command, + * commands.music.playlist.play.description for a subcommand, and + * commands.music.playlist.play.title.description for its option. + */ public function __construct( - string|BackedEnum|null $name = null, - public ?string $description = null, - int|string|null $guildId = null, - public bool $isNsfw = false, - public array $permissions = [], - public bool $directMessage = true, - public ApplicationCommandTypes $type = ApplicationCommandTypes::CHAT_INPUT - ) - { + public string|BackedEnum|null $name = null, + public ?string $description = null, + int|string|null $guildId = null, + public bool $isNsfw = false, + public array $permissions = [], + public bool $directMessage = true, + public ApplicationCommandTypes $type = ApplicationCommandTypes::CHAT_INPUT, + public ?string $translationKey = null, + ) { $this->guildId = $guildId === null ? null : (string) $guildId; - - $this->setAttribute('name', $name); - } - - public function mergeOptions(Command $command): void - { - $this->options = $command->options; } -} \ No newline at end of file +} diff --git a/src/Attributes/Event.php b/src/Attributes/Event.php index 5de1b7d..9561d54 100644 --- a/src/Attributes/Event.php +++ b/src/Attributes/Event.php @@ -3,28 +3,14 @@ namespace Tempcord\Attributes; use Attribute; -use RuntimeException; -use Tempest\Reflection\ClassReflector; -use Tempest\Reflection\MethodReflector; -#[Attribute] -class Event +/** + * Declares an invokable class as a listener for a Discord gateway event. + */ +#[Attribute(Attribute::TARGET_CLASS)] +final readonly class Event { - public ClassReflector $reflector; - - public MethodReflector $handler { - get { - if (!$this->reflector->getReflection()->hasMethod('__invoke')) { - throw new RuntimeException('Class [' . $this->reflector->getName() . '] should declare have an __invoke method'); - } - - return $this->reflector->getMethod('__invoke'); - } - } - public function __construct( public string $name, - ) - { - } -} \ No newline at end of file + ) {} +} diff --git a/src/Attributes/Option.php b/src/Attributes/Option.php index 8a23eb5..57f8618 100644 --- a/src/Attributes/Option.php +++ b/src/Attributes/Option.php @@ -3,118 +3,44 @@ namespace Tempcord\Attributes; use Attribute; -use LogicException; -use Ragnarok\Fenrir\Enums\ApplicationCommandOptionType; -use Ragnarok\Fenrir\Interaction\CommandInteraction; -use Ragnarok\Fenrir\Parts\ApplicationCommandInteractionDataOptionStructure; -use Ragnarok\Fenrir\Parts\Channel; -use Ragnarok\Fenrir\Parts\Role; -use Ragnarok\Fenrir\Parts\User; -use Ragnarok\Fenrir\Rest\Helpers\Command\CommandOptionBuilder; -use RuntimeException; +use Ragnarok\Fenrir\Enums\ChannelType; use Tempcord\Interfaces\Autocomplete; -use Tempcord\Tempcord; -use Tempcord\Traits\HasAttributes; -use Tempest\Reflection\ParameterReflector; -use Throwable; -use function React\Async\await; -use function Tempest\Container\get; -use function Tempest\Support\str; +/** + * Declares a method parameter as a user-supplied command option. + * + * The option's Discord type comes from the parameter's PHP type and whether it + * is required comes from whether the parameter has a default, both resolved by + * the CommandCompiler. + */ #[Attribute(Attribute::TARGET_PARAMETER)] -final class Option +final readonly class Option { - use HasAttributes; - - public ParameterReflector $reflector; - - public string $name { - get { - if ($this->hasAttribute('name')) { - return $this->getAttribute('name'); - } - - return str($this->reflector->getName())->toString(); - } - } - - public ApplicationCommandOptionType $type { - get { - if (!$this->reflector->getReflection()->hasType()) { - throw new LogicException('Command option does not have type'); - } - - $type = $this->reflector->getType(); - - //array, bool, callable, float, int, null, object, false, iterable, mixed, never, true, void, - return match ($type->getName()) { - 'string' => ApplicationCommandOptionType::STRING, - 'int' => ApplicationCommandOptionType::INTEGER, - 'float' => ApplicationCommandOptionType::NUMBER, - 'bool' => ApplicationCommandOptionType::BOOLEAN, - User::class => ApplicationCommandOptionType::USER, - Channel::class => ApplicationCommandOptionType::CHANNEL, - Role::class => ApplicationCommandOptionType::ROLE, - //@todo Add more options: Mentionable - default => throw new LogicException('Command option type not supported'), - //@todo maybe add some DTO mapper!? To map modal data to an object - }; - } - } - - public bool $isRequired { - get { - return !$this->reflector->isOptional(); - } - } - - public CommandOptionBuilder $build { - get { - return CommandOptionBuilder::new() - ->setName($this->name) - ->setDescription($this->description) - ->setRequired($this->isRequired) - ->setType($this->type) - ->setAutoComplete($this->autocomplete !== null); - } - } - - public function __construct( - //@todo Add more options for building the Option (from DiscordPHP) - public string $description, - ?string $name = null, - public ?Autocomplete $autocomplete = null, - ) - { - $this->setAttribute('name', $name); - } - /** - * @param ApplicationCommandInteractionDataOptionStructure|null $option - * @param CommandInteraction $interaction - * @return mixed - * @throws Throwable + * @param string $description shown beneath the option in Discord's picker + * @param string|null $name defaults to the parameter's own name + * @param Autocomplete|null $autocomplete suggests values as the user types; + * mutually exclusive with choices + * @param array|list $choices + * the only values Discord will accept. A map uses its keys as the + * labels users see; a list shows each value as its own label. + * Mutually exclusive with autocomplete. + * @param int|float|null $minValue smallest accepted number + * @param int|float|null $maxValue largest accepted number + * @param int|null $minLength shortest accepted string + * @param int|null $maxLength longest accepted string + * @param list $channelTypes restricts which channels may be + * picked, for a Channel option */ - public function mapValue(?ApplicationCommandInteractionDataOptionStructure $option, CommandInteraction $interaction): mixed - { - if (!$option) { - return null; - } - - $tempcord = get(Tempcord::class); - - - if ($option->type === ApplicationCommandOptionType::USER) { - await($tempcord->discord->rest->user->get($option->value)); - } - - return match ($option->type) { - ApplicationCommandOptionType::USER => await($tempcord->discord->rest->user->get($option->value)), - ApplicationCommandOptionType::CHANNEL => await($tempcord->discord->rest->channel->get($option->value)), - ApplicationCommandOptionType::ROLE => await($tempcord->discord->rest->guild->getRole($interaction->interaction->guild_id, $option->value)), - //@todo need a proxy object that will proxy all props to Channel or User - ApplicationCommandOptionType::MENTIONABLE => throw new RuntimeException('Not implemented'), - default => $option->value, - }; - } -} \ No newline at end of file + public function __construct( + public string $description, + public ?string $name = null, + public ?Autocomplete $autocomplete = null, + public array $choices = [], + public int|float|null $minValue = null, + public int|float|null $maxValue = null, + public ?int $minLength = null, + public ?int $maxLength = null, + public array $channelTypes = [], + ) {} +} diff --git a/src/Attributes/Subcommand.php b/src/Attributes/Subcommand.php index 80a66d2..aeee54f 100644 --- a/src/Attributes/Subcommand.php +++ b/src/Attributes/Subcommand.php @@ -4,115 +4,15 @@ use Attribute; use BackedEnum; -use InvalidArgumentException; -use Ragnarok\Fenrir\Enums\ApplicationCommandOptionType; -use Ragnarok\Fenrir\Interaction\CommandInteraction; -use Ragnarok\Fenrir\Rest\Helpers\Command\CommandOptionBuilder; -use React\Promise\PromiseInterface; -use Tempcord\Traits\HasAttributes; -use Tempest\Reflection\MethodReflector; -use function React\Async\async; -use function React\Async\await; -use function Tempest\Container\get; +/** + * Declares a public method as a subcommand of the command its class declares. + */ #[Attribute(Attribute::TARGET_METHOD)] -final class Subcommand +final readonly class Subcommand { - use HasAttributes; - - public string $name { - get { - $name = $this->getAttribute('name'); - return $name instanceof BackedEnum ? $name->value : $name; - } - } - public MethodReflector $reflector; - - public CommandOptionBuilder $build { - get { - $subcommand = new CommandOptionBuilder() - ->setName($this->name) - ->setDescription($this->description) - ->setType(ApplicationCommandOptionType::SUB_COMMAND); - - foreach ($this->options as $option) { - $subcommand->addOption($option->build); - } - - return $subcommand; - } - } - - /** - * @var array - */ - public array $options { - get { - $options = []; - foreach ($this->reflector->getParameters() as $parameter) { - if ($parameter->hasAttribute(Option::class)) { - /** @var Option $option */ - $option = $parameter->getAttribute(Option::class); - $option->reflector = $parameter; - $options[$parameter->getName()] = $option; - } - } - return $options; - } - } - public \Closure $key { - get { - return function (CommandInteraction $interaction) { - - - $subcommandName = $interaction->getSubCommandName() ? str_replace(':', '.', $interaction->getSubCommandName()) : null; - - $getArgs = async(function () use ($interaction, $subcommandName) { - $args = [ - 'interaction' => $interaction - ]; - foreach ($this->options as $option) { - $args[$option->name] = $option->mapValue($interaction->getOption( - path: $subcommandName ? $subcommandName . '.' . $option->name : $option->name - ), $interaction); - } - return $args; - }); - - - $getArgs()->then(function (array $args) { - $class = get($this->reflector->getDeclaringClass()->getName()); - $this->invokeNamedArgs($class, $args); - }); - - }; - } - } - public function __construct( - string|BackedEnum $name, - public string $description - ) - { - $this->setAttribute('name', $name); - } - - public function invokeNamedArgs(?object $object, array $namedArgs): mixed - { - $ordered = []; - - foreach ($this->reflector->getParameters() as $param) { - $name = $param->getName(); - - if (array_key_exists($name, $namedArgs)) { - $ordered[] = $namedArgs[$name]; - } elseif ($param->isOptional()) { - $ordered[] = $param->getDefaultValue(); - } else { - throw new InvalidArgumentException("Missing required parameter: $name in method {$this->reflector->getShortName()}"); - } - } - - return $this->reflector->invokeArgs($object, $ordered); - } -} \ No newline at end of file + public string|BackedEnum $name, + public string $description, + ) {} +} diff --git a/src/Attributes/SubcommandGroup.php b/src/Attributes/SubcommandGroup.php index 511c204..29054d4 100644 --- a/src/Attributes/SubcommandGroup.php +++ b/src/Attributes/SubcommandGroup.php @@ -4,74 +4,15 @@ use Attribute; use BackedEnum; -use Ragnarok\Fenrir\Enums\ApplicationCommandOptionType; -use Ragnarok\Fenrir\Rest\Helpers\Command\CommandOptionBuilder; -use Tempcord\Traits\HasAttributes; -use Tempest\Reflection\ClassReflector; -use function Tempest\Support\Arr\dot; +/** + * Groups every subcommand its class declares under one more level of nesting. + */ #[Attribute(Attribute::TARGET_CLASS)] -final class SubcommandGroup +final readonly class SubcommandGroup { - use HasAttributes; - - public string $name { - get { - $name = $this->getAttribute('name'); - return $name instanceof BackedEnum ? $name->value : $name; - } - } - - public array $key { - get { - $keys = []; - foreach ($this->options as $option) { - $keys[$option->name] = $option->key; - } - return $keys; - } - } - - public ClassReflector $reflector; - - public CommandOptionBuilder $build { - get { - $subcommandGroup = CommandOptionBuilder::new() - ->setName($this->name) - ->setDescription($this->description) - ->setType(ApplicationCommandOptionType::SUB_COMMAND_GROUP); - - foreach ($this->options as $option) { - $subcommandGroup->addOption($option->build); - } - - return $subcommandGroup; - } - } - - /** @var array */ - public array $options { - get { - $options = []; - foreach ($this->reflector->getPublicMethods() as $method) { - if ($method->hasAttribute(Subcommand::class)) { - /** @var Subcommand $subcommand */ - $subcommand = $method->getAttribute(Subcommand::class); - $subcommand->reflector = $method; - $options[$subcommand->name] = $subcommand; - } - } - return $options; - } - } - - public function __construct( - string|BackedEnum $name, - public string $description - ) - { - $this->setAttribute('name', $name); - } - -} \ No newline at end of file + public string|BackedEnum $name, + public string $description, + ) {} +} diff --git a/src/Compiler/CommandCompiler.php b/src/Compiler/CommandCompiler.php new file mode 100644 index 0000000..4af9005 --- /dev/null +++ b/src/Compiler/CommandCompiler.php @@ -0,0 +1,300 @@ + ApplicationCommandOptionType::STRING, + 'int' => ApplicationCommandOptionType::INTEGER, + 'float' => ApplicationCommandOptionType::NUMBER, + 'bool' => ApplicationCommandOptionType::BOOLEAN, + User::class => ApplicationCommandOptionType::USER, + Channel::class => ApplicationCommandOptionType::CHANNEL, + Role::class => ApplicationCommandOptionType::ROLE, + ]; + + public function compile(ClassReflector $class, Command $command): CommandDefinition + { + $name = $this->nameOf($class, $command); + $options = []; + $handlers = []; + + $key = $command->translationKey; + $group = $this->groupOf($class, $key); + $subcommands = $this->subcommandsOf($class, $key); + + if ($group !== null) { + $options[$group->name] = $group; + + foreach ($group->subcommands as $subcommand) { + $path = $name . '.' . $group->name . '.' . $subcommand->name; + + $handlers[$path] = new HandlerDefinition( + path: $path, + method: $subcommand->method, + options: $subcommand->options, + optionPath: $group->name . '.' . $subcommand->name, + ); + } + } elseif ($subcommands !== []) { + foreach ($subcommands as $subcommand) { + $options[$subcommand->name] = $subcommand; + $path = $name . '.' . $subcommand->name; + + $handlers[$path] = new HandlerDefinition( + path: $path, + method: $subcommand->method, + options: $subcommand->options, + optionPath: $subcommand->name, + ); + } + } else { + /* + * No subcommands anywhere means __invoke is the command, and its + * parameters are the command's options. + */ + $invoke = $this->invokerOf($class); + $options = $this->optionsOf($invoke, $key); + + $handlers[$name] = new HandlerDefinition( + path: $name, + method: $invoke, + options: $options, + ); + } + + if ($command->type === ApplicationCommandTypes::CHAT_INPUT && $command->description === null) { + throw new LogicException("Description for command [{$name}] is required when type=CHAT_INPUT"); + } + + return new CommandDefinition( + name: $name, + description: $command->description, + guildId: $command->guildId, + isNsfw: $command->isNsfw, + directMessage: $command->directMessage, + type: $command->type, + permissions: $command->permissions, + options: $options, + handlers: $handlers, + nameLocalizations: $this->translate($key, 'name'), + descriptionLocalizations: $this->translate($key, 'description'), + ); + } + + /** + * An explicit name wins; otherwise the class name carries it, with the + * conventional Command prefix or suffix stripped and the rest snake_cased. + */ + private function nameOf(ClassReflector $class, Command $command): string + { + if ($command->name !== null) { + return $command->name instanceof BackedEnum + ? (string) $command->name->value + : $command->name; + } + + return str($class->getShortName()) + ->replaceEnd('Command', '') + ->replaceStart('Command', '') + ->snake('_') + ->lower() + ->toString(); + } + + private function groupOf(ClassReflector $class, ?string $key): ?SubcommandGroupDefinition + { + if (!$class->hasAttribute(SubcommandGroup::class)) { + return null; + } + + /** @var SubcommandGroup $group */ + $group = $class->getAttribute(SubcommandGroup::class); + $name = $this->valueOf($group->name); + $groupKey = $this->nest($key, $name); + + return new SubcommandGroupDefinition( + name: $name, + description: $group->description, + subcommands: $this->subcommandsOf($class, $groupKey), + nameLocalizations: $this->translate($groupKey, 'name'), + descriptionLocalizations: $this->translate($groupKey, 'description'), + ); + } + + /** + * @return array + */ + private function subcommandsOf(ClassReflector $class, ?string $key): array + { + $subcommands = []; + + foreach ($class->getPublicMethods() as $method) { + if (!$method->hasAttribute(Subcommand::class)) { + continue; + } + + /** @var Subcommand $subcommand */ + $subcommand = $method->getAttribute(Subcommand::class); + $name = $this->valueOf($subcommand->name); + + $subcommandKey = $this->nest($key, $name); + + $subcommands[$name] = new SubcommandDefinition( + name: $name, + description: $subcommand->description, + options: $this->optionsOf($method, $subcommandKey), + method: $method, + nameLocalizations: $this->translate($subcommandKey, 'name'), + descriptionLocalizations: $this->translate($subcommandKey, 'description'), + ); + } + + return $subcommands; + } + + private function invokerOf(ClassReflector $class): MethodReflector + { + if (!$class->getReflection()->hasMethod('__invoke')) { + throw new RuntimeException( + 'Class [' . $class->getName() . '] should declare public sub-commands or have an __invoke method', + ); + } + + return $class->getMethod('__invoke'); + } + + /** + * @return array + */ + private function optionsOf(MethodReflector $method, ?string $key): array + { + $options = []; + + foreach ($method->getParameters() as $parameter) { + if (!$parameter->hasAttribute(Option::class)) { + continue; + } + + /** @var Option $option */ + $option = $parameter->getAttribute(Option::class); + $name = $option->name ?? $parameter->getName(); + + $optionKey = $this->nest($key, $name); + + $options[$name] = new OptionDefinition( + name: $name, + description: $option->description, + type: $this->typeOf($parameter), + isRequired: !$parameter->isOptional(), + autocomplete: $option->autocomplete, + parameter: $parameter, + choices: $this->choicesOf($option), + minValue: $option->minValue, + maxValue: $option->maxValue, + minLength: $option->minLength, + maxLength: $option->maxLength, + channelTypes: $option->channelTypes, + nameLocalizations: $this->translate($optionKey, 'name'), + descriptionLocalizations: $this->translate($optionKey, 'description'), + ); + } + + return $options; + } + + /** + * A list of choices labels each entry with itself; a map uses its keys as + * the labels, matching how ArrayAutocomplete already reads its items. + * + * @return array + */ + private function choicesOf(Option $option): array + { + if ($option->choices === []) { + return []; + } + + if (!array_is_list($option->choices)) { + return $option->choices; + } + + $choices = []; + + foreach ($option->choices as $choice) { + $choices[(string) $choice] = $choice; + } + + return $choices; + } + + private function typeOf(ParameterReflector $parameter): ApplicationCommandOptionType + { + if (!$parameter->getReflection()->hasType()) { + throw new LogicException('Command option does not have type'); + } + + return self::OPTION_TYPES[$parameter->getType()->getName()] + ?? throw new LogicException('Command option type not supported'); + } + + /** + * Extends a translation key one step down the command tree. Null stays + * null, so a command that declares no key localizes nothing. + */ + private function nest(?string $key, string $segment): ?string + { + return $key === null ? null : $key . '.' . $segment; + } + + /** + * @return array + */ + private function translate(?string $key, string $field): array + { + return $key === null ? [] : $this->localizations->forKey($key . '.' . $field); + } + + private function valueOf(string|BackedEnum $name): string + { + return $name instanceof BackedEnum ? (string) $name->value : $name; + } +} diff --git a/src/Compiler/EventCompiler.php b/src/Compiler/EventCompiler.php new file mode 100644 index 0000000..47d53c1 --- /dev/null +++ b/src/Compiler/EventCompiler.php @@ -0,0 +1,26 @@ +getReflection()->hasMethod('__invoke')) { + throw new RuntimeException( + 'Class [' . $class->getName() . '] should declare an __invoke method', + ); + } + + return new EventDefinition( + name: $event->name, + listener: $class->getName(), + method: $class->getMethod('__invoke'), + ); + } +} diff --git a/src/ConsoleCommands/BootCommand.php b/src/ConsoleCommands/BootCommand.php index 3b213cf..77661de 100644 --- a/src/ConsoleCommands/BootCommand.php +++ b/src/ConsoleCommands/BootCommand.php @@ -2,6 +2,7 @@ namespace Tempcord\ConsoleCommands; +use Tempcord\Runtime\OutcomeReporter; use Tempcord\Tempcord; use Tempest\Console\Console; use Tempest\Console\ConsoleArgument; @@ -11,22 +12,26 @@ { public function __construct( private Tempcord $tempcord, - private Console $console + private Console $console, + private OutcomeReporter $reporter, ) {} #[ConsoleCommand(name: 'boot', description: 'Boots the bot')] public function __invoke( #[ConsoleArgument( description: 'Register bot commands', - aliases: ['r'] + aliases: ['r'], )] - bool $register = false + bool $register = false, ): void { if ($register) { $this->console->header('Registering commands...'); - $this->tempcord->registerCommands(); + $this->reporter->report($this->tempcord->registerCommands()); } + $this->console->header('Starting commands and events...'); + $this->reporter->report($this->tempcord->listen()); + $this->console->header('Booting up...'); $this->tempcord->boot(); } diff --git a/src/Definitions/CommandDefinition.php b/src/Definitions/CommandDefinition.php new file mode 100644 index 0000000..0e61c40 --- /dev/null +++ b/src/Definitions/CommandDefinition.php @@ -0,0 +1,78 @@ + $options + * the command's direct children, in the shape Discord expects + * @param array $handlers keyed by dotted interaction path + * @param list $permissions + * @param array $nameLocalizations keyed by Discord locale + * @param array $descriptionLocalizations keyed by Discord locale + */ + public function __construct( + public string $name, + public ?string $description, + public ?string $guildId, + public bool $isNsfw, + public bool $directMessage, + public ApplicationCommandTypes $type, + public array $permissions, + public array $options, + public array $handlers, + public array $nameLocalizations = [], + public array $descriptionLocalizations = [], + ) {} + + public function isGlobal(): bool + { + return $this->guildId === null; + } + + /** + * Commands are keyed by name and scoped by guild, so a guild command + * collides neither with another command in the same guild nor with the + * global command of the same name. + * + * Discord command names cannot contain ":", so the two key shapes can + * never overlap. + */ + public function key(): string + { + return $this->guildId === null + ? $this->name + : $this->guildId . ':' . $this->name; + } + + /** + * Folds another declaration of the same command into this one, so a command + * may be split across several classes. Options and handlers from both sides + * survive; everything else is taken from the earlier declaration. + */ + public function mergedWith(self $other): self + { + return new self( + name: $this->name, + description: $this->description ?? $other->description, + guildId: $this->guildId, + isNsfw: $this->isNsfw, + directMessage: $this->directMessage, + type: $this->type, + permissions: $this->permissions, + options: [...$this->options, ...$other->options], + handlers: [...$this->handlers, ...$other->handlers], + nameLocalizations: $this->nameLocalizations, + descriptionLocalizations: $this->descriptionLocalizations, + ); + } +} diff --git a/src/Definitions/EventDefinition.php b/src/Definitions/EventDefinition.php new file mode 100644 index 0000000..9c8d2b5 --- /dev/null +++ b/src/Definitions/EventDefinition.php @@ -0,0 +1,17 @@ + $options keyed by option name + * @param string $optionPath the prefix getOption() needs to reach this + * handler's options, empty for an invokable command + */ + public function __construct( + public string $path, + public MethodReflector $method, + public array $options, + public string $optionPath = '', + ) {} + + /** + * Where in the interaction payload the given option will be found. + */ + public function pathTo(OptionDefinition $option): string + { + return $this->optionPath === '' + ? $option->name + : $this->optionPath . '.' . $option->name; + } +} diff --git a/src/Definitions/OptionDefinition.php b/src/Definitions/OptionDefinition.php new file mode 100644 index 0000000..451737e --- /dev/null +++ b/src/Definitions/OptionDefinition.php @@ -0,0 +1,43 @@ + $choices keyed by the label users see + * @param list $channelTypes + * @param array $nameLocalizations keyed by Discord locale + * @param array $descriptionLocalizations keyed by Discord locale + */ + public function __construct( + public string $name, + public string $description, + public ApplicationCommandOptionType $type, + public bool $isRequired, + public ?Autocomplete $autocomplete, + public ParameterReflector $parameter, + public array $choices = [], + public int|float|null $minValue = null, + public int|float|null $maxValue = null, + public ?int $minLength = null, + public ?int $maxLength = null, + public array $channelTypes = [], + public array $nameLocalizations = [], + public array $descriptionLocalizations = [], + ) {} + + public function hasAutocomplete(): bool + { + return $this->autocomplete !== null; + } +} diff --git a/src/Definitions/SubcommandDefinition.php b/src/Definitions/SubcommandDefinition.php new file mode 100644 index 0000000..6d45923 --- /dev/null +++ b/src/Definitions/SubcommandDefinition.php @@ -0,0 +1,25 @@ + $options keyed by option name + * @param array $nameLocalizations keyed by Discord locale + * @param array $descriptionLocalizations keyed by Discord locale + */ + public function __construct( + public string $name, + public string $description, + public array $options, + public MethodReflector $method, + public array $nameLocalizations = [], + public array $descriptionLocalizations = [], + ) {} +} diff --git a/src/Definitions/SubcommandGroupDefinition.php b/src/Definitions/SubcommandGroupDefinition.php new file mode 100644 index 0000000..9a12210 --- /dev/null +++ b/src/Definitions/SubcommandGroupDefinition.php @@ -0,0 +1,23 @@ + $subcommands keyed by subcommand name + * @param array $nameLocalizations keyed by Discord locale + * @param array $descriptionLocalizations keyed by Discord locale + */ + public function __construct( + public string $name, + public string $description, + public array $subcommands, + public array $nameLocalizations = [], + public array $descriptionLocalizations = [], + ) {} +} diff --git a/src/Discord/AllCommandExtension.php b/src/Discord/AllCommandExtension.php new file mode 100644 index 0000000..4d023f8 --- /dev/null +++ b/src/Discord/AllCommandExtension.php @@ -0,0 +1,57 @@ +commandListener = new FilteredEventEmitter( + $discord->gateway->events, + Events::INTERACTION_CREATE, + fn(InteractionCreate $interactionCreate) => isset($interactionCreate->type) + && in_array($interactionCreate->type, [ + InteractionType::APPLICATION_COMMAND, + InteractionType::APPLICATION_COMMAND_AUTOCOMPLETE, + ], true) + && $this->emitInteraction($interactionCreate), + ); + + $this->commandListener->on( + Events::INTERACTION_CREATE, + function (InteractionCreate $interaction) use ($discord): void { + $name = $this->getFullNameByInteraction($interaction); + + if ($interaction->type === InteractionType::APPLICATION_COMMAND_AUTOCOMPLETE) { + $name .= self::AUTOCOMPLETE_SUFFIX; + } + + $this->emit($name, [new CommandInteraction($interaction, $discord)]); + }, + ); + + $this->commandListener->start(); + } + + public function bind(string $command, callable $listener, callable $autocomplete): void + { + $this->on($command, $listener); + $this->on($command . self::AUTOCOMPLETE_SUFFIX, $autocomplete); + } +} diff --git a/src/Discord/CommandBuilderFactory.php b/src/Discord/CommandBuilderFactory.php new file mode 100644 index 0000000..0bc2810 --- /dev/null +++ b/src/Discord/CommandBuilderFactory.php @@ -0,0 +1,182 @@ + + */ + public function payloadFor(CommandDefinition $command): array + { + $payload = $this->forCommand($command)->get(); + + if ($command->permissions !== []) { + $payload['default_member_permissions'] = (string) Bitwise::from(...$command->permissions)->get(); + } + + return $payload; + } + + public function forCommand(CommandDefinition $command): CommandBuilder + { + $builder = CommandBuilder::new() + ->setName($command->name) + ->setNsfw($command->isNsfw) + ->setDmPermission($command->directMessage) + ->setType($command->type); + + if ($command->type === ApplicationCommandTypes::CHAT_INPUT) { + // The compiler guarantees a description for chat input commands. + $builder->setDescription((string) $command->description); + + if ($command->descriptionLocalizations !== []) { + $builder->setDescriptionLocalizations($command->descriptionLocalizations); + } + } + + if ($command->nameLocalizations !== []) { + $builder->setNameLocalizations($command->nameLocalizations); + } + + foreach ($command->options as $option) { + $builder->addOption($this->forOption($option)); + } + + return $builder; + } + + public function forOption( + SubcommandGroupDefinition|SubcommandDefinition|OptionDefinition $option, + ): CommandOptionBuilder { + return match (true) { + $option instanceof SubcommandGroupDefinition => $this->forGroup($option), + $option instanceof SubcommandDefinition => $this->forSubcommand($option), + default => $this->forParameter($option), + }; + } + + private function forGroup(SubcommandGroupDefinition $group): CommandOptionBuilder + { + $builder = $this->localize( + CommandOptionBuilder::new() + ->setName($group->name) + ->setDescription($group->description) + ->setType(ApplicationCommandOptionType::SUB_COMMAND_GROUP), + $group->nameLocalizations, + $group->descriptionLocalizations, + ); + + foreach ($group->subcommands as $subcommand) { + $builder->addOption($this->forSubcommand($subcommand)); + } + + return $builder; + } + + private function forSubcommand(SubcommandDefinition $subcommand): CommandOptionBuilder + { + $builder = $this->localize( + CommandOptionBuilder::new() + ->setName($subcommand->name) + ->setDescription($subcommand->description) + ->setType(ApplicationCommandOptionType::SUB_COMMAND), + $subcommand->nameLocalizations, + $subcommand->descriptionLocalizations, + ); + + foreach ($subcommand->options as $option) { + $builder->addOption($this->forParameter($option)); + } + + return $builder; + } + + /** + * Localizations are left off entirely when there are none, rather than sent + * as empty maps. + * + * @param array $nameLocalizations + * @param array $descriptionLocalizations + */ + private function localize( + CommandOptionBuilder $builder, + array $nameLocalizations, + array $descriptionLocalizations, + ): CommandOptionBuilder { + if ($nameLocalizations !== []) { + $builder->setNameLocalizations($nameLocalizations); + } + + if ($descriptionLocalizations !== []) { + $builder->setDescriptionLocalizations($descriptionLocalizations); + } + + return $builder; + } + + private function forParameter(OptionDefinition $option): CommandOptionBuilder + { + $builder = $this->localize( + CommandOptionBuilder::new() + ->setName($option->name) + ->setDescription($option->description) + ->setRequired($option->isRequired) + ->setType($option->type) + ->setAutoComplete($option->hasAutocomplete()), + $option->nameLocalizations, + $option->descriptionLocalizations, + ); + + foreach ($option->choices as $label => $value) { + $builder->addChoice($label, $value); + } + + if (!is_null($option->minValue)) { + $builder->setMinValue($option->minValue); + } + + if (!is_null($option->maxValue)) { + $builder->setMaxValue($option->maxValue); + } + + if (!is_null($option->minLength)) { + $builder->setMinLength($option->minLength); + } + + if (!is_null($option->maxLength)) { + $builder->setMaxLength($option->maxLength); + } + + if ($option->channelTypes !== []) { + $builder->setChannelTypes(...$option->channelTypes); + } + + return $builder; + } +} diff --git a/src/InteractionCallbackBuilder.php b/src/Discord/InteractionCallbackBuilder.php similarity index 95% rename from src/InteractionCallbackBuilder.php rename to src/Discord/InteractionCallbackBuilder.php index a75955f..01a2e87 100644 --- a/src/InteractionCallbackBuilder.php +++ b/src/Discord/InteractionCallbackBuilder.php @@ -1,6 +1,6 @@ get(TempcordConfig::class); + + return new Discord( + token: $config->token, + logger: $container->get(Logger::class), + )->withGateway( + intents: $config->intents, + )->withRest(); + } +} diff --git a/src/Discoveries/CommandsDiscovery.php b/src/Discoveries/CommandsDiscovery.php index 8971d00..74f5e19 100644 --- a/src/Discoveries/CommandsDiscovery.php +++ b/src/Discoveries/CommandsDiscovery.php @@ -3,6 +3,7 @@ namespace Tempcord\Discoveries; use Tempcord\Attributes\Command; +use Tempcord\Compiler\CommandCompiler; use Tempcord\Registries\CommandsRegistry; use Tempest\Discovery\Discovery; use Tempest\Discovery\DiscoveryLocation; @@ -13,23 +14,23 @@ final class CommandsDiscovery implements Discovery { use IsDiscovery; + /** + * The compiler is injected rather than defaulted so it arrives carrying the + * container's localization provider; a default would silently compile every + * command without translations. + */ public function __construct( private readonly CommandsRegistry $commandRegistry, - ) - { - } + private readonly CommandCompiler $compiler, + ) {} public function discover(DiscoveryLocation $location, ClassReflector $class): void { foreach ($class->getAttributes(Command::class) as $attribute) { - $attribute->reflector = $class; - $this->discoveryItems->add($location, $attribute); + $this->discoveryItems->add($location, $this->compiler->compile($class, $attribute)); } } - /** - * @mago-expect best-practices/no-empty-loop - */ public function apply(): void { foreach ($this->discoveryItems as $command) { diff --git a/src/Discoveries/EventsDiscovery.php b/src/Discoveries/EventsDiscovery.php index ff8b354..78a38df 100644 --- a/src/Discoveries/EventsDiscovery.php +++ b/src/Discoveries/EventsDiscovery.php @@ -3,6 +3,7 @@ namespace Tempcord\Discoveries; use Tempcord\Attributes\Event; +use Tempcord\Compiler\EventCompiler; use Tempcord\Registries\EventsRegistry; use Tempest\Discovery\Discovery; use Tempest\Discovery\DiscoveryLocation; @@ -15,15 +16,13 @@ final class EventsDiscovery implements Discovery public function __construct( private readonly EventsRegistry $eventsRegistry, - ) - { - } + private readonly EventCompiler $compiler = new EventCompiler(), + ) {} public function discover(DiscoveryLocation $location, ClassReflector $class): void { foreach ($class->getAttributes(Event::class) as $attribute) { - $attribute->reflector = $class; - $this->discoveryItems->add($location, $attribute); + $this->discoveryItems->add($location, $this->compiler->compile($class, $attribute)); } } diff --git a/src/Discoveries/PluginsDiscovery.php b/src/Discoveries/PluginsDiscovery.php new file mode 100644 index 0000000..379c1bc --- /dev/null +++ b/src/Discoveries/PluginsDiscovery.php @@ -0,0 +1,42 @@ +getReflection(); + + if (!$class->implements(Plugin::class) || $reflection->isAbstract() || $reflection->isInterface()) { + return; + } + + $this->discoveryItems->add($location, $class->getName()); + } + + public function apply(): void + { + foreach ($this->discoveryItems as $className) { + /** @var Plugin $plugin */ + $plugin = $this->container->get($className); + + $this->plugins->add($plugin); + } + } +} diff --git a/src/Enums/DiscordLocale.php b/src/Enums/DiscordLocale.php new file mode 100644 index 0000000..caaef7a --- /dev/null +++ b/src/Enums/DiscordLocale.php @@ -0,0 +1,69 @@ + 'es', + self::CHINESE_CHINA => 'zh_Hans', + self::CHINESE_TAIWAN => 'zh_Hant', + default => str_replace('-', '_', $this->value), + }; + } +} diff --git a/src/Localization/CatalogLocalizations.php b/src/Localization/CatalogLocalizations.php new file mode 100644 index 0000000..2c3aab1 --- /dev/null +++ b/src/Localization/CatalogLocalizations.php @@ -0,0 +1,47 @@ +tempestLocale()); + + /* + * Asked rather than translated: a translator returns the key itself + * when a message is missing, which would show up in Discord as the + * translation. + */ + if ($locale === null || !$this->catalog->has($locale, $key)) { + continue; + } + + $message = $this->catalog->get($locale, $key); + + if ($message !== null) { + $translations[$discordLocale->value] = $message; + } + } + + return $translations; + } +} diff --git a/src/Localization/LocalizationInitializer.php b/src/Localization/LocalizationInitializer.php new file mode 100644 index 0000000..cf7eebd --- /dev/null +++ b/src/Localization/LocalizationInitializer.php @@ -0,0 +1,26 @@ +get(self::CATALOG)); + } +} diff --git a/src/Localization/LocalizationProvider.php b/src/Localization/LocalizationProvider.php new file mode 100644 index 0000000..0c17191 --- /dev/null +++ b/src/Localization/LocalizationProvider.php @@ -0,0 +1,19 @@ + keyed by Discord locale code + */ + public function forKey(string $key): array; +} diff --git a/src/Localization/NullLocalizations.php b/src/Localization/NullLocalizations.php new file mode 100644 index 0000000..941f5dd --- /dev/null +++ b/src/Localization/NullLocalizations.php @@ -0,0 +1,15 @@ +getComponent($record->level); // Use the appropriate console method based on log level - $this->console->{$component}($message); + $this->console->{$component}($this->safeForConsole($message)); } private function shouldSkipMessage(string $message): bool @@ -51,6 +57,28 @@ private function shouldSkipMessage(string $message): bool ); } + /** + * Log messages are data, not markup. + * + * Tempest's console parses