Discord-Client

 view release on metacpan or  search on metacpan

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

package Discord::Client::REST;

use v5.26;
use strict;
use warnings;
use Carp qw(croak);
use Digest::SHA qw(sha256_hex);
use HTTP::Request;
use IO::Async::Loop;
use JSON::PP ();
use Net::Async::HTTP;
use Scalar::Util qw(blessed);
use Time::HiRes qw(clock_gettime CLOCK_MONOTONIC time);
use URI;
use URI::Escape qw(uri_unescape);
use Discord::Client ();
use Discord::Client::REST::Error;
use Discord::Client::REST::RateLimiter;

our $VERSION = '0.002';

sub new {
    my ( $class, %args ) = @_;

    # Validate authentication and capacity before creating a transport.
    my $auth = $args{auth} // 1;
    croak 'auth must be zero or one' unless !ref($auth) && $auth =~ /\A[01]\z/;
    croak 'token is required' if $auth && ( !defined( $args{token} ) || !length( $args{token} ) );
    croak 'token must be a scalar without whitespace'
        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};

    for my $key (qw(clock wall_clock delay on_diagnostic)) {
        croak "$key must be a code reference" if exists( $args{$key} ) && ref( $args{$key} ) ne 'CODE';
    }

    # A supplied transport stays caller-owned; an owned transport verifies TLS.
    my $http = $args{http};
    my $owns_http = !$http;
    if ($owns_http) {
        $http = Net::Async::HTTP->new(
            fail_on_error => 0,
            max_redirects => 0,
            pipeline => 0,
            SSL_verify_mode => 1,
            SSL_verifycn_scheme => 'http',
            max_connections_per_host => $limits{max_in_flight},
            timeout => $timeout,
        );
        $loop->add($http);
    }
    croak 'http must provide do_request' unless blessed($http) && $http->can('do_request');
    my $self = bless {
        token => $args{token},
        auth => 0 + $auth,
        loop => $loop,
        http => $http,
        owns_http => $owns_http,
        base_url => $base,
        timeout => $timeout,
        user_agent => "DiscordBot ($url, $Discord::Client::VERSION)",
        json => JSON::PP->new->utf8->allow_nonref,
    }, $class;

    # Inject clocks and delays so scheduling is deterministic without network I/O.
    $self->{limiter} = Discord::Client::REST::RateLimiter->new(
        %limits,
        loop => $loop,
        on_diagnostic => $args{on_diagnostic},
        clock => $args{clock} // sub { clock_gettime(CLOCK_MONOTONIC) },
        wall_clock => $args{wall_clock} // sub { time() },
        delay => $args{delay} // sub { $loop->delay_future( after => $_[0] ) },
    );
    return $self;
}

sub _scheduler_limits {
    my ($options) = @_;

    # Retries may be disabled; queue and concurrency capacities must remain positive.
    my %limits = ( max_pending => 1000, max_buckets => 1024, max_in_flight => 8, max_retries => 3 );
    for my $key ( keys %limits ) {

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


    if ( exists $options->{query} ) {
        return ( undef, 'query must be a hash reference' )
            unless ref( $options->{query} ) eq 'HASH';
        my @query;
        for my $key ( sort keys %{ $options->{query} } ) {
            my $value = $options->{query}{$key};
            push @query, $key, $_ for ref($value) eq 'ARRAY' ? @$value : ($value);
        }
        $uri->query_form(@query);
    }

    # Construct the wire request without allowing caller-supplied authorization headers.
    my $request = HTTP::Request->new( $method => $uri );
    $request->protocol('HTTP/1.1');
    $request->header( 'Host' => $uri->authority );
    $request->header( 'Authorization' => 'Bot ' . $self->{token} ) if $auth;
    $request->header( 'User-Agent' => $self->{user_agent} );
    $request->header( 'Content-Type' => 'application/json' );
    $request->header( 'Accept' => 'application/json' );

    # Serialize once so any rate-limit retry sends identical request bytes.
    if ( exists $options->{json} ) {
        my $body = eval { $self->{json}->encode( $options->{json} ) };
        return ( undef, 'Request body is not JSON encodable' ) if $@;
        $request->content($body);
    }

    return ( $request, undef );
}

sub _complete_request {
    my ( $self, $future, $source, $method, $secrets, $raw_response ) = @_;

    # Cancellation callbacks run before readiness; do not re-enter cancellation
    # when the scheduler reports the cancellation we just propagated inward.
    return if $future->is_ready || $future->is_cancelled;
    if ( $source->is_cancelled ) {
        $future->cancel;
        return;
    }

    # Scheduler failures may carry an HTTP response with useful status information.
    my ( $message, $category, $response );
    if ( $source->is_failed ) {
        ( $message, $category, $response ) = $source->failure;
        return $self->_fail( $future, $message, $category ) unless $response;
    }
    else {
        ($response) = $source->result;
    }

    # 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,
        );
    }
    return $self->_fail( $future, 'Discord returned invalid JSON', 'json', status => $response->code )
        unless $valid_json;

    # A raw response must not retain the authenticated originating request.
    if ($raw_response) {
        my $copy = $response->clone;
        $copy->request(undef);
        $future->done($copy);
    }
    else {
        $future->done($data);
    }
}

