File Coverage

blib/lib/AnyEvent/Mattermost.pm
Criterion Covered Total %
statement 27 129 20.9
branch 0 40 0.0
condition 0 56 0.0
subroutine 9 31 29.0
pod 7 7 100.0
total 43 263 16.3


line stmt bran cond sub pod time code
1 2     2   25375 use strict;
  2         2  
  2         58  
2 2     2   8 use warnings;
  2         3  
  2         113  
3             package AnyEvent::Mattermost;
4             $AnyEvent::Mattermost::VERSION = '0.002';
5             # ABSTRACT: AnyEvent module for interacting with Mattermost APIs
6              
7             =pod
8              
9             =encoding UTF-8
10              
11             =head1 NAME
12              
13             AnyEvent::Mattermost - AnyEvent module for interacting with the Mattermost APIs
14              
15             =cut
16              
17 2     2   1095 use AnyEvent;
  2         4697  
  2         74  
18 2     2   1135 use AnyEvent::WebSocket::Client 0.37;
  2         395762  
  2         93  
19 2     2   20 use Carp;
  2         13  
  2         142  
20 2     2   1213 use Furl;
  2         46573  
  2         71  
21 2     2   793 use JSON;
  2         11462  
  2         18  
22 2     2   405 use Time::HiRes qw( time );
  2         4  
  2         20  
23 2     2   261 use Try::Tiny;
  2         5  
  2         4118  
