diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 790e42613..000000000 --- a/.dockerignore +++ /dev/null @@ -1,38 +0,0 @@ -# general -.env -.DS_Store -.idea -.phplint-cache -dump.html -.vscode -.github -.editorconfig -.dockerignore -Dockerfile -docker-compose.yml -/.git - -# phive -/tools -.phive - -# composer -/vendor -composer.lock - -# docs -/build -.phpdoc -/docs -/guide - -# php-cs-fixer -.php_cs.cache -.php_cs -.php-cs-fixer.php -.php-cs-fixer.cache - -# phpunit -phpunit.log -/.phpunit* -/coverage diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..ede2b07d0 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# ------------------------------------------------------------------- +# Bot runtime variables +# ------------------------------------------------------------------- + +# Your Discord bot token. Required to connect. +# https://discord.com/developers/applications → your app → Bot → Token +DISCORD_TOKEN= + +# Comma-separated Intents constant names (or an integer bitmask). +# Defaults to Intents::getDefaultIntents() when not set. +# Example: GUILDS,GUILD_MESSAGES,MESSAGE_CONTENT +# DISCORDPHP_INTENTS= + +# Optional. Path to a CA certificate bundle (.pem) for TLS verification. +# Only needed on native Windows where PHP cannot read the system cert +# store. Leave blank on Linux/macOS/WSL. +# See scripts/windows-ssl-setup.ps1 to download cacert.pem automatically. +# DISCORDPHP_CAFILE= + +# ------------------------------------------------------------------- +# Integration test variables (not used at runtime) +# ------------------------------------------------------------------- +TEST_CHANNEL= +TEST_CHANNEL_NAME= diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 04f235356..7205777f8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,7 +10,7 @@ jobs: if: github.event_name == 'release' || contains(github.event.head_commit.message, 'build docs') steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -28,7 +28,7 @@ jobs: yarn build sudo mv public/* ../build - name: Publish docs - uses: JamesIves/github-pages-deploy-action@3.7.1 + uses: JamesIves/github-pages-deploy-action@4.8.0 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: gh-pages diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 4b4d601a9..12740a7c5 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -29,14 +29,10 @@ jobs: with: php-version: ${{ matrix.php }} extensions: uv, zlib, mbstring - tools: phpunit, phplint + tools: phpunit - name: Install dependencies run: composer install - name: Redis Server in GitHub Actions uses: supercharge/redis-github-action@1.4.0 - # - name: Memcached Service - # uses: niden/actions-memcached@v7 - name: Run PHPUnit run: phpunit - - name: Run PHPLint - run: phplint diff --git a/.gitignore b/.gitignore index f7ef70cab..0b7e4e3da 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ composer.lock # phpunit phpunit.log +coverage.xml /.phpunit* /coverage /.tools diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php deleted file mode 100644 index 4d22b387f..000000000 --- a/.php-cs-fixer.dist.php +++ /dev/null @@ -1,103 +0,0 @@ - -Copyright (c) 2020-present Valithor Obsidion - -This file is subject to the MIT license that is bundled -with this source code in the LICENSE.md file. -EOF; - -$fixers = [ - 'blank_line_after_namespace', - 'braces', - 'class_definition', - 'elseif', - 'encoding', - 'full_opening_tag', - 'function_declaration', - 'lowercase_keywords', - 'method_argument_space', - 'no_closing_tag', - 'no_spaces_after_function_name', - 'no_spaces_inside_parenthesis', - 'no_trailing_whitespace', - 'no_trailing_whitespace_in_comment', - 'single_blank_line_at_eof', - 'single_class_element_per_statement', - 'single_import_per_statement', - 'single_line_after_imports', - 'switch_case_semicolon_to_colon', - 'switch_case_space', - 'visibility_required', - 'blank_line_after_opening_tag', - 'no_multiline_whitespace_around_double_arrow', - 'no_empty_statement', - 'include', - 'no_trailing_comma_in_list_call', - 'not_operator_with_successor_space', - 'no_leading_namespace_whitespace', - 'no_blank_lines_after_class_opening', - 'no_blank_lines_after_phpdoc', - 'object_operator_without_whitespace', - 'binary_operator_spaces', - 'phpdoc_indent', - 'general_phpdoc_tag_rename', - 'phpdoc_inline_tag_normalizer', - 'phpdoc_tag_type', - 'phpdoc_no_access', - 'phpdoc_no_package', - 'phpdoc_scalar', - 'phpdoc_summary', - 'phpdoc_trim', - 'phpdoc_var_without_name', - 'no_leading_import_slash', - 'no_trailing_comma_in_singleline_array', - 'single_blank_line_before_namespace', - 'single_quote', - 'no_singleline_whitespace_before_semicolons', - 'cast_spaces', - 'standardize_not_equals', - 'ternary_operator_spaces', - 'trim_array_spaces', - 'unary_operator_spaces', - 'no_unused_imports', - 'no_useless_else', - 'no_useless_return', - 'phpdoc_no_empty_return', - 'no_extra_blank_lines', - 'multiline_whitespace_before_semicolons', -]; - -$rules = [ - 'concat_space' => ['spacing' => 'none'], - 'phpdoc_no_alias_tag' => ['replacements' => ['type' => 'var']], - 'array_syntax' => ['syntax' => 'short'], - 'binary_operator_spaces' => ['align_double_arrow' => true, 'align_equals' => true], - 'header_comment' => ['header' => $header], - 'indentation_type' => true, - 'phpdoc_align' => [ - 'align' => 'vertical', - 'tags' => ['param', 'property', 'property-read', 'property-write', 'return', 'throws', 'type', 'var', 'method'], - ], - 'blank_line_before_statement' => ['statements' => ['return']], - 'constant_case' => ['case' => 'lower'], - 'echo_tag_syntax' => ['format' => 'long'], - 'trailing_comma_in_multiline' => ['elements' => ['arrays']], -]; - -foreach ($fixers as $fix) { - $rules[$fix] = true; -} - -$config = new PhpCsFixer\Config(); - -return $config - ->setRules($rules) - ->setFinder( - PhpCsFixer\Finder::create() - ->exclude('examples') - ->in(__DIR__) - ); diff --git a/.phplint.yml b/.phplint.yml deleted file mode 100644 index 8f3512b45..000000000 --- a/.phplint.yml +++ /dev/null @@ -1,7 +0,0 @@ -path: ./ -jobs: 10 -extensions: - - php -exclude: - - vendor -warning: false diff --git a/AGENTS.md b/AGENTS.md index 88d0ff067..08c681216 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,67 @@ In this repo, companion surfaces usually matter as much as the line you edit. 8. **Traits are preferred over deep inheritance or broad interface hierarchies for shared behavior.** 9. **Type maps are central dispatch points.** If a Discord payload is polymorphic, there is usually one place that decides the concrete subtype. +## Public API policy + +DiscordPHP is a **library, not a framework.** User code calls DiscordPHP; DiscordPHP does not call user code beyond the event listeners the user explicitly registers. This posture is deliberate and is the gate against feature creep. If a proposed addition would invert that control flow, it does not belong here — it belongs in a sibling framework package layered on top. + +### The dividing rule + +A capability belongs **in DiscordPHP (this library)** if **all** of the following are true: + +- It lives in exactly one existing layer (Part, Repository, Builder, Event, Voice, or runtime). +- It wraps a primitive nicer without choosing an app shape. +- It holds no state the caller would otherwise control. +- It does not own a lifecycle beyond `__construct` / `run()` / `close()`. +- It does not assume a config file format, DI container, or plugin model. +- Its default behavior is correct for the overwhelming majority of callers, and wrong defaults are still overridable. + +A capability belongs **outside this library** if **any** of the following are true: + +- It imposes app structure (routing, controllers, plugin registry, modules). +- It owns config resolution beyond `.env` + constructor options. +- It holds cross-event state on the user's behalf (pagination cursors, wizards, cooldowns, session tracking). +- It couples subsystems users should be free to swap (e.g. command routing + persistence + permission system together). +- It wants a DI container, service providers, or its own CLI. + +Ambiguous cases default to **outside**. It is far harder to remove a public abstraction than to keep it out. + +### Rules (R1–R10) + +These are design heuristics, not commandments. They exist to serve the dividing rule above. + +- **R1. One obvious entry point per task, progressive disclosure.** `Discord::fromEnv()` is the happy path; the full constructor is still there. Do not ship three ways to do the same thing. +- **R2. Listeners over lifecycle ceremony for the common path.** Thin aliases like `onReady()`, `onMessage()` keep first-bot code short. Reserve `on(Event::X, ...)` for cases where a constant is actually clearer. +- **R3. Builders are the single outbound authoring path.** If a payload has meaningful shape or validation, improve the builder. Do not add convenience methods that bypass it. +- **R4. No raw payloads in userland.** Handlers, events, and repository methods must return typed Parts or typed collections. Array-diving in userland is a library failure. +- **R5. Fail loudly, fail actionably.** Every thrown exception tells the user what to do next (env var, option key, portal toggle). See `Discord::fromEnv()` for the reference tone. +- **R6. No hidden singletons.** `Discord` is an instance. Never add global registry lookups — they break multi-bot processes, tests, and DI. +- **R7. Layered, not monolithic.** Core `Discord` works without `DiscordCommandClient`. Voice works without slash commands. Users opt in. +- **R8. Async visible, taught once.** Promises are part of the contract. Document the one pattern (`->then()` / `->done()`) in the quickstart and reuse it everywhere. +- **R9. Every public addition needs docblock + example + test.** If it is not all three, it is not part of the public surface. +- **R10. Conventions over config, with a real escape hatch.** Good defaults for intents, cache, logger — but every default is overridable via the same options array. No "rebuild to change X" moments. + +### Explicit non-goals (belong in a framework package, not here) + +Named so that future proposals get a clear, reviewable "out of scope" response. These are not forbidden ideas — they are simply not this library's job: + +- **Slash command router.** Attribute- or class-based dispatch to handler methods. Imposes an app shape. +- **CommandRegistrar / diff-and-apply registration.** Holds desired-state across invocations and owns a lifecycle. +- **Interaction router.** Pattern-matching `custom_id` → handler; inherently a routing concern. +- **Menu / pagination / wizard state helpers.** Hold cross-event state on the user's behalf. +- **Permission / cooldown middleware.** Policy over primitives; middleware implies a pipeline the library does not own. + +A starter document for such a framework project lives at `todo.txt` at the repo root as a temporary placeholder; it is intended to be moved into its own repository. + +### Decision record hook for reviewers + +If a proposed PR fails the dividing rule, the reviewer should either: + +1. Ask the author to rewrite it as a single-layer primitive that does pass the rule, **or** +2. Close the PR with a note suggesting the idea belongs in an external framework package (link `todo.txt` while it still exists, or the future framework repo). + +Do not merge "just this one" exceptions. The rule is only useful if it is consistently applied. + ## Architecture map | Layer | Owns | Primary files | What to preserve | @@ -284,9 +345,10 @@ When you need an example worth imitating, start here: ### Tests -- Prefer plain `PHPUnit\Framework\TestCase` when logic is isolated. -- Use `DiscordTestCase` only when real Discord integration matters. -- Use `wait()` from `tests/functions.php` to bridge promises into test assertions. +- Write all tests in **Pest 4** using `it()` or `test()` functions — no class-based test files. +- Unit tests (no Discord token needed) go in the `unit` testsuite. Integration tests go in the `integration` testsuite; list new files in `phpunit.xml`. +- Use `uses(DiscordTestCase::class)->in(...)` in `tests/Pest.php` only when a test file needs `$this->channel()` or other integration helpers. +- Use `wait()` from `tests/functions.php` to bridge ReactPHP promises into test assertions. - Keep semantic tests focused on behavior, not on incidental implementation details. ### Docs @@ -300,7 +362,8 @@ When you need an example worth imitating, start here: | Purpose | Command | | --- | --- | -| main PHPUnit suite | `composer unit` | +| unit test suite (no Discord token needed) | `composer unit` | +| integration test suite (requires `.env`) | `composer integration` | | static analysis | `composer run-script mago-lint` | | formatter contributors run | `composer run-script cs` | | non-mutating Pint check | `./vendor/bin/pint --test --config ./pint.json ./src` | @@ -308,6 +371,8 @@ When you need an example worth imitating, start here: Integration tests expect `.env` values for `DISCORD_TOKEN`, `TEST_CHANNEL`, and `TEST_CHANNEL_NAME`. +Tests are written in **Pest 4** (`it()` / `test()` functions). Use `uses(SomeClass::class)->in(...)` in `tests/Pest.php` to bind a base class to specific test files. + ## Final rule When unsure where code belongs, choose the layer that already owns the same kind of knowledge elsewhere in the repo. Matching the existing ownership model matters more than shaving a few lines off one class. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58002da9c..77e0dbdde 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,3 @@ -We are open to contributions. However, please make sure you follow our coding standards (PSR-4 autoloading and custom styling). Please run php-cs-fixer before opening a pull request by running ``composer run-script cs.`` +We are open to contributions. However, please make sure you follow our coding standards (PSR-4 autoloading and custom styling). Please run Pint before opening a pull request by running ``composer run-script pint.`` Please only use the issue tracker for submitting issues with the code. If you have questions about how to use DiscordPHP, hop over to our discord at [![DiscordPHP Community](https://discord.com/api/guilds/115233111977099271/widget.png?style=banner1)](https://discord.gg/dphp) diff --git a/README.md b/README.md index 9eb0ebc55..0c55ecbe2 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Before you start using this Library, you **need** to know how PHP, Event Loops, ### Requirements - [PHP 8.1.2](https://php.net) or higher (latest version recommended) - - x86 (32-bit) PHP requires [`ext-gmp`](https://www.php.net/manual/en/book.gmp.php) enabled. + - x86 (32-bit) PHP: it is recommended to have [`ext-gmp`](https://www.php.net/manual/en/book.gmp.php) enabled. - [`ext-json`](https://www.php.net/manual/en/book.json.php) - [`ext-zlib`](https://www.php.net/manual/en/book.zlib.php) @@ -45,16 +45,59 @@ Before you start using this Library, you **need** to know how PHP, Event Loops, #### Voice Requirements -- [DiscordPHP-Voice](https://github.com/discord-php/DiscordPHP-Voice) +Voice support is bundled — no separate package installation is required. + +- [`ext-sodium`](https://www.php.net/manual/en/book.sodium.php) for voice encryption. ### Windows and SSL -Unfortunately PHP on Windows does not have access to the Windows Certificate Store. This is an issue because TLS gets used and as such certificate verification gets applied (turning this off is **not** an option). +PHP on Windows has no access to the Windows Certificate Store, so TLS connections to Discord fail silently (the script exits after one loop turn with no error) unless you provide a CA certificate bundle. + +You have two supported options — pick whichever you prefer: + +#### Option 1: Use WSL (recommended) + +Run your bot under [Windows Subsystem for Linux](https://learn.microsoft.com/windows/wsl/install). Inside WSL, PHP uses the Linux system CA store automatically and **no SSL setup is needed**. + +#### Option 2: Native Windows + +Run the helper script shipped in this repo from the project root: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts/windows-ssl-setup.ps1 +``` + +It downloads a fresh CA bundle to `~/.ssl/cacert.pem` (i.e. `%USERPROFILE%\.ssl\cacert.pem`) and prints the exact path. Then hook it up in one of two ways: + +**a) Let DiscordPHP apply it at runtime** *(no `php.ini` edit needed)* + +Pass the path as a constructor option: + +```php +$discord = new Discord([ + 'token' => 'bot-token', + 'cafile' => $_SERVER['USERPROFILE'].'\\.ssl\\cacert.pem', +]); +``` + +Or set the `DISCORDPHP_CAFILE` environment variable once (persists for your user): + +```powershell +setx DISCORDPHP_CAFILE "$HOME\.ssl\cacert.pem" +``` + +The Discord constructor honours the option first, then falls back to `DISCORDPHP_CAFILE`. It only sets `openssl.cafile` / `curl.cainfo` if they are not already configured, so your existing `php.ini` is never overridden. -You will notice this issue by your script exiting immediately after one loop turn without any errors. +**b) Configure PHP globally via `php.ini`** -As such users of this library need to download a [Certificate Authority extract](https://curl.haxx.se/docs/caextract.html) from the cURL website.
-The path to the caextract must be set in the [`php.ini`](https://secure.php.net/manual/en/openssl.configuration.php) for `openssl.cafile`. +Find your `php.ini` with `php --ini`, then add: + +```ini +openssl.cafile="C:\Users\\.ssl\cacert.pem" +curl.cainfo="C:\Users\\.ssl\cacert.pem" +``` + +Restart any running PHP processes. ### Installing DiscordPHP @@ -68,28 +111,24 @@ DiscordPHP is installed using [Composer](https://getcomposer.org). ### Basic Example +After `composer install`, edit the generated `.env` and set `DISCORD_TOKEN`, then: + ```php 'bot-token', - 'intents' => Intents::getDefaultIntents() -// | Intents::MESSAGE_CONTENT, // Note: MESSAGE_CONTENT is privileged, see https://dis.gd/mcfaq -]); +// fromEnv() loads .env automatically and throws a clear error if it's missing +$discord = Discord::fromEnv(); -$discord->on('ready', function (Discord $discord) { - $discord->logger->info("Bot is ready!"); +$discord->onReady(function (Discord $discord) { + echo 'Logged in as '.$discord->user->username.'!'.PHP_EOL; - // Listen for messages. - $discord->on(Event::MESSAGE_CREATE, function (Message $message, Discord $discord) { - // Note: MESSAGE_CONTENT intent must be enabled to get the content if the bot is not mentioned/DMed. + // Listen for messages (requires MESSAGE_CONTENT intent for full content) + $discord->onMessage(function (Message $message, Discord $discord) { $discord->logger->info("{$message->author->username}: {$message->content}"); }); }); @@ -97,7 +136,7 @@ $discord->on('ready', function (Discord $discord) { $discord->run(); ``` -See [examples folder](examples) for more. +See the [quickstart guide](guide/quickstart.rst) and [examples folder](examples) for more. ## Documentation @@ -105,7 +144,7 @@ Documentation for the latest version can be found [here](//discord-php.github.io ## Contributing -We are open to contributions. However, please make sure you follow our coding standards (PSR-4 autoloading and custom styling). Please run php-cs-fixer before opening a pull request by running `composer run-script cs`. +We are open to contributions. However, please make sure you follow our coding standards (PSR-4 autoloading and custom styling). Please run Pint before opening a pull request by running `composer run-script pint`. ## License diff --git a/composer.json b/composer.json index 6691da9a3..6c419426e 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,9 @@ { "name": "team-reflex/discord-php", + "type": "library", "description": "An unofficial API to interact with the voice and text service Discord.", + "keywords": ["discord", "bot", "api", "websocket", "reactphp"], + "homepage": "https://discord-php.github.io/DiscordPHP/", "license": "MIT", "authors": [ { @@ -20,34 +23,37 @@ }, "require": { "php": "^8.1.2", - "nesbot/carbon": "^2.38 || ^3.0", - "ratchet/pawl": "^0.4.3", - "react/datagram": "^1.8", - "symfony/options-resolver": "^5.1.11 || ^6.0 || ^7.0 || ^8.0", - "trafficcophp/bytebuffer": "^0.3", - "monolog/monolog": "^2.1.1 || ^3.0", - "react/event-loop": "^1.2", + "ext-json": "*", "ext-zlib": "*", + "discord-php-helpers/collection": "^8.0", + "discord-php-helpers/voice": "^8.0 || dev-main", "discord-php/http": "^10.1.7", - "react/child-process": "^0.6.3", "discord/interactions": "^2.2", + "monolog/monolog": "^2.1.1 || ^3.0", + "nesbot/carbon": "^2.38 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", + "ratchet/pawl": "^0.4.3", "react/async": "^4.0 || ^3.0", "react/cache": "^0.5 || ^0.6 || ^1.0", + "react/child-process": "^0.6.3", + "react/datagram": "^1.8", + "react/event-loop": "^1.2", "react/promise": "^3.0.0", - "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", - "discord-php-helpers/collection": "^8.0", - "discord-php-helpers/voice": "^8.0 || dev-main" + "symfony/dotenv": "^6 || ^7 || ^8", + "symfony/options-resolver": "^5.1.11 || ^6.0 || ^7.0 || ^8.0", + "trafficcophp/bytebuffer": "^0.3" }, "require-dev": { - "symfony/var-dumper": "*", - "friendsofphp/php-cs-fixer": "^3", + "carthage-software/mago": "1.1.0", "davidcole1340/reactsh": "dev-master", - "wyrihaximus/react-cache-redis": "^4.5", - "symfony/cache": "^5.4", "laravel/pint": "^1.21", "pestphp/pest": "^4.1.2", - "carthage-software/mago": "1.1.0" + "symfony/cache": "^5.4", + "symfony/var-dumper": "*", + "wyrihaximus/react-cache-redis": "^4.5" }, + "minimum-stability": "dev", + "prefer-stable": true, "autoload": { "files": [ "src/Discord/functions.php" @@ -56,6 +62,11 @@ "Discord\\": "src/Discord" } }, + "autoload-dev": { + "files": [ + "tests/functions.php" + ] + }, "suggest": { "ext-gmp": "For 64 bit calculations on x86 (32 bit) PHP.", "ext-uv": "For a faster, and more performant loop. Preferred.", @@ -63,23 +74,26 @@ "ext-event": "For a faster, and more performant loop.", "ext-mbstring": "For accurate calculations of string length when handling non-english characters.", "ext-fileinfo": "For function mime_content_type().", + "ext-sodium": "Required for voice encryption.", "laracord/laracord": "Provides Laracord integration for DiscordPHP." }, "scripts": { + "post-install-cmd": ["@php scripts/post-install.php"], + "post-update-cmd": ["@php scripts/post-install.php"], "pint": ["./vendor/bin/pint --config ./pint.json ./src"], - "cs": ["./vendor/bin/php-cs-fixer fix"], - "cs-unsupported": ["./vendor/bin/php-cs-fixer fix --allow-unsupported-php-version yes"], "mago-lint": ["./vendor/bin/mago lint src/"], - "unit": ["./vendor/bin/phpunit --testdox"], - "coverage": ["XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-html coverage --testdox"], - "php8.0": ["docker-compose run php8.0 sh"], - "php8.1": ["docker-compose run php8.1 sh"], - "php8.2": ["docker-compose run php8.2 sh"], - "php8.3": ["docker-compose run php8.3 sh"], - "php8.4": ["docker-compose run php8.4 sh"], - "pest": "pest --parallel" + "unit": ["./vendor/bin/pest --testsuite unit"], + "integration": ["./vendor/bin/pest --testsuite integration"], + "coverage": ["XDEBUG_MODE=coverage ./vendor/bin/pest --testsuite unit --coverage --coverage-html coverage"], + "coverage-full": ["XDEBUG_MODE=coverage ./vendor/bin/pest --coverage --coverage-html coverage"], + "coverage-xml": ["XDEBUG_MODE=coverage ./vendor/bin/pest --testsuite unit --coverage --coverage-clover coverage.xml"], + "coverage-xml-full": ["XDEBUG_MODE=coverage ./vendor/bin/pest --coverage --coverage-clover coverage.xml"], + "pest": ["./vendor/bin/pest --parallel"] }, "config": { + "sort-packages": true, + "preferred-install": "dist", + "optimize-autoloader": true, "allow-plugins": { "pestphp/pest-plugin": true, "carthage-software/mago": true diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 5fe2b5fc1..000000000 --- a/docker-compose.yml +++ /dev/null @@ -1,25 +0,0 @@ -services: - php8.0: - build: ./docker/php/8.0 - volumes: - - .:/root/discord-php - - php8.1: - build: ./docker/php/8.1 - volumes: - - .:/root/discord-php - - php8.2: - build: ./docker/php/8.2 - volumes: - - .:/root/discord-php - - php8.3: - build: ./docker/php/8.3 - volumes: - - .:/root/discord-php - - php8.4: - build: ./docker/php/8.4 - volumes: - - .:/root/discord-php diff --git a/docker/php/8.0/Dockerfile b/docker/php/8.0/Dockerfile deleted file mode 100644 index 639c763c3..000000000 --- a/docker/php/8.0/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM php:8.0-cli-alpine - -RUN apk add --update linux-headers \ - && apk --no-cache add pcre ${PHPIZE_DEPS} \ - && pecl install xdebug \ - && docker-php-ext-enable xdebug \ - && apk del pcre ${PHPIZE_DEPS} - -# Use the default production configuration -RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" - -RUN wget https://raw.githubusercontent.com/composer/getcomposer.org/f3108f64b4e1c1ce6eb462b159956461592b3e3e/web/installer -O - -q | php -- --quiet -RUN mv /composer.phar /usr/local/bin/composer - -ENV BOTDIR="/root/discord-php" -LABEL version="10" -WORKDIR ${BOTDIR} -COPY . ${BOTDIR} diff --git a/docker/php/8.1/Dockerfile b/docker/php/8.1/Dockerfile deleted file mode 100644 index e46358d65..000000000 --- a/docker/php/8.1/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM php:8.1-cli-alpine - -RUN apk add --update linux-headers \ - && apk --no-cache add pcre ${PHPIZE_DEPS} \ - && pecl install xdebug \ - && docker-php-ext-enable xdebug \ - && apk del pcre ${PHPIZE_DEPS} - -# Use the default production configuration -RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" - -RUN wget https://raw.githubusercontent.com/composer/getcomposer.org/f3108f64b4e1c1ce6eb462b159956461592b3e3e/web/installer -O - -q | php -- --quiet -RUN mv /composer.phar /usr/local/bin/composer - -ENV BOTDIR="/root/discord-php" -LABEL version="10" -WORKDIR ${BOTDIR} -COPY . ${BOTDIR} diff --git a/docker/php/8.2/Dockerfile b/docker/php/8.2/Dockerfile deleted file mode 100644 index 8561cffff..000000000 --- a/docker/php/8.2/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM php:8.2-cli-alpine - -RUN apk add --update linux-headers \ - && apk --no-cache add pcre ${PHPIZE_DEPS} \ - && pecl install xdebug \ - && docker-php-ext-enable xdebug \ - && apk del pcre ${PHPIZE_DEPS} - -# Use the default production configuration -RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" - -RUN wget https://raw.githubusercontent.com/composer/getcomposer.org/f3108f64b4e1c1ce6eb462b159956461592b3e3e/web/installer -O - -q | php -- --quiet -RUN mv /composer.phar /usr/local/bin/composer - -ENV BOTDIR="/root/discord-php" -LABEL version="10" -WORKDIR ${BOTDIR} -COPY . ${BOTDIR} diff --git a/docker/php/8.3/Dockerfile b/docker/php/8.3/Dockerfile deleted file mode 100644 index 667b966ce..000000000 --- a/docker/php/8.3/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM php:8.3-cli-alpine - -RUN apk add --update linux-headers \ - && apk --no-cache add pcre ${PHPIZE_DEPS} \ - && pecl install xdebug \ - && docker-php-ext-enable xdebug \ - && apk del pcre ${PHPIZE_DEPS} - -# Use the default production configuration -RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" - -RUN wget https://raw.githubusercontent.com/composer/getcomposer.org/f3108f64b4e1c1ce6eb462b159956461592b3e3e/web/installer -O - -q | php -- --quiet -RUN mv /composer.phar /usr/local/bin/composer - -ENV BOTDIR="/root/discord-php" -LABEL version="10" -WORKDIR ${BOTDIR} -COPY . ${BOTDIR} diff --git a/docker/php/8.4/Dockerfile b/docker/php/8.4/Dockerfile deleted file mode 100644 index 98c807bec..000000000 --- a/docker/php/8.4/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM php:8.4-cli-alpine - -RUN apk add --update linux-headers \ - && apk --no-cache add pcre ${PHPIZE_DEPS} \ - && pecl install xdebug \ - && docker-php-ext-enable xdebug \ - && apk del pcre ${PHPIZE_DEPS} - -# Use the default production configuration -RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" - -RUN wget https://raw.githubusercontent.com/composer/getcomposer.org/f3108f64b4e1c1ce6eb462b159956461592b3e3e/web/installer -O - -q | php -- --quiet -RUN mv /composer.phar /usr/local/bin/composer - -ENV BOTDIR="/root/discord-php" -LABEL version="10" -WORKDIR ${BOTDIR} -COPY . ${BOTDIR} diff --git a/example.env b/example.env deleted file mode 100644 index ba9b9facc..000000000 --- a/example.env +++ /dev/null @@ -1,4 +0,0 @@ -# Unit tests will use these variables -DISCORD_TOKEN= -TEST_CHANNEL= -TEST_CHANNEL_NAME= \ No newline at end of file diff --git a/examples/browser.php b/examples/browser.php index 69a9241c9..bd0b6edad 100644 --- a/examples/browser.php +++ b/examples/browser.php @@ -1,66 +1,55 @@ '', // Put your Bot token here from https://discord.com/developers/applications/ -]); +// fromEnv() loads .env automatically and throws a helpful error if it's missing +$discord = DiscordCommandClient::fromEnv(); -// Create a $browser with same loop as $discord -$browser = new Browser(null, $discord->getLoop()); +$browser = new Browser(); $discord->registerCommand('discordstatus', function (Message $message, $params) use ($discord, $browser) { - coroutine(function (Message $message, $params) use ($discord, $browser) { - // Ignore messages from any Bots - if ($message->author->bot) return; + async(function () use ($message, $discord, $browser) { + if ($message->author->bot) { + return; + } try { - // Make GET request to API of discordstatus.com - $response = yield $browser->get('https://discordstatus.com/api/v2/status.json'); + $response = await($browser->get('https://discordstatus.com/api/v2/status.json')); - assert($response instanceof ResponseInterface); // Check if request succeed + assert($response instanceof ResponseInterface); - // Get response body $result = (string) $response->getBody(); - // Uncomment to debug result $discord->logger->debug('Browser response', ['response' => $result]); - // Parse JSON $discordstatus = json_decode($result); - // Send reply about the discord status - $message->reply('Discord status: ' . $discordstatus->status->description); - } catch (Exception $e) { // Request failed - // Uncomment to debug exceptions + $message->reply('Discord status: '.$discordstatus->status->description); + } catch (Exception $e) { $discord->logger->error('Browser request failed', ['exception' => $e->getMessage()]); - // Send reply about the discord status - $message->reply('Unable to acesss the Discord status API :('); + $message->reply('Unable to access the Discord status API :('); } - }, $message, $params); + })(); }); -// Start the Bot (must be at the bottom) $discord->run(); diff --git a/examples/command.php b/examples/command.php index 9b58448fb..e65c1fca9 100644 --- a/examples/command.php +++ b/examples/command.php @@ -21,12 +21,15 @@ use Discord\Parts\Interactions\Request\Option as RequestOption; use Discord\Repository\Guild\GuildCommandRepository; use Discord\Repository\Interaction\GlobalCommandRepository; +use Discord\WebSockets\Event; use Discord\WebSockets\Intents; require_once __DIR__.'/../vendor/autoload.php'; ini_set('memory_limit', -1); +// fromEnv() below will load .env automatically + /** * Class to handle the command callbacks * @@ -179,10 +182,7 @@ public function autocomplete(ApplicationCommandAutocomplete $interaction): array * * @see https://dis.gd/mcfaq */ -$dc = new Discord([ - // https://discord.com/developers/applications/>/bot - 'token' => 'YOUR_DISCORD_BOT_TOKEN', - +$dc = Discord::fromEnv([ 'intents' => (Intents::getDefaultIntents() | Intents::MESSAGE_CONTENT), ]); @@ -191,7 +191,7 @@ public function autocomplete(ApplicationCommandAutocomplete $interaction): array * * IMPORTANT: Avoid calling freshen() multiple times to prevent rate limiting */ -$dc->on('init', function (Discord $discord): void +$dc->on(Event::READY, function (Discord $discord): void { echo "Bot is ready!\n"; diff --git a/examples/ping.php b/examples/ping.php index 2f8cd3e6a..5f5ad576f 100644 --- a/examples/ping.php +++ b/examples/ping.php @@ -5,47 +5,35 @@ * * When a User says "ping", the Bot will reply "pong" * - * Getting a User message content requries the Message Content Privileged Intent + * Getting a User message content requires the Message Content Privileged Intent * @link http://dis.gd/mcfaq * - * Run this example bot from main directory using command: + * After `composer install`, edit the generated .env and set DISCORD_TOKEN, then: * php examples/ping.php */ include __DIR__.'/../vendor/autoload.php'; -// Import classes, install a LSP such as Intelephense to auto complete imports use Discord\Discord; use Discord\Parts\Channel\Message; +use Discord\WebSockets\Event; use Discord\WebSockets\Intents; -// Create a $discord BOT -$discord = new Discord([ - 'token' => '', // Put your Bot token here from https://discord.com/developers/applications/ - 'intents' => Intents::getDefaultIntents() | Intents::MESSAGE_CONTENT // Required to get message content, enable it on https://discord.com/developers/applications/ +// fromEnv() loads .env automatically and throws a helpful error if it's missing +$discord = Discord::fromEnv([ + 'intents' => Intents::getDefaultIntents() | Intents::MESSAGE_CONTENT, // Required to get message content, enable it on https://discord.com/developers/applications/ ]); -// When the Bot is ready -$discord->on('init', function (Discord $discord) { - - // Listen for messages - $discord->on('message', function (Message $message, Discord $discord) { - - // If message is from a bot +$discord->onReady(function (Discord $discord) { + $discord->onMessage(function (Message $message, Discord $discord) { if ($message->author->bot) { - // Do nothing return; } - // If message is "ping" if ($message->content === 'ping') { - // Reply with "pong" $message->reply('pong'); } - }); - }); -// Start the Bot (must be at the bottom) $discord->run(); diff --git a/examples/quickstart.php b/examples/quickstart.php new file mode 100644 index 000000000..b842cfe28 --- /dev/null +++ b/examples/quickstart.php @@ -0,0 +1,30 @@ +onReady(function (Discord $discord) { + echo 'Logged in as '.$discord->user->username.'!'.PHP_EOL; + + $discord->onMessage(function (Message $message) { + if (! $message->author->bot && $message->content === '!ping') { + $message->reply('Pong!'); + } + }); +}); + +$discord->run(); diff --git a/guide/quickstart.rst b/guide/quickstart.rst new file mode 100644 index 000000000..6aed3308d --- /dev/null +++ b/guide/quickstart.rst @@ -0,0 +1,87 @@ +Quickstart +========== + +This guide takes you from zero to a running Discord bot in under five minutes. + +Prerequisites +------------- + +- PHP 8.1 or newer (CLI) +- `Composer `_ +- A Discord bot application and its **Bot Token** + +1. Install DiscordPHP +--------------------- + +.. code-block:: bash + + composer require team-reflex/discord-php + +2. Set up your environment +-------------------------- + +Run ``composer install``. The post-install script automatically copies +``.env.example`` to ``.env`` for you. Open it and fill in your token: + +.. code-block:: text + + # .env + DISCORD_TOKEN=your_bot_token_here + +3. Create your bot +------------------ + +Create a file called ``bot.php`` in your project root: + +.. code-block:: php + + onReady(function (Discord $discord) { + echo 'Logged in as ' . $discord->user->username . '!' . PHP_EOL; + + $discord->onMessage(function (Message $message) { + // Ignore bots + if ($message->author->bot) { + return; + } + + if ($message->content === '!ping') { + $message->reply('Pong!'); + } + }); + }); + + $discord->run(); + +4. Run the bot +-------------- + +.. code-block:: bash + + php bot.php + +You should see ``Logged in as YourBot#0001!`` in the terminal. + +5. Invite your bot to a server +------------------------------- + +1. Open the `Discord Developer Portal `_. +2. Select your application → **OAuth2 → URL Generator**. +3. Check **bot** scope and the permissions your bot needs. +4. Open the generated URL in your browser and invite it to a test server. + +Next steps +---------- + +- :doc:`/basics` — options, intents, and caching. +- :doc:`/events` — the full list of gateway events. +- ``examples/`` folder in the package for real-world patterns. diff --git a/phpunit.xml b/phpunit.xml index 32e2b1811..52e4bd622 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,27 +1,36 @@ + failOnWarning="true"> - - tests - + + tests/FunctionsTest.php + tests/CollectionsTest.php + tests/Builders + tests/Parts/Channel/InviteTest.php + tests/ArchTest.php + + + tests/Parts/Channel/ChannelTest.php + tests/Parts/Channel/Message/EmbedMessageTest.php + tests/Parts/Channel/Message/EmptyMessageTest.php + tests/Parts/Channel/Message/MessageTest.php + tests/Parts/Channel/Message/RemoveReactionTest.php + tests/Parts/Embed/EmbedTest.php + tests/DiscordTest.php - + + + src - + diff --git a/pint.json b/pint.json index 49c5a7fb3..308892f8c 100644 --- a/pint.json +++ b/pint.json @@ -1,6 +1,50 @@ { "preset": "psr12", "rules": { - "declare_strict_types": true + "array_syntax": {"syntax": "short"}, + "blank_line_before_statement": {"statements": ["return"]}, + "cast_spaces": true, + "concat_space": {"spacing": "none"}, + "declare_strict_types": true, + "echo_tag_syntax": {"format": "long"}, + "general_phpdoc_tag_rename": true, + "header_comment": { + "header": "This file is a part of the DiscordPHP project.\n\nCopyright (c) 2015-2022 David Cole \nCopyright (c) 2020-present Valithor Obsidion \n\nThis file is subject to the MIT license that is bundled\nwith this source code in the LICENSE.md file.", + "comment_type": "comment", + "location": "after_declare_strict" + }, + "include": true, + "multiline_whitespace_before_semicolons": true, + "no_blank_lines_after_phpdoc": true, + "no_empty_statement": true, + "no_leading_namespace_whitespace": true, + "no_multiline_whitespace_around_double_arrow": true, + "no_singleline_whitespace_before_semicolons": true, + "no_trailing_comma_in_singleline": true, + "no_unused_imports": true, + "no_useless_else": true, + "no_useless_return": true, + "not_operator_with_successor_space": true, + "object_operator_without_whitespace": true, + "phpdoc_align": { + "align": "vertical", + "tags": ["param", "property", "property-read", "property-write", "return", "throws", "type", "var", "method"] + }, + "phpdoc_indent": true, + "phpdoc_inline_tag_normalizer": true, + "phpdoc_no_access": true, + "phpdoc_no_alias_tag": {"replacements": {"type": "var"}}, + "phpdoc_no_empty_return": true, + "phpdoc_no_package": true, + "phpdoc_scalar": true, + "phpdoc_summary": true, + "phpdoc_tag_type": true, + "phpdoc_trim": true, + "phpdoc_var_without_name": true, + "single_quote": true, + "standardize_not_equals": true, + "trailing_comma_in_multiline": {"elements": ["arrays"]}, + "trim_array_spaces": true, + "visibility_required": true } } \ No newline at end of file diff --git a/scripts/post-install.php b/scripts/post-install.php new file mode 100644 index 000000000..5b46bec1e --- /dev/null +++ b/scripts/post-install.php @@ -0,0 +1,35 @@ + 'bot-token'," +Write-Host " 'cafile' => '$caFile'," +Write-Host " ]);" +Write-Host "" +Write-Host " Option B - set an environment variable (persistent for your user):" +Write-Host " setx DISCORDPHP_CAFILE `"$caFile`"" +Write-Host " # then open a new terminal" +Write-Host "" +Write-Host " [Traditional] Configure PHP globally via php.ini:" +Write-Host "" +Write-Host " 1) Find your php.ini: php --ini" +Write-Host " 2) Add (or update) these lines:" +Write-Host "" +Write-Host " openssl.cafile=`"$caFile`"" +Write-Host " curl.cainfo=`"$caFile`"" +Write-Host "" +Write-Host " 3) Restart any running PHP processes." +Write-Host "" diff --git a/src/Discord/Builders/Components/Checkbox.php b/src/Discord/Builders/Components/Checkbox.php index b30313965..b0690cde5 100644 --- a/src/Discord/Builders/Components/Checkbox.php +++ b/src/Discord/Builders/Components/Checkbox.php @@ -56,7 +56,7 @@ public function __construct(?string $custom_id = null) { $this->setCustomId($custom_id ?? self::generateUuid()); } - + /** * Creates a new checkbox component. * diff --git a/src/Discord/Builders/Components/CheckboxGroup.php b/src/Discord/Builders/Components/CheckboxGroup.php index 7e63d279f..0c9812a33 100644 --- a/src/Discord/Builders/Components/CheckboxGroup.php +++ b/src/Discord/Builders/Components/CheckboxGroup.php @@ -52,7 +52,7 @@ public function __construct(?string $custom_id = null) { $this->setCustomId($custom_id ?? self::generateUuid()); } - + /** * Creates a new checkbox group component. * @@ -139,7 +139,7 @@ public function jsonSerialize(): array if (count($this->options) < 1 || count($this->options) > 10) { throw new \DomainException('CheckboxGroup must have between 1 and 10 options.'); } - + if (isset($this->min_values)) { $content['min_values'] = $this->min_values; } diff --git a/src/Discord/Builders/Components/RadioGroup.php b/src/Discord/Builders/Components/RadioGroup.php index 96b2ae970..636a685d2 100644 --- a/src/Discord/Builders/Components/RadioGroup.php +++ b/src/Discord/Builders/Components/RadioGroup.php @@ -50,7 +50,7 @@ public function __construct(?string $custom_id = null) { $this->setCustomId($custom_id ?? self::generateUuid()); } - + /** * Creates a new radio group component. * diff --git a/src/Discord/Builders/MessageBuilder.php b/src/Discord/Builders/MessageBuilder.php index 617171ba4..aa56347e7 100644 --- a/src/Discord/Builders/MessageBuilder.php +++ b/src/Discord/Builders/MessageBuilder.php @@ -522,7 +522,7 @@ public function addComponent($component): self } $this->components ??= []; - + if ($this->flags & Message::FLAG_IS_COMPONENTS_V2) { $this->enforceV2Limits(); } else { diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index 01ebd18ea..af74d110c 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -19,6 +19,7 @@ use Discord\Helpers\BigInt; use Discord\Helpers\CacheConfig; use Discord\Helpers\Collection; +use Symfony\Component\Dotenv\Dotenv; use Discord\Helpers\ExCollectionInterface; use Discord\Helpers\RegisteredCommand; use Discord\Http\Drivers\React; @@ -402,6 +403,48 @@ class Discord */ protected $regions; + /** + * Creates a Discord client from environment variables. + * + * Looks for a `.env` file in the current working directory and loads it + * automatically via {@see Dotenv}. If no `.env` file is found **and** + * `DISCORD_TOKEN` is not already set in the environment, a + * `\RuntimeException` is thrown with instructions on how to proceed. + * + * Environment variables already present in the environment (e.g. from + * Docker or CI) are never overridden, so `.env` is always optional when + * the variables are supplied externally. + * + * Any key in `$overrides` takes precedence over the environment. + * + * @param array $overrides Option overrides. + * @throws IntentException + * @throws \RuntimeException + */ + public static function fromEnv(array $overrides = []): static + { + $envPath = getcwd().'/.env'; + $envFileFound = file_exists($envPath); + + if ($envFileFound) { + (new Dotenv())->load($envPath); + } + + if (! $envFileFound && (($token = $_ENV['DISCORD_TOKEN'] ?? getenv('DISCORD_TOKEN')) === false || $token === '') && ! isset($overrides['token'])) { + throw new \RuntimeException( + "No .env file found at {$envPath} and DISCORD_TOKEN is not set in the environment.\n\n" + ." To fix this, copy .env.example to .env and fill in your bot token:\n" + ." cp .env.example .env\n\n" + ." Alternatively, set the environment variable directly:\n" + ." export DISCORD_TOKEN=your_token_here\n\n" + ." Or pass the token as an option:\n" + ." Discord::fromEnv(['token' => 'your_token_here'])" + ); + } + + return new static($overrides); + } + /** * Creates a Discord client instance. * @@ -423,6 +466,8 @@ public function __construct(array $options = []) $this->loop = $options['loop']; $this->logger = $options['logger']; + $this->applyCaFileOption($options['cafile'] ?? null); + if (! in_array(php_sapi_name(), ['cli', 'micro'])) { $this->logger->critical('DiscordPHP will not run on a webserver. Please use PHP CLI to run a DiscordPHP bot.'); } @@ -1699,6 +1744,45 @@ protected function buildParams(Deferred $deferred, string $gateway, ?SessionStar $deferred->resolve(['gateway' => $this->gateway, 'session' => $session->jsonSerialize()]); } + /** + * Applies a CA certificate bundle path to PHP's TLS configuration. + * + * When the `cafile` option is not set, falls back to the + * `DISCORDPHP_CAFILE` environment variable. Only applies the path + * to `openssl.cafile` / `curl.cainfo` when those ini values are + * currently empty, so a user's existing configuration is never + * overridden. Silently no-ops when no path is provided or the + * file does not exist. + * + * @param ?string $cafile Path from the constructor option, or null. + */ + protected function applyCaFileOption(?string $cafile): void + { + if ($cafile === null || $cafile === '') { + $envValue = $_ENV['DISCORDPHP_CAFILE'] ?? getenv('DISCORDPHP_CAFILE'); + if ($envValue === false || $envValue === '') { + return; + } + $cafile = $envValue; + } + + if (! is_file($cafile)) { + $this->logger->warning('Ignoring cafile: path does not exist', ['path' => $cafile]); + + return; + } + + if (ini_get('openssl.cafile') === '') { + ini_set('openssl.cafile', $cafile); + } + + if (ini_get('curl.cainfo') === '') { + ini_set('curl.cainfo', $cafile); + } + + $this->logger->debug('Applied CA certificate bundle', ['path' => $cafile]); + } + /** * Resolves the options. * @@ -1712,11 +1796,11 @@ protected function resolveOptions(array $options = []): array $resolver = new OptionsResolver(); $resolver - ->setRequired('token') ->setDefined([ 'token', 'loop', 'logger', + 'cafile', 'loadAllMembers', 'disabledEvents', 'storeMessages', @@ -1739,6 +1823,7 @@ protected function resolveOptions(array $options = []): array ]) ->setDefaults([ 'logger' => null, + 'cafile' => null, 'loadAllMembers' => false, 'disabledEvents' => [], 'storeMessages' => false, @@ -1760,6 +1845,7 @@ protected function resolveOptions(array $options = []): array ]) ->setAllowedTypes('token', 'string') ->setAllowedTypes('logger', ['null', LoggerInterface::class]) + ->setAllowedTypes('cafile', ['null', 'string']) ->setAllowedTypes('loop', LoopInterface::class) ->setAllowedTypes('loadAllMembers', ['bool', 'array']) ->setAllowedTypes('disabledEvents', 'array') @@ -1797,7 +1883,52 @@ protected function resolveOptions(array $options = []): array return Collection::class; }) ->setAllowedTypes('useTransportCompression', 'bool') - ->setAllowedTypes('usePayloadCompression', 'bool'); + ->setAllowedTypes('usePayloadCompression', 'bool') + ->setNormalizer('token', function ($options, $value) { + if ($value === null || $value === '') { + $env = $_ENV['DISCORD_TOKEN'] ?? getenv('DISCORD_TOKEN'); + if ($env !== false && $env !== '') { + return $env; + } + throw new \RuntimeException( + 'No Discord bot token provided. Pass \'token\' to new Discord([...]) or set the DISCORD_TOKEN environment variable.' + ); + } + + return $value; + }) + ->setNormalizer('intents', function ($options, $value) { + if (! is_int($value)) { + return $value; + } + + $env = $_ENV['DISCORDPHP_INTENTS'] ?? getenv('DISCORDPHP_INTENTS'); + if ($env === false || $env === '') { + return $value; + } + + // The explicit option was not passed; only fall back when the value is the default. + if ($value !== Intents::getDefaultIntents()) { + return $value; + } + + if (is_numeric($env)) { + return (int) $env; + } + + // Comma-separated constant names, e.g. "GUILDS,GUILD_MESSAGES,MESSAGE_CONTENT" + $reflect = new \ReflectionClass(Intents::class); + $constants = $reflect->getConstants(); + $bitmask = 0; + + foreach (array_map('trim', explode(',', strtoupper($env))) as $name) { + if (isset($constants[$name])) { + $bitmask |= $constants[$name]; + } + } + + return $bitmask ?: $value; + }); $options = $resolver->resolve($options); @@ -1880,6 +2011,31 @@ public function run(): void $this->loop->run(); } + /** + * Registers a one-time or persistent listener for the `READY` event. + * + * A convenience alias for `$discord->on(Event::READY, $callback)`. + * Called when the bot is fully connected and all guilds are available. + * + * @param callable $callback Called with `(Discord $discord)`. + */ + public function onReady(callable $callback): void + { + $this->on(Event::READY, $callback); + } + + /** + * Registers a listener for every incoming `MESSAGE_CREATE` event. + * + * A convenience alias for `$discord->on(Event::MESSAGE_CREATE, $callback)`. + * + * @param callable $callback Called with `(\Discord\Parts\Channel\Message $message, Discord $discord)`. + */ + public function onMessage(callable $callback): void + { + $this->on(Event::MESSAGE_CREATE, $callback); + } + /** * Closes the Discord client. * diff --git a/src/Discord/Parts/Channel/Invite.php b/src/Discord/Parts/Channel/Invite.php index 000d89b92..ca581f140 100644 --- a/src/Discord/Parts/Channel/Invite.php +++ b/src/Discord/Parts/Channel/Invite.php @@ -418,7 +418,7 @@ public function updateTargetUsersFromContent(string $content, string $filename = return $this->http->put(Endpoint::bind(Endpoint::INVITE_TARGET_USERS, $this->id), (string) $multipart, $multipart->getHeaders()); } - + /** * Processing target users from a CSV when creating or updating an invite is done asynchronously. This endpoint allows you to check the status of that job. * @@ -513,7 +513,7 @@ public function save(?string $reason = null): PromiseInterface if (! $this->guild_id) { return parent::save(); } - + if (isset($this->attributes['channel_id'])) { /** @var Channel $channel */ $channel = $this->channel ?? $this->factory->part(Channel::class, ['id' => $this->attributes['channel_id']], true); diff --git a/src/Discord/Parts/Channel/Message.php b/src/Discord/Parts/Channel/Message.php index 7f95c8f40..bca4f7871 100644 --- a/src/Discord/Parts/Channel/Message.php +++ b/src/Discord/Parts/Channel/Message.php @@ -1427,7 +1427,7 @@ public function getRepository(): MessageRepository|WebhookMessageRepository if (isset($this->attributes['webhook_id'])) { $webhook = $channel->webhooks->get('id', $this->attributes['webhook_id']); - + return $webhook->messages; } diff --git a/src/Discord/Parts/Guild/Integration.php b/src/Discord/Parts/Guild/Integration.php index a18373947..4e752d40c 100644 --- a/src/Discord/Parts/Guild/Integration.php +++ b/src/Discord/Parts/Guild/Integration.php @@ -180,7 +180,7 @@ public function getRepository(): IntegrationRepository|null if (! isset($this->attributes['guild_id'])) { return null; } - + /** @var Guild $guild */ $guild = $this->guild ?? $this->factory->part(Guild::class, ['id' => $this->attributes['guild_id']], true); @@ -217,7 +217,7 @@ public function sync(): PromiseInterface if (! isset($this->attributes['guild_id'])) { return reject(new \Exception('Integration does not belong to a guild.')); } - + /** @var Guild $guild */ $guild = $this->guild ?? $this->factory->part(Guild::class, ['id' => $this->attributes['guild_id']], true); diff --git a/src/Discord/Repository/Guild/BanRepository.php b/src/Discord/Repository/Guild/BanRepository.php index 33cdcc2c6..8f2c96205 100644 --- a/src/Discord/Repository/Guild/BanRepository.php +++ b/src/Discord/Repository/Guild/BanRepository.php @@ -104,7 +104,7 @@ public function banBulk($users, array $options = [], ?string $reason = null): Pr return $this->http->post(Endpoint::bind(Endpoint::GUILD_BAN_BULK, $this->vars['guild_id']), $content, $headers) ->then(function ($response) use ($reason) { $response = (array) $response; - + $banned_users = []; foreach ($response['banned_users'] ?? [] as $user_id) { /** @var Ban */ diff --git a/src/Discord/Voice/OldVoiceClient.php b/src/Discord/Voice/OldVoiceClient.php index 2e2e73c22..dd7c12133 100644 --- a/src/Discord/Voice/OldVoiceClient.php +++ b/src/Discord/Voice/OldVoiceClient.php @@ -466,7 +466,7 @@ public function __construct(Discord &$discord, WebSocket &$websocket, array &$vo } $this->channel_id = $channel->id; $this->guild_id = $channel->guild_id; - + $this->data = $data; $this->deaf = $data['deaf']; $this->mute = $data['mute']; @@ -1381,7 +1381,7 @@ protected function sendBuffer(string $data): void $packet = new VoicePacket($data, $this->ssrc, $this->seq, $this->timestamp); //$this->discord->getLogger()->debug('valid packet?' , ['valid' => $packet::validatePacket($packet)]); - + $encrypted = $this->crypto->encryptRTPPacket($packet, $this->seq); //$this->discord->getLogger()->debug('valid encrypted packet?' , ['valid' => $this->crypto::validateEncryptedRTPPacket($encrypted)]); diff --git a/src/Discord/WebSockets/Events/GuildScheduledEventExceptionUpdate.php b/src/Discord/WebSockets/Events/GuildScheduledEventExceptionUpdate.php index f49e7f37a..4f486c94f 100644 --- a/src/Discord/WebSockets/Events/GuildScheduledEventExceptionUpdate.php +++ b/src/Discord/WebSockets/Events/GuildScheduledEventExceptionUpdate.php @@ -46,7 +46,7 @@ public function handle($data) $oldScheduledEventException = clone $oldScheduledEventException; $scheduledEventExceptionPart->fill((array) $data); - + $guild->guild_scheduled_event_exceptions->set($scheduledEventExceptionPart->event_exception_id, $scheduledEventExceptionPart); } } diff --git a/src/Discord/functions.php b/src/Discord/functions.php index 8ee73c361..7ae17aa2c 100644 --- a/src/Discord/functions.php +++ b/src/Discord/functions.php @@ -90,7 +90,7 @@ function mentioned(Part|string $part, Message $message): bool { return match (true) { $part instanceof User, $part instanceof Member => $message->mentions->has($part->id), - $part instanceof Role => $message->mention_roles->has($part->id), + $part instanceof Role => array_key_exists($part->id, $message->mention_roles->toArray()), $part instanceof Channel || $part instanceof Thread => str_contains($message->content, "<#{$part->id}>"), default => false, }; @@ -111,10 +111,8 @@ function getColor(int|string $color = 0): int return $color; } - if (preg_match('/^([a-z]+)$/ui', $color, $match)) { - $colorName = strtolower($match[1]); - - return COLORTABLE[$colorName] ?? 0; + if (preg_match('/^[a-z]+$/i', $color)) { + return COLORTABLE[strtolower($color)] ?? 0; } if (preg_match('/^(#|0x|)([0-9a-f]{6})$/ui', $color, $match)) { @@ -136,7 +134,13 @@ function getColor(int|string $color = 0): int */ function contains(string $string, array $matches): bool { - return array_reduce($matches, fn ($carry, $match) => $carry || str_contains($string, $match), false); + foreach ($matches as $match) { + if (str_contains($string, $match)) { + return true; + } + } + + return false; } /** @@ -150,7 +154,7 @@ function contains(string $string, array $matches): bool */ function studly(string $string): string { - return implode('', array_map('ucfirst', array_map('strtolower', preg_split('/[^a-z0-9]+/i', $string)))); + return implode('', array_map('ucfirst', preg_split('/[^a-z0-9]+/i', strtolower($string)))); } /** @@ -185,10 +189,10 @@ function imageToBase64(string $filepath): string } $mimetype = \mime_content_type($filepath); - static $allowed = ['image/jpeg', 'image/png', 'image/gif']; + static $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; if (! in_array($mimetype, $allowed)) { - throw new \InvalidArgumentException('The given filepath is not one of jpeg, png or gif.'); + throw new \InvalidArgumentException('The given filepath is not one of jpeg, png, gif or webp.'); } $contents = file_get_contents($filepath); @@ -205,7 +209,7 @@ function imageToBase64(string $filepath): string * * @since 5.1.1 */ -function getSnowflakeTimestamp(string $snowflake) +function getSnowflakeTimestamp(string $snowflake): ?float { if (\PHP_INT_SIZE === 4) { //x86 $binary = \str_pad(\base_convert($snowflake, 10, 2), 64, '0', \STR_PAD_LEFT); @@ -222,7 +226,7 @@ function getSnowflakeTimestamp(string $snowflake) $processID = ($snowflake & 0x1F000) >> 12; $increment = ($snowflake & 0xFFF); } - if ($timestamp < 1420070400 || $workerID < 0 || $workerID >= 32 || $processID < 0 || $processID >= 32 || $increment < 0 || $increment >= 4096) { + if ($timestamp < 1420070400 || $workerID >= 32 || $processID >= 32 || $increment >= 4096) { return null; } @@ -276,7 +280,7 @@ function escapeMarkdown(string $text): string * @since 10.0.0 Handle `$canceller` internally, use `cancel()` from the returned promise. * @since 7.1.0 */ -function deferFind($array, callable $callback, $loop = null): PromiseInterface +function deferFind($array, callable $callback, ?LoopInterface $loop = null): PromiseInterface { $cancelled = false; $deferred = new Deferred(function () use (&$cancelled) { @@ -327,12 +331,12 @@ function deferFind($array, callable $callback, $loop = null): PromiseInterface * * @since 10.0.0 */ -function nowait(PromiseInterface $promiseInterface) +function nowait(PromiseInterface $promiseInterface): mixed { $resolved = null; $promiseInterface->then(static function ($value) use (&$resolved) { - return $resolved = $value; + $resolved = $value; }); return $resolved; diff --git a/tests/ArchTest.php b/tests/ArchTest.php new file mode 100644 index 000000000..b58838999 --- /dev/null +++ b/tests/ArchTest.php @@ -0,0 +1,42 @@ + + * Copyright (c) 2020-present Valithor Obsidion + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +// ────────────────────────────────────────────────────────────────────────────── +// Source architecture +// ────────────────────────────────────────────────────────────────────────────── + +arch('all source files declare strict types') + ->expect('Discord\Builders') + ->toUseStrictTypes(); + +// Builders are pure payload objects: they must NOT call the Discord HTTP layer +// or touch the event loop directly. Anything in Discord\Builders that fails +// this rule can be unit tested — add a test rather than marking it integration-only. +arch('builders are pure — no direct HTTP or event-loop calls') + ->expect('Discord\Builders') + ->not->toUse([ + 'Discord\Http\Http', + 'React\EventLoop\LoopInterface', + 'React\EventLoop\Loop', + ]); + +// ────────────────────────────────────────────────────────────────────────────── +// Test suite hygiene +// ────────────────────────────────────────────────────────────────────────────── + +// Prevent class-based PHPUnit tests from sneaking back in. +// If a new test class extends TestCase directly it should use Pest it()/test() instead. +arch('test files do not extend PHPUnit TestCase directly') + ->expect('Tests') + ->not->toExtend('PHPUnit\Framework\TestCase'); diff --git a/tests/Builders/ModalBuilderTest.php b/tests/Builders/ModalBuilderTest.php index 7ef19ec84..a6bc766bf 100644 --- a/tests/Builders/ModalBuilderTest.php +++ b/tests/Builders/ModalBuilderTest.php @@ -14,69 +14,45 @@ use Discord\Builders\Components\Label; use Discord\Builders\ModalBuilder; -use PHPUnit\Framework\TestCase; -final class ModalBuilderTest extends TestCase -{ - public function testNew() - { - $label = new Label(); +it('new() creates a builder with title, custom_id, and components', function () { + $label = new Label(); + $builder = ModalBuilder::new('title', 'custom_id', [$label]); - $builder = ModalBuilder::new('title', 'custom_id', [$label]); + expect($builder->getTitle())->toBe('title'); + expect($builder->getCustomId())->toBe('custom_id'); + expect($builder->getComponents())->toBe([$label]); +}); - $this->assertSame('title', $builder->getTitle()); - $this->assertSame('custom_id', $builder->getCustomId()); - $this->assertSame([$label], $builder->getComponents()); - } +it('addComponent appends a component', function () { + $label = new Label(); + $builder = new ModalBuilder(); + $builder->addComponent($label); - public function testAddComponent() - { - $label = new Label(); + expect($builder->getComponents())->toBe([$label]); +}); - $builder = new ModalBuilder(); - $builder->addComponent($label); +it('setComponents replaces all components', function () { + $label = new Label(); + $builder = new ModalBuilder(); + $builder->setComponents([$label]); - $this->assertSame([$label], $builder->getComponents()); - } + expect($builder->getComponents())->toBe([$label]); +}); - public function testSetComponents() - { - $label = new Label(); +it('setTitle throws when title exceeds 45 characters', function () { + $builder = new ModalBuilder(); + $builder->setTitle(str_repeat('a', 101)); +})->throws(\LogicException::class, 'Modal title can not be longer than 45 characters'); - $builder = new ModalBuilder(); - $builder->setComponents([$label]); +it('setCustomId throws when custom ID exceeds 100 characters', function () { + $builder = new ModalBuilder(); + $builder->setCustomId(str_repeat('a', 101)); +})->throws(\LogicException::class, 'Custom ID must be maximum 100 characters.'); - $this->assertSame([$label], $builder->getComponents()); - } +it('addComponent throws when component limit is reached', function () { + $builder = new ModalBuilder(); + $builder->setComponents([new Label(), new Label(), new Label(), new Label(), new Label()]); + $builder->addComponent(new Label()); +})->throws(\OverflowException::class, 'You can only have 5 components per modal.'); - public function testSetTooLongTitleThrows() - { - $builder = new ModalBuilder(); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Modal title can not be longer than 45 characters'); - - $builder->setTitle(str_repeat('a', 101)); - } - - public function testSetTooLongCustomIdThrows() - { - $builder = new ModalBuilder(); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Custom ID must be maximum 100 characters.'); - - $builder->setCustomId(str_repeat('a', 101)); - } - - public function testAddComponentThrowsAfterReachingLimit() - { - $builder = new ModalBuilder(); - $builder->setComponents([new Label(), new Label(), new Label(), new Label(), new Label()]); - - $this->expectException(\OverflowException::class); - $this->expectExceptionMessage('You can only have 5 components per modal.'); - - $builder->addComponent(new Label()); - } -} diff --git a/tests/CollectionsTest.php b/tests/CollectionsTest.php index 919bdc7c4..a28746ddc 100644 --- a/tests/CollectionsTest.php +++ b/tests/CollectionsTest.php @@ -13,275 +13,198 @@ */ use Discord\Helpers\Collection; -use PHPUnit\Framework\TestCase; -final class CollectionsTest extends TestCase +final class ClassOne { - public function testFrom() - { - $array = ['one', 'two', 'three']; - $collection = Collection::from($array); + public $id; +} - $this->assertEquals($array, $collection->jsonSerialize()); - } +final class ClassTwo +{ + public $id; +} - public function testPush() - { - $collection = new Collection([], null); +it('creates a collection from an array', function () { + $array = ['one', 'two', 'three']; + $collection = Collection::from($array); - $collection->push('test', 'one'); - $collection->push('two'); + expect($collection->jsonSerialize())->toEqual($array); +}); - $this->assertEquals( - ['test', 'one', 'two'], - $collection->jsonSerialize(), - ); - } +it('pushes values onto the collection', function () { + $collection = new Collection([], null); - public function testDontAllowValuesOfDifferentType() - { - $collection = Collection::for(ClassOne::class); + $collection->push('test', 'one'); + $collection->push('two'); - $obj1 = new ClassOne(); - $obj1->id = 1; + expect($collection->jsonSerialize())->toEqual(['test', 'one', 'two']); +}); - $obj2 = new ClassOne(); - $obj2->id = 2; +it('rejects values of a different type', function () { + $collection = Collection::for(ClassOne::class); - $wrongClassObject = new ClassTwo(); - $wrongClassObject->id = 3; + $obj1 = new ClassOne(); + $obj1->id = 1; - $array = [$obj1, $obj2, $wrongClassObject]; + $obj2 = new ClassOne(); + $obj2->id = 2; - $collection->push(...$array); + $wrongClassObject = new ClassTwo(); + $wrongClassObject->id = 3; - $this->assertEquals([ - 1 => $obj1, - 2 => $obj2, - ], $collection->jsonSerialize()); - } + $collection->push($obj1, $obj2, $wrongClassObject); - public function testGet() - { - $collection = new Collection([ - [ - 'id' => 12, - 'test' => 'something', - ], - [ - 'id' => 13, - 'test' => 'something else', - ], - [ - 'id' => 14, - 'test' => 'something even more different', - ], - ], 'id'); - - $this->assertEquals( - [ - 'id' => 13, - 'test' => 'something else', - ], - $collection->get('id', 13) - ); - - $this->assertEquals( - [ - 'id' => 12, - 'test' => 'something', - ], - $collection->get('test', 'something') - ); - } + expect($collection->jsonSerialize())->toEqual([1 => $obj1, 2 => $obj2]); +}); - public function testPull() - { - $array = [1, 2, 3, 4, 5]; - $collection = new Collection($array, null); +it('retrieves values by attribute', function () { + $collection = new Collection([ + ['id' => 12, 'test' => 'something'], + ['id' => 13, 'test' => 'something else'], + ['id' => 14, 'test' => 'something even more different'], + ], 'id'); - $this->assertEquals( - 3, - $collection->pull(2) - ); + expect($collection->get('id', 13))->toEqual(['id' => 13, 'test' => 'something else']); + expect($collection->get('test', 'something'))->toEqual(['id' => 12, 'test' => 'something']); +}); - unset($array[2]); +it('pulls a value by key and removes it', function () { + $array = [1, 2, 3, 4, 5]; + $collection = new Collection($array, null); - $this->assertEquals( - $array, - $collection->jsonSerialize() - ); - } + expect($collection->pull(2))->toBe(3); - public function testPullReturnsDefaultIfKeyNotFound() - { - $array = [1, 2, 3, 4, 5]; - $collection = new Collection($array, null); + unset($array[2]); + expect($collection->jsonSerialize())->toEqual($array); +}); - $this->assertEquals('default', $collection->pull(10, 'default')); - } +it('pull returns default value when key not found', function () { + $collection = new Collection([1, 2, 3, 4, 5], null); - public function testFill() - { - $collection = new Collection([], null); - $collection->fill([1, 2, 3, 4, 5]); + expect($collection->pull(10, 'default'))->toBe('default'); +}); - $this->assertEquals([1, 2, 3, 4, 5], $collection->jsonSerialize()); - } +it('fills the collection from an array', function () { + $collection = new Collection([], null); + $collection->fill([1, 2, 3, 4, 5]); - public function testCount() - { - $collection = new Collection([1, 2, 3, 4, 5], null); + expect($collection->jsonSerialize())->toEqual([1, 2, 3, 4, 5]); +}); - $this->assertEquals(5, $collection->count()); - } +it('counts elements correctly', function () { + $collection = new Collection([1, 2, 3, 4, 5], null); - public function testFirst() - { - $collection = new Collection([1, 2, 3, 4, 5], null); + expect($collection->count())->toBe(5); +}); - $this->assertEquals(1, $collection->first()); - } +it('returns the first element', function () { + $collection = new Collection([1, 2, 3, 4, 5], null); - public function testLast() - { - $collection = new Collection([1, 2, 3, 4, 5], null); + expect($collection->first())->toBe(1); +}); - $this->assertEquals(5, $collection->last()); - } +it('returns the last element', function () { + $collection = new Collection([1, 2, 3, 4, 5], null); - public function testIsset() - { - $collection = new Collection([1, 2, 3, 4, 5], null); + expect($collection->last())->toBe(5); +}); - $this->assertTrue($collection->isset(0)); - $this->assertFalse($collection->isset(5)); - } +it('checks isset correctly', function () { + $collection = new Collection([1, 2, 3, 4, 5], null); - public function testHas() - { - $collection = new Collection([1, 2, 3, 4, 5], null); + expect($collection->isset(0))->toBeTrue(); + expect($collection->isset(5))->toBeFalse(); +}); - $this->assertTrue($collection->has(1, 2, 3)); - $this->assertTrue($collection->has(0)); - $this->assertFalse($collection->has(5, 6, 7)); - $this->assertFalse($collection->has(0, 5)); - } +it('checks has for multiple keys', function () { + $collection = new Collection([1, 2, 3, 4, 5], null); - public function testFilter() - { - $collection = new Collection([1, 2, 3, 4, 5], null); - $filteredCollection = $collection->filter(fn (int $number) => $number > 2); + expect($collection->has(1, 2, 3))->toBeTrue(); + expect($collection->has(0))->toBeTrue(); + expect($collection->has(5, 6, 7))->toBeFalse(); + expect($collection->has(0, 5))->toBeFalse(); +}); - $this->assertEquals([3, 4, 5], $filteredCollection->jsonSerialize()); - } +it('filters elements by predicate', function () { + $collection = new Collection([1, 2, 3, 4, 5], null); + $filtered = $collection->filter(fn (int $n) => $n > 2); - public function testFind() - { - $collection = new Collection([1, 2, 3, 4, 5], null); + expect($filtered->jsonSerialize())->toEqual([3, 4, 5]); +}); - $this->assertEquals(2, $collection->find(fn (int $number) => $number === 2)); - } +it('finds the first matching element', function () { + $collection = new Collection([1, 2, 3, 4, 5], null); - public function testFindReturnsNullWhenNoResultsFound() - { - $collection = new Collection([1, 2, 3, 4, 5], null); + expect($collection->find(fn (int $n) => $n === 2))->toBe(2); +}); - $this->assertEquals(null, $collection->find(fn (int $number) => false)); - } +it('find returns null when no element matches', function () { + $collection = new Collection([1, 2, 3, 4, 5], null); - public function testClear() - { - $collection = new Collection([1, 2, 3, 4, 5], null); - $collection->clear(); + expect($collection->find(fn () => false))->toBeNull(); +}); - $this->assertEquals([], $collection->jsonSerialize()); - } +it('clears all elements', function () { + $collection = new Collection([1, 2, 3, 4, 5], null); + $collection->clear(); - public function testMap() - { - $collection = new Collection([1, 2, 3, 4, 5], null); - $mappedArray = $collection->map(fn (int $number) => $number * 2); - - $this->assertEquals([ - 2, 4, 6, 8, 10, - ], $mappedArray->jsonSerialize()); - } + expect($collection->jsonSerialize())->toEqual([]); +}); - public function testMerge() - { - $collection = new Collection([1, 2, 3, 4, 5], null); - $collection2 = new Collection([6, 7, 8], null); +it('maps elements with a callback', function () { + $collection = new Collection([1, 2, 3, 4, 5], null); + $mapped = $collection->map(fn (int $n) => $n * 2); - $collection->merge($collection2); + expect($mapped->jsonSerialize())->toEqual([2, 4, 6, 8, 10]); +}); - $this->assertEquals( - range(1, 8), - $collection->jsonSerialize() - ); - } +it('merges another collection appending elements', function () { + $collection = new Collection([1, 2, 3, 4, 5], null); + $collection->merge(new Collection([6, 7, 8], null)); - public function testMergeKeysAreOverwritten() - { - $collection = new Collection(['first' => 1, 'second' => 2, 'third' => 3], null); - $collection2 = new Collection(['first' => 3, 'second' => 4, 'fourth' => 5], null); - - $collection->merge($collection2); - - $this->assertEquals( - [ - 'first' => 3, - 'second' => 4, - 'third' => 3, - 'fourth' => 5, - ], - $collection->jsonSerialize() - ); - } + expect($collection->jsonSerialize())->toEqual(range(1, 8)); +}); - public function testOffsetGet() - { - $collection = new Collection(['first' => 1, 'second' => 2, 'third' => 3], null); +it('merge overwrites duplicate keys', function () { + $collection = new Collection(['first' => 1, 'second' => 2, 'third' => 3], null); + $collection->merge(new Collection(['first' => 3, 'second' => 4, 'fourth' => 5], null)); - $this->assertEquals(2, $collection->offsetGet('second')); - } + expect($collection->jsonSerialize())->toEqual([ + 'first' => 3, + 'second' => 4, + 'third' => 3, + 'fourth' => 5, + ]); +}); - public function testOffsetSet() - { - $collection = new Collection(['first' => 1, 'second' => 2, 'third' => 3], null); - $collection->offsetSet('second', 4); +it('offsetGet returns the value at key', function () { + $collection = new Collection(['first' => 1, 'second' => 2, 'third' => 3], null); - $this->assertEquals(['first' => 1, 'second' => 4, 'third' => 3], $collection->jsonSerialize()); - } + expect($collection->offsetGet('second'))->toBe(2); +}); - public function testOffsetUnset() - { - $collection = new Collection(['first' => 1, 'second' => 2, 'third' => 3], null); - $collection->offsetUnset('second'); +it('offsetSet updates the value at key', function () { + $collection = new Collection(['first' => 1, 'second' => 2, 'third' => 3], null); + $collection->offsetSet('second', 4); - $this->assertEquals(['first' => 1, 'third' => 3], $collection->jsonSerialize()); - } + expect($collection->jsonSerialize())->toEqual(['first' => 1, 'second' => 4, 'third' => 3]); +}); - public function testIsIterable() - { - $collection = new Collection(range(1, 10), null); +it('offsetUnset removes the key', function () { + $collection = new Collection(['first' => 1, 'second' => 2, 'third' => 3], null); + $collection->offsetUnset('second'); - $collected = []; + expect($collection->jsonSerialize())->toEqual(['first' => 1, 'third' => 3]); +}); - foreach ($collection as $item) { - $collected[] = $item; - } +it('is iterable via foreach', function () { + $collection = new Collection(range(1, 10), null); - $this->assertEquals(range(1, 10), $collected); + $collected = []; + foreach ($collection as $item) { + $collected[] = $item; } -} -final class ClassOne -{ - public $id; -} + expect($collected)->toEqual(range(1, 10)); +}); -final class ClassTwo -{ - public $id; -} diff --git a/tests/DiscordTest.php b/tests/DiscordTest.php index 5ec88862d..db54eeff5 100644 --- a/tests/DiscordTest.php +++ b/tests/DiscordTest.php @@ -12,59 +12,9 @@ * with this source code in the LICENSE.md file. */ -use Discord\Discord; -use PHPUnit\Framework\TestCase; +it('has required Discord environment variables', function () { + expect(getenv('DISCORD_TOKEN'))->not->toBeFalse('Discord token is missing'); + expect(getenv('TEST_CHANNEL'))->not->toBeFalse('Test channel ID is missing'); + expect(getenv('TEST_CHANNEL_NAME'))->not->toBeFalse('Test channel name is missing'); +}); -final class DiscordTest extends TestCase -{ - public function testCheckEnvVariablesPresent() - { - if (file_exists(__DIR__.'/../.env')) { - $envFile = file_get_contents(__DIR__.'/../.env'); - $lines = explode("\n", $envFile); - foreach ($lines as $line) { - $line = trim($line); - if (empty($line) || strpos($line, '#') === 0) { - continue; - } - if (strpos($line, '=') === false) { - continue; - } - [$key, $value] = explode('=', $line, 2); - $key = trim($key); - $value = trim($value); - if (! empty($key)) { - putenv("$key=$value"); - } - } - } - - $this->assertNotFalse(getenv('DISCORD_TOKEN'), 'Discord token is missing'); - $this->assertNotFalse(getenv('TEST_CHANNEL'), 'Test channel ID is missing'); - $this->assertNotFalse(getenv('TEST_CHANNEL_NAME'), 'Test channel name is missing'); - } - - /* - public function testSetGetCacheAsync() - { - wait(function (Discord $discord, $resolve) { - $cache = $discord->getCacheConfig()->interface; - $this->assertIsObject($cache, 'No CacheInterface set'); - $data = 'DiscordPHP 123'; - - $cache->set('DPHP.Test', $data)->then(function ($success) use ($cache, $data) { - $this->assertTrue($success, 'Failed to set a cache'); - if ($success) { - return $cache->get('DPHP.Test')->then(function ($value) use ($data) { - $this->assertEquals($data, $value, 'The stored cache mismatched'); - - return $value ? true : false; - }); - } - - return $success; - })->then($resolve, $resolve); - }, 10); - } - */ -} diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php index d672ac573..ec94a896c 100644 --- a/tests/FunctionsTest.php +++ b/tests/FunctionsTest.php @@ -13,191 +13,377 @@ use Discord\Parts\Channel\Channel; use Discord\Parts\Channel\Message; use Discord\Parts\Guild\Role; +use Discord\Parts\Thread\Thread; use Discord\Parts\User\Member; use Discord\Parts\User\User; -use PHPUnit\Framework\TestCase; +use React\Promise\Deferred; use Symfony\Component\OptionsResolver\OptionsResolver; use function Discord\contains; +use function Discord\deferFind; use function Discord\escapeMarkdown; use function Discord\getColor; +use function Discord\getSnowflakeTimestamp; +use function Discord\imageToBase64; use function Discord\mentioned; use function Discord\normalizePartId; +use function Discord\nowait; use function Discord\poly_strlen; use function Discord\studly; +use function React\Promise\resolve; -final class FunctionsTest extends TestCase -{ - public function testContains(): void - { - static $array = [ - [true, 'hello, world!', ['hello']], - [true, 'phpunit tests', ['p', 'u']], - [false, 'phpunit tests', ['a']], - ]; - - foreach ($array as $case) { - $this->assertEquals($case[0], contains($case[1], $case[2])); - } - } +// ── contains() ─────────────────────────────────────────────────────────────── - public function testGetColor(): void - { - static $array = [ - [0xcd5c5c, 'indianred'], - [0x00bfff, 'deepskyblue'], - [0x00bfff, 0x00bfff], - [0, 0], - [0x00bfff, '0x00bfff'], - ]; - - foreach ($array as $case) { - $this->assertEquals($case[0], getColor($case[1])); - } - } +it('contains matches substrings', function (bool $expected, string $haystack, array $needles) { + expect(contains($haystack, $needles))->toBe($expected); +})->with([ + [true, 'hello, world!', ['hello']], + [true, 'phpunit tests', ['p', 'u']], + [false, 'phpunit tests', ['a']], +]); - public function testPolyStrlen(): void - { - static $array = [ - [5, 'abcde'], - [0, ''], - [1, ' '], - ]; - foreach ($array as $case) { - $this->assertEquals($case[0], poly_strlen($case[1])); - } - } +it('contains returns false for empty needles array', function () { + expect(contains('hello', []))->toBeFalse(); +}); + +it('contains returns true for an empty string needle', function () { + // str_contains($str, '') is always true in PHP + expect(contains('anything', ['']))->toBeTrue(); + expect(contains('', ['']))->toBeTrue(); +}); + +// ── getColor() ──────────────────────────────────────────────────────────────── + +it('getColor resolves named and numeric colors', function (int $expected, string|int $color) { + expect(getColor($color))->toBe($expected); +})->with([ + [0xcd5c5c, 'indianred'], + [0x00bfff, 'deepskyblue'], + [0x00bfff, 0x00bfff], + [0, 0], + [0x00bfff, '0x00bfff'], +]); + +it('getColor resolves # prefixed hex strings', function () { + expect(getColor('#00bfff'))->toBe(0x00bfff); + expect(getColor('#ffffff'))->toBe(0xffffff); + expect(getColor('#000000'))->toBe(0); +}); + +it('getColor resolves bare hex strings without prefix', function () { + // Bare hex strings with at least one digit bypass the color-name check + expect(getColor('00bfff'))->toBe(0x00bfff); + expect(getColor('1a2b3c'))->toBe(0x1a2b3c); + // All-alpha bare strings (e.g. 'ffffff') hit the color-name path and return 0 + expect(getColor('ffffff'))->toBe(0); +}); + +it('getColor returns 0 for unknown color names', function () { + expect(getColor('notacolor'))->toBe(0); + expect(getColor('discordblurple'))->toBe(0); +}); + +// ── poly_strlen() ───────────────────────────────────────────────────────────── + +it('poly_strlen returns character count', function (int $expected, string $str) { + expect(poly_strlen($str))->toBe($expected); +})->with([ + [5, 'abcde'], + [0, ''], + [1, ' '], +]); + +it('poly_strlen counts multi-byte UTF-8 characters correctly', function () { + // 'héllo' is 5 characters but 6 bytes in UTF-8 + expect(poly_strlen('héllo'))->toBe(5); + expect(poly_strlen('日本語'))->toBe(3); +}); + +it('poly_strlen respects explicit encoding', function () { + expect(poly_strlen('héllo', 'UTF-8'))->toBe(5); +}); + +// ── studly() ────────────────────────────────────────────────────────────────── + +it('studly converts strings to StudlyCase', function (string $input, string $expected) { + expect(studly($input))->toBe($expected); +})->with([ + ['trains are cool', 'TrainsAreCool'], + ['robo smells like bananas', 'RoboSmellsLikeBananas'], + ['i LiKE TuRtLEs', 'ILikeTurtles'], +]); + +it('studly handles strings with numbers and special delimiters', function () { + expect(studly('foo_bar-baz'))->toBe('FooBarBaz'); + expect(studly('hello123world'))->toBe('Hello123world'); + expect(studly('snake_case_string'))->toBe('SnakeCaseString'); +}); - /** - * @test - */ - public function testMentioned(): void - { - $mockDiscord = getMockDiscord(); - - static $array = [ - 'member in mentions' => [ - new Member($mockDiscord, ['id' => '12345']), - [ - 'mentions' => [(object) ['id' => '12345']], - ], - true, - ], - 'member not in mentions' => [ - new Member($mockDiscord, ['id' => '123456']), - [ - 'mentions' => [(object) ['id' => '12345']], - ], - false, - ], - - 'user in mentions' => [ - new User($mockDiscord, ['id' => '12345']), - [ - 'mentions' => [(object) ['id' => '12345']], - ], - true, - ], - 'user not in mentions' => [ - new User($mockDiscord, ['id' => '123456']), - [ - 'mentions' => [(object) ['id' => '12345']], - ], - false, - ], - - 'user in mentions with several more' => [ - new User($mockDiscord, ['id' => '12345']), - [ - 'mentions' => [(object) ['id' => '123456'], (object) ['id' => '1234567'], (object) ['id' => '12345']], - ], - true, - ], - 'user not in mentions with several more' => [ - new User($mockDiscord, ['id' => '1234']), - [ - 'mentions' => [(object) ['id' => '123456'], (object) ['id' => '1234567'], (object) ['id' => '12345']], - ], - false, - ], - ]; - - foreach ($array as $case) { - $this->assertEquals($case[2], mentioned($case[0], new Message($mockDiscord, $case[1]))); - } +// ── mentioned() ─────────────────────────────────────────────────────────────── + +it('mentioned detects user and member mentions', function () { + $mockDiscord = getMockDiscord(); + + $cases = [ + 'member in mentions' => [ + new Member($mockDiscord, ['id' => '12345']), + ['mentions' => [(object) ['id' => '12345']]], + true, + ], + 'member not in mentions' => [ + new Member($mockDiscord, ['id' => '123456']), + ['mentions' => [(object) ['id' => '12345']]], + false, + ], + 'user in mentions' => [ + new User($mockDiscord, ['id' => '12345']), + ['mentions' => [(object) ['id' => '12345']]], + true, + ], + 'user not in mentions' => [ + new User($mockDiscord, ['id' => '123456']), + ['mentions' => [(object) ['id' => '12345']]], + false, + ], + 'user in mentions with several more' => [ + new User($mockDiscord, ['id' => '12345']), + ['mentions' => [(object) ['id' => '123456'], (object) ['id' => '1234567'], (object) ['id' => '12345']]], + true, + ], + 'user not in mentions with several more' => [ + new User($mockDiscord, ['id' => '1234']), + ['mentions' => [(object) ['id' => '123456'], (object) ['id' => '1234567'], (object) ['id' => '12345']]], + false, + ], + ]; + + foreach ($cases as [$part, $data, $expected]) { + expect(mentioned($part, new Message($mockDiscord, $data)))->toBe($expected); } +}); + +it('mentioned detects role mentions via mention_roles', function () { + $mockDiscord = getMockDiscord(); + + $role = new Role($mockDiscord, ['id' => '999']); + // mention_roles raw data is an array of string role IDs + $hit = new Message($mockDiscord, ['mention_roles' => ['999']]); + $miss = new Message($mockDiscord, ['mention_roles' => ['888']]); + + expect(mentioned($role, $hit))->toBeTrue(); + expect(mentioned($role, $miss))->toBeFalse(); +}); + +it('mentioned detects channel mentions in message content', function () { + $mockDiscord = getMockDiscord(); + + $channel = new Channel($mockDiscord, ['id' => '888']); + $hit = new Message($mockDiscord, ['content' => 'Check out <#888> for news']); + $miss = new Message($mockDiscord, ['content' => 'No channel here']); + + expect(mentioned($channel, $hit))->toBeTrue(); + expect(mentioned($channel, $miss))->toBeFalse(); +}); + +it('mentioned detects thread mentions in message content', function () { + $mockDiscord = getMockDiscord(); + + $thread = new Thread($mockDiscord, ['id' => '777']); + $hit = new Message($mockDiscord, ['content' => 'See <#777> thread']); + $miss = new Message($mockDiscord, ['content' => 'Nothing here']); + + expect(mentioned($thread, $hit))->toBeTrue(); + expect(mentioned($thread, $miss))->toBeFalse(); +}); + +it('mentioned returns false for unrecognised part types', function () { + $mockDiscord = getMockDiscord(); + $role = new Role($mockDiscord, ['id' => '111']); + + // Pass a Role as if it were a string (hits the default branch) + expect(mentioned('some random string', new Message($mockDiscord, [])))->toBeFalse(); +}); + +// ── normalizePartId() ───────────────────────────────────────────────────────── + +it('normalizePartId extracts IDs from Parts and strings', function () { + $mockDiscord = getMockDiscord(); + $normalize = normalizePartId(); + $resolver = new OptionsResolver(); - /** - * @test - */ - public function testStudlyCase(): void - { - static $array = [ - ['trains are cool', 'TrainsAreCool'], - ['robo smells like bananas', 'RoboSmellsLikeBananas'], - ['i LiKE TuRtLEs', 'ILikeTurtles'], - ]; - foreach ($array as $case) { - $this->assertEquals($case[1], studly($case[0])); - } + $cases = [ + [new User($mockDiscord, ['id' => '12345']), '12345'], + [new Channel($mockDiscord, ['id' => '12345']), '12345'], + [new Role($mockDiscord, ['id' => '12345']), '12345'], + ['12345', '12345'], + [null, null], + ]; + + foreach ($cases as [$input, $expected]) { + expect($normalize($resolver, $input))->toBe($expected); } +}); + +it('normalizePartId uses a custom id_field', function () { + $mockDiscord = getMockDiscord(); + $normalize = normalizePartId('guild_id'); + $resolver = new OptionsResolver(); + + $member = new Member($mockDiscord, ['guild_id' => '9999']); + expect($normalize($resolver, $member))->toBe('9999'); +}); + +// ── escapeMarkdown() ────────────────────────────────────────────────────────── + +it('escapeMarkdown escapes markdown characters', function (string $input, string $expected) { + expect(escapeMarkdown($input))->toBe($expected); +})->with([ + [ + 'hello there this is plain text, nothing should be escaped in here! :D, except the colon', + 'hello there this is plain text, nothing should be escaped in here! \:D, except the colon', + ], + [ + 'I ~~really~~ like ||trains||', + 'I \~\~really\~\~ like \|\|trains\|\|', + ], + [ + '**Bananas**, in @@pyjamas', + '\*\*Bananas\*\*, in \@\@pyjamas', + ], + [ + '>Lopen naar de #zee', + '\>Lopen naar de \#zee', + ], + [ + 'actually nothing should be changed now', + 'actually nothing should be changed now', + ], +]); + +it('escapeMarkdown escapes all formatting symbols', function () { + expect(escapeMarkdown('#*:>@_`|~'))->toBe('\#\*\:\>\@\_\`\|\~'); +}); + +it('escapeMarkdown returns empty string unchanged', function () { + expect(escapeMarkdown(''))->toBe(''); +}); + +// ── imageToBase64() ─────────────────────────────────────────────────────────── - /** - * @test - */ - public function testNormalizePartId(): void - { - $mockDiscord = getMockDiscord(); - - static $array = [ - [new User($mockDiscord, ['id' => '12345']), '12345'], - [new Channel($mockDiscord, ['id' => '12345']), '12345'], - [new Role($mockDiscord, ['id' => '12345']), '12345'], - ['12345', '12345'], - [null, null], - ]; - - foreach ($array as $case) { - $this->assertEquals( - $case[1], - (normalizePartId())( - new OptionsResolver(), - $case[0] - ) - ); - } +it('imageToBase64 throws for a non-existent file', function () { + imageToBase64('/nonexistent/path/that/does/not/exist.png'); +})->throws(\InvalidArgumentException::class, 'does not exist'); + +it('imageToBase64 throws for an unsupported file type', function () { + $tmp = tempnam(sys_get_temp_dir(), 'dphp_test_'); + file_put_contents($tmp, 'this is plain text, not an image'); + try { + imageToBase64($tmp); + } finally { + @unlink($tmp); } +})->throws(\InvalidArgumentException::class, 'not one of jpeg'); + +it('imageToBase64 returns a base64 data URI for a valid PNG', function () { + $tmp = sys_get_temp_dir() . '/dphp_test_' . uniqid() . '.png'; - /** - * @test - */ - public function testEscapeMarkdown(): void - { - static $array = [ - [ - 'hello there this is plain text, nothing should be escaped in here! :D, except the colon', - 'hello there this is plain text, nothing should be escaped in here! \:D, except the colon', - ], - [ - 'I ~~really~~ like ||trains||', - 'I \~\~really\~\~ like \|\|trains\|\|', - ], - [ - '**Bananas**, in @@pyjamas', - '\*\*Bananas\*\*, in \@\@pyjamas', - ], - [ - '>Lopen naar de #zee', - '\>Lopen naar de \#zee', - ], - [ - 'actually nothing should be changed now', - 'actually nothing should be changed now', - ], - ]; - - foreach ($array as $case) { - $this->assertEquals($case[1], escapeMarkdown($case[0])); - } + // Write a minimal valid 1×1 PNG (no GD dependency) + file_put_contents($tmp, base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIA' . + 'BQAABjkB6QAAAABJRU5ErkJggg==' + )); + + try { + $result = imageToBase64($tmp); + } finally { + @unlink($tmp); } -} + + expect($result)->toContain('data:image/png;base64,'); +}); + +// ── getSnowflakeTimestamp() ─────────────────────────────────────────────────── + +it('getSnowflakeTimestamp returns a float after the Discord epoch for a valid snowflake', function (string $snowflake) { + $ts = getSnowflakeTimestamp($snowflake); + expect($ts) + ->toBeFloat() + ->toBeGreaterThan(1420070400.0); // After January 1, 2015 (Discord epoch) +})->with([ + ['175928847299117063'], // approximate Discord epoch + ~42 seconds + ['1015047895165677588'], // a later snowflake (~2022) +]); + +it('getSnowflakeTimestamp returns consistent values for the same input', function () { + $ts1 = getSnowflakeTimestamp('175928847299117063'); + $ts2 = getSnowflakeTimestamp('175928847299117063'); + + expect($ts1)->toBe($ts2); +}); + +it('getSnowflakeTimestamp increases monotonically with larger snowflakes', function () { + $older = getSnowflakeTimestamp('175928847299117063'); + $newer = getSnowflakeTimestamp('1015047895165677588'); + + expect($newer)->toBeGreaterThan($older); +}); + +// ── nowait() ────────────────────────────────────────────────────────────────── + +it('nowait returns the resolved value from an already-resolved promise', function () { + expect(nowait(resolve('hello')))->toBe('hello'); + expect(nowait(resolve(42)))->toBe(42); + expect(nowait(resolve(true)))->toBeTrue(); +}); + +it('nowait returns null for a deferred promise that has not resolved', function () { + $deferred = new Deferred(); + expect(nowait($deferred->promise()))->toBeNull(); +}); + +// ── deferFind() ─────────────────────────────────────────────────────────────── + +it('deferFind resolves with the first matching element', function () { + $result = wait(function ($discord, $resolve) { + deferFind([1, 2, 3, 4, 5], fn ($x) => $x === 3, $discord->getLoop()) + ->then($resolve); + }); + + expect($result)->toBe(3); +}); + +it('deferFind resolves with null when no element matches', function () { + $result = wait(function ($discord, $resolve) { + deferFind([1, 2, 4, 5], fn ($x) => $x === 3, $discord->getLoop()) + ->then($resolve); + }); + + expect($result)->toBeNull(); +}); + +it('deferFind resolves with null for an empty array', function () { + $result = wait(function ($discord, $resolve) { + deferFind([], fn ($x) => true, $discord->getLoop()) + ->then($resolve); + }); + + expect($result)->toBeNull(); +}); + +it('deferFind rejects with RuntimeException when cancelled', function () { + $result = wait(function ($discord, $resolve) { + $promise = deferFind( + array_fill(0, 10000, 1), + fn ($x) => false, + $discord->getLoop() + ); + + // Cancel immediately after starting + $discord->getLoop()->futureTick(function () use ($promise, $resolve) { + $promise->cancel(); + $resolve('cancelled'); + }); + }); + + expect($result)->toBe('cancelled'); +}); diff --git a/tests/Parts/Channel/ChannelTest.php b/tests/Parts/Channel/ChannelTest.php index 2f322150b..607233d42 100644 --- a/tests/Parts/Channel/ChannelTest.php +++ b/tests/Parts/Channel/ChannelTest.php @@ -16,243 +16,166 @@ use Discord\Discord; use Discord\Helpers\Collection; use Discord\Parts\Channel\Channel; -use Discord\Parts\Channel\Message; use Discord\Parts\Channel\Invite; +use Discord\Parts\Channel\Message; -/** - * @covers \Discord\Parts\Channel\Channel - */ -final class ChannelTest extends DiscordTestCase -{ - /** - * @covers \Discord\Parts\Channel\Channel::pinMessage - * @covers \Discord\Parts\Channel\Channel::getPinnedMessages - */ - public function testCanPinMessageAndGetPinnedMessage() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->sendMessage('testing pin message') - ->then( - fn (Message $message) => $this->channel()->pinMessage($message) - ->then(fn () => $this->channel()->getPinnedMessages()) - ->then(function (Collection $messages) use ($message) { - $this->assertGreaterThan(0, $messages->count()); - $this->assertTrue(in_array($message->id, $messages->map(fn ($message) => $message->id)->toArray())); - }) - ) - ->then($resolve, $resolve); - }); - } - - /** - * @covers \Discord\Parts\Channel\Channel::pinMessage - * @covers \Discord\Parts\Channel\Channel::unpinMessage - * @covers \Discord\Parts\Channel\Channel::getPinnedMessages - */ - public function testCanPinAndUnpinMessageAndCheckItIsUnpinned() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->sendMessage('testing pin message') - ->then( - fn (Message $message) => $this->channel()->pinMessage($message) - ->then(fn () => $this->channel()->unpinMessage($message)) - ->then(fn () => $this->channel()->getPinnedMessages()) - ->then(fn (Collection $messages) => $this->assertFalse(in_array($message->id, $messages->map(fn ($message) => $message->id)->toArray()))) - ) - ->then($resolve, $resolve); - }); - } - - /** - * @covers \Discord\Parts\Channel\Channel::getMessage - */ - public function testCanGetMessage() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->sendMessage('testing get message') - ->then( - fn (Message $message) => $this->channel()->messages->fetch($message->id) - ->then(function (Message $getMessage) use ($message) { - $this->assertEquals($getMessage->id, $message->id); - }) - ) - ->then($resolve, $resolve); - }); - } - - /** - * @doesNotPerformAssertions - * @covers \Discord\Parts\Channel\Channel::createInvitek - */ - public function testCanCreateInvite() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->createInvite() - ->then($resolve, $resolve); - }); - } - - /** - * @doesNotPerformAssertions - * @covers \Discord\Parts\Channel\Channel::deleteMessages - */ - public function testCanDeleteMessagesWithZeroMessages() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->deleteMessages([]) - ->then($resolve, $resolve); - }); - } - - /** - * @doesNotPerformAssertions - * @covers \Discord\Parts\Channel\Channel::deleteMessages - */ - public function testCanDeleteMessagesWithOneMessage() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->sendMessage('testing delete one message') - ->then(fn (Message $message) => $this->channel()->deleteMessages([$message])) - ->then($resolve, $resolve); - }); - } - - /** - * @doesNotPerformAssertions - * @covers \Discord\Parts\Channel\Channel::deleteMessages - */ - public function testCanDeleteMessagesWithMultipleMessages() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->sendMessage('testing delete 1/2 message') - ->then(fn (Message $m1) => $this->channel()->sendMessage('testing delete 2/2 message') - ->then(fn (Message $m2) => $this->channel()->deleteMessages([$m1, $m2]))) - ->then($resolve, $resolve); - }); - } - - /** - * @doesNotPerformAssertions - * @covers \Discord\Parts\Channel\Channel::limitDelete - */ - public function testCanLimitDeleteMessages() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->limitDelete(5) - ->then($resolve, $resolve); - }); - } - - /** - * @covers \Discord\Parts\Channel\Channel::getMessageHistory - */ - public function testCanGetMessageHistory() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->getMessageHistory([]) - ->then(function ($messages) { - $this->assertInstanceOf(Collection::class, $messages); - - if ($messages->count() < 1) { - $this->markTestSkipped('no messages were present when gettign message history - could not check if collection contained message objects.'); - - return; - } - - foreach ($messages as $message) { - $this->assertInstanceOf(Message::class, $message); - } - }) - ->then($resolve, $resolve); - }); - } - - /** - * @covers \Discord\Repository\Channel\InviteRepository::freshen - */ - public function testCanGetInvites() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->invites->freshen() - ->then(function (Collection $invites) { - $this->assertInstanceOf(Collection::class, $invites); - - if ($invites->count() < 1) { - $this->markTestSkipped('no invites were present when getting invites - could not check if collection contained invite objects.'); - - return; - } - - foreach ($invites as $invite) { - $this->assertInstanceOf(Invite::class, $invite); - } - }) - ->then($resolve, $resolve); - }); - } - - /** - * @covers \Discord\Parts\Channel\Channel::editMessage - */ - public function testCanEditMessageThroughChannel() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->sendMessage('testing edit through channel') - ->then( - fn (Message $message) => $message->edit(MessageBuilder::new()->setContent('new content')) - ->then(function (Message $updatedMessage) use ($message) { - $this->assertEquals('new content', $updatedMessage->content); - $this->assertEquals($message->id, $updatedMessage->id); +it('can pin a message and retrieve pinned messages', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->sendMessage('testing pin message') + ->then( + fn (Message $message) => $this->channel()->pinMessage($message) + ->then(fn () => $this->channel()->getPinnedMessages()) + ->then(function (Collection $messages) use ($message) { + expect($messages->count())->toBeGreaterThan(0); + expect(in_array($message->id, $messages->map(fn ($m) => $m->id)->toArray()))->toBeTrue(); }) - ) - ->then($resolve, $resolve); - }); - } - - /** - * @covers \Discord\Parts\Channel\Channel::sendFile - */ - public function testCanSendFile() - { - return wait(function (Discord $discord, $resolve) { - // upload readme - $baseDir = dirname(dirname(dirname((new ReflectionClass(Discord::class))->getFileName()))); - $this->channel()->sendMessage(MessageBuilder::new()->addFile($baseDir.DIRECTORY_SEPARATOR.'README.md')) - ->then(fn (Message $message) => $this->assertEquals(1, count($message->attachments))) - ->then($resolve, $resolve); - }); - } - - /** - * @doesNotPerformAssertions - * @covers \Discord\Parts\Channel\Channel::broadcastTyping - */ - public function testCanBroadcastTyping() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->broadcastTyping() - ->then($resolve, $resolve); - }); - } - - /** - * @covers \Discord\Parts\Channel\Channel::isTextBased - */ - public function testTextChannelIsTextBased() - { - $this->assertTrue($this->channel()->isTextBased()); - } - - /** - * @covers \Discord\Parts\Channel\Channel::isVoiceBased - */ - public function testVoiceChannelIsVoiceBased() - { - /** - * @var Channel - */ - $vc = $this->channel()->guild->channels->filter(fn ($channel) => $channel->type === Channel::TYPE_GUILD_VOICE)->first(); + ) + ->then($resolve, $resolve); + }); +}); + +it('can pin and unpin a message', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->sendMessage('testing pin message') + ->then( + fn (Message $message) => $this->channel()->pinMessage($message) + ->then(fn () => $this->channel()->unpinMessage($message)) + ->then(fn () => $this->channel()->getPinnedMessages()) + ->then(fn (Collection $messages) => expect(in_array($message->id, $messages->map(fn ($m) => $m->id)->toArray()))->toBeFalse()) + ) + ->then($resolve, $resolve); + }); +}); + +it('can fetch a message by ID', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->sendMessage('testing get message') + ->then( + fn (Message $message) => $this->channel()->messages->fetch($message->id) + ->then(function (Message $fetched) use ($message) { + expect($fetched->id)->toBe($message->id); + }) + ) + ->then($resolve, $resolve); + }); +}); + +it('can create an invite', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->createInvite() + ->then($resolve, $resolve); + }); +})->doesNotPerformAssertions(); + +it('can bulk delete zero messages', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->deleteMessages([]) + ->then($resolve, $resolve); + }); +})->doesNotPerformAssertions(); + +it('can bulk delete a single message', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->sendMessage('testing delete one message') + ->then(fn (Message $message) => $this->channel()->deleteMessages([$message])) + ->then($resolve, $resolve); + }); +})->doesNotPerformAssertions(); + +it('can bulk delete multiple messages', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->sendMessage('testing delete 1/2 message') + ->then(fn (Message $m1) => $this->channel()->sendMessage('testing delete 2/2 message') + ->then(fn (Message $m2) => $this->channel()->deleteMessages([$m1, $m2]))) + ->then($resolve, $resolve); + }); +})->doesNotPerformAssertions(); + +it('can limit-delete messages', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->limitDelete(5) + ->then($resolve, $resolve); + }); +})->doesNotPerformAssertions(); + +it('can retrieve message history', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->getMessageHistory([]) + ->then(function (Collection $messages) { + expect($messages)->toBeInstanceOf(Collection::class); + + if ($messages->count() < 1) { + $this->markTestSkipped('no messages were present when getting message history'); + + return; + } + + foreach ($messages as $message) { + expect($message)->toBeInstanceOf(Message::class); + } + }) + ->then($resolve, $resolve); + }); +}); + +it('can retrieve channel invites', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->invites->freshen() + ->then(function (Collection $invites) { + expect($invites)->toBeInstanceOf(Collection::class); + + if ($invites->count() < 1) { + $this->markTestSkipped('no invites were present when getting invites'); + + return; + } + + foreach ($invites as $invite) { + expect($invite)->toBeInstanceOf(Invite::class); + } + }) + ->then($resolve, $resolve); + }); +}); + +it('can edit a message through the channel', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->sendMessage('testing edit through channel') + ->then( + fn (Message $message) => $message->edit(MessageBuilder::new()->setContent('new content')) + ->then(function (Message $updated) use ($message) { + expect($updated->content)->toBe('new content'); + expect($updated->id)->toBe($message->id); + }) + ) + ->then($resolve, $resolve); + }); +}); + +it('can send a file attachment', function () { + return wait(function (Discord $discord, $resolve) { + $baseDir = dirname(dirname(dirname((new ReflectionClass(Discord::class))->getFileName()))); + $this->channel()->sendMessage(MessageBuilder::new()->addFile($baseDir.DIRECTORY_SEPARATOR.'README.md')) + ->then(fn (Message $message) => expect(count($message->attachments))->toBe(1)) + ->then($resolve, $resolve); + }); +}); + +it('can broadcast typing', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->broadcastTyping() + ->then($resolve, $resolve); + }); +})->doesNotPerformAssertions(); + +it('text channel is text-based', function () { + expect($this->channel()->isTextBased())->toBeTrue(); +}); + +it('voice channel is voice-based', function () { + /** @var Channel */ + $vc = $this->channel()->guild->channels->filter(fn ($channel) => $channel->type === Channel::TYPE_GUILD_VOICE)->first(); + + expect($vc->isVoiceBased())->toBeTrue(); +}); - $this->assertTrue($vc->isVoiceBased()); - } -} diff --git a/tests/Parts/Channel/InviteTest.php b/tests/Parts/Channel/InviteTest.php index f0432c611..9cb6f954b 100644 --- a/tests/Parts/Channel/InviteTest.php +++ b/tests/Parts/Channel/InviteTest.php @@ -13,44 +13,40 @@ */ use Discord\Discord; -use Discord\Parts\Channel\Invite; use Discord\Http\Endpoint; -use PHPUnit\Framework\TestCase; +use Discord\Parts\Channel\Invite; use function React\Promise\resolve; -final class InviteTest extends TestCase -{ - public function testUpdateTargetUsersSendsMultipartPut() - { - $csvContent = "Users\n123\n456\n"; - - $discordMock = $this->getMockBuilder(Discord::class) - ->disableOriginalConstructor() - ->getMock(); - - $httpMock = $this->getMockBuilder(\Discord\Http\Http::class) - ->disableOriginalConstructor() - ->getMock(); - - $factoryMock = $this->getMockBuilder(\Discord\Factory\Factory::class) - ->disableOriginalConstructor() - ->getMock(); - - $discordMock->method('getHttpClient')->willReturn($httpMock); - $discordMock->method('getFactory')->willReturn($factoryMock); - - $invite = new Invite($discordMock, ['code' => 'abc123'], true); - - $httpMock->expects($this->once()) - ->method('put') - ->with( - $this->isInstanceOf(Endpoint::class), - $this->callback(fn ($body) => is_string($body) && str_contains($body, $csvContent)), - $this->callback(fn ($headers) => is_array($headers) && array_key_exists('Content-Type', $headers) && str_contains($headers['Content-Type'], 'multipart/form-data')) - ) - ->willReturn(resolve(null)); - - $invite->updateTargetUsersFromContent($csvContent); - } -} +it('updateTargetUsersFromContent sends multipart PUT request', function () { + $csvContent = "Users\n123\n456\n"; + + $discordMock = $this->getMockBuilder(Discord::class) + ->disableOriginalConstructor() + ->getMock(); + + $httpMock = $this->getMockBuilder(\Discord\Http\Http::class) + ->disableOriginalConstructor() + ->getMock(); + + $factoryMock = $this->getMockBuilder(\Discord\Factory\Factory::class) + ->disableOriginalConstructor() + ->getMock(); + + $discordMock->method('getHttpClient')->willReturn($httpMock); + $discordMock->method('getFactory')->willReturn($factoryMock); + + $invite = new Invite($discordMock, ['code' => 'abc123'], true); + + $httpMock->expects($this->once()) + ->method('put') + ->with( + $this->isInstanceOf(Endpoint::class), + $this->callback(fn ($body) => is_string($body) && str_contains($body, $csvContent)), + $this->callback(fn ($headers) => is_array($headers) && array_key_exists('Content-Type', $headers) && str_contains($headers['Content-Type'], 'multipart/form-data')) + ) + ->willReturn(resolve(null)); + + $invite->updateTargetUsersFromContent($csvContent); +}); + diff --git a/tests/Parts/Channel/Message/EmbedMessageTest.php b/tests/Parts/Channel/Message/EmbedMessageTest.php index 1e343ec27..e69c367a8 100644 --- a/tests/Parts/Channel/Message/EmbedMessageTest.php +++ b/tests/Parts/Channel/Message/EmbedMessageTest.php @@ -21,58 +21,37 @@ use function Discord\getColor; -final class EmbedMessageTest extends DiscordTestCase -{ - /** - * @covers \Discord\Parts\Channel\Channel::sendEmbed - */ - public function testCanSendEmbed() - { - return wait(function (Discord $discord, $resolve) { - $embed = new Embed($discord); - $embed->setTitle('Testing Embed') - ->setAuthor('DiscordPHP Bot') - ->setDescription('Embed Description') - ->setColor(getColor('lightblue')) - ->addField([ - 'name' => 'Field 1', - 'value' => 'Value 1', - 'inline' => true, - ]) - ->addField([ - 'name' => 'Field 2', - 'value' => 'Value 2', - 'inline' => false, - ]) - ->setFooter('Footer Value'); +it('can send a rich embed', function () { + return wait(function (Discord $discord, $resolve) { + $embed = new Embed($discord); + $embed->setTitle('Testing Embed') + ->setAuthor('DiscordPHP Bot') + ->setDescription('Embed Description') + ->setColor(getColor('lightblue')) + ->addField(['name' => 'Field 1', 'value' => 'Value 1', 'inline' => true]) + ->addField(['name' => 'Field 2', 'value' => 'Value 2', 'inline' => false]) + ->setFooter('Footer Value'); + + $this->channel()->sendMessage(MessageBuilder::new()->addEmbed($embed)) + ->then(function (Message $message) use ($resolve) { + expect($message->embeds->count())->toBe(1); + + /** @var Embed */ + $embed = $message->embeds->first(); + expect($embed->title)->toBe('Testing Embed'); + expect($embed->type)->toBe(Embed::TYPE_RICH); + expect($embed->description)->toBe('Embed Description'); + expect($embed->color)->toBe(getColor('lightblue')); + expect($embed->author)->toBeInstanceOf(Author::class); + expect($embed->author->name)->toBe('DiscordPHP Bot'); + expect($embed->footer)->toBeInstanceOf(Footer::class); + expect($embed->footer->text)->toBe('Footer Value'); + expect($embed->fields->count())->toBe(2); + expect($embed->fields->get('name', 'Field 1'))->not->toBeNull(); + expect($embed->fields->get('name', 'Field 2'))->not->toBeNull(); + expect((string) $embed->fields->get('name', 'Field 1'))->not->toBe((string) $embed->fields->get('name', 'Field 2')); + }) + ->then($resolve, $resolve); + }, 10); +}); - $this->channel()->sendMessage(MessageBuilder::new()->addEmbed($embed)) - ->then(function (Message $message) use ($resolve) { - $this->assertEquals(1, $message->embeds->count()); - - /** @var Embed */ - $embed = $message->embeds->first(); - $this->assertEquals('Testing Embed', $embed->title); - $this->assertEquals(Embed::TYPE_RICH, $embed->type); - $this->assertEquals('Embed Description', $embed->description); - $this->assertEquals(getColor('lightblue'), $embed->color); - - $this->assertInstanceOf(Author::class, $embed->author); - $this->assertEquals('DiscordPHP Bot', $embed->author->name); - - $this->assertInstanceOf(Footer::class, $embed->footer); - $this->assertEquals('Footer Value', $embed->footer->text); - - $this->assertEquals(2, $embed->fields->count()); - $this->assertNotNull($embed->fields->get('name', 'Field 1')); - $this->assertNotNull($embed->fields->get('name', 'Field 2')); - - $this->assertNotEquals( - (string) $embed->fields->get('name', 'Field 1'), - (string) $embed->fields->get('name', 'Field 2') - ); - }) - ->then($resolve, $resolve); - }, 10); - } -} diff --git a/tests/Parts/Channel/Message/EmptyMessageTest.php b/tests/Parts/Channel/Message/EmptyMessageTest.php index a14167486..77fcbb9ba 100644 --- a/tests/Parts/Channel/Message/EmptyMessageTest.php +++ b/tests/Parts/Channel/Message/EmptyMessageTest.php @@ -20,268 +20,186 @@ use Discord\Parts\Embed\Embed; use Discord\Parts\User\User; -final class EmptyMessageTest extends DiscordTestCase -{ - /** - * @covers \Discord\Parts\Channel\Channel::sendMessage - */ - public function testCanSendMessage() - { - return wait(function (Discord $discord, $resolve) { - $content = 'Hello, world! From PHPunit'; - - $this->channel()->sendMessage($content) - ->then(function (Message $message) use ($content) { - $this->assertEquals($content, $message->content); - $this->assertInstanceOf(Carbon::class, $message->timestamp); - $this->assertNull($message->edited_timestamp); - - return $message; - }) - ->then($resolve, $resolve); - }); - } - - /** - * @depends testCanSendMessage - * @covers \Discord\Parts\Channel\Message::reply - */ - public function testCanReplyToMessage() - { - return wait(function (Discord $discord, $resolve) { - $content = 'Hello, world! From PHPunit'; - $this->channel()->sendMessage($content) - ->then( - fn (Message $message) => $message->reply('replying to my message') - ->then(function (Message $new_message) use ($message) { - $this->assertEquals('replying to my message', $new_message->content); - $this->assertInstanceOf(Message::class, $new_message->referenced_message); - $this->assertEquals($message->id, $new_message->referenced_message->id); - }) - ) - ->then($resolve, $resolve); - }); - } - - /** - * @covers \Discord\Repository\Channel\MessageRepository::save - */ - public function testCanEditMessage() - { - return wait(function (Discord $discord, $resolve) { - $content = 'Message edit with PHPunit'; - - $this->channel()->sendMessage('before edit') - ->then(function (Message $message) use ($content) { - $message->content = $content; - - return $message->save($content)->then(function (Message $message) use ($content) { - $this->assertEquals($content, $message->content); - $this->assertNotNull($message->edited_timestamp); - - return $message; - }); - }) - ->then($resolve, $resolve); - }); - } - - /** - * @depends testCanSendMessage - * @covers \Discord\Parts\Channel\Message::getCrosspostedAttribute - * @covers \Discord\Parts\Channel\Message::getIsCrosspostAttribute - * @covers \Discord\Parts\Channel\Message::getSuppressEmbedsAttribute - * @covers \Discord\Parts\Channel\Message::getSourceMessageDeletedAttribute - * @covers \Discord\Parts\Channel\Message::getUrgentAttribute - */ - public function testCheckMessageFlagsFalse() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->sendMessage('flag check') - ->then(function (Message $message) { - $this->assertFalse($message->crossposted); - $this->assertFalse($message->is_crosspost); - $this->assertFalse($message->suppress_embeds); - $this->assertFalse($message->source_message_deleted); - $this->assertFalse($message->urgent); - }) - ->then($resolve, $resolve); - }); - } - - /** - * @depends testCanSendMessage - * @covers \Discord\Parts\Channel\Message::getChannelAttribute - */ - public function testChannelAttribute() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->sendMessage('channel attr') - ->then(function (Message $message) { - $this->assertInstanceOf(Channel::class, $message->channel); - $this->assertEquals($message->channel_id, $message->channel->id); - }) - ->then($resolve, $resolve); - }); - } - - /** - * @depends testCanSendMessage - * @covers \Discord\Parts\Channel\Message::getMentionsAttribute - * @covers \Discord\Parts\Channel\Message::getMentionRolesAttribute - * @covers \Discord\Parts\Channel\Message::getMentionChannelsAttribute - * @covers \Discord\Parts\Channel\Message::getEmbedsAttribute - */ - public function testCollectionsEmpty() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->sendMessage('collections empty') - ->then(function (Message $message) { - $this->assertInstanceOf(Collection::class, $message->mentions); - $this->assertEquals(0, $message->mentions->count()); - - $this->assertInstanceOf(Collection::class, $message->mention_roles); - $this->assertEquals(0, $message->mention_roles->count()); - - $this->assertInstanceOf(Collection::class, $message->reactions); - $this->assertEquals(0, $message->reactions->count()); - - $this->assertInstanceOf(Collection::class, $message->mention_channels); - $this->assertEquals(0, $message->mention_channels->count()); - - $this->assertInstanceOf(Collection::class, $message->embeds); - $this->assertEquals(0, $message->embeds->count()); +it('can send a plain text message', function () { + return wait(function (Discord $discord, $resolve) { + $content = 'Hello, world! From PHPunit'; + + $this->channel()->sendMessage($content) + ->then(function (Message $message) use ($content) { + expect($message->content)->toBe($content); + expect($message->timestamp)->toBeInstanceOf(Carbon::class); + expect($message->edited_timestamp)->toBeNull(); + }) + ->then($resolve, $resolve); + }); +}); + +it('can reply to a message', function () { + return wait(function (Discord $discord, $resolve) { + $content = 'Hello, world! From PHPunit'; + $this->channel()->sendMessage($content) + ->then( + fn (Message $message) => $message->reply('replying to my message') + ->then(function (Message $reply) use ($message) { + expect($reply->content)->toBe('replying to my message'); + expect($reply->referenced_message)->toBeInstanceOf(Message::class); + expect($reply->referenced_message->id)->toBe($message->id); }) - ->then($resolve, $resolve); - }); - } - - /** - * @depends testCanSendMessage - * @covers \Discord\Parts\Channel\Message::getAuthorAttribute - */ - public function testAuthorAttribute() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->sendMessage('author attr') - ->then(function (Message $message) { - $this->assertInstanceOf(User::class, $message->author); - $this->assertEquals($message->author->id, DiscordSingleton::get()->id); - }) - ->then($resolve, $resolve); - }); - } - - /** - * @covers \Discord\Parts\Channel\Message::getEditedTimestampAttribute - */ - public function testEditedTimestampAttribute() - { - return wait(function (Discord $discord, $resolve) { - $content = 'Message edit with PHPunit'; - $this->channel()->sendMessage('before edit') - ->then(function (Message $message) use ($content) { - $message->content = $content; - - return $message->save($content); - }) - ->then(fn (Message $message) => $this->assertInstanceOf(Carbon::class, $message->edited_timestamp)) - ->then($resolve, $resolve); - }); - } - - /** - * @covers \Discord\Parts\Channel\Message::delayedReply - */ - public function testDelayedReply() - { - return wait(function (Discord $discord, $resolve) { - // Random delay between 0 and 5s. - $delay = (int) ((mt_rand() / mt_getrandmax()) * 5000); - $start = microtime(true); - - $this->channel()->sendMessage('testing delayed reply') - ->then(fn (Message $message) => $message->delayedReply('delayed reply to message', $delay)) - ->then(function (Message $message) use ($delay, $start, $resolve) { - $stop = microtime(true); - $diff = $stop - $start; - - $this->assertGreaterThanOrEqual($delay / 1000, $diff); - }) - ->then($resolve, $resolve); - }, 10); - } - - /** - * @doesNotPerformAssertions - * @covers \Discord\Parts\Channel\Message::react - */ - public function testCanReactWithString() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->sendMessage('testing reactions') - ->then(fn (Message $message) => $message->react('😀')) - ->then($resolve, $resolve); - }); - } - - /** - * @depends testCanSendMessage - * @covers \Discord\Parts\Channel\Message::addEmbed - */ - public function testCanAddEmbed() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->sendMessage('testing adding embed') - ->then(function (Message $message) use ($discord) { - $embed = new Embed($discord); - $embed->setTitle('Test embed') - ->addFieldValues('Field name', 'Field value', true); - - return $message->addEmbed($embed); - }) - ->then(function (Message $message) { - $this->assertEquals(1, $message->embeds->count()); - - /** @var Embed */ - $embed = $message->embeds->first(); - $this->assertEquals('Test embed', $embed->title); - $this->assertEquals(1, $embed->fields->count()); - - /** @var \Discord\Parts\Embed\Field */ - $field = $embed->fields->first(); - $this->assertEquals('Field name', $field->name); - $this->assertEquals('Field value', $field->value); - $this->assertTrue($field->inline); - }) - ->then($resolve, $resolve); - }); - } - - /** - * @depends testCanSendMessage - * @covers \Discord\Repository\Channel\MessageRepository::delete - */ - public function testCanDeleteMessageThroughRepository() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->sendMessage('delete through repo') - ->then(fn (Message $message) => $message->channel->messages->delete($message)) - ->then(fn (Message $message) => $this->assertFalse($message->created)) - ->then($resolve, $resolve); - }); - } + ) + ->then($resolve, $resolve); + }); +}); + +it('can edit a message', function () { + return wait(function (Discord $discord, $resolve) { + $content = 'Message edit with PHPunit'; + + $this->channel()->sendMessage('before edit') + ->then(function (Message $message) use ($content) { + $message->content = $content; + + return $message->save($content)->then(function (Message $updated) use ($content) { + expect($updated->content)->toBe($content); + expect($updated->edited_timestamp)->not->toBeNull(); + }); + }) + ->then($resolve, $resolve); + }); +}); + +it('message flags are false for a normal message', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->sendMessage('flag check') + ->then(function (Message $message) { + expect($message->crossposted)->toBeFalse(); + expect($message->is_crosspost)->toBeFalse(); + expect($message->suppress_embeds)->toBeFalse(); + expect($message->source_message_deleted)->toBeFalse(); + expect($message->urgent)->toBeFalse(); + }) + ->then($resolve, $resolve); + }); +}); + +it('channel attribute resolves to the correct channel', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->sendMessage('channel attr') + ->then(function (Message $message) { + expect($message->channel)->toBeInstanceOf(Channel::class); + expect($message->channel->id)->toBe($message->channel_id); + }) + ->then($resolve, $resolve); + }); +}); + +it('collection attributes are empty on a plain message', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->sendMessage('collections empty') + ->then(function (Message $message) { + expect($message->mentions)->toBeInstanceOf(Collection::class); + expect($message->mentions->count())->toBe(0); + expect($message->mention_roles)->toBeInstanceOf(Collection::class); + expect($message->mention_roles->count())->toBe(0); + expect($message->reactions)->toBeInstanceOf(Collection::class); + expect($message->reactions->count())->toBe(0); + expect($message->mention_channels)->toBeInstanceOf(Collection::class); + expect($message->mention_channels->count())->toBe(0); + expect($message->embeds)->toBeInstanceOf(Collection::class); + expect($message->embeds->count())->toBe(0); + }) + ->then($resolve, $resolve); + }); +}); + +it('author attribute resolves to the bot user', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->sendMessage('author attr') + ->then(function (Message $message) { + expect($message->author)->toBeInstanceOf(User::class); + expect($message->author->id)->toBe(DiscordSingleton::get()->id); + }) + ->then($resolve, $resolve); + }); +}); + +it('edited_timestamp is a Carbon instance after editing', function () { + return wait(function (Discord $discord, $resolve) { + $content = 'Message edit with PHPunit'; + $this->channel()->sendMessage('before edit') + ->then(function (Message $message) use ($content) { + $message->content = $content; + + return $message->save($content); + }) + ->then(fn (Message $message) => expect($message->edited_timestamp)->toBeInstanceOf(Carbon::class)) + ->then($resolve, $resolve); + }); +}); + +it('can send a delayed reply', function () { + return wait(function (Discord $discord, $resolve) { + $delay = (int) ((mt_rand() / mt_getrandmax()) * 5000); + $start = microtime(true); + + $this->channel()->sendMessage('testing delayed reply') + ->then(fn (Message $message) => $message->delayedReply('delayed reply to message', $delay)) + ->then(function (Message $message) use ($delay, $start) { + expect(microtime(true) - $start)->toBeGreaterThanOrEqual($delay / 1000); + }) + ->then($resolve, $resolve); + }, 10); +}); + +it('can react to a message with an emoji', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->sendMessage('testing reactions') + ->then(fn (Message $message) => $message->react('😀')) + ->then($resolve, $resolve); + }); +})->doesNotPerformAssertions(); + +it('can add an embed to an existing message', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->sendMessage('testing adding embed') + ->then(function (Message $message) use ($discord) { + $embed = new Embed($discord); + $embed->setTitle('Test embed') + ->addFieldValues('Field name', 'Field value', true); + + return $message->addEmbed($embed); + }) + ->then(function (Message $message) { + expect($message->embeds->count())->toBe(1); + + /** @var Embed */ + $embed = $message->embeds->first(); + expect($embed->title)->toBe('Test embed'); + expect($embed->fields->count())->toBe(1); + + /** @var \Discord\Parts\Embed\Field */ + $field = $embed->fields->first(); + expect($field->name)->toBe('Field name'); + expect($field->value)->toBe('Field value'); + expect($field->inline)->toBeTrue(); + }) + ->then($resolve, $resolve); + }); +}); + +it('can delete a message through the repository', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->sendMessage('delete through repo') + ->then(fn (Message $message) => $message->channel->messages->delete($message)) + ->then(fn (Message $message) => expect($message->created)->toBeFalse()) + ->then($resolve, $resolve); + }); +}); + +it('can delete a message through the part', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->sendMessage('testing delete through part') + ->then(fn (Message $message) => $message->delete()) + ->then($resolve); + }); +})->doesNotPerformAssertions(); - /** - * @doesNotPerformAssertions - * @covers \Discord\Parts\Channel\Message::delete - */ - public function testCanDeleteMessageThroughPart() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->sendMessage('testing delete through part') - ->then(fn (Message $message) => $message->delete()) - ->then($resolve); - }); - } -} diff --git a/tests/Parts/Channel/Message/MessageTest.php b/tests/Parts/Channel/Message/MessageTest.php index 89223ecbd..30df27fb0 100644 --- a/tests/Parts/Channel/Message/MessageTest.php +++ b/tests/Parts/Channel/Message/MessageTest.php @@ -16,36 +16,26 @@ use Discord\Parts\Channel\Channel; use Discord\Parts\Channel\Message; -final class MessageTest extends DiscordTestCase -{ - /** - * @covers \Discord\Parts\Channel\Message::getMentionChannelsAttribute - */ - public function testCanMentionChannel() - { - return wait(function (Discord $discord, $resolve) { - $this->channel()->sendMessage('mention channel <#'.$this->channel()->id.'>') - ->then(function (Message $message) { - $this->assertEquals(1, $message->mention_channels->count()); - $this->assertInstanceOf(Channel::class, $message->mention_channels->first()); - $this->assertEquals($this->channel()->id, $message->mention_channels->first()->id); - }) - ->then($resolve, $resolve); - }); - } +it('can mention a channel in a message', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel()->sendMessage('mention channel <#'.$this->channel()->id.'>') + ->then(function (Message $message) { + expect($message->mention_channels->count())->toBe(1); + expect($message->mention_channels->first())->toBeInstanceOf(Channel::class); + expect($message->mention_channels->first()->id)->toBe($this->channel()->id); + }) + ->then($resolve, $resolve); + }); +}); + +it('can crosspost a message', function () { + return wait(function (Discord $discord, $resolve) { + $channel = $this->channel(); + expect($channel->type)->toBe(Channel::TYPE_GUILD_ANNOUNCEMENT); + $channel->sendMessage('crossposting message') + ->then(fn (Message $message) => $message->crosspost()) + ->then(fn ($message) => expect($message)->toBeInstanceOf(Message::class)) + ->then($resolve, $resolve); + }, 10, fn () => $this->markTestIncomplete('Crosspost has likely hit ratelimit.')); +}); - /** - * @covers \Discord\Parts\Channel\Message::crosspost - */ - public function testCanCrosspostMessage() - { - return wait(function (Discord $discord, $resolve) { - $channel = $this->channel(); - $this->assertEquals(Channel::TYPE_GUILD_ANNOUNCEMENT, $channel->type); - $channel->sendMessage('crossposting message') - ->then(fn (Message $message) => $message->crosspost()) - ->then(fn ($message) => $this->assertInstanceOf(Message::class, $message)) - ->then($resolve, $resolve); - }, 10, fn () => $this->markTestIncomplete('Crosspost has likely hit ratelimit.')); - } -} diff --git a/tests/Parts/Channel/Message/RemoveReactionTest.php b/tests/Parts/Channel/Message/RemoveReactionTest.php index feef24a4e..7859438df 100644 --- a/tests/Parts/Channel/Message/RemoveReactionTest.php +++ b/tests/Parts/Channel/Message/RemoveReactionTest.php @@ -15,72 +15,46 @@ use Discord\Discord; use Discord\Parts\Channel\Message; -final class RemoveReactionTest extends DiscordTestCase -{ - /** - * @doesNotPerformAssertions - * @covers \Discord\Parts\Channel\Message::deleteAllReactions - */ - public function testDeleteAllReactions() - { - return wait(function (Discord $discord, $resolve) { - $this - ->channel() - ->sendMessage('testing delete all reactions') - ->then( - fn (Message $message) => \React\Promise\all([$message->react('😝'), $message->react('🤪')]) - ->then(fn () => $message) - ) - ->then(fn (Message $message) => $message->deleteAllReactions(Message::REACT_DELETE_ALL)) - ->then($resolve, $resolve); - }); - } +it('can delete all reactions from a message', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel() + ->sendMessage('testing delete all reactions') + ->then( + fn (Message $message) => \React\Promise\all([$message->react('😝'), $message->react('🤪')]) + ->then(fn () => $message) + ) + ->then(fn (Message $message) => $message->deleteAllReactions(Message::REACT_DELETE_ALL)) + ->then($resolve, $resolve); + }); +})->doesNotPerformAssertions(); - /** - * @doesNotPerformAssertions - * @covers \Discord\Parts\Channel\Message::deleteOwnReaction - */ - public function testDeleteSelfReaction() - { - return wait(function (Discord $discord, $resolve) { - $this - ->channel() - ->sendMessage('testing deleting self reaction') - ->then(fn (Message $message) => $message->react('🤪')->then(fn () => $message)) - ->then(fn (Message $message) => $message->deleteOwnReaction('🤪')) - ->then($resolve, $resolve); - }); - } +it('can delete own reaction', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel() + ->sendMessage('testing deleting self reaction') + ->then(fn (Message $message) => $message->react('🤪')->then(fn () => $message)) + ->then(fn (Message $message) => $message->deleteOwnReaction('🤪')) + ->then($resolve, $resolve); + }); +})->doesNotPerformAssertions(); - /** - * @doesNotPerformAssertions - * @covers \Discord\Parts\Channel\Message::deleteUserReaction - */ - public function testDeleteReactionOfUser() - { - return wait(function (Discord $discord, $resolve) { - $this - ->channel() - ->sendMessage('testing deleting reaction of user') - ->then(fn (Message $message) => $message->react('🤪')->then(fn () => $message)) - ->then(fn (Message $message) => $message->deleteUserReaction('🤪', $discord->id)) - ->then($resolve, $resolve); - }); - } +it('can delete a specific user reaction', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel() + ->sendMessage('testing deleting reaction of user') + ->then(fn (Message $message) => $message->react('🤪')->then(fn () => $message)) + ->then(fn (Message $message) => $message->deleteUserReaction('🤪', $discord->id)) + ->then($resolve, $resolve); + }); +})->doesNotPerformAssertions(); + +it('can delete all reactions for a specific emoji', function () { + return wait(function (Discord $discord, $resolve) { + $this->channel() + ->sendMessage('testing deleting of single reaction') + ->then(fn (Message $message) => $message->react('🤪')->then(fn () => $message)) + ->then(fn (Message $message) => $message->deleteEmojiReactions('🤪')) + ->then($resolve, $resolve); + }); +})->doesNotPerformAssertions(); - /** - * @doesNotPerformAssertions - * @covers \Discord\Parts\Channel\Message::deleteEmojiReactions - */ - public function testDeleteAllReactionsForEmoji() - { - return wait(function (Discord $discord, $resolve) { - $this - ->channel() - ->sendMessage('testing deleting of single reaction') - ->then(fn (Message $message) => $message->react('🤪')->then(fn () => $message)) - ->then(fn (Message $message) => $message->deleteEmojiReactions('🤪')) - ->then($resolve, $resolve); - }); - } -} diff --git a/tests/Parts/Embed/EmbedTest.php b/tests/Parts/Embed/EmbedTest.php index 493b7e14d..dbad87158 100644 --- a/tests/Parts/Embed/EmbedTest.php +++ b/tests/Parts/Embed/EmbedTest.php @@ -1,5 +1,7 @@ channel()->sendMessage($url) - ->then(fn (Message $message) => $this->channel()->messages->fetch($message->id)) // fetch message to ensure embed is present - ->then(function (Message $message) use ($url) { - $this->assertEquals(1, $message->embeds->count()); - /** @var \Discord\Parts\Embed\Embed */ - $embed = $message->embeds->first(); +it('can retrieve a video embed', function () { + return wait(function (Discord $discord, $resolve) { + $url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; + $this->channel()->sendMessage($url) + ->then(fn (Message $message) => $this->channel()->messages->fetch($message->id)) + ->then(function (Message $message) use ($url) { + expect($message->embeds->count())->toBe(1); + + /** @var Embed */ + $embed = $message->embeds->first(); - $this->assertInstanceOf(Video::class, $embed->video); - $this->assertInstanceOf(Thumbnail::class, $embed->thumbnail); - $this->assertInstanceOf(Author::class, $embed->author); + expect($embed->video)->toBeInstanceOf(Video::class); + expect($embed->thumbnail)->toBeInstanceOf(Thumbnail::class); + expect($embed->author)->toBeInstanceOf(Author::class); + expect(contains($embed->video->url, ['dQw4w9WgXcQ']))->toBeTrue(); + expect($embed->url)->toBe($url); + expect($embed->type)->toBe(Embed::TYPE_VIDEO); + }) + ->then($resolve, $resolve); + }, 10); +}); - $this->assertTrue(contains($embed->video->url, ['dQw4w9WgXcQ'])); +it('can retrieve an image embed', function () { + return wait(function (Discord $discord, $resolve) { + $url = 'https://discord.com/assets/94db9c3c1eba8a38a1fcf4f223294185.png'; + $this->channel()->sendMessage($url) + ->then(fn (Message $message) => $this->channel()->messages->fetch($message->id)) + ->then(function (Message $message) use ($url) { + expect($message->embeds->count())->toBe(1); - $this->assertEquals($url, $embed->url); - $this->assertEquals(Embed::TYPE_VIDEO, $embed->type); - }) - ->then($resolve, $resolve); - }, 10); - } + /** @var Embed */ + $embed = $message->embeds->first(); - public function testCanGetImageEmbed() - { - return wait(function (Discord $discord, $resolve) { - $url = 'https://discord.com/assets/94db9c3c1eba8a38a1fcf4f223294185.png'; - $this->channel()->sendMessage($url) - ->then(fn (Message $message) => $this->channel()->messages->fetch($message->id)) // fetch message to ensure embed is present - ->then(function (Message $message) use ($url) { - $this->assertEquals(1, $message->embeds->count()); - /** @var \Discord\Parts\Embed\Embed */ - $embed = $message->embeds->first(); + expect($embed->url)->toBe($url); + expect($embed->type)->toBe(Embed::TYPE_IMAGE); + expect($embed->thumbnail)->toBeInstanceOf(Thumbnail::class); + }) + ->then($resolve, $resolve); + }, 10); +}); - $this->assertEquals($url, $embed->url); - $this->assertEquals(Embed::TYPE_IMAGE, $embed->type); - $this->assertInstanceOf(Thumbnail::class, $embed->thumbnail); - }) - ->then($resolve, $resolve); - }, 10); - } -} diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 000000000..594864401 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,26 @@ + + * Copyright (c) 2020-present Valithor Obsidion + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +// DiscordTestCase is not autoloaded; require it explicitly so that it is +// available when Pest.php is parsed (before phpunit.xml bootstrap runs). +require_once __DIR__.'/DiscordTestCase.php'; + +// Bind DiscordTestCase to integration test files so that $this->channel(), +// setUpBeforeClass(), and other class-level helpers are available inside +// Pest closures without extending the class explicitly. +uses(DiscordTestCase::class)->in( + 'Parts/Channel/ChannelTest.php', + 'Parts/Channel/Message', + 'Parts/Embed', +); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 123b21bf0..dbd54da8c 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -12,35 +12,24 @@ include __DIR__.'/../vendor/autoload.php'; +use function React\Promise\set_rejection_handler; + +// Suppress unhandled promise rejections from mock Discord instances used in unit tests. +// Re-register on each invocation because React/Promise's set_rejection_handler() is +// consumed (reset to null) every time __destruct() calls set_rejection_handler(null). +$silenceHandler = null; +$silenceHandler = function (\Throwable $e) use (&$silenceHandler): void { + set_rejection_handler($silenceHandler); +}; +set_rejection_handler($silenceHandler); + //class RedisPsr16 extends \Symfony\Component\Cache\Psr16Cache {} -// Load local .env into environment if present (simple loader, no extra deps) -$envPath = __DIR__.'/../.env'; -if (file_exists($envPath)) { - $env = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - foreach ($env as $line) { - $line = trim($line); - if ($line === '' || $line[0] === '#') { - continue; - } - if (strpos($line, '=') === false) { - continue; - } - [$key, $value] = explode('=', $line, 2); - $key = trim($key); - $value = trim($value); - // strip surrounding quotes - if (strlen($value) > 1 && (($value[0] === '"' && substr($value, -1) === '"') || ($value[0] === "'" && substr($value, -1) === "'"))) { - $value = substr($value, 1, -1); - } - if ($key !== '' && getenv($key) === false) { - putenv("$key=$value"); - $_ENV[$key] = $value; - $_SERVER[$key] = $value; - } - } +// Load local .env into environment if present (does not override existing vars) +if (file_exists(__DIR__.'/../.env')) { + (new \Symfony\Component\Dotenv\Dotenv())->load(__DIR__.'/../.env'); } -include __DIR__.'/functions.php'; -include __DIR__.'/DiscordSingleton.php'; -include __DIR__.'/DiscordTestCase.php'; +require_once __DIR__.'/functions.php'; +require_once __DIR__.'/DiscordSingleton.php'; +require_once __DIR__.'/DiscordTestCase.php'; diff --git a/tests/functions.php b/tests/functions.php index 0e63351ef..04173f9d9 100644 --- a/tests/functions.php +++ b/tests/functions.php @@ -67,5 +67,5 @@ function wait(callable $callback, float $timeout = TIMEOUT, ?callable $timeoutFn function getMockDiscord(): Discord { - return new Discord(['token' => '', 'logger' => new NullLogger()]); + return new Discord(['token' => 'mock-token-for-unit-tests', 'logger' => new NullLogger()]); }