File Coverage

blib/lib/AnyEvent/SlackBot.pm
Criterion Covered Total %
statement 43 183 23.5
branch 0 56 0.0
condition 0 6 0.0
subroutine 15 28 53.5
pod 8 10 80.0
total 66 283 23.3


line stmt bran cond sub pod time code
1             package AnyEvent::SlackBot;
2              
3 1     1   465 use Modern::Perl;
  1         2  
  1         5  
4 1     1   397 use Moo;
  1         7293  
  1         4  
5 1     1   1228 use MooX::Types::MooseLike::Base qw(:all);
  1         4882  
  1         238  
6 1     1   273 use namespace::clean;
  1         7438  
  1         5  
7 1     1   929 use Data::Dumper;
  1         4621  
  1         48  
8 1     1   385 use LWP::UserAgent;
  1         30195  
  1         28  
9 1     1   6 use HTTP::Request;
  1         3  
  1         21  
10 1     1   4 use URI::Escape;
  1         2  
  1         47  
11 1     1   287 use HTTP::Request::Common qw(POST);
  1         1653  
  1         75  
12 1     1   348 use AnyEvent::HTTP::MultiGet;
  1         114026  
  1         26  
13 1     1   315 use AnyEvent::WebSocket::Client;
  1         136283  
  1         28  
14 1     1   402 use JSON;
  1         5354  
  1         4  
15 1     1   94 use namespace::clean;
  1         27  
  1         8  
