package Bot::Cobalt::Plugin::Seen; $Bot::Cobalt::Plugin::Seen::VERSION = '0.021002'; use v5.10; use Bot::Cobalt; use Bot::Cobalt::Common; use Bot::Cobalt::DB; use Path::Tiny; sub SDB () { 0 } sub BUF () { 1 } sub new { bless [ undef, # SDB +{}, # BUF ], shift } sub _parse_nick { my ($context, $nickname) = @_; lc_irc $nickname, (core->get_irc_casemap($context) || 'rfc1459') } ## FIXME method to retrieve users w/ similar hosts ## !seen search ... ? sub retrieve { my ($self, $context, $nickname) = @_; $nickname = _parse_nick($context, $nickname); my $ref = $self->[BUF]->{$context}->{$nickname}; # intentional autoviv unless (defined $ref) { my $db = $self->[SDB]; unless ($db->dbopen) { logger->warn("dbopen failed in retrieve; cannot open SeenDB"); return } my $thiskey = $context .'%'. $nickname; $ref = $db->get($thiskey); $db->dbclose; } ref $ref ? $ref : () } sub Cobalt_register { my ($self, $core) = splice @_, 0, 2; my $pcfg = $core->get_plugin_cfg($self); my $seendb_path = path( $core->var .'/'. ($pcfg->{SeenDB} || "seen.db") ); logger->info("Opening SeenDB at $seendb_path"); $self->[BUF] = +{}; $self->[SDB] = Bot::Cobalt::DB->new(file => $seendb_path); my $rc = $self->[SDB]->dbopen; $self->[SDB]->dbclose; unless ($rc) { logger->warn("Failed to open SeenDB at $seendb_path"); die "Unable to open SeenDB at $seendb_path" } register( $self, 'SERVER', qw/ public_cmd_seen nick_changed chan_sync user_joined user_left user_quit seendb_update seenplug_deferred_list /, ); core->timer_set( 6, +{ Event => 'seendb_update' }, 'SEENDB_WRITE' ); logger->info("Loaded"); PLUGIN_EAT_NONE } sub Cobalt_unregister { my ($self, $core) = splice @_, 0, 2; $self->Bot_seendb_update($core, \1); $core->log->info("Unloaded"); PLUGIN_EAT_NONE } sub Bot_seendb_update { my ($self, $core) = splice @_, 0, 2; my $force_flush = @_ ? ${ $_[0] } : 0; my $buf = $self->[BUF]; unless (keys %$buf) { $core->timer_set( 2, +{ Event => 'seendb_update' } ); return PLUGIN_EAT_ALL } my $db = $self->[SDB]; CONTEXT: for my $context (keys %$buf) { unless ($db->dbopen) { logger->warn("dbopen failed in update; cannot update SeenDB"); # FIXME exponential back-off? $core->timer_set( 6, +{ Event => 'seendb_update' } ); return PLUGIN_EAT_ALL } my $writes; NICK: for my $nickname (keys %{ $buf->{$context} }) { ## if we've done a lot of writes, yield back (unless we're cleaning up) if (!$force_flush && $writes && $writes % 50 == 0) { $db->dbclose; broadcast 'seendb_update'; return PLUGIN_EAT_ALL } ## .. else flush this item to disk my $thisbuf = delete $buf->{$context}->{$nickname}; my $thiskey = $context .'%'. $nickname; $db->put($thiskey, $thisbuf); ++$writes; } ## NICK $db->dbclose; delete $buf->{$context} unless keys %{ $buf->{$context} }; } ## CONTEXT $core->timer_set( 2, +{ Event => 'seendb_update' } ); return PLUGIN_EAT_ALL } sub Bot_user_joined { my ($self, $core) = splice @_, 0, 2; my $join = ${ $_[0] }; my $context = $join->context; my $nick = $join->src_nick; my $user = $join->src_user; my $host = $join->src_host; my $chan = $join->channel; $nick = _parse_nick($context, $nick); $self->[BUF]->{$context}->{$nick} = +{ TS => time(), Action => 'join', Channel => $chan, Username => $user, Host => $host, }; PLUGIN_EAT_NONE } sub Bot_chan_sync { my ($self, $core) = splice @_, 0, 2; my $context = ${$_[0]}; my $channel = ${$_[1]}; broadcast seenplug_deferred_list => $context, $channel; PLUGIN_EAT_NONE } sub Bot_seenplug_deferred_list { my ($self, $core) = splice @_, 0, 2; my $context = ${$_[0]}; my $channel = ${$_[1]}; my $irc = $core->get_irc_object($context); my @nicks = $irc->channel_list($channel); for my $nick (@nicks) { $nick = _parse_nick($context, $nick); $self->[BUF]->{$context}->{$nick} = +{ TS => time(), Action => 'present', Channel => $channel, Username => '', Host => '', }; } PLUGIN_EAT_ALL } sub Bot_user_left { my ($self, $core) = splice @_, 0, 2; my $part = ${ $_[0] }; my $context = $part->context; my $nick = $part->src_nick; my $user = $part->src_user; my $host = $part->src_host; my $chan = $part->channel; $nick = _parse_nick($context, $nick); $self->[BUF]->{$context}->{$nick} = +{ TS => time(), Action => 'part', Channel => $chan, Username => $user, Host => $host, }; PLUGIN_EAT_NONE } sub Bot_user_quit { my ($self, $core) = splice @_, 0, 2; my $quit = ${ $_[0] }; my $context = $quit->context; my $nick = $quit->src_nick; my $user = $quit->src_user; my $host = $quit->src_host; my $common = $quit->common; $nick = _parse_nick($context, $nick); $self->[BUF]->{$context}->{$nick} = +{ TS => time(), Action => 'quit', Channel => $common->[0], Username => $user, Host => $host, }; PLUGIN_EAT_NONE } sub Bot_nick_changed { my ($self, $core) = splice @_, 0, 2; my $nchange = ${ $_[0] }; my $context = $nchange->context; return PLUGIN_EAT_NONE if $nchange->equal; my $old = $nchange->old_nick; my $new = $nchange->new_nick; my $irc = $core->get_irc_obj($context); my $src = $irc->nick_long_form($new) || $new; my ($nick, $user, $host) = parse_user($src); my $first_common = $nchange->channels->[0]; $self->[BUF]->{$context}->{$old} = +{ TS => time(), Action => 'nchange', Channel => $first_common, Username => $user || 'unknown', Host => $host || 'unknown', Meta => { To => $new }, }; $self->[BUF]->{$context}->{$new} = +{ TS => time(), Action => 'nchange', Channel => $first_common, Username => $user || 'unknown', Host => $host || 'unknown', Meta => { From => $old }, }; PLUGIN_EAT_NONE } sub Bot_public_cmd_seen { my ($self, $core) = splice @_, 0, 2; my $msg = ${ $_[0] }; my $context = $msg->context; my $channel = $msg->channel; my $nick = $msg->src_nick; my $targetnick = $msg->message_array->[0]; unless ($targetnick) { broadcast message => $context, $channel, "Need a nickname to look for, $nick"; return PLUGIN_EAT_NONE } my $ref = $self->retrieve($context, $targetnick); unless ($ref) { broadcast message => $context, $channel, "${nick}: I don't know anything about $targetnick"; return PLUGIN_EAT_NONE } my $last_ts = $ref->{TS}; my $last_act = $ref->{Action} // ''; my $last_chan = $ref->{Channel}; my $last_user = $ref->{Username}; my $last_host = $ref->{Host}; my $meta = $ref->{Meta} // {}; my $ts_delta = time - $last_ts ; my $ts_str = secs_to_str_y($ts_delta); my $resp; ACTION: { if ($last_act eq 'quit') { $resp = "$targetnick was last seen quitting IRC $ts_str ago"; last ACTION } if ($last_act eq 'join') { $resp = "$targetnick was last seen joining $last_chan $ts_str ago"; last ACTION } if ($last_act eq 'part') { $resp = "$targetnick was last seen leaving $last_chan $ts_str ago"; last ACTION } if ($last_act eq 'present') { $resp = "$targetnick was last seen when I joined $last_chan $ts_str ago"; last ACTION } if ($last_act eq 'nchange') { if ($meta->{From}) { $resp = "$targetnick was last seen changing nicknames from " . $meta->{From} . " $ts_str ago"; } elsif ($meta->{To}) { $resp = "$targetnick was last seen changing nicknames to " . $meta->{To} . " $ts_str ago"; } else { logger->warn("BUG; no To/From recorded for nick change"); $resp = 'Something weird happened; check log file for details.'; } last ACTION } logger->warn("BUG; unknown action '$last_act'"); $resp = 'Something weird happened; check log file for details.'; } broadcast message => $context, $channel, $resp; PLUGIN_EAT_NONE } 1; __END__ =pod =head1 NAME Bot::Cobalt::Plugin::Seen - Bot::Cobalt 'seen' plugin =head1 SYNOPSIS !seen SomeNickname =head1 DESCRIPTION A fairly basic 'seen' command; tracks users joining, leaving, and changing nicknames. Uses L<Bot::Cobalt::DB> for storage. The path to the SeenDB can be specified via C<plugins.conf>: Seen: Module: Bot::Cobalt::Plugin::Seen Opts: SeenDB: path/relative/to/var/seen.db =head1 AUTHOR Jon Portnoy <avenj@cobaltirc.org> =cut