File Coverage

blib/lib/Bot/Backbone/Service/SlackChat.pm
Criterion Covered Total %
statement 23 159 14.4
branch 0 68 0.0
condition 0 3 0.0
subroutine 8 37 21.6
pod 11 13 84.6
total 42 280 15.0


line stmt bran cond sub pod time code
1             package Bot::Backbone::Service::SlackChat;
2             $Bot::Backbone::Service::SlackChat::VERSION = '0.161950';
3 1     1   498 use v5.14;
  1         3  
4 1     1   394 use Bot::Backbone::Service;
  1         849492  
  1         4  
5              
6             with qw(
7             Bot::Backbone::Service::Role::Service
8             Bot::Backbone::Service::Role::Dispatch
9             Bot::Backbone::Service::Role::BareMetalChat
10             Bot::Backbone::Service::Role::GroupJoiner
11             );
12              
13 1     1   6523 use Bot::Backbone::Message;
  1         239616  
  1         37  
14 1     1   7 use Carp;
  1         1  
  1         59  
15 1     1   661 use CHI;
  1         50362  
  1         28  
16 1     1   488 use Encode;
  1         6696  
  1         64  
17 1     1   407 use AnyEvent::SlackRTM;
  1         162437  
  1         28  
18 1     1   456 use WebService::Slack::WebApi;
  1         20256  
  1         1600  
