Discord-Client

 view release on metacpan or  search on metacpan

CONTRIBUTING.md  view on Meta::CPAN

`use warnings` and `T2->...` assertions. Use `is` for data, `ref_is` for object
identity and `dies` for exceptions. Import fixture helpers explicitly.

Test inputs, observable results and failure conditions. Name each scenario and
use fresh fixtures. Avoid assertions about private methods or object layout.
For bot scenarios, register handlers through `Discord::Client`, start the client
and deliver events through the test transport. Check replies, returned Futures
and shutdown. Keep focused component tests for protocol and resource limits.
Use fake transports and controlled clocks for protocol and cancellation tests.
Local HTTP/TLS tests exercise the real adapters using temporary, CA-signed
loopback certificates. Normal tests must not contact Discord or need credentials.
With controlled clocks, check Future
completion before inspecting a failure so a regression fails instead of hanging.

Cancellation checks should cover both the pure Perl and optional `Future::XS`
implementations. With `Future::XS` installed, run `prove -lr t` normally and
with `PERL_FUTURE_NO_XS=1` to exercise both implementations.

Update relevant documentation with code changes.
Record published releases in `Changes`; keep development notes out of it.
Run `prove -lr t` before submitting a pull request.

docs/DESIGN.md  view on Meta::CPAN

its retry limit; it does not retry uncertain network failures.
Queues, active handlers and cache storage have configurable limits.
Gateway dispatch updates Client state and admits interactions before queuing
application event callbacks. Application queue overflow cannot discard these
SDK operations; interaction admission retains its separate dispatcher limits.
HTTP delivery separates running work, completed response storage and retained
IDs. Response eviction retains IDs to prevent duplicate side effects; admission
failures before handler execution can be retried.

Tests use fake transports and controlled clocks, plus local HTTP/TLS servers.
Normal tests require no Discord credentials. The SDK does not provide voice or
audio, a full guild/member cache, or coordination across processes.

lib/Discord/Client.pm  view on Meta::CPAN


sub model {
    my ( $self, $type, $data ) = @_;
    require Discord::Client::Model;
    return Discord::Client::Model->wrap( $type, $data, rest => $self->{rest} );
}

sub stats {
    my ($self) = @_;

    # Expose counters without including credentials or event payloads.
    return {
        rest => $self->{rest}->stats,
        gateway => $self->{gateway}->stats,
        callbacks => scalar( keys %{ $self->{callbacks} } ),
        ( $self->{cache} ? ( cache => $self->{cache}->stats ) : () )
    };
}

