File Coverage

blib/lib/Bot/Telegram.pm
Criterion Covered Total %
statement 168 168 100.0
branch 49 50 98.0
condition 26 31 83.8
subroutine 35 35 100.0
pod 13 13 100.0
total 291 297 97.9


line stmt bran cond sub pod time code
1             package Bot::Telegram;
2             # ABSTRACT: a micro^W nano framework for creating Telegram bots based on L
3             our $VERSION = '1.10'; # VERSION
4              
5 7     7   1764215 use v5.16.3;
  7         28  
6              
7 7     7   39 use Mojo::Base 'Mojo::EventEmitter';
  7         45  
  7         71  
8 7     7   25350 use WWW::Telegram::BotAPI;
  7         3570555  
  7         327  
9 7     7   82 use Mojo::Promise;
  7         24  
  7         80  
10 7     7   4030 use Mojo::Log;
  7         100929  
  7         75  
11              
12 7     7   427 use Mojo::JSON 'encode_json';
  7         19  
  7         485  
13 7     7   47 use Mojo::Transaction::HTTP;
  7         13  
  7         61  
14 7     7   217 use Mojo::Message::Response;
  7         23  
  7         100  
15              
16 7     7   4464 use Bot::Telegram::X::InvalidArgumentsError;
  7         74  
  7         486  
17 7     7   3544 use Bot::Telegram::X::InvalidStateError;
  7         20  
  7         401  
18              
19 7     7   47 use constant ERR_NODETAILS => 'no details available';
  7         9  
  7         556  
20              
21 7     7   37 use constant DEFAULT_POLLING_TIMEOUT => 20;
  7         31  
  7         389  
22 7     7   33 use constant DEFAULT_POLLING_INTERVAL => 0.3;
  7         20  
  7         2339  