sub _http_error_category {
    my ($status) = @_;
    return 'authentication' if $status == 401;
    return 'permission' if $status == 403;
    return 'not_found' if $status == 404;
    return 'server' if $status >= 500;
    return 'http';
}

sub _discord_error_code {
    my ($data) = @_;

    # Only expose a scalar numeric Discord code from the decoded response.
    return undef
        unless ref($data) eq 'HASH'
        && defined( $data->{code} )
        && !ref( $data->{code} )
        && $data->{code} =~ /\A\d+\z/;
    return $data->{code};
}

sub _route {
    my ( $method, $path ) = @_;

    # Keep major resource IDs distinct while normalizing ordinary resource IDs.
    my @segments = split m{/}, $path;
    my ( @major, @secrets );
    for ( my $segment_index = 1 ; $segment_index < @segments ; ++$segment_index ) {
        my $segment = $segments[$segment_index];

        # Major resource IDs partition buckets even when the remaining route shape matches.
        if ( $segment =~ /\A(?:channels|guilds|webhooks)\z/ && defined( $segments[ $segment_index + 1 ] ) ) {
            push @major, $segment, $segments[ $segment_index + 1 ];
            ++$segment_index;

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

=head2 get_message

C<get_message(channel_id, message_id)> returns a Future containing a message hashref.

=head2 get_messages

C<get_messages(channel_id, query_pairs)> returns a Future containing one page of
messages as an arrayref. Accepts C<limit> (1-100) and at most one of C<before>,
C<after> or C<around>. It does not scan further pages.

=head2 create_message

C<create_message(channel_id, text_or_hash)> returns a Future containing the
created message. Hashrefs preserve embeds, components, C<allowed_mentions>
and other supplied JSON fields.

=head2 edit_message

C<edit_message(channel_id, message_id, text_or_hash)> returns a Future containing
the edited message.

=head2 delete_message

C<delete_message(channel_id, message_id)> returns a Future resolving to undef.

=head2 create_dm

C<create_dm(user_id)> returns a Future containing the DM channel hashref.

=head2 trigger_typing

C<trigger_typing(channel_id)> returns a Future resolving to undef.

Helper IDs are decimal strings. Invalid IDs, pagination or body shapes fail
before I/O. Discord validates endpoint permissions and payload semantics.

=head2 close

Returns a Future after failing outstanding work with C<shutdown>, cancelling
I/O and timers, and removing the owned HTTP notifier. Repeated calls succeed.
An injected HTTP transport remains caller-owned.

=head2 loop

Returns the configured IO::Async::Loop.

=head2 stats

Returns a copied hashref of request, retry and queue counters.

=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>

HTTP 401 or previously rejected bot token.

=item C<permission>

HTTP 403.

=item C<not_found>

HTTP 404.

=item C<http>

Other unsuccessful HTTP response.

=item C<server>

HTTP 5xx.

=item C<json>

Invalid successful JSON response.

=item C<rate_limit>

Exhausted retries or unusable 429 delay.

=item C<timeout>

HTTP request or stall timeout.

=item C<transport>

Transport failure or invalid result.

=item C<queue_full>

Request or route capacity exhausted.

=item C<shutdown>

Client closed.

=back

A Bot-authorized 401 rejects queued and future requests without sending them.
Already sent requests may finish but cannot retry. Create a new client with a
corrected token. Cancelling a Future cancels active local I/O; it does not undo
an operation Discord may have received.

=head1 RATE LIMITS AND OPTIONS

Limits are learned from L<response headers|https://docs.discord.com/developers/topics/rate-limits>. Channel, guild and webhook ID/token
pairs separate buckets. One request runs per bucket; separate buckets can run
concurrently. C<X-RateLimit-Bucket> joins routes sharing quota.
C<X-RateLimit-Reset-After> is preferred over C<X-RateLimit-Reset>.

A 429 waits for the larger usable delay from JSON C<retry_after> and C<Retry-After>.
Global and shared limits are respected. Missing or nonpositive delays fail.
Only received 429 responses are retried. Network failures, timeouts and 5xx
responses are not retried automatically.

=over 4

=item C<max_pending>

Queued and active requests; default 1000.

=item C<max_in_flight>

Concurrent HTTP requests; default 8.

=item C<max_buckets>

Route identities and learned bucket mappings; default 1024.

=item C<max_retries>

Additional 429 attempts; zero disables retries; default 3.

=item C<timeout>

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,
and C<delay> returning a cancellable Future for the given delay in seconds.

=head1 LICENSE

Apache License 2.0.

=cut



( run in 0.983 second using v1.01-cache-2.11-cpan-3f962061493 )