diff --git a/lib/PBot/Core/Factoids/Code.pm b/lib/PBot/Core/Factoids/Code.pm new file mode 100644 index 00000000..27f3b887 --- /dev/null +++ b/lib/PBot/Core/Factoids/Code.pm @@ -0,0 +1,84 @@ +# File: Code.pm +# +# Purpose: Launching pad for code factoids. Configures $context as a code +# factoid and executes the compiler-vm module. + +# SPDX-FileCopyrightText: 2021 Pragmatic Software +# SPDX-License-Identifier: MIT + +package PBot::Core::Factoids::Code; +use parent 'PBot::Core::Class'; + +use PBot::Imports; + +use JSON; + +sub initialize {} + +sub execute { + my ($self, $context) = @_; + + my $factoids = $self->{pbot}->{factoids}->{data}->{storage}; + + my $interpolate = $factoids->get_data($context->{channel}, $context->{keyword}, 'interpolate'); + + unless (defined $interpolate and not $interpolate) { + if ($context->{code} =~ m/(?:\$\{?nick\b|\$\{?args\b|\$\{?arg\[)/ and length $context->{arguments}) { + # disable nick overriding + $context->{nickprefix_disabled} = 1; + } else { + # allow nick overriding + $context->{nickprefix_disabled} = 0; + } + + my $variables = $self->{pbot}->{factoids}->{variables}; + + $context->{code} = $variables->expand_factoid_vars($context, $context->{code}); + + if ($factoids->get_data($context->{channel}, $context->{keyword}, 'allow_empty_args')) { + $context->{code} = $variables->expand_action_arguments($context->{code}, $context->{arguments}, ''); + } else { + $context->{code} = $variables->expand_action_arguments($context->{code}, $context->{arguments}, $context->{nick}); + } + } else { + # otherwise allow nick overriding + $context->{nickprefix_disabled} = 0; + } + + # set up `compiler` module arguments + my %args = ( + nick => $context->{nick}, + channel => $context->{from}, + lang => $context->{lang}, + code => $context->{code}, + arguments => $context->{arguments}, + factoid => "$context->{channel}:$context->{keyword}", + ); + + # the vm can persist filesystem data to external storage identified by a key. + # if the `persist-key` factoid metadata is set, then use this key. + my $persist_key = $factoids->get_data($context->{channel}, $context->{keyword}, 'persist-key'); + + if (defined $persist_key) { + $args{'persist-key'} = $persist_key; + } + + # encode args to utf8 json string + my $json = encode_json \%args; + + # update context details + $context->{special} = 'code-factoid'; # ensure handle_result(), etc, process this as a code-factoid + $context->{root_channel} = $context->{channel}; # override root channel to current channel + $context->{keyword} = 'compiler'; # code-factoid uses `compiler` command to invoke vm + $context->{arguments} = $json; # set arguments to json string as `compiler` wants + $context->{args_utf8} = 1; # arguments are utf8 encoded by encode_json + + # launch the `compiler` module + $self->{pbot}->{modules}->execute_module($context); + + # return empty string since the module process reader will + # pass the output along to the result handler + return ''; +} + +1; diff --git a/lib/PBot/Core/Factoids/Data.pm b/lib/PBot/Core/Factoids/Data.pm new file mode 100644 index 00000000..f7b1ea7d --- /dev/null +++ b/lib/PBot/Core/Factoids/Data.pm @@ -0,0 +1,268 @@ +# File: Data.pm +# +# Purpose: Implements factoid data-related functions. + +# SPDX-FileCopyrightText: 2021 Pragmatic Software +# SPDX-License-Identifier: MIT + +package PBot::Core::Factoids::Data; +use parent 'PBot::Core::Class'; + +use PBot::Imports; + +use Time::HiRes qw(gettimeofday); + +our %factoid_metadata = ( + 'action' => 'TEXT', + 'action_with_args' => 'TEXT', + 'add_nick' => 'INTEGER', + 'allow_empty_args' => 'INTEGER', + 'background-process' => 'INTEGER', + 'cap-override' => 'TEXT', + 'created_on' => 'NUMERIC', + 'dont-protect-self' => 'INTEGER', + 'dont-replace-pronouns' => 'INTEGER', + 'edited_by' => 'TEXT', + 'edited_on' => 'NUMERIC', + 'enabled' => 'INTEGER', + 'help' => 'TEXT', + 'interpolate' => 'INTEGER', + 'keyword_override' => 'TEXT', + 'last_referenced_in' => 'TEXT', + 'last_referenced_on' => 'NUMERIC', + 'locked' => 'INTEGER', + 'locked_to_channel' => 'INTEGER', + 'no_keyword_override' => 'INTEGER', + 'noembed' => 'INTEGER', + 'nooverride' => 'INTEGER', + 'owner' => 'TEXT', + 'persist-key' => 'INTEGER', + 'preserve_whitespace' => 'INTEGER', + 'process-timeout' => 'INTEGER', + 'rate_limit' => 'INTEGER', + 'ref_count' => 'INTEGER', + 'ref_user' => 'TEXT', + 'require_explicit_args' => 'INTEGER', + 'requires_arguments' => 'INTEGER', + 'type' => 'TEXT', + 'unquote_spaces' => 'INTEGER', + 'usage' => 'TEXT', + 'use_output_queue' => 'INTEGER', + 'workdir' => 'TEXT', +); + +sub initialize { + my ($self, %conf) = @_; + + my $filename = $conf{filename}; + + $self->{storage} = PBot::Core::Storage::DualIndexSQLiteObject->new( + pbot => $self->{pbot}, + name => 'Factoids', + filename => $filename, + ); +} + +sub load { + my ($self) = @_; + $self->{storage}->load; + $self->{storage}->create_metadata(\%factoid_metadata); +} + +sub save { + my ($self, $export) = @_; + $self->{storage}->save; + $self->{pbot}->{factoids}->{exporter}->export if $export; +} + +sub add { + my ($self, $type, $channel, $owner, $trigger, $action, $dont_save) = @_; + + $type = lc $type; + $channel = '.*' if $channel !~ /^#/; + + my $data; + if ($self->{storage}->exists($channel, $trigger)) { + # only update action field if force-adding it through factadd -f + $data = $self->{storage}->get_data($channel, $trigger); + + $data->{action} = $action; + $data->{type} = $type; + } else { + $data = { + enabled => 1, + type => $type, + action => $action, + owner => $owner, + created_on => scalar gettimeofday, + ref_count => 0, + ref_user => "nobody", + rate_limit => $self->{pbot}->{registry}->get_value('factoids', 'default_rate_limit'), + last_referenced_in => '', + }; + } + + $self->{storage}->add($channel, $trigger, $data, $dont_save); +} + +sub remove { + my $self = shift; + my ($channel, $trigger) = @_; + $channel = '.*' if $channel !~ /^#/; + return $self->{storage}->remove($channel, $trigger); +} + +sub get_meta { + my ($self, $channel, $trigger, $key) = @_; + return $self->{storage}->get_data($channel, $trigger, $key); +} + +sub find { + my ($self, $from, $keyword, %opts) = @_; + + my %default_opts = ( + arguments => '', + exact_channel => 0, + exact_trigger => 0, + find_alias => 0 + ); + + %opts = (%default_opts, %opts); + + my $debug = 0; + + $from = '.*' if not defined $from or $from !~ /^#/; + $from = lc $from; + $keyword = lc $keyword; + + my $arguments = $opts{arguments}; + + my @result = eval { + my @results; + my ($channel, $trigger); + for (my $depth = 0; $depth < 15; $depth++) { + my $action; + my $string = $keyword . (length $arguments ? " $arguments" : ''); + $self->{pbot}->{logger}->log("string: $string\n") if $debug; + + if ($opts{exact_channel} and $opts{exact_trigger}) { + if ($self->{storage}->exists($from, $keyword)) { + ($channel, $trigger) = ($from, $keyword); + goto CHECK_ALIAS; + } + + if ($opts{exact_trigger} > 1 and $self->{storage}->exists('.*', $keyword)) { + ($channel, $trigger) = ('.*', $keyword); + goto CHECK_ALIAS; + } + + goto CHECK_REGEX; + } + + if ($opts{exact_channel} and not $opts{exact_trigger}) { + if (not $self->{storage}->exists($from, $keyword)) { + ($channel, $trigger) = ($from, $keyword); + goto CHECK_REGEX if $from eq '.*'; + goto CHECK_REGEX if not $self->{storage}->exists('.*', $keyword); + ($channel, $trigger) = ('.*', $keyword); + goto CHECK_ALIAS; + } + ($channel, $trigger) = ($from, $keyword); + goto CHECK_ALIAS; + } + + if (not $opts{exact_channel}) { + foreach my $factoid ($self->{storage}->get_all("index2 = $keyword", 'index1', 'action')) { + $channel = $factoid->{index1}; + $trigger = $keyword; + + if ($opts{find_alias} && $factoid->{action} =~ m{^/call\s+(.*)$}ms) { + goto CHECK_ALIAS; + } + + push @results, [$channel, $trigger]; + } + + goto CHECK_REGEX; + } + + CHECK_ALIAS: + if ($opts{find_alias}) { + $action = $self->{storage}->get_data($channel, $trigger, 'action') if not defined $action; + if ($action =~ m{^/call\s+(.*)$}ms) { + my $command; + if (length $arguments) { + $command = "$1 $arguments"; + } else { + $command = $1; + } + my $arglist = $self->{pbot}->{interpreter}->make_args($command); + ($keyword, $arguments) = $self->{pbot}->{interpreter}->split_args($arglist, 2, 0, 1); + goto NEXT_DEPTH; + } + } + + if ($opts{exact_channel} == 1) { + return ($channel, $trigger); + } else { + push @results, [$channel, $trigger]; + } + + CHECK_REGEX: + if (not $opts{exact_trigger}) { + my @factoids; + + if ($opts{exact_channel}) { + if ($channel ne '.*') { + @factoids = $self->{storage}->get_all('type = regex', "index1 = $channel", 'OR index1 = .*', 'index2', 'action'); + } else { + @factoids = $self->{storage}->get_all('type = regex', "index1 = $channel", 'index2', 'action'); + } + } else { + @factoids = $self->{storage}->get_all('type = regex', 'index1', 'index2', 'action'); + } + + foreach my $factoid (@factoids) { + $channel = $factoid->{index1}; + $trigger = $factoid->{index2}; + $action = $factoid->{action}; + + if ($string =~ /$trigger/) { + if ($opts{find_alias}) { + my $command = $action; + my $arglist = $self->{pbot}->{interpreter}->make_args($command); + ($keyword, $arguments) = $self->{pbot}->{interpreter}->split_args($arglist, 2, 0, 1); + goto NEXT_DEPTH; + } + + if ($opts{exact_channel} == 1) { return ($channel, $trigger); } + else { push @results, [$channel, $trigger]; } + } + } + } + + # match not found + last; + + NEXT_DEPTH: + last if not $opts{find_alias}; + } + + if ($debug) { + if (not @results) { $self->{pbot}->{logger}->log("Factoids: find: no match\n"); } + else { + $self->{pbot}->{logger}->log("Factoids: find: got results: " . (join ', ', map { "$_->[0] -> $_->[1]" } @results) . "\n"); + } + } + return @results; + }; + + if ($@) { + $self->{pbot}->{logger}->log("Factoids: error in find: $@\n"); + return undef; + } + + return @result; +} + +1; diff --git a/lib/PBot/Core/Factoids/Exporter.pm b/lib/PBot/Core/Factoids/Exporter.pm new file mode 100644 index 00000000..7cb04d66 --- /dev/null +++ b/lib/PBot/Core/Factoids/Exporter.pm @@ -0,0 +1,176 @@ +# File: Exporter.pm +# +# Purpose: Exports factoids to HTML. + +# SPDX-FileCopyrightText: 2021 Pragmatic Software +# SPDX-License-Identifier: MIT + +package PBot::Core::Factoids::Exporter; +use parent 'PBot::Core::Class'; + +use PBot::Imports; + +use HTML::Entities; +use POSIX qw(strftime); + +sub initialize { +} + +sub export { + my $self = shift; + + my $filename; + + if (@_) { + $filename = shift; + } else { + $filename = $self->{pbot}->{registry}->get_value('general', 'data_dir') . '/factoids.html'; + } + + if (not defined $filename) { + $self->{pbot}->{logger}->log("Factoids: export: no filename, skipping export.\n"); + return "No file to export to."; + } + + if (not defined $self->{pbot}->{factoids}->{data}->{storage}->{dbh}) { + $self->{pbot}->{logger}->log("Factoids: export: database closed, skipping export.\n"); + return "Factoids database closed; can't export."; + } + + $self->{pbot}->{logger}->log("Exporting factoids to $filename\n"); + + if (not open FILE, "> $filename") { + $self->{pbot}->{logger}->log("Could not open export file: $!\n"); + return "Could not open export file: $!"; + } + + my $botnick = $self->{pbot}->{registry}->get_value('irc', 'botnick'); + + my $factoids = $self->{pbot}->{factoids}->{data}->{storage}; + + my $time = localtime; + + print FILE "\n\n"; + print FILE '' . "\n"; + print FILE '' . "\n"; + print FILE '' . "\n"; + print FILE "\nLast updated at $time\n"; + print FILE "

