Group
Extension

WebService-Mattermost/lib/WebService/Mattermost/V4/Client.pm

package WebService::Mattermost::V4::Client;

# ABSTRACT: Perl websocket client for Mattermost.

use Encode 'encode';
use Mojo::IOLoop;
use Mojo::JSON qw(decode_json encode_json);
use Moo;
use MooX::HandlesVia;
use Types::Standard qw(ArrayRef Bool InstanceOf Int Maybe Str);

extends 'WebService::Mattermost';
with    qw(
    WebService::Mattermost::Role::Logger
    WebService::Mattermost::Role::UserAgent
    Role::EventEmitter
);

################################################################################

has _events       => (is => 'ro', isa => ArrayRef,                      lazy => 1, builder => 1);
has _ua           => (is => 'rw', isa => InstanceOf['Mojo::UserAgent'], lazy => 1, builder => 1);
has ioloop        => (is => 'rw', isa => InstanceOf['Mojo::IOLoop'],    lazy => 1, builder => 1);
has websocket_url => (is => 'ro', isa => Str,                           lazy => 1, builder => 1);

has ws => (is => 'rw', isa => Maybe[InstanceOf['Mojo::Base']]);

has debug                     => (is => 'ro', isa => Bool, default => 0);
has ignore_self               => (is => 'ro', isa => Bool, default => 1);
has ping_interval             => (is => 'ro', isa => Int,  default => 15);
has reconnection_wait_time    => (is => 'ro', isa => Int,  default => 2);
has reauthentication_interval => (is => 'ro', isa => Int,  default => 3600);

has last_seq => (is => 'rw', isa => Int,  default => 1,
    handles_via => 'Number',
    handles     => {
        inc_last_seq => 'add',
    });

has loops => (is => 'rw', isa => ArrayRef[InstanceOf['Mojo::IOLoop']], default => sub { [] },
    handles_via => 'Array',
    handles     => {
        add_loop    => 'push',
        clear_loops => 'clear',
    });

################################################################################

sub BUILD {
    my $self = shift;

    $self->authenticate(1);
    $self->next::method(@_);

    # Set up expected subroutines for a child class to catch. The events can
    # also be caught raw in a script.
    foreach my $emission (@{$self->_events}) {
        # Values from events must be set up in child class
        if ($self->can($emission)) {
            $self->on($emission, sub { shift; $self->$emission(@_) });
        }
    }

    return 1;
}

sub start {
    my $self = shift;

    $self->_connect();
    $self->ioloop->start unless $self->ioloop->is_running();

    return;
}

sub message_has_content {
    my $self = shift;
    my $args = shift;

    return $args->{post_data} && $args->{post_data}->{message};
}

################################################################################

sub _connect {
    my $self = shift;

    $self->_ua->on(start => sub { $self->_on_start(@_) });

    $self->_ua->websocket($self->websocket_url => sub {
        my ($ua, $tx) = @_;

        $self->ws($tx);

        unless ($tx->is_websocket) {
            $self->logger->fatal('WebSocket handshake failed');
        }

        $self->emit(gw_ws_started => {});

        $self->logger->debug('Adding ping loop');

        $self->add_loop($self->ioloop->recurring(15 => sub { $self->_ping($tx) }));
        $self->add_loop($self->ioloop->recurring($self->reauthentication_interval => sub { $self->_reauthenticate() }));

        $tx->on(error   => sub { $self->_on_error(@_)   });
        $tx->on(finish  => sub { $self->_on_finish(@_)  });
        $tx->on(message => sub { $self->_on_message(@_) });
    });

    return 1;
}

sub _ping {
    my $self = shift;
    my $tx   = shift;

    if ($self->debug) {
        $self->logger->debugf('[Seq: %d] Sending ping', $self->last_seq);
    }

    return $tx->send(encode_json({
        seq    => $self->last_seq,
        action => 'ping',
    }));
}

sub _on_start {
    my $self = shift;
    my $ua   = shift;
    my $tx   = shift;

    if ($self->debug) {
        $self->logger->debugf('UserAgent connected to %s', $tx->req->url->to_string);
        $self->logger->debugf('Auth token: %s', $self->auth_token);
    }

    # The methods here are from the UserAgent role
    $tx->req->headers->header('Cookie'        => $self->mmauthtoken($self->auth_token));
    $tx->req->headers->header('Authorization' => $self->bearer($self->auth_token));
    $tx->req->headers->header('Keep-Alive'    => 1);

    return 1;
}

sub _on_finish {
    my $self   = shift;
    my $tx     = shift;
    my $code   = shift;
    my $reason = shift || 'Unknown';

    $self->logger->infof('WebSocket connection closed: [%d] %s', $code, $reason);
    $self->logger->infof('Reconnecting in %d seconds...', $self->reconnection_wait_time);

    $self->ws->finish;
    $self->emit(gw_ws_finished => { code => $code, reason => $reason });

    # Delay the reconnection a little
    Mojo::IOLoop->timer($self->reconnection_wait_time => sub {
        return $self->_reconnect();
    });
}

