a7f8c4d66055123b14649dc8fb0d78d7b393f1ab
[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
9 use vars qw($VERSION %IRSSI);
10 use constant { DEBUG => 0 };
11
12 $VERSION = "1.2";
13 my ($REV) = '$Rev: 310 $' =~ /(\d+)/;
14 %IRSSI = (
15     authors     => 'Dan Boger',
16     contact     => 'zigdon@gmail.com',
17     name        => 'twirssi',
18     description => 'Send twitter updates using /tweet.  '
19       . 'Can optionally set your bitlbee /away message to same',
20     license => 'GNU GPL v2',
21     url     => 'http://tinyurl.com/twirssi',
22     changed => 'Mon Dec  1 15:36:01 PST 2008',
23 );
24
25 my $window;
26 my $twit;
27 my %twits;
28 my $user;
29 my $poll;
30 my %nicks;
31 my %friends;
32 my $last_poll = time - 300;
33
34 sub cmd_direct {
35     my ( $data, $server, $win ) = @_;
36
37     unless ($twit) {
38         &notice("Not logged in!  Use /twitter_login username pass!");
39         return;
40     }
41
42     my ( $target, $text ) = split ' ', $data, 2;
43     unless ( $target and $text ) {
44         &notice("Usage: /dm <nick> <message>");
45         return;
46     }
47
48     &cmd_direct_as( "$user $data", $server, $win );
49 }
50
51 sub cmd_direct_as {
52     my ( $data, $server, $win ) = @_;
53
54     unless ($twit) {
55         &notice("Not logged in!  Use /twitter_login username pass!");
56         return;
57     }
58
59     my ( $username, $target, $text ) = split ' ', $data, 3;
60     unless ( $username and $target and $text ) {
61         &notice("Usage: /dm_as <username> <nick> <message>");
62         return;
63     }
64
65     unless ( exists $twits{$username} ) {
66         &notice("Unknown username $username");
67         return;
68     }
69
70     unless ( $twits{$username}
71         ->new_direct_message( { user => $target, text => $text } ) )
72     {
73         &notice("DM to $target failed");
74         return;
75     }
76
77     &notice("DM sent to $target");
78     $nicks{$target} = time;
79 }
80
81 sub cmd_tweet {
82     my ( $data, $server, $win ) = @_;
83
84     unless ($twit) {
85         &notice("Not logged in!  Use /twitter_login username pass!");
86         return;
87     }
88
89     $data =~ s/^\s+|\s+$//;
90     unless ($data) {
91         &notice("Usage: /tweet <update>");
92         return;
93     }
94
95     &cmd_tweet_as( "$user $data", $server, $win );
96 }
97
98 sub cmd_tweet_as {
99     my ( $data, $server, $win ) = @_;
100
101     unless ($twit) {
102         &notice("Not logged in!  Use /twitter_login username pass!");
103         return;
104     }
105
106     $data =~ s/^\s+|\s+$//;
107     my ( $username, $data ) = split ' ', $data, 2;
108
109     unless ( $username and $data ) {
110         &notice("Usage: /tweet_as <username> <update>");
111         return;
112     }
113
114     unless ( exists $twits{$username} ) {
115         &notice("Unknown username $username");
116         return;
117     }
118
119     foreach my $url ( $data =~ /(https?:\/\/\S+[\w\/])/g ) {
120         eval { my $short = makeashorterlink($url); $data =~ s/\Q$url/$short/g; };
121     }
122
123     unless ( $twits{$username}->update($data) ) {
124         &notice("Update failed");
125         return;
126     }
127
128     foreach ( $data =~ /@([-\w]+)/ ) {
129         $nicks{$1} = time;
130     }
131
132     my $away = 0;
133     if (    Irssi::settings_get_bool("tweet_to_away")
134         and $data !~ /\@\w/
135         and $data !~ /^[dD] / )
136     {
137         my $server =
138           Irssi::server_find_tag( Irssi::settings_get_str("bitlbee_server") );
139         if ($server) {
140             $server->send_raw("away :$data");
141             $away = 1;
142         } else {
143             &notice( "Can't find bitlbee server.",
144                 "Update bitlbee_server or disalbe tweet_to_away" );
145         }
146     }
147
148     &notice( "Update sent" . ( $away ? " (and away msg set)" : "" ) );
149 }
150
151 sub gen_cmd {
152     my ( $usage_str, $api_name, $post_ref ) = @_;
153
154     return sub {
155         my ( $data, $server, $win ) = @_;
156
157         unless ($twit) {
158             &notice("Not logged in!  Use /twitter_login username pass!");
159             return;
160         }
161
162         $data =~ s/^\s+|\s+$//;
163         unless ($data) {
164             &notice("Usage: $usage_str");
165             return;
166         }
167
168         unless ( $twit->$api_name($data) ) {
169             &notice("$api_name failed");
170             return;
171         }
172
173         &$post_ref($data) if $post_ref;
174       }
175 }
176
177 sub cmd_switch {
178     my ( $data, $server, $win ) = @_;
179
180     $data =~ s/^\s+|\s+$//g;
181     if ( exists $twits{$data} ) {
182         &notice("Switching to $data");
183         $twit = $twits{$data};
184         $user = $data;
185     } else {
186         &notice("Unknown user $data");
187     }
188 }
189
190 sub cmd_logout {
191     my ( $data, $server, $win ) = @_;
192
193     $data =~ s/^\s+|\s+$//g;
194     if ( $data and exists $twits{$data} ) {
195         &notice("Logging out $data...");
196         $twits{$data}->end_session();
197         delete $twits{$data};
198     } elsif ($data) {
199         &notice("Unknown username '$data'");
200     } else {
201         &notice("Logging out $user...");
202         $twit->end_session();
203         undef $twit;
204         delete $twits{$user};
205         if ( keys %twits ) {
206             &cmd_switch( ( keys %twits )[0], $server, $win );
207         } else {
208             Irssi::timeout_remove($poll) if $poll;
209             undef $poll;
210         }
211     }
212 }
213
214 sub cmd_login {
215     my ( $data, $server, $win ) = @_;
216     my $pass;
217     ( $user, $pass ) = split ' ', $data, 2;
218
219     %friends = %nicks = ();
220
221     $twit = Net::Twitter->new(
222         username => $user,
223         password => $pass,
224         source   => "twirssi"
225     );
226
227     unless ( $twit->verify_credentials() ) {
228         &notice("Login failed");
229         $twit = undef;
230         return;
231     }
232
233     if ($twit) {
234         my $rate_limit = $twit->rate_limit_status();
235         if ( $rate_limit and $rate_limit->{remaining_hits} < 1 ) {
236             &notice("Rate limit exceeded, try again later");
237             $twit = undef;
238             return;
239         }
240
241         $twits{$user} = $twit;
242         Irssi::timeout_remove($poll) if $poll;
243         $poll = Irssi::timeout_add( 300 * 1000, \&get_updates, "" );
244         &notice("Logged in as $user, loading friends list...");
245         &load_friends;
246         &notice( "loaded friends: ", scalar keys %friends );
247         %nicks = %friends;
248         $nicks{$user} = 0;
249         &get_updates;
250     } else {
251         &notice("Login failed");
252     }
253 }
254
255 sub load_friends {
256     my $page = 1;
257     my %new_friends;
258     while (1) {
259         my $friends = $twit->friends( { page => $page } );
260         last unless $friends;
261         $new_friends{ $_->{screen_name} } = time foreach @$friends;
262         $page++;
263         last if @$friends == 0 or $page == 10;
264         $friends = $twit->friends( page => $page );
265     }
266
267     foreach ( keys %new_friends ) {
268         next if exists $friends{$_};
269         $friends{$_} = time;
270     }
271
272     foreach ( keys %friends ) {
273         delete $friends{$_} unless exists $new_friends{$_};
274     }
275 }
276
277 sub get_updates {
278     $window =
279       Irssi::window_find_name( Irssi::settings_get_str('twitter_window') );
280     unless ($window) {
281         Irssi::active_win()
282           ->print( "Can't find a window named '"
283               . Irssi::settings_get_str('twitter_window')
284               . "'.  Create it or change the value of twitter_window" );
285     }
286     unless ($twit) {
287         &notice("Not logged in!  Use /twitter_login username pass!");
288         return;
289     }
290
291     my ( $fh, $filename ) = File::Temp::tempfile();
292     my $pid = fork();
293
294     if ($pid) {    # parent
295         Irssi::timeout_add_once( 5000, 'monitor_child', [$filename] );
296     } elsif ( defined $pid ) {    # child
297         close STDIN;
298         close STDOUT;
299         close STDERR;
300
301         my $new_poll = time;
302
303         &do_updates( $fh, $user, $twit );
304         foreach ( keys %twits ) {
305             next if $_ eq $user;
306             &do_updates( $fh, $_, $twits{$_} );
307         }
308
309         print $fh "__friends__\n";
310         &load_friends;
311         foreach ( sort keys %friends ) {
312             print $fh "$_ $friends{$_}\n";
313         }
314         print $fh $new_poll;
315         close $fh;
316         exit;
317     }
318 }
319
320 sub do_updates {
321     my ( $fh, $username, $obj ) = @_;
322
323     print scalar localtime, " - Polling for updates for $username" if DEBUG;
324     my $tweets =
325       $obj->friends_timeline( { since => HTTP::Date::time2str($last_poll) } )
326       || [];
327     foreach my $t ( reverse @$tweets ) {
328         my $text = decode_entities( $t->{text} );
329         $text =~ s/%/%%/g;
330         $text =~ s/(^|\W)\@([-\w]+)/$1%B\@$2%n/g;
331         my $prefix = "";
332         if (    Irssi::settings_get_bool("show_reply_context")
333             and $t->{in_reply_to_screen_name} ne $username
334             and $t->{in_reply_to_screen_name}
335             and not exists $friends{ $t->{in_reply_to_screen_name} } )
336         {
337             $nicks{ $t->{in_reply_to_screen_name} } = time;
338             my $context = $obj->show_status( $t->{in_reply_to_status_id} );
339             if ($context) {
340                 my $ctext = decode_entities( $context->{text} );
341                 $ctext =~ s/%/%%/g;
342                 $ctext =~ s/(^|\W)\@([-\w]+)/$1%B\@$2%n/g;
343                 printf $fh "[%s%%B\@%s%%n] %s\n",
344                   ( $username ne $user ? "$username: " : "" ),
345                   $context->{user}{screen_name}, $ctext;
346                 $prefix = "\--> ";
347             }
348         }
349         next
350           if $t->{user}{screen_name} eq $username
351               and not Irssi::settings_get_bool("show_own_tweets");
352         printf $fh "%s[%s%%B\@%s%%n] %s\n",
353           $prefix,
354           ( $username ne $user ? "$username: " : "" ),
355           $t->{user}{screen_name},
356           $text;
357     }
358
359     print scalar localtime, " - Polling for replies" if DEBUG;
360     $tweets = $obj->replies( { since => HTTP::Date::time2str($last_poll) } )
361       || [];
362     foreach my $t ( reverse @$tweets ) {
363         next
364           if exists $friends{ $t->{user}{screen_name} };
365
366         my $text = decode_entities( $t->{text} );
367         $text =~ s/%/%%/g;
368         $text =~ s/(^|\W)\@([-\w]+)/$1%B\@$2%n/g;
369         printf $fh "[%s%%B\@%s%%n] %s\n",
370           ( $username ne $user ? "$username: " : "" ),
371           $t->{user}{screen_name},
372           $text;
373     }
374
375     print scalar localtime, " - Polling for DMs" if DEBUG;
376     $tweets =
377       $obj->direct_messages( { since => HTTP::Date::time2str($last_poll) } )
378       || [];
379     foreach my $t ( reverse @$tweets ) {
380         my $text = decode_entities( $t->{text} );
381         $text =~ s/%/%%/g;
382         $text =~ s/(^|\W)\@([-\w]+)/$1%B\@$2%n/g;
383         printf $fh "[%s%%B\@%s%%n (%%WDM%%n)] %s\n",
384           ( $username ne $user ? "$username: " : "" ),
385           $t->{sender_screen_name},
386           $text;
387     }
388     print scalar localtime, " - Done" if DEBUG;
389 }
390
391 sub monitor_child {
392     my $data     = shift;
393     my $filename = $data->[0];
394
395     print scalar localtime, " - checking child log at $filename" if DEBUG;
396     if ( open FILE, $filename ) {
397         my @lines;
398         while (<FILE>) {
399             chomp;
400             last if /^__friends__/;
401             push @lines, $_ unless /^__friends__/;
402         }
403
404         %friends = ();
405         while (<FILE>) {
406             if (/^\d+$/) {
407                 $last_poll = $_;
408                 last;
409             }
410             my ( $f, $t ) = split ' ', $_;
411             $nicks{$f} = $friends{$f} = $t;
412         }
413
414         print "new last_poll = $last_poll" if DEBUG;
415         foreach my $line (@lines) {
416             chomp $line;
417             $window->print( $line, MSGLEVEL_PUBLIC );
418             foreach ( $line =~ /\@([-\w]+)/ ) {
419                 $nicks{$1} = time;
420             }
421         }
422
423         close FILE;
424         unlink $filename or warn "Failed to remove $filename: $!";
425         return;
426     }
427
428     Irssi::timeout_add_once( 5000, 'monitor_child', [$filename] );
429 }
430
431 sub notice {
432     $window->print( "%R***%n @_", MSGLEVEL_PUBLIC );
433 }
434
435 sub sig_complete {
436     my ( $complist, $window, $word, $linestart, $want_space ) = @_;
437
438     return unless $linestart =~ /^\/(?:tweet|dm)/;
439     return if $linestart eq '/tweet' and $word !~ s/^@//;
440     push @$complist, grep /^\Q$word/i,
441       sort { $nicks{$b} <=> $nicks{$a} } keys %nicks;
442     @$complist = map { "\@$_" } @$complist if $linestart eq '/tweet';
443 }
444
445 Irssi::settings_add_str( "twirssi", "twitter_window",     "twitter" );
446 Irssi::settings_add_str( "twirssi", "bitlbee_server",     "bitlbee" );
447 Irssi::settings_add_str( "twirssi", "short_url_provider", "TinyURL" );
448 Irssi::settings_add_bool( "twirssi", "tweet_to_away",      0 );
449 Irssi::settings_add_bool( "twirssi", "show_reply_context", 0 );
450 Irssi::settings_add_bool( "twirssi", "show_own_tweets",    1 );
451 $window = Irssi::window_find_name( Irssi::settings_get_str('twitter_window') );
452 if ($window) {
453     Irssi::command_bind( "dm",             "cmd_direct" );
454     Irssi::command_bind( "tweet",          "cmd_tweet" );
455     Irssi::command_bind( "dm_as",          "cmd_direct_as" );
456     Irssi::command_bind( "tweet_as",       "cmd_tweet_as" );
457     Irssi::command_bind( "twitter_login",  "cmd_login" );
458     Irssi::command_bind( "twitter_logout", "cmd_logout" );
459     Irssi::command_bind( "twitter_switch", "cmd_switch" );
460     Irssi::command_bind(
461         "twirssi_version",
462         sub {
463             &notice(
464 "Twirssi v$VERSION (r$REV).  See details at http://tinyurl.com/twirssi"
465             );
466         }
467     );
468     Irssi::command_bind(
469         "twitter_friend",
470         &gen_cmd(
471             "/twitter_friend <username>",
472             "create_friend",
473             sub { &notice("Following $_[0]"); $nicks{ $_[0] } = time; }
474         )
475     );
476     Irssi::command_bind(
477         "twitter_unfriend",
478         &gen_cmd(
479             "/twitter_unfriend <username>",
480             "destroy_friend",
481             sub { &notice("Stopped following $_[0]"); delete $nicks{ $_[0] }; }
482         )
483     );
484     Irssi::command_bind( "twitter_updates", "get_updates" );
485     Irssi::signal_add_last( 'complete word' => \&sig_complete );
486
487     &notice("  %Y<%C(%B^%C)%N                   TWIRSSI v%R$VERSION%N (r$REV)");
488     &notice("   %C(_(\\%N        http://tinyurl.com/twirssi for full docs");
489     &notice(
490         "    %Y||%C `%N Log in with /twitter_login, send updates with /tweet");
491
492     if ( my $provider = Irssi::settings_get_str("short_url_provider") ) {
493         eval "use WWW::Shorten::$provider;";
494
495         if ($@) {
496             &notice(
497 "Failed to load WWW::Shorten::$provider - either clear short_url_provider or install the CPAN module"
498             );
499         }
500     }
501 } else {
502     Irssi::active_win()
503       ->print( "Create a window named "
504           . Irssi::settings_get_str('twitter_window')
505           . " or change the value of twitter_window.  Then, reload twirssi." );
506 }
507