File Coverage

blib/lib/WWW/Telegram/BotAPI.pm
Criterion Covered Total %
statement 63 102 61.7
branch 29 66 43.9
condition 14 54 25.9
subroutine 14 18 77.7
pod 4 4 100.0
total 124 244 50.8


line stmt bran cond sub pod time code
1             package WWW::Telegram::BotAPI;
2 3     3   59339 use strict;
  3         18  
  3         65  
3 3     3   13 use warnings;
  3         4  
  3         62  
4 3     3   11 use warnings::register;
  3         5  
  3         274  
5 3     3   13 use Carp ();
  3         4  
  3         41  
6 3     3   1325 use Encode ();
  3         24863  
  3         52  
7 3     3   332 use JSON::MaybeXS ();
  3         5753  
  3         66  
8 3   50 3   12 use constant DEBUG => $ENV{TELEGRAM_BOTAPI_DEBUG} || 0;
  3         6  
  3         265  
9              
10             our $VERSION = "0.10";
11             my $json; # for debugging purposes, only defined when DEBUG = 1
12              
13             BEGIN {
14 3 50 33 3   140 eval "require Mojo::UserAgent; 1" or
15             eval "require LWP::UserAgent; 1" or
16             die "Either Mojo::UserAgent or LWP::UserAgent is required.\n$@";
17 3         3433 $json = JSON::MaybeXS->new (pretty => 1, utf8 => 1) if DEBUG;
18             }
19              
20             # Debugging functions (only used when DEBUG is true)
21 0     0   0 sub _dprintf { printf "-T- $_[0]\n", splice @_, 1 }
22             sub _ddump
23             {
24 0     0   0 my ($varname, $to_dump) = splice @_, -2;
25 0 0       0 _dprintf @_ if @_;
26 0         0 printf "%s = %s", $varname, $json->encode ($to_dump);
27             }
28              
29             # %settings = (
30             # async => Bool,
31             # token => String,
32             # api_url => "http://something/%s/%s", # 1st %s = tok, 2nd %s = method
33             # force_lwp => Bool
34             # )
35             sub new
36             {
37 6     6 1 3513 my ($class, %settings) = @_;
38             exists $settings{token}
39 6 100       163 or Carp::croak "ERROR: missing 'token' from \%settings.";
40             # When DEBUG is enabled, and Mojo::UserAgent is used, Mojolicious must be at
41             # least version 6.22 (https://github.com/kraih/mojo/blob/v6.22/Changes). This is because
42             # Mojo::JSON used incompatible JSON boolean constants which led JSON::MaybeXS to crash
43             # with a mysterious error message. To prevent this, we force LWP in this case.
44 5         5 if (DEBUG && Mojo::JSON->can ("true") && ref Mojo::JSON->true ne "JSON::PP::Boolean")
45             {
46             warnings::warnif (
47             "WARNING: Enabling DEBUG with Mojolicious versions < 6.22 won't work. Forcing " .
48             "LWP::UserAgent. (update Mojolicious or disable DEBUG to fix)"
49             );
50             ++$settings{force_lwp};
51             }
52             # Ensure that LWP is loaded if "force_lwp" is specified.
53             $settings{force_lwp}
54 5 100       1112 and require LWP::UserAgent;
55             # Instantiate the correct user-agent. This automatically detects whether Mojo::UserAgent is
56             # available or not.
57 5 100 66     64770 $settings{_agent} = ($settings{force_lwp} or !Mojo::UserAgent->can ("new")) ?
58             LWP::UserAgent->new : Mojo::UserAgent->new;
59 5 100 100     5835 ($settings{async} ||= 0) and $settings{_agent}->isa ("LWP::UserAgent")
      66        
60             and Carp::croak "ERROR: Mojo::UserAgent is required to use 'async'.";
61 4   50     13 $settings{api_url} ||= "https://api.telegram.org/bot%s/%s";
62             DEBUG && _dprintf "WWW::Telegram::BotAPI initialized (v%s), using agent %s %ssynchronously.",
63 4         5 $VERSION, ref $settings{_agent}, $settings{async} ? "a" : "";
64 4         27 bless \%settings, $class
65             }
66              
67             # Don't let old Perl versions call AUTOLOAD when DESTROYing our class.
68       0     sub DESTROY {}
69              
70             # Magically provide methods named as the Telegram API ones, such as $o->sendMessage.
71             sub AUTOLOAD
72             {
73 4     4   36 my $self = shift;
74 4         5 our $AUTOLOAD;
75 4         21 (my $method = $AUTOLOAD) =~ s/.*:://; # removes the package name at the beginning
76 4         10 $self->api_request ($method, @_);
77             }
78              
79             # The real stuff!
80             sub api_request
81             {
82 4     4 1 9 my ($self, $method) = splice @_, 0, 2;
83             # Detect if the user provided a callback to use for async requests.
84             # The only parameter whose order matters is $method. The callback and the request parameters
85             # can be put in any order, like this: $o->api_request ($method, sub {}, { a => 1 }) or
86             # $o->api_request ($method, { a => 1 }, sub {}), or even
87             # $o->api_request ($method, "LOL", "DONGS", sub {}, { a => 1 }).
88 4         16 my ($postdata, $async_cb);
89 4         8 for my $arg (@_)
90             {
91             # Poor man's switch block
92 2         4 for (ref $arg)
93             {
94             # Ensure that we don't get async callbacks when we aren't in async mode.
95 2 0 33     6 ($async_cb = $arg, last) if $_ eq "CODE" and $self->{async};
96 2 50       5 ($postdata = $arg, last) if $_ eq "HASH";
97             }
98 2 50 33     6 last if defined $async_cb and defined $postdata;
99             }
100             # Prepare the request method parameters.
101 4         5 my @request;
102 4         7 my $is_lwp = $self->_is_lwp;
103             # Push the request URI (this is the same in LWP and Mojo)
104 4         16 push @request, sprintf ($self->{api_url}, $self->{token}, $method);
105 4 100       34 if (defined $postdata)
106             {
107             # POST arguments which are array/hash references need to be handled as follows:
108             # - if no file upload exists, use application/json and encode everything with JSON::MaybeXS
109             # or let Mojo::UserAgent handle everything, when available.
110             # - whenever a file upload exists, the MIME type is switched to multipart/form-data.
111             # Other refs which are not file uploads are then encoded with JSON::MaybeXS.
112 2         6 my @fixable_keys; # This array holds keys found before file uploads which have to be fixed.
113             my @utf8_keys; # This array holds keys found before file uploads which have to be encoded.
114 2         0 my $has_file_upload;
115             # Traverse the post arguments.
116 2         6 for my $k (keys %$postdata)
117             {
118             # Ensure we pass octets to LWP with multipart/form-data and that we deal only with
119             # references.
120             ($is_lwp
121             ? $has_file_upload ? $postdata->{$k} = Encode::encode ("utf-8", $postdata->{$k})
122             : push @utf8_keys, $k
123 2 50       10 : ()), next unless my $ref = ref $postdata->{$k};
    100          
    50          
124             # Process file uploads.
125 0 0 0     0 if ($ref eq "HASH" and
      0        
126             (exists $postdata->{$k}{file} or exists $postdata->{$k}{content}))
127             {
128             # WARNING: using file uploads implies switching to the MIME type
129             # multipart/form-data, which needs a JSON stringification for every complex object.
130 0         0 ++$has_file_upload;
131             # No particular treatment is needed for file uploads when using Mojo.
132 0 0       0 next unless $is_lwp;
133             # The structure of the hash must be:
134             # { content => 'file content' } or { file => 'path to file' }
135             # With an optional key "filename" and optional headers to be merged into the
136             # multipart/form-data stuff.
137             # See https://metacpan.org/pod/Mojo::UserAgent::Transactor#tx
138             # HTTP::Request::Common uses this syntax instead:
139             # [ $file, $filename, SomeHeader => 'bla bla', Content => 'fileContent' ]
140             # See p3rl.org/HTTP::Request::Common#POST-url-Header-Value-...-Content-content
141 0         0 my $new_val = [];
142             # Push and remove the keys 'file' and 'filename' (if defined) to $new_val.
143             push @$new_val, delete $postdata->{$k}{file},
144 0         0 delete $postdata->{$k}{filename};
145             # Push 'Content' (note the uppercase 'C')
146             exists $postdata->{$k}{content}
147 0 0       0 and push @$new_val, Content => delete $postdata->{$k}{content};
148             # Push the other headers.
149 0         0 push @$new_val, %{$postdata->{$k}};
  0         0  
150             # Finalize the changes.
151 0         0 $postdata->{$k} = $new_val;
152             }
153             else
154             {
155 0 0       0 $postdata->{$k} = JSON::MaybeXS::encode_json ($postdata->{$k}), next
156             if $has_file_upload;
157 0         0 push @fixable_keys, $k;
158             }
159             }
160 2 50       4 if ($has_file_upload)
161             {
162             # Fix keys found before the file upload.
163 0         0 $postdata->{$_} = JSON::MaybeXS::encode_json ($postdata->{$_}) for @fixable_keys;
164 0         0 $postdata->{$_} = Encode::encode ("utf-8", $postdata->{$_}) for @utf8_keys;
165 0 0 0     0 $is_lwp
166             and push @request, Content => $postdata,
167             Content_Type => "form-data"
168             or push @request, form => $postdata;
169             }
170             else
171             {
172 2 100 66     15 $is_lwp
173             and push @request, DEBUG ? (DBG => $postdata) : (), # handled in _fix_request_args
174             Content => JSON::MaybeXS::encode_json ($postdata),
175             Content_Type => "application/json"
176             or push @request, json => $postdata;
177             }
178             }
179             # Protip (also mentioned in the doc): if you are using non-blocking requests with
180             # Mojo::UserAgent, remember to start the event loop with Mojo::IOLoop->start.
181             # This is superfluous when using this module in a Mojolicious app.
182 4 50       8 push @request, $async_cb if $async_cb;
183             # Stop here if this is a test - specified using the (internal) "_dry_run" flag.
184 4 50       17 return 1 if $self->{_dry_run};
185 0         0 DEBUG and _ddump "BEGIN REQUEST to /%s :: %s", $method, scalar localtime,
186             PAYLOAD => _fix_request_args ($self, \@request);
187             # Perform the request.
188 0         0 my $tx = $self->agent->post (@request);
189 0         0 DEBUG and $async_cb and
190             _dprintf "END REQUEST to /%s (async) :: %s", $method, scalar localtime;
191             # We're done if the request is asynchronous.
192 0 0       0 return $tx if $async_cb;
193             # Pre-decode the response to provide, if possible, an error message.
194             my $response = $is_lwp ?
195 0 0 0     0 eval { JSON::MaybeXS::decode_json ($tx->decoded_content) } || undef :
196             $tx->res->json;
197             # Dump it in debug mode.
198 0         0 DEBUG and _ddump RESPONSE => $response;
199             # If we (or the server) f****d up... die horribly.
200 0 0 0     0 unless (($is_lwp ? $tx->is_success : $tx->success) && $response && $response->{ok})
    0 0        
201             {
202 0   0     0 $response ||= {};
203             # Handle old errors supplied by ancient Mojolicious versions: in some conditions,
204             # `$tx->error` returned a string instead of the expected hash reference. See issue #16.
205             my $error = $response->{description} || ($is_lwp ? $tx->status_line :
206 0   0     0 ((ref ($tx->error || {}) ? $tx->error : { message => $tx->error }) || {})->{message});
207             # Print either the error returned by the API or the HTTP status line.
208             Carp::confess
209 0 0 0     0 "ERROR: ", ($response->{error_code} ? "code " . $response->{error_code} . ": " : ""),
210             $error || "something went wrong!";
211             }
212 0         0 DEBUG and _dprintf "END REQUEST to /%s :: %s", $method, scalar localtime;
213 0         0 $response
214             }
215              
216             sub parse_error
217             {
218 4   33 4 1 6541 my $r = { type => "unknown", msg => $_[1] || $@ };
219             # The following regexp matches the error code to the first group and the error message to the
220             # second.
221             # Issue #19: match only `at ...` messages separated by at least one space. See t/02-exceptions
222 4 100       38 return $r unless $r->{msg} =~ /ERROR: (?:code ([0-9]+): )?(.+?)(?:\s+at .+)?$/m;
223             # Find and save the error code and message.
224 3 100       13 $r->{code} = $1 if $1;
225 3         26 $r->{msg} = $2;
226             # If the error message has a code, then it comes from the BotAPI. Otherwise, it's our agent
227             # telling us something went wrong.
228 3 100       16 $r->{type} = exists $r->{code} ? "api" : "agent" if $r->{msg} ne "something went wrong!";
    50          
229 3         8 $r
230             }
231              
232             sub agent
233             {
234             shift->{_agent}
235 6     6 1 377 }
236              
237             # Hides the bot's token from the request arguments and improves debugging output.
238             sub _fix_request_args
239             {
240 0     0   0 my ($self, $args) = @_;
241 0         0 my $args_cpy = [ @$args ];
242 0         0 $args_cpy->[0] =~ s/\Q$self->{token}\E/XXXXXXXXX/g;
243             # Note for the careful reader: you may remember that the position of Perl's hash keys is
244             # undeterminate - that is, an hash has no particular order. This is true, however we are
245             # dealing with an array which has a fixed order, so no particular problem arises here.
246             # Addendum: the original reference of $args is used here to get rid of `DBG => $postdata`.
247 0 0 0     0 if (@$args > 1 and $args->[1] eq "DBG")
248             {
249 0         0 my (undef, $data) = splice @$args, 1, 2;
250             # Be sure to get rid of the `DBG` key in our copy too.
251 0         0 splice @$args_cpy, 1, 2;
252             # In the debug output, substitute the JSON-encoded data (which is not human readable) with
253             # the raw POST arguments.
254 0         0 $args_cpy->[2] = $data;
255             }
256             # Ensure that we do NOT try display async subroutines!
257 0 0       0 pop @$args_cpy if ref $args_cpy->[-1] eq "CODE";
258 0         0 $args_cpy
259             }
260              
261             sub _is_lwp
262             {
263 4     4   8 shift->agent->isa ("LWP::UserAgent")
264             }
265              
266             1;
267              
268             =encoding utf8
269              
270             =head1 NAME
271              
272             WWW::Telegram::BotAPI - Perl implementation of the Telegram Bot API
273              
274             =head1 SYNOPSIS
275              
276             use WWW::Telegram::BotAPI;
277             my $api = WWW::Telegram::BotAPI->new (
278             token => 'my_token'
279             );
280             # The API methods die when an error occurs.
281             say $api->getMe->{result}{username};
282             # ... but error handling is available as well.
283             my $result = eval { $api->getMe }
284             or die 'Got error message: ', $api->parse_error->{msg};
285             # Uploading files is easier than ever.
286             $api->sendPhoto ({
287             chat_id => 123456,
288             photo => {
289             file => '/home/me/cool_pic.png'
290             },
291             caption => 'Look at my cool photo!'
292             });
293             # Complex objects are as easy as writing a Perl object.
294             $api->sendMessage ({
295             chat_id => 123456,
296             # Object: ReplyKeyboardMarkup
297             reply_markup => {
298             resize_keyboard => \1, # \1 = true when JSONified, \0 = false
299             keyboard => [
300             # Keyboard: row 1
301             [
302             # Keyboard: button 1
303             'Hello world!',
304             # Keyboard: button 2
305             {
306             text => 'Give me your phone number!',
307             request_contact => \1
308             }
309             ]
310             ]
311             }
312             });
313             # Asynchronous request are supported with Mojo::UserAgent.
314             $api = WWW::Telegram::BotAPI->new (
315             token => 'my_token',
316             async => 1 # WARNING: may fail if Mojo::UserAgent is not available!
317             );
318             $api->sendMessage ({
319             chat_id => 123456,
320             text => 'Hello world!'
321             }, sub {
322             my ($ua, $tx) = @_;
323             die 'Something bad happened!' unless $tx->success;
324             say $tx->res->json->{ok} ? 'YAY!' : ':('; # Not production ready!
325             });
326             Mojo::IOLoop->start;
327              
328             =head1 DESCRIPTION
329              
330             This module provides an easy to use interface for the
331             L. It also supports async requests out of the
332             box using L, which makes this module easy to integrate with an existing
333             L application.
334              
335             =head1 METHODS
336              
337             L implements the following methods.
338              
339             =head2 new
340              
341             my $api = WWW::Telegram::BotAPI->new (%options);
342              
343             Creates a new L instance.
344              
345             B you should only create one instance of this module and reuse it when needed. Calling
346             C each time you run an async request causes unexpected behavior with L and
347             won't work correctly. See also
348             L.
349              
350             C<%options> may contain the following:
351              
352             =over 4
353              
354             =item * C<< token => 'my_token' >>
355              
356             The token that will be used to authenticate the bot.
357              
358             B
359              
360             =item * C<< api_url => 'https://api.example.com/token/%s/method/%s' >>
361              
362             A format string that will be used to create the final API URL. The first parameter specifies
363             the token, the second one specifies the method.
364              
365             Defaults to C.
366              
367             =item * C<< async => 1 >>
368              
369             Enables asynchronous requests.
370              
371             B, and the method will croak if it isn't found.>
372              
373             Defaults to C<0>.
374              
375             =item * C<< force_lwp => 1 >>
376              
377             Forces the usage of L instead of L, even if the latter is
378             available.
379              
380             By default, the module tries to load L, and on failure it uses L.
381              
382             =back
383              
384             =head2 AUTOLOAD
385              
386             $api->getMe;
387             $api->sendMessage ({
388             chat_id => 123456,
389             text => 'Hello world!'
390             });
391             # with async => 1 and the IOLoop already started
392             $api->setWebhook ({ url => 'https://example.com/webhook' }, sub {
393             my ($ua, $tx) = @_;
394             die unless $tx->success;
395             say 'Webhook set!'
396             });
397              
398             This module makes use of L. This means that B
399             method of the Telegram Bot API can be used by calling its Perl equivalent>, without requiring an
400             update of the module.
401              
402             If you'd like to avoid using C, then you may simply call the L method
403             specifying the method name as the first argument.
404              
405             $api->api_request ('getMe');
406              
407             This is, by the way, the exact thing the C method of this module does.
408              
409             =head2 api_request
410              
411             # Remember: each of these samples can be aliased with
412             # $api->methodName ($params).
413             $api->api_request ('getMe');
414             $api->api_request ('sendMessage', {
415             chat_id => 123456,
416             text => 'Oh, hai'
417             });
418             # file upload
419             $api->api_request ('sendDocument', {
420             chat_id => 123456,
421             document => {
422             filename => 'dump.txt',
423             content => 'secret stuff'
424             }
425             });
426             # complex objects are supported natively since v0.04
427             $api->api_request ('sendMessage', {
428             chat_id => 123456,
429             reply_markup => {
430             keyboard => [ [ 'Button 1', 'Button 2' ] ]
431             }
432             });
433             # with async => 1 and the IOLoop already started
434             $api->api_request ('getMe', sub {
435             my ($ua, $tx) = @_;
436             die unless $tx->success;
437             # ...
438             });
439              
440             This method performs an API request. The first argument must be the method name
441             (L).
442              
443             Once the request is completed, the response is decoded using L and then
444             returned. If L is used as the user-agent, then the response is decoded
445             automatically using L.
446              
447             If the request is not successful or the server tells us something isn't C, then this method
448             dies with the first available error message (either the error description or the status line).
449             You can make this method non-fatal using C:
450              
451             my $response = eval { $api->api_request ($method, $args) }
452             or warn "Request failed with error '$@', but I'm still alive!";
453              
454             Further processing of error messages can be obtained using L.
455              
456             Request parameters can be specified using an hash reference. Additionally, complex objects can be
457             specified like you do in JSON. See the previous examples or the example bot provided in
458             L.
459              
460             File uploads can be specified using an hash reference containing the following mappings:
461              
462             =over 4
463              
464             =item * C<< file => '/path/to/file.ext' >>
465              
466             Path to the file you want to upload.
467              
468             Required only if C is not specified.
469              
470             =item * C<< filename => 'file_name.ext' >>
471              
472             An optional filename that will be used instead of the real name of the file.
473              
474             Particularly recommended when C is specified.
475              
476             =item * C<< content => 'Being a file is cool :-)' >>
477              
478             The content of the file to send. When using this, C must not be specified.
479              
480             =item * C<< AnyCustom => 'Header' >>
481              
482             Custom headers can be specified as hash mappings.
483              
484             =back
485              
486             Upload of multiple files is not supported. See L for more
487             information about file uploads.
488              
489             To resend files, you don't need to perform a file upload at all. Just pass the ID as a normal
490             parameter.
491              
492             $api->sendPhoto ({
493             chat_id => 123456,
494             photo => $photo_id
495             });
496              
497             When asynchronous requests are enabled, a callback can be specified as an argument.
498             The arguments passed to the callback are, in order, the user-agent (a L object)
499             and the response (a L object). More information can be found in the
500             documentation of L and L.
501              
502             B ensure that the event loop L is started when using asynchronous requests.
503             This is not needed when using this module inside a L app.
504              
505             The order of the arguments, except of the first one, does not matter:
506              
507             $api->api_request ('sendMessage', $parameters, $callback);
508             $api->api_request ('sendMessage', $callback, $parameters); # same thing!
509              
510             =head2 parse_error
511              
512             unless (eval { $api->doSomething(...) }) {
513             my $error = $api->parse_error;
514             die "Unknown error: $error->{msg}" if $error->{type} eq 'unknown';
515             # Handle error gracefully using "type", "msg" and "code" (optional)
516             }
517             # Or, use it with a custom error message.
518             my $error = $api->parse_error ($message);
519              
520             When sandboxing calls to L methods using C, it is useful to parse
521             error messages using this method.
522              
523             B up until version 0.09, this method incorrectly stopped at the first occurence of C
524             in error messages, producing results such as C instead of C.
525              
526             This method accepts an error message as its first argument, otherwise C<$@> is used.
527              
528             An hash reference containing the following elements is returned:
529              
530             =over 4
531              
532             =item * C<< type => unknown|agent|api >>
533              
534             The source of the error.
535              
536             C specifies an error originating from Telegram's BotAPI. When C is C, the key
537             C is guaranteed to exist.
538              
539             C specifies an error originating from this module's user-agent. This may indicate a network
540             issue, a non-200 HTTP response code or any error not related to the API.
541              
542             C specifies an error with no known source.
543              
544             =item * C<< msg => ... >>
545              
546             The error message.
547              
548             =item * C<< code => ... >>
549              
550             The error code. B is C>.
551              
552             =back
553              
554             =head2 agent
555              
556             my $user_agent = $api->agent;
557              
558             Returns the instance of the user-agent used by the module. You can determine if the module is using
559             L or L by using C:
560              
561             my $is_lwp = $user_agent->isa ('LWP::UserAgent');
562              
563             =head1 DEBUGGING
564              
565             To perform some cool troubleshooting, you can set the environment variable C
566             to a true value:
567              
568             TELEGRAM_BOTAPI_DEBUG=1 perl script.pl
569              
570             This dumps the content of each request and response in a friendly, human-readable way.
571             It also prints the version and the configuration of the module. As a security measure, the bot's
572             token is automatically removed from the output of the dump.
573              
574             B using this option along with an old Mojolicious version (< 6.22) leads to a warning,
575             and forces L instead of L. This is because L
576             used incompatible boolean values up to version 6.21, which led to an horrible death of
577             L when serializing the data.
578              
579             =head1 CAVEATS
580              
581             When asynchronous mode is enabled, no error handling is performed. You have to do it by
582             yourself as shown in the L.
583              
584             =head1 SEE ALSO
585              
586             L, L,
587             L, L,
588             L,
589             L
590              
591             =head1 AUTHOR
592              
593             Roberto Frenna (robertof AT cpan DOT org)
594              
595             =head1 BUGS
596              
597             Please report any bugs or feature requests to
598             L.
599              
600             =head1 THANKS
601              
602             Thanks to L for inspiration about the license and the
603             documentation.
604              
605             =head1 LICENSE
606              
607             Copyright (C) 2015, Roberto Frenna.
608              
609             This program is free software, you can redistribute it and/or modify it under the terms of the
610             Artistic License version 2.0.
611              
612             =cut