$botnick\'s factoids

\n"; + + my $i = 0; + my $table_id = 1; + + foreach my $channel (sort $factoids->get_keys) { + next if not $factoids->get_keys($channel); + + my $chan = $factoids->get_data($channel, '_name'); + $chan = 'global' if $chan eq '.*'; + + print FILE "" . encode_entities($chan) . "
\n"; + } + + foreach my $channel (sort $factoids->get_keys) { + next if not $factoids->get_keys($channel); + + my $chan = $factoids->get_data($channel, '_name'); + $chan = 'global' if $chan eq '.*'; + + print FILE "\n"; + print FILE "
\n

" . encode_entities($chan) . "

\n
\n"; + print FILE "\n"; + print FILE "\n\n"; + print FILE "\n"; + print FILE "\n"; + print FILE "\n"; + print FILE "\n"; + print FILE "\n"; + print FILE "\n"; + print FILE "\n"; + print FILE "\n"; + print FILE "\n\n\n"; + + $table_id++; + + my $iter = $factoids->get_each("index1 = $channel", '_everything', '_sort = index1'); + + while (defined (my $factoid = $factoids->get_next($iter))) { + my $trigger_name = $factoids->get_data($factoid->{index1}, $factoid->{index2}, '_name'); + + if ($factoid->{type} eq 'text') { + $i++; + + if ($i % 2) { + print FILE "\n"; + } else { + print FILE "\n"; + } + + print FILE "\n"; + print FILE "\n"; + + print FILE "\n"; + + my $action = $factoid->{'action'}; + + if ($action =~ m/https?:\/\/[^ ]+/) { + $action =~ s/(.*?)http(s?:\/\/[^ ]+)/encode_entities($1) . "http" . encode_entities($2) . "<\/a>"/ge; + $action =~ s/(.*)<\/a>(.*$)/"$1<\/a>" . encode_entities($2)/e; + } else { + $action = encode_entities($action); + } + + if (defined $factoid->{'action_with_args'}) { + my $with_args = $factoid->{'action_with_args'}; + $with_args =~ s/(.*?)http(s?:\/\/[^ ]+)/encode_entities($1) . "http" . encode_entities($2) . "<\/a>"/ge; + $with_args =~ s/(.*)<\/a>(.*$)/"$1<\/a>" . encode_entities($2)/e; + print FILE "\n"; + } else { + print FILE "\n"; + } + + if (defined $factoid->{'edited_by'}) { + print FILE "\n"; + print FILE "\n"; + } else { + print FILE "\n"; + print FILE "\n"; + } + + print FILE "\n"; + + if (defined $factoid->{'last_referenced_on'}) { + print FILE "\n"; + } else { + print FILE "\n"; + } + + print FILE "\n"; + } + } + + print FILE "\n
ownercreated ontimes referencedfactoidlast edited byedited datelast referenced bylast referenced date
" . encode_entities($factoid->{'owner'}) . "" . encode_entities(strftime "%Y/%m/%d %H:%M:%S", localtime $factoid->{'created_on'}) . "" . $factoid->{'ref_count'} . "" . encode_entities($trigger_name) . " is $action

