3
0
mirror of https://github.com/pragma-/pbot.git synced 2024-11-27 06:19:25 +01:00
pbot/PBot/MessageHistory.pm
Pragmatic Software 857d1aa0d3 Refactor message account linking
Linking of message accounts is now significantly less likely to produce
false-positives.

Previously, any hostmasks with matching nick!*@* would be strongly linked
together.  This led to falsely-linking accounts, either inadvertently or
intentionally.

For example, Bob might also be known as Bob_ and Bobby,
but primarily uses Bob as his main nick.  Somebody else might join with
Bobby and end up being linked to Bob.  Now both Bob and the new Bobby are
linked together as the same person, but likely with different *!user@host.

Now if the new Bobby ever gets banned, then Bob will also end up being
banned for evading Bobby's ban.

This was a sore spot in the previous linking implementation.

This new implementation has several adjustments to more intelligently link
accounts only when they're proven beyond a reasonable doubt to be the same
person (e.g. by matching nickserv accounts, etc).

Consequently, rather than aggressively linking accounts and catching more
ban-evaders at the risk of potentially falsely-linking accounts and falsely
detecting innocent people as ban-evaders, this new implementation will instead
link accounts more reliably at the risk of potential ban-evaders not yet being
linked together and thus being able to evade a ban.

This is a more preferable and reasonable risk.  Active channel ops should be
able to catch any obnoxious ban-evaders that slip through this net.
2016-08-17 20:34:45 -07:00

395 lines
13 KiB
Perl

