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 )