source: perl/modules/Twitter/lib/BarnOwl/Module/Twitter/Handle.pm @ 140429f

release-1.10
Last change on this file since 140429f was 140429f, checked in by Nelson Elhage <nelhage@nelhage.com>, 10 years ago
Implement :twitter-favorite
  • Property mode set to 100644
File size: 12.9 KB
RevLine 
[159aaad]1use warnings;
2use strict;
3
4=head1 NAME
5
6BarnOwl::Module::Twitter::Handle
7
8=head1 DESCRIPTION
9
10Contains everything needed to send and receive messages from a Twitter-like service.
11
12=cut
13
14package BarnOwl::Module::Twitter::Handle;
15
[0b13bbc]16use Net::Twitter::Lite;
[9876953]17BEGIN {
18    # Backwards compatibility with version of Net::Twitter::Lite that
19    # lack home_timeline.
20    if(!defined(*Net::Twitter::Lite::home_timeline{CODE})) {
21        *Net::Twitter::Lite::home_timeline =
22          \&Net::Twitter::Lite::friends_timeline;
23    }
24}
[159aaad]25use HTML::Entities;
26
[7424a5b]27use Scalar::Util qw(weaken);
28
[159aaad]29use BarnOwl;
30use BarnOwl::Message::Twitter;
[4ebbfbc]31use POSIX qw(strftime);
[7430aa4]32
[7aa1fa5]33use LWP::UserAgent;
34use URI;
35use JSON;
36
37use constant CONSUMER_KEY_URI => 'http://barnowl.mit.edu/twitter-keys';
38our $oauth_keys;
39
40sub fetch_keys {
41    my $ua = LWP::UserAgent->new;
42    $ua->timeout(5);
43    my $response = $ua->get(CONSUMER_KEY_URI);
44    if ($response->is_success) {
45        $oauth_keys = eval { from_json($response->decoded_content) };
46    } else {
47        warn "[Twitter] Unable to download OAuth keys: $response->status_line\n";
48    }
49}
[1eafdfa]50
[159aaad]51sub fail {
52    my $self = shift;
53    my $msg = shift;
54    undef $self->{twitter};
[7430aa4]55    my $nickname = $self->{cfg}->{account_nickname} || "";
56    die("[Twitter $nickname] Error: $msg\n");
[159aaad]57}
58
59sub new {
60    my $class = shift;
61    my $cfg = shift;
62
[385dd69]63    my $val;
64
65    if(!exists $cfg->{default} &&
66       defined($val = delete $cfg->{default_sender})) {
67        $cfg->{default} = $val;
68    }
69
70    if(!exists $cfg->{show_mentions} &&
71       defined($val = delete $cfg->{show_unsubscribed_replies})) {
72        $cfg->{show_mentions} = $val;
73    }
74
[7aa1fa5]75
76    if (!defined($oauth_keys)) {
77        fetch_keys();
78    }
79    my $keys = $oauth_keys->{URI->new($cfg->{service})->canonical} || {};
80
[efcd223]81    $cfg = {
82        account_nickname => '',
[385dd69]83        default          => 0,
[efcd223]84        poll_for_tweets  => 1,
85        poll_for_dms     => 1,
[26f9e2e]86        publish_tweets   => 0,
[385dd69]87        show_mentions    => 1,
[7aa1fa5]88        oauth_key        => $keys->{oauth_key},
89        oauth_secret     => $keys->{oauth_secret},
[8c6e2c1]90        %$cfg,
[efcd223]91       };
92
[7430aa4]93    my $self = {
[159aaad]94        'cfg'  => $cfg,
95        'twitter' => undef,
96        'last_id' => undef,
97        'last_direct' => undef,
[36546fa]98        'timer'        => undef,
99        'direct_timer' => undef
[7430aa4]100    };
101
102    bless($self, $class);
[159aaad]103
104    my %twitter_args = @_;
105
[1eafdfa]106    my ($username, $password, $xauth);
107
[10dd8e6]108    if ($cfg->{service} eq 'http://twitter.com') {
109        BarnOwl::debug('Checking for xAuth support in Net::Twitter');
110        if (*Net::Twitter::Lite::xauth{CODE}) {
111            $xauth = 1;
112            $username = delete $twitter_args{username};
113            $password = delete $twitter_args{password};
114            $twitter_args{consumer_key}    = $cfg->{oauth_key};
115            $twitter_args{consumer_secret} = $cfg->{oauth_secret};
116        } else {
117            BarnOwl::error("Please upgrade your version of Net::Twitter::Lite to support xAuth.");
118        }
[1eafdfa]119    }
120
121    $self->{twitter}  = Net::Twitter::Lite->new(%twitter_args,);
122
123    if ($xauth){
124        eval {
125            $self->{twitter}->xauth($username, $password);
126        };
127        if($@) {
128            $self->fail("Invalid credentials: $@");
129        }
130    }
[159aaad]131
[9876953]132    my $timeline = eval { $self->{twitter}->home_timeline({count => 1}) };
[0948fa0f]133    warn "$@\n" if $@;
[a8a0b0a]134
[1eafdfa]135    if(!defined($timeline) && !$xauth) {
[7430aa4]136        $self->fail("Invalid credentials");
[159aaad]137    }
138
[a8a0b0a]139    eval {
140        $self->{last_id} = $timeline->[0]{id};
141    };
142    $self->{last_id} = 1 unless defined($self->{last_id});
[159aaad]143
[a8a0b0a]144    eval {
145        $self->{last_direct} = $self->{twitter}->direct_messages()->[0]{id};
146    };
[0948fa0f]147    warn "$@\n" if $@;
[a8a0b0a]148    $self->{last_direct} = 1 unless defined($self->{last_direct});
[159aaad]149
150    eval {
[7430aa4]151        $self->{twitter}->{ua}->timeout(1);
[159aaad]152    };
[0948fa0f]153    warn "$@\n" if $@;
[159aaad]154
[36546fa]155    $self->sleep(0);
156
[7430aa4]157    return $self;
[159aaad]158}
159
[36546fa]160=head2 sleep SECONDS
161
162Stop polling Twitter for SECONDS seconds.
163
164=cut
165
166sub sleep {
167    my $self  = shift;
168    my $delay = shift;
169
[e4951ea]170    my $weak = $self;
171    weaken($weak);
[7424a5b]172
[c8d9f84]173    # Stop any existing timers.
174    if (defined $self->{timer}) {
175        $self->{timer}->stop;
176        $self->{timer} = undef;
177    }
178    if (defined $self->{direct_timer}) {
179        $self->{direct_timer}->stop;
180        $self->{direct_timer} = undef;
181    }
182
[c6adf17]183    my $nickname = $self->{cfg}->{account_nickname};
[36546fa]184    if($self->{cfg}->{poll_for_tweets}) {
185        $self->{timer} = BarnOwl::Timer->new({
[c6adf17]186            name     => "Twitter ($nickname) poll_for_tweets",
[36546fa]187            after    => $delay,
[f2adf6c]188            interval => 90,
[7424a5b]189            cb       => sub { $weak->poll_twitter if $weak }
[36546fa]190           });
191    }
192
193    if($self->{cfg}->{poll_for_dms}) {
194        $self->{direct_timer} = BarnOwl::Timer->new({
[c6adf17]195            name     => "Twitter ($nickname) poll_for_dms",
[36546fa]196            after    => $delay,
[f2adf6c]197            interval => 180,
[7424a5b]198            cb       => sub { $weak->poll_direct if $weak }
[36546fa]199           });
200    }
201}
202
[118d800]203=head2 twitter_command COMMAND ARGS...
204
205Call the specified method on $self->{twitter} with an extended
206timeout. This is intended for interactive commands, with the theory
207that if the user explicitly requested an action, it is slightly more
208acceptable to hang the UI for a second or two than to fail just
209because Twitter is being slightly slow. Polling commands should be
210called normally, with the default (short) timeout, to prevent
211background Twitter suckage from hosing the UI normally.
212
213=cut
214
215sub twitter_command {
216    my $self = shift;
217    my $cmd = shift;
218
219    eval { $self->{twitter}->{ua}->timeout(5); };
220    my $result = eval {
221        $self->{twitter}->$cmd(@_);
222    };
223    my $error = $@;
224    eval { $self->{twitter}->{ua}->timeout(1); };
225    if ($error) {
226        die($error);
227    }
228    return $result;
229}
230
[159aaad]231sub twitter_error {
232    my $self = shift;
233
[82e0f26]234    my $ratelimit = eval { $self->{twitter}->rate_limit_status };
[c9bdb46]235    BarnOwl::debug($@) if $@;
[159aaad]236    unless(defined($ratelimit) && ref($ratelimit) eq 'HASH') {
[36546fa]237        # Twitter's probably just sucking, try again later.
[d69c37c]238        $self->sleep(5*60);
[159aaad]239        return;
240    }
[36546fa]241
[159aaad]242    if(exists($ratelimit->{remaining_hits})
243       && $ratelimit->{remaining_hits} <= 0) {
[d389947]244        my $timeout = $ratelimit->{reset_time_in_seconds};
245        $self->sleep($timeout - time + 60);
246        BarnOwl::error("Twitter" .
247                       ($self->{cfg}->{account_nickname} ?
248                        "[$self->{cfg}->{account_nickname}]" : "") .
[4ebbfbc]249                        ": ratelimited until " . strftime('%c', localtime($timeout)));
[159aaad]250    } elsif(exists($ratelimit->{error})) {
[36546fa]251        $self->sleep(60*20);
[159aaad]252        die("Twitter: ". $ratelimit->{error} . "\n");
253    }
254}
255
256sub poll_twitter {
257    my $self = shift;
258
259    return unless BarnOwl::getvar('twitter:poll') eq 'on';
260
[36546fa]261    BarnOwl::debug("Polling " . $self->{cfg}->{account_nickname});
262
[9876953]263    my $timeline = eval { $self->{twitter}->home_timeline( { since_id => $self->{last_id} } ) };
[c9bdb46]264    BarnOwl::debug($@) if $@;
[159aaad]265    unless(defined($timeline) && ref($timeline) eq 'ARRAY') {
266        $self->twitter_error();
267        return;
268    };
[5da6ed8]269
[385dd69]270    if ($self->{cfg}->{show_mentions}) {
[82e0f26]271        my $mentions = eval { $self->{twitter}->mentions( { since_id => $self->{last_id} } ) };
[c9bdb46]272        BarnOwl::debug($@) if $@;
[5da6ed8]273        unless (defined($mentions) && ref($mentions) eq 'ARRAY') {
274            $self->twitter_error();
275            return;
276        };
277        #combine, sort by id, and uniq
278        push @$timeline, @$mentions;
279        @$timeline = sort { $b->{id} <=> $a->{id} } @$timeline;
280        my $prev = { id => 0 };
281        @$timeline = grep($_->{id} != $prev->{id} && (($prev) = $_), @$timeline);
282    }
283
[159aaad]284    if ( scalar @$timeline ) {
285        for my $tweet ( reverse @$timeline ) {
286            if ( $tweet->{id} <= $self->{last_id} ) {
287                next;
288            }
[5d59c1e]289            my $orig = $tweet->{retweeted_status};
290            $orig = $tweet unless defined($orig);
291
[159aaad]292            my $msg = BarnOwl::Message->new(
293                type      => 'Twitter',
[5d59c1e]294                sender    => $orig->{user}{screen_name},
[159aaad]295                recipient => $self->{cfg}->{user} || $self->{user},
296                direction => 'in',
[5d59c1e]297                source    => decode_entities($orig->{source}),
298                location  => decode_entities($orig->{user}{location}||""),
299                body      => decode_entities($orig->{text}),
[159aaad]300                status_id => $tweet->{id},
301                service   => $self->{cfg}->{service},
302                account   => $self->{cfg}->{account_nickname},
[5d59c1e]303                $tweet->{retweeted_status} ? (retweeted_by => $tweet->{user}{screen_name}) : ()
[159aaad]304               );
305            BarnOwl::queue_message($msg);
306        }
307        $self->{last_id} = $timeline->[0]{id} if $timeline->[0]{id} > $self->{last_id};
308    } else {
309        # BarnOwl::message("No new tweets...");
310    }
311}
312
313sub poll_direct {
314    my $self = shift;
315
316    return unless BarnOwl::getvar('twitter:poll') eq 'on';
317
[36546fa]318    BarnOwl::debug("Polling direct for " . $self->{cfg}->{account_nickname});
319
[82e0f26]320    my $direct = eval { $self->{twitter}->direct_messages( { since_id => $self->{last_direct} } ) };
[c9bdb46]321    BarnOwl::debug($@) if $@;
[159aaad]322    unless(defined($direct) && ref($direct) eq 'ARRAY') {
323        $self->twitter_error();
324        return;
325    };
326    if ( scalar @$direct ) {
327        for my $tweet ( reverse @$direct ) {
328            if ( $tweet->{id} <= $self->{last_direct} ) {
329                next;
330            }
331            my $msg = BarnOwl::Message->new(
332                type      => 'Twitter',
333                sender    => $tweet->{sender}{screen_name},
334                recipient => $self->{cfg}->{user} || $self->{user},
335                direction => 'in',
336                location  => decode_entities($tweet->{sender}{location}||""),
337                body      => decode_entities($tweet->{text}),
[c78d06f]338                private => 'true',
[159aaad]339                service   => $self->{cfg}->{service},
340                account   => $self->{cfg}->{account_nickname},
341               );
342            BarnOwl::queue_message($msg);
343        }
344        $self->{last_direct} = $direct->[0]{id} if $direct->[0]{id} > $self->{last_direct};
345    } else {
346        # BarnOwl::message("No new tweets...");
347    }
348}
349
[538a5f7]350sub _stripnl {
351    my $msg = shift;
352
353    # strip non-newline whitespace around newlines
354    $msg =~ s/[^\n\S]*(\n+)[^\n\S]*/$1/sg;
355    # change single newlines to a single space; leave multiple newlines
356    $msg =~ s/([^\n])\n([^\n])/$1 $2/sg;
357    # strip leading and trailing whitespace
358    $msg =~ s/\s+$//s;
359    $msg =~ s/^\s+//s;
360
361    return $msg;
362}
363
[159aaad]364sub twitter {
365    my $self = shift;
366
[538a5f7]367    my $msg = _stripnl(shift);
[159aaad]368    my $reply_to = shift;
369
370    if($msg =~ m{\Ad\s+([^\s])+(.*)}sm) {
371        $self->twitter_direct($1, $2);
372    } elsif(defined $self->{twitter}) {
[118d800]373        $self->twitter_command('update', {
374            status => $msg,
375            defined($reply_to) ? (in_reply_to_status_id => $reply_to) : ()
376           });
[159aaad]377    }
[bdb7c26]378    $self->poll_twitter if $self->{cfg}->{poll_for_tweets};
[159aaad]379}
380
381sub twitter_direct {
382    my $self = shift;
383
384    my $who = shift;
[538a5f7]385    my $msg = _stripnl(shift);
386
[159aaad]387    if(defined $self->{twitter}) {
[118d800]388        $self->twitter_command('new_direct_message', {
[159aaad]389            user => $who,
390            text => $msg
391           });
392        if(BarnOwl::getvar("displayoutgoing") eq 'on') {
393            my $tweet = BarnOwl::Message->new(
394                type      => 'Twitter',
395                sender    => $self->{cfg}->{user} || $self->{user},
396                recipient => $who, 
397                direction => 'out',
398                body      => $msg,
[c78d06f]399                private => 'true',
[159aaad]400                service   => $self->{cfg}->{service},
401               );
402            BarnOwl::queue_message($tweet);
403        }
404    }
405}
406
407sub twitter_atreply {
408    my $self = shift;
409
410    my $to  = shift;
411    my $id  = shift;
412    my $msg = shift;
413    if(defined($id)) {
[a2640485]414        $self->twitter($msg, $id);
[159aaad]415    } else {
[a2640485]416        $self->twitter($msg);
[159aaad]417    }
418}
419
[5214546]420sub twitter_retweet {
421    my $self = shift;
422    my $msg = shift;
423
424    if($msg->service ne $self->{cfg}->{service}) {
425        die("Cannot retweet a message from a different service.\n");
426    }
427    $self->twitter_command(retweet => $msg->{status_id});
[bdb7c26]428    $self->poll_twitter if $self->{cfg}->{poll_for_tweets};
[5214546]429}
430
[140429f]431sub twitter_favorite {
432    my $self = shift;
433    my $msg = shift;
434
435    if($msg->service ne $self->{cfg}->{service}) {
436        die("Cannot favorite a message from a different service.\n");
437    }
438    $self->twitter_command(create_favorite => $msg->{status_id});
439}
440
441
[513da71]442sub twitter_follow {
443    my $self = shift;
444
445    my $who = shift;
446
[118d800]447    my $user = $self->twitter_command('create_friend', $who);
[513da71]448    # returns a string on error
449    if (defined $user && !ref $user) {
450        BarnOwl::message($user);
451    } else {
452        BarnOwl::message("Following " . $who);
453    }
454}
455
456sub twitter_unfollow {
457    my $self = shift;
458
459    my $who = shift;
460
[118d800]461    my $user = $self->twitter_command('destroy_friend', $who);
[513da71]462    # returns a string on error
463    if (defined $user && !ref $user) {
464        BarnOwl::message($user);
465    } else {
466        BarnOwl::message("No longer following " . $who);
467    }
468}
469
[8462b38]470sub nickname {
471    my $self = shift;
472    return $self->{cfg}->{account_nickname};
473}
474
[159aaad]4751;
Note: See TracBrowser for help on using the repository browser.