File Coverage

blib/lib/App/RoboBot.pm
Criterion Covered Total %
statement 27 29 93.1
branch n/a
condition n/a
subroutine 10 10 100.0
pod n/a
total 37 39 94.8


line stmt bran cond sub pod time code
1             package App::RoboBot;
2             $App::RoboBot::VERSION = '4.002';
3             # ABSTRACT: Extensible multi-protocol S-Expression chatbot.
4              
5             =head1 NAME
6              
7             App::Robobot - Extensible multi-protocol S-Expression chatbot
8              
9             =head1 SYNOPSIS
10              
11             use AnyEvent;
12             use App::RoboBot;
13             App::RoboBot->new()->run;
14              
15             =head1 DESCRIPTION
16              
17             App::RoboBot provides an event-driven, multi-protocol, multi-network,
18             user-programmable, plugin-based, S-Expression chatbot. Any text-based chat
19             service could be supported, with plugins currently for IRC, Slack, and
20             Mattermost included.
21              
22             Major features include:
23              
24             =over 4
25              
26             =item * S-Expression Syntax
27              
28             Chatbot commands are issued via an S-Expression syntax (spiritual guidance from
29             Clojure on some of the sugar for non-list structures). This language, while no
30             match for a full-blown, general purpose programming environment, is flexible
31             enough when combined with the macro and plugin support to allow users on your
32             chat service of choice to dynamically extend the functionality of the bot on
33             the fly.
34              
35             =item * Multi-protocol
36              
37             App::RoboBot currently includes support for IRC, Slack, and Mattermost out of
38             the box. Additional service plugins would be easy to add, as long as there is
39             an AnyEvent compatible library for them on CPAN or you are willing to write
40             one. Network protocol plugins need only implement a small number of methods
41             for core actions like connection/disconnecting from a network service, parsing
42             incoming messages, and sending messages.
43              
44             =item * Multi-network
45              
46             Bot instances created with App::RoboBot may connect to multiple networks
47             simultaneously (critical for some plugins like ChannelLink which let you create
48             your own bridges between disparate networks), even across different protocols.
49             The only practical limits are memory and bandwidth for the host running your
50             bot.
51              
52             =item * Macros
53              
54             User-defined macros are core to App::RoboBot's operation and allow authorized
55             users on your chat services to define new functionality for the bot on the fly
56             using a Lisp-like (emphasis on the "like") language. Macros can invoke
57             functions, other macros, and even create more macros. Macros use the exact same
58             S-Expression language as everything else in the bot, and have access to the
59             full functionality.
60              
61             =item * Plugins
62              
63             Nearly all App::RoboBot functionality is provided through the plugin system.
64             The distribution ships with many plugins already included, from interfaces to
65             external programs like fortune and filters, all the way through to HTTP clients
66             and XML parsing and XPath queries. New plugins may be submitted to the core
67             App::RoboBot project, or distributed separately.
68              
69             =back
70              
71             =head1 SEE ALSO
72              
73             The full documentation for App::RoboBot is available at the following site:
74              
75             https://robobot.automatomatromaton.com/
76              
77             Instructions for installing, configuring, and operating bots with this module
78             are provided.
79              
80             =head1 AUTHOR
81              
82             Jon Sime <jonsime@gmail.com>
83              
84             =head1 CONTRIBUTORS
85              
86             =over 4
87              
88             =item * Lukas Eklund
89              
90             =item * Mohammad S. Anwar
91              
92             =item * Shawn Delysse
93              
94             =back
95              
96             =head1 LICENSE AND COPYRIGHT
97              
98             This software is copyright (c) 2016 by Jon Sime.
99              
100             This is free software; you can redistribute it and/or modify it under the same
101             terms as the Perl 5 programming language system itself.
102              
103             =cut
104              
105 5     5   727 use v5.18;
  5         12  
106              
107 5     5   2779 use namespace::autoclean;
  5         82736  
  5         18  
108              
109 5     5   3337 use Moose;
  5         1754660  
  5         31  
110 5     5   31104 use MooseX::ClassAttribute;
  5         332877  
  5         25  
111 5     5   1185695 use MooseX::SetOnce;
  5         43733  
  5         163  
112              
113 5     5   5458 use AnyEvent;
  5         23537  
  5         171  
114 5     5   3138 use Data::Dumper;
  5         27916  
  5         381  
