+sub cmd_upgrade {
+ my ( $data, $server, $win ) = @_;
+
+ my $loc = Irssi::settings_get_str("twirssi_location");
+ unless ( -w $loc ) {
+ ¬ice(
+"$loc isn't writable, can't upgrade. Perhaps you need to /set twirssi_location?"
+ );
+ return;
+ }
+
+ my $md5;
+ unless ( $data or Irssi::settings_get_bool("twirssi_upgrade_beta") ) {
+ eval { use Digest::MD5; };
+
+ if ($@) {
+ ¬ice(
+"Failed to load Digest::MD5. Try '/twirssi_upgrade nomd5' to skip MD5 verification"
+ );
+ return;
+ }
+
+ $md5 = get("http://twirssi.com/md5sum");
+ chomp $md5;
+ $md5 =~ s/ .*//;
+ unless ($md5) {
+ ¬ice("Failed to download md5sum from peeron! Aborting.");
+ return;
+ }
+
+ unless ( open( CUR, $loc ) ) {
+ ¬ice(
+"Failed to read $loc. Check that /set twirssi_location is set to the correct location."
+ );
+ return;
+ }
+
+ my $cur_md5 = Digest::MD5::md5_hex(<CUR>);
+ close CUR;
+
+ if ( $cur_md5 eq $md5 ) {
+ ¬ice("Current twirssi seems to be up to date.");
+ return;
+ }
+ }
+
+ my $URL =
+ Irssi::settings_get_bool("twirssi_upgrade_beta")
+ ? "http://github.com/zigdon/twirssi/raw/master/twirssi.pl"
+ : "http://twirssi.com/twirssi.pl";
+ ¬ice("Downloading twirssi from $URL");
+ LWP::Simple::getstore( $URL, "$loc.upgrade" );
+
+ unless ( -s "$loc.upgrade" ) {
+ ¬ice("Failed to save $loc.upgrade."
+ . " Check that /set twirssi_location is set to the correct location."
+ );
+ return;
+ }
+
+ unless ( $data or Irssi::settings_get_bool("twirssi_upgrade_beta") ) {
+ unless ( open( NEW, "$loc.upgrade" ) ) {
+ ¬ice("Failed to read $loc.upgrade."
+ . " Check that /set twirssi_location is set to the correct location."
+ );
+ return;
+ }
+
+ my $new_md5 = Digest::MD5::md5_hex(<NEW>);
+ close NEW;
+
+ if ( $new_md5 ne $md5 ) {
+ ¬ice("MD5 verification failed. expected $md5, got $new_md5");
+ return;
+ }
+ }
+
+ rename $loc, "$loc.backup"
+ or ¬ice("Failed to back up $loc: $!. Aborting")
+ and return;
+ rename "$loc.upgrade", $loc
+ or ¬ice("Failed to rename $loc.upgrade: $!. Aborting")
+ and return;
+
+ my ( $dir, $file ) = ( $loc =~ m{(.*)/([^/]+)$} );
+ if ( -e "$dir/autorun/$file" ) {
+ ¬ice("Updating $dir/autorun/$file");
+ unlink "$dir/autorun/$file"
+ or ¬ice("Failed to remove old $file from autorun: $!");
+ symlink "../$file", "$dir/autorun/$file"
+ or ¬ice("Failed to create symlink in autorun directory: $!");
+ }
+
+ ¬ice("Download complete. Reload twirssi with /script load $file");
+}
+
+sub load_friends {
+ my $fh = shift;
+ my $page = 1;
+ my %new_friends;
+ eval {
+ while (1)
+ {
+ print $fh "type:debug Loading friends page $page...\n"
+ if ( $fh and &debug );
+ my $friends = $twit->friends( { page => $page } );
+ last unless $friends;
+ $new_friends{ $_->{screen_name} } = time foreach @$friends;
+ $page++;
+ last if @$friends == 0 or $page == 10;
+ }
+ };
+
+ if ($@) {
+ print $fh "type:debug Error during friends list update. Aborted.\n";
+ return;
+ }
+
+ my ( $added, $removed ) = ( 0, 0 );
+ print $fh "type:debug Scanning for new friends...\n" if ( $fh and &debug );
+ foreach ( keys %new_friends ) {
+ next if exists $friends{$_};
+ $friends{$_} = time;
+ $added++;
+ }
+
+ print $fh "type:debug Scanning for removed friends...\n"
+ if ( $fh and &debug );
+ foreach ( keys %friends ) {
+ next if exists $new_friends{$_};
+ delete $friends{$_};
+ $removed++;
+ }
+
+ return ( $added, $removed );
+}
+
+sub get_updates {
+ print scalar localtime, " - get_updates starting" if &debug;
+
+ $window =
+ Irssi::window_find_name( Irssi::settings_get_str('twitter_window') );
+ unless ($window) {
+ Irssi::active_win()
+ ->print( "Can't find a window named '"
+ . Irssi::settings_get_str('twitter_window')
+ . "'. Create it or change the value of twitter_window" );
+ }
+
+ return unless &logged_in($twit);
+
+ my ( $fh, $filename ) = File::Temp::tempfile();
+ binmode( $fh, ":utf8" );
+ my $pid = fork();
+
+ if ($pid) { # parent
+ Irssi::timeout_add_once( 5000, 'monitor_child',
+ [ "$filename.done", 0 ] );
+ Irssi::pidwait_add($pid);
+ } elsif ( defined $pid ) { # child
+ close STDIN;
+ close STDOUT;
+ close STDERR;
+
+ my $new_poll = time;
+
+ my $error = 0;
+ my %context_cache;
+ foreach ( keys %twits ) {
+ $error++ unless &do_updates( $fh, $_, $twits{$_}, \%context_cache );
+ }
+
+ print $fh "__friends__\n";
+ if (
+ time - $last_friends_poll >
+ Irssi::settings_get_int('twitter_friends_poll') )
+ {
+ print $fh "__updated ", time, "\n";
+ my ( $added, $removed ) = &load_friends($fh);
+ if ( $added + $removed ) {
+ print $fh "type:debug %R***%n Friends list updated: ",
+ join( ", ",
+ sprintf( "%d added", $added ),
+ sprintf( "%d removed", $removed ) ),
+ "\n";
+ }
+ }
+
+ foreach ( sort keys %friends ) {
+ print $fh "$_ $friends{$_}\n";
+ }
+
+ if ($error) {
+ print $fh "type:debug Update encountered errors. Aborted\n";
+ print $fh "-- $last_poll";
+ } else {
+ print $fh "-- $new_poll";
+ }
+ close $fh;
+ rename $filename, "$filename.done";
+ exit;
+ } else {
+ &ccrap("Failed to fork for updating: $!");
+ }
+ print scalar localtime, " - get_updates ends" if &debug;
+}
+
+sub do_updates {
+ my ( $fh, $username, $obj, $cache ) = @_;
+
+ my $rate_limit = $obj->rate_limit_status();
+ if ( $rate_limit and $rate_limit->{remaining_hits} < 1 ) {
+ ¬ice("Rate limit exceeded for $username");
+ return undef;
+ }
+
+ print scalar localtime, " - Polling for updates for $username" if &debug;
+ my $tweets;
+ my $new_poll_id = 0;
+ eval {
+ if ( $id_map{__last_id}{$username}{timeline} )
+ {
+ $tweets = $obj->friends_timeline( { count => 100 } );
+ } else {
+ $tweets = $obj->friends_timeline();
+ }
+ };
+
+ if ($@) {
+ print $fh "type:debug Error during friends_timeline call: Aborted.\n";
+ print $fh "type:debug : $_\n" foreach split /\n/, Dumper($@);
+ return undef;
+ }
+
+ unless ( ref $tweets ) {
+ if ( $obj->can("get_error") ) {
+ my $error = "Unknown error";
+ eval { $error = JSON::Any->jsonToObj( $obj->get_error() ) };
+ unless ($@) { $error = $obj->get_error() }
+ print $fh
+ "type:debug API Error during friends_timeline call: Aborted\n";
+ print $fh "type:debug : $_\n" foreach split /\n/, Dumper($error);
+
+ } else {
+ print $fh
+ "type:debug API Error during friends_timeline call. Aborted.\n";
+ }
+ return undef;
+ }
+
+ foreach my $t ( reverse @$tweets ) {
+ my $text = decode_entities( $t->{text} );
+ $text =~ s/[\n\r]/ /g;
+ my $reply = "tweet";
+ if ( Irssi::settings_get_bool("show_reply_context")
+ and $t->{in_reply_to_screen_name} ne $username
+ and $t->{in_reply_to_screen_name}
+ and not exists $friends{ $t->{in_reply_to_screen_name} } )
+ {
+ $nicks{ $t->{in_reply_to_screen_name} } = time;
+ my $context;
+ unless ( $cache->{ $t->{in_reply_to_status_id} } ) {
+ eval {
+ $cache->{ $t->{in_reply_to_status_id} } =
+ $obj->show_status( $t->{in_reply_to_status_id} );
+ };
+
+ }
+ $context = $cache->{ $t->{in_reply_to_status_id} };
+
+ if ($context) {
+ my $ctext = decode_entities( $context->{text} );
+ $ctext =~ s/[\n\r]/ /g;
+ if ( $context->{truncated} and ref($obj) ne 'Net::Identica' ) {
+ $ctext .=
+ " -- http://twitter.com/$context->{user}{screen_name}"
+ . "/status/$context->{id}";
+ }
+ printf $fh "id:%u account:%s nick:%s type:tweet %s\n",
+ $context->{id}, $username,
+ $context->{user}{screen_name}, $ctext;
+ $reply = "reply";
+ }
+ }
+ next
+ if $t->{user}{screen_name} eq $username
+ and not Irssi::settings_get_bool("show_own_tweets");
+ if ( $t->{truncated} and ref($obj) ne 'Net::Identica' ) {
+ $text .= " -- http://twitter.com/$t->{user}{screen_name}"
+ . "/status/$t->{id}";
+ }
+ printf $fh "id:%u account:%s nick:%s type:%s %s\n",
+ $t->{id}, $username, $t->{user}{screen_name}, $reply, $text;
+ $new_poll_id = $t->{id} if $new_poll_id < $t->{id};
+ }
+ printf $fh "id:%u account:%s type:last_id timeline\n",
+ $new_poll_id, $username;
+
+ print scalar localtime, " - Polling for replies since ",
+ $id_map{__last_id}{$username}{reply}
+ if &debug;
+ $new_poll_id = 0;
+ eval {
+ if ( $id_map{__last_id}{$username}{reply} )
+ {
+ $tweets = $obj->replies(
+ { since_id => $id_map{__last_id}{$username}{reply} } )
+ || [];
+ } else {
+ $tweets = $obj->replies() || [];
+ }
+ };
+
+ if ($@) {
+ print $fh "type:debug Error during replies call. Aborted.\n";
+ return undef;
+ }
+
+ foreach my $t ( reverse @$tweets ) {
+ next
+ if exists $friends{ $t->{user}{screen_name} };
+
+ my $text = decode_entities( $t->{text} );
+ $text =~ s/[\n\r]/ /g;
+ if ( $t->{truncated} ) {
+ $text .= " -- http://twitter.com/$t->{user}{screen_name}"
+ . "/status/$t->{id}";
+ }
+ printf $fh "id:%u account:%s nick:%s type:tweet %s\n",
+ $t->{id}, $username, $t->{user}{screen_name}, $text;
+ $new_poll_id = $t->{id} if $new_poll_id < $t->{id};
+ }
+ printf $fh "id:%u account:%s type:last_id reply\n", $new_poll_id, $username;
+
+ print scalar localtime, " - Polling for DMs" if &debug;
+ $new_poll_id = 0;
+ eval {
+ if ( $id_map{__last_id}{$username}{dm} )
+ {
+ $tweets = $obj->direct_messages(
+ { since_id => $id_map{__last_id}{$username}{dm} } )
+ || [];
+ } else {
+ $tweets = $obj->direct_messages() || [];
+ }
+ };
+
+ if ($@) {
+ print $fh "type:debug Error during direct_messages call. Aborted.\n";
+ return undef;
+ }
+
+ foreach my $t ( reverse @$tweets ) {
+ my $text = decode_entities( $t->{text} );
+ $text =~ s/[\n\r]/ /g;
+ printf $fh "id:%u account:%s nick:%s type:dm %s\n",
+ $t->{id}, $username, $t->{sender_screen_name}, $text;
+ $new_poll_id = $t->{id} if $new_poll_id < $t->{id};
+ }
+ printf $fh "id:%u account:%s type:last_id dm\n", $new_poll_id, $username;
+
+ print scalar localtime, " - Polling for subscriptions" if &debug;
+ if ( $obj->can('search') and $id_map{__searches}{$username} ) {
+ my $search;
+ foreach my $topic ( sort keys %{ $id_map{__searches}{$username} } ) {
+ print $fh "type:debug searching for $topic since ",
+ "$id_map{__searches}{$username}{$topic}\n";
+ eval {
+ $search = $obj->search(
+ {
+ q => $topic,
+ since_id => $id_map{__searches}{$username}{$topic}
+ }
+ );
+ };
+
+ if ($@) {
+ print $fh
+ "type:debug Error during search($topic) call. Aborted.\n";
+ return undef;
+ }
+
+ unless ( $search->{max_id} ) {
+ print $fh "type:debug Invalid search results when searching",
+ " for $topic. Aborted.\n";
+ return undef;
+ }
+
+ $id_map{__searches}{$username}{$topic} = $search->{max_id};
+ printf $fh "id:%u account:%s type:searchid topic:%s\n",
+ $search->{max_id}, $username, $topic;
+
+ foreach my $t ( reverse @{ $search->{results} } ) {
+ my $text = decode_entities( $t->{text} );
+ $text =~ s/[\n\r]/ /g;
+ printf $fh "id:%u account:%s nick:%s type:search topic:%s %s\n",
+ $t->{id}, $username, $t->{from_user}, $topic, $text;
+ $new_poll_id = $t->{id}
+ if not $new_poll_id
+ or $t->{id} < $new_poll_id;
+ }
+ }
+ }
+
+ print scalar localtime, " - Done" if &debug;
+
+ return 1;
+}
+
+sub monitor_child {
+ my ($data) = @_;
+ my $filename = $data->[0];
+ my $attempt = $data->[1];
+
+ print scalar localtime, " - checking child log at $filename ($attempt)"
+ if &debug;
+ my ($new_last_poll);
+
+ # first time we run we don't want to print out *everything*, so we just
+ # pretend
+
+ if ( open FILE, $filename ) {
+ binmode FILE, ":utf8";
+ my @lines;
+ my %new_cache;
+ while (<FILE>) {
+ last if /^__friends__/;
+ unless (/\n$/) { # skip partial lines
+ # print "Skipping partial line: $_" if &debug;
+ next;
+ }
+ chomp;
+ my $hilight = 0;
+ my %meta;
+
+ foreach my $key (qw/id account nick type topic/) {
+ if (s/^$key:(\S+)\s*//) {
+ $meta{$key} = $1;
+ }
+ }
+
+ if ( not $meta{type} or $meta{type} !~ /searchid|last_id/ ) {
+ if ( exists $meta{id} and exists $new_cache{ $meta{id} } ) {
+ next;
+ }
+
+ $new_cache{ $meta{id} } = time;
+
+ if ( exists $meta{id} and exists $tweet_cache{ $meta{id} } ) {
+ next;
+ }
+ }
+
+ my $account = "";
+ $meta{account} =~ s/\@(\w+)$//;
+ $meta{service} = $1;
+ if (
+ lc $meta{service} eq
+ lc Irssi::settings_get_str("twirssi_default_service") )
+ {
+ $account = "$meta{account}: "
+ if lc "$meta{account}\@$meta{service}" ne lc
+ "$user\@$defservice";
+ } else {
+ $account = "$meta{account}\@$meta{service}: ";
+ }
+
+ my $marker = "";
+ if ( $meta{type} ne 'dm'
+ and Irssi::settings_get_bool("twirssi_track_replies")
+ and $meta{nick}
+ and $meta{id} )
+ {
+ $marker = ( $id_map{__indexes}{ $meta{nick} } + 1 ) % 100;
+ $id_map{ lc $meta{nick} }[$marker] = $meta{id};
+ $id_map{__indexes}{ $meta{nick} } = $marker;
+ $id_map{__tweets}{ lc $meta{nick} }[$marker] = $_;
+ $marker = ":$marker";
+ }
+
+ my $hilight_color =
+ $irssi_to_mirc_colors{ Irssi::settings_get_str("hilight_color") };
+ my $nick = "\@$meta{account}";
+ if ( $_ =~ /\Q$nick\E(?:\W|$)/i
+ and Irssi::settings_get_bool("twirssi_hilights") )
+ {
+ $meta{nick} = "\cC$hilight_color$meta{nick}\cO";
+ $hilight = MSGLEVEL_HILIGHT;
+ }
+
+ if ( $meta{type} =~ /tweet|reply/ ) {
+ push @lines,
+ [
+ ( MSGLEVEL_PUBLIC | $hilight ),
+ $meta{type}, $account, $meta{nick}, $marker, $_
+ ];
+ } elsif ( $meta{type} eq 'search' ) {
+ push @lines,
+ [
+ ( MSGLEVEL_PUBLIC | $hilight ),
+ $meta{type}, $account, $meta{topic},
+ $meta{nick}, $marker, $_
+ ];
+ if (
+ exists $id_map{__searches}{ $meta{account} }{ $meta{topic} }
+ and $meta{id} >
+ $id_map{__searches}{ $meta{account} }{ $meta{topic} } )
+ {
+ $id_map{__searches}{ $meta{account} }{ $meta{topic} } =
+ $meta{id};
+ }
+ } elsif ( $meta{type} eq 'dm' ) {
+ push @lines,
+ [
+ ( MSGLEVEL_MSGS | $hilight ),
+ $meta{type}, $account, $meta{nick}, $_
+ ];
+ } elsif ( $meta{type} eq 'searchid' ) {
+ print "Search '$meta{topic}' returned id $meta{id}" if &debug;
+ if (
+ not
+ exists $id_map{__searches}{ $meta{account} }{ $meta{topic} }
+ or $meta{id} >=
+ $id_map{__searches}{ $meta{account} }{ $meta{topic} } )
+ {
+ $id_map{__searches}{ $meta{account} }{ $meta{topic} } =
+ $meta{id};
+ } elsif (&debug) {
+ print "Search '$meta{topic}' returned invalid id $meta{id}";
+ }
+ } elsif ( $meta{type} eq 'last_id' ) {
+ $id_map{__last_id}{"$meta{account}\@$meta{service}"}{$_} =
+ $meta{id}
+ if $id_map{__last_id}{"$meta{account}\@$meta{service}"}{$_} <
+ $meta{id};
+ } elsif ( $meta{type} eq 'error' ) {
+ push @lines, [ MSGLEVEL_MSGS, $_ ];
+ } elsif ( $meta{type} eq 'debug' ) {
+ print "$_" if &debug,;
+ } else {
+ print "Unknown line type $meta{type}: $_" if &debug,;
+ }
+ }
+
+ %friends = ();
+ while (<FILE>) {
+ if (/^__updated (\d+)$/) {
+ $last_friends_poll = $1;
+ print "Friend list updated" if &debug;
+ next;
+ }
+
+ if (/^-- (\d+)$/) {
+ $new_last_poll = $1;
+ if ( $new_last_poll >= $last_poll ) {
+ last;
+ } else {
+ print "Impossible! ",
+ "new_last_poll=$new_last_poll < last_poll=$last_poll!"
+ if &debug;
+ undef $new_last_poll;
+ next;
+ }
+ }
+ my ( $f, $t ) = split ' ', $_;
+ $nicks{$f} = $friends{$f} = $t;
+ }
+
+ if ($new_last_poll) {
+ print "new last_poll = $new_last_poll" if &debug;
+ print "new last_poll_id = ", Dumper( $id_map{__last_id} ) if &debug;
+ if ($first_call) {
+ print "First call, not printing updates" if &debug;
+ } else {
+ foreach my $line (@lines) {
+ $window->printformat(
+ $line->[0],
+ "twirssi_" . $line->[1],
+ @$line[ 2 .. $#$line - 1 ],
+ &hilight( $line->[-1] )
+ );
+ }
+ }
+
+ close FILE;
+ unlink $filename
+ or warn "Failed to remove $filename: $!"
+ unless &debug;
+
+ # commit the pending cache lines to the actual cache, now that
+ # we've printed our output
+ %tweet_cache = ( %tweet_cache, %new_cache );
+
+ # keep enough cached tweets, to make sure we don't show duplicates.
+ foreach ( keys %tweet_cache ) {
+ next if $tweet_cache{$_} >= $last_poll - 3600;
+ delete $tweet_cache{$_};
+ }
+ $last_poll = $new_last_poll;
+
+ # save id_map hash
+ if ( keys %id_map
+ and my $file =
+ Irssi::settings_get_str("twirssi_replies_store") )
+ {
+ if ( open JSON, ">$file" ) {
+ print JSON JSON::Any->objToJson( \%id_map );
+ close JSON;
+ } else {
+ &ccrap("Failed to write replies to $file: $!");
+ }
+ }
+ $failwhale = 0;
+ $first_call = 0;
+ return;
+ }
+ }
+
+ close FILE;
+
+ if ( $attempt < 24 ) {
+ Irssi::timeout_add_once( 5000, 'monitor_child',
+ [ $filename, $attempt + 1 ] );
+ } else {
+ print "Giving up on polling $filename" if &debug;
+ unlink $filename unless &debug;
+
+ return unless Irssi::settings_get_bool("twirssi_notify_timeouts");
+
+ my $since;
+ my @time = localtime($last_poll);
+ if ( time - $last_poll < 24 * 60 * 60 ) {
+ $since = sprintf( "%d:%02d", @time[ 2, 1 ] );
+ } else {
+ $since = scalar localtime($last_poll);
+ }
+
+ if ( not $failwhale and time - $last_poll > 60 * 60 ) {
+ foreach my $whale (
+ q{ v v v},
+ q{ | | v | v},
+ q{ | .-, | | |},
+ q{ .--./ / | _.---.| },
+ q{ '-. (__..-" \\},
+ q{ \\ a |},
+ q{ ',.__. ,__.-'/},
+ q{ '--/_.'----'`}
+ )
+ {
+ &ccrap($whale);
+ }
+ $failwhale = 1;
+ }
+
+ if ( time - $last_poll < 600 ) {
+ &ccrap("Haven't been able to get updated tweets since $since");
+ }
+ }
+}
+
+sub debug {
+ return Irssi::settings_get_bool("twirssi_debug");
+}
+
+sub notice {
+ $window->print( "%R***%n @_", MSGLEVEL_PUBLIC );
+}
+
+sub ccrap {
+ $window->print( "%R***%n @_", MSGLEVEL_CLIENTCRAP );
+}
+
+sub update_away {
+ my $data = shift;
+
+ if ( Irssi::settings_get_bool("tweet_to_away")
+ and $data !~ /\@\w/
+ and $data !~ /^[dD] / )
+ {
+ my $server =
+ Irssi::server_find_tag( Irssi::settings_get_str("bitlbee_server") );
+ if ($server) {
+ $server->send_raw("away :$data");
+ return 1;
+ } else {
+ &ccrap( "Can't find bitlbee server.",
+ "Update bitlbee_server or disable tweet_to_away" );
+ return 0;
+ }
+ }
+
+ return 0;
+}
+
+sub too_long {
+ my $data = shift;
+ my $noalert = shift;
+
+ if ( length $data > 140 ) {
+ ¬ice( "Tweet too long (" . length($data) . " characters) - aborted" )
+ unless $noalert;
+ return 1;
+ }
+
+ return 0;
+}
+
+sub valid_username {
+ my $username = shift;
+
+ $username = &normalize_username($username);
+
+ unless ( exists $twits{$username} ) {
+ ¬ice("Unknown username $username");
+ return undef;
+ }
+
+ return $username;
+}
+
+sub logged_in {
+ my $obj = shift;
+ unless ($obj) {
+ ¬ice("Not logged in! Use /twitter_login username pass!");
+ return 0;
+ }
+
+ return 1;