1.3 - r312 - Add /twirssi_upgrade, /twirssi_debug
[twirssi-net-twitter-lite.git] / twirssi.pl
1 use strict;
2 use Irssi;
3 use Irssi::Irc;
4 use Net::Twitter;
5 use HTTP::Date;
6 use HTML::Entities;
7 use File::Temp;
8 use LWP::Simple;
9 use Data::Dumper;
10 $Data::Dumper::Indent = 1;
11
12 use vars qw($VERSION %IRSSI);
13
14 $VERSION = "1.3";
15 my ($REV) = '$Rev: 312 $' =~ /(\d+)/;
16 %IRSSI = (
17     authors     => 'Dan Boger',
18     contact     => 'zigdon@gmail.com',
19     name        => 'twirssi',
20     description => 'Send twitter updates using /tweet.  '
21       . 'Can optionally set your bitlbee /away message to same',
22     license => 'GNU GPL v2',
23     url     => 'http://tinyurl.com/twirssi',
24     changed => 'Mon Dec  1 15:36:01 PST 2008',
25 );
26
27 my $window;
28 my $twit;
29 my %twits;
30 my $user;
31 my $poll;
32 my %nicks;
33 my %friends;
34 my $last_poll = time - 300;
35
36 sub cmd_direct {
37     my ( $data, $server, $win ) = @_;
38
39     unless ($twit) {
40         &notice("Not logged in!  Use /twitter_login username pass!");
41         return;
42     }
43
44     my ( $target, $text ) = split ' ', $data, 2;
45     unless ( $target and $text ) {
46         &notice("Usage: /dm <nick> <message>");
47         return;
48     }
49
50     &cmd_direct_as( "$user $data", $server, $win );
51 }
52
53 sub cmd_direct_as {
54     my ( $data, $server, $win ) = @_;
55
56     unless ($twit) {
57         &notice("Not logged in!  Use /twitter_login username pass!");
58         return;
59     }
60
61     my ( $username, $target, $text ) = split ' ', $data, 3;
62     unless ( $username and $target and $text ) {
63         &notice("Usage: /dm_as <username> <nick> <message>");
64         return;
65     }
66
67     unless ( exists $twits{$username} ) {
68         &notice("Unknown username $username");
69         return;
70     }
71
72     unless ( $twits{$username}
73         ->new_direct_message( { user => $target, text => $text } ) )
74     {
75         &notice("DM to $target failed");
76         return;
77     }
78
79     &notice("DM sent to $target");
80     $nicks{$target} = time;
81 }
82
83 sub cmd_tweet {
84     my ( $data, $server, $win ) = @_;
85
86     unless ($twit) {
87         &notice("Not logged in!  Use /twitter_login username pass!");
88         return;
89     }
90
91     $data =~ s/^\s+|\s+$//;
92     unless ($data) {
93         &notice("Usage: /tweet <update>");
94         return;
95     }
96
97     &cmd_tweet_as( "$user $data", $server, $win );
98 }
99
100 sub cmd_tweet_as {
101     my ( $data, $server, $win ) = @_;
102
103     unless ($twit) {
104         &notice("Not logged in!  Use /twitter_login username pass!");
105         return;
106     }
107
108     $data =~ s/^\s+|\s+$//;
109     my ( $username, $data ) = split ' ', $data, 2;
110
111     unless ( $username and $data ) {
112         &notice("Usage: /tweet_as <username> <update>");
113         return;
114     }
115
116     unless ( exists $twits{$username} ) {
117         &notice("Unknown username $username");
118         return;
119     }
120
121     if ( Irssi::settings_get_str("short_url_provider") ) {
122         foreach my $url ( $data =~ /(https?:\/\/\S+[\w\/])/g ) {
123             eval {
124                 my $short = makeashorterlink($url);
125                 $data =~ s/\Q$url/$short/g;
126             };
127         }
128     }
129
130     unless ( $twits{$username}->update($data) ) {
131         &notice("Update failed");
132         return;
133     }
134
135     foreach ( $data =~ /@([-\w]+)/ ) {
136         $nicks{$1} = time;
137     }
138
139     my $away = 0;
140     if (    Irssi::settings_get_bool("tweet_to_away")
141         and $data !~ /\@\w/
142         and $data !~ /^[dD] / )
143     {
144         my $server =
145           Irssi::server_find_tag( Irssi::settings_get_str("bitlbee_server") );
146         if ($server) {
147             $server->send_raw("away :$data");
148             $away = 1;
149         } else {
150             &notice( "Can't find bitlbee server.",
151                 "Update bitlbee_server or disalbe tweet_to_away" );
152         }
153     }
154
155     &notice( "Update sent" . ( $away ? " (and away msg set)" : "" ) );
156 }
157
158 sub gen_cmd {
159     my ( $usage_str, $api_name, $post_ref ) = @_;
160
161     return sub {
162         my ( $data, $server, $win ) = @_;
163
164         unless ($twit) {
165             &notice("Not logged in!  Use /twitter_login username pass!");
166             return;
167         }
168
169         $data =~ s/^\s+|\s+$//;
170         unless ($data) {
171             &notice("Usage: $usage_str");
172             return;
173         }
174
175         unless ( $twit->$api_name($data) ) {
176             &notice("$api_name failed");
177             return;
178         }
179
180         &$post_ref($data) if $post_ref;
181       }
182 }
183
184 sub cmd_switch {
185     my ( $data, $server, $win ) = @_;
186
187     $data =~ s/^\s+|\s+$//g;
188     if ( exists $twits{$data} ) {
189         &notice("Switching to $data");
190         $twit = $twits{$data};
191         $user = $data;
192     } else {
193         &notice("Unknown user $data");
194     }
195 }
196
197 sub cmd_logout {
198     my ( $data, $server, $win ) = @_;
199
200     $data =~ s/^\s+|\s+$//g;
201     if ( $data and exists $twits{$data} ) {
202         &notice("Logging out $data...");
203         $twits{$data}->end_session();
204         delete $twits{$data};
205     } elsif ($data) {
206         &notice("Unknown username '$data'");
207     } else {
208         &notice("Logging out $user...");
209         $twit->end_session();
210         undef $twit;
211         delete $twits{$user};
212         if ( keys %twits ) {
213             &cmd_switch( ( keys %twits )[0], $server, $win );
214         } else {
215             Irssi::timeout_remove($poll) if $poll;
216             undef $poll;
217         }
218     }
219 }
220
221 sub cmd_login {
222     my ( $data, $server, $win ) = @_;
223     my $pass;
224     ( $user, $pass ) = split ' ', $data, 2;
225
226     %friends = %nicks = ();
227
228     $twit = Net::Twitter->new(
229         username => $user,
230         password => $pass,
231         source   => "twirssi"
232     );
233
234     unless ( $twit->verify_credentials() ) {
235         &notice("Login failed");
236         $twit = undef;
237         return;
238     }
239
240     if ($twit) {
241         my $rate_limit = $twit->rate_limit_status();
242         if ( $rate_limit and $rate_limit->{remaining_hits} < 1 ) {
243             &notice("Rate limit exceeded, try again later");
244             $twit = undef;
245             return;
246         }
247
248         $twits{$user} = $twit;
249         Irssi::timeout_remove($poll) if $poll;
250         $poll = Irssi::timeout_add( 300 * 1000, \&get_updates, "" );
251         &notice("Logged in as $user, loading friends list...");
252         &load_friends;
253         &notice( "loaded friends: ", scalar keys %friends );
254         %nicks = %friends;
255         $nicks{$user} = 0;
256         &get_updates;
257     } else {
258         &notice("Login failed");
259     }
260 }
261
262 sub cmd_upgrade {
263     my ( $data, $server, $win ) = @_;
264
265     my $loc = Irssi::settings_get_str("twirssi_location");
266     unless ( -w $loc ) {
267         &notice(
268 "$loc isn't writable, can't upgrade.  Perhaps you need to /set twirssi_location?"
269         );
270         return;
271     }
272
273     if ( not -x "/usr/bin/md5sum" and not $data ) {
274         &notice(
275 "/usr/bin/md5sum can't be found - try '/twirssi_upgrade nomd5' to skip MD5 verification"
276         );
277         return;
278     }
279
280     my $md5;
281     unless ($data) {
282         eval { use Digest::MD5; };
283
284         if ($@) {
285             &notice(
286 "Failed to load Digest::MD5.  Try '/twirssi_upgrade nomd5' to skip MD5 verification"
287             );
288             return;
289         }
290
291         $md5 = get("http://irc.peeron.com/~zigdon/twirssi/md5sum");
292         chomp $md5;
293         $md5 =~ s/ .*//;
294         unless ($md5) {
295             &notice("Failed to download md5sum from peeron!  Aborting.");
296             return;
297         }
298
299         unless ( open( CUR, $loc ) ) {
300             &notice(
301 "Failed to read $loc.  Check that /set twirssi_location is set to the correct location."
302             );
303             return;
304         }
305
306         my $cur_md5 = Digest::MD5::md5_hex(<CUR>);
307         close CUR;
308
309         if ( $cur_md5 eq $md5 ) {
310             &notice("Current twirssi seems to be up to date.");
311             return;
312         }
313     }
314
315     my $URL = "http://irc.peeron.com/~zigdon/twirssi/twirssi.pl";
316     &notice("Downloading twirssi from $URL");
317     LWP::Simple::getstore( $URL, "$loc.upgrade" );
318
319     unless ($data) {
320         unless ( open( NEW, "$loc.upgrade" ) ) {
321             &notice(
322 "Failed to read $loc.upgrade.  Check that /set twirssi_location is set to the correct location."
323             );
324             return;
325         }
326
327         my $new_md5 = Digest::MD5::md5_hex(<NEW>);
328         close NEW;
329
330         if ( $new_md5 ne $md5 ) {
331             &notice("MD5 verification failed. expected $md5, got $new_md5");
332             return;
333         }
334     }
335
336     rename $loc, "$loc.backup"
337       or &notice("Failed to back up $loc: $!.  Aborting")
338       and return;
339     rename "$loc.upgrade", $loc
340       or &notice("Failed to rename $loc.upgrade: $!.  Aborting")
341       and return;
342
343     my ( $dir, $file ) = ( $loc =~ m{(.*)/([^/]+)$} );
344     if ( -e "$dir/autorun/$file" ) {
345         &notice("Updating $dir/autorun/$file");
346         unlink "$dir/autorun/$file"
347           or &notice("Failed to remove old $file from autorun: $!");
348         symlink "../$file", "$dir/autorun/$file"
349           or &notice("Failed to create symlink in autorun directory: $!");
350     }
351
352     &notice("Download complete.  Reload twirssi with /script load $file");
353 }
354
355 sub load_friends {
356     my $page = 1;
357     my %new_friends;
358     while (1) {
359         my $friends = $twit->friends( { page => $page } );
360         last unless $friends;
361         $new_friends{ $_->{screen_name} } = time foreach @$friends;
362         $page++;
363         last if @$friends == 0 or $page == 10;
364         $friends = $twit->friends( page => $page );
365     }
366
367     foreach ( keys %new_friends ) {
368         next if exists $friends{$_};
369         $friends{$_} = time;
370     }
371
372     foreach ( keys %friends ) {
373         delete $friends{$_} unless exists $new_friends{$_};
374     }
375 }
376
377 sub get_updates {
378     print scalar localtime, " - get_updates starting" if &debug;
379
380     $window =
381       Irssi::window_find_name( Irssi::settings_get_str('twitter_window') );
382     unless ($window) {
383         Irssi::active_win()
384           ->print( "Can't find a window named '"
385               . Irssi::settings_get_str('twitter_window')
386               . "'.  Create it or change the value of twitter_window" );
387     }
388     unless ($twit) {
389         &notice("Not logged in!  Use /twitter_login username pass!");
390         return;
391     }
392
393     my ( $fh, $filename ) = File::Temp::tempfile();
394     my $pid = fork();
395
396     if ($pid) {    # parent
397         Irssi::timeout_add_once( 5000, 'monitor_child', [$filename] );
398     } elsif ( defined $pid ) {    # child
399         close STDIN;
400         close STDOUT;
401         close STDERR;
402
403         my $new_poll = time;
404
405         &do_updates( $fh, $user, $twit );
406         foreach ( keys %twits ) {
407             next if $_ eq $user;
408             &do_updates( $fh, $_, $twits{$_} );
409         }
410
411         print $fh "__friends__\n";
412         &load_friends;
413         foreach ( sort keys %friends ) {
414             print $fh "$_ $friends{$_}\n";
415         }
416         print $fh $new_poll;
417         close $fh;
418         exit;
419     }
420     print scalar localtime, " - get_updates ends" if &debug;
421 }
422
423 sub do_updates {
424     my ( $fh, $username, $obj ) = @_;
425
426     print scalar localtime, " - Polling for updates for $username" if &debug;
427     my $tweets =
428       $obj->friends_timeline( { since => HTTP::Date::time2str($last_poll) } )
429       || [];
430     foreach my $t ( reverse @$tweets ) {
431         my $text = decode_entities( $t->{text} );
432         $text =~ s/%/%%/g;
433         $text =~ s/(^|\W)\@([-\w]+)/$1%B\@$2%n/g;
434         my $prefix = "";
435         if (    Irssi::settings_get_bool("show_reply_context")
436             and $t->{in_reply_to_screen_name} ne $username
437             and $t->{in_reply_to_screen_name}
438             and not exists $friends{ $t->{in_reply_to_screen_name} } )
439         {
440             $nicks{ $t->{in_reply_to_screen_name} } = time;
441             my $context = $obj->show_status( $t->{in_reply_to_status_id} );
442             if ($context) {
443                 my $ctext = decode_entities( $context->{text} );
444                 $ctext =~ s/%/%%/g;
445                 $ctext =~ s/(^|\W)\@([-\w]+)/$1%B\@$2%n/g;
446                 printf $fh "[%s%%B\@%s%%n] %s\n",
447                   ( $username ne $user ? "$username: " : "" ),
448                   $context->{user}{screen_name}, $ctext;
449                 $prefix = "\--> ";
450             } else {
451                 print "Failed to get context from $t->{in_reply_to_screen_name}"
452                   if &debug;
453             }
454         }
455         next
456           if $t->{user}{screen_name} eq $username
457               and not Irssi::settings_get_bool("show_own_tweets");
458         printf $fh "%s[%s%%B\@%s%%n] %s\n",
459           $prefix,
460           ( $username ne $user ? "$username: " : "" ),
461           $t->{user}{screen_name},
462           $text;
463     }
464
465     print scalar localtime, " - Polling for replies" if &debug;
466     $tweets = $obj->replies( { since => HTTP::Date::time2str($last_poll) } )
467       || [];
468     foreach my $t ( reverse @$tweets ) {
469         next
470           if exists $friends{ $t->{user}{screen_name} };
471
472         my $text = decode_entities( $t->{text} );
473         $text =~ s/%/%%/g;
474         $text =~ s/(^|\W)\@([-\w]+)/$1%B\@$2%n/g;
475         printf $fh "[%s%%B\@%s%%n] %s\n",
476           ( $username ne $user ? "$username: " : "" ),
477           $t->{user}{screen_name},
478           $text;
479     }
480
481     print scalar localtime, " - Polling for DMs" if &debug;
482     $tweets =
483       $obj->direct_messages( { since => HTTP::Date::time2str($last_poll) } )
484       || [];
485     foreach my $t ( reverse @$tweets ) {
486         my $text = decode_entities( $t->{text} );
487         $text =~ s/%/%%/g;
488         $text =~ s/(^|\W)\@([-\w]+)/$1%B\@$2%n/g;
489         printf $fh "[%s%%B\@%s%%n (%%WDM%%n)] %s\n",
490           ( $username ne $user ? "$username: " : "" ),
491           $t->{sender_screen_name},
492           $text;
493     }
494     print scalar localtime, " - Done" if &debug;
495 }
496
497 sub monitor_child {
498     my $data     = shift;
499     my $filename = $data->[0];
500
501     print scalar localtime, " - checking child log at $filename" if &debug;
502     my $old_last_poll = $last_poll;
503     if ( open FILE, $filename ) {
504         my @lines;
505         while (<FILE>) {
506             chomp;
507             last if /^__friends__/;
508             push @lines, $_ unless /^__friends__/;
509         }
510
511         %friends = ();
512         while (<FILE>) {
513             if (/^\d+$/) {
514                 $last_poll = $_;
515                 last;
516             }
517             my ( $f, $t ) = split ' ', $_;
518             $nicks{$f} = $friends{$f} = $t;
519         }
520
521         if ( $last_poll != $old_last_poll ) {
522             print "new last_poll = $last_poll" if &debug;
523             foreach my $line (@lines) {
524                 chomp $line;
525                 $window->print( $line, MSGLEVEL_PUBLIC );
526                 foreach ( $line =~ /\@([-\w]+)/ ) {
527                     $nicks{$1} = time;
528                 }
529             }
530
531             close FILE;
532             unlink $filename
533               or warn "Failed to remove $filename: $!"
534               unless &debug;
535             return;
536         }
537     }
538
539     close FILE;
540     Irssi::timeout_add_once( 5000, 'monitor_child', [$filename] );
541 }
542
543 sub debug {
544     return Irssi::settings_get_bool("twirssi_debug");
545 }
546
547 sub notice {
548     $window->print( "%R***%n @_", MSGLEVEL_PUBLIC );
549 }
550
551 sub sig_complete {
552     my ( $complist, $window, $word, $linestart, $want_space ) = @_;
553
554     return unless $linestart =~ /^\/(?:tweet|dm)/;
555     return if $linestart eq '/tweet' and $word !~ s/^@//;
556     push @$complist, grep /^\Q$word/i,
557       sort { $nicks{$b} <=> $nicks{$a} } keys %nicks;
558     @$complist = map { "\@$_" } @$complist if $linestart eq '/tweet';
559 }
560
561 Irssi::settings_add_str( "twirssi", "twitter_window",     "twitter" );
562 Irssi::settings_add_str( "twirssi", "bitlbee_server",     "bitlbee" );
563 Irssi::settings_add_str( "twirssi", "short_url_provider", "TinyURL" );
564 Irssi::settings_add_str( "twirssi", "twirssi_location",
565     ".irssi/scripts/twirssi.pl" );
566 Irssi::settings_add_bool( "twirssi", "tweet_to_away",      0 );
567 Irssi::settings_add_bool( "twirssi", "show_reply_context", 0 );
568 Irssi::settings_add_bool( "twirssi", "show_own_tweets",    1 );
569 Irssi::settings_add_bool( "twirssi", "twirssi_debug",      0 );
570 $window = Irssi::window_find_name( Irssi::settings_get_str('twitter_window') );
571
572 if ($window) {
573     Irssi::command_bind( "dm",              "cmd_direct" );
574     Irssi::command_bind( "tweet",           "cmd_tweet" );
575     Irssi::command_bind( "dm_as",           "cmd_direct_as" );
576     Irssi::command_bind( "tweet_as",        "cmd_tweet_as" );
577     Irssi::command_bind( "twitter_login",   "cmd_login" );
578     Irssi::command_bind( "twitter_logout",  "cmd_logout" );
579     Irssi::command_bind( "twitter_switch",  "cmd_switch" );
580     Irssi::command_bind( "twirssi_upgrade", "cmd_upgrade" );
581     Irssi::command_bind(
582         "twitter_dump",
583         sub {
584             print "twits: ",   Dumper \%twits;
585             print "friends: ", join ", ", sort keys %friends;
586             print "nicks: ",   join ", ", sort keys %nicks;
587             print "last poll: $last_poll";
588         }
589     );
590     Irssi::command_bind(
591         "twirssi_version",
592         sub {
593             &notice(
594 "Twirssi v$VERSION (r$REV);  Net::Twitter v$Net::Twitter::VERSION. "
595                   . "See details at http://tinyurl.com/twirssi" );
596         }
597     );
598     Irssi::command_bind(
599         "twitter_friend",
600         &gen_cmd(
601             "/twitter_friend <username>",
602             "create_friend",
603             sub { &notice("Following $_[0]"); $nicks{ $_[0] } = time; }
604         )
605     );
606     Irssi::command_bind(
607         "twitter_unfriend",
608         &gen_cmd(
609             "/twitter_unfriend <username>",
610             "destroy_friend",
611             sub { &notice("Stopped following $_[0]"); delete $nicks{ $_[0] }; }
612         )
613     );
614     Irssi::command_bind( "twitter_updates", "get_updates" );
615     Irssi::signal_add_last( 'complete word' => \&sig_complete );
616
617     &notice("  %Y<%C(%B^%C)%N                   TWIRSSI v%R$VERSION%N (r$REV)");
618     &notice("   %C(_(\\%N        http://tinyurl.com/twirssi for full docs");
619     &notice(
620         "    %Y||%C `%N Log in with /twitter_login, send updates with /tweet");
621
622     if ( my $provider = Irssi::settings_get_str("short_url_provider") ) {
623         eval "use WWW::Shorten::$provider;";
624
625         if ($@) {
626             &notice(
627 "Failed to load WWW::Shorten::$provider - either clear short_url_provider or install the CPAN module"
628             );
629         }
630     }
631 } else {
632     Irssi::active_win()
633       ->print( "Create a window named "
634           . Irssi::settings_get_str('twitter_window')
635           . " or change the value of twitter_window.  Then, reload twirssi." );
636 }
637