#!/usr/bin/perl -w # poor man's ICQ group chat implementation # # Dobrica Pavlinusic 2005-03-14 # released under GPL v2 or perl artistic licence use strict; use Net::OSCAR qw(:standard); use YAML qw(LoadFile DumpFile Dump); use Text::Iconv; # local encoding my $encoding = 'ISO-8859-2'; my $motd = <<_MOTD_; Welcome to group ICQ chat. Change your name with: !nick [your_name] For help type: !help _MOTD_ my $help = <<_HELP_; Confused? Change your name with !nick [nickname] Exit group chat !leave or !exit List group members !members or !list Invite new member with !invite [uin] [name] Turn echo to sender with !echo See last messages with !last _HELP_ my $config_file = shift @ARGV || $ENV{'HOME'}.'/.icq-chat'; # name of buddy group my $buddy_group = 'chat'; my $echo = 0; # default DSN for log my $dsn = 'dbi:Pg:dbname=test'; my $my_uin; my $config; my $oscar; my $signon_done = 0; my $iconv_utf8 = Text::Iconv->new("UTF-8", $encoding); my $iconv_utf16 = Text::Iconv->new("UTF-16BE", $encoding); $|=1; sub readln { my $msg = shift || return; print "$msg "; my $in = ; chomp($in); return $in; } sub read_config { if (-e $config_file) { $config = LoadFile($config_file) || die "can't open $config_file: $!"; $config->{'uin'} ||= readln("group uin:"); $config->{'passwd'} ||= readln($config->{'uin'}." password:"); die "configuration file $config_file is corrupt. Erase it to recover.\n" unless ($config->{'uin'} && $config->{'passwd'}); } else { $config->{'uin'} = readln("group uin:"); $config->{'passwd'} = readln("password:"); $config->{'members'} = {}; $config->{'motd'} = $motd; } $config->{'dsn'} ||= readln("log dns [$dsn]:"); $config->{'dsn'} ||= $dsn; save_config(); $my_uin = $config->{'uin'}; } sub save_config { DumpFile($config_file, $config) || die "can't open $config_file: $!"; xlog('config', $my_uin, "$config_file updated"); } sub uin2name { my $uin = shift || return "uin2name: missing uin"; return "bot" if ($uin eq $my_uin); return $config->{'members'}->{$uin} || "anonymous $uin"; } sub im_in { my($oscar, $sender, $message, $is_away) = @_; $message = $iconv_utf16->convert($message) || $iconv_utf8->convert($message) || $message || return; if ($is_away) { xlog('away', $sender, $message); return; } else { xlog('im_in', $sender, $message); } # strip html from message $message =~ s#]*?/*>##gsi; $config->{'last_sender_t'}->{$sender} = time(); $config->{'last_t'} = time(); if ($message =~ m#^!ping\s*(.*)$#) { my $stamp = $1; $config->{'ping'}->{$sender}->{'rcv'}++; $config->{'ping'}->{$sender}->{'rcv_stamp'} = $stamp if ($stamp); $config->{'nack_cnt'} = 0; $stamp ||= ''; $stamp .= " -> ".int(time()); xsend_im($sender, "!pong $stamp") if ($sender ne $my_uin); xlog('ping', $sender, $stamp); return; } # make user online and count it's messages $config->{'online'}->{$sender}++; if ($sender ne $my_uin && # not me (bot) ! $config->{'members'}->{$sender} # not member ) { $config->{'members'}->{$sender} = $sender; $config->{'online'}->{$sender}++; } # seen first time? if ($config->{'online'}->{$sender} == 1) { # send motd xsend_im($sender, $config->{'motd'}) if ($config->{'motd'}); add_member($sender); xlog('add_member', $sender); } if ($message =~ m#^!nick\s+(.+)\s*$#) { $config->{'members'}->{$sender} = $1; xsend_im($sender, "Your name will be: $1"); xlog('nick', $sender, $1); save_config(); return; } if ($message =~ m#^!invite\s+(\S+)\s+(.+)*\s*$#) { my ($uin, $nick) = ($1, $2); xsend_im($uin, "Your are joined to chat by ".uin2name($sender).". You screen name is: $nick"); xsend_im($sender, "You invited $nick [$uin] to join this chat."); add_member($uin, $nick); xlog('invite', $uin, $nick); return; } if ($message =~ m#^!(?:skip|kick|leave|exit)\s*(\S*)\s*$#) { my $uin = $1 || $sender; if ($config->{'members'}->{$uin}) { if ($uin == $sender) { xsend_im($sender, "You left group chat."); xlog('leave', $sender); } else { xsend_im($sender, "You kicked ".uin2name($uin)." out of this group."); xlog('leave', $uin, "kicked by $sender [".uin2name($sender)."]"); } remove_member($uin); } else { xsend_im($sender, "UIN $uin is not member of group"); } return; } if ($message =~ m#^!config#) { read_config(); xsend_im($sender, "Configuration reloaded."); xlog('config', $sender, 'reloaded'); return; } if ($message =~ m#^!(?:members*|list)#) { my $members = join(", ", map { uin2name($_) } keys %{ $config->{'online'} } ); xsend_im($sender, "Group members: $members"); xlog('members', $sender, $members); return; } if ($message =~ m#^!help#) { xsend_im($sender, $help); xlog('help', $sender); return; } if ($message =~ m#^!fortune#) { my $text = `fortune` || "Can't guess your fortune."; chomp($text); xsend_im($sender, $text); xlog('fortune', $sender, $text); return; } if ($message =~ m#^!debug#) { my $debug = Dump($config); $debug =~ s/^passwd:.*$/passwd removed/m; xsend_im($sender, $debug); xlog('debug', $sender, $debug); return; } if ($message =~ m#^!info\s+(\S+)\s*$#) { my $uin = $1; my $info = Dump($oscar->buddy($uin)) || "Can't get info for $uin [".uin2name($uin)."]"; xsend_im($sender, $info); xlog('info', $sender, $info); return; } if ($message =~ m#^!on-*line\s*(\S*)\s*$#) { my $uin = $1; xlog('online', $sender, $uin); if ($uin && $config->{'members'}->{$uin}) { $config->{'online'}->{$uin}++; xsend_im($sender, "Changed status of $uin to on-line."); } elsif ($uin) { xsend_im($sender, "UIN $uin is not member. Try !invite $uin [name] first"); } else { # check and list on-line members xsend_im($sender, "on-line members: ". join(", ", map { uin2name($_) } online_uins($oscar) )); } return; } if ($message =~ m#^!(?:broadcast|all)#) { foreach my $uin (keys %{$config->{'members'}}) { $config->{'online'}->{$uin} = 1 unless ($config->{'online'}->{$uin}); } xsend_im($sender, "Your next message will be broadcasted to all members without regard to on-line flag."); xlog('broadcast', $sender); } if ($message =~ m#^!echo#) { my $own; my $echo = $config->{'echo'}->{$sender}; if ($echo) { $own = "not sent back"; delete($config->{'echo'}->{$sender}); } else { $own = "sent back to sender"; $config->{'echo'}->{$sender}++; } xsend_im($sender, "own messages are $own"); xlog('echo', $sender, $echo); save_config(); return; } if ($message =~ m#^!last\s*?(\d*)$#) { my $nr = $1; xsend_im($sender, "\n".xlast($nr)); xlog('last', $sender); return; } if ($message =~ m#^!rmskip\s+(\S+)\s*$#) { my $uin = $1; my $who = uin2name($uin)." [$uin]"; if ($config->{'skip_buddy'}->{$uin}) { delete $config->{'skip_buddy'}->{$uin}; xsend_im($sender, "removed $who from skip list"); xlog('rmskip', $sender, $uin); } else { xsend_im($sender, "can't remove $who from skip list, not a member"); } return; } $message =~ s#<br>#\n#gis; if ($message =~ m#^!motd\s*?(.*)#s) { $config->{'motd'} = $1 || $motd; xsend_im($sender, "New MOTD is:\n".$config->{'motd'}); save_config(); xlog('motd', $sender); return; } xlog('msg', $sender, $message); if ($message =~ m#^(!.*)#) { xsend_im($sender, "Unknown command: $1"); xlog("unkown", $sender, $1); return; } # prefix with name if ($sender ne $my_uin) { my $m = $message || return; $message = "[".uin2name($sender)."] $m"; } foreach my $uin (keys %{$config->{'online'}}) { next if (! $config->{'echo'}->{$sender} && $uin eq $sender || $uin eq $my_uin); xsend_im($uin, $message); } print "\n"; } sub xsend_all_except { my $sender = shift || return; my $message = shift || return; foreach my $uin (keys %{$config->{'online'}}) { # don't send to sender or bot next if ($uin eq $sender or $uin eq $my_uin); xsend_im($uin, $message); } } sub buddy_in { my ($oscar, $uin) = @_; warn "buddy in got empty uin\n" and return unless ($uin); return if ($uin eq $my_uin); $config->{'online'}->{$uin}++; xsend_all_except($uin, uin2name($uin)." joined chat.") if ($config->{'online'}->{$uin} == 1); xlog('buddy_in', $uin); save_config(); } sub buddy_out { my ($oscar, $uin) = @_; return if ($uin eq $my_uin); # me? delete($config->{'online'}->{$uin}); xsend_all_except($uin, uin2name($uin)." left chat."); xlog('buddy_out', $uin); save_config(); } my $buddylist_commit_active = 0; sub remove_member($) { my $uin = shift || return; delete ($config->{'online'}->{$uin}); $oscar->remove_buddy($buddy_group, $uin); $oscar->commit_buddylist() if ($buddylist_commit_active == 0); $buddylist_commit_active++; xlog('remove_member', $uin); } sub add_member($$) { my ($uin, $nick) = @_; return unless ($uin && $nick); $config->{'members'}->{$uin} = $nick; $oscar->add_buddy($buddy_group, $uin); $oscar->add_permit($uin); $oscar->commit_buddylist() if ($buddylist_commit_active == 0); $buddylist_commit_active++; xlog('add_member', $uin); } sub buddylist_ok { my $oscar = shift; print "Buddy list commited with $buddylist_commit_active changes commited.\n"; $buddylist_commit_active = 0; save_config(); xlog('buddylist_ok', $my_uin); } sub buddylist_error { my ($oscar, $error, $what) = @_; if ($error = 14 && $what =~ m/(\d+)/) { my $uin = $1; print "ERROR: $what [$error], adding $uin [",uin2name($uin),"] to skip buddy list\n"; $config->{'skip_buddy'}->{$uin}++; remove_member($uin); } else { print "ERROR: Buddy list commit failed [$error]: $what\n"; } xlog('buddylist_error', $my_uin, $what); } sub online_uins($) { my $oscar = shift || return; my @online; $config->{'online'} = {}; foreach my $uin (keys %{$config->{'members'}}) { next if ($uin eq $my_uin); my $info = $oscar->buddy($uin); if ($info->{'online'}) { $config->{'online'}->{$uin}++; push @online, $uin; } } xlog('online_uins', $my_uin, join(", ", @online)); save_config(); return @online; } sub signon_done { my $oscar = shift; my @buddies = $oscar->buddies(); print "adding buddies:\n"; foreach my $uin (keys %{$config->{'members'}}) { my $status = 'old'; unless (grep(/^$uin$/, @buddies)) { if ($config->{'skip_buddy'}->{$uin}) { $status = 'SKIPPED'; } else { $oscar->add_buddy($buddy_group, $uin); $oscar->set_buddy_alias($buddy_group, $uin, uin2name($uin)); $status = 'NEW'; } } printf("%-10d : %s - %s buddy\n", $uin, uin2name($uin), $status, ); xlog('signon_done', $uin, $status); } # fixup (just in case) -- remove own uin from members and buddies my $me = $my_uin; $oscar->remove_buddy($buddy_group, $config->{$me}); delete($config->{'members'}->{$me}); $oscar->commit_buddylist(); print "on-line buddies:\n"; $config->{'online'} = {}; foreach my $uin (online_uins($oscar)) { printf("%-10d : %s online\n", $uin, uin2name($uin)); } save_config(); $signon_done++; } sub rate_alert { my ($oscar, $level, $clear, $window, $worrisome) = @_; my $msg = "$window messages max in $clear ms limit reached"; xlog('rate_alter', $my_uin, $level . " " . $msg); print "# $msg - sleeping $clear ms\n"; select(undef, undef, undef, ($clear/100)); # if ($worrisome) { # xsend_im($my_uin, $msg); # } } sub error { my ($oscar, $connection, $error, $description, $fatal) = @_; xlog('error', $my_uin, $description); print "ERROR [$error]: $description\n"; if ($fatal) { $signon_done = 0; print "# repeating sign-on\n"; $oscar->signon($my_uin, $config->{'passwd'}) || die "can't sign on as $my_uin"; } } ## DSN logging support my ($dbh,$sth_log, $sth_sent, $sth_sent_ok, $sth_last); sub create_log_table { $dbh ||= connect_db(); return unless ($dbh); # exit if table exists return if ($dbh->do("select * from log limit 1")); print "# creating log table in $dsn\n"; $dbh->do(q{ create table log ( id serial, date timestamp default now(), bot text not null, type text not null, uin text not null, name text not null, message text, primary key(id) ) }) || die $dbh->errstr(); $dbh->do(qq{ create index log_date on log(date) }) or die $dbh->errstr(); $dbh->do(qq{ create index log_bot on log(bot) }) or die $dbh->errstr(); $dbh->do(qq{ create index log_type on log(type) }) or die $dbh->errstr(); $dbh->do(qq{ create index log_uin on log(uin) }) or die $dbh->errstr(); $dbh->do(qq{ create index log_name on log(name) }) or die $dbh->errstr(); $dbh->do(qq{ create table sent ( date timestamp default now(), bot text not null, uin text not null, name text not null, r_id text not null, message text, sent boolean default false, primary key(r_id) ) }); } sub connect_db { return unless ($config->{'dsn'}); return if ($dbh); require DBI; print "# using $dsn for log\n"; $dbh = DBI->connect($config->{'dsn'},"","") || die $DBI::errstr; return $dbh; } sub xlog { my ($type,$uin, $message) = @_; my $name = uin2name($uin); print localtime()." $type: $uin [$name] ", ( $message || '' ),"\n"; return unless ($dbh); $sth_log ||= $dbh->prepare(qq{ insert into log (bot,type,uin,name,message) values (?,?,?,?,?) }) || die $dbh->errstr(); $sth_log->execute($my_uin, $type, $uin, $name, $message) || print "$type: [$uin] $message"; } sub xsend_im { my ($who, $message, $away) = @_; my $r_id = $oscar->send_im($who, $message, $away); print "# sent $who $r_id\n"; return unless ($dbh); if (! $r_id) { xlog('error', $my_uin, "failed send_im to $who: $message"); return; } $sth_sent ||= $dbh->prepare(qq{ insert into sent (bot,uin,name,r_id,message) values (?,?,?,?,?) }) || die $dbh->errstr(); $sth_sent->execute($my_uin, $who, uin2name($who), $r_id, $message) || xlog('error', $my_uin, "insert of sent $who $r_id failed"); } sub im_ok { my ($oscar, $to, $r_id) = @_; print "# im_ok $to $r_id\n"; return unless ($dbh); # oh, there seem to be bug in Net::OSCAR. It returns totally off-sync # request_id, so I just ack last send messages. $sth_sent_ok ||= $dbh->prepare(qq{ update sent set sent = true where bot = ? and uin = ? and r_id = (select r_id from sent as s2 where s2.uin = sent.uin and s2.sent = false order by s2.date desc limit 1) }) || die $dbh->errstr(); $sth_sent_ok->execute($my_uin, $to) || xlog('error', $my_uin, "insert of im_ok $to $r_id failed"); } sub xlast { my $nr = shift; $nr ||= 10; # default: show last 10 messages return 'last not supported without database support' unless ($dbh); $sth_last ||= $dbh->prepare(qq{ select date,name,message from log where type = 'msg' and bot = ? order by date desc limit ? }) || die $dbh->errstr(); $sth_last->execute($my_uin, $nr) || xlog('error', $my_uin, "last failed") && return 'last failed'; my @last; my $last_date = ''; while (my $row = $sth_last->fetchrow_hashref() ) { my ($date, $time); if( $row->{'date'} =~ m#^(\d+-\d+-\d+)\s(\d+:\d+:\d+)# ) { ($date,$time) = ($1,$2); if ($date ne $last_date) { unshift @last, "date: $date"; $last_date = $date; } } $time ||= "unknown"; unshift @last, "($time) [".$row->{'name'}."] ".$row->{'message'}; } return join("\n", @last); } $oscar = Net::OSCAR->new(capabilities => [qw(extended_status typing_status)]) || die; $oscar->loglevel(3); read_config(); create_log_table(); $oscar->set_callback_im_in(\&im_in); $oscar->set_callback_im_ok(\&im_ok); $oscar->set_callback_buddy_in(\&buddy_in); $oscar->set_callback_buddy_out(\&buddy_out); $oscar->set_callback_buddylist_ok(\&buddylist_ok); $oscar->set_callback_buddylist_error(\&buddylist_error); $oscar->set_callback_signon_done(\&signon_done); $oscar->set_callback_rate_alert(\&rate_alert); $oscar->signon($my_uin, $config->{'passwd'}) || die "can't sign on as $my_uin"; my $interval = 3600; my $signoff_i = 5; $config->{'last_t'} = time(); $config->{'nack_cnt'} = 0; while(1) { $oscar->do_one_loop(); next unless ($signon_done); my $last_t = $config->{'last_t'} || die "no last_t?"; my $dt = time() - $last_t; if ($dt >= $interval) { my $nack_cnt = $config->{'nack_cnt'}++; print "# dt[$nack_cnt]: $dt\n"; if ($nack_cnt < $signoff_i) { print "# ping keep-alive timeout: $dt s - sending ping, count: $nack_cnt\n"; xsend_im($my_uin, "!ping ".int(time()) ); $config->{'last_t'} = time(); } else { print "# serious problems!\n"; $config->{'nack_cnt'} = 0; # $oscar->signoff; # $signon_done = 0; # $oscar->signon($my_uin, $config->{'passwd'}) || die "can't sign on as $my_uin"; } } } # make strict happy $DBI::errstr++;