115 5     5   2408 use File::ShareDir qw( dist_dir );
  5         26210  
  5         328  
116 5     5   2475 use Module::Pluggable::Object;
  5         29263  
  5         176  
117              
118 5     5   2182 use App::RoboBot::Config;
  0            
  0            
119             use App::RoboBot::Message;
120             use App::RoboBot::Plugin;
121              
122             use App::RoboBot::Doc;
123              
124             has 'config_paths' => (
125             is => 'ro',
126             isa => 'ArrayRef[Str]',
127             predicate => 'has_config_paths',
128             );
129              
130             has 'do_migrations' => (
131             is => 'ro',
132             isa => 'Bool',
133             default => 0,
134             );
135              
136             has 'config' => (
137             is => 'rw',
138             isa => 'App::RoboBot::Config',
139             traits => [qw( SetOnce )],
140             predicate => 'has_config',
141             );
142              
143             has 'raw_config' => (
144             is => 'ro',
145             isa => 'HashRef',
146             predicate => 'has_raw_config',
147             );
148              
149             has 'plugins' => (
150             is => 'rw',
151             isa => 'ArrayRef',
152             default => sub { [] },
153             );
154              
155             has 'doc' => (
156             is => 'rw',
157             isa => 'App::RoboBot::Doc',
158             traits => [qw( SetOnce )],
159             );
160              
161             has 'before_hooks' => (
162             is => 'rw',
163             isa => 'ArrayRef',
164             predicate => 'run_before_hooks',
165             default => sub { [] },
166             );
167              
168             has 'after_hooks' => (
169             is => 'rw',
170             isa => 'ArrayRef',
171             predicate => 'run_after_hooks',
172             default => sub { [] },
173             );
174              
175             has 'networks' => (
176             is => 'rw',
177             isa => 'ArrayRef[App::RoboBot::Network]',
178             default => sub { [] },
179             );
180              
181             class_has 'commands' => (
182             is => 'rw',
183             isa => 'HashRef',
184             default => sub { {} },
185             );
186              
187             class_has 'macros' => (
188             is => 'rw',
189             isa => 'HashRef',
190             default => sub { {} },
191             );
192              
193             sub BUILD {
194             my ($self) = @_;
195              
196             $self->doc(App::RoboBot::Doc->new( bot => $self ));
197              
198             if ($self->has_raw_config) {
199             $self->config(App::RoboBot::Config->new( bot => $self, config => $self->raw_config ));
200             } else {
201             if ($self->has_config_paths) {
202             $self->config(App::RoboBot::Config->new( bot => $self, config_paths => $self->config_paths ));
203             } else {
204             $self->config(App::RoboBot::Config->new( bot => $self ));
205             }
206             }
207              
208             $self->config->load_config;
209              
210             # Gather list of supported plugin commands (naming conflicts are considered
211             # warnable offenses, not fatal errors).
212             my $finder = Module::Pluggable::Object->new( search_path => 'App::RoboBot::Plugin', instantiate => 'new' );
213              
214             foreach my $plugin ($finder->plugins) {
215             push(@{$self->plugins}, $plugin);
216             $plugin->bot($self);
217             $plugin->init($self);
218              
219             foreach my $command (keys %{$plugin->commands}) {
220             warn sprintf("Command name collision: %s/%s superseded by %s/%s",
221             $self->commands->{$command}->ns, $command, $plugin->ns, $command)
222             if exists $self->commands->{$command};
223              
224             # Offer both plain and namespaced access to individual functions
225             $self->commands->{$command} = $plugin;
226             $self->commands->{sprintf('%s/%s', $plugin->ns, $command)} = $plugin;
227             }
228              
229             # Gather list of plugins which have before/after hooks.
230             push(@{$self->before_hooks}, $plugin) if $plugin->has_before_hook;
231             push(@{$self->after_hooks}, $plugin) if $plugin->has_after_hook;
232             }
233              
234             # Two-phase plugin initialization's second phase now called, so that plugins
235             # which require knowledge of the existence of commands/macros/etc. can see
236             # that (it having been done already in the first phase). This is critical
237             # for plugins which use things like App::RoboBot::Parser to parse stored
238             # expressions.
239             foreach my $plugin (@{$self->plugins}) {
240             $plugin->post_init($self);
241             }
242              
243             # Pre-load all saved macros
244             $self->macros({ App::RoboBot::Macro->load_all($self) });
245             # TODO: This is an awful hack around the fact that nested macros get parsed incorrectly
246             # the first time around, depending on their load order out of the database. The
247             # Parser module doesn't know about their name yet, so it parses them as a String
248             # instead of a Macro object. That should get fixed in a cleaner way, but for now
249             # we can just load them a second time. All their names will be available for the
250             # Parser and we'll just overwrite their definitions with the correct versions.
251             $self->macros({ App::RoboBot::Macro->load_all($self) });
252             }
253              
254             sub run {
255             my ($self) = @_;
256              
257             my $c = AnyEvent->condvar;
258             $_->connect for @{$self->networks};
259             $c->recv;
260             $_->disconnect for @{$self->networks};
261             }
262              
263             sub version {
264             my ($self) = @_;
265              
266             use vars qw( $VERSION );
267              
268             return $VERSION // "*-devel";
269             }
270              
271             sub add_macro {
272             my ($self, $network, $nick, $macro_name, $args, $body) = @_;
273              
274             if (exists $self->macros->{$network->id}{$macro_name}) {
275             $self->macros->{$network->id}{$macro_name}->name($macro_name);
276             $self->macros->{$network->id}{$macro_name}->arguments($args);
277             $self->macros->{$network->id}{$macro_name}->definition($body);
278             $self->macros->{$network->id}{$macro_name}->definer($nick);
279              
280             return unless $self->macros->{$network->id}{$macro_name}->save;
281             } else {
282             my $macro = App::RoboBot::Macro->new(
283             bot => $self,
284             network => $network,
285             name => $macro_name,
286             arguments => $args,
287             definition => $body,
288             definer => $nick,
289             );
290              
291             return unless $macro->save;
292              
293             $self->macros->{$network->id} = {} unless exists $self->macros->{$network->id};
294             $self->macros->{$network->id}{$macro->name} = $macro;
295             }
296              
297             return 1;
298             }
299              
300             sub remove_macro {
301             my ($self, $network, $macro_name) = @_;
302              
303             return unless exists $self->macros->{$network->id}{$macro_name};
304              
305             $self->macros->{$network->id}{$macro_name}->delete;
306             delete $self->macros->{$network->id}{$macro_name};
307              
308             return 1;
309             }
310              
311             sub network_by_id {
312             my ($self, $network_id) = @_;
313              
314             return undef unless defined $network_id && $network_id =~ m{^\d+$};
315             return (grep { $_->id == $network_id } @{$self->networks})[0] || undef;
316             }
317              
318             sub migrate_database {
319             my ($self) = @_;
320              
321             my $migrations_dir = dist_dir('App-RoboBot') . '/migrations';
322             die "Could not locate database migrations (remember to use `dzil run` during development)!"
323             unless -d $migrations_dir;
324              
325             my $cfg = $self->config->config->{'database'}{'primary'};
326              
327             my $db_uri = 'db:pg://';
328             $db_uri .= $cfg->{'user'} . '@' if $cfg->{'user'};
329             $db_uri .= $cfg->{'host'} if $cfg->{'host'};
330             $db_uri .= ':' . $cfg->{'port'} if $cfg->{'port'};
331             $db_uri .= '/' . $cfg->{'database'} if $cfg->{'database'};
332              
333             chdir($migrations_dir) or die "Could not chdir() $migrations_dir: $!";
334              
335             open(my $status_fh, '-|', 'sqitch', 'status', $db_uri) or die "Could not check database status: $!";
336             while (my $l = <$status_fh>) {
337             return if $l =~ m{up-to-date};
338             }
339             close($status_fh);
340              
341             die "Database schema is out of date, but --migrate was not specified so we cannot upgrade.\n"
342             unless $self->do_migrations;
343              
344             open(my $deploy_fh, '-|', 'sqitch', 'deploy', '--verify', $db_uri) or die "Could not begin database migrations: $!";
345             while (my $l = <$deploy_fh>) {
346             if ($l =~ m{^\s*\+\s*(.+)\s+\.\.\s+(.*)$}) {
347             die "Failed during database migration $1.\n" if lc($2) ne 'ok';
348             }
349             }
350             close($deploy_fh);
351             }
352              
353             __PACKAGE__->meta->make_immutable;
354              
355             1;