File Coverage

blib/lib/App/RoboBot.pm
Criterion Covered Total %
statement 30 32 93.7
branch n/a
condition n/a
subroutine 11 11 100.0
pod n/a
total 41 43 95.3


line stmt bran cond sub pod time code
1             package App::RoboBot;
2             $App::RoboBot::VERSION = '4.003';
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   639 use v5.18;
  5         11  
106              
107 5     5   2025 use namespace::autoclean;
  5         65026  
  5         17  
108              
109 5     5   2666 use Moose;
  5         1464726  
  5         28  
110 5     5   26478 use MooseX::ClassAttribute;
  5         285069  
  5         23  
111 5     5   1028684 use MooseX::SetOnce;
  5         38909  
  5         149  
112              
113 5     5   5736 use AnyEvent;
  5         19296  
  5         212  
114 5     5   3304 use Data::Dumper;
  5         24505  
  5         328  
115 5     5   2158 use File::ShareDir qw( dist_dir );
  5         22963  
  5         323  
116 5     5   3638 use Log::Log4perl;
  5         176871  
  5         26  
117 5     5   2769 use Module::Pluggable::Object;
  5         25738  
  5         138  
118              
119 5     5   2031 use App::RoboBot::Config;
  0            
  0            
120             use App::RoboBot::Message;
121             use App::RoboBot::Plugin;
122              
123             use App::RoboBot::Doc;
124              
125             has 'config_paths' => (
126             is => 'ro',
127             isa => 'ArrayRef[Str]',
128             predicate => 'has_config_paths',
129             );
130              
131             has 'do_migrations' => (
132             is => 'ro',
133             isa => 'Bool',
134             default => 0,
135             );
136              
137             has 'config' => (
138             is => 'rw',
139             isa => 'App::RoboBot::Config',
140             traits => [qw( SetOnce )],
141             predicate => 'has_config',
142             );
143              
144             has 'raw_config' => (
145             is => 'ro',
146             isa => 'HashRef',
147             predicate => 'has_raw_config',
148             );
149              
150             has 'plugins' => (
151             is => 'rw',
152             isa => 'ArrayRef',
153             default => sub { [] },
154             );
155              
156             has 'doc' => (
157             is => 'rw',
158             isa => 'App::RoboBot::Doc',
159             traits => [qw( SetOnce )],
160             );
161              
162             has 'before_hooks' => (
163             is => 'rw',
164             isa => 'ArrayRef',
165             predicate => 'run_before_hooks',
166             default => sub { [] },
167             );
168              
169             has 'after_hooks' => (
170             is => 'rw',
171             isa => 'ArrayRef',
172             predicate => 'run_after_hooks',
173             default => sub { [] },
174             );
175              
176             has 'networks' => (
177             is => 'rw',
178             isa => 'ArrayRef[App::RoboBot::Network]',
179             default => sub { [] },
180             );
181              
182             class_has 'commands' => (
183             is => 'rw',
184             isa => 'HashRef',
185             default => sub { {} },
186             );
187              
188             class_has 'macros' => (
189             is => 'rw',
190             isa => 'HashRef',
191             default => sub { {} },
192             );
193              
194             sub BUILD {
195             my ($self) = @_;
196              
197             $self->doc(App::RoboBot::Doc->new( bot => $self ));
198              
199             if ($self->has_raw_config) {
200             $self->config(App::RoboBot::Config->new( bot => $self, config => $self->raw_config ));
201             } else {
202             if ($self->has_config_paths) {
203             $self->config(App::RoboBot::Config->new( bot => $self, config_paths => $self->config_paths ));
204             } else {
205             $self->config(App::RoboBot::Config->new( bot => $self ));
206             }
207             }
208              
209             $self->config->load_config;
210              
211             my $logger = $self->logger('core.init');
212             $logger->info('Configuration loaded.');
213              
214             # Gather list of supported plugin commands (naming conflicts are considered
215             # warnable offenses, not fatal errors).
216             $logger->info('Loading plugins.');
217             my $finder = Module::Pluggable::Object->new( search_path => 'App::RoboBot::Plugin', instantiate => 'new' );
218              
219             foreach my $plugin ($finder->plugins) {
220             $logger->debug(sprintf('Loading %s plugin.', $plugin->name));
221             push(@{$self->plugins}, $plugin);
222             $plugin->bot($self);
223             $plugin->init($self);
224             $logger->debug(sprintf('Initialized %s plugin.', $plugin->name));
225              
226             foreach my $command (keys %{$plugin->commands}) {
227             $logger->warn(sprintf('Command name collision: %s/%s superseded by %s/%s',
228             $self->commands->{$command}->ns, $command,
229             $plugin->ns, $command))
230             if exists $self->commands->{$command};
231             $logger->debug(sprintf('Plugin command %s loaded.', $command));
232              
233             # Offer both plain and namespaced access to individual functions
234             $self->commands->{$command} = $plugin;
235             $self->commands->{sprintf('%s/%s', $plugin->ns, $command)} = $plugin;
236             }
237              
238             # Gather list of plugins which have before/after hooks.
239             push(@{$self->before_hooks}, $plugin) if $plugin->has_before_hook;
240             push(@{$self->after_hooks}, $plugin) if $plugin->has_after_hook;
241             }
242              
243             # Two-phase plugin initialization's second phase now called, so that plugins
244             # which require knowledge of the existence of commands/macros/etc. can see
245             # that (it having been done already in the first phase). This is critical
246             # for plugins which use things like App::RoboBot::Parser to parse stored
247             # expressions.
248             foreach my $plugin (@{$self->plugins}) {
249             $plugin->post_init($self);
250             }
251              
252             $logger->debug('Plugin post-initialization hooks finished.');
253              
254             # Pre-load all saved macros
255             $self->macros({ App::RoboBot::Macro->load_all($self) });
256             # TODO: This is an awful hack around the fact that nested macros get parsed incorrectly
257             # the first time around, depending on their load order out of the database. The
258             # Parser module doesn't know about their name yet, so it parses them as a String
259             # instead of a Macro object. That should get fixed in a cleaner way, but for now
260             # we can just load them a second time. All their names will be available for the
261             # Parser and we'll just overwrite their definitions with the correct versions.
262             $self->macros({ App::RoboBot::Macro->load_all($self) });
263              
264             $logger->debug('Macro initializations finished.');
265             }
266              
267             sub run {
268             my ($self) = @_;
269              
270             my $logger = $self->logger('core.run');
271              
272             $logger->info('Bot starting.');
273              
274             my $c = AnyEvent->condvar;
275             $_->connect for @{$self->networks};
276             $c->recv;
277             $_->disconnect for @{$self->networks};
278              
279             $logger->info('Bot disconnected from all networks and preparing to stop.');
280             }
281              
282             sub version {
283             my ($self) = @_;
284              
285             use vars qw( $VERSION );
286              
287             return $VERSION // "*-devel";
288             }
289              
290             sub logger {
291             my ($self, $category) = @_;
292              
293             $category = defined $category ? lc($category) : 'core';
294              
295             return Log::Log4perl::get_logger($category);
296             }
297              
298             sub add_macro {
299             my ($self, $network, $nick, $macro_name, $args, $body) = @_;
300              
301             my $logger = $self->logger('core.macro');
302              
303             $logger->debug(sprintf('Adding macro %s for %s on %s network.', $macro_name, $nick->name, $network->name));
304              
305             if (exists $self->macros->{$network->id}{$macro_name}) {
306             $logger->debug('Macro already exists. Overwriting definition.');
307             $self->macros->{$network->id}{$macro_name}->name($macro_name);
308             $self->macros->{$network->id}{$macro_name}->arguments($args);
309             $self->macros->{$network->id}{$macro_name}->definition($body);
310             $self->macros->{$network->id}{$macro_name}->definer($nick);
311              
312             return unless $self->macros->{$network->id}{$macro_name}->save;
313             } else {
314             $logger->debug('Creating as new macro and saving definition.');
315             my $macro = App::RoboBot::Macro->new(
316             bot => $self,
317             network => $network,
318             name => $macro_name,
319             arguments => $args,
320             definition => $body,
321             definer => $nick,
322             );
323              
324             return unless $macro->save;
325             $logger->debug('Macro saved successfully. Caching definition for future use.');
326              
327             $self->macros->{$network->id} = {} unless exists $self->macros->{$network->id};
328             $self->macros->{$network->id}{$macro->name} = $macro;
329             }
330              
331             return 1;
332             }
333              
334             sub remove_macro {
335             my ($self, $network, $macro_name) = @_;
336              
337             my $logger = $self->logger('core.macro');
338              
339             $logger->debug(sprintf('Removing macro %s on %s network.', $macro_name, $network->name));
340              
341             return unless exists $self->macros->{$network->id}{$macro_name};
342              
343             $self->macros->{$network->id}{$macro_name}->delete;
344             delete $self->macros->{$network->id}{$macro_name};
345              
346             $logger->debug('Macro successfully removed.');
347              
348             return 1;
349             }
350              
351             sub network_by_id {
352             my ($self, $network_id) = @_;
353              
354             return undef unless defined $network_id && $network_id =~ m{^\d+$};
355             return (grep { $_->id == $network_id } @{$self->networks})[0] || undef;
356             }
357              
358             sub migrate_database {
359             my ($self) = @_;
360              
361             my $logger = $self->logger('core.migrate');
362              
363             $logger->info('Checking database migration status.');
364              
365             my $migrations_dir = dist_dir('App-RoboBot') . '/migrations';
366             die "Could not locate database migrations (remember to use `dzil run` during development)!"
367             unless -d $migrations_dir;
368              
369             my $cfg = $self->config->config->{'database'}{'primary'};
370              
371             my $db_uri = 'db:pg://';
372             $db_uri .= $cfg->{'user'} . '@' if $cfg->{'user'};
373             $db_uri .= $cfg->{'host'} if $cfg->{'host'};
374             $db_uri .= ':' . $cfg->{'port'} if $cfg->{'port'};
375             $db_uri .= '/' . $cfg->{'database'} if $cfg->{'database'};
376              
377             $logger->debug(sprintf('Using database URI %s for migration status check.', $db_uri));
378              
379             chdir($migrations_dir) or die "Could not chdir() $migrations_dir: $!";
380              
381             open(my $status_fh, '-|', 'sqitch', 'status', $db_uri) or die "Could not check database status: $!";
382             while (my $l = <$status_fh>) {
383             if ($l =~ m{up-to-date}) {
384             $logger->info('Database schema up to date. No migrations run.');
385             return;
386             }
387             }
388             close($status_fh);
389              
390             die "Database schema is out of date, but --migrate was not specified so we cannot upgrade.\n"
391             unless $self->do_migrations;
392              
393             $logger->info('Migration necessary. Running with verification enabled.');
394              
395             open(my $deploy_fh, '-|', 'sqitch', 'deploy', '--verify', $db_uri) or die "Could not begin database migrations: $!";
396             while (my $l = <$deploy_fh>) {
397             if ($l =~ m{^\s*\+\s*(.+)\s+\.\.\s+(.*)$}) {
398             die "Failed during database migration $1.\n" if lc($2) ne 'ok';
399             }
400             }
401             close($deploy_fh);
402              
403             $logger->info('Database migration completed successfully.');
404             }
405              
406             __PACKAGE__->meta->make_immutable;
407              
408             1;