24              
25             =head1 SYNOPSIS
26              
27             use AnyEvent;
28             use AnyEvent::Mattermost;
29              
30             my $host = "https://mattermost.example.com/";
31             my $team = "awesome-chat";
32             my $user = "janedoe@example.com";
33             my $pass = "foobar123";
34              
35             my $cond = AnyEvent->condvar;
36             my $mconn = AnyEvent::Mattermost->new($host, $team, $user, $pass);
37              
38             $mconn->on('posted' => sub {
39             my ($self, $message) = @_;
40             printf "<%s> %s\n", $message->{data}{sender_name}, $message->{data}{post}";
41             });
42              
43             $mconn->start;
44             AnyEvent->condvar->recv;
45              
46             =head1 DESCRIPTION
47              
48             This module provides an L based interface to Mattermost chat servers
49             using the Web Service API.
50              
51             It is very heavily inspired by L and I owe a debt of
52             gratitude to Andrew Hanenkamp for his work on that module.
53              
54             This library is still very basic and currently attempts to implement little
55             beyond authentication and simple message receiving and sending. Feature parity
56             with SlackRTM support is a definite goal, and then beyond that it would be nice
57             to support all the stable Mattermost API features. Baby steps.
58              
59             =head1 METHODS
60              
61             =cut
62              
63             =head2 new
64              
65             $mconn = AnyEvent::Mattermost->new( $host, $team, $email, $password );
66              
67             Creates a new AnyEvent::Mattermost object. No connections are opened and no
68             callbacks are registered yet.
69              
70             The C<$host> parameter must be the HTTP/HTTPS URL of your Mattermost server. If
71             you omit the scheme and provide only a hostname, HTTPS will be assumed. Note
72             that Mattermost servers configured over HTTP will also use unencrypted C
73             for the persistent WebSockets connection for receiving incoming messages. You
74             should use HTTPS unless there is no other choice.
75              
76             C<$team> must be the Mattermost team's short name (the version which appears in
77             the URLs when connected through the web client).
78              
79             C<$email> must be the email address of the account to be used for logging into
80             the Mattermost server. The short username is not supported for logins via the
81             Mattermost APIs, only the email address.
82              
83             C<$password> is hopefully self-explanatory.
84              
85             =cut
86              
87             sub new {
88 0     0 1   my ($class, $host, $team, $user, $pass) = @_;
89              
90 0 0 0       croak "must provide a Mattermost server address"
91             unless defined $host && length($host) > 0;
92 0 0 0       croak "must provide a Mattermost team name"
93             unless defined $team && length($team) > 0;
94 0 0 0       croak "must provide a login email and password"
      0        
      0        
95             unless defined $user && defined $pass && length($user) > 0 && length($pass) > 0;
96              
97 0 0         $host = "https://$host" unless $host =~ m{^https?://}i;
98 0 0         $host .= '/' unless substr($host, -1, 1) eq '/';
99              
100 0           return bless {
101             furl => Furl->new( agent => "AnyEvent::Mattermost" ),
102             host => $host,
103             team => $team,
104             user => $user,
105             pass => $pass,
106             registry => {},
107             channels => {},
108             }, $class;
109             }
110              
111             =head2 start
112              
113             $mconn->start();
114              
115             Opens the connection to the Mattermost server, authenticates the previously
116             provided account credentials and performs an initial data request for user,
117             team, and channel information.
118              
119             Any errors encountered will croak() and the connection will be aborted.
120              
121             =cut
122              
123             sub start {
124 0     0 1   my ($self) = @_;
125              
126             my $data = $self->_post('api/v3/users/login', {
127             name => $self->{'team'},
128             login_id => $self->{'user'},
129 0           password => $self->{'pass'},
130             });
131              
132 0 0         croak "could not log in" unless exists $self->{'token'};
133              
134 0           my $userdata = $self->_get('api/v3/users/initial_load');
135              
136             croak "did not receive valid initial_load user data"
137             unless exists $userdata->{'user'}
138             && ref($userdata->{'user'}) eq 'HASH'
139 0 0 0       && exists $userdata->{'user'}{'id'};
      0        
140              
141             croak "did not receive valid initial_load teams data"
142             unless exists $userdata->{'teams'}
143             && ref($userdata->{'teams'}) eq 'ARRAY'
144 0 0 0       && grep { $_->{'name'} eq $self->{'team'} } @{$userdata->{'teams'}};
  0   0        
  0            
145              
146 0           $self->{'userdata'} = $userdata->{'user'};
147 0           $self->{'teamdata'} = (grep { $_->{'name'} eq $self->{'team'} } @{$userdata->{'teams'}})[0];
  0            
  0            
148              
149 0           my $wss_url = $self->{'host'} . 'api/v3/users/websocket';
150 0           $wss_url =~ s{^http(s)?}{ws$1}i;
151              
152 0           $self->{'client'} = AnyEvent::WebSocket::Client->new(
153             http_headers => $self->_headers
154             );
155              
156             $self->{'client'}->connect($wss_url)->cb(sub {
157 0     0     my $client = shift;
158              
159             my $conn = try {
160 0           $client->recv;
161             }
162             catch {
163 0           die $_;
164 0           };
165              
166 0           $self->{'started'}++;
167 0           $self->{'conn'} = $conn;
168              
169 0           $conn->on(each_message => sub { $self->_handle_incoming(@_) });
  0            
170 0           });
171             }
172              
173             =head2 stop
174              
175             $mconn->stop();
176              
177             Closes connection with Mattermost server and ceases processing messages.
178             Callbacks which have been registered are left in place in case you wish to
179             start() the connection again.
180              
181             If you wish to remove callbacks, without disposing of the AnyEvent::Mattermost
182             object itself, you will need to call on() and pass C for each events'
183             callback value (rather than the anonymous subroutines you had provided when
184             registering them).
185              
186             =cut
187              
188             sub stop {
189 0     0 1   my ($self) = @_;
190              
191 0           $self->{'conn'}->close;
192             }
193              
194             =head2 on
195              
196             $mconn->on( $event1 => sub {}, [ $event2 => sub {}, ... ] );
197              
198             Registers a callback for the named event type. Multiple events may be registered
199             in a single call to on(), but only one callback may exist for any given event
200             type. Any subsequent callbacks registered to an existing event handler will
201             overwrite the previous callback.
202              
203             Every callback will receive two arguments: the AnyEvent::Mattermost object and
204             the raw message data received over the Mattermost WebSockets connection. This
205             message payload will take different forms depending on the type of event which
206             occurred, but the top-level data structure is always a hash reference with at
207             least the key C (with a value matching that which you used to register
208             the callback). Most event types include a C key, whose value is a hash
209             reference containing the payload of the event. For channel messages this will
210             include things like the sender's name, the channel name and type, and of course
211             the message itself.
212              
213             For more explanation of event types, hope that the Mattermost project documents
214             them at some point. For now, L based callbacks are your best bet.
215              
216             =cut
217              
218             sub on {
219 0     0 1   my ($self, %registrations) = @_;
220              
221 0           foreach my $type (keys %registrations) {
222 0           my $cb = $registrations{$type};
223 0           $self->{'registry'}{$type} = $cb;
224             }
225             }
226              
227             =head2 send
228              
229             $mconn->send( \%message );
230              
231             Posts a message to the Mattermost server. This method is currently fairly
232             limited and supports only providing a channel name and a message body. There
233             are formatting, attachment, and other features that are planned to be
234             supported in future releases.
235              
236             The C<\%message> hash reference should contain at bare minimum two keys:
237              
238             =over 4
239              
240             =item * channel
241              
242             The name of the channel to which the message should be posted. This may be
243             either the short name (which appears in URLs in the web UI) or the display
244             name (which may contain spaces). In the case of conflicts, the display name
245             takes precedence, on the theory that it is the most enduser-visible name of
246             channels and thus the least surprising.
247              
248             =item * message
249              
250             The body of the message to be posted. This may include any markup options that
251             are supported by Mattermost, which includes a subset of the Markdown language
252             among other things.
253              
254             =back
255              
256             To announce your presence to the default Mattermost channel (Town Square, using
257             its short name), you might call the method like this:
258              
259             $mconn->send({ channel => "town-square", message => "Hey everybody!" });
260              
261             =cut
262              
263             sub send {
264 0     0 1   my ($self, $data) = @_;
265              
266 0 0         croak "cannot send message because connection has not yet started"
267             unless $self->started;
268              
269 0 0 0       croak "send payload must be a hashref"
270             unless defined $data && ref($data) eq 'HASH';
271             croak "message must be a string of greater than zero bytes"
272 0 0 0       unless exists $data->{'message'} && !ref($data->{'message'}) && length($data->{'message'}) > 0;
      0        
273             croak "message must have a destination channel"
274 0 0 0       unless exists $data->{'channel'} && length($data->{'channel'}) > 0;
275              
276 0           my $team_id = $self->{'teamdata'}{'id'};
277 0           my $user_id = $self->{'userdata'}{'id'};
278 0           my $channel_id = $self->_get_channel_id($data->{'channel'});
279              
280 0           my $create_at = int(time() * 1000);
281              
282             my $res = $self->_post('api/v3/teams/' . $team_id . '/channels/' . $channel_id . '/posts/create', {
283             user_id => $user_id,
284             channel_id => $channel_id,
285 0           message => $data->{'message'},
286             create_at => $create_at+0,
287             filenames => [],
288             pending_post_id => $user_id . ':' . $create_at,
289             });
290             }
291              
292              
293             =head1 INTERNAL METHODS
294              
295             The following methods are not intended to be used by code outside this module,
296             and their signatures (even their very existence) are not guaranteed to remain
297             stable between versions. However, if you're the adventurous type ...
298              
299             =cut
300              
301             =head2 ping
302              
303             $mconn->ping();
304              
305             Pings the Mattermost server over the WebSocket connection to maintain online
306             status and ensure the connection remains alive. You should not have to call
307             this method yourself, as start() sets up a ping callback on a timer for you.
308              
309             =cut
310              
311             sub ping {
312 0     0 1   my ($self) = @_;
313              
314 0           $self->{'conn'}->send("ping");
315             }
316              
317             =head2 started
318              
319             $mconn->started();
320              
321             Returns a boolean status indicating whether the Mattermost WebSockets API
322             connection has started yet.
323              
324             =cut
325              
326             sub started {
327 0     0 1   my ($self) = @_;
328              
329 0   0       return $self->{'started'} // 0;
330             }
331              
332             =head1 LIMITATIONS
333              
334             =over 4
335              
336             =item * Only basic message sending and receiving is currently supported.
337              
338             =back
339              
340             =head1 CONTRIBUTING
341              
342             If you would like to contribute to this module, report bugs, or request new
343             features, please visit the module's official GitHub project:
344              
345             L
346              
347             =head1 AUTHOR
348              
349             Jon Sime
350              
351             =head1 COPYRIGHT AND LICENSE
352              
353             This software is copyright (c) 2016 by Jon Sime.
354              
355             This is free software; you can redistribute it and/or modify it under
356             the same terms as the Perl 5 programming language system itself.
357              
358             =cut
359              
360             sub _do {
361 0     0     my ($self, $type, @args) = @_;
362              
363 0 0         if (defined $self->{'registry'}{$type}) {
364 0           $self->{'registry'}{$type}->($self, @args);
365             }
366             }
367              
368             sub _handle_incoming {
369 0     0     my ($self, $conn, $raw) = @_;
370              
371             my $msg = try {
372 0     0     decode_json($raw->body);
373             }
374             catch {
375 0     0     my $message = $raw->body;
376 0           croak "unable to decode incoming message: $message";
377 0           };
378              
379 0 0         if ($msg->{'event'} eq 'hello') {
380 0           $self->{'hello'}++;
381 0           $self->_do($msg->{'event'}, $msg);
382             } else {
383 0           $self->_do($msg->{'event'}, $msg);
384             }
385             }
386              
387             sub _get_channel_id {
388 0     0     my ($self, $channel_name) = @_;
389              
390 0 0         unless (exists $self->{'channels'}{$channel_name}) {
391 0           my $data = $self->_get('api/v3/teams/' . $self->{'teamdata'}{'id'} . '/channels/');
392              
393             croak "no channels returned"
394             unless defined $data && ref($data) eq 'HASH'
395 0 0 0       && exists $data->{'channels'} && ref($data->{'channels'}) eq 'ARRAY';
      0        
      0        
396              
397 0           foreach my $channel (@{$data->{'channels'}}) {
  0            
398             next unless ref($channel) eq 'HASH'
399 0 0 0       && exists $channel->{'id'} && exists $channel->{'name'};
      0        
400              
401 0           $self->{'channels'}{$channel->{'name'}} = $channel->{'id'};
402 0           $self->{'channels'}{$channel->{'display_name'}} = $channel->{'id'};
403             }
404              
405             # Ensure that we got the channel we were looking for.
406             croak "channel $channel_name was not found"
407 0 0         unless exists $self->{'channels'}{$channel_name};
408             }
409              
410 0           return $self->{'channels'}{$channel_name};
411             }
412              
413             sub _get {
414 0     0     my ($self, $path) = @_;
415              
416 0           my $furl = $self->{'furl'};
417 0           my $res = $furl->get($self->{'host'} . $path, $self->_headers);
418              
419             my $data = try {
420 0     0     decode_json($res->content);
421             } catch {
422 0     0     my $status = $res->status;
423 0           my $message = $res->content;
424 0           croak "unable to call $path: $status $message";
425 0           };
426              
427 0           return $data;
428             }
429              
430             sub _post {
431 0     0     my ($self, $path, $postdata) = @_;
432              
433 0           my $furl = $self->{'furl'};
434              
435             my $res = try {
436 0     0     $furl->post($self->{'host'} . $path, $self->_headers, encode_json($postdata));
437             } catch {
438 0     0     croak "unable to post to mattermost api: $_";
439 0           };
440              
441             # Check for session token and update if it was present in response.
442 0 0         if (my $token = $res->header('Token')) {
443 0           $self->{'token'} = $token;
444             }
445              
446             my $data = try {
447 0     0     decode_json($res->content);
448             } catch {
449 0     0     my $status = $res->status;
450 0           my $message = $res->content;
451 0           croak "unable to call $path: $status $message";
452 0           };
453              
454 0           return $data;
455             }
456              
457             sub _headers {
458 0     0     my ($self) = @_;
459              
460 0           my $headers = [
461             'Content-Type' => 'application/json',
462             'X-Requested-With' => 'XMLHttpRequest',
463             ];
464              
465             # initial_load is fine with just the Cookie, other endpoints like channels/
466             # require Authorization. We'll just always include both to be sure.
467 0 0         if (exists $self->{'token'}) {
468 0           push(@{$headers},
469             'Cookie' => 'MMAUTHTOKEN=' . $self->{'token'},
470 0           'Authorization' => 'Bearer ' . $self->{'token'},
471             );
472             }
473              
474 0           return $headers;
475             }
476              
477             1;