File Coverage

blib/lib/Bot/Cobalt/Plugin/RDB.pm
Criterion Covered Total %
statement 21 23 91.3
branch n/a
condition n/a
subroutine 8 8 100.0
pod n/a
total 29 31 93.5


line stmt bran cond sub pod time code
1             package Bot::Cobalt::Plugin::RDB;
2             $Bot::Cobalt::Plugin::RDB::VERSION = '0.021003';
3 1     1   1253 use v5.10;
  1         2  
4 1     1   4 use strictures 2;
  1         6  
  1         31  
5              
6 1     1   167 use File::Spec;
  1         2  
  1         17  
7 1     1   3 use List::Util 'shuffle', 'first';
  1         1  
  1         77  
8 1     1   440 use POSIX ();
  1         3356  
  1         24  
9              
10 1     1   5 use Bot::Cobalt;
  1         1  
  1         6  
11 1     1   614 use Bot::Cobalt::Common;
  1         2  
  1         7  
12 1     1   432 use Bot::Cobalt::Plugin::RDB::Database;
  0            
  0            
13              
14             use POE;
15              
16             sub new {
17             bless {
18              
19             ## Errors from DB -> RPL values:
20             RPL_MAP => {
21             RDB_NOTPERMITTED => "RDB_ERR_NOTPERMITTED",
22              
23             RDB_INVALID_NAME => "RDB_ERR_INVALID_NAME",
24              
25             RDB_EXISTS => "RDB_ERR_RDB_EXISTS",
26              
27             RDB_DBFAIL => "RPL_DB_ERR",
28              
29             RDB_FILEFAILURE => "RDB_UNLINK_FAILED",
30              
31             RDB_NOSUCH => "RDB_ERR_NO_SUCH_RDB",
32             RDB_NOSUCH_ITEM => "RDB_ERR_NO_SUCH_ITEM",
33             },
34              
35             }, shift
36             }
37              
38             sub DBmgr {
39             my ($self) = @_;
40              
41             unless ($self->{DBMGR}) {
42             my $cfg = core->get_plugin_cfg($self);
43             my $cachekeys = $cfg->{Opts}->{CacheItems} // 30;
44              
45             my $rdbdir = File::Spec->catdir(
46             core()->var,
47             $cfg->{Opts}->{RDBDir} ? $cfg->{Opts}->{RDBDir} : ('db', 'rdb')
48             );
49              
50             $self->{DBMGR} = Bot::Cobalt::Plugin::RDB::Database->new(
51             CacheKeys => $cachekeys,
52             RDBDir => $rdbdir,
53             );
54             }
55              
56             $self->{DBMGR}
57             }
58              
59             sub rand_delay {
60             my ($self, $delay) = @_;
61             return $self->{RANDDELAY} = $delay if defined $delay;
62             $self->{RANDDELAY}
63             }
64              
65             sub SessionID {
66             my ($self, $id) = @_;
67             return $self->{SESSID} = $id if defined $id;
68             $self->{SESSID}
69             }
70              
71             sub AsyncSessionID {
72             my ($self, $id) = @_;
73             return $self->{ASYNCID} = $id if defined $id;
74             $self->{ASYNCID}
75             }
76              
77             sub Cobalt_register {
78             my ($self, $core) = splice @_, 0, 2;
79              
80             register($self, 'SERVER',
81             [
82             'public_msg',
83             'rdb_broadcast',
84             'rdb_triggered',
85             ],
86             );
87              
88             ## if the rdbdir doesn't exist, ::Database will try to create it
89             ## (it'll also handle creating 'main' for us)
90             my $dbmgr = $self->DBmgr;
91              
92             ## we'll die out here if there's a problem with 'main' :
93             my $keys_c = $dbmgr->get_keys('main');
94             core->Provided->{randstuff_items} = $keys_c;
95              
96             ## kickstart a randstuff timer (named timer for rdb_broadcast)
97             ## delay is in Opts->RandDelay as a timestr
98             ## (0 turns off timer)
99             my $cfg = core->get_plugin_cfg( $self );
100             my $randdelay = $cfg->{Opts}->{RandDelay} // '30m';
101             logger->debug("randdelay: $randdelay");
102              
103             $randdelay = timestr_to_secs($randdelay) unless $randdelay =~ /^\d+$/;
104              
105             $self->rand_delay( $randdelay );
106              
107             if ($randdelay) {
108             core->timer_set( $randdelay,
109             {
110             Event => 'rdb_broadcast',
111             Alias => core->get_plugin_alias($self)
112             },
113             'RANDSTUFF'
114             );
115             }
116              
117             if ($cfg->{Opts}->{AsyncSearch}) {
118             logger->debug("spawning Session to handle AsyncSearch");
119              
120             POE::Session->create(
121             object_states => [
122             $self => [
123             '_start',
124              
125             'poe_post_search',
126              
127             'poe_got_result',
128              
129             'poe_got_error',
130             ],
131             ],
132             );
133             }
134              
135             logger->info("Registered, $keys_c items in main RDB");
136              
137             return PLUGIN_EAT_NONE
138             }
139              
140             sub Cobalt_unregister {
141             my ($self, $core) = splice @_, 0, 2;
142              
143             logger->info("Unregistering RDB");
144              
145             $poe_kernel->alias_remove('sess_'. core->get_plugin_alias($self) );
146              
147             if ( $self->AsyncSessionID ) {
148             $poe_kernel->post( $self->AsyncSessionID, 'shutdown' );
149             }
150              
151             delete core->Provided->{randstuff_items};
152              
153             core->timer_del('RANDSTUFF');
154              
155             return PLUGIN_EAT_NONE
156             }
157              
158              
159             sub Bot_public_msg {
160             my ($self, $core) = splice @_, 0, 2;
161             my $msg = ${$_[0]};
162             my $context = $msg->context;
163              
164             my @handled = qw/
165             randstuff
166             randq
167             rdb
168             /;
169              
170             ## would be better in a public_cmd_, but eh, darkbot legacy syntax..
171             return PLUGIN_EAT_NONE unless $msg->highlight;
172              
173             ## uses message_array_sp, ie spaces are preserved
174             ## (so don't include them prior to rdb names, for example)
175             my $msg_arr = $msg->message_array_sp;
176              
177             ## since this is a highlighted message, bot's nickname is first
178             my ($cmd, @message) = @$msg_arr[1 .. (scalar @$msg_arr - 1)];
179             $cmd = lc($cmd||'');
180              
181             ## ..if it's not @handled we don't care:
182             return PLUGIN_EAT_NONE unless $cmd and first {; $_ eq $cmd } @handled;
183              
184             logger->debug("dispatching $cmd");
185              
186             ## dispatcher:
187             my ($id, $resp);
188              
189             CMD: {
190             if ($cmd eq "randstuff") {
191             $resp = $self->_cmd_randstuff(\@message, $msg);
192             last CMD
193             }
194              
195             if ($cmd eq "randq") {
196             $resp = $self->_cmd_randq(\@message, $msg, 'randq');
197             last CMD
198             }
199              
200             if ($cmd eq "rdb") {
201             $resp = $self->_cmd_rdb(\@message, $msg);
202             last CMD
203             }
204             }
205              
206             my $channel = $msg->channel;
207              
208             if (defined $resp) {
209             logger->debug("dispatching msg -> $channel");
210             broadcast( 'message', $context, $channel, $resp );
211             }
212              
213             PLUGIN_EAT_NONE
214             }
215              
216              
217             ### command handlers ###
218              
219             sub _cmd_randstuff {
220             ## $parsed_msg_a == message_array without prefix/cmd
221             ## $msg == original message obj
222             my ($self, $parsed_msg_a, $msg) = @_;
223             my @message = @{ $parsed_msg_a };
224              
225             my $src_nick = $msg->src_nick;
226             my $context = $msg->context;
227              
228             my $pcfg = core->get_plugin_cfg( $self );
229              
230             my $required_level = $pcfg->{RequiredLevels}->{rdb_add_item} // 1;
231              
232             my $rplvars;
233             $rplvars->{nick} = $src_nick;
234              
235             unless ( core->auth->level($context, $src_nick) >= $required_level ) {
236             return core->rpl( 'RPL_NO_ACCESS', $rplvars )
237             }
238              
239             ## randstuff is 'main', darkbot legacy:
240             my $rdb = 'main';
241             $rplvars->{rdb} = $rdb;
242              
243             ## ...but this may be randstuff ~rdb ... syntax:
244             if (@message && index($message[0], '~') == 0) {
245             $rdb = substr(shift @message, 1);
246             $rplvars->{rdb} = $rdb;
247              
248             my $dbmgr = $self->DBmgr;
249             unless ($rdb && $dbmgr->dbexists($rdb) ) {
250             ## ~rdb specified but nonexistant
251             return core->rpl( 'RDB_ERR_NO_SUCH_RDB', $rplvars );
252             }
253             }
254              
255             ## should have just the randstuff itself now (and maybe a different rdb):
256             my $randstuff_str = join ' ', @message;
257             $randstuff_str = decode_irc($randstuff_str);
258              
259             unless ($randstuff_str) {
260             return core->rpl( 'RDB_ERR_NO_STRING', $rplvars )
261             }
262              
263             ## call _add_item
264             my $username = core->auth->username($context, $src_nick);
265             my ($newidx, $err) =
266             $self->_add_item($rdb, $randstuff_str, $username);
267             $rplvars->{index} = $newidx;
268             ## _add_item returns either a status from ::Database->put
269             ## or a new item key:
270              
271             unless ($newidx) {
272              
273             if ($err eq "RDB_DBFAIL") {
274             return core->rpl( 'RPL_DB_ERR', $rplvars )
275             } elsif ($err eq "RDB_NOSUCH") {
276             return core->rpl( 'RDB_ERR_NO_SUCH_RDB', $rplvars )
277             } else {
278             return "Unknown error status: $err"
279             }
280              
281             } else {
282             return core->rpl( 'RDB_ITEM_ADDED', $rplvars )
283             }
284              
285             }
286              
287             sub _select_random {
288             my ($self, $msg, $rdb, $quietfail) = @_;
289              
290             my $dbmgr = $self->DBmgr;
291              
292             my($item_ref, $content);
293              
294             try {
295             $item_ref = $dbmgr->random($rdb);
296             $content = $self->_content_from_ref($item_ref)
297             // '(undef - broken db?)';
298             } catch {
299             logger->debug("_select_random failure $_");
300             my $rpl = $self->{RPL_MAP}->{$_};
301             $content = core->rpl( $rpl,
302             nick => $msg->src_nick // '',
303             rdb => $rdb,
304             );
305              
306             0
307             } or return if $quietfail;
308              
309             if ($self->{LastRandom} && $self->{LastRandom} eq $content) {
310             try {
311             $item_ref = $dbmgr->random($rdb);
312             $content = $self->_content_from_ref($item_ref)
313             // '(undef - broken db?)';
314             } catch {
315             my $rpl = $self->{RPL_MAP}->{$_};
316             $content = core->rpl( $rpl,
317             nick => $msg->src_nick // '',
318             rdb => $rdb,
319             );
320             undef
321             } or return if $quietfail;
322             }
323              
324             $self->{LastRandom} = $content;
325              
326             return $content // ''
327             }
328              
329              
330             sub _cmd_randq {
331             my ($self, $parsed_msg_a, $msg, $type, $rdbpassed, $strpassed) = @_;
332             my @message = @{ $parsed_msg_a };
333              
334             ## also handler for 'rdb search rdb str'
335             my $dbmgr = $self->DBmgr;
336              
337             my($str, $rdb);
338             if ($type eq 'random') {
339             ## this is actually deprecated
340             ## use '~main' rdb info3 topic trick instead
341             return $self->_select_random($msg, 'main')
342             } elsif ($type eq 'rdb') {
343             $rdb = $rdbpassed;
344             $str = $strpassed;
345             } else { ## 'randq'
346             $rdb = 'main';
347             ## search what looks like irc quotes by default:
348             $str = shift @message // '<*>';
349             }
350              
351             logger->debug("_cmd_randq; dispatching search for $str in $rdb");
352              
353             if ( $self->SessionID ) {
354             ## if we have asyncsearch, post and return immediately
355              
356             unless ( $dbmgr->dbexists($rdb) ) {
357             return core->rpl( 'RDB_ERR_NO_SUCH_RDB',
358             nick => $msg->src_nick,
359             rdb => $rdb,
360             );
361             }
362              
363             $poe_kernel->post( $self->SessionID,
364             'poe_post_search',
365             $rdb,
366             $str,
367             { ## Hints hash
368             Glob => $str,
369             Context => $msg->context,
370             Channel => $msg->channel,
371             Nickname => $msg->src_nick,
372             GetType => 'string',
373             RDB => $rdb,
374             },
375             );
376              
377             logger->debug("_cmd_randq; search ($rdb) dispatched to AsyncSearch");
378              
379             return
380             }
381              
382             my($rpl, $match);
383              
384             try {
385             $match = $dbmgr->search($rdb, $str, 'WANTONE')
386             } catch {
387             logger->debug("_cmd_randq; Database->search() err: $_");
388             $rpl = $self->{RPL_MAP}->{$_};
389             };
390              
391             return "No matches found for $str" if not defined $match;
392              
393             return core->rpl( $rpl,
394             nick => $msg->src_nick,
395             rdb => $rdb,
396             ) if defined $rpl;
397              
398             my $item_ref = try {
399             $dbmgr->get($rdb, $match)
400             } catch {
401             logger->debug("_cmd_randq; Database->get() err: $_");
402             $rpl = $self->{RPL_MAP}->{$_};
403             };
404              
405             return core->rpl( $rpl,
406             nick => $msg->src_nick,
407             rdb => $rdb,
408             index => $match,
409             ) if defined $rpl;
410              
411             logger->debug("_cmd_randq; item found: $match");
412              
413              
414             my $content = $self->_content_from_ref($item_ref)
415             // '(undef - broken db?)';
416              
417             return "[$match] $content"
418             }
419              
420              
421             sub _cmd_rdb {
422             ## Command dispatcher for:
423             ## rdb add
424             ## rdb del
425             ## rdb get
426             ## rdb dbadd
427             ## rdb dbdel
428             ## rdb info
429             ## rdb search
430             ## rdb searchidx
431             ## FIXME rdb dblist ?
432             my ($self, $parsed_msg_a, $msg) = @_;
433             my @message = @{ $parsed_msg_a };
434              
435             my $pcfg = core->get_plugin_cfg( $self );
436             my $required_levs = $pcfg->{RequiredLevels} // {};
437             ## this hash maps commands to levels.
438             ## commands not found here aren't recognized.
439             my %access_levs = (
440             ## FIXME document these ...
441             count => $required_levs->{rdb_count} // 0,
442             get => $required_levs->{rdb_get_item} // 0,
443             dblist => $required_levs->{rdb_dblist} // 0,
444             info => $required_levs->{rdb_info} // 0,
445             dbadd => $required_levs->{rdb_create} // 9999,
446             dbdel => $required_levs->{rdb_delete} // 9999,
447             add => $required_levs->{rdb_add_item} // 2,
448             del => $required_levs->{rdb_del_item} // 3,
449             search => $required_levs->{rdb_search} // 0,
450             searchidx => $required_levs->{rdb_search} // 0,
451             );
452              
453             my $cmd = lc(shift @message || '');
454             $cmd = 'del' if $cmd eq 'delete';
455              
456             my @handled = keys %access_levs;
457             unless ($cmd && first {; $_ eq $cmd } @handled) {
458             return "Commands: add ; del , info ; "
459             ."get ; search(idx) ; count ; "
460             ."dbadd ; dbdel ";
461             }
462              
463             my $context = $msg->context;
464             my $nickname = $msg->src_nick;
465              
466             my $user_lev = core->auth->level($context, $nickname) // 0;
467             unless ($user_lev >= $access_levs{$cmd}) {
468             return core->rpl( 'RPL_NO_ACCESS',
469             nick => $nickname,
470             );
471             }
472              
473             my $method = '_cmd_rdb_'.$cmd;
474              
475             if ( $self->can($method) ) {
476             logger->debug("dispatching $method");
477             return $self->$method($msg, \@message)
478             }
479              
480             return "No handler found for command $cmd"
481             }
482              
483             sub _cmd_rdb_dbadd {
484             my ($self, $msg, $parsed_args) = @_;
485              
486             my $dbmgr = $self->DBmgr;
487              
488             my ($rdb) = @$parsed_args;
489              
490             return 'Syntax: rdb dbadd ' unless $rdb;
491              
492             return 'RDB name must be in the a-z0-9 set'
493             unless $rdb =~ /^[a-z0-9]+$/;
494              
495             return 'RDB name must be less than 32 characters'
496             unless length $rdb <= 32;
497              
498             logger->debug("_cmd_rdb_dbadd; issuing createdb()");
499              
500             my $rpl;
501             try {
502             $dbmgr->createdb($rdb);
503             $rpl = "RDB_CREATED";
504             } catch {
505             logger->debug("createdb() failure: $_");
506             $rpl = $self->{RPL_MAP}->{$_};
507             };
508              
509             my %rplvars = (
510             nick => $msg->src_nick,
511             rdb => $rdb,
512             op => 'dbadd',
513             );
514              
515             return core->rpl( $rpl, %rplvars )
516             }
517              
518             sub _cmd_rdb_dbdel {
519             my ($self, $msg, $parsed_args) = @_;
520              
521             my ($rdb) = @$parsed_args;
522              
523             return 'Syntax: rdb dbdel ' unless $rdb;
524              
525             my $rplvars = {
526             nick => $msg->src_nick,
527             rdb => $rdb,
528             op => 'dbdel',
529             };
530              
531             my ($retval, $err) = $self->_delete_rdb($rdb);
532              
533             my $rpl;
534             if ($retval) {
535             $rpl = 'RDB_DELETED';
536             } else {
537             DBDELERR: {
538             if ($err eq 'RDB_NOTPERMITTED') {
539             $rpl = 'RDB_ERR_NOTPERMITTED'; last DBDELERR
540             }
541              
542             if ($err eq 'RDB_NOSUCH') {
543             $rpl = 'RDB_ERR_NO_SUCH_RDB'; last DBDELERR
544             }
545            
546             if ($err eq 'RDB_DBFAIL') {
547             $rpl = 'RPL_DB_ERR'; last DBDELERR
548             }
549              
550             if ($err eq 'RDB_FILEFAILURE') {
551             $rpl = 'RDB_UNLINK_FAILED'; last DBDELERR
552             }
553              
554             my $errstr = "BUG; Unknown err $err from _delete_rdb";
555             logger->warn($errstr);
556             return $errstr
557             }
558             }
559              
560             return core->rpl( $rpl, $rplvars )
561             }
562              
563             sub _cmd_rdb_add {
564             my ($self, $msg, $parsed_args) = @_;
565              
566             my ($rdb, @pieces) = @$parsed_args;
567             my $item = join ' ', @pieces;
568              
569             return 'Syntax: rdb add ' unless $rdb and $item;
570              
571             my $rplvars = {
572             nick => $msg->src_nick,
573             rdb => $rdb,
574             };
575              
576             my $username = core->auth->username($msg->context, $msg->src_nick);
577              
578             my ($retval, $err) =
579             $self->_add_item($rdb, decode_irc($item), $username);
580              
581             my $rpl;
582             if ($retval) {
583             $rplvars->{index} = $retval;
584             $rpl = 'RDB_ITEM_ADDED';
585             } else {
586             RDBADDERR: {
587             if ($err eq 'RDB_NOSUCH') {
588             $rpl = 'RDB_ERR_NO_SUCH_RDB'; last RDBADDERR
589             }
590              
591             if ($err eq 'RDB_DBFAIL') {
592             $rpl = 'RPL_DB_ERR'; last RDBADDERR
593             }
594              
595             my $errstr = "BUG; Unknown err $err from _add_item";
596             logger->warn($errstr);
597             return $errstr
598             }
599             }
600              
601             return core->rpl( $rpl, $rplvars )
602             }
603              
604             sub _cmd_rdb_del {
605             my ($self, $msg, $parsed_args) = @_;
606              
607             my ($rdb, @item_indexes) = @$parsed_args;
608              
609             return 'Syntax: rdb del '
610             unless $rdb and @item_indexes;
611              
612             my $rplvars = {
613             nick => $msg->src_nick,
614             rdb => $rdb,
615             };
616              
617             my $username = core->auth->username($msg->context, $msg->src_nick);
618              
619             INDEX: for my $item_idx (@item_indexes) {
620             my ($retval, $err) =
621             $self->_delete_item($rdb, $item_idx, $username);
622              
623             $rplvars->{index} = $item_idx;
624              
625             my $rpl;
626              
627             if ($retval) {
628             $rpl = "RDB_ITEM_DELETED";
629             } else {
630             ITEMDELERR: {
631             if ($err eq 'RDB_NOSUCH') {
632             $rpl = 'RDB_ERR_NO_SUCH_RDB'; last ITEMDELERR
633             }
634             if ($err eq 'RDB_DBFAIL') {
635             $rpl = 'RPL_DB_ERR'; last ITEMDELERR
636             }
637             if ($err eq 'RDB_NOSUCH_ITEM') {
638             $rpl = 'RDB_ERR_NO_SUCH_ITEM'; last ITEMDELERR
639             }
640             my $errstr = "BUG; Unknown err $err from _delete_item";
641             logger->warn($errstr);
642             return $errstr
643             }
644              
645             }
646              
647             broadcast( 'message',
648             $msg->context,
649             $msg->channel,
650             core->rpl($rpl, $rplvars)
651             );
652              
653             } ## INDEX
654              
655             return
656             }
657              
658             sub _cmd_rdb_get {
659             my ($self, $msg, $parsed_args) = @_;
660              
661             my $dbmgr = $self->DBmgr;
662              
663             my ($rdb, $idx) = @$parsed_args;
664              
665             return 'Syntax: rdb get '
666             unless $rdb and $idx;
667              
668             return "Invalid item index ID"
669             unless $idx =~ /^[0-9a-f]+$/i;
670              
671             $idx = lc($idx);
672              
673             my $rplvars = {
674             nick => $msg->src_nick,
675             rdb => $rdb,
676             ## Cut the index ID in response string to 16 chars
677             ## Gives some flex without making flooding too easy
678             index => substr($idx, 0, 16),
679             };
680              
681             unless ( $dbmgr->dbexists($rdb) ) {
682             return core->rpl( 'RDB_ERR_NO_SUCH_RDB', $rplvars );
683             }
684              
685             my ($item_ref, $rpl);
686              
687             try {
688             $item_ref = $dbmgr->get($rdb, $idx)
689             } catch {
690             logger->debug("_cmd_rdb_get; Database->get error $_");
691             $rpl = $self->{RPL_MAP}->{$_}
692             };
693              
694             return core->rpl( $rpl, $rplvars )
695             if defined $rpl;
696              
697             my $content = $self->_content_from_ref($item_ref)
698             // '(undef - broken db?)' ;
699              
700             return "[$idx] $content"
701             }
702              
703             sub _cmd_rdb_info {
704             my ($self, $msg, $parsed_args) = @_;
705              
706             my $dbmgr = $self->DBmgr;
707              
708             my ($rdb, $idx) = @$parsed_args;
709              
710             return 'Syntax: rdb info '
711             unless $rdb;
712              
713             my $rplvars = {
714             nick => $msg->src_nick,
715             rdb => $rdb,
716             };
717              
718             return core->rpl( 'RDB_ERR_NO_SUCH_RDB', $rplvars )
719             unless $dbmgr->dbexists($rdb);
720              
721             if (!$idx) {
722              
723             return try {
724             my $n_keys = $dbmgr->get_keys($rdb);
725             "RDB $rdb has $n_keys items"
726             } catch {
727             "RDB::Database error: ".$_
728             }
729              
730             } else {
731             return "Invalid item index ID"
732             unless $idx =~ /^[0-9a-f]+$/i;
733              
734             $idx = lc($idx);
735             }
736              
737             $rplvars->{index} = substr($idx, 0, 16);
738              
739             my ($item_ref, $rpl);
740              
741             try {
742             $item_ref = $dbmgr->get($rdb, $idx)
743             } catch {
744             logger->debug("_cmd_rdb_info; Database->get error $_");
745             $rpl = $self->{RPL_MAP}->{$_}
746             };
747              
748             return core->rpl( $rpl, $rplvars )
749             if defined $rpl;
750              
751             my $addedat_ts = ref $item_ref eq 'HASH' ?
752             $item_ref->{AddedAt} : $item_ref->[1];
753              
754             my $added_by = ref $item_ref eq 'HASH' ?
755             $item_ref->{AddedBy} : $item_ref->[2];
756              
757             $rplvars->{date} = POSIX::strftime(
758             "%Y-%m-%d", localtime( $addedat_ts )
759             );
760              
761             $rplvars->{time} = POSIX::strftime(
762             "%H:%M:%S (%Z)", localtime( $addedat_ts )
763             );
764              
765             $rplvars->{addedby} = $added_by // '(undef)' ;
766              
767             return core->rpl( 'RDB_ITEM_INFO', $rplvars );
768             }
769              
770             sub _cmd_rdb_count {
771             my ($self, $msg, $parsed_args) = @_;
772              
773             my ($rdb, $str) = @$parsed_args;
774              
775             ## count is the same as info
776             return $self->_cmd_rdb_info($msg, $parsed_args)
777             unless defined $str;
778              
779             return 'Syntax: rdb count '
780             unless defined $rdb;
781              
782             my $indices = $self->_searchidx($msg, 'count', $rdb, $str);
783              
784             ## Same deal as searchidx, return immediately if this is async.
785             return unless ref $indices eq 'ARRAY';
786              
787             my $count = @$indices;
788             return $msg->src_nick .": Found $count matches";
789             }
790              
791             sub _cmd_rdb_search {
792             my ($self, $msg, $parsed_args) = @_;
793              
794             ## Pass-thru to _cmd_randq
795              
796             my ($rdb, $str) = @$parsed_args;
797              
798             $str = '*' unless $str;
799             return 'Syntax: rdb search ' unless $rdb;
800              
801             return $self->_cmd_randq([], $msg, 'rdb', $rdb, $str)
802             }
803              
804             sub _cmd_rdb_searchidx {
805             my ($self, $msg, $parsed_args) = @_;
806              
807             my ($rdb, $str) = @$parsed_args;
808              
809             return 'Syntax: rdb searchidx '
810             unless $rdb and $str;
811              
812             my $indices = $self->_searchidx($msg, 'indexes', $rdb, $str);
813              
814             ## if we posted out to asyncsearch, return immediately
815             return unless ref $indices eq 'ARRAY';
816              
817             ## otherwise we should have indices
818             $indices->[0] = 'No matches' unless @$indices;
819             my $count = @$indices;
820              
821             my (@returned, $prefix);
822             if ($count > 30) {
823             @returned = @$indices[0 .. 29];
824             $prefix = "Matches (30 of $count): ";
825             } else {
826             @returned = @$indices;
827             $prefix = "Matches: ";
828             }
829              
830             return $prefix.join(' ', @returned);
831             }
832             ### self-events ###
833              
834             sub Bot_rdb_triggered {
835             ## Bot_rdb_triggered $context, $channel, $nick, $rdb
836             my ($self, $core) = splice @_, 0, 2;
837             my $context = ${$_[0]};
838             my $channel = ${$_[1]};
839             my $nick = ${$_[2]};
840             my $rdb = ${$_[3]};
841             my $orig = ${$_[4]};
842             my $questionstr = ${$_[5]};
843              
844             ## event normally triggered by Info3 when a topic references a ~rdb
845             ## grab a random response and throw it back at the pipeline
846             ## info3 plugin can pick it up and do variable replacement on it
847              
848             logger->debug("received rdb_triggered");
849              
850             my $dbmgr = $self->DBmgr;
851              
852             ## if referenced rdb doesn't exist, send orig string
853             my $send_orig;
854             unless ( $dbmgr->dbexists($rdb) ) {
855             ++$send_orig;
856             }
857              
858             ## construct fake msg obj for _select_random
859             my $new_msg = Bot::Cobalt::IRC::Message::Public->new(
860             context => $context,
861             src => $nick . '!fake@host',
862             targets => [ $channel ],
863             message => '',
864             );
865              
866             my $random = $send_orig ? $orig
867             : $self->_select_random($new_msg, $rdb, 'quietfail') ;
868              
869             if (exists core()->Provided->{info_topics}) {
870             broadcast( 'info3_relay_string',
871             $context, $channel, $nick, $random, $questionstr
872             );
873             } else {
874             logger->warn("RDB plugin cannot trigger, Info3 is missing");
875             }
876             return PLUGIN_EAT_ALL
877             }
878              
879             sub Bot_rdb_broadcast {
880             my ($self, $core) = splice @_, 0, 2;
881             ## our timer self-event
882              
883             ## reset timer unless randdelay is 0
884             if ($self->rand_delay) {
885             $core->timer_set( $self->rand_delay,
886             {
887             Event => 'rdb_broadcast',
888             Alias => $core->get_plugin_alias($self)
889             },
890             'RANDSTUFF'
891             );
892              
893             logger->debug("rdb_broadcast; timer reset; ".$self->rand_delay);
894             }
895              
896             my $mock_msg = Bot::Cobalt::IRC::Message::Public->new(
897             context => '',
898             src => '',
899             targets => [],
900             message => '',
901             );
902              
903             my $random = $self->_select_random($mock_msg, 'main', 'quietfail')
904             // return PLUGIN_EAT_ALL;
905              
906             ## iterate channels cfg
907             ## throw randstuffs at configured channels unless told not to
908             my $servers = $core->Servers;
909              
910             SERVER: for my $context (keys %$servers) {
911             my $c_obj = $core->get_irc_context($context);
912              
913             next SERVER unless $c_obj->connected;
914              
915             my $irc = $core->get_irc_obj($context) || next SERVER;
916             my $chcfg = $core->get_channels_cfg($context) || next SERVER;
917              
918             logger->debug("rdb_broadcast to $context");
919              
920             my $on_channels = $irc->channels || {};
921             my $casemap = $core->get_irc_casemap($context) || 'rfc1459';
922             my @channels = map { lc_irc($_, $casemap) } keys %$on_channels;
923              
924             my $evtype;
925             if ( index($random, '+') == 0 ) {
926             ## action
927             $random = substr($random, 1);
928             $evtype = 'action';
929             } else {
930             $evtype = 'message';
931             }
932              
933             logger->debug("rdb_broadcast; type is $evtype");
934              
935             @channels = grep {
936             $chcfg->{ lc_irc($_, $casemap) }->{rdb_randstuffs} // 1
937             } @channels;
938              
939             if ($evtype eq 'message') {
940             my $maxtargets = $c_obj->maxtargets;
941             while (my @targets = splice @channels, 0, $maxtargets) {
942             my $tcount = @targets;
943             my $targetstr = join ',', @targets;
944              
945             logger->debug(
946             "rdb_broadcast (MSG) to $tcount targets (max $maxtargets)",
947             "($context -> $targetstr)"
948             );
949              
950             broadcast($evtype, $context, $targetstr, $random);
951             }
952             } else {
953             ## FIXME
954             ## Seeing incorrect output when directing ACTION to multiple
955             ## channels; TESTME
956             for my $targetstr (@channels) {
957             logger->debug(
958             "rdb_broadcast (ACTION) to $targetstr",
959             );
960             broadcast($evtype, $context, $targetstr, $random)
961             }
962             }
963              
964             } # SERVER
965              
966             return PLUGIN_EAT_ALL ## theoretically no one else cares
967             }
968              
969              
970             ### util methods
971              
972             sub _content_from_ref {
973             ## Backwards-compat retrieval.
974             ## (Old-style RDB items were hashrefs.)
975             my ($self, $ref) = @_;
976             ref $ref eq 'HASH' ? $ref->{String} : $ref->[0]
977             }
978              
979             sub _searchidx {
980             my ($self, $msg, $type, $rdb, $string) = @_;
981             $rdb = 'main' unless $rdb;
982             $string = '<*>' unless $string;
983              
984             my $dbmgr = $self->DBmgr;
985              
986             if ( $self->SessionID ) {
987             ## if we have asyncsearch, return immediately
988              
989             unless ( $dbmgr->dbexists($rdb) ) {
990             return core->rpl( 'RDB_ERR_NO_SUCH_RDB',
991             nick => $msg->src_nick,
992             rdb => $rdb,
993             );
994             }
995              
996             logger->debug("_searchidx; dispatching to poe_post_search");
997              
998             $poe_kernel->post( $self->SessionID,
999             'poe_post_search',
1000             $rdb,
1001             $string,
1002             { ## Hints hash
1003             Glob => $string,
1004             Context => $msg->context,
1005             Channel => $msg->channel,
1006             Nickname => $msg->src_nick,
1007             GetType => $type,
1008             RDB => $rdb,
1009             },
1010             );
1011              
1012             return
1013             }
1014              
1015             return try {
1016             logger->debug("_searchidx; dispatching (blocking) search");
1017             scalar $dbmgr->search($rdb, $string)
1018             } catch {
1019             logger->debug("_searchidx failure; $_");
1020             undef ## FIXME throw exception ?
1021             }
1022             }
1023              
1024             sub _add_item {
1025             my ($self, $rdb, $item, $username) = @_;
1026             return unless $rdb and defined $item;
1027              
1028             $username = '-undefined' unless $username;
1029              
1030             my $dbmgr = $self->DBmgr;
1031             unless ( $dbmgr->dbexists($rdb) ) {
1032             logger->debug("cannot add item to nonexistant rdb: $rdb");
1033             return (0, 'RDB_NOSUCH')
1034             }
1035              
1036             my $itemref = [ $item, time(), $username ];
1037              
1038             my ($status, $err);
1039             try {
1040             $status = $dbmgr->put($rdb, $itemref)
1041             } catch {
1042             $err = $_
1043             };
1044              
1045             return(0, $err) if defined $err;
1046              
1047             ## otherwise we should've gotten the new key back:
1048             my $pref = core->Provided;
1049             ++$pref->{randstuff_items} if $rdb eq 'main';
1050              
1051             return $status
1052             }
1053              
1054             sub _delete_item {
1055             my ($self, $rdb, $item_idx, $username) = @_;
1056             return unless $rdb and defined $item_idx;
1057              
1058             my $dbmgr = $self->DBmgr;
1059              
1060             unless ( $dbmgr->dbexists($rdb) ) {
1061             logger->debug("cannot delete from nonexistant rdb: $rdb");
1062             return(0, 'RDB_NOSUCH')
1063             }
1064              
1065             my ($status, $err);
1066             try {
1067             $status = $dbmgr->del($rdb, $item_idx)
1068             } catch {
1069             $err = $_
1070             };
1071              
1072             return(0, $err) if defined $err;
1073              
1074             my $pref = core->Provided;
1075             --$pref->{randstuff_items} if $rdb eq 'main';
1076              
1077             return $item_idx
1078             }
1079              
1080              
1081             sub _delete_rdb {
1082             my ($self, $rdb) = @_;
1083             return unless $rdb;
1084              
1085             my $pcfg = core->get_plugin_cfg( $self );
1086              
1087             my $can_delete = $pcfg->{Opts}->{AllowDelete} // 0;
1088              
1089             unless ($can_delete) {
1090             logger->debug("attempted delete but AllowDelete = 0");
1091             return (0, 'RDB_NOTPERMITTED')
1092             }
1093              
1094             my $dbmgr = $self->DBmgr;
1095              
1096             unless ( $dbmgr->dbexists($rdb) ) {
1097             logger->debug("cannot delete nonexistant rdb $rdb");
1098             return (0, 'RDB_NOSUCH')
1099             }
1100              
1101             if ($rdb eq 'main') {
1102             ## check if this is 'main'
1103             ## check core cfg to see if we can delete 'main'
1104             ## default to no
1105             my $can_del_main = $pcfg->{Opts}->{AllowDeleteMain} // 0;
1106             unless ($can_del_main) {
1107             logger->debug(
1108             "attempted to delete main but AllowDelete Main = 0"
1109             );
1110             return (0, 'RDB_NOTPERMITTED')
1111             }
1112             }
1113              
1114             my ($status, $err);
1115             try {
1116             $status = $dbmgr->deldb($rdb)
1117             } catch {
1118             $err = $_
1119             };
1120              
1121             return(0, $err) if defined $err;
1122              
1123             return 1
1124             }
1125              
1126              
1127             ## POE
1128              
1129             sub _start {
1130             my ($self, $kernel, $heap) = @_[OBJECT, KERNEL, HEAP];
1131              
1132             $self->SessionID( $_[SESSION]->ID );
1133              
1134             $kernel->alias_set('sess_'. core->get_plugin_alias($self) );
1135              
1136             # if you change the default (5) adjust default etc/plugins/rdb.conf ->
1137             my $maxworkers = core()->get_plugin_cfg($self)->{Opts}->{AsyncSearch};
1138             $maxworkers = 5 unless $maxworkers =~ /^[0-9]+$/ and $maxworkers > 1;
1139              
1140             ## spawn asyncsearch sess
1141             require Bot::Cobalt::Plugin::RDB::AsyncSearch;
1142              
1143             my $asid = Bot::Cobalt::Plugin::RDB::AsyncSearch->spawn(
1144             MaxWorkers => $maxworkers,
1145             ResultEvent => 'poe_got_result',
1146             ErrorEvent => 'poe_got_error',
1147             );
1148              
1149             $self->AsyncSessionID( $asid );
1150             }
1151              
1152             sub poe_post_search {
1153             my ($self, $kernel, $heap) = @_[OBJECT, KERNEL, HEAP];
1154             my ($rdbname, $globstr, $hintshash) = @_[ARG0 .. $#_];
1155              
1156             logger->debug("Posting async search ($rdbname)");
1157              
1158             ## compose rdb path
1159             my $cfg = core->get_plugin_cfg($self);
1160              
1161             my $rdbdir = File::Spec->catdir(
1162             core()->var,
1163             $cfg->{Opts}->{RDBDir} ? $cfg->{Opts}->{RDBDir} : ('db', 'rdb')
1164             );
1165              
1166             my $rdbpath = File::Spec->catfile( $rdbdir, "$rdbname.rdb" );
1167              
1168             my $dbmgr = $self->DBmgr;
1169              
1170             if (my @matches = $dbmgr->cache_check($rdbname, $globstr) ) {
1171             ## have cached results in ::Database's cache
1172             ## yield back to ourselves and return
1173             $kernel->post( $_[SESSION], 'poe_got_result',
1174             \@matches,
1175             $hintshash,
1176             );
1177             return
1178             }
1179              
1180             my $re = glob_to_re_str($globstr);
1181              
1182             ## post a search w / hintshash
1183             $kernel->post( $self->AsyncSessionID,
1184             'search_rdb',
1185             $rdbpath,
1186             $re,
1187             $hintshash
1188             );
1189             }
1190              
1191             sub poe_got_result {
1192             my ($self, $kernel, $heap) = @_[OBJECT, KERNEL, HEAP];
1193             my ($resultarr, $hintshash) = @_[ARG0, ARG1];
1194              
1195             my $context = $hintshash->{Context};
1196             my $channel = $hintshash->{Channel};
1197             my $nickname = $hintshash->{Nickname};
1198             ## type is: string, indexes, or count
1199             ## (aka: randq / rdb search, rdb searchidx, rdb count)
1200             my $type = $hintshash->{GetType};
1201             my $glob = $hintshash->{Glob};
1202             my $rdb = $hintshash->{RDB};
1203              
1204             logger->debug("Received async search response ($rdb)");
1205              
1206             my $resp;
1207              
1208             my $dbmgr = $self->DBmgr;
1209              
1210             RESPTYPE: for ($type) {
1211             if ($type eq 'string') {
1212             unless (@$resultarr) {
1213             $resp = "$nickname: No matches found for $glob";
1214             } else {
1215             ## cachable, we get a full set back
1216             $dbmgr->cache_push($rdb, $glob, $resultarr);
1217              
1218             my $itemkey = $resultarr->[rand @$resultarr];
1219              
1220             my ($item, $rpl);
1221              
1222             try {
1223             $item = $dbmgr->get($rdb, $itemkey)
1224             } catch {
1225             logger->debug("poe_got_result; error from get(): $_");
1226             $rpl = $self->{RPL_MAP}->{$_}
1227             };
1228              
1229             if (defined $rpl) {
1230             $resp = core->rpl( $rpl,
1231             nick => $nickname,
1232             rdb => $rdb,
1233             index => $itemkey,
1234             );
1235             } else {
1236             my $content = $self->_content_from_ref($item)
1237             // '(undef - broken db?)';
1238              
1239             $resp = "[$itemkey] $content"
1240             }
1241             }
1242             last RESPTYPE
1243             }
1244              
1245             if ($type eq 'indexes') {
1246             unless (@$resultarr) {
1247             $resp = "$nickname: No matches found for $glob";
1248             } else {
1249             $dbmgr->cache_push($rdb, $glob, $resultarr);
1250              
1251             my $count = @$resultarr;
1252              
1253             my (@returned, $prefix);
1254              
1255             if ($count > 30) {
1256             @returned = (shuffle @$resultarr)[0 .. 29];
1257             $prefix = "$nickname: matches (30 / $count): ";
1258             } else {
1259             @returned = @$resultarr;
1260             $prefix = "$nickname: matches ($count): ";
1261             }
1262              
1263             $resp = $prefix . join(' ', @returned);
1264             }
1265             last RESPTYPE
1266             }
1267              
1268             if ($type eq 'count') {
1269             $dbmgr->cache_push($rdb, $glob, $resultarr)
1270             if @$resultarr;
1271              
1272             my $count = @$resultarr;
1273             $resp = "$nickname: Found $count matches for $glob";
1274             last RESPTYPE
1275             }
1276              
1277             }
1278              
1279             broadcast( 'message', $context, $channel, $resp )
1280             if defined $resp;
1281             }
1282              
1283             sub poe_got_error {
1284             my ($self, $kernel, $heap) = @_[OBJECT, KERNEL, HEAP];
1285             my ($error, $hints) = @_[ARG0, ARG1];
1286              
1287             my $glob = $hints->{Glob};
1288             my $rdb = $hints->{RDB};
1289              
1290             logger->warn("Received error from AsyncSearch: $rdb ($glob): $error");
1291              
1292             my $context = $hints->{Context};
1293             my $channel = $hints->{Channel};
1294             my $nickname = $hints->{Nickname};
1295              
1296             broadcast( 'message', $context, $channel,
1297             "$nickname: asyncsearch error: $error ($rdb)"
1298             );
1299             }
1300              
1301             1;
1302              
1303             =pod
1304              
1305             =head1 NAME
1306              
1307             Bot::Cobalt::Plugin::RDB - Bot::Cobalt "random" DB plugin
1308              
1309             =head1 DESCRIPTION
1310              
1311             Jason Hamilton's B came with the concept of "randstuffs,"
1312             randomized responses broadcast to channels via a timer.
1313              
1314             Later versions included a search interface and "RDBs" -- discrete
1315             'randstuff' databases that could be accessed via 'info' topic triggers
1316             to return a random response.
1317              
1318             B used essentially the same interface.
1319             This B plugin attempts to expand on that concept.
1320              
1321             This functionality is often useful to simulate humanoid responses to
1322             conversation (by writing 'conversational' RDB replies triggered by
1323             L topics), to implement IRC quotebots, or just
1324             to fill your channel with random chatter.
1325              
1326             The "randstuff" db is labelled "main" -- all other RDB names must be
1327             in the [a-z0-9] set.
1328              
1329             Requires L.
1330              
1331             =head1 COMMANDS
1332              
1333             Commands are prefixed with the bot's nickname, rather than CmdChar.
1334              
1335             This is a holdover from darkbot legacy syntax.
1336              
1337             botnick: randq some*glob
1338              
1339             =head2 randq
1340              
1341             Search for a specified glob in RDB 'main' (randstuffs):
1342              
1343             bot: randq some+string*
1344              
1345             See L for details regarding glob syntax.
1346              
1347             =head2 randstuff
1348              
1349             Add a new "randstuff" to the 'main' RDB
1350              
1351             bot: randstuff new randstuff string
1352              
1353             A randstuff can also be an action; simply prefix the string with B<+> :
1354              
1355             bot: randstuff +dances around
1356              
1357             Legacy darkbot-style syntax is supported; you can add items to RDBs
1358             by prefixing the RDB name with B<~>, like so:
1359              
1360             randstuff ~myrdb some new string
1361              
1362             The RDB must already exist; see L
1363              
1364             =head2 rdb
1365              
1366             =head3 rdb get
1367              
1368             rdb get
1369              
1370             Retrieves the specified item from the specified RDB.
1371              
1372             =head3 rdb info
1373              
1374             rdb info
1375             rdb info
1376              
1377             Given just a RDB name, returns the number of items in the RDB.
1378              
1379             Given a RDB name and a valid itemID, returns some metadata regarding the
1380             item, including the username that added it and the date it was added.
1381              
1382             =head3 rdb add
1383              
1384             rdb add
1385              
1386             Add a new item to the specified RDB. Also see L
1387              
1388             =head3 rdb del
1389              
1390             rdb del [itemID ...]
1391              
1392             Deletes items from the specified RDB.
1393              
1394             =head3 rdb dbadd
1395              
1396             rdb dbadd
1397              
1398             Creates a new, empty RDB.
1399              
1400             =head3 rdb dbdel
1401              
1402             rdb dbdel
1403              
1404             Deletes the specified RDB entirely.
1405              
1406             Deletion may be disabled in the plugin's configuration file via the
1407             B<< Opts->AllowDelete >> directive.
1408              
1409             =head3 rdb search
1410              
1411             rdb search
1412              
1413             Search within a specific RDB. Returns a single random response from the
1414             result set. Also see L and L
1415             for more details on search syntax.
1416              
1417             =head3 rdb searchidx
1418              
1419             rdb searchidx
1420              
1421             Returns all RDB item IDs matching the specified glob.
1422              
1423             =head3 rdb count
1424              
1425             rdb count
1426              
1427             Returns just the total number of matches for the specified glob.
1428              
1429             =head2 random
1430              
1431             'random' is not actually a built-in command; however, since you must have
1432             L, a handy trick is to add a topic named 'random'
1433             that triggers RDB 'main':
1434              
1435             bot: add random ~main
1436              
1437             That will allow use of 'random' to pull a randomly-selected entry from the
1438             'randstuffs' database.
1439              
1440              
1441             =head1 EVENTS
1442              
1443             =head2 Received events
1444              
1445             =head3 rdb_broadcast
1446              
1447             Self-triggered event.
1448              
1449             Called on a timer to broadcast randstuffs from RDB "main."
1450              
1451             Takes no arguments.
1452              
1453             =head3 rdb_triggered
1454              
1455             Triggered (usually by L) when a RDB is polled
1456             for a random response.
1457              
1458             Arguments are:
1459              
1460             $context, $channel, $nick, $rdb, $topic_value, $original_str
1461              
1462             Broadcasts an L in response, which is picked up by
1463             B to perform variable replacement before relaying back to the
1464             calling channel.
1465              
1466             =head2 Emitted events
1467              
1468             =head3 info3_relay_string
1469              
1470             Broadcast by L to be picked up by L.
1471              
1472             Arguments are:
1473              
1474             $context, $channel, $nick, $string, $original
1475              
1476             =head1 AUTHOR
1477              
1478             Jon Portnoy
1479              
1480             =cut