├── .env.example ├── .github └── workflows │ └── php.yml ├── .gitignore ├── .php-cs-fixer.dist.php ├── Bootstrap ├── Commands.php ├── Config.php ├── Discord.php ├── Environment.php ├── Events.php └── Requires.php ├── Bot.php ├── BotDev.php ├── Commands └── Ping.php ├── Core ├── Commands │ ├── Command.php │ ├── CommandHandler.php │ ├── CommandQueue.php │ └── QueuedCommand.php ├── Disabled.php ├── Env.php ├── Events │ ├── ApplicationCommandPermissionsUpdate.php │ ├── AutoModerationRuleCreate.php │ ├── Event.php │ ├── Init.php │ ├── MessageCreate.php │ ├── MessageDelete.php │ ├── MessageDeleteBulk.php │ └── MessageUpdate.php ├── HMR │ ├── HotDirectory.php │ └── HotFile.php └── functions.php ├── Dockerfile ├── Events └── Ready.php ├── LICENSE ├── README.md ├── Tests ├── CommandAttributeTest.php └── FunctionsTest.php ├── composer.json └── phpunit.xml /.env.example: -------------------------------------------------------------------------------- 1 | TOKEN=BOT_TOKEN_HERE 2 | -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: PHP Composer 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | pull_request: 8 | branches: 9 | - '**' 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | build: 16 | 17 | runs-on: ubuntu-latest 18 | 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | php: [ '8.1', '8.2' ] 23 | 24 | steps: 25 | - uses: actions/checkout@v3 26 | 27 | - name: Validate composer.json and composer.lock 28 | run: composer validate --strict 29 | 30 | - name: Setup PHP 31 | uses: shivammathur/setup-php@v2 32 | with: 33 | php-version: ${{ matrix.php }} 34 | coverage: xdebug 35 | ini-values: xdebug.mode=develop,debug,coverage, 36 | 37 | - name: Install dependencies 38 | run: composer install --prefer-dist --no-progress 39 | 40 | - name: PHP-CS-Fixer 41 | run: composer fix:dry 42 | 43 | - name: PHPUnit 44 | run: composer test 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### PHP-CS-Fixer 2 | .php-cs-fixer.cache 3 | .php-cs-fixer.php 4 | 5 | ### Composer 6 | composer.phar 7 | /vendor/ 8 | composer.lock 9 | 10 | ### JetBrains IDEs 11 | /.idea/ 12 | 13 | ### VSCode 14 | /.vscode/ 15 | 16 | ### PHPUnit 17 | /.phpunit.cache/ 18 | 19 | 20 | ### Misc 21 | .env 22 | Core/HMR/Cached 23 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | setRiskyAllowed(true) 6 | ->setRules([ 7 | // Each element of an array must be indented exactly once. 8 | 'array_indentation' => true, 9 | // PHP arrays should be declared using the configured syntax. 10 | 'array_syntax' => ['syntax' => 'short'], 11 | // Binary operators should be surrounded by space as configured. 12 | 'binary_operator_spaces' => ['default' => 'single_space'], 13 | // There MUST be one blank line after the namespace declaration. 14 | 'blank_line_after_namespace' => true, 15 | // Ensure there is no code on the same line as the PHP open tag and it is followed by a blank line. 16 | 'blank_line_after_opening_tag' => true, 17 | // An empty line feed must precede any configured statement. 18 | 'blank_line_before_statement' => ['statements' => ['continue', 'return']], 19 | // A single space or none should be between cast and variable. 20 | 'cast_spaces' => false, 21 | // Class, trait and interface elements must be separated with one or none blank line. 22 | 'class_attributes_separation' => ['elements' => ['const' => 'none', 'method' => 'one', 'property' => 'none', 'trait_import' => 'none']], 23 | // Whitespace around the keywords of a class, trait, enum or interfaces definition should be one space. 24 | 'class_definition' => ['multi_line_extends_each_single_line' => true, 'single_item_single_line' => true, 'single_line' => true], 25 | // Namespace must not contain spacing, comments or PHPDoc. 26 | 'clean_namespace' => true, 27 | // Remove extra spaces in a nullable typehint. 28 | 'compact_nullable_typehint' => true, 29 | // Concatenation should be spaced according to configuration. 30 | 'concat_space' => ['spacing' => 'one'], 31 | // The PHP constants `true`, `false`, and `null` MUST be written using the correct casing. 32 | 'constant_case' => ['case' => 'lower'], 33 | // The body of each control structure MUST be enclosed within braces. 34 | 'control_structure_braces' => true, 35 | // Control structure continuation keyword must be on the configured line. 36 | 'control_structure_continuation_position' => ['position' => 'same_line'], 37 | // Curly braces must be placed as configured. 38 | 'curly_braces_position' => [ 39 | 'control_structures_opening_brace' => 'same_line', 40 | 'functions_opening_brace' => 'next_line_unless_newline_at_signature_end', 41 | 'anonymous_functions_opening_brace' => 'same_line', 42 | 'classes_opening_brace' => 'next_line_unless_newline_at_signature_end', 43 | 'anonymous_classes_opening_brace' => 'next_line_unless_newline_at_signature_end', 44 | 'allow_single_line_empty_anonymous_classes' => false, 45 | 'allow_single_line_anonymous_functions' => false, 46 | ], 47 | // Equal sign in declare statement should be surrounded by spaces or not following configuration. 48 | 'declare_equal_normalize' => true, 49 | // There must not be spaces around `declare` statement parentheses. 50 | 'declare_parentheses' => true, 51 | // The keyword `elseif` should be used instead of `else if` so that all control keywords look like single words. 52 | 'elseif' => true, 53 | // PHP code MUST use only UTF-8 without BOM (remove BOM). 54 | 'encoding' => true, 55 | // PHP code must use the long ` true, 57 | // Transforms imported FQCN parameters and return types in function arguments to short version. 58 | 'fully_qualified_strict_types' => true, 59 | // Spaces should be properly placed in a function declaration. 60 | 'function_declaration' => true, 61 | // Ensure single space between function's argument and its typehint. 62 | 'function_typehint_space' => true, 63 | // Renames PHPDoc tags. 64 | 'general_phpdoc_tag_rename' => true, 65 | // Convert `heredoc` to `nowdoc` where possible. 66 | 'heredoc_to_nowdoc' => true, 67 | // Include/Require and file path should be divided with a single space. File path should not be placed under brackets. 68 | 'include' => true, 69 | // Pre- or post-increment and decrement operators should be used if possible. 70 | 'increment_style' => ['style' => 'post'], 71 | // Code MUST use configured indentation type. 72 | 'indentation_type' => true, 73 | // Integer literals must be in correct case. 74 | 'integer_literal_case' => true, 75 | // Lambda must not import variables it doesn't use. 76 | 'lambda_not_used_import' => true, 77 | // All PHP files must use same line ending. 78 | 'line_ending' => true, 79 | // Ensure there is no code on the same line as the PHP open tag. 80 | 'linebreak_after_opening_tag' => true, 81 | // List (`array` destructuring) assignment should be declared using the configured syntax. Requires PHP >= 7.1. 82 | 'list_syntax' => true, 83 | // Cast should be written in lower case. 84 | 'lowercase_cast' => true, 85 | // PHP keywords MUST be in lower case. 86 | 'lowercase_keywords' => true, 87 | // Class static references `self`, `static` and `parent` MUST be in lower case. 88 | 'lowercase_static_reference' => true, 89 | // Magic constants should be referred to using the correct casing. 90 | 'magic_constant_casing' => true, 91 | // Magic method definitions and calls must be using the correct casing. 92 | 'magic_method_casing' => true, 93 | // In method arguments and method call, there MUST NOT be a space before each comma and there MUST be one space after each comma. Argument lists MAY be split across multiple lines, where each subsequent line is indented once. When doing so, the first item in the list MUST be on the next line, and there MUST be only one argument per line. 94 | 'method_argument_space' => ['on_multiline' => 'ignore'], 95 | // Method chaining MUST be properly indented. Method chaining with different levels of indentation is not supported. 96 | 'method_chaining_indentation' => true, 97 | // Forbid multi-line whitespace before the closing semicolon or move the semicolon to the new line for chained calls. 98 | 'multiline_whitespace_before_semicolons' => ['strategy' => 'no_multi_line'], 99 | // Function defined by PHP should be called using the correct casing. 100 | 'native_function_casing' => true, 101 | // Native type hints for functions should use the correct case. 102 | 'native_function_type_declaration_casing' => true, 103 | // Master functions shall be used instead of aliases. 104 | 'no_alias_functions' => true, 105 | // Master language constructs shall be used instead of aliases. 106 | 'no_alias_language_construct_call' => true, 107 | // Replace control structure alternative syntax to use braces. 108 | 'no_alternative_syntax' => true, 109 | // There should not be a binary flag before strings. 110 | 'no_binary_string' => true, 111 | // There should be no empty lines after class opening brace. 112 | 'no_blank_lines_after_class_opening' => true, 113 | // There should not be blank lines between docblock and the documented element. 114 | 'no_blank_lines_after_phpdoc' => true, 115 | // The closing `? >` tag MUST be omitted from files containing only PHP. 116 | 'no_closing_tag' => true, 117 | // There should not be empty PHPDoc blocks. 118 | 'no_empty_phpdoc' => true, 119 | // Remove useless (semicolon) statements. 120 | 'no_empty_statement' => true, 121 | // Removes extra blank lines and/or blank lines following configuration. 122 | 'no_extra_blank_lines' => ['tokens' => ['extra', 'throw', 'use']], 123 | // Remove leading slashes in `use` clauses. 124 | 'no_leading_import_slash' => true, 125 | // The namespace declaration line shouldn't contain leading whitespace. 126 | 'no_leading_namespace_whitespace' => true, 127 | // Either language construct `print` or `echo` should be used. 128 | 'no_mixed_echo_print' => ['use' => 'echo'], 129 | // Operator `=>` should not be surrounded by multi-line whitespaces. 130 | 'no_multiline_whitespace_around_double_arrow' => true, 131 | // There must not be more than one statement per line. 132 | 'no_multiple_statements_per_line' => true, 133 | // Short cast `bool` using double exclamation mark should not be used. 134 | 'no_short_bool_cast' => true, 135 | // Single-line whitespace before closing semicolon are prohibited. 136 | 'no_singleline_whitespace_before_semicolons' => true, 137 | // There must be no space around double colons (also called Scope Resolution Operator or Paamayim Nekudotayim). 138 | 'no_space_around_double_colon' => true, 139 | // When making a method or function call, there MUST NOT be a space between the method or function name and the opening parenthesis. 140 | 'no_spaces_after_function_name' => true, 141 | // There MUST NOT be spaces around offset braces. 142 | 'no_spaces_around_offset' => ['positions' => ['inside', 'outside']], 143 | // There MUST NOT be a space after the opening parenthesis. There MUST NOT be a space before the closing parenthesis. 144 | 'no_spaces_inside_parenthesis' => true, 145 | // Removes `@param`, `@return` and `@var` tags that don't provide any useful information. 146 | 'no_superfluous_phpdoc_tags' => ['allow_mixed' => true, 'allow_unused_params' => true], 147 | // If a list of values separated by a comma is contained on a single line, then the last item MUST NOT have a trailing comma. 148 | 'no_trailing_comma_in_singleline' => true, 149 | // Remove trailing whitespace at the end of non-blank lines. 150 | 'no_trailing_whitespace' => true, 151 | // There MUST be no trailing spaces inside comment or PHPDoc. 152 | 'no_trailing_whitespace_in_comment' => true, 153 | // Removes unneeded parentheses around control statements. 154 | 'no_unneeded_control_parentheses' => ['statements' => ['break', 'clone', 'continue', 'echo_print', 'return', 'switch_case', 'yield']], 155 | // Removes unneeded curly braces that are superfluous and aren't part of a control structure's body. 156 | 'no_unneeded_curly_braces' => true, 157 | // In function arguments there must not be arguments with default values before non-default ones. 158 | 'no_unreachable_default_argument_value' => true, 159 | // Variables must be set `null` instead of using `(unset)` casting. 160 | 'no_unset_cast' => true, 161 | // Unused `use` statements must be removed. 162 | 'no_unused_imports' => true, 163 | // There should not be an empty `return` statement at the end of a function. 164 | 'no_useless_return' => true, 165 | // In array declaration, there MUST NOT be a whitespace before each comma. 166 | 'no_whitespace_before_comma_in_array' => true, 167 | // Remove trailing whitespace at the end of blank lines. 168 | 'no_whitespace_in_blank_line' => true, 169 | // Array index should always be written by using square braces. 170 | 'normalize_index_brace' => true, 171 | // Logical NOT operators (`!`) should have one trailing whitespace. 172 | 'not_operator_with_successor_space' => false, 173 | // There should not be space before or after object operators `->` and `?->`. 174 | 'object_operator_without_whitespace' => true, 175 | // Ordering `use` statements. 176 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 177 | // Docblocks should have the same indentation as the documented subject. 178 | 'phpdoc_indent' => true, 179 | // Fixes PHPDoc inline tags. 180 | 'phpdoc_inline_tag_normalizer' => true, 181 | // `@access` annotations should be omitted from PHPDoc. 182 | 'phpdoc_no_access' => true, 183 | // `@package` and `@subpackage` annotations should be omitted from PHPDoc. 184 | 'phpdoc_no_package' => true, 185 | // Classy that does not inherit must not have `@inheritdoc` tags. 186 | 'phpdoc_no_useless_inheritdoc' => true, 187 | // Annotations in PHPDoc should be ordered in defined sequence. 188 | 'phpdoc_order' => true, 189 | // Scalar types should always be written in the same form. `int` not `integer`, `bool` not `boolean`, `float` not `real` or `double`. 190 | 'phpdoc_scalar' => true, 191 | // Annotations in PHPDoc should be grouped together so that annotations of the same type immediately follow each other. Annotations of a different type are separated by a single blank line. 192 | 'phpdoc_separation' => true, 193 | // Single line `@var` PHPDoc should have proper spacing. 194 | 'phpdoc_single_line_var_spacing' => true, 195 | // Forces PHPDoc tags to be either regular annotations or inline. 196 | 'phpdoc_tag_type' => ['tags' => ['inheritdoc' => 'inline']], 197 | // PHPDoc should start and end with content, excluding the very first and last line of the docblocks. 198 | 'phpdoc_trim' => true, 199 | // The correct case must be used for standard PHP types in PHPDoc. 200 | 'phpdoc_types' => true, 201 | // `@var` and `@type` annotations of classy properties should not contain the name. 202 | 'phpdoc_var_without_name' => true, 203 | // Adjust spacing around colon in return type declarations and backed enum types. 204 | 'return_type_declaration' => ['space_before' => 'none'], 205 | // Inside a `final` class or anonymous class `self` should be preferred to `static`. 206 | 'self_static_accessor' => true, 207 | // Cast `(boolean)` and `(integer)` should be written as `(bool)` and `(int)`, `(double)` and `(real)` as `(float)`, `(binary)` as `(string)`. 208 | 'short_scalar_cast' => true, 209 | // A PHP file without end tag must always end with a single empty line feed. 210 | 'single_blank_line_at_eof' => true, 211 | // There MUST NOT be more than one property or constant declared per statement. 212 | 'single_class_element_per_statement' => ['elements' => ['const', 'property']], 213 | // There MUST be one use keyword per declaration. 214 | 'single_import_per_statement' => true, 215 | // Each namespace use MUST go on its own line and there MUST be one blank line after the use statements block. 216 | 'single_line_after_imports' => true, 217 | // Single-line comments and multi-line comments with only one line of actual content should use the `//` syntax. 218 | 'single_line_comment_style' => ['comment_types' => ['hash']], 219 | // Convert double quotes to single quotes for simple strings. 220 | 'single_quote' => true, 221 | // Ensures a single space after language constructs. 222 | 'single_space_around_construct' => true, 223 | // Fix whitespace after a semicolon. 224 | 'space_after_semicolon' => true, 225 | // Replace all `<>` with `!=`. 226 | 'standardize_not_equals' => true, 227 | // Each statement must be indented. 228 | 'statement_indentation' => true, 229 | // A case should be followed by a colon and not a semicolon. 230 | 'switch_case_semicolon_to_colon' => true, 231 | // Removes extra spaces between colon and case value. 232 | 'switch_case_space' => true, 233 | // Standardize spaces around ternary operator. 234 | 'ternary_operator_spaces' => true, 235 | // Multi-line arrays, arguments list, parameters list and `match` expressions must have a trailing comma. 236 | 'trailing_comma_in_multiline' => ['elements' => ['arrays']], 237 | // Arrays should be formatted like function/method arguments, without leading or trailing single line space. 238 | 'trim_array_spaces' => true, 239 | // A single space or none should be around union type and intersection type operators. 240 | 'types_spaces' => true, 241 | // Unary operators should be placed adjacent to their operands. 242 | 'unary_operator_spaces' => true, 243 | // Visibility MUST be declared on all properties and methods; `abstract` and `final` MUST be declared before the visibility; `static` MUST be declared after the visibility. 244 | 'visibility_required' => ['elements' => ['method', 'property']], 245 | // In array declaration, there MUST be a whitespace after each comma. 246 | 'whitespace_after_comma_in_array' => true, 247 | // Enabling PSR12 rules. 248 | '@PSR12' => true, 249 | ]) 250 | ->setFinder(PhpCsFixer\Finder::create() 251 | ->exclude([ 252 | 'vendor', 253 | ]) 254 | ->in(__DIR__) 255 | ->name('*.php') 256 | ->ignoreDotFiles(true) 257 | ->ignoreVCS(true)); -------------------------------------------------------------------------------- /Bootstrap/Commands.php: -------------------------------------------------------------------------------- 1 | appendCommand(new QueuedCommand( 27 | $attribute->newInstance(), 28 | new $className() 29 | )); 30 | }); 31 | 32 | $commandQueue->runQueue(registerCommands: Config::AUTO_REGISTER_COMMANDS)->otherwise(static fn (Throwable $e) => error($e->getMessage())); 33 | -------------------------------------------------------------------------------- /Bootstrap/Config.php: -------------------------------------------------------------------------------- 1 | discord = new Discord([ 11 | 'token' => Env::get()->TOKEN, 12 | 'intents' => Intents::getAllIntents(), 13 | ]); 14 | 15 | require_once BOT_ROOT . '/Bootstrap/Events.php'; 16 | 17 | d()->on('init', static function (Discord $discord) { 18 | debug('Bootstrapping Commands...'); 19 | require_once BOT_ROOT . '/Bootstrap/Commands.php'; 20 | }); 21 | -------------------------------------------------------------------------------- /Bootstrap/Environment.php: -------------------------------------------------------------------------------- 1 | TOKEN)) { 8 | throw new RuntimeException('No token supplied to environment!'); 9 | } 10 | -------------------------------------------------------------------------------- /Bootstrap/Events.php: -------------------------------------------------------------------------------- 1 | newInstance()->name; 25 | }); 26 | 27 | loopClasses(BOT_ROOT . '/Events', static function (string $className) use ($events, $discord) { 28 | if (doesClassHaveAttribute($className, Disabled::class) !== false) { 29 | return; 30 | } 31 | 32 | $event = new $className(); 33 | $reflection = new ReflectionClass($event); 34 | 35 | foreach ($reflection->getInterfaceNames() as $interface) { 36 | $eventName = $events['\\' . $interface] ?? null; 37 | 38 | if ($eventName === null) { 39 | continue; 40 | } 41 | 42 | $discord->on($eventName, $event->handle(...)); 43 | } 44 | }); 45 | -------------------------------------------------------------------------------- /Bootstrap/Requires.php: -------------------------------------------------------------------------------- 1 | run(); // Run the bot 9 | -------------------------------------------------------------------------------- /BotDev.php: -------------------------------------------------------------------------------- 1 | ['file', $stdout, 'w'], 42 | 2 => ['file', $stderr, 'w'], 43 | ], 44 | $pipes 45 | ); 46 | 47 | return [ 48 | 'files' => [$stdout, $stderr], 49 | 'command' => $command, 50 | 'process' => &$process, 51 | ]; 52 | } 53 | 54 | public function execute(): PromiseInterface 55 | { 56 | if ($this->isRunning()) { 57 | throw new LogicException('Command is already running'); 58 | } 59 | 60 | return new Promise(function ($resolve, $reject) { 61 | $this->process = $this->procExecute($this->command . ' ' . implode(' ', $this->args)); 62 | Loop::addPeriodicTimer( 63 | 1, 64 | function (TimerInterface $timer) use (&$stdout, &$stderr, $reject, $resolve) { 65 | $status = proc_get_status($this->process['process']); 66 | $stdout = file_get_contents($this->process['files'][0]); 67 | $stderr = file_get_contents($this->process['files'][1]); 68 | 69 | if ($status['running']) { 70 | if ($this->stdoutPos < strlen($stdout)) { 71 | echo substr($stdout, $this->stdoutPos); 72 | $this->stdoutPos = strlen($stdout); 73 | } 74 | 75 | if ($this->stderrPos < strlen($stderr)) { 76 | echo substr($stderr, $this->stderrPos); 77 | $this->stderrPos = strlen($stderr); 78 | } 79 | 80 | return; 81 | } 82 | 83 | if ($status['exitcode'] !== 0) { 84 | $reject([$stderr, $this->command, $this->args]); 85 | } else { 86 | $resolve($stdout, $stderr); 87 | } 88 | 89 | $this->process = []; 90 | 91 | Loop::cancelTimer($timer); 92 | } 93 | ); 94 | }); 95 | } 96 | 97 | public function isRunning(): bool 98 | { 99 | return $this->process !== []; 100 | } 101 | 102 | public function getProcess(): array 103 | { 104 | return $this->process; 105 | } 106 | 107 | public function kill(): void 108 | { 109 | if (!$this->isRunning()) { 110 | return; 111 | } 112 | 113 | $this->stdoutPos = $this->stderrPos = 0; 114 | 115 | proc_terminate($this->process['process']); 116 | } 117 | } 118 | 119 | $directory = new HotDirectory(__DIR__); 120 | 121 | $restart = static function () use (&$command) { 122 | $command ??= new Command('php', ['Bot.php']); 123 | $time = date('H:i:s'); 124 | echo "\nRestarting bot ({$time})...\n"; 125 | 126 | $command->kill(); 127 | 128 | await(new Promise(static function ($resolve, $reject) use (&$command) { 129 | Loop::addPeriodicTimer(2, static function (TimerInterface $timer) use (&$command, $resolve) { 130 | if ($command->isRunning()) { 131 | echo "Command is still running\n"; 132 | 133 | return; 134 | } 135 | try { 136 | Loop::cancelTimer($timer); 137 | } catch (Throwable $e) { 138 | } 139 | 140 | $resolve(); 141 | }); 142 | })); 143 | 144 | try { 145 | $command->execute(); 146 | } catch (Throwable $e) { 147 | echo "Error: {$e->getMessage()}\n"; 148 | } 149 | }; 150 | 151 | $restart(); 152 | 153 | $directory->on(HotDirectory::EVENT_FILE_ADDED, $restart(...)); 154 | $directory->on(HotDirectory::EVENT_FILE_CHANGED, $restart(...)); 155 | $directory->on(HotDirectory::EVENT_FILE_REMOVED, $restart(...)); 156 | 157 | try { 158 | Loop::run(); 159 | } catch (Throwable $e) { 160 | Loop::run(); 161 | } 162 | -------------------------------------------------------------------------------- /Commands/Ping.php: -------------------------------------------------------------------------------- 1 | respondWithMessage(messageWithContent('Pong :ping_pong:'), true); 18 | } 19 | 20 | public function autocomplete(Interaction $interaction): void 21 | { 22 | } 23 | 24 | public function getConfig(): CommandBuilder 25 | { 26 | return (new CommandBuilder()) 27 | ->setName('ping') 28 | ->setDescription('Ping the bot'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Core/Commands/Command.php: -------------------------------------------------------------------------------- 1 | guild) && preg_match('/[^0-9]/', $this->guild)) { 16 | throw new LogicException('Guild ID must be alphanumeric'); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Core/Commands/CommandHandler.php: -------------------------------------------------------------------------------- 1 | queue[] = $command; 25 | 26 | return $this; 27 | } 28 | 29 | public function runQueue(bool $loadCommands = true, bool $registerCommands = true): PromiseInterface 30 | { 31 | $discord = discord(); 32 | $discord->getLogger()->info('Running command queue...'); 33 | 34 | return new Promise(function ($resolve) use ($registerCommands, $discord, $loadCommands) { 35 | debug('Running Loop for ' . count($this->queue) . ' commands...'); 36 | async(function () use ($registerCommands, $discord, $loadCommands, $resolve) { 37 | if ($registerCommands) { 38 | debug('Getting commands...'); 39 | /** @var GlobalCommandRepository $globalCommands */ 40 | $globalCommands = await($discord->application->commands->freshen()); 41 | 42 | /** @var GuildCommandRepository[] $guildCommands */ 43 | $guildCommands = []; 44 | 45 | foreach ($this->queue as $command) { 46 | debug("Checking {$command->getName()}..."); 47 | /** @var GlobalCommandRepository|GuildCommandRepository $rCommands */ 48 | $rCommands = $command->properties->guild === null ? 49 | $globalCommands : 50 | $guildCommands[$command->properties->guild] ??= await($discord->guilds->get('id', $command->properties->guild)->commands->freshen()); 51 | 52 | $rCommand = $rCommands->get('name', $command->getName()); 53 | 54 | if ($rCommand === null || $command->hasCommandChanged($rCommand)) { 55 | debug("Command {$command->getName()} has changed, re-registering it..."); 56 | $command->setNeedsRegistered(true); 57 | } 58 | } 59 | } 60 | 61 | if ($loadCommands) { 62 | $this->loadCommands(); 63 | } 64 | 65 | $resolve(); 66 | })(); 67 | }); 68 | } 69 | 70 | protected function loadCommands(): void 71 | { 72 | debug('Loading commands...'); 73 | $discord = discord(); 74 | 75 | $listen = static function (string|array $name, QueuedCommand $command) use ($discord) { 76 | try { 77 | $registered = $discord->listenCommand($command->getName(), $command->handler->handle(...), $command->handler->autocomplete(...)); 78 | 79 | if (!is_array($command->name) || count($command->name) === 1) { 80 | return; 81 | } 82 | 83 | $loop = static function (array $commands) use (&$loop, $registered, $command) { 84 | foreach ($commands as $commandName) { 85 | if (is_array($commandName)) { 86 | $loop($commandName); 87 | } 88 | 89 | $registered->addSubCommand($commandName, $command->handler->handle(...), $command->handler->autocomplete(...)); 90 | } 91 | }; 92 | $names = $command->name; 93 | array_shift($names); 94 | 95 | $loop($names); 96 | } catch (Throwable $e) { 97 | if (preg_match_all('/The command `(\w+)` already exists\./m', $e->getMessage(), $matches, PREG_SET_ORDER)) { 98 | return; 99 | } 100 | 101 | error($e); 102 | } 103 | }; 104 | 105 | foreach ($this->queue as $command) { 106 | $listen($command->name, $command); 107 | 108 | debug("Loaded command {$command->getName()}"); 109 | 110 | if (!$command->needsRegistered()) { 111 | debug("Command {$command->getName()} does not need to be registered"); 112 | 113 | continue; 114 | } 115 | 116 | $this->registerCommand($command); 117 | debug("Command {$command->getName()} was registered"); 118 | } 119 | } 120 | 121 | protected function registerCommand(QueuedCommand $command): PromiseInterface 122 | { 123 | return new Promise(static function ($resolve, $reject) use ($command) { 124 | $discord = discord(); 125 | $commands = $command->properties->guild === null ? 126 | $discord->application->commands : 127 | $discord->guilds->get('id', $command->properties->guild)?->commands ?? null; 128 | 129 | if ($commands === null && $command->properties->guild !== null) { 130 | $discord->getLogger()->error("Failed to register command {$command->getName()}: Guild {$command->properties->guild} not found"); 131 | 132 | return; 133 | } 134 | 135 | try { 136 | $commands->save( 137 | $commands->create( 138 | $command->handler->getConfig()->toArray() 139 | ) 140 | )->then(static function () use ($command, $resolve) { 141 | debug("Command {$command->getName()} was registered"); 142 | $resolve(); 143 | })->otherwise(static function (Throwable $e) use ($command, $reject) { 144 | error("Failed to register command {$command->getName()}: {$e->getMessage()}"); 145 | $reject($e); 146 | }); 147 | } catch (Throwable $e) { 148 | error("Failed to register command {$command->getName()}: {$e->getMessage()}"); 149 | $reject($e); 150 | } 151 | }); 152 | } 153 | 154 | public static function queueAndRunCommands(bool $loadCommands = true, bool $registerCommands = true, QueuedCommand ...$commands): PromiseInterface 155 | { 156 | $queue = (new static()); 157 | 158 | foreach ($commands as $command) { 159 | $queue->appendCommand($command); 160 | } 161 | 162 | return $queue->runQueue($loadCommands, $registerCommands); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /Core/Commands/QueuedCommand.php: -------------------------------------------------------------------------------- 1 | properties->name)) { 24 | $config = $this->handler->getConfig(); 25 | 26 | if ($config instanceof CommandBuilder) { 27 | $config = $config->toArray(); 28 | } 29 | 30 | $name = $config['name'] ?? null; 31 | } else { 32 | $name = $this->properties->name; 33 | } 34 | 35 | if (empty($name)) { 36 | $className = get_class($this->handler); 37 | throw new LogicException("Command {$className} has no name"); 38 | } 39 | 40 | $this->name = $name; 41 | } 42 | 43 | public function getName(): string 44 | { 45 | return is_array($this->name) ? $this->name[0] : $this->name; 46 | } 47 | 48 | public function hasCommandChanged(DiscordCommand $rCommand): bool 49 | { 50 | $command = $this->handler->getConfig(); 51 | try { 52 | $rCommand = $rCommand->jsonSerialize(); 53 | } catch (Exception $e) { 54 | throw new RuntimeException($e->getMessage(), previous: $e); 55 | } 56 | 57 | if ($command instanceof CommandBuilder) { 58 | $command = $command->jsonSerialize(); 59 | } 60 | 61 | return static::compareCommands($command, $rCommand); 62 | } 63 | 64 | protected static function compareCommands(array $command, array|ArrayAccess $other): bool 65 | { 66 | foreach ($command as $key => $value) { 67 | if (in_array($key, static::IGNORE_COMPARISON_FIELDS)) { 68 | continue; 69 | } 70 | 71 | if (!isset($other[$key])) { 72 | return false; 73 | } 74 | 75 | $otherValue = $other[$key]; 76 | 77 | if ($value === $otherValue) { 78 | continue; 79 | } 80 | 81 | if (is_array($value) && (is_array($otherValue) || $otherValue instanceof ArrayAccess)) { 82 | if (!self::compareCommands($value, $otherValue)) { 83 | return false; 84 | } 85 | 86 | continue; 87 | } 88 | 89 | return false; 90 | } 91 | 92 | return true; 93 | } 94 | 95 | public function setNeedsRegistered(bool $needsRegistered): void 96 | { 97 | $this->needsRegistered = $needsRegistered; 98 | } 99 | 100 | public function needsRegistered(): bool 101 | { 102 | return $this->needsRegistered; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Core/Disabled.php: -------------------------------------------------------------------------------- 1 | */ 17 | protected array $files = []; 18 | 19 | public function __construct( 20 | public readonly string $directory, 21 | int $interval = 1 22 | ) { 23 | if (!is_dir($directory)) { 24 | throw new LogicException("Directory {$directory} does not exist"); 25 | } 26 | 27 | $files = FileSystemUtils::getAllFiles($directory); 28 | 29 | foreach ($files as $file) { 30 | $hotFile = new HotFile($file, $interval); 31 | $this->files[$file] = $hotFile; 32 | 33 | $hotFile 34 | ->on(HotFile::EVENT_CHANGED, fn (HotFile $file) => $this->emit(self::EVENT_FILE_CHANGED, [$this, $file])) 35 | ->on(HotFile::EVENT_REMOVED, fn (HotFile $file) => $this->emit(self::EVENT_FILE_REMOVED, [$this, $file])); 36 | } 37 | 38 | Loop::addPeriodicTimer($interval, function () use ($interval) { 39 | foreach (FileSystemUtils::getAllFiles($this->directory) as $file) { 40 | if (isset($this->files[$file])) { 41 | continue; 42 | } 43 | 44 | $this->files[$file] = new HotFile($file, $interval); 45 | 46 | $this->emit(self::EVENT_FILE_ADDED, [$this, $file]); 47 | } 48 | }); 49 | } 50 | 51 | public function getFiles(): array 52 | { 53 | return $this->files; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Core/HMR/HotFile.php: -------------------------------------------------------------------------------- 1 | name = basename($file); 28 | $this->hash = $this->createHash(); 29 | 30 | $this->timer = Loop::addPeriodicTimer($interval, function () { 31 | if (!is_file($this->file)) { 32 | $this->emit(self::EVENT_REMOVED, [$this]); 33 | $this->cancel(); 34 | 35 | return; 36 | } 37 | 38 | if (!$this->hasChanged()) { 39 | return; 40 | } 41 | 42 | $this->hash = $this->createHash(); 43 | $this->emit(self::EVENT_CHANGED, [$this]); 44 | }); 45 | } 46 | 47 | public function getContents(): string 48 | { 49 | return file_get_contents($this->file); 50 | } 51 | 52 | private function createHash(): string 53 | { 54 | return hash('sha256', $this->getContents()); 55 | } 56 | 57 | public function hasChanged(): bool 58 | { 59 | return $this->createHash() !== $this->hash; 60 | } 61 | 62 | private function cancel(): void 63 | { 64 | Loop::cancelTimer($this->timer); 65 | } 66 | 67 | public function __destruct() 68 | { 69 | $this->cancel(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Core/functions.php: -------------------------------------------------------------------------------- 1 | discord ?? null; 45 | } 46 | 47 | /** 48 | * Create a new Option used for building slash commands 49 | */ 50 | function newSlashCommandOption(string $name, string $description, int $type, bool $required = false): CommandOption 51 | { 52 | return newDiscordPart(CommandOption::class) 53 | ->setName($name) 54 | ->setDescription($description) 55 | ->setType($type) 56 | ->setRequired($required); 57 | } 58 | 59 | /** 60 | * Create a new Choice used for building slash commands 61 | */ 62 | function newSlashCommandChoice(string $name, float|int|string $value): Choice 63 | { 64 | return newDiscordPart(Choice::class) 65 | ->setName($name) 66 | ->setValue($value); 67 | } 68 | 69 | /** 70 | * Create a new MessageBuilder object with the content define for creating simple MessageBuilders quickly 71 | * 72 | * ```php 73 | * $message = messageWithContent("Hello World"); 74 | * ``` 75 | */ 76 | function messageWithContent(string $content): MessageBuilder 77 | { 78 | return MessageBuilder::new()->setContent($content); 79 | } 80 | 81 | /** 82 | * Append to grab and empty array field. You can supply an embed to have the empty field added, or 83 | * if you leave the `$embed` option `null`, then an array containing the empty field will be returned 84 | * 85 | * ```php 86 | * $embed = newDiscordPart("\Discord\Parts\Embed\Embed"); 87 | * emptyEmbedField($embed); 88 | * ``` 89 | * 90 | * or 91 | * 92 | * ```php 93 | * $embed = newDiscordPart("\Discord\Parts\Embed\Embed"); 94 | * $emptyField = emptyEmbedField(); 95 | * ``` 96 | */ 97 | function emptyEmbedField(?Embed $embed = null): array|Embed 98 | { 99 | $emptyField = ['name' => "\u{200b}", 'value' => "\u{200b}"]; 100 | 101 | if ($embed !== null) { 102 | return $embed->addField($emptyField); 103 | } 104 | 105 | return $emptyField; 106 | } 107 | 108 | /** 109 | * @template T 110 | * 111 | * @param class-string $class 112 | * 113 | * @return T 114 | */ 115 | function newDiscordPart(string $class, mixed ...$args): mixed 116 | { 117 | return new $class(discord(), ...$args); 118 | } 119 | 120 | /** 121 | * Quickly build an action row with multiple buttons 122 | * 123 | * ```php 124 | * $banButton = (new Button(Button::STYLE_DANGER))->setLabel("Ban User"); 125 | * $kickButton = (new Button(Button::STYLE_DANGER))->setLabel("Kick User"); 126 | * $actionRow = buildActionRowWithButtons($banButton, $kickButton); 127 | * ``` 128 | * 129 | * *This can also be paired with newButton* 130 | * 131 | * ```php 132 | * $actionRow = buildActionWithButtons( 133 | * newButton(Button::STYLE_DANGER, "Ban User") 134 | * newButton(Button::STYLE_DANGER, "Kick User") 135 | * ); 136 | * ``` 137 | */ 138 | function buildActionRowWithButtons(Button ...$buttons): ActionRow 139 | { 140 | $actionRow = new ActionRow(); 141 | 142 | foreach ($buttons as $button) { 143 | $actionRow->addComponent($button); 144 | } 145 | 146 | return $actionRow; 147 | } 148 | 149 | /** 150 | * Quickly create button objects 151 | * 152 | * ```php 153 | * $button = newButton(Button::STYLE_DANGER, "Kick User", "Kick|Command_String"); 154 | * ``` 155 | */ 156 | function newButton(int $style, string $label, ?string $custom_id = null): Button 157 | { 158 | return (new Button($style, $custom_id))->setLabel($label); 159 | } 160 | 161 | /** 162 | * Get an option from an Interaction/Interaction Repository by specifying the option(s) name 163 | * 164 | * For regular slash commands 165 | * `/ban :user` 166 | * 167 | * ```php 168 | * $user = getOptionFromInteraction($interaction, "user"); 169 | * ``` 170 | * 171 | * For sub commands / sub command groups you can stack the names 172 | * `/admin ban :user` 173 | * 174 | * ```php 175 | * $user = getOptionFromInteraction($interaction->data->options, "ban", "user"); 176 | * ``` 177 | */ 178 | function getOptionFromInteraction(Collection|Interaction $options, string ...$names): ?Option 179 | { 180 | if ($options instanceof Interaction) { 181 | $options = $options->data->options; 182 | } 183 | 184 | $option = null; 185 | foreach ($names as $key => $name) { 186 | $option = $options->get('name', $name); 187 | 188 | if ($key !== count($names) - 1) { 189 | $options = $option?->options; 190 | } 191 | 192 | if (!isset($options, $option)) { 193 | break; 194 | } 195 | } 196 | 197 | return $option; 198 | } 199 | 200 | // Logging Functions 201 | 202 | function log($level, string $message, array $context = []): void 203 | { 204 | discord()?->getLogger()->log($level, $message, $context); 205 | } 206 | 207 | function debug(string $message, array $context = []): void 208 | { 209 | discord()?->getLogger()->debug($message, $context); 210 | } 211 | 212 | function error(string $message, array $context = []): void 213 | { 214 | discord()?->getLogger()->error($message, $context); 215 | } 216 | 217 | function info(string $message, array $context = []): void 218 | { 219 | discord()?->getLogger()->info($message, $context); 220 | } 221 | 222 | // Internal Functions // 223 | 224 | /** 225 | * Loop through all the classes in a directory and call a callback function with the class name 226 | */ 227 | function loopClasses(string $directory, callable $callback): void 228 | { 229 | $convertPathToNamespace = static fn (string $path): string => str_replace([realpath(BOT_ROOT), '/'], ['', '\\'], $path); 230 | 231 | foreach (FileSystemUtils::getAllFiles($directory, true) as $file) { 232 | if (!str_ends_with($file, '.php')) { 233 | continue; 234 | } 235 | 236 | $className = basename($file, '.php'); 237 | $path = dirname($file); 238 | $namespace = $convertPathToNamespace($path); 239 | $className = $namespace . '\\' . $className; 240 | 241 | $callback($className, $namespace, $file, $path); 242 | } 243 | } 244 | 245 | /** 246 | * @template T 247 | * 248 | * @param class-string $class 249 | * @param class-string $attribute 250 | * 251 | * @throws ReflectionException 252 | * 253 | * @return T|false 254 | */ 255 | function doesClassHaveAttribute(string $class, string $attribute): object|false 256 | { 257 | return (new ReflectionClass($class))->getAttributes($attribute, ReflectionAttribute::IS_INSTANCEOF)[0] ?? false; 258 | } 259 | 260 | function deleteAllFilesInDirectory(string $directory): void 261 | { 262 | if (!is_dir($directory)) { 263 | return; 264 | } 265 | 266 | foreach (FileSystemUtils::getAllFiles($directory) as $file) { 267 | unlink($file); 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM composer/composer as COMPOSER 2 | 3 | COPY . /bot 4 | WORKDIR /bot 5 | RUN composer install 6 | 7 | FROM php:8.2-cli 8 | 9 | COPY --from=COMPOSER /bot /bot 10 | WORKDIR /bot 11 | 12 | CMD [ "php", "./Bot.php" ] 13 | -------------------------------------------------------------------------------- /Events/Ready.php: -------------------------------------------------------------------------------- 1 | respondWithMessage(messageWithContent('Ping :ping_pong:'), true); 60 | } 61 | 62 | public function autocomplete(Interaction $interaction): void 63 | { 64 | } 65 | 66 | public function getConfig(): CommandBuilder 67 | { 68 | return (new CommandBuilder()) 69 | ->setName('ping') 70 | ->setDescription('Ping the bot'); 71 | } 72 | } 73 | ``` 74 | 75 | Once you start the bot, it will automatically register the command with Discord. 76 | And if you make any changes to the config, it will update the command on the fly. 77 | 78 | # Events 79 | 80 | Create a class that implements any of the interfaces found inside of `Core\Events`. 81 | Implement the interface that matches your desired event. 82 | 83 | ```php 84 | expectException(\LogicException::class); 13 | $this->expectExceptionMessage('Guild ID must be alphanumeric'); 14 | 15 | new Command(guild: 'not a snowflake'); 16 | } 17 | 18 | public function testItAcceptsGoodSnowflakes(): void 19 | { 20 | $this->expectNotToPerformAssertions(); 21 | 22 | new Command(guild: '1234567890'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/FunctionsTest.php: -------------------------------------------------------------------------------- 1 | assertEquals([ 16 | 'content' => 'Hello World', 17 | ], $message->jsonSerialize()); 18 | } 19 | 20 | public function testItCreatesAButton(): void 21 | { 22 | $button = newButton(Button::STYLE_DANGER, 'DANGER'); 23 | 24 | $this->assertEquals('DANGER', $button->getLabel()); 25 | $this->assertEquals(Button::STYLE_DANGER, $button->getStyle()); 26 | 27 | $button = newButton(Button::STYLE_PRIMARY, 'PRIMARY', 'primary_button'); 28 | $this->assertEquals('PRIMARY', $button->getLabel()); 29 | $this->assertEquals(Button::STYLE_PRIMARY, $button->getStyle()); 30 | $this->assertEquals('primary_button', $button->getCustomId()); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "commandstring/dphp-bot", 3 | "description": "An unofficial way to structure a DPHP Bot", 4 | "license": "MIT", 5 | "type": "project", 6 | "authors": [ 7 | { 8 | "name": "Robert Snedeker", 9 | "email": "rsnedeker20@gmail.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "^8.1", 14 | "commandstring/utils": "^1.7", 15 | "react/async": "^4.1", 16 | "team-reflex/discord-php": "dev-master", 17 | "tnapf/env": "^1.1" 18 | }, 19 | "require-dev": { 20 | "ergebnis/composer-normalize": "^2.31", 21 | "fakerphp/faker": "^1.21", 22 | "friendsofphp/php-cs-fixer": "^3.16", 23 | "jetbrains/phpstorm-attributes": "^1.0", 24 | "phpunit/phpunit": "^10.1", 25 | "roave/security-advisories": "dev-latest", 26 | "xheaven/composer-git-hooks": "^3.0" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Commands\\": "Commands/", 31 | "Core\\": "Core/", 32 | "Events\\": "Events/", 33 | "Tests\\": "Tests/" 34 | }, 35 | "files": [ 36 | "Core/functions.php" 37 | ] 38 | }, 39 | "config": { 40 | "allow-plugins": { 41 | "ergebnis/composer-normalize": true 42 | }, 43 | "sort-packages": true 44 | }, 45 | "extra": { 46 | "composer-normalize": { 47 | "indent-size": 2, 48 | "indent-style": "space" 49 | }, 50 | "hooks": { 51 | "pre-commit": "composer fix:dry", 52 | "pre-push": "composer test" 53 | } 54 | }, 55 | "scripts": { 56 | "post-install-cmd": "cghooks add --ignore-lock", 57 | "post-update-cmd": "cghooks update", 58 | "post-autoload-dump": "composer normalize", 59 | "fix": "php-cs-fixer fix --using-cache=no", 60 | "fix:dry": "php-cs-fixer fix --using-cache=no --diff --dry-run", 61 | "test": "phpunit", 62 | "test:coverage": "phpunit --coverage-html .phpunit.cache/cov-html" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | Tests 9 | 10 | 11 | 12 | 13 | 14 | src 15 | 16 | 17 | --------------------------------------------------------------------------------