sub command {
    my ( $self, @args ) = @_;

lib/Discord/Client.pm  view on Meta::CPAN


Returns the bot user hashref after READY, or undef before it.

=head2 cache

Returns the configured Cache object, or undef when disabled.

=head2 stats

Returns a hashref of REST, Gateway, pending callback and optional cache counters.
It does not include payloads or credentials.

=head1 EXAMPLES

From a source checkout, set C<DISCORD_BOT_TOKEN> and C<TEST_GUILD_ID>.
Invite the bot with C<bot> and C<applications.commands> scopes and leave the
Interactions Endpoint URL empty for Gateway delivery.

    # Deploy /ping once, then run the bot.
    perl examples/sync-commands.pl examples/ping.pl
    perl examples/ping.pl

lib/Discord/Client/Error.pm  view on Meta::CPAN

__END__

=encoding UTF-8

=head1 NAME

Discord::Client::Error - SDK failure details

=head1 SYNOPSIS

    # Inspect a failed SDK Future without logging credentials.
    my ($message, $category, $error) = $future->failure;
    my $status = $error->status;
    my $code = $error->discord_code;

=head1 DESCRIPTION

Failure details for SDK Futures and error callbacks. Stringification returns
C<message>. SDK-produced messages omit credentials, raw bodies and exceptions.
Unavailable fields return undef. HTTP status and Discord error code are separate.

=head1 METHODS

=head2 new

C<new(message =E<gt> ..., category =E<gt> ..., field_pairs)> returns an Error.
It stores supplied fields without validation or sanitization; the SDK sanitizes
its own errors before construction.

lib/Discord/Client/Gateway.pm  view on Meta::CPAN

C<on('*' =E<gt> CODE)> receives C<($event, $data, $gateway, $packet)>.
Unknown events and fields are preserved; unknown opcodes produce C<OPCODE>
with C<{op, d}>.

Return handler Futures. Pending Futures do not delay heartbeats or incoming
packets, but blocking Perl code blocks the loop. Thrown exceptions and failed
Futures produce C<handler> errors without stopping the connection.

C<on_error =E<gt> CODE> receives C<($error, $gateway)> synchronously. Without it,
the SDK warns with a generic message. Errors omit raw frames, exceptions and
credentials. Fatal L<close codes|https://docs.discord.com/developers/topics/opcodes-and-status-codes> appear in C<close_code> and stop reconnecting.

When the callback queue is full, the incoming event's application callbacks are
dropped. Through L<Discord::Client>, SDK cache and READY state updates and
interaction dispatch occur before this queue and are preserved. Interaction
dispatch still has its own bounded capacity.
A C<queue_full> error is reported once per saturation episode; C<stats> counts
all C<dropped_events> and C<dropped_callbacks>. Earlier work continues. Dropped
events are not replayed; applications needing a complete stream must reconcile
state. Each callback invocation uses capacity, even with a shared Future.

lib/Discord/Client/REST.pm  view on Meta::CPAN

        if defined( $args{token} )
        && ( ref( $args{token} ) || $args{token} =~ /[\s\x00-\x1f\x7f]/ );
    my $loop = $args{loop} // IO::Async::Loop->new;
    my %limits = _scheduler_limits( \%args );
    my $timeout = $args{timeout} // 30;
    croak 'timeout must be positive'
        if ref($timeout)
        || $timeout !~ /\A\d+(?:\.\d+)?\z/
        || $timeout <= 0;

    # Build an explicit API origin; credentials, query strings and fragments are invalid.
    my $base = $args{base_url} // 'https://discord.com/api/v10';
    my $uri = URI->new($base);
    croak 'base_url must be an absolute HTTP(S) URL without credentials, query or fragment'
        unless ( $uri->scheme // '' ) =~ /\Ahttps?\z/
        && $uri->host
        && !defined( $uri->userinfo )
        && !defined( $uri->query )
        && !defined( $uri->fragment );
    $base =~ s{/\z}{};
    my $url = $args{user_agent_url} // 'https://github.com/viasnake/Discord-Client';
    croak 'user_agent_url must be an HTTP(S) URL without whitespace or parentheses'
        if ref($url) || $url !~ m{\Ahttps?://[^\s()]+\z};

lib/Discord/Client/REST.pm  view on Meta::CPAN

    }

    # HEAD and 204 responses have no JSON body to decode.
    my $data;
    my $valid_json = 1;
    if ( $response->code != 204 && $method ne 'HEAD' ) {
        $data = eval { $self->{json}->decode( $response->content ) };
        $valid_json = !$@;
    }

    # Expose structured diagnostics only after removing known credentials.
    if ( !$response->is_success || defined $category ) {
        $category //= _http_error_category( $response->code );
        $message //= 'Discord REST request failed (HTTP ' . $response->code . ')';
        my $request_id = $response->header('X-Request-ID') // $response->header('CF-Ray');
        return $self->_fail(
            $future, $message, $category,
            status => $response->code,
            discord_code => _discord_error_code($data),
            request_id => _sanitize( $request_id, $secrets ),
            details => $valid_json ? _sanitize( $data, $secrets ) : undef,

lib/Discord/Client/REST.pm  view on Meta::CPAN

=head1 ERRORS AND CANCELLATION

    # Inspect a failed Future.
    my ($message, $category, $error) = $request->failure;
    my $status = $error->status;
    my $code = $error->discord_code;

The error is a C<Discord::Client::REST::Error>. It exposes C<message>, C<category>,
C<status>, C<discord_code>, C<request_id> and C<details>. HTTP status and Discord
code are separate. C<request_id> comes from C<X-Request-ID> or C<CF-Ray>.
Validation details redact known credentials and sensitive fields. Error strings
omit tokens, bodies and raw transport exceptions.

=over 4

=item C<request>

Invalid request arguments.

=item C<authentication>

lib/Discord/Client/REST.pm  view on Meta::CPAN

Seconds per HTTP attempt; default 30.

=back

Capacity exhaustion fails with C<queue_full>. There is no queue-wait deadline;
callers can cancel Futures. Use one REST client per bot token because separate
clients do not coordinate limits.

C<stats> returns request, retry and queue counters. C<on_diagnostic =E<gt> CODE>
receives synchronous events with counts, status and timing, excluding request
paths, headers, bodies and credentials.

=head1 TRANSPORT INJECTION

Pass C<http =E<gt> $transport> to supply an object with C<do_request(%args)>.
It returns a cancellable Future yielding an C<HTTP::Response>; an C<http> failure
containing a response is also accepted. Arguments include C<request>, C<host>,
C<port>, C<SSL>, C<timeout> and C<max_redirects>. Attach the transport to the supplied
loop and honor those options.

Tests can supply C<clock> for monotonic seconds, C<wall_clock> for Unix seconds,

lib/Discord/Client/REST/Error.pm  view on Meta::CPAN

Discord::Client::REST::Error - REST failure details

=head1 DESCRIPTION

REST failures use this subclass of L<Discord::Client::Error> as the third value
in a Future failure tuple:

    my ($message, $category, $error) = $request->failure;

Inherits C<new>, C<message>, C<category>, C<status>, C<discord_code>, C<request_id>,
C<details>, C<close_code> and C<event>. SDK errors redact known credentials and
sensitive fields in validation details. Request IDs come from C<X-Request-ID>
or C<CF-Ray>. See L<Discord::Client::REST/ERRORS AND CANCELLATION> for categories.

=head1 LICENSE

Apache License 2.0.

=cut

t/00-load.t  view on Meta::CPAN

use v5.26;
use warnings;
use Test2::V1;

# Every public module must load before the application supplies credentials.
my @packages = qw(
    Discord::Client
    Discord::Client::Intents
    Discord::Client::REST
    Discord::Client::REST::Error
    Discord::Client::Gateway
    Discord::Client::Gateway::Shards
    Discord::Client::Commands
    Discord::Client::Interaction
    Discord::Client::Interactions

t/00-load.t  view on Meta::CPAN

    Discord::Client::UI
    Discord::Client::Cache
    Discord::Client::Model
    Discord::Client::Model::Message
    Discord::Client::Model::Channel
    Discord::Client::Model::Guild
    Discord::Client::Model::User
    Discord::Client::Error
);

# Do not let credentials inherited from the shell hide load-time dependencies.
local $ENV{DISCORD_BOT_TOKEN};
local $ENV{DISCORD_PUBLIC_KEY};
for my $package (@packages) {
    my $file = $package =~ s{::}{/}gr;
    T2->is( T2->dies( sub { require "$file.pm" } ), undef, "$package loads without credentials" );
}

T2->done_testing;

t/04-rest.t  view on Meta::CPAN

            $request->header('User-Agent'),
            qr/^DiscordBot \(https:\/\/.+, \Q$version\E\)$/,
            'User-Agent identifies the SDK version'
        );
        T2->is( $request->content_type, 'application/json', 'JSON content type is sent' );
        T2->is(
            JSON::PP->new->utf8->decode( $request->content ),
            { content => "hello \x{2603}" },
            'non-ASCII content is transmitted as UTF-8 JSON'
        );
        T2->is( $transport->{max_redirects}, 0, 'credentials cannot follow a redirect' );

        # Return a uint64-sized string ID and unknown fields without narrowing either.
        my $message = { id => '18446744073709551615', extra => [ 1, 2 ] };
        $http->respond( response( 200, $message ) );
        T2->is( scalar( $future->get ), $message, 'large snowflakes and unknown fields survive decoding' );
        $rest->close;
    }
);

T2->subtest(

t/04-rest.t  view on Meta::CPAN

        my $future = $rest->request( 'GET', '/users/@me' );
        $http->respond( HTTP::Response->new( 503, undef, [], '<html>unavailable</html>' ) );

        T2->is( error($future)->category, 'server', 'HTTP failure takes precedence over JSON decoding' );
        T2->is( error($future)->status, 503, 'the server status remains available' );
        $rest->close;
    }
);