19              
20             # ABSTRACT: Connect and chat with a Slack server
21              
22              
23             has token => (
24                 is => 'ro',
25                 isa => 'Str',
26                 required => 1,
27             );
28              
29              
30             has on_channel_joined => (
31                 is => 'ro',
32                 isa => 'CodeRef',
33                 predicate => 'has_channel_joined_callback',
34             );
35              
36             has _seen_channels => (
37                 is => 'ro',
38                 isa => 'HashRef',
39                 required => 1,
40                 default => sub { {} },
41             );
42              
43              
44             # To avoid Slack rate limiting
45             has cache => (
46                 is => 'ro',
47                 required => 1,
48                 lazy => 1,
49                 builder => '_build_cache',
50             );
51              
52             sub _build_cache {
53 0     0         CHI->new(
54                     driver => 'Memory',
55                     datastore => {},
56                     expires_in => 60, # let's not bother to cache for long
57                 );
58             }
59              
60             # This is kind of kludgey. It needs work. Not documenting it in the POD for now
61             # because it is very likely to change.
62             has last_mark => (
63                 is => 'rw',
64                 isa => 'Int',
65                 required => 1,
66                 default => 0,
67             );
68              
69              
70             has api => (
71                 is => 'ro',
72                 isa => 'WebService::Slack::WebApi',
73                 lazy => 1,
74                 builder => '_build_api',
75             );
76              
77             sub _build_api {
78 0     0         my $self = shift;
79 0               WebService::Slack::WebApi->new(token => $self->token);
80             }
81              
82              
83             has rtm => (
84                 is => 'ro',
85                 isa => 'AnyEvent::SlackRTM',
86                 lazy => 1,
87                 builder => '_build_rtm',
88             );
89              
90             sub _build_rtm {
91 0     0         my $self = shift;
92 0               AnyEvent::SlackRTM->new($self->token);
93             }
94              
95              
96             has error_callback => (
97                 is => 'ro',
98                 isa => 'CodeRef',
99                 lazy => 1,
100                 builder => '_build_error_callback',
101             );
102              
103             sub _build_error_callback {
104                 return sub {
105 0     0             my ($self, $rtm, $message) = @_;
106 0                   carp "Slack Error #$message->{error}{code}: $message->{error}{msg}\n";
107                 }
108 0     0     }
109              
110              
111             has whoami => (
112                 is => 'rw',
113                 isa => 'HashRef',
114                 required => 1,
115                 lazy => 1,
116                 builder => '_build_whoami',
117             );
118              
119             sub _build_whoami {
120 0     0         my $self = shift;
121 0               my $res = $self->api->auth->test;
122              
123 0 0             if ($res->{ok}) {
124 0                   $res;
125                 }
126                 else {
127 0                   croak "unable to ask Slack who am I?";
128                 }
129             }
130              
131              
132 0     0 1   sub user { shift->whoami->{user} }
133 0     0 1   sub user_id { shift->whoami->{user_id} }
134 0     0 1   sub team_id { shift->whoami->{team_id} }
135              
136              
137             sub _when_channel_joined {
138 0     0         my ($self, $channel) = @_;
139              
140 0 0             return unless $self->has_channel_joined_callback;
141              
142 0               my $id = $channel->{id};
143              
144 0 0             next if $self->_seen_channels->{ $id };
145              
146 0               $self->on_channel_joined->($self, $channel->{id}, $channel->{name}, '');
147 0               $self->_seen_channels->{ $id }++;
148              
149 0               $self->bot->construct_services;
150 0               $self->bot->initialize_services;
151             }
152              
153             sub _check_channels_joined {
154 0     0         my ($self) = @_;
155              
156 0 0             return unless $self->has_channel_joined_callback;
157              
158 0               my @mine;
159              
160                 my $channels = $self->_cached('api.channels.list', sub {
161 0     0             $self->api->channels->list
162 0               });
163              
164 0 0             if ($channels->{ok}) {
165 0 0                 push @mine, grep { $_->{is_member} && !$_->{is_archived} } @{ $channels->{channels} };
  0            
  0            
166                 }
167              
168                 my $groups = $self->_cached('api.groups.list', sub {
169 0     0             $self->api->groups->list
170 0               });
171              
172 0 0             if ($groups->{ok}) {
173 0                   push @mine, grep { !$_->{is_archived} } @{ $groups->{groups} };
  0            
  0            
174                 }
175              
176 0               for my $channel (@mine) {
177 0                   my $id = $channel->{id};
178              
179 0 0                 next if $self->_seen_channels->{ $id };
180              
181 0                   $self->on_channel_joined->($self, $channel->{id}, $channel->{name}, 1);
182 0                   $self->_seen_channels->{ $id }++;
183                 }
184              
185 0               $self->bot->construct_services;
186 0               $self->bot->initialize_services;
187             }
188              
189             sub initialize {
190                 my $self = shift;
191              
192                 $self->_check_channels_joined;
193              
194                 $self->rtm->on(
195                     message => sub { $self->got_message(@_) },
196                     error => sub { $self->error_callback->($self, @_) },
197                     channel_joined => sub { $self->_when_channel_joined($_[1]{channel}) },
198                     group_joined => sub { $self->_when_channel_joined($_[1]{channel}) },
199                 );
200              
201                 $self->rtm->quiet(1);
202              
203                 $self->rtm->start;
204             }
205              
206              
207             sub _cached {
208 0     0         my ($self, $key, $code) = @_;
209              
210 0               my $cached = $self->cache->get($key);
211 0 0             return $cached if $cached;
212              
213 0               my $value = $code->();
214 0               $self->cache->set($key, $value);
215 0               return $value;
216             }
217              
218             sub load_user {
219 0     0 1       my ($self, $by, $value) = @_;
220              
221 0               my $user;
222 0 0             if ($by eq 'id') {
    0          
223                     my $res = $self->_cached("api.users.info:user=$value", sub {
224 0     0                     $self->api->users->info(user => $value);
225 0                       });
226 0 0                 $user = $res->{user} if $res->{ok};
227                 }
228                 elsif ($by eq 'name') {
229 0     0             my $list = $self->_cached("api.users.list", sub { $self->api->users->list });
  0            
230 0 0                 if ($list->{ok}) {
231 0                       ($user) = grep { $_->{name} eq $value } @{ $list->{members} };
  0            
  0            
232                     }
233                 }
234                 else {
235 0                   croak "unknown lookup type $by";
236                 }
237              
238 0 0             if (defined $user) {
239                     return Bot::Backbone::Identity->new(
240                         username => $user->{id},
241                         nickname => $user->{name},
242 0                       me => ($user->{id} eq $self->user_id),
243                     );
244                 }
245                 else {
246 0                   croak "unknown user $by $value";
247                 }
248             }
249              
250              
251             sub load_me {
252 0     0 1       my $self = shift;
253 0               return $self->load_user(id => $self->user_id);
254             }
255              
256              
257             sub load_user_channel {
258 0     0 1       my ($self, $by, $value) = @_;
259              
260 0 0 0           croak "unknown lookup type $by" unless $by eq 'user' or $by eq 'id';
261              
262 0     0         my $list = $self->_cached("api.im.list", sub { $self->api->im->list });
  0            
263              
264 0 0             croak "unknown IM $by $value" unless $list->{ok};
265              
266 0               my ($im) = grep { $_->{ $by } eq $value } @{ $list->{members} };
  0            
  0            
267 0               return $im->{id};
268             }
269              
270             # Initially, I thought this method would be necessary. Now I'm thinking
271             # it's completely unnecessary. As such, I don't want to document it for now.
272             sub load_channel {
273 0     0 0       my ($self, $by, $value) = @_;
274              
275             # It really has to be by ID since we collapse group/channel notions
276 0 0             croak "unknown lookup type $by" unless $by eq 'id';
277              
278 0               my $group;
279 0               my $type = substr $value, 0, 1;
280 0 0             if ($type eq 'G') {
    0          
281                     my $res = $self->_cached("api.groups.info:group=$value", sub {
282 0     0                 $self->api->groups->info( channel => $value )
283 0                   });
284 0 0                 $group = $res->{group} if $res->{ok};
285                 }
286                 elsif ($type eq 'C') {
287                     my $res = $self->_cached("api.channels.info:channel=$value", sub {
288 0     0                 $self->api->channels->info( channel => $value )
289 0                   });
290 0 0                 $group = $res->{channel} if $res->{ok};
291                 }
292                 else {
293 0                   croak "unknown group type $type";
294                 }
295              
296 0 0             if (defined $group) {
297 0                   return $group->{id};
298                 }
299                 else {
300 0                   croak "cannot find group $by $value";
301                 }
302             }
303              
304              
305             sub join_group {
306 0     0 1       my ($self, $options) = @_;
307              
308 0               my $type = substr $options->{group}, 0, 1;
309              
310 0 0             if ($type eq 'G') {
    0          
311 0                   $self->api->groups->open(channel => $options->{group});
312                 }
313                 elsif ($type eq 'C') {
314 0                   $self->api->channels->join(name => $options->{group});
315                 }
316                 else {
317 0                   croak "unknown group type $type";
318                 }
319             }
320              
321              
322             sub got_message {
323 0     0 1       my ($self, $rtm, $slack_msg) = @_;
324              
325             # Mark every message as read as it comes
326 0               $self->mark_read($slack_msg);
327              
328             # Ignore messages with a subtype
329 0 0             return if defined $slack_msg->{subtype};
330              
331             # Ignore message edits
332 0 0             return if defined $slack_msg->{edited};
333              
334             # We need to determine the channel type
335 0               my $channel_type = substr $slack_msg->{channel}, 0, 1;
336              
337             # IDs for Slack identify type by starting char:
338             #
339             # D - IM channel
340             # G - Private Group channel
341             # C - Team channel
342             #
343              
344 0 0             if ($channel_type eq 'D') {
345 0                   $self->got_direct_message($slack_msg);
346                 }
347                 else {
348 0                   $self->got_group_message($slack_msg);
349                 }
350             }
351              
352              
353             sub got_direct_message {
354 0     0 1       my ($self, $slack_msg) = @_;
355              
356             # Ignore messages from ourself
357 0 0             return if $slack_msg->{user} eq $self->whoami->{user_id};
358              
359                 my $message = Bot::Backbone::Message->new({
360                         chat => $self,
361                         from => $self->load_user(id => $slack_msg->{user}),
362                         to => $self->load_user(id => $self->user_id),
363                         group => undef,
364                         text => $slack_msg->{text},
365 0                   });
366              
367 0               $self->resend_message($message);
368 0               $self->dispatch_message($message);
369             }
370              
371              
372             sub is_to_me {
373 0     0 1       my ($self, $me_user, $text) = @_;
374              
375 0               my $me_nick = $me_user->nickname;
376 0               return scalar($$text =~ s/^ @?$me_nick \s* [:,\-] \s*
377             | \s* , \s* @?$me_nick [.!?]? $
378             | , \s* @?$me_nick \s* ,
379             //x);
380             }
381              
382             # Not sure I like how this works yet. Leaving out of the docs for now.
383             sub mark_read {
384 0     0 0       my ($self, $slack_msg) = @_;
385              
386             # Don't really mark more than every 15 seconds
387 0 0             return unless time - $self->last_mark > 15;
388              
389 0               my $channel = $slack_msg->{channel};
390 0               my $ts = $slack_msg->{ts};
391              
392 0               my $type = substr $channel, 0, 1;
393 0 0             if ($type eq 'C') {
    0          
    0          
394 0                   $self->api->channels->mark( channel => $channel, ts => $ts );
395                 }
396                 elsif ($type eq 'G') {
397 0                   $self->api->groups->mark( channel => $channel, ts => $ts );
398                 }
399                 elsif ($type eq 'D') {
400 0                   $self->api->im->mark( channel => $channel, ts => $ts );
401                 }
402              
403 0               $self->last_mark(time);
404             }
405              
406              
407             sub got_group_message {
408 0     0 1       my ($self, $slack_msg) = @_;
409              
410             # Ignore messages from ourself
411 0 0             return if $slack_msg->{user} eq $self->whoami->{user_id};
412              
413 0               my $me_user = $self->load_me;
414              
415 0               my $text = $slack_msg->{text};
416 0               my $to_identity;
417 0 0             if ($self->is_to_me($me_user, \$text)) {
418 0                   $to_identity = $me_user;
419                 }
420              
421                 my $message = Bot::Backbone::Message->new({
422                         chat => $self,
423                         from => $self->load_user(id => $slack_msg->{user}),
424                         to => $to_identity,
425 0                       group => $self->load_channel( id => $slack_msg->{channel} ),
426                         text => $text,
427                     });
428              
429 0               $self->resend_message($message);
430 0               $self->dispatch_message($message);
431             }
432              
433              
434             sub send_message {
435                 my ($self, $params) = @_;
436              
437                 my $to = $params->{to};
438                 my $group = $params->{group};
439                 my $text = $params->{text};
440                 my $attachments = $params->{attachments};
441              
442                 my $channel;
443                 if (defined $group) {
444                     $channel = $self->load_channel( id => $group );
445                 }
446                 else {
447                     $channel = $self->load_user_channel( user => $to );
448                 }
449              
450                 my %message_opts = (
451                     channel => $channel,
452                     as_user => 1,
453                 );
454                 if (defined $text) {
455                     $message_opts{text} = encode('utf8', $text);
456                 }
457                 if (defined $attachments) {
458                     $message_opts{attachments} = $attachments;
459                 }
460              
461                 $self->api->chat->post_message(%message_opts);
462             }
463              
464              
465             __PACKAGE__->meta->make_immutable;
466              
467             __END__
468            
469             =pod
470            
471             =encoding UTF-8
472            
473             =head1 NAME
474            
475             Bot::Backbone::Service::SlackChat - Connect and chat with a Slack server
476            
477             =head1 VERSION
478            
479             version 0.161950
480            
481             =head1 SYNOPSIS
482            
483             package MyBot;
484             use Bot::Backbone;
485            
486             service slack_chat => (
487             service => 'SlackChat',
488             token => '...', # see slack.com for your tokens
489             );
490            
491             service dice => (
492             service => 'OFun::Dice',
493             );
494            
495             service "general_chat" => (
496             service => 'GroupChat',
497             chat => 'SlackChat',
498             group => 'C',
499             dispatcher => 'general_dispatch',
500             );
501            
502             dispatcher 'general_dispatch' => as {
503             redispatch_to "dice";
504             };
505            
506             __PACKAGE__->new->run;
507            
508             =head1 DESCRIPTION
509            
510             This allows a L<Bot::Backbone> chat bot to be connect to a Slack server using their Real-Time Messaging API.
511            
512             This is based on L<AnyEvent::SlackRTM> and L<WebService::Slack::WebApi>. It also uses a L<CHI> cache to help avoid contacting the Slack server too often, which could result in your bot becoming rate limited.
513            
514             =head1 ATTRIBUTES
515            
516             =head2 token
517            
518             The C<token> is the access token from Slack to use. This may be either of the following type of tokens:
519            
520             =over
521            
522             =item *
523            
524             L<User Token|https://api.slack.com/tokens>. This is a token to perform actions on behalf of a user account.
525            
526             =item *
527            
528             L<Bot Token|https://slack.com/services/new/bot>. If you configure a bot integration, you may use the access token on the bot configuration page to use this library to act on behalf of the bot account. Bot accounts may not have the same features as a user account, so please be sure to read the Slack documentation to understand any differences or limitations.
529            
530             =back
531            
532             Which you use will depend on whether you want the bot to control a user account or a bot integration account. You are responsible for adhering to the Slack terms of use in whatever you do.
533            
534             =head2 on_channel_joined
535            
536             This may be set to a subroutine to call whenever the bot is invited to join a channel. This allows the bot to be configured to handle channels as it is invited to them.
537            
538             For example:
539            
540             service slack_chat => (
541             service => 'SlackChat',
542             token => '...', # see slack.com for your tokens
543             on_channel_joined => sub {
544             my ($slack, $channel, $name, $during_init) = @_;
545             service "group_$name" => (
546             service => 'GroupChat',
547             chat => 'slack_chat',
548             group => $channel,
549             dispatcher => 'general',
550             );
551             },
552             );
553            
554             The called subroutine will be passed this object (from which you can make API calls via C<< $slack->api >>), the Slack ID of the newly joined channel, the human name of the newly joined channel, and the "during init" flag. The boolean flag sent as the third argument is set to true if the callback is being called while the SlackChat service is being initialized. If the flag is false, this indicates that it is happening in reaction to the bot receiving a "channel_joined" message while running.
555            
556             =head2 cache
557            
558             This is a L<CHI> cache to use to temporarily store response from the Slack APIs. By default, this is a memory-only cache that caches data for only 60 seconds. The purpose is mainly to prevent repeated requests to the API, which might result in rate limiting.
559            
560             =head2 api
561            
562             This is the L<WebService::Slack::WebApi> object used to contact Slack for information about channels, users, etc.
563            
564             =head2 rtm
565            
566             This is the L<AnyEvent::SlackRTM> object used to communicate with Slack and trigger events from the Real-Time Messaging API.
567            
568             =head2 error_callback
569            
570             This is a callback sub that may be used to report error events from the RTM API. Set it to a sub that will be called as follows:
571            
572             sub {
573             my ($self, $rtm, $message) = @_;
574            
575             ...
576             }
577            
578             Here, C<$self> is the L<Bot::Backbone::Service::SlackChat> object, C<$rtm> is the L<AnyEvent::SlackRTM> object, and C<$message> is a hash containing the error message, as described on the L<Real Time Messaging API|https://api.slack.com/rtm> documentation.
579            
580             =head2 whoami
581            
582             This returns a hash containing information about who the bot is.
583            
584             =head1 METHODS
585            
586             =head2 user
587            
588             Returns the name of the bot.
589            
590             =head2 user_id
591            
592             Returns the user ID for the bot.
593            
594             =head2 team_id
595            
596             Returns the team ID for the team account.
597            
598             =head2 initialize
599            
600             This connects to Slack and prepares the bot for communication.
601            
602             =head2 load_user
603            
604             method load_user($by, $value) returns Bot::Backbone::Identity
605            
606             Fetches information about a user from Slack and returns the user as a L<Bot::Backbone::Identity>. The C<$by> setting determines how the user is looked up, which may either be by "id" or by "name". The value, then, is the value to check.
607            
608             =head2 load_me
609            
610             method load_me() returns Bot::Backbone::Identity
611            
612             Returns the identity object for the bot itself.
613            
614             =head2 load_user_channel
615            
616             method load_user_channel($by, $value) returns Str
617            
618             Returns the ID of a user's IM channel. Here C<$by> may be "user" to lookup by user ID.
619            
620             =head2 join_group
621            
622             method join_group({ group => $group })
623            
624             Given the ID of a channel or group, this causes the bot to open or join it. Note that Slack bot integration accounts might not be able to join team channels, but may still be invited.
625            
626             B<CAVEAT:> Slack does not permit bots to join groups, so this method call will be a no-op for bot users. This will only work if this code is operating a regular user account.
627            
628             =head2 got_message
629            
630             Handles messages from Slack. Decides whether they are group messages or direct and forwards them on as appropriate. Messages with a "subtype" will be ignored as will messages that are "edited".
631            
632             This method also marks messages as read.
633            
634             =head2 got_direct_message
635            
636             Handles direct messages received from an IM channel.
637            
638             =head2 is_to_me
639            
640             This determines whether or not the message is to the bot.
641            
642             =head2 got_group_message
643            
644             This handles message received from private group or team channels.
645            
646             =head2 send_message
647            
648             method send_message({
649             to => $user_id,
650             group => $group_id,
651             text => $message,
652             attachments => $attachments,
653             })
654            
655             This sends a message to a Slack channel. To the named user's IM channel or to a private group or team channel named by C<$group_id>. Attachments can be included to produce formatted messages.
656            
657             L<Slack Message Attachment API|https://api.slack.com/docs/attachments>
658            
659             =for Pod::Coverage load_channel
660             mark_read
661            
662             =head1 AUTHOR
663            
664             Andrew Sterling Hanenkamp <hanenkamp@cpan.org>
665            
666             =head1 COPYRIGHT AND LICENSE
667            
668             This software is copyright (c) 2016 by Qubling Software LLC.
669            
670             This is free software; you can redistribute it and/or modify it under
671             the same terms as the Perl 5 programming language system itself.
672            
673             =cut
674