File Coverage

blib/lib/Net/Async/Slack.pm
Criterion Covered Total %
statement 81 246 32.9
branch 0 64 0.0
condition 0 43 0.0
subroutine 27 61 44.2
pod 20 27 74.0
total 128 441 29.0


line stmt bran cond sub pod time code
1             package Net::Async::Slack;
2             # ABSTRACT: Slack realtime messaging API support for IO::Async
3              
4 1     1   374905 use strict;
  1         2  
  1         50  
5 1     1   7 use warnings;
  1         2  
  1         108  
6              
7             our $VERSION = '0.015';
8              
9 1     1   8 use parent qw(IO::Async::Notifier Net::Async::Slack::Commands);
  1         2  
  1         9  
10              
11             =head1 NAME
12              
13             Net::Async::Slack - support for the L APIs with L
14              
15             =head1 SYNOPSIS
16              
17             use IO::Async::Loop;
18             use Net::Async::Slack;
19             my $loop = IO::Async::Loop->new;
20             $loop->add(
21             my $slack = Net::Async::Slack->new(
22             token => '...',
23             )
24             );
25              
26             =head1 DESCRIPTION
27              
28             This is a basic wrapper for Slack's API. It's an early version, the module API is likely
29             to change somewhat over time.
30              
31             See the C<< examples/ >> directory for usage.
32              
33             =cut
34              
35 1     1   893 no indirect;
  1         2109  
  1         5  
36 1     1   669 use mro;
  1         894  
  1         8  
37              
38 1     1   56 use Future;
  1         2  
  1         42  
39 1     1   5 use Future::AsyncAwait;
  1         3  
  1         36  
40 1     1   634 use Dir::Self;
  1         794  
  1         8  
41 1     1   812 use URI;
  1         10158  
  1         147  
42 1     1   625 use URI::QueryParam;
  1         181  
  1         48  
43 1     1   669 use URI::Template;
  1         13205  
  1         178  
44 1     1   14 use JSON::MaybeUTF8 qw(:v1);
  1         3  
  1         204  
45 1     1   768 use Time::Moment;
  1         3513  
  1         124  
46 1     1   669 use Syntax::Keyword::Try;
  1         2057  
  1         7  
47 1     1   780 use File::ShareDir ();
  1         55067  
  1         86  
48 1     1   986 use Path::Tiny ();
  1         32598  
  1         63  
49              
50 1     1   739 use Cache::LRU;
  1         1474  
  1         98  
51              
52 1     1   691 use Ryu::Async;
  1         182134  
  1         127  
53 1     1   495 use Ryu::Observable;
  1         2063  
  1         59  
54 1     1   491 use Net::Async::WebSocket::Client;
  1         33192  
  1         100  
55              
56 1     1   10 use Log::Any qw($log);
  1         2  
  1         13  
57              
58 1     1   719 use Net::Async::OAuth::Client;
  1         29436  
  1         73  
59              
60 1     1   516 use Net::Async::Slack::RTM;
  1         5  
  1         107  
61 1     1   673 use Net::Async::Slack::Socket;
  1         5  
  1         134  
62 1     1   760 use Net::Async::Slack::Message;
  1         4  
  1         2624  
