Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions examples/command.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php
/**
* An example that registers a slash command with an autocomplete callback.
*
* Type "/roll" in chat, the option "sides" will trigger the autocomplete callback, listing available options.
*
* Run this example bot from main directory using command:
* php examples/command.php
*/
declare(strict_types = 1);

use Discord\Builders\CommandBuilder;
use Discord\Builders\MessageBuilder;
use Discord\Discord;
use Discord\Helpers\Collection;
use Discord\Parts\Interactions\ApplicationCommand;
use Discord\Parts\Interactions\ApplicationCommandAutocomplete;
use Discord\Parts\Interactions\Command\Choice;
use Discord\Parts\Interactions\Command\Command;
use Discord\Parts\Interactions\Command\Option;
use Discord\WebSockets\Intents;

require_once __DIR__.'/../vendor/autoload.php';

ini_set('memory_limit', -1);

// a class to handle the command callbacks
// we're going to roll dice
class DiceRollHandler
{
public const NAME = 'roll';

public function __construct(
protected Discord $discord,
) {
// noop
}

public function buildCommand():CommandBuilder
{
// an option "sides"
$sides = (new Option($this->discord))
->setType(Option::INTEGER)
->setName('sides')
->setDescription('sides on the die')
->setAutoComplete(true);

// the command "roll"
return (new CommandBuilder)
->setType(Command::CHAT_INPUT)
->setName(static::NAME)
->setDescription('rolls an n-sided die')
->addOption($sides);
}

// attempt to register a global slash command
public function register():static
{
// after the command was created successfully, you should disable this code
$this->discord->application->commands->save(new Command($this->discord, $this->buildCommand()->toArray()));

return $this;
}

// add listener(s) for the command and possible subcommands
public function listen():static
{
$registeredCommand = $this->discord->listenCommand(DiceRollHandler::NAME, $this->execute(...), $this->autocomplete(...));

// you may register different handlers for each subcommand here
# foreach(['subcommand1', 'subcommand2', /*...*/] as $subcommand){
# $registeredCommand->addSubCommand($subcommand, $this->execute(...), $this->autocomplete(...));
# }

return $this;
}

// the command callback
public function execute(ApplicationCommand $interaction, Collection $params):void
{
$sides = ($interaction->data->options->offsetGet('sides')?->value ?? 20);
Copy link
Contributor Author

@codemasher codemasher Oct 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just noticed that the $params instance is empty here - shouldn't it contain the options that I'm fishing from $interaction->data?

This is what I'm getting

object(Discord\Helpers\Collection)#465 (1) {
  [""]=>
  object(Discord\Parts\Interactions\Request\Option)#187 (3) {
    ["attributes"]=>
    array(0) {
    }
    ["created"]=>
    bool(true)
    ["class"]=>
    string(41) "Discord\Parts\Interactions\Request\Option"
  }
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It definitely should. I'll look into this more later tonight. I'm curious as to why the attributes array is empty because that is all of the data that should be sent from Discord, so the fact that it's empty means that there are no options being sent.

Copy link
Contributor Author

@codemasher codemasher Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I just had a glance over the unit tests, and tbh I'd start there before doing anything else: replace every instance of assertEquals with assertSame and you'll get fun things like this The option "token" with value false is expected to be of type "string", but is of type "bool". I'm not sure if this is exactly related to the issue right here, but you've been testing against broken unit tests all the time. Aside of that, I've seen way too many loose comparisons throughout the codebase, which are typically sources of error. I'd suggest updating the unit tests and add proper static analysis (phan, phpstan, php-codesniffer). You will cry blood and tears, but I promise you it's worth it, because hunting for bugs will be a lot easier.

Edit: Granted, I don't think the test error I cited above is caused by a loose comparison, but whatever caused that output could be replaced by a TestCase::assertSame() too.

Edit2: The mentioned error comes out of the Symfony options resolver (shudders).

Copy link
Member

@valzargaming valzargaming Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have unit tests that work, but I believe they were made to only function in the context of GitHub actions. I added dotenv to my local environment and updated DiscordTestCase as such and everything works as expected with the exception of testCanReplyToMessage:

public static function setUpBeforeClass(): void
{
    set_rejection_handler(function (\Throwable $e): void {});

    // Use vlucas/phpdotenv to load environment variables when running tests in a local environment
    $dotenv = Dotenv::createImmutable(dirname(__DIR__));
    if ($envVars = $dotenv->load()) {
        foreach ($envVars as $key => $value) {
            putenv("$key=$value");
        }
    }

    /** @var Channel $channel */
    $channel = wait(function (Discord $discord, $resolve) {
        $channel = $discord->getChannel(getenv('TEST_CHANNEL'));
        $resolve($channel);
    });
    self::$channel = $channel;
    assert(self::$channel instanceof Channel, 'Channel not found. Please check your environment variables and ensure TEST_CHANNEL is set.');
}

Tests: 91, Assertions: 184, Errors: 1, Risky: 2.

Copy link
Contributor Author

@codemasher codemasher Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By broken I mean primarily all the loose comparisons that will never reveal the real cause of an issue. PhpUnits's assertEquals (loose comparison) should never be used unless it's absolutely necessary.

This test for example does not what you think it does:

$this->assertEquals(true, $collection->has(1, 2, 3));
$this->assertEquals(true, $collection->has(0));
$this->assertEquals(false, $collection->has(5, 6, 7));
$this->assertEquals(false, $collection->has(0, 5));

Aside, there's also assertTrue and assertFalse.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You know you can just check $array === [] right? Then there's other cases where if(!empty()){...} is checkd and the result is handed over into a foreach without further check - in which case you should check for is_iterable() instead - in general, check for the expected types because in most cases empty() will just lead to something exploding.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe empty is also used to check for empty strings, and is meant to also be used where isset could also be checked beforehand. (e.g. if(! isset($var) || ! $var) could be shortened to just be if (! empty($var))`. Regardless, I would like to update these to just be basic if() statements and let the truth table logic attempt to see if it's equivalent to true or false. I need to look over each case to make sure there isn't any breaking change with doing so in some places, and figure out what is appropriate where. A lot of this is legacy code and will need to be refactored as part of library maintenance.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe it's painstaking - I did the same in my most popular libraries too when there were a bunch of empty() related issues before.

Copy link
Member

@valzargaming valzargaming Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wanted to quickly follow up on the topic of unit tests as this was on my mind somewhat today. We do have unit tests configured within our github workflow, but I do not believe they're currently being utilized (or contains a syntax error). The configuration is in .github/workflows/unit.yml

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is better suited for another issue or even discussion rather than a comment on an already closed issue :)


// sanity check
if (! in_array($sides, [4, 6, 8, 10, 12, 20], true)) {
$sides = 20;
}

$message = sprintf('%s rolled %s with a %s-sided die', $interaction->user, random_int(1, $sides), $sides);

// respond to the command with an interaction message
$interaction->respondWithMessage((new MessageBuilder)->setContent($message));
}

// the autocomplete callback (must return array to trigger a response)
public function autocomplete(ApplicationCommandAutocomplete $interaction):array|null
{
// respond if the desired option is focused
/** @see \Discord\Parts\Interactions\Request\Option */
if ($interaction->data->options->offsetGet('sides')->focused) {
// the dataset, e.g. fetched from a database (25 results max)
$dataset = [4, 6, 8, 10, 12, 20];
$choices = [];

foreach ($dataset as $sides) {
$choices[] = new Choice($this->discord, ['name' => sprintf('%s-sided', $sides), 'value' => $sides]);
}

return $choices;
}

return null;
}
}

// invoke the discord client
$dc = new Discord([
// https://discord.com/developers/applications/<APP_ID>>/bot
'token' => 'YOUR_DISCORD_BOT_TOKEN',
// Note: MESSAGE_CONTENT, GUILD_MEMBERS and GUILD_PRESENCES are privileged, see https://dis.gd/mcfaq
'intents' => (Intents::getDefaultIntents() | Intents::MESSAGE_CONTENT),
]);

$dc->on('init', function (Discord $discord):void {
echo "Bot is ready!\n";

// invoke the command handler
$commandHandler = new DiceRollHandler($discord);

// this method shouldn't be run on each bot start
# if($options->registerCommands){
$commandHandler->register();
# }

// add a listener for the command
$commandHandler->listen();
});

$dc->run();