T2->subtest(
    'failure details redact Bot, webhook and nested authorization credentials',
    sub {
        my ( $rest, $http ) = client();

        # Simulate credentials echoed in message text, named fields and nested structures.
        my $future = $rest->request( 'POST', '/webhooks/123/WEBHOOK_SECRET', json => {} );
        $http->respond(
            response(
                400,
                {
                    code => 50035,
                    message => 'FAKE_TEST_TOKEN WEBHOOK_SECRET',
                    token => 'other secret',
                    nested => [ { authorization => 'other secret' } ]
                }
            )
        );

        T2->unlike(
            JSON::PP->new->encode( error($future)->details ),
            qr/FAKE_TEST_TOKEN|WEBHOOK_SECRET|other secret/,
            'structured errors omit all credentials'
        );
        T2->unlike(
            join( ' ', $future->failure ),
            qr/SECRET|FAKE_TEST_TOKEN/,
            'failure strings are also sanitized'
        );
        $rest->close;
    }
);

t/08-rest-tls.t  view on Meta::CPAN

        );
        $fixture->{loop}->add($http);
        my $rest = Discord::Client::REST->new(
            token => 'FAKE_TEST_TOKEN',
            loop => $fixture->{loop},
            http => $http,
            timeout => 2,
            base_url => $fixture->{base_url},
        );

        # A trusted matching certificate must allow credentials to reach the test server.
        my $request = $rest->get_current_user;
        $fixture->{loop}->await($request);
        T2->is( scalar( $request->get ), { tls => 1 }, 'HTTPS response is decoded' );
        T2->is( scalar( @{ $fixture->{requests} } ), 1, 'one authenticated request reaches the server' );
        T2->like(
            $fixture->{requests}[0],
            qr/^Authorization: Bot FAKE_TEST_TOKEN\r$/m,
            'authorization is sent only after TLS succeeds'
        );

        # Close SDK work first, then release the injected transport and server.
        $rest->close->get;
        T2->ref_is( $http->loop, $fixture->{loop}, 'injected TLS transport remains caller-owned' );
        $fixture->{loop}->remove($http);
        close_server($fixture);
    }
);