# File: MessageHistory.pm
# Author: pragma_
#
# Purpose: Keeps track of who has said what and when, as well as their
# nickserv accounts and alter-hostmasks.
#
# Used in conjunction with AntiFlood and Quotegrabs for kick/ban on
# flood/ban-evasion and grabbing quotes, respectively.
package PBot::MessageHistory;
use warnings;
use strict;
use Getopt::Long qw(GetOptionsFromString);
use Time::HiRes qw(gettimeofday tv_interval);
use Time::Duration;
use Carp ();
use PBot::MessageHistory_SQLite;
sub new {
if(ref($_[1]) eq 'HASH') {
Carp::croak("Options to " . __FILE__ . " should be key/value pairs, not hash reference");
}
my ($class, %conf) = @_;
my $self = bless {}, $class;
$self->initialize(%conf);
return $self;
}
sub initialize {
my ($self, %conf) = @_;
$self->{pbot} = delete $conf{pbot} // Carp::croak("Missing pbot reference to " . __FILE__);
$self->{filename} = delete $conf{filename} // $self->{pbot}->{registry}->get_value('general', 'data_dir') . '/message_history.sqlite3';
$self->{database} = PBot::MessageHistory_SQLite->new(pbot => $self->{pbot}, filename => $self->{filename});
$self->{database}->begin();
$self->{database}->devalidate_all_channels();
$self->{MSG_CHAT} = 0; # PRIVMSG, ACTION
$self->{MSG_JOIN} = 1; # JOIN
$self->{MSG_DEPARTURE} = 2; # PART, QUIT, KICK
$self->{MSG_NICKCHANGE} = 3; # CHANGED NICK
$self->{pbot}->{registry}->add_default('text', 'messagehistory', 'max_recall_time', $conf{max_recall_time} // 0);
$self->{pbot}->{registry}->add_default('text', 'messagehistory', 'max_messages', 32);
$self->{pbot}->{commands}->register(sub { $self->recall_message(@_) }, "recall", 0);
$self->{pbot}->{commands}->register(sub { $self->list_also_known_as(@_) }, "aka", 0);
$self->{pbot}->{commands}->register(sub { $self->rebuild_aliases(@_) }, "rebuildaliases", 90);
$self->{pbot}->{commands}->register(sub { $self->aka_link(@_) }, "akalink", 60);
$self->{pbot}->{commands}->register(sub { $self->aka_unlink(@_) }, "akaunlink", 60);
$self->{pbot}->{atexit}->register(sub { $self->{database}->end(); return; });
}
sub get_message_account {
my ($self, $nick, $user, $host) = @_;
return $self->{database}->get_message_account($nick, $user, $host);
}
sub add_message {
my ($self, $account, $mask, $channel, $text, $mode) = @_;
$self->{database}->add_message($account, $mask, $channel, { timestamp => scalar gettimeofday, msg => $text, mode => $mode });
}
sub rebuild_aliases {
my ($self, $from, $nick, $user, $host, $arguments) = @_;
$self->{database}->rebuild_aliases_table;
}
sub aka_link {
my ($self, $from, $nick, $user, $host, $arguments) = @_;
my ($id, $alias, $type) = split /\s+/, $arguments;
$type = $self->{database}->{alias_type}->{STRONG} if not defined $type;
if (not $id or not $alias) {
return "Usage: link <target id> <alias id> [type]";
}
my $source = $self->{database}->find_most_recent_hostmask($id);
my $target = $self->{database}->find_most_recent_hostmask($alias);
if (not $source) {
return "No such id $id found.";
}
if (not $target) {
return "No such id $alias found.";
}
if ($self->{database}->link_alias($id, $alias, $type)) {
return "$source " . ($type == $self->{database}->{alias_type}->{WEAK} ? "weakly" : "strongly") . " linked to $target.";
} else {
return "Link failed.";
}
}
sub aka_unlink {
my ($self, $from, $nick, $user, $host, $arguments) = @_;
my ($id, $alias) = split /\s+/, $arguments;
if (not $id or not $alias) {
return "Usage: unlink <target id> <alias id>";
}
my $source = $self->{database}->find_most_recent_hostmask($id);
my $target = $self->{database}->find_most_recent_hostmask($alias);
if (not $source) {
return "No such id $id found.";
}
if (not $target) {
return "No such id $alias found.";
}
if ($self->{database}->unlink_alias($id, $alias)) {
return "$source unlinked from $target.";
} else {
return "Unlink failed.";
}
}
sub list_also_known_as {
my ($self, $from, $nick, $user, $host, $arguments) = @_;
my $usage = "Usage: aka [-h] [-i] [-n] [-g] [-r] [-w] <nick>; -h show hostmasks; -i show ids; -n show nickserv accounts; -g show gecos, -r show relationships; -w show weak links";
if(not length $arguments) {
return $usage;
}
my $getopt_error;
local $SIG{__WARN__} = sub {
$getopt_error = shift;
chomp $getopt_error;
};
my ($show_hostmasks, $show_gecos, $show_nickserv, $show_id, $show_relationship, $show_weak, $dont_use_aliases_table);
my ($ret, $args) = GetOptionsFromString($arguments,
'h' => \$show_hostmasks,
'n' => \$show_nickserv,
'r' => \$show_relationship,
'g' => \$show_gecos,
'w' => \$show_weak,
'nt' => \$dont_use_aliases_table,
'i' => \$show_id);
return "$getopt_error -- $usage" if defined $getopt_error;
return "Too many arguments -- $usage" if @$args > 1;
return "Missing argument -- $usage" if @$args != 1;
my %akas = $self->{database}->get_also_known_as(@$args[0], $dont_use_aliases_table);
if(%akas) {
my $result = "@$args[0] also known as:\n";
my %nicks;
my $sep = "";
foreach my $aka (sort keys %akas) {
next if $aka =~ /^Guest\d+(?:!.*)?$/;
next if $akas{$aka}->{type} == $self->{database}->{alias_type}->{WEAK} && not $show_weak;
if (not $show_hostmasks) {
my ($nick) = $aka =~ m/([^!]+)/;
next if exists $nicks{$nick};
$nicks{$nick}->{id} = $akas{$aka}->{id};
$result .= "$sep$nick";
} else {
$result .= "$sep$aka";
}
$result .= "!" if $akas{$aka}->{nickchange} == 1;
$result .= " ($akas{$aka}->{nickserv})" if $show_nickserv and exists $akas{$aka}->{nickserv};
$result .= " {$akas{$aka}->{gecos}}" if $show_gecos and exists $akas{$aka}->{gecos};
if ($show_relationship) {
if ($akas{$aka}->{id} == $akas{$aka}->{alias}) {
$result .= " [$akas{$aka}->{id}]";
} else {
$result .= " [$akas{$aka}->{id} -> $akas{$aka}->{alias}]";
}
} elsif ($show_id) {
$result .= " [$akas{$aka}->{id}]";
}
$result .= " [WEAK]" if $akas{$aka}->{type} == $self->{database}->{alias_type}->{WEAK};
if ($show_hostmasks or $show_nickserv or $show_gecos or $show_id or $show_relationship) {
$sep = ",\n";
} else {
$sep = ", ";
}
}
return $result;
} else {
return "I don't know anybody named @$args[0].";
}
}
sub recall_message {
my ($self, $from, $nick, $user, $host, $arguments) = @_;
if(not defined $from) {
$self->{pbot}->{logger}->log("Command missing ~from parameter!\n");
return "";
}
my $usage = 'Usage: recall [nick [history [channel]]] [-c,channel <channel>] [-t,text,h,history <history>] [-b,before <context before>] [-a,after <context after>] [-x,context <nick>] [-n,count <count>] [+ ...]';
if(not defined $arguments or not length $arguments) {
return $usage;
}
$arguments = lc $arguments;
my @recalls = split /\s\+\s/, $arguments;
my $getopt_error;
local $SIG{__WARN__} = sub {
$getopt_error = shift;
chomp $getopt_error;
};
my $recall_text = '';
foreach my $recall (@recalls) {
my ($recall_nick, $recall_history, $recall_channel, $recall_before, $recall_after, $recall_context, $recall_count);
my ($ret, $args) = GetOptionsFromString($recall,
'channel|c:s' => \$recall_channel,
'text|t|history|h:s' => \$recall_history,
'before|b:i' => \$recall_before,
'after|a:i' => \$recall_after,
'count|n:i' => \$recall_count,
'context|x:s' => \$recall_context);
return "$getopt_error -- $usage" if defined $getopt_error;
my $channel_arg = 1 if defined $recall_channel;
my $history_arg = 1 if defined $recall_history;
$recall_nick = shift @$args;
$recall_history = shift @$args if not defined $recall_history;
$recall_channel = shift @$args if not defined $recall_channel;
$recall_count = 1 if (not defined $recall_count) || ($recall_count <= 0);
return "You may only select a count of up to 50 messages." if $recall_count > 50;
$recall_before = 0 if not defined $recall_before;
$recall_after = 0 if not defined $recall_after;
if ($recall_before + $recall_after > 200) {
return "You may only select up to 200 lines of surrounding context.";
}
if ($recall_count > 1 and ($recall_before > 0 or $recall_after > 0)) {
return "The `count` and `context before/after` options cannot be used together.";
}
# swap nick and channel if recall nick looks like channel and channel wasn't specified
if(not $channel_arg and $recall_nick =~ m/^#/) {
my $temp = $recall_nick;
$recall_nick = $recall_channel;
$recall_channel = $temp;
}
$recall_history = 1 if not defined $recall_history;
# swap history and channel if history looks like a channel and neither history or channel were specified
if(not $channel_arg and not $history_arg and $recall_history =~ m/^#/) {
my $temp = $recall_history;
$recall_history = $recall_channel;
$recall_channel = $temp;
}
# skip recall command if recalling self without arguments
$recall_history = $nick eq $recall_nick ? 2 : 1 if defined $recall_nick and not defined $recall_history;
# set history to most recent message if not specified
$recall_history = '1' if not defined $recall_history;
# set channel to current channel if not specified
$recall_channel = $from if not defined $recall_channel;
if (not defined $recall_nick and defined $recall_context) {
$recall_nick = $recall_context;
}
my ($account, $found_nick);
if(defined $recall_nick) {
($account, $found_nick) = $self->{database}->find_message_account_by_nick($recall_nick);
if(not defined $account) {
return "I don't know anybody named $recall_nick.";
}
$found_nick =~ s/!.*$//;
}
my $message;
if($recall_history =~ /^\d+$/) {
# integral history
if(defined $account) {
my $max_messages = $self->{database}->get_max_messages($account, $recall_channel);
if ($recall_history < 1 || $recall_history > $max_messages) {
if ($max_messages == 0) {
my @channels = $self->{database}->get_channels($account);
my $result = "No messages for $recall_nick in $recall_channel; I have messages for them in ";
my $comma = '';
my $count = 0;
foreach my $channel (sort @channels) {
next if $channel !~ /^#/;
$result .= "$comma$channel";
$comma = ', ';
$count++;
}
if ($count == 0) {
return "I have no messages for $recall_nick.";
} else {
return "$result.";
}
} else {
return "Please choose a history between 1 and $max_messages";
}
}
}
$recall_history--;
$message = $self->{database}->recall_message_by_count($account, $recall_channel, $recall_history, 'recall');
if(not defined $message) {
return "No message found at index $recall_history in channel $recall_channel.";
}
} else {
# regex history
$message = $self->{database}->recall_message_by_text($account, $recall_channel, $recall_history, 'recall');
if(not defined $message) {
if(defined $account) {
return "No such message for nick $found_nick in channel $recall_channel containing text '$recall_history'";
} else {
return "No such message in channel $recall_channel containing text '$recall_history'";
}
}
}
my $context_account;
if (defined $recall_context) {
($context_account) = $self->{database}->find_message_account_by_nick($recall_context);
if(not defined $context_account) {
return "I don't know anybody named $recall_context.";
}
}
my $messages = $self->{database}->get_message_context($message, $recall_before, $recall_after, $recall_count, $recall_history, $context_account);
my $max_recall_time = $self->{pbot}->{registry}->get_value('messagehistory', 'max_recall_time');
foreach my $msg (@$messages) {
$self->{pbot}->{logger}->log("$nick ($from) recalled <$msg->{nick}/$msg->{channel}> $msg->{msg}\n");
if ($max_recall_time && gettimeofday - $msg->{timestamp} > $max_recall_time && not $self->{pbot}->{admins}->loggedin($from, "$nick!$user\@$host")) {
$max_recall_time = duration($max_recall_time);
$recall_text .= "Sorry, you can not recall messages older than $max_recall_time.";
return $recall_text;
}
my $text = $msg->{msg};
my $ago = ago(gettimeofday - $msg->{timestamp});
if($text =~ s/^\/me\s+// or $text =~ m/^KICKED /) {
$recall_text .= "[$ago] * $msg->{nick} $text\n";
} else {
$recall_text .= "[$ago] <$msg->{nick}> $text\n";
}
}
}
return $recall_text;
}
1;