sub _on_message {
    my $self    = shift;
    my $tx      = shift;
    my $input   = shift;

    return unless $input;

    my $message = decode_json(encode('utf8', $input));

    if ($message->{seq}) {
        $self->logger->debugf('[Seq: %d]', $message->{seq}) if $self->debug;
        $self->last_seq($message->{seq});
    }

    return $self->_on_non_event($message) unless $message && $message->{event};

    my $message_args = { message => $message };

    if ($message->{data}->{post}) {
        my $post_data = decode_json(encode('utf8', $message->{data}->{post}));

        # Early return if the message is from the bot's own user ID (to halt
        # recursion)
        return if $self->ignore_self && $post_data->{user_id} eq $self->user_id;

        $message_args->{post_data} = $post_data;
    }

    $self->emit(gw_message => $message_args);

    if ($message->{event} eq 'hello') {
        if ($self->debug) {
            $self->logger->debug('Received "hello" event, sending authentication challenge');
        }

        $tx->send(encode_json({
            seq    => 1,
            action => 'authentication_challenge',
            data   => { token => $self->auth_token },
        }));
    }

    return 1;
}

sub _on_non_event {
    my $self    = shift;
    my $message = shift;

    if ($self->debug && $message->{data} && $message->{data}->{text}) {
        $self->logger->debugf('[Seq: %d] Received %s', $self->last_seq, $message->{data}->{text});
    }

    return $self->emit(gw_message_no_event => $message);
}

sub _on_error {
    my $self    = shift;
    my $ws      = shift;
    my $message = shift;

    $self->emit(gw_ws_error => { message => $message });

    return $ws->finish($message);
}

sub _reauthenticate {
    my $self = shift;

    # Mattermost authentication tokens expire after a given (and unknown) amount
    # of time. By default, the client will reconnect every hour in order to
    # refresh the token.
    $self->authenticate(1);
    $self->_try_authentication();

    return 1;
}

sub _reconnect {
    my $self = shift;

    # Reset things which have been altered during the course of the last
    # connection
    $self->last_seq(1);
    $self->_try_authentication();
    $self->_clean_up_loops();
    $self->ws(undef);
    $self->_ua($self->_build__ua);

    return $self->_connect();
}

sub _clean_up_loops {
    my $self = shift;

    foreach my $loop (@{$self->loops}) {
        $self->ioloop->remove($loop);
    }

    return $self->clear_loops();
}

################################################################################

sub _build__events {
    return [ qw(
        gw_ws_error
        gw_ws_finished
        gw_ws_started
        gw_message
        gw_message_no_event
    ) ];
}

sub _build__ua { Mojo::UserAgent->new }

sub _build_ioloop { Mojo::IOLoop->singleton }

sub _build_websocket_url {
    my $self = shift;

    # Convert the API URL to the WebSocket URL
    my $ws_url = $self->base_url;

    if ($ws_url !~ /\/$/) {
        $ws_url .= '/';
    }

    $ws_url .= 'websocket';
    $ws_url =~ s/^http(?:s)?/wss/s;

    return $ws_url;
}

################################################################################

1;

__END__

=pod

=encoding UTF-8

=head1 NAME

WebService::Mattermost::V4::Client - Perl websocket client for Mattermost.

=head1 VERSION

version 0.31

=head1 DESCRIPTION

This class connects to Mattermost via the WebSocket gateway and can either be
extended in a child class, or used in a script.

=head2 USAGE

=head3 FROM A SCRIPT

    use WebService::Mattermost::V4::Client;

    my $bot = WebService::Mattermost::V4::Client->new({
        username => 'usernamehere',
        password => 'password',
        base_url => 'https://mattermost.server.com/api/v4/',

        # Optional arguments
        debug       => 1, # Show extra connection information
        ignore_self => 0, # May cause recursion!
    });

    $bot->on(message => sub {
        my ($bot, $args) = @_;

        # $args contains the decoded message content
    });

    $bot->start(); # Add me last

=head3 EXTENSION

See L<WebService::Mattermost::V4::Example::Bot>.

=head2 EVENTS

Events are either available to be caught with C<on> in scripts, or have methods
which can be overridden in child classes.

=over 4

=item C<gw_ws_started>

The bot connected to the Mattermost gateway. Can be overridden as
C<gw_ws_started()>.

=item C<gw_ws_finished>

The bot disconnected from the Mattermost gateway. Can be overridden as
C<gw_ws_finished()>.

=item C<gw_message>

The bot received a message. Can be overridden as C<gw_message()>.

=item C<gw_ws_error>

The bot received an error. Can be overridden as C<gw_error()>.

=item C<gw_message_no_event>

The bot received a message without an event (which is usually a "ping" item).
Can be overridden as C<gw_message_no_event()>.

=back

=head1 AUTHOR

Mike Jones <mike@netsplit.org.uk>

=head1 COPYRIGHT AND LICENSE

This software is Copyright (c) 2023 by Mike Jones.

This is free software, licensed under:

  The MIT (X11) License

=cut


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.