T2->subtest(
    'the default HTTPS transport rejects an untrusted server before sending credentials',
    sub {
        my $fixture = tls_server('127.0.0.1');
        my $rest = Discord::Client::REST->new(
            token => 'FAKE_TEST_TOKEN',
            loop => $fixture->{loop},
            timeout => 2,
            base_url => $fixture->{base_url},
        );

        # Success tests alone cannot detect disabled certificate verification.

t/08-rest-tls.t  view on Meta::CPAN

            loop => $fixture->{loop},
            http => $http,
            timeout => 2,
            base_url => $fixture->{base_url},
        );

        # Hostname validation must reject the connection before any HTTP headers are sent.
        my $request = $rest->get_current_user;
        $fixture->{loop}->await($request);
        T2->ok( $request->is_failed, 'hostname mismatch fails the request' );
        T2->is( scalar( @{ $fixture->{requests} } ), 0, 'credentials are withheld on hostname mismatch' );
        $rest->close->get;
        $fixture->{loop}->remove($http);
        close_server($fixture);
    }
);

T2->done_testing;

t/09-gateway-invariants.t  view on Meta::CPAN

            random => sub { 0 }
        );
        $session->opened;
        T2->is(
            $session->receive( { op => 10, d => { heartbeat_interval => 1000 } } )->[1]{payload}{d}{shard},
            [ 1, 2 ],
            'Identify carries shard'
        );
        $session = ready();
        $session->closed(4004);
        T2->ok( !$session->can_resume, 'fatal close clears credentials for resume' );
    }
);