63              
64             =head1 METHODS
65              
66             =cut
67              
68             =head2 rtm
69              
70             Establishes a connection to the Slack RTM websocket API, and
71             resolves to a L instance.
72              
73             =cut
74              
75             sub rtm {
76 0     0 1   my ($self, %args) = @_;
77 0           warn "RTM is deprecated and will no longer be supported by slack.com, please use socket mode instead: https://slack.com/apis/connections/socket";
78 0           $log->tracef('Endpoint is %s', $self->endpoint(
79             'rtm_connect',
80             ));
81             $self->{rtm} //= $self->http_get(
82             uri => $self->endpoint(
83             'rtm_connect',
84             ),
85             )->then(sub {
86 0     0     my $result = shift;
87 0 0         return Future->done(URI->new($result->{url})) if exists $result->{url};
88 0           return Future->fail('invalid URL');
89             })->then(sub {
90 0     0     my ($uri) = @_;
91 0           $self->add_child(
92             my $rtm = Net::Async::Slack::RTM->new(
93             slack => $self,
94             wss_uri => $uri,
95             )
96             );
97 0           $rtm->connect->transform(done => sub { $rtm })
98 0           })
99 0   0       }
100              
101 0     0 0   async sub socket_mode {
102 0           my ($self, %args) = @_;
103 0           my ($uri) = await $self->socket;
104 0           $self->add_child(
105             my $socket = Net::Async::Slack::Socket->new(
106             slack => $self,
107             wss_uri => $uri,
108             )
109             );
110 0           await $socket->connect;
111 0           return $socket;
112             }
113              
114             =head2 send_message
115              
116             Send a message to a user or channel.
117              
118             Supports the following named parameters:
119              
120             =over 4
121              
122             =item * channel - who to send the message to, can be a channel ID or C<< #channel >> name, or user ID
123              
124             =item * text - the message, see L for details
125              
126             =item * attachments - more advanced messages, see L
127              
128             =item * parse - whether to parse content and convert things like links
129              
130             =back
131              
132             and the following named boolean parameters:
133              
134             =over 4
135              
136             =item * link_names - convert C<< @user >> and C<< #channel >> to links
137              
138             =item * unfurl_links - show preview for URLs
139              
140             =item * unfurl_media - show preview for things that look like media links
141              
142             =item * as_user - send as user
143              
144             =item * reply_broadcast - send to all users when replying to a thread
145              
146             =back
147              
148             Returns a L, although the content of the response is subject to change.
149              
150             =cut
151              
152 0     0 1   async sub send_message {
153 0           my ($self, %args) = @_;
154 0 0 0       die 'You need to pass either text or attachments' unless $args{text} || $args{attachments};
155 0           my @content;
156 0           push @content, token => $self->token;
157 0   0       push @content, channel => $args{channel} || die 'need a channel';
158 0 0         push @content, text => $args{text} if defined $args{text};
159 0 0         push @content, attachments => encode_json_text($args{attachments}) if $args{attachments};
160 0 0         push @content, blocks => encode_json_text($args{blocks}) if $args{blocks};
161 0           push @content, $_ => $args{$_} for grep exists $args{$_}, qw(parse link_names unfurl_links unfurl_media as_user reply_broadcast thread_ts);
162 0           my ($data) = await $self->http_post(
163             $self->endpoint(
164             'chat_post_message',
165             ),
166             \@content,
167             );
168 0 0         Future::Exception->throw('send failed', slack => $data) unless $data->{ok};
169             return Net::Async::Slack::Message->new(
170             slack => $self,
171             channel => $data->{channel},
172             thread_ts => $data->{ts},
173 0           );
174             }
175              
176             =head2 files_upload
177              
178             Upload file(s) to a channel or thread.
179              
180             Supports the following named parameters:
181              
182             =over 4
183              
184             =item * channel - who to send the message to, can be a channel ID or C<< #channel >> name, or user ID
185              
186             =item * text - the message, see L for details
187              
188             =item * attachments - more advanced messages, see L
189              
190             =item * parse - whether to parse content and convert things like links
191              
192             =back
193              
194             and the following named boolean parameters:
195              
196             =over 4
197              
198             =item * link_names - convert C<< @user >> and C<< #channel >> to links
199              
200             =item * unfurl_links - show preview for URLs
201              
202             =item * unfurl_media - show preview for things that look like media links
203              
204             =item * as_user - send as user
205              
206             =item * reply_broadcast - send to all users when replying to a thread
207              
208             =back
209              
210             Returns a L, although the content of the response is subject to change.
211              
212             =cut
213              
214 0     0 1   async sub files_upload {
215 0           my ($self, %args) = @_;
216 0 0 0       die 'You need to pass file name and content' unless length($args{filename} // '') and defined($args{content});
      0        
217 0           my @content;
218 0   0       push @content, channels => $args{channel} || die 'need a channel';
219 0 0         push @content, initial_comment => $args{text} if defined $args{text};
220 0           push @content, $_ => $args{$_} for grep exists $args{$_}, qw(filetype thread_ts title);
221 0           push @content, file => [ undef, $args{filename}, Content => $args{content} ];
222 0           my ($data) = await $self->http_post(
223             $self->endpoint(
224             'files_upload',
225             ),
226             \@content,
227             content_type => 'form-data',
228             );
229 0 0         Future::Exception->throw('send failed', slack => $data) unless $data->{ok};
230 0           return $data;
231             }
232              
233             =head2 conversations_info
234              
235             Provide information about a channel.
236              
237             Takes the following named parameters:
238              
239             =over 4
240              
241             =item * C - the channel ID to look up
242              
243             =back
244              
245             and returns a L which will resolve to a hashref containing
246             C<< { channel => { name => '...' } } >>.
247              
248             =cut
249              
250             sub conversations_info {
251 0     0 1   my ($self, %args) = @_;
252 0           my @content;
253 0           push @content, token => $self->token;
254 0   0       push @content, channel => $args{channel} || die 'need a channel';
255 0           return $self->http_post(
256             $self->endpoint(
257             'conversations_info',
258             ),
259             \@content,
260             )
261             }
262              
263             sub conversations_invite {
264 0     0 1   my ($self, %args) = @_;
265 0 0         my $chan = $args{channel} or die 'need a channel';
266 0 0         my @users = ref($args{users}) ? $args{users}->@* : $args{users};
267 0           return $self->http_post(
268             $self->endpoint(
269             'conversations_invite',
270             ),
271             {
272             channel => $chan,
273             users => join(',', @users),
274             }
275             )
276             }
277              
278 0     0 1   async sub users_list {
279 0           my ($self, %args) = @_;
280 0           return await $self->http_get_paged(
281             key => 'members',
282             uri => $self->endpoint(
283             'users_list',
284             %args
285             ),
286             )
287             }
288 0     0 1   async sub conversations_list {
289 0           my ($self, %args) = @_;
290 0           return await $self->http_get(
291             uri => $self->endpoint(
292             'conversations_list',
293             %args
294             ),
295             )
296             }
297 0     0 1   async sub conversations_history {
298 0           my ($self, %args) = @_;
299 0           return await $self->http_get(
300             uri => $self->endpoint(
301             'conversations_history',
302             %args
303             ),
304             )
305             }
306              
307             =head2 join_channel
308              
309             Attempt to join the given channel.
310              
311             Takes the following named parameters:
312              
313             =over 4
314              
315             =item * C - the channel ID or name to join
316              
317             =back
318              
319             =cut
320              
321             sub join_channel {
322 0     0 1   my ($self, %args) = @_;
323 0 0         die 'You need to pass a channel name' unless $args{channel};
324 0           my @content;
325 0           push @content, token => $self->token;
326 0           push @content, channel => $args{channel};
327 0           $self->http_post(
328             $self->endpoint(
329             'conversations_join',
330             ),
331             \@content,
332             )
333             }
334              
335 0     0 1   async sub users_profile_get {
336 0           my ($self, %args) = @_;
337 0           return await $self->http_get(
338             uri => $self->endpoint(
339             'users_profile_get',
340             %args
341             ),
342             )
343             }
344              
345 0     0 1   async sub workflows_update_step {
346 0           my ($self, %args) = @_;
347 0           return await $self->http_post(
348             $self->endpoint(
349             'workflows_update_step',
350             ),
351             \%args,
352             )
353             }
354              
355             =head1 METHODS - Internal
356              
357             =head2 endpoints
358              
359             Returns the hashref of API endpoints, loading them on first call from the C file.
360              
361             =cut
362              
363             sub endpoints {
364 0     0 1   my ($self) = @_;
365 0   0       $self->{endpoints} ||= do {
366 0           my $path = Path::Tiny::path(__DIR__)->parent(3)->child('share/endpoints.json');
367 0 0         $path = Path::Tiny::path(
368             File::ShareDir::dist_file(
369             'Net-Async-Slack',
370             'endpoints.json'
371             )
372             ) unless $path->exists;
373 0           $log->tracef('Loading endpoints from %s', $path);
374 0           decode_json_text($path->slurp_utf8)
375             };
376             }
377              
378 0     0 0   sub slack_host { shift->{slack_host} }
379              
380             =head2 endpoint
381              
382             Processes the given endpoint as a template, using the named parameters
383             passed to the method.
384              
385             =cut
386              
387             sub endpoint {
388 0     0 1   my ($self, $endpoint, %args) = @_;
389 0           my $uri = URI::Template->new($self->endpoints->{$endpoint})->process(%args);
390 0 0         $uri->host($self->slack_host) if $self->slack_host;
391 0           $uri
392             }
393              
394             sub oauth {
395 0     0 0   my ($self) = @_;
396 0   0       $self->{oauth} //= Net::Async::OAuth::Client->new(
397             realm => 'Slack',
398             consumer_key => $self->key,
399             consumer_secret => $self->secret,
400             token => $self->token,
401             token_secret => $self->token_secret,
402             )
403             }
404              
405 0     0 1   sub client_id { shift->{client_id} }
406              
407             =head2 oauth_request
408              
409             =cut
410              
411             sub oauth_request {
412 1     1   649 use Bytes::Random::Secure qw(random_string_from);
  1         11136  
  1         108  
413 1     1   25 use namespace::clean qw(random_string_from);
  1         2  
  1         13  
414 0     0 1   my ($self, $code, %args) = @_;
415              
416 0           my $state = random_string_from('abcdefghjklmnpqrstvwxyz0123456789', 32);
417              
418 0           my $uri = $self->endpoint(
419             'oauth',
420             client_id => $self->client_id,
421             scope => 'bot,channels:write',
422             state => $state,
423             %args,
424             );
425 0           $log->debugf("OAuth URI endpoint is %s", "$uri");
426             $code->($uri)->then(sub {
427 0     0     Future->done;
428             })
429 0           }
430              
431             =head2 token
432              
433             API token.
434              
435             =cut
436              
437 0     0 1   sub token { shift->{token} }
438              
439 0     0 0   sub app_token { shift->{app_token} }
440              
441             =head2 http
442              
443             Returns the HTTP instance used for communicating with the API.
444              
445             Currently autocreates a L instance.
446              
447             =cut
448              
449             sub http {
450 0     0 1   my ($self) = @_;
451 0   0       $self->{http} ||= do {
452 0           require Net::Async::HTTP;
453 0           $self->add_child(
454             my $ua = Net::Async::HTTP->new(
455             fail_on_error => 1,
456             close_after_request => 0,
457             max_connections_per_host => 4,
458             pipeline => 1,
459             max_in_flight => 8,
460             decode_content => 1,
461             timeout => 30,
462             user_agent => 'Mozilla/4.0 (perl; https://metacpan.org/pod/Net::Async::Slack; TEAM@cpan.org)',
463             )
464             );
465 0           $ua
466             }
467             }
468              
469             =head2 http_get
470              
471             Issues an HTTP GET request.
472              
473             =cut
474              
475             sub http_get {
476 0     0 1   my ($self, @args) = @_;
477 0 0         my %args = (@args == 1) ? (uri => @args) : @args;
478              
479 0           my $uri = delete $args{uri};
480 0           $log->tracef("GET %s { %s }", "$uri", \%args);
481 0   0       $args{headers} ||= $self->auth_headers;
482             $self->http->GET(
483             $uri,
484             %args
485             )->then(sub {
486 0     0     my ($resp) = @_;
487 0 0         return { } if $resp->code == 204;
488 0 0         return { } if 3 == ($resp->code / 100);
489             try {
490             $log->tracef('HTTP response for %s was %s', "$uri", $resp->as_string("\n"));
491             return Future->done(decode_json_utf8($resp->content))
492 0           } catch {
493             $log->errorf("JSON decoding error %s from HTTP response %s", $@, $resp->as_string("\n"));
494             return Future->fail($@ => json => $resp);
495             }
496             })->else(sub {
497 0     0     my ($err, $src, $resp, $req) = @_;
498 0   0       $src //= '';
499 0 0         if($src eq 'http') {
500 0           $log->errorf("HTTP error %s, request was %s with response %s", $err, $req->as_string("\n"), $resp->as_string("\n"));
501             } else {
502 0   0       $log->errorf("Other failure (%s): %s", $src // 'unknown', $err);
503             }
504 0           Future->fail(@_);
505             })
506 0           }
507              
508 0     0 0   async sub http_get_paged {
509 0           my ($self, %args) = @_;
510             my $key = delete $args{key}
511 0 0         or die 'need a hash key to find the results array in the response';
512 0           my $uri = delete $args{uri};
513 0 0         $uri = URI->new($uri) unless ref($uri);
514 0 0         $uri->query_param(limit => 500) unless $uri->query_param('limit');
515 0           my $data;
516             my $found;
517 0           my $offset;
518 0   0       do {
519 0           my $res = await $self->http_get(uri => $uri, %args);
520 0 0         die $res unless $res->{ok};
521 0           $offset = $res->{offset};
522 0           $uri->query_param(offset => $offset);
523 0           $found = 0 + $res->{$key}->@*;
524 0 0         if($data) {
525 0           push $data->@*, $res->{$key}->@*;
526             } else {
527 0           $data = $res->{$key};
528             }
529             } while $found and $offset;
530 0           return $data;
531             }
532              
533             sub auth_headers {
534 0     0 0   my ($self) = @_;
535 0 0         return {} unless $self->token;
536             return {
537 0           Authorization => 'Bearer ' . $self->token
538             }
539             }
540              
541             =head2 http_post
542              
543             Issues an HTTP POST request.
544              
545             =cut
546              
547             sub http_post {
548 0     0 1   my ($self, $uri, $content, %args) = @_;
549              
550 0           $log->tracef("POST %s { %s } <= %s", "$uri", \%args, $content);
551              
552 0   0       $args{headers} ||= $self->auth_headers;
553 0 0         if(ref $content eq 'HASH') {
554 0           $content = encode_json_utf8($content);
555 0           $args{content_type} = 'application/json; charset=utf-8';
556             }
557             $self->http->POST(
558             $uri,
559             $content,
560             %args,
561             )->then(sub {
562 0     0     my ($resp) = @_;
563 0 0         return { } if $resp->code == 204;
564 0 0         return { } if 3 == ($resp->code / 100);
565             try {
566             $log->tracef('HTTP response for %s was %s', "$uri", $resp->as_string("\n"));
567             return Future->done(decode_json_utf8($resp->content))
568 0           } catch {
569             $log->errorf("JSON decoding error %s from HTTP response %s", $@, $resp->as_string("\n"));
570             return Future->fail($@ => json => $resp);
571             }
572             })->else(sub {
573 0     0     my ($err, $src, $resp, $req) = @_;
574 0   0       $src //= '';
575 0 0         if($src eq 'http') {
576 0           $log->errorf("HTTP error %s, request was %s with response %s", $err, $req->as_string("\n"), $resp->as_string("\n"));
577             } else {
578 0   0       $log->errorf("Other failure (%s): %s", $src // 'unknown', $err);
579             }
580 0           Future->fail(@_);
581             })
582 0           }
583              
584 0     0 0   async sub socket {
585 0           my ($self) = @_;
586 0           my $target_uri = do {
587 0 0         my $uri = $self->endpoint(
588             'apps_connections_open',
589             ) or die 'no endpoint';
590 0           my $res = await $self->http_post(
591             $uri,
592             [ ],
593             headers => {
594             Authorization => 'Bearer ' . $self->app_token
595             }
596             );
597 0 0         die 'failed to obtain socket-mode URL' unless $res->{ok};
598 0           URI->new($res->{url});
599             };
600 0 0         $target_uri->query_param(debug_reconnects => 'true') if $self->{debug};
601 0           return $target_uri;
602             }
603              
604             sub configure {
605 0     0 1   my ($self, %args) = @_;
606 0           for my $k (qw(client_id token app_token slack_host debug)) {
607 0 0         $self->{$k} = delete $args{$k} if exists $args{$k};
608             }
609 0           $self->next::method(%args);
610             }
611              
612             1;
613              
614             =head1 SEE ALSO
615              
616             =over 4
617              
618             =item * L - low-level API wrapper around RTM
619              
620             =item * L - another RTM-specific wrapper, this time based on Mojolicious
621              
622             =item * L - more RTM support, this time via LWP and a subprocess/thread for handling the websocket part
623              
624             =item * L - Furl-based wrapper around the REST API
625              
626             =item * L - another AnyEvent RTM implementation
627              
628             =back
629              
630             =head1 AUTHOR
631              
632             Tom Molesworth
633              
634             =head1 LICENSE
635              
636             Copyright Tom Molesworth 2016-2024. Licensed under the same terms as Perl itself.
637