-
Notifications
You must be signed in to change notification settings - Fork 20
/
Interpreter.pm
1464 lines (1191 loc) · 50.2 KB
/
Interpreter.pm
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# File: Interpreter.pm
#
# Purpose: Main entry point to parse and interpret a string into bot
# commands and dispatch the commands to registered interpreters.
# Handles argument processing, command piping, command substitution,
# command splitting, command output processing such as truncating long
# text to web paste sites, etc.
# SPDX-FileCopyrightText: 2001-2023 Pragmatic Software <[email protected]>
# SPDX-License-Identifier: MIT
package PBot::Core::Interpreter;
use parent 'PBot::Core::Class', 'PBot::Core::Registerable';
use PBot::Imports;
use PBot::Core::MessageHistory::Constants ':all';
use PBot::Core::Utils::Indefinite;
use PBot::Core::Utils::ValidateString;
use Encode;
use Getopt::Long qw(GetOptionsFromArray);
use Time::Duration;
use Time::HiRes qw(gettimeofday);
use Unicode::Truncate;
sub initialize($self, %conf) {
# PBot::Core::Interpreter can register multiple interpreter subrefs.
# See also: Commands::interpreter() and Factoids::interpreter()
$self->PBot::Core::Registerable::initialize(%conf);
# registry entry for maximum recursion depth
$self->{pbot}->{registry}->add_default('text', 'interpreter', 'max_recursion', 10);
}
# this is the main entry point for a message to be parsed into commands
# and to execute those commands and process their output
sub process_line($self, $from, $nick, $user, $host, $text, $tags = '', $is_command = 0) {
# lowercase `from` field for case-insensitivity
$from = lc $from;
# sanitize text a bit
$text =~ s/^\s+|\s+$//g;
$text = validate_string($text, 0);
# context object maintains contextual information about the state and
# processing of this message. this object is passed between various bot
# functions and interfaces, which may themselves add more fields.
my $context = {
from => $from, # source (channel, sender hostmask, 'stdin@pbot', etc)
nick => $nick, # nickname
user => $user, # username
host => $host, # hostname/ip address
hostmask => "$nick!$user\@$host", # full hostmask
text => $text, # message contents
tags => $tags, # message tags
};
# add hostmask to user/message tracking database and get their account id
my $message_account = $self->{pbot}->{messagehistory}->get_message_account($nick, $user, $host);
# add account id to context object
$context->{message_account} = $message_account;
# add message to message history as a chat message
$self->{pbot}->{messagehistory}->add_message($message_account, $context->{hostmask}, $from, $text, MSG_CHAT);
# look up channel-specific flood threshold settings from registry
my $flood_threshold = $self->{pbot}->{registry}->get_value($from, 'chat_flood_threshold');
my $flood_time_threshold = $self->{pbot}->{registry}->get_value($from, 'chat_flood_time_threshold');
# get general flood threshold settings if there are no channel-specific settings
$flood_threshold //= $self->{pbot}->{registry}->get_value('antiflood', 'chat_flood_threshold');
$flood_time_threshold //= $self->{pbot}->{registry}->get_value('antiflood', 'chat_flood_time_threshold');
# perform anti-flood processing on this message
$self->{pbot}->{antiflood}->check_flood(
$from, $nick, $user, $host, $text,
$flood_threshold, $flood_time_threshold,
MSG_CHAT,
$context
);
# get bot nickname
my $botnick = $self->{pbot}->{conn}->nick;
# get channel-specific bot trigger if available
my $bot_trigger = $self->{pbot}->{registry}->get_value($from, 'trigger');
# otherwise get general bot trigger
$bot_trigger //= $self->{pbot}->{registry}->get_value('general', 'trigger');
# get nick regex from registry entry
my $nick_regex = $self->{pbot}->{registry}->get_value('regex', 'nickname');
# preserve original text and parse $cmd_text for bot commands
my $cmd_text = $text;
$cmd_text =~ s/^\/me\s+//; # remove leading /me
# parse for bot command invocation
my @commands; # all commands parsed out of this text so far
my $command; # current command being parsed
my $embedded = 0; # was command embedded within a message, e.g.: "see the !{help xyz} about that"
my $nick_prefix = undef; # addressed nickname for prefixing output
my $processed = 0; # counts how many commands were successfully processed
# check if we should treat this entire text as a command
# (i.e., it came from /msg or was otherwise flagged as a command)
if ($is_command) {
$command = $cmd_text;
$command =~ s/^$bot_trigger//; # strip leading bot trigger, if any
# restore command if stripping bot trigger makes command empty
# (they wanted to invoke a command named after the trigger itself)
# TODO: this could potentially be confusing when trying to invoke
# commands that are sequential instances of the bot trigger, e.g.
# attempting to invoke a factoid named `...` while the bot trigger
# is `.` could now prove confusing via /msg or stdin. Might need
# to rethink this and just require bot trigger all the time ...
# but for now let's see how this goes and if people can figure it
# out with minimal confusion.
$command = $cmd_text if not length $command;
$context->{addressed} = 1;
goto CHECK_EMBEDDED_CMD;
}
# otherwise try to parse any potential commands
if ($cmd_text =~ m/^\s*($nick_regex)[,:]?\s+$bot_trigger\{\s*(.+?)\s*\}\s*$/) {
# "somenick: !{command}"
$context->{addressed} = 1;
goto CHECK_EMBEDDED_CMD;
} elsif ($cmd_text =~ m/^\s*$bot_trigger\{\s*(.+?)\s*\}\s*$/) {
# "!{command}"
$context->{addressed} = 1;
goto CHECK_EMBEDDED_CMD;
} elsif ($cmd_text =~ m/^\s*($nick_regex)[,:]\s+$bot_trigger\s*(.+)$/) {
# "somenick: !command"
my $possible_nick_prefix = $1;
$command = $2;
# does somenick or similar exist in channel?
my $recipient = $self->{pbot}->{nicklist}->is_present_similar($from, $possible_nick_prefix);
if ($recipient) {
$nick_prefix = $recipient;
} else {
# disregard command if no such nick is present.
$self->{pbot}->{logger}->log("No similar nick for $possible_nick_prefix; disregarding command.\n");
return 0;
}
$context->{addressed} = 1;
} elsif ($cmd_text =~ m/^$bot_trigger\s*(.+)$/) {
# "!command"
$command = $1;
$context->{addressed} = 1;
} elsif ($cmd_text =~ m/^.?\s*$botnick\s*[,:]\s+(.+)$/i) {
# "botnick: command"
$command = $1;
$context->{addressed} = 1;
} elsif ($cmd_text =~ m/^.?\s*$botnick\s+(.+)$/i) {
# "botnick command"
$command = $1;
$context->{addressed} = 0;
} elsif ($cmd_text =~ m/^(.+?),\s+$botnick[?!.]*$/i) {
# "command, botnick?"
$command = $1;
$context->{addressed} = 1;
} elsif ($cmd_text =~ m/^(.+?)\s+$botnick[?!.]*$/i) {
# "command botnick?"
$command = $1;
$context->{addressed} = 0;
}
# check for embedded commands
CHECK_EMBEDDED_CMD:
# if no command was parsed yet (or if we reached this point by one of the gotos above)
# then look for embedded commands, e.g.: "today is !{date} and the weather is !{weather}"
if (not defined $command or $command =~ m/^\{.*\}/) {
# check for an addressed nickname
if ($cmd_text =~ s/^\s*($nick_regex)[,:]\s+//) {
my $possible_nick_prefix = $1;
# does somenick or similar exist in channel?
my $recipient = $self->{pbot}->{nicklist}->is_present_similar($from, $possible_nick_prefix);
if ($recipient) {
$nick_prefix = $recipient;
}
}
# get max embed registry value
my $max_embed = $self->{pbot}->{registry}->get_value('interpreter', 'max_embed') // 3;
# extract embedded commands
for (my $count = 0; $count < $max_embed; $count++) {
my ($extracted, $rest) = $self->extract_bracketed($cmd_text, '{', '}', $bot_trigger);
# nothing to extract found, all done.
last if not length $extracted;
# move command text buffer forwards past extracted text
$cmd_text = $rest;
# trim surrounding whitespace
$extracted =~ s/^\s+|\s+$//g;
# add command to parsed commands.
push @commands, $extracted;
# set embedded flag
$embedded = 1;
}
} else {
# otherwise a single command has already been parsed.
# so, add the command to parsed commands.
push @commands, $command;
}
# set $context's command output recipient field
if ($nick_prefix) {
$context->{nickprefix} = $nick_prefix;
$context->{nickprefix_forced} = 1;
}
# set $context object's embedded flag
$context->{embedded} = $embedded;
# interpret all parsed commands
foreach $command (@commands) {
# check if user is ignored
# the `login` command gets a pass on the ignore filter
if ($command !~ /^login / and $self->{pbot}->{ignorelist}->is_ignored($from, "$nick!$user\@$host")) {
$self->{pbot}->{logger}->log("Disregarding command from ignored user $nick!$user\@$host in $from.\n");
return 1;
}
# update $context command field
$context->{command} = $command;
# reset $context's interpreter recursion depth counter
$context->{interpret_depth} = 0;
# interpet this command
$context->{result} = $self->interpret($context);
# handle command output
$self->handle_result($context);
# increment processed counter
$processed++;
}
# return number of commands processed
return $processed;
}
# main entry point to interpret/execute a bot command.
# takes a $context object containing contextual information about the
# command such as the channel, nick, user, host, command, etc.
sub interpret($self, $context) {
# log command invocation
$self->{pbot}->{logger}->log("=== [$context->{interpret_depth}] Got command: "
. "($context->{from}) $context->{hostmask}: $context->{command}\n");
# debug flag to trace $context location and contents
if ($self->{pbot}->{registry}->get_value('general', 'debugcontext')) {
use Data::Dumper;
$Data::Dumper::Sortkeys = sub { [sort grep { not /(?:cmdlist|arglist)/ } keys %$context] };
$Data::Dumper::Indent = 2;
$self->{pbot}->{logger}->log("Interpreter::interpret\n");
$self->{pbot}->{logger}->log(Dumper $context);
$Data::Dumper::Sortkeys = 1;
}
# enforce recursion limit
if (++$context->{interpret_depth} > $self->{pbot}->{registry}->get_value('interpreter', 'max_recursion')) {
return "Too many levels of recursion, aborted.";
}
# sanity check the context fields, none of these should be missing
if (not defined $context->{nick} || not defined $context->{user} || not defined $context->{host} || not defined $context->{command}) {
$self->{pbot}->{logger}->log("Error: Interpreter::interpret: missing field(s)\n");
return '/me coughs weakly.'; # indicate that something went wrong
}
# check for a split command, e.g. "echo Hello ;;; echo world."
if ($context->{command} =~ m/^(.*?)\s*(?<!\\);;;\s*(.*)/ms) {
$context->{command} = $1; # command is the first half of the split
$context->{command_split} = $2; # store the rest of the split, potentially containing more splits
}
# convert command string to list of arguments
my $cmdlist = $self->make_args($context->{command}, preserve_escapes => 1);
$context->{cmdlist} = $cmdlist;
# create context command history if non-existent
if (not exists $context->{commands}) {
$context->{commands} = [];
}
# add command to context command history
push @{$context->{commands}}, $context->{command};
# parse the command into keyword, arguments and recipient
my ($keyword, $arguments, $recipient) = ('', '', undef);
if ($self->arglist_size($cmdlist) >= 4 and lc $cmdlist->[0] eq 'tell' and (lc $cmdlist->[2] eq 'about' or lc $cmdlist->[2] eq 'the')) {
# tell nick about/the cmd [args]; e.g. "tell somenick about malloc" or "tell somenick the date"
# split the list into two fields (keyword and remaining arguments)
# starting at the 4th element and preserving quotes
($keyword, $arguments) = $self->split_args($cmdlist, 2, 3, 1);
# 2nd element is the recipient
$recipient = $cmdlist->[1];
} elsif ($self->arglist_size($cmdlist) >= 3 and lc $cmdlist->[0] eq 'give') {
# give nick cmd [args]; e.g. "give somenick date"
# split the list into two fields (keyword and remaining arguments)
# starting at the 3rd element and preserving quotes
($keyword, $arguments) = $self->split_args($cmdlist, 2, 2, 1);
# 2nd element is the recipient
$recipient = $cmdlist->[1];
} else {
# normal command, split into keywords and arguments while preserving quotes
($keyword, $arguments) = $self->split_args($cmdlist, 2, 0, 1);
}
# limit keyword length (in bytes)
# TODO: make this a registry item
{
# lexical scope for use bytes
use bytes;
if (length $keyword > 128) {
$keyword = truncate_egc $keyword, 128; # safely truncate unicode strings
$self->{pbot}->{logger}->log("Truncating keyword to <= 128 bytes: $keyword\n");
}
}
# strip any trailing newlines from keyword
$keyword =~ s/\n+$//;
# ensure we have a $keyword
if (not defined $keyword or not length $keyword) {
$self->{pbot}->{logger}->log("Error: Missing keyword; disregarding command\n");
return undef;
}
# ensure $arguments is a string if none were given
$arguments //= '';
if (defined $recipient) {
# ensure that the recipient is present in the channel
$recipient = $self->{pbot}->{nicklist}->is_present_similar($context->{from}, $recipient);
if ($recipient) {
# if present then set and force the nickprefix
$context->{nickprefix} = $recipient;
$context->{nickprefix_forced} = 1;
} else {
# otherwise discard nickprefix
delete $context->{nickprefix};
delete $context->{nickprefix_forced};
}
}
# find factoid channel for dont-replace-pronouns metadata
my ($fact_channel, $fact_trigger);
my @factoids = $self->{pbot}->{factoids}->{data}->find($context->{from}, $keyword, exact_trigger => 1);
if (@factoids == 1) {
# found the factoid's channel
($fact_channel, $fact_trigger) = @{$factoids[0]};
} else {
# match the factoid in the current channel if it exists
foreach my $f (@factoids) {
if ($f->[0] eq $context->{from}) {
($fact_channel, $fact_trigger) = ($f->[0], $f->[1]);
last;
}
}
# and otherwise assume global if it doesn't exist (FIXME: what to do if there isn't a global one?)
if (not defined $fact_channel) {
($fact_channel, $fact_trigger) = ('.*', $keyword);
}
}
if ($self->{pbot}->{commands}->get_meta($keyword, 'suppress-no-output')
or $self->{pbot}->{factoids}->{data}->get_meta($fact_channel, $fact_trigger, 'suppress-no-output'))
{
$context->{'suppress_no_output'} = 1;
} else {
delete $context->{'suppress_no_output'};
}
if ($self->{pbot}->{commands}->get_meta($keyword, 'dont-replace-pronouns')
or $self->{pbot}->{factoids}->{data}->get_meta($fact_channel, $fact_trigger, 'dont-replace-pronouns'))
{
$context->{'dont-replace-pronouns'} = 1;
}
# replace pronouns like "i", "my", etc, with "nick", "nick's", etc
if (not $context->{'dont-replace-pronouns'}) {
# if command recipient is "me" then replace it with invoker's nick
# e.g., "!tell me about date" or "!give me date", etc
if (defined $context->{nickprefix} and lc $context->{nickprefix} eq 'me') {
$context->{nickprefix} = $context->{nick};
}
# strip trailing sentence-ending punctuators from $keyword
# TODO: why are we doing this? why here? why at all?
$keyword =~ s/(\w+)[?!.]+$/$1/;
# replace pronouns in $arguments.
# but only on the top-level command (not on subsequent recursions).
# all pronouns can be escaped to prevent replacement, e.g. "!give \me date"
if (length $arguments and $context->{interpret_depth} <= 1) {
$arguments =~ s/(?<![\w\/\-\\])i am\b/$context->{nick} is/gi;
$arguments =~ s/(?<![\w\/\-\\])me\b/$context->{nick}/gi;
$arguments =~ s/(?<![\w\/\-\\])my\b/$context->{nick}'s/gi;
# unescape any escaped pronouns
$arguments =~ s/\\i am\b/i am/gi;
$arguments =~ s/\\my\b/my/gi;
$arguments =~ s/\\me\b/me/gi;
}
}
# parse out a substituted command
if ($arguments =~ m/(?<!\\)&\s*\{/) {
my ($command) = $self->extract_bracketed($arguments, '{', '}', '&', 1);
# did we find a substituted command?
if (length $command) {
# replace it with a placeholder
$arguments =~ s/&\s*\{\Q$command\E\}/&{subcmd}/;
# add it to the list of substituted commands
push @{$context->{subcmd}}, "$keyword $arguments";
# FIXME: quick-and-dirty hack to fix $0.
# Without this hack `pet &{echo dog}` will output `You echo
# the dog` instead of `You pet the dog`.
if (not defined $context->{root_keyword}) {
$context->{root_keyword} = $keyword;
}
# trim surrounding whitespace
$command =~ s/^\s+|\s+$//g;
# replace contextual command
$context->{command} = $command;
# interpret the substituted command
$context->{result} = $self->interpret($context);
# return the output
return $context->{result};
}
}
# parse out a pipe
if ($arguments =~ m/(?<!\\)\|\s*\{\s*[^}]+\}\s*$/) {
my ($pipe, $rest) = $self->extract_bracketed($arguments, '{', '}', '|', 1);
# strip pipe and everything after it from arguments
$arguments =~ s/\s*(?<!\\)\|\s*{(\Q$pipe\E)}.*$//s;
# trim surrounding whitespace
$pipe =~ s/^\s+|\s+$//g;
# update contextual pipe data
if (exists $context->{pipe}) {
$context->{pipe_rest} = "$rest | { $context->{pipe} }$context->{pipe_rest}";
} else {
$context->{pipe_rest} = $rest;
}
$context->{pipe} = $pipe;
}
# unescape any escaped command splits
$arguments =~ s/\\;;;/;;;/g;
# unescape any escaped substituted commands
$arguments =~ s/\\&\s*\{/&{/g;
# unescape any escaped pipes
$arguments =~ s/\\\|\s*\{/| {/g;
# the bot doesn't like performing bot commands on itself
# unless dont-protect-self is true
if (not $self->{pbot}->{commands}->get_meta($keyword, 'dont-protect-self')
and not $self->{pbot}->{factoids}->{data}->get_meta($fact_channel, $fact_trigger, 'dont-protect-self'))
{
my $botnick = $self->{pbot}->{conn}->nick;
if ($arguments =~ m/^(your|him|her|its|it|them|their)(self|selves)$/i || $arguments =~ m/^$botnick$/i) {
# build message structure
my $message = {
nick => $context->{nick},
user => $context->{user},
host => $context->{host},
hostmask => $context->{hostmask},
command => $context->{command},
checkflood => 1,
message => "$context->{nick}: Why would I want to do that to myself?",
};
# get a random delay
my $delay = rand(10) + 5;
# add message to output queue
$self->add_message_to_output_queue($context->{from}, $message, $delay);
# log upcoming message + delay
$delay = duration($delay);
$self->{pbot}->{logger}->log("($delay delay) $message->{message}\n");
# end pipe/substitution processing
$context->{alldone} = 1;
# no output to return
return undef;
}
}
# set the contextual root root keyword.
# this is the keyword first used to invoke this command. it is not updated
# on subsequent command interpreter recursions.
if (not exists $context->{root_keyword}) {
$context->{root_keyword} = $keyword;
}
# update the contextual keyword field
$context->{keyword} = $keyword;
# update the contextual arguments field
$context->{arguments} = $arguments;
# update the original arguments field.
# the actual arguments field may be manipulated/overridden by
# the interpreters. the arguments field is reset with this
# field after each interpreter finishes.
$context->{original_arguments} = $arguments;
# make the argument list
$context->{arglist} = $self->make_args($arguments);
# reset utf8 flag for arguments
# arguments aren't a utf8 encoded string at this point
delete $context->{args_utf8};
# reset the special behavior
$context->{special} = '';
# execute all registered interpreters
my $result;
foreach my $func (@{$self->{handlers}}) {
# call the interpreter
$result = $func->{subref}->($context);
# exit loop if interpreter returned output
last if $context->{interpreted} || defined $result;
# reset any manipulated/overridden arguments
$context->{arguments} = $context->{original_arguments};
delete $context->{args_utf8};
}
# return command output
return $result;
}
# finalizes processing on a command.
# updates pipes, substitutions, splits. truncates to paste site.
# sends final command output to appropriate queues.
# use context result if no result argument given.
sub handle_result($self, $context, $result = $context->{result}) {
# condensation of consecutive whitespace is disabled by default
$context->{'condense-whitespace'} //= 0;
# reset interpreted to allow pipes/command-substitutions to finish
delete $context->{'interpreted'};
# debug flag to trace $context location and contents
if ($self->{pbot}->{registry}->get_value('general', 'debugcontext')) {
use Data::Dumper;
$Data::Dumper::Sortkeys = sub { [sort grep { not /(?:cmdlist|arglist)/ } keys %$context] };
$Data::Dumper::Indent = 2;
$self->{pbot}->{logger}->log("Interpreter::handle_result [$result]\n");
$self->{pbot}->{logger}->log(Dumper $context);
}
# ensure we have a command result to work with
if (!defined $result || $context->{'skip-handle-result'}) {
$self->{pbot}->{logger}->log("Skipping handle_result\n");
delete $context->{'skip-handle-result'};
return;
}
# strip and store /command prefixes
# to be re-added after result processing
if ($result =~ s!^(/say|/me|/msg \S+) !!) {
$context->{result_prefix} = $1;
} else {
delete $context->{result_prefix};
}
# finish piping
if (exists $context->{pipe}) {
my ($pipe, $pipe_rest) = (delete $context->{pipe}, delete $context->{pipe_rest});
if (not $context->{alldone}) {
$context->{command} = "$pipe $result $pipe_rest";
$context->{result} = $self->interpret($context);
}
$self->handle_result($context);
return 0;
}
# finish command substitution
if (exists $context->{subcmd}) {
my $command = pop @{$context->{subcmd}};
if (@{$context->{subcmd}} == 0 or $context->{alldone}) {
delete $context->{subcmd};
}
if ($command =~ s/\b(an?)(\s+)&\{subcmd\}/&{subcmd}/i) {
# fix-up a/an article
my ($article, $spaces) = ($1, $2);
my $fixed_article = select_indefinite_article $result;
if ($article eq 'AN') {
$fixed_article = uc $fixed_article;
} elsif ($article eq 'An' or $article eq 'A') {
$fixed_article = ucfirst $fixed_article;
}
$command =~ s/&\{subcmd\}/$fixed_article$spaces$result/;
} else {
$command =~ s/&\{subcmd\}/$result/;
}
if (not $context->{alldone}) {
$context->{command} = $command;
$context->{result} = $self->interpret($context);
}
$self->handle_result($context);
return 0;
}
# restore /command prefix
if ($context->{result_prefix}) {
$result = "$context->{result_prefix} $result";
}
# finish command split
if ($context->{command_split}) {
my $botnick = $self->{pbot}->{conn}->nick;
# update contextual command with next command in split
$context->{command} = delete $context->{command_split};
# reformat result to be more suitable for joining together
$result =~ s!^/say !\n!i;
$result =~ s!^/me !\n* $botnick !i;
if (not length $context->{split_result}) {
$result =~ s/^\n//;
$context->{split_result} = $result;
} else {
$context->{split_result} .= $result;
}
$context->{result} = $self->interpret($context);
$self->handle_result($context);
return 0;
}
# join command split
if ($context->{split_result}) {
my $botnick = $self->{pbot}->{conn}->nick;
# reformat result to be more suitable for joining together
$result =~ s!^/say !\n!i;
$result =~ s!^/me !\n* $botnick !i;
$result = $context->{split_result} . $result;
delete $context->{split_result};
}
# nothing more to do here if we have no result or keyword
return 0 if not length $result or not exists $context->{keyword};
my $preserve_newlines = $self->{pbot}->{registry}->get_value($context->{from}, 'preserve_newlines');
my $original_result = $result;
$context->{original_result} = $result;
$result =~ s/[\n\r]/ /g unless $preserve_newlines;
$result =~ s/[ \t]+/ /g if $context->{'condense-whitespace'};
my $max_lines = $self->{pbot}->{registry}->get_value($context->{from}, 'max_newlines') // 4;
my $lines = 0;
# split result into lines and go over each line
foreach my $line (split /[\n\r]+/, $result) {
# skip blank lines
next if $line !~ /\S/;
# paste everything if we've output the maximum lines
if (++$lines >= $max_lines) {
my $link = $self->{pbot}->{webpaste}->paste("$context->{from} <$context->{nick}> $context->{text}\n\n$original_result");
my $message = "<truncated; $link>";
if ($context->{use_output_queue}) {
my $message = {
nick => $context->{nick},
user => $context->{user},
host => $context->{host},
hostmask => $context->{hostmask},
command => $context->{command},
message => $message,
checkflood => 1
};
$self->add_message_to_output_queue($context->{from}, $message, 0);
} else {
unless ($context->{from} eq 'stdin@pbot') {
$self->{pbot}->{conn}->privmsg($context->{from}, $message);
}
}
last;
}
if ($context->{use_output_queue}) {
my $delay = rand(10) + 5;
my $message = {
nick => $context->{nick},
user => $context->{user},
host => $context->{host},
hostmask => $context->{hostmask},
command => $context->{command},
message => $line,
checkflood => 1,
};
$self->add_message_to_output_queue($context->{from}, $message, $delay);
$delay = duration($delay);
$self->{pbot}->{logger}->log("($delay delay) $line\n");
} else {
$context->{output} = $line;
$self->output_result($context);
$self->{pbot}->{logger}->log("$line\n");
}
}
# log a separator bar after command finishes
$self->{pbot}->{logger}->log("---------------------------------------------\n");
# successful command completion
return 1;
}
# truncates a message, optionally pasting to a web paste site.
# $paste_text is the version of text (e.g. with whitespace formatting preserved, etc)
# to send to the paste site.
sub truncate_result($self, $context, $text, $paste_text) {
my $max_msg_len = $self->{pbot}->{registry}->get_value('irc', 'max_msg_len') // 510;
# reduce max msg len by length of hostmask and PRIVMSG command
$max_msg_len -= length ":$self->{pbot}->{hostmask} PRIVMSG $context->{from} :";
# encode text to utf8 for byte length truncation
$text = encode('UTF-8', $text);
$paste_text = encode('UTF-8', $paste_text);
my $text_len = length $text;
if ($text_len > $max_msg_len) {
my $paste_result;
if (defined $paste_text) {
# limit pastes to 32k by default, overridable via paste.max_length
my $max_paste_len = $self->{pbot}->{registry}->get_value('paste', 'max_length') // 1024 * 32;
# truncate paste to max paste length
$paste_text = truncate_egc $paste_text, $max_paste_len;
# send text to paste site
$paste_result = $self->{pbot}->{webpaste}->paste("$context->{from} <$context->{nick}> $context->{text}\n\n$paste_text");
}
my $trunc = '... <truncated';
if (not defined $paste_result) {
# no paste
$trunc .= '>';
} else {
$trunc .= "; $paste_result>";
}
$paste_result //= 'not pasted';
$self->{pbot}->{logger}->log("Message truncated -- $paste_result\n");
# make room to append the truncation text to the message text
# (third argument to truncate_egc is '' to prevent appending its own ellipsis)
my $trunc_len = $text_len < $max_msg_len ? $text_len : $max_msg_len;
$text = truncate_egc $text, $trunc_len - length $trunc, '';
# append the truncation text
$text .= $trunc;
} else {
# decode text from utf8
$text = decode('UTF-8', $text);
}
return $text;
}
my @dehighlight_exclusions = qw/auto if unsigned break inline void case int volatile char long while const register _Alignas continue restrict _Alignof default return _Atomic do short _Bool double signed _Complex else sizeof _Generic enum static _Imaginary extern struct _Noreturn float switch _Static_assert for typedef _Thread_local goto union/;
sub dehighlight_nicks($self, $line, $channel) {
return $line if $self->{pbot}->{registry}->get_value('general', 'no_dehighlight_nicks');
my @tokens = split / /, $line;
foreach my $token (@tokens) {
my $potential_nick = $token;
$potential_nick =~ s/^[^\w\[\]\-\\\^\{\}]+//;
$potential_nick =~ s/[^\w\[\]\-\\\^\{\}]+$//;
next if length $potential_nick == 1;
next if grep { /\Q$potential_nick/i } @dehighlight_exclusions;
next if not $self->{pbot}->{nicklist}->is_present($channel, $potential_nick);
my $dehighlighted_nick = $potential_nick;
$dehighlighted_nick =~ s/(.)/$1\x{feff}/;
$token =~ s/\Q$potential_nick\E(?!:)/$dehighlighted_nick/;
}
return join ' ', @tokens;
}
sub output_result($self, $context) {
# debug flag to trace $context location and contents
if ($self->{pbot}->{registry}->get_value('general', 'debugcontext')) {
use Data::Dumper;
$Data::Dumper::Sortkeys = sub { [sort grep { not /(?:cmdlist|arglist)/ } keys %$context] };
$Data::Dumper::Indent = 2;
$self->{pbot}->{logger}->log("Interpreter::output_result\n");
$self->{pbot}->{logger}->log(Dumper $context);
$Data::Dumper::Sortkeys = 1;
}
my $output = $context->{output};
# nothing to do if we have nothing to do innit
return if not defined $output or not length $output;
# nothing more to do here if the command came from STDIN
return if $context->{from} eq 'stdin@pbot';
my $botnick = $self->{pbot}->{conn}->nick;
my $to = $context->{from};
# log the message if requested
if ($context->{checkflood}) {
$self->{pbot}->{antiflood}->check_flood($to, $botnick, $self->{pbot}->{registry}->get_value('irc', 'username'), 'pbot', $output, 0, 0, 0);
}
# nothing more to do here if the output is going to the bot
return if $to eq $botnick;
# insert null-width spaces into nicknames to prevent IRC clients
# from unncessarily highlighting people
$output = $self->dehighlight_nicks($output, $to) unless $output =~ m|^/msg |;
# handle various /command prefixes
my $type = 'echo'; # will be set to 'echo' or 'action' depending on /command prefix
if ($output =~ s/^\/say //i) {
# /say stripped off
$output = ' ' if not length $output; # ensure we output something
}
elsif ($output =~ s/^\/me //i) {
# /me stripped off
$type = 'action';
}
elsif ($context->{keyword} ne 'vm-client' && $output =~ s/^\/msg\s+([^\s]+) //i) {
# /msg somenick stripped off
$to = $1; # reset $to to output to somenick
# don't allow /msg nick1,nick2,etc
if ($to =~ /,/) {
$self->{pbot}->{logger}->log("[HACK] Disregarding attempt to /msg multiple users. $context->{hostmask} [$context->{command}] $output\n");
return;
}
# don't allow /msging any nicks that end with "serv" (e.g. ircd services; NickServ, ChanServ, etc)
if ($to =~ /.*serv(?:@.*)?$/i) {
$self->{pbot}->{logger}->log("[HACK] Disregarding attempt to /msg *serv. $context->{hostmask} [$context->{command}] $output]\n");
return;
}
if ($output =~ s/^\/me //i) {
# /me stripped off
$type = 'action';
}
else {
# strip off /say if present
$output =~ s/^\/say //i;
}
}
my $bot_nick = $self->{pbot}->{conn}->nick;
my $bot_hostmask = "$bot_nick!pbot3\@pbot";
my $bot_account = $self->{pbot}->{messagehistory}->get_message_account($bot_nick, 'pbot3', 'pbot');
if ($type eq 'echo') {
# prepend nickprefix to output
if ($context->{nickprefix} && (! $context->{nickprefix_disabled} || $context->{nickprefix_forced})) {
$output = "$context->{nickprefix}: $output";
}
elsif ($context->{add_nick}) {
$output = "$context->{nick}: $output";
}
# truncate if necessary, pasting original result to a web paste site
$output = $self->truncate_result($context, $output, $context->{original_result});
# add bot's output to message history for recall/grab
if ($to =~ /^#/) {
$self->{pbot}->{messagehistory}->add_message($bot_account, $bot_hostmask, $to, $output, MSG_CHAT);
}
# send the message to the channel/user
$self->{pbot}->{conn}->privmsg($to, $output);
}
elsif ($type eq 'action') {
# truncate if necessary, pasting original result to a web paste site
$output = $self->truncate_result($context, $output, $context->{original_result});
# add bot's output to message history for recall/grab
if ($to =~ /^#/) {
$self->{pbot}->{messagehistory}->add_message($bot_account, $bot_hostmask, $to, "/me $output", MSG_CHAT);
}
# CTCP ACTION the message to the channel/user
$self->{pbot}->{conn}->me($to, $output);
}
}
sub add_message_to_output_queue($self, $channel, $message, $delay = 0) {
$self->{pbot}->{event_queue}->enqueue_event(
sub {
my $context = {
from => $channel,
nick => $message->{nick},
user => $message->{user},
host => $message->{host},
hostmask => $message->{hostmask},
output => $message->{message},
command => $message->{command},
keyword => $message->{keyword},
checkflood => $message->{checkflood}
};
$self->output_result($context);
},
$delay, "output $channel $message->{message}"
);
}
sub add_to_command_queue($self, $channel, $command, $delay = 0, $repeating = 0) {
$self->{pbot}->{event_queue}->enqueue_event(
sub {
my $context = {
from => $channel,
nick => $command->{nick},
user => $command->{user},
host => $command->{host},