File Coverage

blib/lib/WebService/Discord/Webhook.pm
Criterion Covered Total %
statement 37 186 19.8
branch 6 88 6.8
condition 0 25 0.0
subroutine 8 15 53.3
pod 7 7 100.0
total 58 321 18.0


line stmt bran cond sub pod time code
1             package WebService::Discord::Webhook;
2              
3 1     1   68540 use strict;
  1         2  
  1         31  
4 1     1   6 use warnings;
  1         2  
  1         28  
5              
6             # Module for interacting with the REST service
7 1     1   745 use HTTP::Tiny;
  1         51164  
  1         47  
8              
9             # JSON decode
10 1     1   859 use JSON::PP qw(encode_json decode_json);
  1         15802  
  1         93  
11              
12             # Base64 encode for avatar images
13 1     1   526 use MIME::Base64 qw(encode_base64);
  1         722  
  1         60  
14              
15             # Parse filename from filepath
16 1     1   8 use File::Spec;
  1         3  
  1         41  
17              
18             # better error messages
19 1     1   6 use Carp qw(croak carp);
  1         2  
  1         2223  
20              
21             # PACKAGE VARS
22             our $VERSION = '1.00';
23              
24             # Base URL for all API requests
25             our $BASE_URL = 'https://discordapp.com/api';
26              
27             ##################################################
28              
29             # Create a new Webhook object.
30             # Pass a hash containing parameters
31             # Requires:
32             # url, or
33             # token and id
34             # Optional:
35             # wait
36             # timeout
37             # verify_SSL
38             # A single scalar is treated as a URL
39             sub new {
40 1     1 1 84 my $class = shift;
41              
42 1         2 my %params;
43 1 50       6 if ( scalar @_ > 1 ) {
44 0         0 %params = @_;
45             } else {
46 1         3 $params{url} = shift;
47             }
48              
49             # check parameters
50 1         3 my ( $id, $token );
51 1 50 0     3 if ( $params{url} ) {
    0          
52 1 50       9 if ( $params{url} =~ m{discordapp\.com/api/webhooks/(\d+)/([^/?]+)}i ) {
53 1         4 $id = $1;
54 1         3 $token = $2;
55             } else {
56 0         0 croak "Failed to parse ID and Token from URL";
57             }
58             } elsif ( $params{id} && $params{token} ) {
59 0 0 0     0 if ( $params{id} =~ m/^\d+$/ && $params{token} =~ m{^[^/?]+$} ) {
60 0         0 $id = $params{id};
61 0         0 $token = $params{token};
62             } else {
63 0         0 croak "Failed to validate ID and Token";
64             }
65             } else {
66 0         0 croak "Must provide either URL, or ID and Token";
67             }
68              
69             # Create an LWP UserAgent for REST requests
70 1         6 my %attributes =
71             ( agent =>
72             "p5-WebService-Discord-Webhook (https://github.com/greg-kennedy/p5-WebService-Discord-Webhook, $VERSION)"
73             );
74 1 50       5 if ( $params{timeout} ) { $attributes{timeout} = $params{timeout} }
  0         0  
75 1 50       5 if ( $params{verify_SSL} ) { $attributes{verify_SSL} = $params{verify_SSL} }
  0         0  
76              
77 1         8 my $http = HTTP::Tiny->new(%attributes);
78              
79             # create class with some params
80 1         92 my $self = bless { id => $id, token => $token, http => $http }, $class;
81 1 50       5 if ( $params{wait} ) { $self->{wait} = 1 }
  0         0  
82              
83             # call get to populate additional details
84             #$self->get();
85              
86 1         7 return $self;
87             }
88              
89             # updates internal structures after a webhook request
90             sub _parse_response {
91 0     0     my $self = shift;
92 0           my $json = shift;
93              
94 0           my $response = decode_json($json);
95              
96             # sanity
97 0 0         if ( $self->{id} ne $response->{id} ) {
98             carp "Warning: get() returned ID='"
99             . $response->{id}
100             . "', expected ID='"
101 0           . $self->{id} . "'";
102             }
103 0 0         if ( $self->{token} ne $response->{token} ) {
104             carp "Warning: get() returned Token='"
105             . $response->{token}
106             . "', expected Token='"
107 0           . $self->{token} . "'";
108             }
109              
110             # store / update details
111 0 0         if ( $response->{guild_id} ) {
112 0           $self->{guild_id} = $response->{guild_id};
113             } else {
114 0           delete $self->{guild_id};
115             }
116 0           $self->{channel_id} = $response->{channel_id};
117 0           $self->{name} = $response->{name};
118 0           $self->{avatar} = $response->{avatar};
119              
120 0           return $response;
121             }
122              
123             # GET request
124             # Retrieves some info about the webhook setup
125             # No parameters
126             sub get {
127 0     0 1   my $self = shift;
128              
129 0           my $url = $BASE_URL . '/webhooks/' . $self->{id} . '/' . $self->{token};
130              
131 0           my $response = $self->{http}->get($url);
132 0 0         if ( !$response->{success} ) {
    0          
133              
134             # non-200 code returned
135             carp "Warning: HTTP::Tiny->get($url) returned error ("
136             . $response->{status} . " "
137             . $response->{reason} . "): '"
138 0           . $response->{content} . "'";
139 0           return;
140             } elsif ( !$response->{content} ) {
141              
142             # empty result
143             carp "Warning: HTTP::Tiny->get($url) returned empty response ("
144             . $response->{status} . " "
145 0           . $response->{reason} . ")";
146 0           return;
147             }
148              
149             # update internal structs and return
150 0           return $self->_parse_response( $response->{content} );
151             }
152              
153             # PATCH request
154             # Allows webhook to alter its Name or Avatar
155             sub modify {
156 0     0 1   my $self = shift;
157              
158 0           my %params;
159 0 0         if ( scalar @_ > 1 ) {
160 0           %params = @_;
161             } else {
162 0           $params{name} = shift;
163             }
164              
165             # check params
166 0 0 0       if ( !( $params{name} || exists $params{avatar} ) ) {
167 0           croak "Modify request with no valid parameters";
168             }
169              
170 0           my %request;
171              
172             # retrieve the two allowed params and place in request if needed
173 0 0         if ( $params{name} ) { $request{name} = $params{name} }
  0            
174              
175 0 0         if ( exists $params{avatar} ) {
176 0 0         if ( $params{avatar} ) {
177              
178             # try to infer type from data string
179 0           my $type;
180 0 0 0       if (
    0          
    0          
181             substr( $params{avatar}, 0, 8 ) eq "\x89\x50\x4e\x47\x0d\x0a\x1a\x0a" )
182             {
183 0           $type = 'image/png';
184             } elsif ( substr( $params{avatar}, 0, 2 ) eq "\xff\xd8"
185             && substr( $params{avatar}, -2 ) eq "\xff\xd9" )
186             {
187 0           $type = 'image/jpeg';
188             } elsif ( substr( $params{avatar}, 0, 4 ) eq 'GIF8' ) {
189 0           $type = 'image/gif';
190             } else {
191 0           croak
192             "Could not determine image type from data (not a valid png, jpeg or gif image)";
193             }
194              
195             $request{avatar} =
196 0           'data:' . $type . ';base64,' . encode_base64( $params{avatar} );
197             } else {
198 0           $request{avatar} = undef;
199             }
200             }
201              
202 0           my $url = $BASE_URL . '/webhooks/' . $self->{id} . '/' . $self->{token};
203              
204             # PATCH method not yet built-in as of 0.076
205             #my $response = $self->{http}->patch($url, \%request);
206             my $response = $self->{http}->request(
207 0           'PATCH', $url,
208             {
209             headers => { 'Content-Type' => 'application/json' },
210             content => encode_json( \%request )
211             }
212             );
213 0 0         if ( !$response->{success} ) {
    0          
214              
215             # non-200 code returned
216             carp "Warning: HTTP::Tiny->patch($url) returned error ("
217             . $response->{status} . " "
218             . $response->{reason} . "): '"
219 0           . $response->{content} . "'";
220 0           return;
221             } elsif ( !$response->{content} ) {
222              
223             # empty result
224             carp "Warning: HTTP::Tiny->patch($url) returned empty response ("
225             . $response->{status} . " "
226 0           . $response->{reason} . ")";
227 0           return;
228             }
229              
230             # update internal structs and return
231 0           return $self->_parse_response( $response->{content} );
232             }
233              
234             # DELETE request - deletes the webhook
235             sub destroy {
236 0     0 1   my $self = shift;
237              
238 0           my $url = $BASE_URL . '/webhooks/' . $self->{id} . '/' . $self->{token};
239              
240 0           my $response = $self->{http}->delete($url);
241 0 0         if ( !$response->{success} ) {
242             carp "Warning: HTTP::Tiny->delete($url) returned error ("
243             . $response->{status} . " "
244             . $response->{reason} . "): '"
245 0           . $response->{content} . "'";
246 0           return;
247             }
248              
249             # DELETE response is 204 NO CONTENT, simply return true if successful.
250 0           return 1;
251             }
252              
253             # EXECUTE - posts the message.
254             # Required parameters: one of
255             # content
256             # files
257             # embeds
258             # Optional paremeters:
259             # username
260             # avatar_url
261             # tts
262             sub execute {
263 0     0 1   my $self = shift;
264              
265             # extract params
266 0           my %params;
267 0 0         if ( scalar @_ > 1 ) {
268 0           %params = @_;
269             } else {
270 0           $params{content} = shift;
271             }
272              
273             # convenience params
274 0 0         if ( $params{file} ) { $params{files} = [ delete $params{file} ] }
  0            
275 0 0         if ( $params{embed} ) { $params{embeds} = [ delete $params{embed} ] }
  0            
276              
277             # test required fields
278 0 0 0       if ( !( $params{content} || $params{files} || $params{embeds} ) ) {
    0 0        
279 0           croak
280             "Execute request missing required parameters (must have at least content, embed, or file)";
281             } elsif ( $params{embeds} && $params{files} ) {
282 0           croak "Execute request: cannot combine file and embed request in one call.";
283             }
284              
285             # construct JSON request
286 0           my %request;
287              
288             # all messages types may have these params
289 0 0         if ( $params{content} ) { $request{content} = $params{content} }
  0            
290              
291 0 0         if ( $params{username} ) { $request{username} = $params{username} }
  0            
292 0 0         if ( $params{avatar_url} ) { $request{avatar_url} = $params{avatar_url} }
  0            
293 0 0         if ( $params{tts} ) { $request{tts} = JSON::PP::true }
  0            
294              
295             # compose URL
296 0           my $url = $BASE_URL . '/webhooks/' . $self->{id} . '/' . $self->{token};
297 0 0         if ( $self->{wait} ) { $url .= '?wait=true' }
  0            
298              
299             # switch mode for request based on file upload or no
300 0           my $response;
301 0 0         if ( !$params{files} ) {
302              
303             # This is a regular, no-fuss JSON request
304 0 0         if ( $params{embeds} ) { $request{embeds} = $params{embeds} }
  0            
305              
306             $response = $self->{http}->post(
307 0           $url,
308             {
309             headers => { 'Content-Type' => 'application/json' },
310             content => encode_json( \%request )
311             }
312             );
313             } else {
314              
315             # File upload, construct a multipart/form-data message
316             # 32 random chars to make a boundary
317 0           my @chars = ( 'A' .. 'Z', 'a' .. 'z', '0' .. '9' );
318 0           my $boundary = '';
319 0           for ( my $i = 0; $i < 32; $i++ ) {
320 0           $boundary .= $chars[ rand @chars ];
321             }
322              
323             # Build request body
324 0           my $content = '';
325              
326 0           for ( my $i = 0; $i < scalar @{ $params{files} }; $i++ ) {
  0            
327 0           my $file = $params{files}[$i];
328 0           $content .= "\r\n--$boundary\r\n";
329             $content .=
330             "Content-Disposition: form-data; name=\"file$i\"; filename=\""
331             . $file->{name}
332 0           . "\"\r\n";
333              
334             # Discord ignores content-type, just put octet-stream for everything
335 0           $content .= "Content-Type: application/octet-stream\r\n";
336 0           $content .= "\r\n";
337 0           $content .= $file->{data} . "\r\n";
338             }
339              
340             # add the json payload for the rest of the message
341 0           $content .= "\r\n--$boundary\r\n";
342 0           $content .= "Content-Disposition: form-data; name=\"payload_json\";\r\n";
343 0           $content .= "Content-Type: application/json\r\n";
344 0           $content .= "\r\n";
345 0           $content .= encode_json( \%request ) . "\r\n";
346              
347 0           $content .= "\r\n--$boundary--\r\n";
348              
349             $response = $self->{http}->post(
350 0           $url,
351             {
352             headers =>
353             { 'Content-Type' => "multipart/form-data; boundary=$boundary" },
354             content => $content
355             }
356             );
357             }
358              
359 0 0         if ( !$response->{success} ) {
360             carp "Warning: HTTP::Tiny->post($url) returned: "
361             . $response->{status} . " "
362             . $response->{reason} . ": '"
363 0           . $response->{content} . "'";
364 0           return;
365             }
366              
367             # return details, or just true if content is empty (wait=0)
368 0 0         if ( $response->{content} ) { return decode_json( $response->{content} ) }
  0            
369 0           return 1;
370             }
371              
372             sub execute_slack {
373 0     0 1   my $self = shift;
374              
375 0           my $json;
376 0 0         if ( scalar @_ > 1 ) {
377 0           my %params = @_;
378 0           $json = encode_json( \%params );
379             } else {
380 0           $json = shift;
381             }
382              
383             # create a slack-format post url
384             my $url =
385 0           $BASE_URL . '/webhooks/' . $self->{id} . '/' . $self->{token} . '/slack';
386 0 0         if ( $self->{wait} ) { $url .= '?wait=true' }
  0            
387              
388 0           my $response = $self->{http}->post( $url,
389             { headers => { 'Content-Type' => 'application/json' }, content => $json } );
390 0 0         if ( !$response->{success} ) {
391             carp "Warning: HTTP::Tiny->post($url) returned: "
392             . $response->{status} . " "
393             . $response->{reason} . ": '"
394 0           . $response->{content} . "'";
395 0           return;
396             }
397              
398             # return details, or just true if content is empty (wait=0)
399             # Slack request usually returns the string "ok"
400 0   0       return $response->{content} || 1;
401             }
402              
403             sub execute_github {
404 0     0 1   my $self = shift;
405              
406 0           my %params = @_;
407              
408             # check params
409 0 0 0       if ( !( $params{event} && $params{json} ) ) {
410 0           croak "execute_github missing required event and json parameters";
411             }
412              
413             # create a github-format post url
414             my $url =
415 0           $BASE_URL . '/webhooks/' . $self->{id} . '/' . $self->{token} . '/github';
416 0 0         if ( $self->{wait} ) { $url .= '?wait=true' }
  0            
417              
418             my $response = $self->{http}->post(
419             $url,
420             {
421             headers => {
422             'Content-Type' => 'application/json',
423             'X-GitHub-Event' => $params{event}
424             },
425             content => $params{json}
426             }
427 0           );
428 0 0         if ( !$response->{success} ) {
429             carp "Warning: HTTP::Tiny->post($url) returned: "
430             . $response->{status} . " "
431             . $response->{reason} . ": '"
432 0           . $response->{content} . "'";
433 0           return;
434             }
435              
436             # return details, or just true if content is empty (wait=0)
437             # github request usually has no response
438 0   0       return $response->{content} || 1;
439             }
440              
441             1;
442              
443             __END__