with_args: " . encode_entities($with_args) . "
" . encode_entities($trigger_name) . " is $action" . $factoid->{'edited_by'} . "" . encode_entities(strftime "%Y/%m/%d %H:%M:%S", localtime $factoid->{'edited_on'}) . "" . encode_entities($factoid->{'ref_user'}) . "" . encode_entities(strftime "%Y/%m/%d %H:%M:%S", localtime $factoid->{'last_referenced_on'}) . "
\n"; + } + + print FILE "
$i factoids memorized.
"; + print FILE "
Last updated at $time\n"; + + print FILE "\n"; + print FILE "\n\n"; + + close FILE; + + return "/say $i factoids exported."; +} + +1; diff --git a/lib/PBot/Core/Factoids/Interpreter.pm b/lib/PBot/Core/Factoids/Interpreter.pm new file mode 100644 index 00000000..b462e650 --- /dev/null +++ b/lib/PBot/Core/Factoids/Interpreter.pm @@ -0,0 +1,514 @@ +# File: Interpreter.pm +# +# Purpose: Provides functionality for factoids. + +# SPDX-FileCopyrightText: 2021 Pragmatic Software +# SPDX-License-Identifier: MIT + +package PBot::Core::Factoids::Interpreter; +use parent 'PBot::Core::Class'; + +use PBot::Imports; + +use Time::HiRes qw(gettimeofday); +use Time::Duration qw(duration); + +sub initialize {} + +# main entry point for PBot::Core::Interpreter to interpret a factoid command +sub interpreter { + my ($self, $context) = @_; + + # trace context and context's contents + if ($self->{pbot}->{registry}->get_value('general', 'debugcontext')) { + use Data::Dumper; + $Data::Dumper::Sortkeys = 1; + $self->{pbot}->{logger}->log("Factoids::interpreter\n"); + $self->{pbot}->{logger}->log(Dumper $context); + } + + if (not length $context->{keyword}) { + $self->{pbot}->{logger}->log("Factoids: interpreter: no keyword.\n"); + return; + } + + if ($context->{interpret_depth} > $self->{pbot}->{registry}->get_value('interpreter', 'max_recursion')) { + $self->{pbot}->{logger}->log("Factoids: interpreter: max-recursion.\n"); + return; + } + + my $strictnamespace = $self->{pbot}->{registry}->get_value($context->{from}, 'strictnamespace'); + + $strictnamespace //= $self->{pbot}->{registry}->get_value('general', 'strictnamespace'); + + # search for factoid against global channel and current channel (from unless ref_from is defined) + my $original_keyword = $context->{keyword}; + + my ($channel, $keyword) = + $self->{pbot}->{factoids}->{data}->find( + $context->{ref_from} ? $context->{ref_from} : $context->{from}, + $context->{keyword}, + arguments => $context->{arguments}, + exact_channel => 1, + ); + + # determine if we prepend [channel] to factoid output + if (not defined $context->{ref_from} + or $context->{ref_from} eq '.*' + or $context->{ref_from} eq $context->{from}) + { + $context->{ref_from} = ''; + } + + if (defined $channel and not $channel eq '.*' + and not $channel eq lc $context->{from}) + { + $context->{ref_from} = $channel; + } + + # factoid > nick redirection + my $nick_regex = $self->{pbot}->{registry}->get_value('regex', 'nickname'); + + if ($context->{arguments} =~ s/> ($nick_regex)$//) { + my $rcpt = $1; + if ($self->{pbot}->{nicklist}->is_present($context->{from}, $rcpt)) { + $context->{nickprefix} = $rcpt; + $context->{nickprefix_forced} = 1; + } else { + $context->{arguments} .= "> $rcpt"; + } + } + + # if no match found, attempt to call factoid from another channel if it exists there + if (not defined $keyword) { + my $string = "$original_keyword $context->{arguments}"; + + my @chanlist = (); + my ($fwd_chan, $fwd_trig); + + unless ($strictnamespace) { + # build list of which channels contain the keyword, keeping track of the last one and count + foreach my $factoid ($self->{pbot}->{factoids}->{data}->{storage}->get_all("index2 = $original_keyword", 'index1', 'type')) { + next if $factoid->{type} ne 'text' and $factoid->{type} ne 'module'; + push @chanlist, $self->{pbot}->{factoids}->{data}->{storage}->get_data($factoid->{index1}, '_name'); + $fwd_chan = $factoid->{index1}; + $fwd_trig = $original_keyword; + } + } + + my $ref_from = $context->{ref_from} ? "[$context->{ref_from}] " : ''; + + # if multiple channels have this keyword, then ask user to disambiguate + if (@chanlist> 1) { + return if $context->{embedded}; + return $ref_from . "Factoid `$original_keyword` exists in " . join(', ', @chanlist) . "; use `fact $original_keyword` to choose one."; + } + + # if there's just one other channel that has this keyword, trigger that instance + elsif (@chanlist == 1) { + $self->{pbot}->{logger}->log("Found '$original_keyword' as '$fwd_trig' in [$fwd_chan]\n"); + $context->{keyword} = $fwd_trig; + $context->{interpret_depth}++; + $context->{ref_from} = $fwd_chan; + return $self->interpreter($context); + } + + # otherwise keyword hasn't been found, display similiar matches for all channels + else { + my $namespace = $strictnamespace ? $context->{from} : '.*'; + $namespace = '.*' if $namespace !~ /^#/; + + my $namespace_regex = $namespace; + if ($strictnamespace) { $namespace_regex = "(?:" . (quotemeta $namespace) . '|\\.\\*)'; } + + $context->{arguments} = quotemeta($original_keyword) . " -channel $namespace_regex"; + my $matches = $self->{pbot}->{commands}->{modules}->{Factoids}->cmd_factfind($context); + + # found factfind matches + if ($matches !~ m/^No factoids/) { + return if $context->{embedded}; + return "No such factoid '$original_keyword'; $matches"; + } + + # otherwise find levenshtein closest matches + $matches = $self->{pbot}->{factoids}->{data}->{storage}->levenshtein_matches($namespace, lc $original_keyword, 0.50, $strictnamespace); + + # if a non-nick argument was supplied, e.g., a sentence using the bot's nick, /msg the error to the caller + if (length $context->{arguments} and not $self->{pbot}->{nicklist}->is_present($context->{from}, $context->{arguments})) { + $context->{send_msg_to_caller} = 1; + } + + # /msg the caller if nothing similiar was found + $context->{send_msg_to_caller} = 1 if $matches eq 'none'; + $context->{send_msg_to_caller} = 1 if $context->{embedded}; + + my $msg_caller = ''; + $msg_caller = "/msg $context->{nick} " if $context->{send_msg_to_caller}; + + my $ref_from = $context->{ref_from} ? "[$context->{ref_from}] " : ''; + + if ($matches eq 'none') { + return $msg_caller . $ref_from . "No such factoid '$original_keyword'; no similar matches."; + } else { + return $msg_caller . $ref_from . "No such factoid '$original_keyword'; did you mean $matches?"; + } + } + } + + my $channel_name = $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, '_name'); + my $trigger_name = $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, '_name'); + + $channel_name = 'global' if $channel_name eq '.*'; + $trigger_name = "\"$trigger_name\"" if $trigger_name =~ / /; + + $context->{keyword} = $keyword; + $context->{trigger} = $keyword; + $context->{channel} = $channel; + $context->{original_keyword} = $original_keyword; + $context->{channel_name} = $channel_name; + $context->{trigger_name} = $trigger_name; + + if ($context->{embedded} and $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'noembed')) { + $self->{pbot}->{logger}->log("Factoids: interpreter: ignoring $channel.$keyword due to noembed.\n"); + return; + } + + if ($self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'locked_to_channel')) { + if ($context->{ref_from} ne '') { # called from another channel + return "$trigger_name may be invoked only in $context->{ref_from}."; + } + } + + # rate-limiting + if ($context->{interpret_depth} <= 1 + and $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'last_referenced_in') eq $context->{from}) + { + my $ratelimit = $self->{pbot}->{registry}->get_value($context->{from}, 'ratelimit_override'); + + $ratelimit //= $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'rate_limit'); + + if (gettimeofday - $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'last_referenced_on') < $ratelimit) { + my $ref_from = $context->{ref_from} ? "[$context->{ref_from}] " : ''; + + unless ($self->{pbot}->{users}->loggedin_admin($channel, "$context->{nick}!$context->{user}\@$context->{host}")) { + return "/msg $context->{nick} $ref_from'$trigger_name' is rate-limited; try again in " + . duration($ratelimit - int(gettimeofday - $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'last_referenced_on'))) . "." + } + } + } + + # update factoid reference-related metadata + my $ref_count = $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'ref_count'); + + my $update_data = { + ref_count => ++$ref_count, + ref_user => "$context->{nick}!$context->{user}\@$context->{host}", + last_referenced_on => scalar gettimeofday, + last_referenced_in => $context->{from} || 'stdin', + }; + + $self->{pbot}->{factoids}->{data}->{storage}->add($channel, $keyword, $update_data, 1); + + # show usage if usage metadata exists and context has no arguments + if ($self->{pbot}->{factoids}->{data}->{storage}->exists($channel, $keyword, 'usage') + and not length $context->{arguments}) + { + my $usage = $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'usage'); + $usage =~ s/(?{alldone} = 1; + return $usage; + } + + # factoid action + my $action; + + # action_with_args or regular action? + if (length $context->{arguments} and $self->{pbot}->{factoids}->{data}->{storage}->exists($channel, $keyword, 'action_with_args')) { + $action = $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'action_with_args'); + } else { + $action = $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'action'); + } + + # action is a code factoid + if ($action =~ m{^/code\s+([^\s]+)\s+(.+)$}msi) { + my ($lang, $code) = ($1, $2); + $context->{lang} = $lang; + $context->{code} = $code; + $self->{pbot}->{factoids}->{code}->execute($context); + return ''; + } + + # fork factoid if background-process is enabled + if ($self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'background-process')) { + my $timeout = $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'process-timeout'); + + $timeout //= $self->{pbot}->{registry}->get_value('processmanager', 'default_timeout'); + + $self->{pbot}->{process_manager}->execute_process( + $context, + sub { $context->{result} = $self->handle_action($context, $action); }, + $timeout, + ); + + return ''; + } else { + return $self->handle_action($context, $action); + } +} + +sub handle_action { + my ($self, $context, $action) = @_; + + # trace context and context's contents + if ($self->{pbot}->{registry}->get_value('general', 'debugcontext')) { + use Data::Dumper; + $Data::Dumper::Sortkeys = 1; + $self->{pbot}->{logger}->log("Factoids::handle_action [$action]\n"); + $self->{pbot}->{logger}->log(Dumper $context); + } + + if (not length $action) { + $self->{pbot}->{logger}->log("Factoids: handle_action: no action.\n"); + return ''; + } + + my ($channel, $keyword) = ($context->{channel}, $context->{trigger}); + + my ($channel_name, $trigger_name) = ($context->{channel_name}, $context->{trigger_name}); + + my $ref_from = ''; + + unless ($context->{pipe} or $context->{subcmd}) { + $ref_from = $context->{ref_from} ? "[$context->{ref_from}] " : ''; + } + + my $interpolate = $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'interpolate'); + + if (defined $interpolate and not $interpolate) { + $context->{interpolate} = 0; + } else { + $context->{interpolate} = 1; + } + + if ($context->{interpolate}) { + my ($root_channel, $root_keyword) = $self->{pbot}->{factoids}->{data}->find( + $context->{ref_from} ? $context->{ref_from} : $context->{from}, + $context->{root_keyword}, + arguments => $context->{arguments}, + exact_channel => 1, + ); + + if (not defined $root_channel or not defined $root_keyword) { + $root_channel = $channel; + $root_keyword = $keyword; + } + + if (not length $context->{keyword_override} + and length $self->{pbot}->{factoids}->{data}->{storage}->get_data($root_channel, $root_keyword, 'keyword_override')) + { + $context->{keyword_override} = $self->{pbot}->{factoids}->{data}->{storage}->get_data($root_channel, $root_keyword, 'keyword_override'); + } + + $action = $self->{pbot}->{factoids}->{variables}->expand_factoid_vars($context, $action); + } + + # handle arguments + if (length $context->{arguments}) { + # arguments supplied + if ($action =~ m/\$\{?args/ or $action =~ m/\$\{?arg\[/) { + # factoid has $args, replace them + if ($context->{interpolate}) { + $action = $self->{pbot}->{factoids}->{variables}->expand_action_arguments($action, $context->{arguments}, $context->{nick}); + } + + $context->{arguments} = ''; + $context->{original_arguments} = ''; + } else { + # set nickprefix if args is a present nick and factoid action doesn't have $nick + if ($self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'type') eq 'text') { + my $target = $self->{pbot}->{nicklist}->is_present_similar($context->{from}, $context->{arguments}); + + if ($target and $action !~ /\$\{?nick\b/) { + $context->{nickprefix} = $target unless $context->{nickprefix_forced}; + $context->{nickprefix_disabled} = 0; + } + } + } + } else { + # no arguments supplied + if ($self->{pbot}->{factoids}->{data}->{storage}->exists($channel, $keyword, 'usage')) { + # factoid has a usage message, show it + $action = "/say " . $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'usage'); + $action =~ s/(?{alldone} = 1; + } else { + if ($self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'allow_empty_args')) { + $action = $self->{pbot}->{factoids}->{variables}->expand_action_arguments($action, undef, ''); + } else { + $action = $self->{pbot}->{factoids}->{variables}->expand_action_arguments($action, undef, $context->{nick}); + } + } + + $context->{nickprefix_disabled} = 0; + } + + # Check if it's an alias + if ($action =~ /^\/call\s+(.*)$/msi) { + my $command = $1; + $command =~ s/\n$//; + + unless ($self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'require_explicit_args')) { + my $args = $context->{arguments}; + $command .= " $args" if length $args and not $context->{special} eq 'code-factoid'; + $context->{arguments} = ''; + } + + unless ($self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'no_keyword_override')) { + if ($command =~ s/\s*--keyword-override=([^ ]+)\s*//) { $context->{keyword_override} = $1; } + } + + $context->{command} = $command; + $context->{aliased} = 1; + + $self->{pbot}->{logger}->log("$context->{from}: $context->{nick}!$context->{user}\@$context->{host}: $trigger_name aliased to: $command\n"); + + if (defined $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'cap-override')) { + if ($self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'locked')) { + $self->{pbot}->{logger}->log("Capability override set to " . $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'cap-override') . "\n"); + $context->{'cap-override'} = $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'cap-override'); + } else { + $self->{pbot}->{logger}->log("Ignoring cap-override of " . $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'cap-override') . " on unlocked factoid\n"); + } + } + + return $self->{pbot}->{interpreter}->interpret($context); + } + + $self->{pbot}->{logger}->log("$context->{from}: $context->{nick}!$context->{user}\@$context->{host}: $trigger_name: action: \"$action\"\n"); + + my $enabled = $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'enabled'); + + if (defined $enabled and $enabled == 0) { + $self->{pbot}->{logger}->log("$trigger_name disabled.\n"); + return "/msg $context->{nick} ${ref_from}$trigger_name is disabled."; + } + + if ($context->{interpolate}) { + my ($root_channel, $root_keyword) = $self->{pbot}->{factoids}->{data}->find( + $context->{ref_from} ? $context->{ref_from} : $context->{from}, + $context->{root_keyword}, + arguments => $context->{arguments}, + exact_channel => 1, + ); + + if (not defined $root_channel or not defined $root_keyword) { + $root_channel = $channel; + $root_keyword = $keyword; + } + + if (not length $context->{keyword_override} + and length $self->{pbot}->{factoids}->{data}->{storage}->get_data($root_channel, $root_keyword, 'keyword_override')) + { + $context->{keyword_override} = $self->{pbot}->{factoids}->{data}->{storage}->get_data($root_channel, $root_keyword, 'keyword_override'); + } + + $action = $self->{pbot}->{factoids}->{variables}->expand_factoid_vars($context, $action); + + if ($self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'allow_empty_args')) { + $action = $self->{pbot}->{factoids}->{variables}->expand_action_arguments($action, $context->{arguments}, ''); + } else { + $action = $self->{pbot}->{factoids}->{variables}->expand_action_arguments($action, $context->{arguments}, $context->{nick}); + } + } + + return $action if $context->{special} eq 'code-factoid'; + + if ($self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'type') eq 'module') { + my $preserve_whitespace = $self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'preserve_whitespace'); + $preserve_whitespace = 0 if not defined $preserve_whitespace; + + $context->{preserve_whitespace} = $preserve_whitespace; + $context->{root_keyword} = $keyword unless defined $context->{root_keyword}; + $context->{root_channel} = $channel; + + my $result = $self->{pbot}->{modules}->execute_module($context); + + if (length $result) { + return $ref_from . $result; + } else { + return ''; + } + } + elsif ($self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'type') eq 'text') { + # Don't allow user-custom /msg factoids, unless invoked by admin + if ($action =~ m/^\/msg/i) { + if (not $self->{pbot}->{users}->loggedin_admin($context->{from}, $context->{hostmask})) { + $self->{pbot}->{logger}->log("[ABUSE] Bad factoid (starts with /msg): $action\n"); + return "You must be an admin to use /msg."; + } + } + + if ($ref_from) { + if ( $action =~ s/^\/say\s+/$ref_from/i + || $action =~ s/^\/me\s+(.*)/\/me $1 $ref_from/i + || $action =~ s/^\/msg\s+([^ ]+)/\/msg $1 $ref_from/i + ) { + return $action; + } else { + return $ref_from . "$trigger_name is $action"; + } + } else { + if ($action =~ m/^\/(?:say|me|msg)/i) { + return $action; + } else { + return "/say $trigger_name is $action"; + } + } + } + elsif ($self->{pbot}->{factoids}->{data}->{storage}->get_data($channel, $keyword, 'type') eq 'regex') { + my $result = eval { + my $string = "$context->{original_keyword}" . (length $context->{arguments} ? " $context->{arguments}" : ''); + my $cmd; + + if ($string =~ m/$keyword/i) { + $self->{pbot}->{logger}->log("[$string] matches [$keyword] - calling [" . $action . "$']\n"); + $cmd = $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 = $action; + } + + $context->{command} = $cmd; + return $self->{pbot}->{interpreter}->interpret($context); + }; + + if ($@) { + $self->{pbot}->{logger}->log("Factoids: bad regex: $@\n"); + return ''; + } + + if (length $result) { + return $ref_from . $result; + } else { + return ''; + } + } else { + $self->{pbot}->{logger}->log("$context->{from}: $context->{nick}!$context->{user}\@$context->{host}): bad type for $channel.$keyword\n"); + return "/me blinks. $ref_from"; + } +} + +1; diff --git a/lib/PBot/Core/Factoids/Modifiers.pm b/lib/PBot/Core/Factoids/Modifiers.pm new file mode 100644 index 00000000..f8e8f3e3 --- /dev/null +++ b/lib/PBot/Core/Factoids/Modifiers.pm @@ -0,0 +1,110 @@ +# File: Modifiers.pm +# +# Purpose: Implements factoid expansion modifiers. + +# SPDX-FileCopyrightText: 2021 Pragmatic Software +# SPDX-License-Identifier: MIT + +package PBot::Core::Factoids::Modifiers; +use parent 'PBot::Core::Class'; + +use PBot::Imports; + +sub initialize { +} + +sub parse { + my ($self, $modifier) = @_; + + my %modifiers; + + my $interp = $self->{pbot}->{interpreter}; + + while ($$modifier =~ s/^:(?=\w)//) { + if ($$modifier =~ s/^join\s*(?=\(.*?(?=\)))//) { + my ($params, $rest) = $interp->extract_bracketed($$modifier, '(', ')', '', 1); + $$modifier = $rest; + my @args = $interp->split_line($params, strip_quotes => 1, strip_commas => 1); + $modifiers{'join'} = $args[0]; + next; + } + + if ($$modifier=~ s/^\+?sort//) { + $modifiers{'sort+'} = 1; + next; + } + + if ($$modifier=~ s/^\-sort//) { + $modifiers{'sort-'} = 1; + next; + } + + if ($$modifier=~ s/^pick_unique\s*(?=\(.*?(?=\)))//) { + my ($params, $rest) = $interp->extract_bracketed($$modifier, '(', ')', '', 1); + $$modifier = $rest; + my @args = $interp->split_line($params, strip_quotes => 1, strip_commas => 1); + + $modifiers{'pick'} = 1; + $modifiers{'unique'} = 1; + + if (@args == 2) { + $modifiers{'random'} = 1; + $modifiers{'pick_min'} = $args[0]; + $modifiers{'pick_max'} = $args[1]; + } elsif (@args == 1) { + $modifiers{'pick_min'} = 1; + $modifiers{'pick_max'} = $args[0]; + } else { + push @{$modifiers{errors}}, "pick_unique(): missing argument(s)"; + } + + next; + } + + if ($$modifier=~ s/^pick\s*(?=\(.*?(?=\)))//) { + my ($params, $rest) = $interp->extract_bracketed($$modifier, '(', ')', '', 1); + $$modifier = $rest; + my @args = $interp->split_line($params, strip_quotes => 1, strip_commas => 1); + + $modifiers{'pick'} = 1; + + if (@args == 2) { + $modifiers{'random'} = 1; + $modifiers{'pick_min'} = $args[0]; + $modifiers{'pick_max'} = $args[1]; + } elsif (@args == 1) { + $modifiers{'pick_min'} = 1; + $modifiers{'pick_max'} = $args[0]; + } else { + push @{$modifiers{errors}}, "pick(): missing argument(s)"; + } + + next; + } + + if ($$modifier=~ s/^index\s*(?=\(.*?(?=\)))//) { + my ($params, $rest) = $interp->extract_bracketed($$modifier, '(', ')', '', 1); + $$modifier = $rest; + my @args = $interp->split_line($params, strip_quotes => 1, strip_commas => 1); + if (@args == 1) { + $modifiers{'index'} = $args[0]; + } else { + push @{$modifiers{errors}}, "index(): missing argument"; + } + next; + } + + if ($$modifier =~ s/^(enumerate|comma|ucfirst|lcfirst|title|uc|lc)//) { + $modifiers{$1} = 1; + next; + } + + if ($$modifier =~ s/^(\w+)//) { + push @{$modifiers{errors}}, "Unknown modifier `$1`"; + } + } + + return %modifiers; +} + +1; diff --git a/lib/PBot/Core/Factoids/Selectors.pm b/lib/PBot/Core/Factoids/Selectors.pm new file mode 100644 index 00000000..261e714f --- /dev/null +++ b/lib/PBot/Core/Factoids/Selectors.pm @@ -0,0 +1,244 @@ +# File: Selectors.pm +# +# Purpose: Provides implementation of factoid selectors. + +# SPDX-FileCopyrightText: 2021 Pragmatic Software +# SPDX-License-Identifier: MIT + +package PBot::Core::Factoids::Selectors; +use parent 'PBot::Core::Class'; + +use PBot::Imports; + +use Time::HiRes qw(gettimeofday); +use Time::Duration qw(duration); + +sub initialize { +} + +sub make_list { + my ($self, $context, $extracted, $settings, %opts) = @_; + + if ($extracted =~ /(.*?)(?expand_selectors($context, $extracted, %opts); + $opts{nested}--; + } + + my @list; + foreach my $item (split /\s*(?{'uc'}) { + $item = uc $item; + } + + if ($settings->{'lc'}) { + $item = lc $item; + } + + if ($settings->{'ucfirst'}) { + $item = ucfirst $item; + } + + if ($settings->{'title'}) { + $item = ucfirst lc $item; + $item =~ s/ (\w)/' ' . uc $1/ge; + } + + if ($settings->{'json'}) { + $item = $self->{pbot}->{factoids}->{variables}->escape_json($item); + } + + push @list, $item; + } + + if ($settings->{'unique'}) { + foreach my $choice (@{$settings->{'choices'}}) { + @list = grep { $_ ne $choice } @list; + } + } + + if ($settings->{'sort+'}) { + @list = sort { $a cmp $b } @list; + } + + if ($settings->{'sort-'}) { + @list = sort { $b cmp $a } @list; + } + + return \@list; +} + +sub select_weighted_item_from_list { + my ($self, $list, $index) = @_; + + my @weights; + my $weight_sum = 0; + + for (my $i = 0; $i <= $#$list; $i++) { + my $weight = 1; + + if ($list->[$i] =~ s/:weight\(([0-9.-]+)\)//) { + $weight = $1; + } + + $weights[$i] = [ $weight, $i ]; + $weight_sum += $weight; + } + + if (defined $index) { + return $list->[$index]; + } + + my $n = rand $weight_sum; + + for my $weight (@weights) { + if ($n < $weight->[0]) { + return $list->[$weight->[1]]; + } + + $n -= $weight->[0]; + } +} + +sub select_item { + my ($self, $context, $extracted, $modifiers, %opts) = @_; + + my %settings = $self->{pbot}->{factoids}->{modifiers}->parse($modifiers); + + if (exists $settings{errors}) { + return "[Error: " . join ('; ', @{$settings{errors}}) . ']'; + } + + my $item; + + if (exists $settings{'index'}) { + my $list = $self->make_list($context, $extracted, \%settings, %opts); + + my $index = $settings{'index'}; + + $index = $#$list - -$index if $index < 0; + $index = 0 if $index < 0; + $index = $#$list if $index > $#$list; + + $item = $self->select_weighted_item_from_list($list, $index); + + # strip outer quotes + if (not $item =~ s/^"(.*)"$/$1/) { $item =~ s/^'(.*)'$/$1/; } + } elsif ($settings{'pick'}) { + my $min = $settings{'pick_min'}; + my $max = $settings{'pick_max'}; + + $max = 100 if $max > 100; + + my $count = $max; + + if ($settings{'random'}) { + $count = int rand ($max + 1 - $min) + $min; + } + + my @choices; + $settings{'choices'} = \@choices; + + while ($count-- > 0) { + my $list = $self->make_list($context, $extracted, \%settings, %opts); + + last if not @$list; + + $max = @$list if $settings{'unique'} and $max > @$list; + $min = $max if $min > $max; + + my $choice = $self->select_weighted_item_from_list($list); + + push @choices, $choice; + } + + # strip outer quotes + foreach my $choice (@choices) { + if (not $choice =~ s/^"(.*)"$/$1/) { $choice =~ s/^'(.*)'$/$1/; } + } + + if ($settings{'sort+'}) { + @choices = sort { $a cmp $b } @choices; + } + + if ($settings{'sort-'}) { + @choices = sort { $b cmp $a } @choices; + } + + return @choices if wantarray; + + if (exists $settings{'join'}) { + my $sep = $settings{'join'} // ''; + $item = join $sep, @choices; + } + elsif ($settings{'enumerate'} or $settings{'comma'}) { + $item = join ', ', @choices; + $item =~ s/(.*), /$1 and / if $settings{'enumerate'}; + } + else { + $item = $opts{nested} ? join('|', @choices) : "@choices"; + } + } else { + my $list = $self->make_list($context, $extracted, \%settings, %opts); + + $item = $self->select_weighted_item_from_list($list); + + # strip outer quotes + if (not $item =~ s/^"(.*)"$/$1/) { $item =~ s/^'(.*)'$/$1/; } + } + + return $item; +} + +sub expand_selectors { + my ($self, $context, $action, %opts) = @_; + + my %default_opts = ( + nested => 0, + recursions => 0, + ); + + %opts = (%default_opts, %opts); + + return '!recursion limit!' if ++$opts{recursions} > 100; + + my $result = ''; + + while (1) { + if ($action =~ /(.*?)(?{pbot}->{interpreter}->extract_bracketed($action, '(', ')', '%', 1); + + last if not length $extracted; + + my $item = $self->select_item($context, $extracted, \$rest, %opts); + + if ($result =~ s/\b(a|an)(\s+)$//i) { + my ($article, $trailing) = ($1, $2); + my $fixed_article = select_indefinite_article $item; + + if ($article eq 'AN') { + $fixed_article = uc $fixed_article; + } elsif ($article eq 'An' or $article eq 'A') { + $fixed_article = ucfirst $fixed_article; + } + + $item = $fixed_article . $trailing . $item; + } + + $result .= $item; + $action = $rest; + } + + $result .= $action; + return $result; +} + +1; diff --git a/lib/PBot/Core/Factoids/Variables.pm b/lib/PBot/Core/Factoids/Variables.pm new file mode 100644 index 00000000..bb5ed81b --- /dev/null +++ b/lib/PBot/Core/Factoids/Variables.pm @@ -0,0 +1,338 @@ +# File: Variables.pm +# +# Purpose: Implements factoid variables, including $args, $nick, $channel, etc. + +# SPDX-FileCopyrightText: 2021 Pragmatic Software +# SPDX-License-Identifier: MIT + +package PBot::Core::Factoids::Variables; +use parent 'PBot::Core::Class'; + +use PBot::Imports; + +use PBot::Core::Utils::Indefinite; +use PBot::Core::Utils::ValidateString; + +use JSON; + +sub initialize {} + +sub expand_factoid_vars { + my ($self, $context, $action, %opts) = @_; + + my %default_opts = ( + nested => 0, + recursions => 0, + ); + + %opts = (%default_opts, %opts); + + if (++$opts{recursions} > 100) { + return '!recursion limit reached!'; + } + + my $from = length $context->{ref_from} ? $context->{ref_from} : $context->{from}; + my $nick = $context->{nick}; + my $root_keyword = $context->{keyword_override} ? $context->{keyword_override} : $context->{root_keyword}; + + $action = defined $action ? $action : $context->{action}; + + my $interpolate = $self->{pbot}->{factoids}->{data}->{storage}->get_data($context->{channel}, $context->{keyword}, 'interpolate'); + return $action if defined $interpolate and not $interpolate; + + $interpolate = $self->{pbot}->{registry}->get_value($context->{channel}, 'interpolate_factoids'); + return $action if defined $interpolate and not $interpolate; + + $action = $self->{pbot}->{factoids}->{selectors}->expand_selectors($context, $action, %opts); + + my $depth = 0; + + if ($action =~ m/^\/call --keyword-override=([^ ]+)/i) { + $root_keyword = $1; + } + + my $result = ''; + my $rest = $action; + + while (++$depth < 100) { + $rest =~ s/(?{pbot}->{interpreter}->extract_bracketed($rest, '{', '}'); + + if ($var =~ /:/) { + my @stuff = split /:/, $var, 2; + $var = $stuff[0]; + $rest = ':' . $stuff[1] . $rest; + } + + $extract_method = 'bracket'; + } else { + $rest =~ s/^(\w+)//; + $var = $1; + $extract_method = 'regex'; + } + + if ($var =~ /^(?:_.*|[[:punct:]0-9]+|a|b|nick|channel|randomnick|arglen|args|arg\[.+\])$/i) { + # skip identifiers with leading underscores, etc + $result .= $extract_method eq 'bracket' ? '${' . $var . '}' : '$' . $var; + next; + } + + $matches++; + + # extract channel expansion modifier + if ($rest =~ s/^:(#[^:]+|global)//i) { + $from = $1; + $from = '.*' if lc $from eq 'global'; + } + + my $recurse = 0; + + ALIAS: + + my @factoids = $self->{pbot}->{factoids}->{data}->find($from, $var, exact_channel => 2, exact_trigger => 2); + + if (not @factoids or not $factoids[0]) { + $result .= $extract_method eq 'bracket' ? '${' . $var . '}' : '$' . $var; + next; + } + + my $var_chan; + ($var_chan, $var) = ($factoids[0]->[0], $factoids[0]->[1]); + + if ($self->{pbot}->{factoids}->{data}->{storage}->get_data($var_chan, $var, 'action') =~ m{^/call (.*)}ms) { + $var = $1; + + if (++$recurse > 100) { + $self->{pbot}->{logger}->log("Factoids: variable expansion recursion limit reached\n"); + $result .= $extract_method eq 'bracket' ? '${' . $var . '}' : '$' . $var; + next; + } + + goto ALIAS; + } + + my $copy = $rest; + my %settings = $self->{pbot}->{factoids}->{modifiers}->parse(\$copy); + + if ($self->{pbot}->{factoids}->{data}->{storage}->get_data($var_chan, $var, 'type') eq 'text') { + my $change = $self->{pbot}->{factoids}->{data}->{storage}->get_data($var_chan, $var, 'action'); + my @list = $self->{pbot}->{interpreter}->split_line($change); + + my @replacements; + + if (wantarray) { + @replacements = $self->{pbot}->{factoids}->{selectors}->select_item($context, join ('|', @list), \$rest, %opts); + return @replacements; + } else { + push @replacements, scalar $self->{pbot}->{factoids}->{selectors}->select_item($context, join ('|', @list), \$rest, %opts); + } + + my $replacement = $opts{nested} ? join('|', @replacements) : "@replacements"; + + if (not length $replacement) { + $result =~ s/\s+$//; + } else { + $replacement = $self->{pbot}->{factoids}->{variables}->expand_factoid_vars($context, $replacement, %opts); + } + + if ($settings{'uc'}) { + $replacement = uc $replacement; + } + + if ($settings{'lc'}) { + $replacement = lc $replacement; + } + + if ($settings{'ucfirst'}) { + $replacement = ucfirst $replacement; + } + + if ($settings{'title'}) { + $replacement = ucfirst lc $replacement; + $replacement =~ s/ (\w)/' ' . uc $1/ge; + } + + if ($settings{'json'}) { + $replacement = $self->escape_json($replacement); + } + + if ($result =~ s/\b(a|an)(\s+)$//i) { + my ($article, $trailing) = ($1, $2); + my $fixed_article = select_indefinite_article $replacement; + + if ($article eq 'AN') { + $fixed_article = uc $fixed_article; + } elsif ($article eq 'An' or $article eq 'A') { + $fixed_article = ucfirst $fixed_article; + } + + $replacement = $fixed_article . $trailing . $replacement; + } + + $result .= $replacement; + + $expansions++; + } else { + $result .= $extract_method eq 'bracket' ? '${' . $var . '}' : '$' . $var; + } + } + + last if $matches == 0 or $expansions == 0; + + if (not length $rest) { + $rest = $result; + $result = ''; + } + } + + $result .= $rest; + + $result = $self->expand_special_vars($from, $nick, $root_keyword, $result); + + # unescape certain symbols + $result =~ s/(?{pbot}->{registry}->get_value('factoids', 'max_content_length')); +} + +sub expand_action_arguments { + my ($self, $action, $input, $nick) = @_; + + $action = validate_string($action, $self->{pbot}->{registry}->get_value('factoids', 'max_content_length')); + $input = validate_string($input, $self->{pbot}->{registry}->get_value('factoids', 'max_content_length')); + + my %h; + + if (not defined $input or $input eq '') { + %h = (args => $nick); + } else { + %h = (args => $input); + } + + my $jsonargs = to_json \%h; + $jsonargs =~ s/^{".*":"//; + $jsonargs =~ s/"}$//; + + if (not defined $input or $input eq '') { + $input = ""; + $action =~ s/\$args:json|\$\{args:json\}/$jsonargs/ge; + $action =~ s/\$args(?![[\w])|\$\{args(?![[\w])\}/$nick/g; + } else { + $action =~ s/\$args:json|\$\{args:json\}/$jsonargs/g; + $action =~ s/\$args(?![[\w])|\$\{args(?![[\w])\}/$input/g; + } + + my @args = $self->{pbot}->{interpreter}->split_line($input); + + $action =~ s/\$arglen\b|\$\{arglen\}/scalar @args/eg; + + my $depth = 0; + my $const_action = $action; + + while ($const_action =~ m/\$arg\[([^]]+)]|\$\{arg\[([^]]+)]\}/g) { + my $arg = defined $2 ? $2 : $1; + + last if ++$depth >= 100; + + if ($arg eq '*') { + if (not defined $input or $input eq '') { + $action =~ s/\$arg\[\*\]|\$\{arg\[\*\]\}/$nick/; + } else { + $action =~ s/\$arg\[\*\]|\$\{arg\[\*\]\}/$input/; + } + + next; + } + + if ($arg =~ m/([^:]*):(.*)/) { + my $arg1 = $1; + my $arg2 = $2; + + my $arg1i = $arg1; + my $arg2i = $arg2; + + $arg1i = 0 if $arg1i eq ''; + $arg2i = $#args if $arg2i eq ''; + $arg2i = $#args if $arg2i > $#args; + + my @values = eval { + local $SIG{__WARN__} = sub { }; + return @args[$arg1i .. $arg2i]; + }; + + if ($@) { + next; + } else { + my $string = join(' ', @values); + + if ($string eq '') { + $action =~ s/\s*\$\{arg\[$arg1:$arg2\]\}// || $action =~ s/\s*\$arg\[$arg1:$arg2\]//; + } else { + $action =~ s/\$\{arg\[$arg1:$arg2\]\}/$string/ || $action =~ s/\$arg\[$arg1:$arg2\]/$string/; + } + } + + next; + } + + my $value = eval { + local $SIG{__WARN__} = sub { }; + return $args[$arg]; + }; + + if ($@) { + next; + } else { + if (not defined $value) { + if ($arg == 0) { + $action =~ s/\$\{arg\[$arg\]\}/$nick/ || $action =~ s/\$arg\[$arg\]/$nick/; + } else { + $action =~ s/\s*\$\{arg\[$arg\]\}// || $action =~ s/\s*\$arg\[$arg\]//; + } + } else { + $action =~ s/\$arg\{\[$arg\]\}/$value/ || $action =~ s/\$arg\[$arg\]/$value/; + } + } + } + + return $action; +} + +sub escape_json { + my ($self, $text) = @_; + my $thing = {thing => $text}; + my $json = to_json $thing; + $json =~ s/^{".*":"//; + $json =~ s/"}$//; + return $json; +} + +sub expand_special_vars { + my ($self, $from, $nick, $root_keyword, $action) = @_; + + $action =~ s/(?escape_json($nick)/ge; + $action =~ s/(?escape_json($from)/ge; + $action =~ + s/(?{pbot}->{nicklist}->random_nick($from); $random ? $self->escape_json($random) : $self->escape_json($nick)/ge; + $action =~ s/(?escape_json($root_keyword)/ge; + + $action =~ s/(?{pbot}->{nicklist}->random_nick($from); $random ? $random : $nick/ge; + $action =~ s/(?{pbot}->{registry}->get_value('factoids', 'max_content_length')); +} + +1;