File Coverage

blib/lib/AnyEvent/SlackBot.pm
Criterion Covered Total %
statement 46 187 24.6
branch 0 56 0.0
condition 0 6 0.0
subroutine 16 29 55.1
pod 8 10 80.0
total 70 288 24.3


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