T2->done_testing;

t/10-rest-diagnostics.t  view on Meta::CPAN

T2->is(
    { global => $limit->{global}, retry_after => $limit->{retry_after} },
    { global => 1, retry_after => 1 },
    'rate-limit telemetry'
);

# Inspect every emitted event for accidental payload or credential disclosure.
T2->unlike(
    JSON::PP->new->encode( \@events ),
    qr/FAKE_TEST_TOKEN|PRIVATE_/,
    'diagnostics exclude credentials, paths and message bodies'
);

# Snapshots must be detached; cancellation still updates the live counters.
$stats->{http_started} = 999;
T2->is( $rest->stats->{http_started}, 2, 'stats returns an independent snapshot' );
$rest->request( 'GET', '/users/@me' )->cancel;
T2->is( $rest->stats->{request_cancelled}, 1, 'cancellation counted' );
$rest->close;
T2->done_testing;

t/14-rest-auth-scopes.t  view on Meta::CPAN

        # Rate-limit Bot authorization before sending an interaction-token callback.
        my $bot = $rest->request( 'GET', '/users/@me' );
        $http->respond( response( 429, { retry_after => 2, global => 1 } ) );
        my $reply = $rest->request(
            'POST', '/interactions/123/INTERACTION_SECRET/callback',
            auth => 0,
            json => { type => 4 }
        );
        T2->is( $http->count, 2, 'token-only request bypasses bot global pause' );
        T2->ok( !defined( $http->call->{request}->header('Authorization') ),
            'Bot credentials not attached to interaction' );
        $http->respond( response( 204, undef ), 1 );
        T2->ok( $reply->is_done, 'interaction can acknowledge while bot is rate limited' );

        # The Bot retry still waits for its own global window.
        $clock->advance(2);
        $http->respond( response( 200, {} ), 2 );
        T2->ok( $bot->is_done, 'bot retry respects its own pause' );
        $rest->close;
    }
);

t/14-rest-auth-scopes.t  view on Meta::CPAN

T2->subtest(
    'HTTP interaction applications need no Bot token',
    sub {
        my ( $rest, $http ) = client( auth => 0, token => undef );

        # Exercise token-only requests and redact both escaped and unescaped token forms.
        my $request_future = $rest->request( 'POST', '/webhooks/123/URL%2FSECRET' );
        T2->ok( !defined( $http->call->{request}->header('Authorization') ), 'tokenless client supported' );
        $http->respond( response( 400, { message => 'URL/SECRET URL%2FSECRET' } ) );
        T2->unlike( error($request_future)->details->{message},
            qr/SECRET/, 'encoded and decoded URL credentials redacted' );

        # Numeric-looking tokens are opaque secrets, not resource IDs.
        $request_future = $rest->request( 'POST', '/interactions/123/987654321/callback' );
        $http->respond( response( 400, { message => 'invalid 987654321' } ) );
        T2->unlike( error($request_future)->details->{message},
            qr/987654321/, 'opaque numeric interaction token is still redacted' );
        T2->is( error( $rest->request( 'GET', '/users/@me', auth => 1 ) )->category,
            'request', 'Bot authorization still requires token' );
        $rest->close;
    }



( run in 3.595 seconds using v1.01-cache-2.11-cpan-3f962061493 )