16             our $VERSION='1.0002';
17              
18             BEGIN {
19 1     1   724 no namespace::clean;
  1         2  
  1         4  
20 1     1   371 with 'Log::LogMethods','Data::Result::Moo';
21             }
22            
23              
24             =head1 NAME
25              
26             AnyEvent::SlackBot - AnyEvent Driven Slack Bot Interface
27              
28             =head1 SYNOPSIS
29              
30             use Modern::Perl;
31             use Data::Dumper;
32             use AnyEvent::SlackBot;
33             use AnyEvent::Loop;
34              
35             $|=1;
36             my $sb=AnyEvent::SlackBot->new(
37             on_event=>sub {
38             my ($sb,$json,$conn_data)=@_;
39             if(exists $json->{type} and $json->{type} eq 'desktop_notification') {
40             my $ref={
41             type=>'message',
42             bot_id=>$sb->bot_id,
43             channel=>$json->{channel},
44             text=>'this is a test',
45             subtype=>'bot_message',
46             };
47             print Dumper($json,$ref);
48             $sb->send($ref);
49             }
50             },
51             );
52              
53             my $result=$sb->connect_and_run;
54             die $result unless $result;
55             AnyEvent::Loop::run;
56              
57             =head1 DESCRIPTION
58              
59             Slack client. Handles Ping Pong on idle conntions, and transparrently reconnects as needed. The guts of the module wrap AnyEvent::WebSocket::Client, keeping the code base very light.
60              
61             =head1 OO Arguments and accessors
62              
63             Required Arguments
64              
65             on_event: code refrence for handling events
66             sub { my ($self,$connection,$message,$startup_info)=@_ }
67              
68             Optional Arguments
69              
70             on_idle: code refrence for use in idle time
71             sub { my ($self)=@_ }
72              
73             on_reply: code refrence called when the server responds to a post
74             sub { my ($self,$json,$connection_data)=@_ }
75              
76             agent: Sets the AnyEvent::HTTP::MultiGet Object
77             logger: sets the logging object, DOES( Log::Log4perl::Logger )
78             rtm_start_url: the url used to fetch the websockets connection from
79             token: the authentication token used by rtm_start_url
80             auto_reconnect: if true ( default ) reconnects when a connection fails
81             unknown_que: array ref of objects that may be repeats from us
82              
83             Set at Run time
84              
85             connection: The connection object
86             bot_id: The Bot ID defined at runtime
87             stats: An anonyous hash ref of useful stats
88              
89             =cut
90              
91             has unknown_que=>(
92             is=>'ro',
93             isa=>ArrayRef,
94             default=>sub { [] },
95             required=>1,
96             );
97              
98             has on_reply=>(
99             is=>'ro',
100             isa=>CodeRef,
101             default=>sub { sub {} },
102             required=>1,
103             );
104              
105             has agent=>(
106             is=>'ro',
107             isa=>Object,
108             default=>sub { new AnyEvent::HTTP::MultiGet() },
109             );
110              
111             has rtm_start_url=>(
112             is=>'ro',
113             isa=>Str,
114             required=>1,
115             default=>'https://slack.com/api/rtm.start',
116             );
117              
118             has on_idle=>(
119             is=>'ro',
120             isa=>CodeRef,
121             required=>1,
122             default=>sub { sub {} }
123             );
124              
125             has token=>(
126             is=>'ro',
127             isa=>Str,
128             required=>1,
129             );
130              
131             has stats=>(
132             is=>'ro',
133             isa=>HashRef,
134             required=>1,
135             default=>sub { {} },
136             );
137              
138             has on_event=>(
139             is=>'ro',
140             isa=>CodeRef,
141             requried=>1,
142             );
143              
144             has auto_reconnect=>(
145             is=>'rw',
146             isa=>Bool,
147             required=>1,
148             default=>1,
149             );
150              
151             has connection=>(
152             is=>'rw',
153             isa=>Object,
154             required=>0,
155             );
156              
157             has bot_id=>(
158             is=>'rw',
159             isa=>Str,
160             required=>0,
161             );
162              
163             has keep_alive_timeout =>(
164             is=>'ro',
165             isa=>Int,
166             requried=>1,
167             default=>15,
168             );
169              
170             # This method runs after the new constructor
171             sub BUILD {
172 0     0 0   my ($self)=@_;
173              
174 0           $self->{backlog}=[];
175 0           $self->{ignore}={};
176 0           $self->stats->{service_started_on}=time;
177 0           $self->stats->{running_posts}=0;
178             }
179              
180             # this method runs before the new constructor, and can be used to change the arguments passed to the module
181             around BUILDARGS => sub {
182             my ($org,$class,@args)=@_;
183            
184             return $class->$org(@args);
185             };
186              
187             =head1 OO Methods
188              
189             =over 4
190              
191             =item * $self->connect_and_run
192              
193             COnnects and starts running
194              
195             =cut
196              
197             sub connect_and_run {
198 0     0 1   my ($self)=@_;
199 0           my $request=POST $self->rtm_start_url,[token=>$self->token];
200 0           my $ua=LWP::UserAgent->new;
201 0           my $response=$ua->request($request);
202 0           $self->{timer}=undef;
203 0 0         if($response->code==200) {
204 0           my $data=eval { from_json($response->decoded_content) };
  0            
205 0 0         if($@) {
206 0           return $self->new_false("Failed to decode response, error was: $@");
207             }
208 0 0 0       unless(exists $data->{url} and $data->{self}) {
209 0 0         my $msg=exists $data->{error} ? $data->{error} : 'unknown slack error';
210 0           return $self->new_false("Failed to get valid connection info, error was: $msg");
211             }
212              
213 0           $self->build_connection($data);
214             } else {
215 0           return $self->new_false("Failed to get conenction info from slack, error was: ".$response->status_line);
216             }
217             }
218              
219             =item * my $id=$self->next_id
220              
221             Provides an id for the next message.
222              
223             =cut
224              
225             sub next_id {
226 0     0 1   my ($self)=@_;
227             return ++$self->{next_id}
228 0           }
229              
230             =item * if($self->is_connected) { ... }
231              
232             Denotes if we are currently connected to slack
233              
234             =cut
235              
236             sub is_connected {
237 0     0 0   return defined($_[0]->connection)
238             }
239              
240             =item * $self->send($ref)
241              
242             Converts $ref to json and sends it on the session.
243              
244             =cut
245              
246             sub send {
247 0     0 1   my ($self,$ref)=@_;
248 0           my $json=to_json($ref);
249 0 0         if($self->connection) {
250 0           $self->connection->send($json);
251 0           ++$self->stats->{total_messages_sent};
252             } else {
253 0           push @{$self->{backlog}},$json;
  0            
254             }
255             }
256              
257             =item * $self->send_typing($json)
258              
259             Given $json sends a currently typing reply
260              
261             =cut
262              
263             sub send_typing {
264 0     0 1   my ($self,$json)=@_;
265 0           my $id=$self->next_id;
266             my $msg={
267             bot_id=>$self->bot_id,
268             channel=>$json->{channel},
269 0           id=>$id,
270             type=>'typing',
271             };
272 0           $self->send($msg);
273             }
274              
275             =item * $self->post_to_web($msg,$endpoint|undef,"FORM"|"JSON"|undef)
276              
277             Posts the to the given REST Endpoint outside of the WebSocket.
278              
279             msg:
280             Hash ref representing the requrest being sent
281             token: set to $self->token if not set
282             scope: set to: 'chat:write:bot' if not set
283              
284             endpoint:
285             The Rest xxx endpint, the default is 'chat.postMessage'
286              
287             type:
288             Sets how the data will be sent over
289             Supported options are:
290             - FORM: posts the data using form encoding
291             - JSON: converts $msg to a json string and posts
292              
293             =cut
294              
295             sub post_to_web {
296 0     0 1   my ($self,$msg,$endpoint,$type)=@_;
297 0 0         $endpoint='chat.postMessage' unless defined($endpoint);
298 0           $type='FORM';
299              
300 0           $self->stats->{running_posts}++;
301 0           my $url="https://slack.com/api/$endpoint";
302              
303              
304 0 0         $msg->{token}=$self->token unless exists $msg->{token};
305 0 0         $msg->{scope}='chat:write:bot' unless exists $msg->{scope};
306              
307 0           my $request;
308              
309 0 0         if($type eq 'FORM') {
310 0           $request=POST $url,[%{$msg}];
  0            
311             } else {
312 0           $request=POST $url,'Conent-Type'=>'application/json',Content=>to_json($msg);
313             }
314              
315             $self->agent->add_cb($request,sub {
316 0     0     my ($agent,$request,$response)=@_;
317 0           ++$self->stats->{http_posts_sent};
318 0           $self->stats->{running_posts}--;
319 0 0         if($response->code!=200) {
320 0           $self->log_error("Failed to send Message,error was: ".$response->status_line) ;
321             } else {
322 0           my $json=eval { from_json($response->decoded_content) };
  0            
323 0 0         if($@) {
324 0           $self->log_error("Failed to parse json response, error was: $@")
325             } else {
326 0           $self->{ignore}->{$json->{ts}}++;
327 0 0         $self->log_error("Slack Responded with an eror: $json->{error}".Dumper($json)) unless $json->{ok};
328             }
329             }
330              
331 0 0         if($self->stats->{running_posts}==0) {
332             # some times we get a response from the websocet before
333             # the http request completes
334              
335 0           BACKLOG: while(my $args=shift @{$self->unknown_que}) {
  0            
336 0           my (undef,$ref,$data)=@{$args};
  0            
337 0           $self->log_info("processing backlog event");
338              
339 0 0         next if $self->we_sent_msg($ref);
340              
341 0           $self->on_event->($self,$ref,$data);
342             }
343             }
344 0           });
345 0           $self->agent->run_next;
346             }
347              
348             =item * if($self->we_sent_msg($json,$connection_data)) { ... }
349              
350             When true, $json is a duplicate from something we sent
351              
352             =cut
353              
354             sub we_sent_msg {
355 0     0 1   my ($self,$ref,$data)=@_;
356 0 0         if(exists $ref->{msg}) {
    0          
357 0           my $sent=delete $self->{ignore}->{$ref->{msg}};
358 0 0         if(defined($sent)) {
359 0           $self->info("This is a message we sent");
360 0           $self->on_reply->($self,$ref,$data);
361 0           return 1;;
362             }
363             } elsif(exists $ref->{reply_to}) {
364 0           $self->info("This is a message we sent");
365 0           $self->on_reply->($self,$ref,$data);
366 0           return 1;
367             } else {
368 0           $self->debug(Dumper($ref));
369             }
370 0           return 0;
371             }
372              
373             =item * $self->build_connection($connection_details)
374              
375             Internal Method used for buiding connections.
376              
377             =cut
378              
379             sub build_connection {
380 0     0 1   my ($self,$data)=@_;
381 0           my $url=$data->{url};
382 0           $self->bot_id($data->{self}->{id});
383              
384 0           my $client=AnyEvent::WebSocket::Client->new;
385             $client->connect($url)->cb(sub {
386 0     0     my $connection=eval { shift->recv };
  0            
387 0           $self->connection($connection);
388              
389 0 0         if($@) {
390 0           $self->log_error("Failed to cnnect to our web socket, error was: $@");
391 0           return $self->handle_reconnect;
392             }
393 0           $self->stats->{last_connected_on}=time;
394 0           $self->stats->{total_connections}++;
395 0           $self->stats->{last_msg_on}=time;
396             $self->{timer}=AnyEvent->timer(
397             interval=>$self->keep_alive_timeout,
398             after=>$self->keep_alive_timeout,
399             cb=>sub {
400 0           my $max_timeout=$self->stats->{last_msg_on} + 3 * $self->keep_alive_timeout;
401 0 0         if(time < $max_timeout) {
402 0 0         if(time > $self->stats->{last_msg_on} + $self->keep_alive_timeout) {
403 0           $self->log_info("sending keep alive to server");
404 0           $connection->send(to_json({
405             id=>$self->next_id,
406             type=>'ping',
407             timestamp=>time,
408             }));
409 0           %{$self->{ignore}}=();
  0            
410 0           $self->on_idle->($self);
411 0           $self->stats->{last_idle_on}=time;
412             }
413             } else {
414 0           return $self->handle_reconnect;
415             }
416             }
417 0           );
418              
419             $self->connection->on(finish=>sub {
420 0           return $self->handle_reconnect;
421 0           });
422             $self->connection->on(each_message=> sub {
423 0           my ($connection,$message)=@_;
424 0           $self->stats->{last_msg_on}=time;
425 0           $self->stats->{total_messages_recived}++;
426 0 0         if($message->is_text) {
427 0           my $ref=eval { from_json($message->body) };
  0            
428 0 0         if($@) {
429 0           $self->log_error("Failed to parse json body, error was: $@");
430 0           return $self->handle_reconnect;
431             }
432 0 0 0       if(exists $ref->{type} and $ref->{type} eq 'pong') {
433 0           $self->log_info("got keep alive response from server");
434             } else {
435 0 0         if($self->stats->{running_posts}!=0) {
436             # Don't try to handle unknown commands while we are waiting on a post to go out!
437 0           push @{$self->unknown_que},[$self,$ref,$data];
  0            
438 0           $self->log_info("HTTP Post response pending.. will hold off on responding to commands until we know if we sent it or not");
439 0           return;
440             } else {
441 0 0         return if $self->we_sent_msg($ref,$data);
442 0           $self->log_info("real time response");
443 0           $self->debug('Inbound message: ',Dumper($ref));
444 0           $self->on_event->($self,$ref,$data);
445             }
446             }
447             }
448 0           });
449              
450 0           });
451              
452             }
453              
454             =item * $self->handle_reconnect
455              
456             Internal method used to reconnect.
457              
458             =cut
459              
460             sub handle_reconnect {
461 0     0 1   my ($self)=@_;
462 0 0         $self->connection->close if $self->connection;
463 0           $self->{connection}=undef;
464 0 0         if($self->auto_reconnect) {
465 0           my $result=$self->connect_and_run;
466 0 0         if($result) {
467 0           $self->log_info("auto reconnected without an error, flushing backlog of outbound messages");
468 0           while(my $msg=shift @{$self->{backlog}}) {
  0            
469 0           $self->send($msg);
470             }
471             } else {
472 0           $self->log_error("Failed to reconnect will try again in 15 seconds, error was: $result");
473             $self->{timer}=AnyEvent->timer(
474             interval=>$self->keep_alive_timeout,
475             after=>$self->keep_alive_timeout,
476 0     0     cb=>sub { $self->handle_reconnect },
477 0           );
478             }
479             }
480             }
481              
482             =back
483              
484             =head1 See Also
485              
486             The slack api documentation - L<https://api.slack.com/rtm>
487              
488             The AnyEvent WebSocket Client library - L<AnyEvent::WebSocket::Client>
489              
490             The AnyEvent HTTP Client library - L<AnyEvent::HTTP::MultiGet>
491              
492             =head1 AUTHOR
493              
494             Michael Shipper L<mailto:AKALINUX@CPAN.ORG>
495              
496             =cut
497              
498             1;