6d23292ea720b22f4c8f80034dd6bf2dcf02525c
[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.4";
15 my ($REV) = '$Rev: 317 $' =~ /(\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 as $user failed");
236         $twit = undef;
237         if ( keys %twits ) {
238             &cmd_switch( ( keys %twits )[0], $server, $win );
239         }
240         return;
241     }
242
243     if ($twit) {
244         my $rate_limit = $twit->rate_limit_status();
245         if ( $rate_limit and $rate_limit->{remaining_hits} < 1 ) {
246             &notice("Rate limit exceeded, try again later");
247             $twit = undef;
248             return;
249         }
250
251         $twits{$user} = $twit;
252         Irssi::timeout_remove($poll) if $poll;
253         $poll = Irssi::timeout_add( 300 * 1000, \&get_updates, "" );
254         &notice("Logged in as $user, loading friends list...");
255         &load_friends;
256         &notice( "loaded friends: ", scalar keys %friends );
257         if ( Irssi::settings_get_bool("twirssi_first_run") ) {
258             Irssi::settings_set_bool( "twirssi_first_run", 0 );
259             unless ( exists $friends{twirssi} ) {
260                 &notice("Welcome to twirssi!"
261                       . "  Perhaps you should add \@twirssi to your friends list,"
262                       . " so you can be notified when a new version is release?"
263                       . "  Just type /twitter_friend twirssi." );
264             }
265         }
266         %nicks = %friends;
267         $nicks{$user} = 0;
268         &get_updates;
269     } else {
270         &notice("Login failed");
271     }
272 }
273
274 sub cmd_upgrade {
275     my ( $data, $server, $win ) = @_;
276
277     my $loc = Irssi::settings_get_str("twirssi_location");
278     unless ( -w $loc ) {
279         &notice(
280 "$loc isn't writable, can't upgrade.  Perhaps you need to /set twirssi_location?"
281         );
282         return;
283     }
284
285     if ( not -x "/usr/bin/md5sum" and not $data ) {
286         &notice(
287 "/usr/bin/md5sum can't be found - try '/twirssi_upgrade nomd5' to skip MD5 verification"
288         );
289         return;
290     }
291
292     my $md5;
293     unless ($data) {
294         eval { use Digest::MD5; };
295
296         if ($@) {
297             &notice(
298 "Failed to load Digest::MD5.  Try '/twirssi_upgrade nomd5' to skip MD5 verification"
299             );
300             return;
301         }
302
303         $md5 = get("http://irc.peeron.com/~zigdon/twirssi/md5sum");
304         chomp $md5;
305         $md5 =~ s/ .*//;
306         unless ($md5) {
307             &notice("Failed to download md5sum from peeron!  Aborting.");
308             return;
309         }
310
311         unless ( open( CUR, $loc ) ) {
312             &notice(
313 "Failed to read $loc.  Check that /set twirssi_location is set to the correct location."
314             );
315             return;
316         }
317
318         my $cur_md5 = Digest::MD5::md5_hex(<CUR>);
319         close CUR;
320
321         if ( $cur_md5 eq $md5 ) {
322             &notice("Current twirssi seems to be up to date.");
323             return;
324         }
325     }
326
327     my $URL = "http://irc.peeron.com/~zigdon/twirssi/twirssi.pl";
328     &notice("Downloading twirssi from $URL");
329     LWP::Simple::getstore( $URL, "$loc.upgrade" );
330
331     unless ($data) {
332         unless ( open( NEW, "$loc.upgrade" ) ) {
333             &notice(
334 "Failed to read $loc.upgrade.  Check that /set twirssi_location is set to the correct location."
335             );
336             return;
337         }
338
339         my $new_md5 = Digest::MD5::md5_hex(<NEW>);
340         close NEW;
341
342         if ( $new_md5 ne $md5 ) {
343             &notice("MD5 verification failed. expected $md5, got $new_md5");
344             return;
345         }
346     }
347
348     rename $loc, "$loc.backup"
349       or &notice("Failed to back up $loc: $!.  Aborting")
350       and return;
351     rename "$loc.upgrade", $loc
352       or &notice("Failed to rename $loc.upgrade: $!.  Aborting")
353       and return;
354
355     my ( $dir, $file ) = ( $loc =~ m{(.*)/([^/]+)$} );
356     if ( -e "$dir/autorun/$file" ) {
357         &notice("Updating $dir/autorun/$file");
358         unlink "$dir/autorun/$file"
359           or &notice("Failed to remove old $file from autorun: $!");
360         symlink "../$file", "$dir/autorun/$file"
361           or &notice("Failed to create symlink in autorun directory: $!");
362     }
363
364     &notice("Download complete.  Reload twirssi with /script load $file");
365 }
366
367 sub load_friends {
368     my $page = 1;
369     my %new_friends;
370     while (1) {
371         my $friends = $twit->friends( { page => $page } );
372         last unless $friends;
373         $new_friends{ $_->{screen_name} } = time foreach @$friends;
374         $page++;
375         last if @$friends == 0 or $page == 10;
376         $friends = $twit->friends( page => $page );
377     }
378
379     foreach ( keys %new_friends ) {
380         next if exists $friends{$_};
381         $friends{$_} = time;
382     }
383
384     foreach ( keys %friends ) {
385         delete $friends{$_} unless exists $new_friends{$_};
386     }
387 }
388
389 sub get_updates {
390     print scalar localtime, " - get_updates starting" if &debug;
391
392     $window =
393       Irssi::window_find_name( Irssi::settings_get_str('twitter_window') );
394     unless ($window) {
395         Irssi::active_win()
396           ->print( "Can't find a window named '"
397               . Irssi::settings_get_str('twitter_window')
398               . "'.  Create it or change the value of twitter_window" );
399     }
400     unless ($twit) {
401         &notice("Not logged in!  Use /twitter_login username pass!");
402         return;
403     }
404
405     my ( $fh, $filename ) = File::Temp::tempfile();
406     my $pid = fork();
407
408     if ($pid) {    # parent
409         Irssi::timeout_add_once( 5000, 'monitor_child', [$filename] );
410     } elsif ( defined $pid ) {    # child
411         close STDIN;
412         close STDOUT;
413         close STDERR;
414
415         my $new_poll = time;
416
417         &do_updates( $fh, $user, $twit );
418         foreach ( keys %twits ) {
419             next if $_ eq $user;
420             &do_updates( $fh, $_, $twits{$_} );
421         }
422
423         print $fh "__friends__\n";
424         &load_friends;
425         foreach ( sort keys %friends ) {
426             print $fh "$_ $friends{$_}\n";
427         }
428         print $fh $new_poll;
429         close $fh;
430         exit;
431     }
432     print scalar localtime, " - get_updates ends" if &debug;
433 }
434
435 sub do_updates {
436     my ( $fh, $username, $obj ) = @_;
437
438     print scalar localtime, " - Polling for updates for $username" if &debug;
439     my $tweets =
440       $obj->friends_timeline( { since => HTTP::Date::time2str($last_poll) } )
441       || [];
442     foreach my $t ( reverse @$tweets ) {
443         my $text = decode_entities( $t->{text} );
444         $text =~ s/%/%%/g;
445         $text =~ s/(^|\W)\@([-\w]+)/$1%B\@$2%n/g;
446         my $prefix = "";
447         if (    Irssi::settings_get_bool("show_reply_context")
448             and $t->{in_reply_to_screen_name} ne $username
449             and $t->{in_reply_to_screen_name}
450             and not exists $friends{ $t->{in_reply_to_screen_name} } )
451         {
452             $nicks{ $t->{in_reply_to_screen_name} } = time;
453             my $context = $obj->show_status( $t->{in_reply_to_status_id} );
454             if ($context) {
455                 my $ctext = decode_entities( $context->{text} );
456                 $ctext =~ s/%/%%/g;
457                 $ctext =~ s/(^|\W)\@([-\w]+)/$1%B\@$2%n/g;
458                 printf $fh "[%s%%B\@%s%%n] %s\n",
459                   ( $username ne $user ? "$username: " : "" ),
460                   $context->{user}{screen_name}, $ctext;
461                 $prefix = "\--> ";
462             } else {
463                 print "Failed to get context from $t->{in_reply_to_screen_name}"
464                   if &debug;
465             }
466         }
467         next
468           if $t->{user}{screen_name} eq $username
469               and not Irssi::settings_get_bool("show_own_tweets");
470         printf $fh "%s[%s%%B\@%s%%n] %s\n",
471           $prefix,
472           ( $username ne $user ? "$username: " : "" ),
473           $t->{user}{screen_name},
474           $text;
475     }
476
477     print scalar localtime, " - Polling for replies" if &debug;
478     $tweets = $obj->replies( { since => HTTP::Date::time2str($last_poll) } )
479       || [];
480     foreach my $t ( reverse @$tweets ) {
481         next
482           if exists $friends{ $t->{user}{screen_name} };
483
484         my $text = decode_entities( $t->{text} );
485         $text =~ s/%/%%/g;
486         $text =~ s/(^|\W)\@([-\w]+)/$1%B\@$2%n/g;
487         printf $fh "[%s%%B\@%s%%n] %s\n",
488           ( $username ne $user ? "$username: " : "" ),
489           $t->{user}{screen_name},
490           $text;
491     }
492
493     print scalar localtime, " - Polling for DMs" if &debug;
494     $tweets =
495       $obj->direct_messages( { since => HTTP::Date::time2str($last_poll) } )
496       || [];
497     foreach my $t ( reverse @$tweets ) {
498         my $text = decode_entities( $t->{text} );
499         $text =~ s/%/%%/g;
500         $text =~ s/(^|\W)\@([-\w]+)/$1%B\@$2%n/g;
501         printf $fh "[%s%%B\@%s%%n (%%WDM%%n)] %s\n",
502           ( $username ne $user ? "$username: " : "" ),
503           $t->{sender_screen_name},
504           $text;
505     }
506     print scalar localtime, " - Done" if &debug;
507 }
508
509 sub monitor_child {
510     my $data     = shift;
511     my $filename = $data->[0];
512
513     print scalar localtime, " - checking child log at $filename" if &debug;
514     my $old_last_poll = $last_poll;
515     if ( open FILE, $filename ) {
516         my @lines;
517         while (<FILE>) {
518             chomp;
519             last if /^__friends__/;
520             push @lines, $_ unless /^__friends__/;
521         }
522
523         %friends = ();
524         while (<FILE>) {
525             if (/^\d+$/) {
526                 $last_poll = $_;
527                 last;
528             }
529             my ( $f, $t ) = split ' ', $_;
530             $nicks{$f} = $friends{$f} = $t;
531         }
532
533         if ( $last_poll != $old_last_poll ) {
534             print "new last_poll = $last_poll" if &debug;
535             foreach my $line (@lines) {
536                 chomp $line;
537                 $window->print( $line, MSGLEVEL_PUBLIC );
538                 foreach ( $line =~ /\@([-\w]+)/ ) {
539                     $nicks{$1} = time;
540                 }
541             }
542
543             close FILE;
544             unlink $filename
545               or warn "Failed to remove $filename: $!"
546               unless &debug;
547             return;
548         }
549     }
550
551     close FILE;
552     Irssi::timeout_add_once( 5000, 'monitor_child', [$filename] );
553 }
554
555 sub debug {
556     return Irssi::settings_get_bool("twirssi_debug");
557 }
558
559 sub notice {
560     $window->print( "%R***%n @_", MSGLEVEL_PUBLIC );
561 }
562
563 sub sig_complete {
564     my ( $complist, $window, $word, $linestart, $want_space ) = @_;
565
566     return unless $linestart =~ /^\/(?:tweet|dm)/;
567     return if $linestart eq '/tweet' and $word !~ s/^@//;
568     push @$complist, grep /^\Q$word/i,
569       sort { $nicks{$b} <=> $nicks{$a} } keys %nicks;
570     @$complist = map { "\@$_" } @$complist if $linestart eq '/tweet';
571 }
572
573 Irssi::settings_add_str( "twirssi", "twitter_window",     "twitter" );
574 Irssi::settings_add_str( "twirssi", "bitlbee_server",     "bitlbee" );
575 Irssi::settings_add_str( "twirssi", "short_url_provider", "TinyURL" );
576 Irssi::settings_add_str( "twirssi", "twirssi_location",
577     ".irssi/scripts/twirssi.pl" );
578 Irssi::settings_add_str( "twirssi", "twitter_usernames", undef );
579 Irssi::settings_add_str( "twirssi", "twitter_passwords", undef );
580 Irssi::settings_add_bool( "twirssi", "tweet_to_away",      0 );
581 Irssi::settings_add_bool( "twirssi", "show_reply_context", 0 );
582 Irssi::settings_add_bool( "twirssi", "show_own_tweets",    1 );
583 Irssi::settings_add_bool( "twirssi", "twirssi_debug",      0 );
584 Irssi::settings_add_bool( "twirssi", "twirssi_first_run",  1 );
585 $window = Irssi::window_find_name( Irssi::settings_get_str('twitter_window') );
586
587 if ($window) {
588     Irssi::command_bind( "dm",              "cmd_direct" );
589     Irssi::command_bind( "tweet",           "cmd_tweet" );
590     Irssi::command_bind( "dm_as",           "cmd_direct_as" );
591     Irssi::command_bind( "tweet_as",        "cmd_tweet_as" );
592     Irssi::command_bind( "twitter_login",   "cmd_login" );
593     Irssi::command_bind( "twitter_logout",  "cmd_logout" );
594     Irssi::command_bind( "twitter_switch",  "cmd_switch" );
595     Irssi::command_bind( "twirssi_upgrade", "cmd_upgrade" );
596     Irssi::command_bind(
597         "twitter_dump",
598         sub {
599             print "twits: ",   Dumper \%twits;
600             print "friends: ", join ", ", sort keys %friends;
601             print "nicks: ",   join ", ", sort keys %nicks;
602             print "last poll: $last_poll";
603         }
604     );
605     Irssi::command_bind(
606         "twirssi_version",
607         sub {
608             &notice(
609 "Twirssi v$VERSION (r$REV);  Net::Twitter v$Net::Twitter::VERSION. "
610                   . "See details at http://tinyurl.com/twirssi" );
611         }
612     );
613     Irssi::command_bind(
614         "twitter_friend",
615         &gen_cmd(
616             "/twitter_friend <username>",
617             "create_friend",
618             sub { &notice("Following $_[0]"); $nicks{ $_[0] } = time; }
619         )
620     );
621     Irssi::command_bind(
622         "twitter_unfriend",
623         &gen_cmd(
624             "/twitter_unfriend <username>",
625             "destroy_friend",
626             sub { &notice("Stopped following $_[0]"); delete $nicks{ $_[0] }; }
627         )
628     );
629     Irssi::command_bind( "twitter_updates", "get_updates" );
630     Irssi::signal_add_last( 'complete word' => \&sig_complete );
631
632     &notice("  %Y<%C(%B^%C)%N                   TWIRSSI v%R$VERSION%N (r$REV)");
633     &notice("   %C(_(\\%N        http://tinyurl.com/twirssi for full docs");
634     &notice(
635         "    %Y||%C `%N Log in with /twitter_login, send updates with /tweet");
636
637     if ( my $provider = Irssi::settings_get_str("short_url_provider") ) {
638         eval "use WWW::Shorten::$provider;";
639
640         if ($@) {
641             &notice(
642 "Failed to load WWW::Shorten::$provider - either clear short_url_provider or install the CPAN module"
643             );
644         }
645     }
646
647     if (    my $autouser = Irssi::settings_get_str("twitter_usernames")
648         and my $autopass = Irssi::settings_get_str("twitter_passwords") )
649     {
650         my @user = split /\s*,\s*/, $autouser;
651         my @pass = split /\s*,\s*/, $autopass;
652         if ( @user != @pass ) {
653             &notice(
654 "Number of usernames doesn't match the number of passwords - auto-login failed"
655             );
656         } else {
657             my ( $u, $p );
658             while ( @user and @pass ) {
659                 $u = shift @user;
660                 $p = shift @pass;
661                 &cmd_login("$u $p");
662             }
663         }
664     }
665
666 } else {
667     Irssi::active_win()
668       ->print( "Create a window named "
669           . Irssi::settings_get_str('twitter_window')
670           . " or change the value of twitter_window.  Then, reload twirssi." );
671 }
672