3
0
mirror of https://github.com/pragma-/pbot.git synced 2024-10-02 09:28:46 +02:00
pbot/PBot/Factoids.pm
Pragmatic Software d955bfa06c Add centralized configuration registry module
Allows changing of bot configuration values without needing to restart
bot instance or needing to edit pbot.pl script.

Registry will initially be populated with default values from pbot.pl,
but if a registry file exists then the registry values will take
precedence over the pbot.pl values. For instance, if you regset the
bot trigger to '%' then the trigger will be '%' even if pbot.pl has '!'
or something else explicitly set.

Some registry items can have trigger hooks associated with them.  For
instance, the irc->botnick registry entry has a change_botnick_trigger
associated with it which changes the IRC nick on the server when a new
value is set via regset/regadd.

Tons of other fixes and improvements throughout.
2014-05-17 20:08:19 +00:00

596 lines
22 KiB
Perl

# File: Factoids.pm
# Author: pragma_
#
# Purpose: Provides functionality for factoids and a type of external module execution.
package PBot::Factoids;
use warnings;
use strict;
use HTML::Entities;
use Time::HiRes qw(gettimeofday);
use Carp ();
use POSIX qw(strftime);
use PBot::PBot qw($VERSION);
use PBot::FactoidModuleLauncher;
use PBot::DualIndexHashObject;
sub new {
if(ref($_[1]) eq 'HASH') {
Carp::croak("Options to Factoids should be key/value pairs, not hash reference");
}
my ($class, %conf) = @_;
my $self = bless {}, $class;
$self->initialize(%conf);
return $self;
}
sub initialize {
my ($self, %conf) = @_;
my $filename = delete $conf{filename};
my $export_path = delete $conf{export_path};
my $export_site = delete $conf{export_site};
my $pbot = delete $conf{pbot} // Carp::croak("Missing pbot reference to Factoids");
$self->{factoids} = PBot::DualIndexHashObject->new(name => 'Factoids', filename => $filename);
$self->{export_path} = $export_path;
$self->{export_site} = $export_site;
$self->{pbot} = $pbot;
$self->{factoidmodulelauncher} = PBot::FactoidModuleLauncher->new(pbot => $pbot);
$self->{pbot}->{atexit}->register(sub { $self->save_factoids; return; });
$self->load_factoids;
$self->add_factoid('text', '.*', $self->{pbot}->{registry}->get_value('irc', 'botnick'), 'version', "/say $VERSION", 1);
}
sub load_factoids {
my $self = shift;
$self->{pbot}->logger->log("Loading factoids from " . $self->factoids->filename . " ...\n");
$self->factoids->load;
my ($text, $regex, $modules);
foreach my $channel (keys %{ $self->factoids->hash }) {
foreach my $trigger (keys %{ $self->factoids->hash->{$channel} }) {
$text++ if $self->factoids->hash->{$channel}->{$trigger}->{type} eq 'text';
$regex++ if $self->factoids->hash->{$channel}->{$trigger}->{type} eq 'regex';
$modules++ if $self->factoids->hash->{$channel}->{$trigger}->{type} eq 'module';
}
}
$self->{pbot}->logger->log(" " . ($text + $regex + $modules) . " factoids loaded ($text text, $regex regexs, $modules modules).\n");
$self->{pbot}->logger->log("Done.\n");
}
sub save_factoids {
my $self = shift;
$self->factoids->save;
$self->export_factoids;
}
sub add_factoid {
my $self = shift;
my ($type, $channel, $owner, $trigger, $action, $dont_save) = @_;
$type = lc $type;
$channel = lc $channel;
$self->factoids->hash->{$channel}->{$trigger}->{enabled} = 1;
$self->factoids->hash->{$channel}->{$trigger}->{type} = $type;
$self->factoids->hash->{$channel}->{$trigger}->{action} = $action;
$self->factoids->hash->{$channel}->{$trigger}->{owner} = $owner;
$self->factoids->hash->{$channel}->{$trigger}->{created_on} = gettimeofday;
$self->factoids->hash->{$channel}->{$trigger}->{ref_count} = 0;
$self->factoids->hash->{$channel}->{$trigger}->{ref_user} = "nobody";
$self->factoids->hash->{$channel}->{$trigger}->{rate_limit} = 15;
$self->save_factoids unless $dont_save;
}
sub remove_factoid {
my $self = shift;
my ($channel, $trigger) = @_;
$channel = lc $channel;
delete $self->factoids->hash->{$channel}->{$trigger};
if(not scalar keys $self->factoids->hash->{$channel}) {
delete $self->factoids->hash->{$channel};
}
$self->save_factoids;
}
sub export_factoids {
my $self = shift;
my $filename;
if(@_) { $filename = shift; } else { $filename = $self->export_path; }
return if not defined $filename;
open FILE, "> $filename" or return "Could not open export path.";
my $time = localtime;
print FILE "<html><head>\n<link href='css/blue.css' rel='stylesheet' type='text/css'>\n";
print FILE '<script type="text/javascript" src="js/jquery-latest.js"></script>' . "\n";
print FILE '<script type="text/javascript" src="js/jquery.tablesorter.js"></script>' . "\n";
print FILE '<script type="text/javascript" src="js/picnet.table.filter.min.js"></script>' . "\n";
print FILE "</head>\n<body><i>Last updated at $time</i>\n";
print FILE "<hr><h2>Candide's factoids</h2>\n";
my $i = 0;
my $table_id = 1;
foreach my $channel (sort keys %{ $self->factoids->hash }) {
next if not scalar keys %{ $self->factoids->hash->{$channel} };
my $chan = $channel eq '.*' ? 'global' : $channel;
print FILE "<a href='#" . $chan . "'>" . encode_entities($chan) . "</a><br>\n";
}
foreach my $channel (sort keys %{ $self->factoids->hash }) {
next if not scalar keys %{ $self->factoids->hash->{$channel} };
my $chan = $channel eq '.*' ? 'global' : $channel;
print FILE "<a name='$chan'></a>\n";
print FILE "<hr>\n<h3>$chan</h3>\n<hr>\n";
print FILE "<table border=\"0\" id=\"table$table_id\" class=\"tablesorter\">\n";
print FILE "<thead>\n<tr>\n";
print FILE "<th>owner</th>\n";
print FILE "<th>created on</th>\n";
print FILE "<th>times referenced</th>\n";
print FILE "<th>factoid</th>\n";
print FILE "<th>last edited by</th>\n";
print FILE "<th>edited date</th>\n";
print FILE "<th>last referenced by</th>\n";
print FILE "<th>last referenced date</th>\n";
print FILE "</tr>\n</thead>\n<tbody>\n";
$table_id++;
foreach my $trigger (sort keys %{ $self->factoids->hash->{$channel} }) {
if($self->factoids->hash->{$channel}->{$trigger}->{type} eq 'text') {
$i++;
if($i % 2) {
print FILE "<tr bgcolor=\"#dddddd\">\n";
} else {
print FILE "<tr>\n";
}
print FILE "<td>" . $self->factoids->hash->{$channel}->{$trigger}->{owner} . "</td>\n";
print FILE "<td>" . encode_entities(strftime "%Y/%m/%d %H:%M:%S", localtime $self->factoids->hash->{$channel}->{$trigger}->{created_on}) . "</td>\n";
print FILE "<td>" . $self->factoids->hash->{$channel}->{$trigger}->{ref_count} . "</td>\n";
my $action = $self->factoids->hash->{$channel}->{$trigger}->{action};
$action =~ s/(.*?)http(s?:\/\/[^ ]+)/encode_entities($1) . "<a href='http" . encode_entities($2) . "'>http" . encode_entities($2) . "<\/a>"/ge;
$action =~ s/(.*)<\/a>(.*$)/"$1<\/a>" . encode_entities($2)/e;
if(exists $self->factoids->hash->{$channel}->{$trigger}->{action_with_args}) {
my $with_args = $self->factoids->hash->{$channel}->{$trigger}->{action_with_args};
$with_args =~ s/(.*?)http(s?:\/\/[^ ]+)/encode_entities($1) . "<a href='http" . encode_entities($2) . "'>http" . encode_entities($2) . "<\/a>"/ge;
$with_args =~ s/(.*)<\/a>(.*$)/"$1<\/a>" . encode_entities($2)/e;
print FILE "<td width=100%><b>$trigger</b> is $action<br><br><b>with_args:</b> $with_args</td>\n";
} else {
print FILE "<td width=100%><b>$trigger</b> is $action</td>\n";
}
if(exists $self->factoids->hash->{$channel}->{$trigger}->{edited_by}) {
print FILE "<td>" . $self->factoids->hash->{$channel}->{$trigger}->{edited_by} . "</td>\n";
print FILE "<td>" . encode_entities(strftime "%Y/%m/%d %H:%M:%S", localtime $self->factoids->hash->{$channel}->{$trigger}->{edited_on}) . "</td>\n";
} else {
print FILE "<td></td>\n";
print FILE "<td></td>\n";
}
print FILE "<td>" . $self->factoids->hash->{$channel}->{$trigger}->{ref_user} . "</td>\n";
if(exists $self->factoids->hash->{$channel}->{$trigger}->{last_referenced_on}) {
print FILE "<td>" . encode_entities(strftime "%Y/%m/%d %H:%M:%S", localtime $self->factoids->hash->{$channel}->{$trigger}->{last_referenced_on}) . "</td>\n";
} else {
print FILE "<td></td>\n";
}
print FILE "</tr>\n";
}
}
print FILE "</tbody>\n</table>\n";
}
print FILE "<hr>$i factoids memorized.<br>";
print FILE "<hr><i>Last updated at $time</i>\n";
print FILE "<script type='text/javascript'>\n";
$table_id--;
print FILE '$(document).ready(function() {' . "\n";
while($table_id > 0) {
print FILE '$("#table' . $table_id . '").tablesorter();' . "\n";
print FILE '$("#table' . $table_id . '").tableFilter();' . "\n";
$table_id--;
}
print FILE "});\n";
print FILE "</script>\n";
print FILE "</body>\n</html>\n";
close(FILE);
#$self->{pbot}->logger->log("$i factoids exported to path: " . $self->export_path . ", site: " . $self->export_site . "\n");
return "$i factoids exported to " . $self->export_site;
}
sub find_factoid {
my ($self, $from, $keyword, $arguments, $exact_channel, $exact_trigger) = @_;
my $debug = 0;
$self->{pbot}->logger->log("find_factoid: from: [$from], kw: [$keyword], args: [" . (defined $arguments ? $arguments : "undef") . "], " . (defined $exact_channel ? $exact_channel : "undef") . ", " . (defined $exact_trigger ? $exact_trigger : "undef") . "\n") if $debug;
$from = '.*' if not defined $from or $from !~ /^#/;
$self->{pbot}->logger->log("from: $from\n") if $debug;
my $string = "$keyword" . (defined $arguments ? " $arguments" : "");
$self->{pbot}->logger->log("string: $string\n") if $debug;
my @result = eval {
foreach my $channel (sort keys %{ $self->factoids->hash }) {
if($exact_channel) {
next unless lc $from eq lc $channel or $from eq '.*' or $channel eq '.*';
}
foreach my $trigger (keys %{ $self->factoids->hash->{$channel} }) {
if(not $exact_trigger and $self->factoids->hash->{$channel}->{$trigger}->{type} eq 'regex') {
if($string =~ m/$trigger/i) {
$self->{pbot}->logger->log("return regex $channel: $trigger\n") if $debug;
return ($channel, $trigger);
}
} else {
if($keyword =~ m/^\Q$trigger\E$/i) {
$self->{pbot}->logger->log("return $channel: $trigger\n") if $debug;
return ($channel, $trigger);
}
}
}
}
$self->{pbot}->logger->log("find_factoid: no match\n") if $debug;
return undef;
};
if($@) {
$self->{pbot}->logger->log("find_factoid: bad regex: $@\n");
return undef;
}
return @result;
}
sub interpreter {
my $self = shift;
my ($from, $nick, $user, $host, $count, $keyword, $arguments, $tonick, $ref_from) = @_;
my ($result, $channel);
my $pbot = $self->{pbot};
return undef if not length $keyword or $count > 5;
$from = lc $from;
#$self->{pbot}->logger->log("factoids interpreter: from: [$from], ref_from: [" . (defined $ref_from ? $ref_from : "undef") . "]\n");
# search for factoid against global channel and current channel (from unless ref_from is defined)
my $original_keyword = $keyword;
#$self->{pbot}->logger->log("calling find_factoid in Factoids.pm, interpreter() to search for factoid against global/current\n");
($channel, $keyword) = $self->find_factoid($ref_from ? $ref_from : $from, $keyword, $arguments, 1);
if(not defined $ref_from or $ref_from eq '.*') {
$ref_from = "";
} else {
$ref_from = "[$ref_from] ";
}
if(defined $channel and not $channel eq '.*' and not lc $channel eq $from) {
$ref_from = "[$channel] ";
}
$arguments = "" if not defined $arguments;
# if no match found, attempt to call factoid from another channel if it exists there
if(not defined $keyword) {
my $chans = "";
my $comma = "";
my $found = 0;
my ($fwd_chan, $fwd_trig);
# build string of which channels contain the keyword, keeping track of the last one and count
foreach my $chan (keys %{ $self->factoids->hash }) {
foreach my $trig (keys %{ $self->factoids->hash->{$chan} }) {
if(lc $trig eq lc $original_keyword) {
$chans .= $comma . $chan;
$comma = ", ";
$found++;
$fwd_chan = $chan;
$fwd_trig = $trig;
last;
}
}
}
# if multiple channels have this keyword, then ask user to disambiguate
if($found > 1) {
return $ref_from . "Ambiguous keyword '$original_keyword' exists in multiple channels (use 'fact <channel> <keyword>' to choose one): $chans";
}
# if there's just one other channel that has this keyword, trigger that instance
elsif($found == 1) {
$pbot->logger->log("Found '$original_keyword' as '$fwd_trig' in [$fwd_chan]\n");
return $pbot->factoids->interpreter($from, $nick, $user, $host, ++$count, $fwd_trig, $arguments, $tonick, $fwd_chan);
}
# otherwise keyword hasn't been found, display similiar matches for all channels
else {
# if a non-nick argument was supplied, e.g., a sentence using the bot's nick, don't say anything
return "" if length $arguments and $arguments !~ /^[^.+-, ]{1,20}$/;
my $matches = $self->{pbot}->{factoidcmds}->factfind($from, $nick, $user, $host, quotemeta $original_keyword);
# found factfind matches
if($matches !~ m/^No factoids/) {
return "No such factoid '$original_keyword'; $matches";
}
# otherwise find levenshtein closest matches from all channels
$matches = $self->factoids->levenshtein_matches('.*', lc $original_keyword);
# don't say anything if nothing similiar was found
return undef if $matches eq 'none';
return $ref_from . "No such factoid '$original_keyword'; did you mean $matches?";
}
}
my $type = $self->factoids->hash->{$channel}->{$keyword}->{type};
# Check if it's an alias
if($self->factoids->hash->{$channel}->{$keyword}->{action} =~ /^\/call\s+(.*)$/) {
my $command;
if(length $arguments) {
$command = "$1 $arguments";
} else {
$command = $1;
}
$pbot->logger->log("[" . (defined $from ? $from : "stdin") . "] ($nick!$user\@$host) [$keyword] aliased to: [$command]\n");
$self->factoids->hash->{$channel}->{$keyword}->{ref_count}++;
$self->factoids->hash->{$channel}->{$keyword}->{ref_user} = "$nick!$user\@$host";
$self->factoids->hash->{$channel}->{$keyword}->{last_referenced_on} = gettimeofday;
return $pbot->interpreter->interpret($from, $nick, $user, $host, $count, $command, $tonick);
}
my $last_ref_in = 0;
if(exists $self->factoids->hash->{$channel}->{$keyword}->{last_referenced_on}) {
if(exists $self->factoids->hash->{$channel}->{$keyword}->{last_referenced_in}) {
if($self->factoids->hash->{$channel}->{$keyword}->{last_referenced_in} eq $from) {
$last_ref_in = 1;
}
}
if(($last_ref_in == 1) and (gettimeofday - $self->factoids->hash->{$channel}->{$keyword}->{last_referenced_on} < $self->factoids->hash->{$channel}->{$keyword}->{rate_limit})) {
return "/msg $nick $ref_from'$keyword' is rate-limited; try again in " . ($self->factoids->hash->{$channel}->{$keyword}->{rate_limit} - int(gettimeofday - $self->factoids->hash->{$channel}->{$keyword}->{last_referenced_on})) . " seconds.";
}
}
if($self->factoids->hash->{$channel}->{$keyword}->{enabled} == 0) {
$self->{pbot}->logger->log("$keyword disabled.\n");
return "/msg $nick $ref_from$keyword is currently disabled.";
}
elsif($self->factoids->hash->{$channel}->{$keyword}->{type} eq 'module') {
$self->{pbot}->logger->log("Found module\n");
$self->factoids->hash->{$channel}->{$keyword}->{ref_count}++;
$self->factoids->hash->{$channel}->{$keyword}->{ref_user} = "$nick!$user\@$host";
$self->factoids->hash->{$channel}->{$keyword}->{last_referenced_on} = gettimeofday;
$self->factoids->hash->{$channel}->{$keyword}->{last_referenced_in} = $from || "stdin";
my $preserve_whitespace = $self->factoids->hash->{$channel}->{$keyword}->{preserve_whitespace};
$preserve_whitespace = 0 if not defined $preserve_whitespace;
return $ref_from . $self->{factoidmodulelauncher}->execute_module($from, $tonick, $nick, $user, $host, "$keyword $arguments", $keyword, $arguments, $preserve_whitespace);
}
elsif($self->factoids->hash->{$channel}->{$keyword}->{type} eq 'text') {
$self->{pbot}->logger->log("Found factoid\n");
# Don't allow user-custom /msg factoids, unless factoid triggered by admin
if(($self->factoids->hash->{$channel}->{$keyword}->{action} =~ m/^\/msg/i) and (not $self->{pbot}->admins->loggedin($from, "$nick!$user\@$host"))) {
$self->{pbot}->logger->log("[ABUSE] Bad factoid (contains /msg): " . $self->factoids->hash->{$channel}->{$keyword}->{action} . "\n");
return "You must login to use this command."
}
$self->factoids->hash->{$channel}->{$keyword}->{ref_count}++;
$self->factoids->hash->{$channel}->{$keyword}->{ref_user} = "$nick!$user\@$host";
$self->factoids->hash->{$channel}->{$keyword}->{last_referenced_on} = gettimeofday;
$self->factoids->hash->{$channel}->{$keyword}->{last_referenced_in} = $from || "stdin";
$result = $self->factoids->hash->{$channel}->{$keyword}->{action};
if(length $arguments) {
if(exists $self->factoids->hash->{$channel}->{$keyword}->{action_with_args}) {
$result = $self->factoids->hash->{$channel}->{$keyword}->{action_with_args};
}
if(not $result =~ s/\$args/$arguments/gi and not exists $self->factoids->hash->{$channel}->{$keyword}->{action_with_args}) {
# factoid doesn't take an argument, so assume argument is a nick if it is a single-word 20 characters or less
# TODO - maintain list of channel nicks and compare against this list to ensure nick exists
if($arguments =~ /^[^.+-, ]{1,20}$/) {
# might be a nick
if($result =~ /^\/.+? /) {
$result =~ s/^(\/.+?) /$1 $arguments: /;
} else {
$result =~ s/^/\/say $arguments: $keyword is / unless defined $tonick;
}
} else {
# return "";
}
}
} else {
# no arguments supplied
if(defined $tonick) {
$result =~ s/\$args/$tonick/gi;
} else {
$result =~ s/\$args/$nick/gi;
}
}
if(defined $tonick) { # !tell foo about bar
$self->{pbot}->logger->log("($from): $nick!$user\@$host) sent to $tonick\n");
my $botnick = $self->{pbot}->{registry}->get_value('irc', 'botnick');
# get rid of original caller's nick
$result =~ s/^\/([^ ]+) \Q$nick\E:\s+/\/$1 /;
$result =~ s/^\Q$nick\E:\s+//;
if($result =~ s/^\/say\s+//i || $result =~ s/^\/me\s+/* $botnick /i
|| $result =~ /^\/msg\s+/i) {
$result = "/say $tonick: $result";
} else {
$result = "/say $tonick: $keyword is $result";
}
$self->{pbot}->logger->log("result set to [$result]\n");
}
$self->{pbot}->logger->log("(" . (defined $from ? $from : "(undef)") . "): $nick!$user\@$host: $keyword: Displaying text \"" . $result . "\"\n");
$result =~ s/\$nick/$nick/g;
$result =~ s/\$channel/$from/g;
while ($result =~ /[^\\]\$([a-zA-Z0-9_\-]+)/g) {
#$self->{pbot}->logger->log("adlib: looking for [$1]\n");
#$self->{pbot}->logger->log("calling find_factoid in Factoids.pm, interpreter() to look for adlib");
my ($var_chan, $var) = $self->find_factoid($from, $1, undef, 0, 1);
if(defined $var && $self->factoids->hash->{$var_chan}->{$var}->{type} eq 'text') {
my $change = $self->factoids->hash->{$var_chan}->{$var}->{action};
my @list = split(/\s|(".*?")/, $change);
my @mylist;
#$self->{pbot}->logger->log("adlib: list [". join(':', @mylist) ."]\n");
for(my $i = 0; $i <= $#list; $i++) {
#$self->{pbot}->logger->log("adlib: pushing $i $list[$i]\n");
push @mylist, $list[$i] if $list[$i];
}
my $line = int(rand($#mylist + 1));
$mylist[$line] =~ s/"//g;
$result =~ s/\$$var/$mylist[$line]/;
#$self->{pbot}->logger->log("adlib: found: change: $result\n");
} else {
$result =~ s/\$$var/$var/g;
#$self->{pbot}->logger->log("adlib: not found: change: $result\n");
}
}
$result =~ s/\\\$/\$/g;
if($ref_from) {
if($result =~ s/^\/say\s+/$ref_from/i || $result =~ s/^\/me\s+(.*)/\/me $1 $ref_from/i
|| $result =~ s/^\/msg\s+([^ ]+)/\/msg $1 $ref_from/i) {
return $result;
} else {
return $ref_from . "$keyword is $result";
}
} else {
if($result =~ m/^\/say/i || $result =~ m/^\/me/i || $result =~ m/^\/msg/i) {
return $result;
} else {
return "$keyword is $result";
}
}
} elsif($self->factoids->hash->{$channel}->{$keyword}->{type} eq 'regex') {
$result = eval {
my $string = "$original_keyword" . (defined $arguments ? " $arguments" : "");
my $cmd;
if($string =~ m/$keyword/i) {
$self->{pbot}->logger->log("[$string] matches [$keyword] - calling [" . $self->factoids->hash->{$channel}->{$keyword}->{action} . "$']\n");
$cmd = $self->factoids->hash->{$channel}->{$keyword}->{action} . $';
my ($a, $b, $c, $d, $e, $f, $g, $h, $i, $before, $after) = ($1, $2, $3, $4, $5, $6, $7, $8, $9, $`, $');
$cmd =~ s/\$1/$a/g;
$cmd =~ s/\$2/$b/g;
$cmd =~ s/\$3/$c/g;
$cmd =~ s/\$4/$d/g;
$cmd =~ s/\$5/$e/g;
$cmd =~ s/\$6/$f/g;
$cmd =~ s/\$7/$g/g;
$cmd =~ s/\$8/$h/g;
$cmd =~ s/\$9/$i/g;
$cmd =~ s/\$`/$before/g;
$cmd =~ s/\$'/$after/g;
$cmd =~ s/^\s+//;
$cmd =~ s/\s+$//;
} else {
$cmd = $self->factoids->hash->{$channel}->{$keyword}->{action};
}
$result = $pbot->interpreter->interpret($from, $nick, $user, $host, $count, $cmd, $tonick);
return $result;
};
if($@) {
$self->{pbot}->logger->log("Regex fail: $@\n");
return "/msg $nick $ref_from" . "Fail.";
}
return $ref_from . $result;
} else {
$self->{pbot}->logger->log("($from): $nick!$user\@$host): Unknown command type for '$keyword'\n");
return "/me blinks." . " $ref_from";
}
# should never be reached; if it has, something has gone horribly wrong.
# (advanced notification of corruption or a waste of space?)
return "/me wrinkles her nose." . " $ref_from";
}
sub export_path {
my $self = shift;
if(@_) { $self->{export_path} = shift; }
return $self->{export_path};
}
sub logger {
my $self = shift;
if(@_) { $self->{logger} = shift; }
return $self->{logger};
}
sub export_site {
my $self = shift;
if(@_) { $self->{export_site} = shift; }
return $self->{export_site};
}
sub factoids {
my $self = shift;
return $self->{factoids};
}
sub filename {
my $self = shift;
if(@_) { $self->{filename} = shift; }
return $self->{filename};
}
1;