blib/lib/WebService/Mattermost/V4/Client.pm | |||
---|---|---|---|
Criterion | Covered | Total | % |
statement | 55 | 137 | 40.1 |
branch | 4 | 30 | 13.3 |
condition | 2 | 17 | 11.7 |
subroutine | 16 | 27 | 59.2 |
pod | 0 | 3 | 0.0 |
total | 77 | 214 | 35.9 |
line | stmt | bran | cond | sub | pod | time | code |
---|---|---|---|---|---|---|---|
1 | package WebService::Mattermost::V4::Client; 2: 3: # ABSTRACT: Perl websocket client for Mattermost. 4: 5: use Encode 'encode'; 6: use Mojo::IOLoop; 7: use Mojo::JSON qw(decode_json encode_json); 8: use Moo; 9: use MooX::HandlesVia; 10: use Types::Standard qw(ArrayRef Bool InstanceOf Int Maybe Str); 11: 12: extends 'WebService::Mattermost'; 13: with qw( 14: WebService::Mattermost::Role::Logger 15: WebService::Mattermost::Role::UserAgent 16: Role::EventEmitter 17: ); 18: 19: ################################################################################ 20: 21: has _events => (is => 'ro', isa => ArrayRef, lazy => 1, builder => 1); 22: has _ua => (is => 'rw', isa => InstanceOf['Mojo::UserAgent'], lazy => 1, builder => 1); 23: has ioloop => (is => 'rw', isa => InstanceOf['Mojo::IOLoop'], lazy => 1, builder => 1); 24: has websocket_url => (is => 'ro', isa => Str, lazy => 1, builder => 1); 25: 26: has ws => (is => 'rw', isa => Maybe[InstanceOf['Mojo::Base']]); 27: 28: has debug => (is => 'ro', isa => Bool, default => 0); 29: has ignore_self => (is => 'ro', isa => Bool, default => 1); 30: has ping_interval => (is => 'ro', isa => Int, default => 15); 31: has reconnection_wait_time => (is => 'ro', isa => Int, default => 2); 32: has reauthentication_interval => (is => 'ro', isa => Int, default => 3600); 33: 34: has last_seq => (is => 'rw', isa => Int, default => 1, 35: handles_via => 'Number', 36: handles => { 37: inc_last_seq => 'add', 38: }); 39: 40: has loops => (is => 'rw', isa => ArrayRef[InstanceOf['Mojo::IOLoop']], default => sub { [] }, 41: handles_via => 'Array', 42: handles => { 43: add_loop => 'push', 44: clear_loops => 'clear', 45: }); 46: 47: ################################################################################ 48: 49: sub BUILD { 50: my $self = shift; 51: 52: $self->authenticate(1); 53: $self->next::method(@_); 54: 55: # Set up expected subroutines for a child class to catch. The events can 56: # also be caught raw in a script. 57: foreach my $emission (@{$self->_events}) { 58: # Values from events must be set up in child class 59: if ($self->can($emission)) { 60: $self->on($emission, sub { shift; $self->$emission(@_) }); 61: } 62: } 63: 64: return 1; 65: } 66: 67: sub start { 68: my $self = shift; 69: 70: $self->_connect(); 71: $self->ioloop->start unless $self->ioloop->is_running(); 72: 73: return; 74: } 75: 76: sub message_has_content { 77: my $self = shift; 78: my $args = shift; 79: 80: return $args->{post_data} && $args->{post_data}->{message}; 81: } 82: 83: ################################################################################ 84: 85: sub _connect { 86: my $self = shift; 87: 88: $self->_ua->on(start => sub { $self->_on_start(@_) }); 89: 90: $self->_ua->websocket($self->websocket_url => sub { 91: my ($ua, $tx) = @_; 92: 93: $self->ws($tx); 94: 95: unless ($tx->is_websocket) { 96: $self->logger->fatal('WebSocket handshake failed'); 97: } 98: 99: $self->emit(gw_ws_started => {}); 100: 101: $self->logger->debug('Adding ping loop'); 102: 103: $self->add_loop($self->ioloop->recurring(15 => sub { $self->_ping($tx) })); 104: $self->add_loop($self->ioloop->recurring($self->reauthentication_interval => sub { $self->_reauthenticate() })); 105: 106: $tx->on(error => sub { $self->_on_error(@_) }); 107: $tx->on(finish => sub { $self->_on_finish(@_) }); 108: $tx->on(message => sub { $self->_on_message(@_) }); 109: }); 110: 111: return 1; 112: } 113: 114: sub _ping { 115: my $self = shift; 116: my $tx = shift; 117: 118: if ($self->debug) { 119: $self->logger->debugf('[Seq: %d] Sending ping', $self->last_seq); 120: } 121: 122: return $tx->send(encode_json({ 123: seq => $self->last_seq, 124: action => 'ping', 125: })); 126: } 127: 128: sub _on_start { 129: my $self = shift; 130: my $ua = shift; 131: my $tx = shift; 132: 133: if ($self->debug) { 134: $self->logger->debugf('UserAgent connected to %s', $tx->req->url->to_string); 135: $self->logger->debugf('Auth token: %s', $self->auth_token); 136: } 137: 138: # The methods here are from the UserAgent role 139: $tx->req->headers->header('Cookie' => $self->mmauthtoken($self->auth_token)); 140: $tx->req->headers->header('Authorization' => $self->bearer($self->auth_token)); 141: $tx->req->headers->header('Keep-Alive' => 1); 142: 143: return 1; 144: } 145: 146: sub _on_finish { 147: my $self = shift; 148: my $tx = shift; 149: my $code = shift; 150: my $reason = shift || 'Unknown'; 151: 152: $self->logger->infof('WebSocket connection closed: [%d] %s', $code, $reason); 153: $self->logger->infof('Reconnecting in %d seconds...', $self->reconnection_wait_time); 154: 155: $self->ws->finish; 156: $self->emit(gw_ws_finished => { code => $code, reason => $reason }); 157: 158: # Delay the reconnection a little 159: Mojo::IOLoop->timer($self->reconnection_wait_time => sub { 160: return $self->_reconnect(); 161: }); 162: } 163: 164: sub _on_message { 165: my $self = shift; 166: my $tx = shift; 167: my $input = shift; 168: 169: return unless $input; 170: 171: my $message = decode_json(encode('utf8', $input)); 172: 173: if ($message->{seq}) { 174: $self->logger->debugf('[Seq: %d]', $message->{seq}) if $self->debug; 175: $self->last_seq($message->{seq}); 176: } 177: 178: return $self->_on_non_event($message) unless $message && $message->{event}; 179: 180: my $message_args = { message => $message }; 181: 182: if ($message->{data}->{post}) { 183: my $post_data = decode_json(encode('utf8', $message->{data}->{post})); 184: 185: # Early return if the message is from the bot's own user ID (to halt 186: # recursion) 187: return if $self->ignore_self && $post_data->{user_id} eq $self->user_id; 188: 189: $message_args->{post_data} = $post_data; 190: } 191: 192: $self->emit(gw_message => $message_args); 193: 194: if ($message->{event} eq 'hello') { 195: if ($self->debug) { 196: $self->logger->debug('Received "hello" event, sending authentication challenge'); 197: } 198: 199: $tx->send(encode_json({ 200: seq => 1, 201: action => 'authentication_challenge', 202: data => { token => $self->auth_token }, 203: })); 204: } 205: 206: return 1; 207: } 208: 209: sub _on_non_event { 210: my $self = shift; 211: my $message = shift; 212: 213: if ($self->debug && $message->{data} && $message->{data}->{text}) { 214: $self->logger->debugf('[Seq: %d] Received %s', $self->last_seq, $message->{data}->{text}); 215: } 216: 217: return $self->emit(gw_message_no_event => $message); 218: } 219: 220: sub _on_error { 221: my $self = shift; 222: my $ws = shift; 223: my $message = shift; 224: 225: $self->emit(gw_ws_error => { message => $message }); 226: 227: return $ws->finish($message); 228: } 229: 230: sub _reauthenticate { 231: my $self = shift; 232: 233: # Mattermost authentication tokens expire after a given (and unknown) amount 234: # of time. By default, the client will reconnect every hour in order to 235: # refresh the token. 236: $self->authenticate(1); 237: $self->_try_authentication(); 238: 239: return 1; 240: } 241: 242: sub _reconnect { 243: my $self = shift; 244: 245: # Reset things which have been altered during the course of the last 246: # connection 247: $self->last_seq(1); 248: $self->_try_authentication(); 249: $self->_clean_up_loops(); 250: $self->ws(undef); 251: $self->_ua($self->_build__ua); 252: 253: return $self->_connect(); 254: } 255: 256: sub _clean_up_loops { 257: my $self = shift; 258: 259: foreach my $loop (@{$self->loops}) { 260: $self->ioloop->remove($loop); 261: } 262: 263: return $self->clear_loops(); 264: } 265: 266: ################################################################################ 267: 268: sub _build__events { 269: return [ qw( 270: gw_ws_error 271: gw_ws_finished 272: gw_ws_started 273: gw_message 274: gw_message_no_event 275: ) ]; 276: } 277: 278: sub _build__ua { Mojo::UserAgent->new } 279: 280: sub _build_ioloop { Mojo::IOLoop->singleton } 281: 282: sub _build_websocket_url { 283: my $self = shift; 284: 285: # Convert the API URL to the WebSocket URL 286: my $ws_url = $self->base_url; 287: 288: if ($ws_url !~ /\/$/) { 289: $ws_url .= '/'; 290: } 291: 292: $ws_url .= 'websocket'; 293: $ws_url =~ s/^http(?:s)?/wss/s; 294: 295: return $ws_url; 296: } 297: 298: ################################################################################ 299: 300: 1; 301: 302: __END__ 303: 304: =pod 305: 306: =encoding UTF-8 307: 308: =head1 NAME 309: 310: WebService::Mattermost::V4::Client - Perl websocket client for Mattermost. 311: 312: =head1 VERSION 313: 314: version 0.28 315: 316: =head1 DESCRIPTION 317: 318: This class connects to Mattermost via the WebSocket gateway and can either be 319: extended in a child class, or used in a script. 320: 321: =head2 USAGE 322: 323: =head3 FROM A SCRIPT 324: 325: use WebService::Mattermost::V4::Client; 326: 327: my $bot = WebService::Mattermost::V4::Client->new({ 328: username => 'usernamehere', 329: password => 'password', 330: base_url => 'https://mattermost.server.com/api/v4/', 331: 332: # Optional arguments 333: debug => 1, # Show extra connection information 334: ignore_self => 0, # May cause recursion! 335: }); 336: 337: $bot->on(message => sub { 338: my ($bot, $args) = @_; 339: 340: # $args contains the decoded message content 341: }); 342: 343: $bot->start(); # Add me last 344: 345: =head3 EXTENSION 346: 347: See L<WebService::Mattermost::V4::Example::Bot>. 348: 349: =head2 EVENTS 350: 351: Events are either available to be caught with C<on> in scripts, or have methods 352: which can be overridden in child classes. 353: 354: =over 4 355: 356: =item C<gw_ws_started> 357: 358: The bot connected to the Mattermost gateway. Can be overridden as 359: C<gw_ws_started()>. 360: 361: =item C<gw_ws_finished> 362: 363: The bot disconnected from the Mattermost gateway. Can be overridden as 364: C<gw_ws_finished()>. 365: 366: =item C<gw_message> 367: 368: The bot received a message. Can be overridden as C<gw_message()>. 369: 370: =item C<gw_ws_error> 371: 372: The bot received an error. Can be overridden as C<gw_error()>. 373: 374: =item C<gw_message_no_event> 375: 376: The bot received a message without an event (which is usually a "ping" item). 377: Can be overridden as C<gw_message_no_event()>. 378: 379: =back 380: 381: =head1 AUTHOR 382: 383: Mike Jones <mike@netsplit.org.uk> 384: 385: =head1 COPYRIGHT AND LICENSE 386: 387: This software is Copyright (c) 2020 by Mike Jones. 388: 389: This is free software, licensed under: 390: 391: The MIT (X11) License 392: 393: =cut 394: |