23              
24             use constant DEFAULT_POLLING_ERROR_CB => sub {
25 4         83 my ($self, $tx, $type) = @_;
26              
27             my $message = sub {
28             return $tx -> {error}{msg}
29             unless $self -> is_async;
30              
31             for ($type) {
32             /agent/ and return $tx -> error -> {message};
33             /api/ and return ($tx -> res -> json // {}) -> {description};
34             }
35 4   100     72 } -> () || ERR_NODETAILS;
36              
37 4         134 $self -> log -> warn("Polling failed (error type: $type): $message");
38 7     7   57 };
  7         15  
  7         893  
39              
40             use constant DEFAULT_CALLBACK_ERROR_CB => sub {
41 1         18 my ($self, undef, $err) = @_;
42 1         4 $self -> log -> warn("Update processing failed: $err");
43 7     7   39 };
  7         9  
  7         18655  
44              
45             has [qw/api current_update polling_config/];
46             has [qw/_polling
47             _polling_timer
48             _polling_interval
49             _polling_request_id/
50             ];
51              
52             has callbacks => sub { {} };
53             has ioloop => sub { Mojo::IOLoop -> new };
54             has log => sub { Mojo::Log -> new -> level('info') };
55              
56             sub new {
57 18     18 1 1875341 my $self = shift -> SUPER::new(@_);
58              
59 18         247 $self -> on(polling_error => DEFAULT_POLLING_ERROR_CB);
60 18         222 $self -> on(callback_error => DEFAULT_CALLBACK_ERROR_CB);
61              
62 18         132 $self
63             }
64              
65             ################################################################################
66             # General
67             ################################################################################
68              
69             sub init_api {
70 2     2 1 7351 my ($self, %args) = @_;
71              
72             Bot::Telegram::X::InvalidArgumentsError
73             -> throw('No token provided')
74 2 100       41 unless exists $args{token};
75              
76 1   50     11 $args{async} //= 1;
77              
78 1         19 $self -> api(WWW::Telegram::BotAPI -> new(%args));
79              
80 1         114 return $self;
81             }
82              
83             sub is_async {
84 100     100 1 9054 my $self = shift;
85              
86 100 100       537 Bot::Telegram::X::InvalidStateError
87             -> throw('API is not initialized')
88             unless $self -> api;
89              
90 99         1773 $self -> api -> {async};
91             }
92              
93 1     1 1 7327 sub api_request { shift -> api -> api_request(@_) }
94             sub api_request_p {
95 4     4 1 20503 my ($self, @args) = @_;
96              
97             Mojo::Promise -> new(sub {
98 4     4   217 my ($resolve, $reject) = @_;
99              
100             $self -> api -> api_request(@args, sub {
101 4         408731 my ($ua, $tx) = @_;
102 4         56 my $response = $tx -> res -> json;
103              
104             ((!$tx -> error && ref $response && $$response{ok})
105 4 100 100     729 ? $resolve
106             : $reject) -> ($ua, $tx);
107             })
108 4         53 });
  4         21  
109             }
110              
111             ################################################################################
112             # Callbacks
113             ################################################################################
114              
115             sub set_callbacks {
116 11     11 1 4167 my ($self, %cbs) = @_;
117              
118 11         111 while ( my ($key, $val) = each %cbs) {
119 17         75 $self -> callbacks -> {$key} = $val;
120             }
121              
122 11         69 return $self;
123             }
124              
125             sub remove_callbacks {
126 1     1 1 7776 my ($self, @events) = @_;
127              
128 1         5 foreach my $event (@events) {
129 2         13 delete $self -> callbacks -> {$event};
130             }
131              
132 1         8 return $self;
133             }
134              
135             ################################################################################
136             # Updates
137             ################################################################################
138              
139             sub shift_offset {
140 38     38 1 116 my $self = shift;
141              
142 38         116 for (my $update = $self -> current_update) {
143             $self -> polling_config -> {offset} = $$update{update_id} + 1
144 38 100       256 if $$update{update_id} >= $self -> polling_config -> {offset};
145             }
146              
147             $self
148 38         427 }
149              
150             sub process_update {
151 38     38 1 86 my ($self, $update) = @_;
152              
153 38         173 $self -> current_update($update);
154 38         324 my $type = $self -> _get_update_type($update);
155              
156 38         238 eval {
157             # If update type is recognized, call the appropriate callback
158 38 100       91 if ($type) {
159             $self -> callbacks
160 15         46 -> {$type}
161             -> ($self, $update);
162             }
163              
164             # Otherwise report an unknown update
165 23         89 else { $self -> emit(unknown_update => $update) }
166             };
167              
168             # Report a callback error if we failed to handle the update
169 38 100       26251 $self -> emit(callback_error => $update, $@) if $@;
170              
171 38         390 return $self;
172             }
173              
174             # Return the update type if we have a callback for it
175             # Or just return zero, if we don't
176             sub _get_update_type {
177 38     38   81 my ($self, $update) = @_;
178              
179             exists $$update{$_}
180             and return $_
181 38   100     103 for keys %{ $self -> callbacks };
  38         125  
182              
183 23         114 return 0;
184             }
185              
186             ################################################################################
187             # Webhook
188             ################################################################################
189              
190             sub set_webhook {
191 4     4 1 8879 my ($self, $config, $cb) = @_;
192              
193 4 100       12 Bot::Telegram::X::InvalidStateError
194             -> throw('Disable long polling first')
195             if $self -> is_polling;
196              
197 3 100       42 Bot::Telegram::X::InvalidArgumentsError
198             -> throw('No config provided')
199             unless ref $config;
200              
201 2 50       7 $self -> api -> api_request(
202             setWebhook => $config,
203             ref $cb eq 'CODE' ? $cb : undef);
204              
205 2         458 return $self;
206             }
207              
208             ################################################################################
209             # Long polling
210             ################################################################################
211              
212             sub start_polling {
213 26     26 1 21901 my $self = shift;
214 26         97 my $config = $self -> polling_config;
215 26 100       188 if (ref $_[0] eq 'HASH') {
216 2         4 $config = shift;
217 2   100     9 $config -> {offset} //= 0; # make sure we won't get any uninitiailzed warnings in shift_offset
218             }
219              
220 26         95 my (%opts) = @_;
221              
222 26 100       97 if ($opts{restart}) {
223 4         9 $self -> stop_polling;
224             } else {
225 22 100       84 Bot::Telegram::X::InvalidStateError
226             -> throw('Already running')
227             if $self -> is_polling;
228             }
229              
230 25   100     324 $self -> polling_config($config // { timeout => DEFAULT_POLLING_TIMEOUT, offset => 0 });
231              
232 25   100     299 $self -> _polling_interval($opts{interval} // DEFAULT_POLLING_INTERVAL);
233 25         177 $self -> _polling(1);
234 25         196 $self -> _poll;
235             }
236              
237             sub stop_polling {
238 24     24 1 14099 my $self = shift;
239              
240 24 100       149 return $self unless $self -> is_polling;
241              
242 22         224 for (my $agent = $self -> api -> agent) {
243 22         406 $self -> _polling(undef);
244              
245             # In synchronous mode, it's enough to simply clear state
246 22 100 66     258 return $self -> _polling_interval(undef)
247             unless $agent -> isa('Mojo::UserAgent')
248             and $self -> is_async;
249              
250             # In asynchronous mode, we also need to cancel existing timers
251 18         176 for (my $loop = Mojo::IOLoop -> singleton) {
252 18         100 $loop -> remove($self -> _polling_request_id);
253 18 100       1029 $loop -> remove($self -> _polling_timer)
254             if $self -> _polling_timer; # if another request is scheduled, cancel it
255             }
256              
257             # Reset state
258             $self -> _polling_request_id(undef)
259 18         449 -> _polling_interval(undef)
260             -> _polling_timer(undef);
261             }
262              
263             $self
264 18         265 }
265              
266 83     83 1 931 sub is_polling { !! shift -> _polling }
267              
268             # In asynchronous mode: process getUpdates response or handle errors, if any
269             # In synchronous mode, WWW::Telegram::BotAPI::parse_error takes care of error handling for us.
270             sub _process_getUpdates_results {
271 29     29   113 my $self = shift;
272 29         180 my $async = $self -> is_async;
273 29         199 my ($response, $error);
274              
275 29         228 $self -> log -> trace('processing getUpdates results');
276              
277 29         872 my $retry_after;
278              
279 29 100       94 if ($async) {
280 21         92 my ($ua, $tx) = @_;
281 21   100     151 $response = $tx -> res -> json // {};
282              
283             # Error
284 21 100 100     3392 if ($error = ($tx -> error or not $$response{ok})) {
285 9   100     582 my $type = eval {
286 9 100       49 return 'api' if $$response{error_code};
287 6 100       20 return 'agent' if $tx -> error;
288             } // 'unknown';
289              
290 9         184 $self -> emit(polling_error => $tx, $type);
291             }
292             } else {
293 8         25 ($response, $error) = @_;
294             # NOTE: $response and $error are mutually exclusive - only one is `defined` at a time
295              
296 8 100       30 if ($error) {
297 4         16 $error = $self -> api -> parse_error;
298              
299             # no way to access the original $tx in synchronous mode
300             # https://metacpan.org/dist/WWW-Telegram-BotAPI/source/lib/WWW/Telegram/BotAPI.pm#L228
301 4         213 $self -> emit(polling_error => { error => $error }, $$error{type});
302             }
303             }
304              
305             # Handle rate limits
306 29 100       58851 if (exists $response -> {parameters}{retry_after}) {
307 1         5 $retry_after = $response -> {parameters}{retry_after};
308 1         7 $self -> log -> info("Rate limit exceeded, waiting ${retry_after}s before polling again");
309             }
310              
311             # Process the updates we have retrieved (if any) and poll for more
312             # (unless someone or something has disabled the polling loop in the meantime)
313 29 100       204 unless ($error) {
314 16         46 for my $result ($$response{result}) {
315             # last unless ref $result eq 'ARRAY'; # nothing to process
316              
317             $self -> process_update($_)
318             -> shift_offset
319 16         95 for @$result;
320             }
321             }
322              
323 29 100       117 return unless $self -> is_polling;
324 23         184 $self -> log -> trace('still polling, scheduling another iteration...');
325              
326 23 100       345 if ($async) {
327             my $tid = Mojo::IOLoop -> timer(
328             $retry_after // $self -> _polling_interval,
329 13         191 sub { $self -> tap(sub { $self -> log -> trace("it's polling time!") })
330 19   66 13   138 -> _poll });
  13         3515133  
331              
332 19         2321 $self -> _polling_timer($tid);
333             } else {
334 4   33     23 my $d = $retry_after // $self -> _polling_interval;
335              
336             # Sleep
337 4     4   32 $self -> ioloop -> timer($d, sub { $self -> ioloop -> stop });
  4         905397  
338 4         671 $self -> ioloop -> start;
339 4         261 $self -> log -> trace("it's polling time!");
340              
341 4         241 $self -> _poll;
342             }
343             }
344              
345             sub _poll {
346 42     42   617 my $self = shift;
347              
348 42         172 $self -> log -> trace('polling');
349              
350 42 100       1036 if ($self -> is_async) {
351             my $id = $self -> api -> api_request(
352             getUpdates => $self -> polling_config,
353 21     21   2129031 sub { $self -> _process_getUpdates_results(@_) }
354 34         230 );
355              
356             # Assuming api_request always returns a valid ioloop connection ID when in asynchronous mode...
357 34         15056 $self -> _polling_request_id($id);
358             } else {
359 8         53 my $response = eval {
360 8         23 $self -> api -> api_request(
361             getUpdates => $self -> polling_config)
362             };
363              
364 8         10291 $self -> _process_getUpdates_results($response, $@);
365             }
366             }
367              
368             1
369              
370             __END__