source: perl/modules/Twitter/lib/BarnOwl/Module/Twitter/Handle.pm @ 7aa1fa5

release-1.7release-1.8release-1.9
Last change on this file since 7aa1fa5 was 7aa1fa5, checked in by David Benjamin <davidben@mit.edu>, 11 years ago
Download Twitter consumer keys from barnowl.mit.edu [davidben@mit.edu: Fetch keys lazily in case Twitter is not configured.] Reviewed-by: Anders Kaseorg <andersk@mit.edu>
  • Property mode set to 100644
File size: 12.7 KB
Line 
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
16use Net::Twitter::Lite;
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}
25use HTML::Entities;
26
27use Scalar::Util qw(weaken);
28
29use BarnOwl;
30use BarnOwl::Message::Twitter;
31use POSIX qw(asctime);
32
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}
50
51sub fail {
52    my $self = shift;
53    my $msg = shift;
54    undef $self->{twitter};
55    my $nickname = $self->{cfg}->{account_nickname} || "";
56    die("[Twitter $nickname] Error: $msg\n");
57}
58
59sub new {
60    my $class = shift;
61    my $cfg = shift;
62
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
75
76    if (!defined($oauth_keys)) {
77        fetch_keys();
78    }
79    my $keys = $oauth_keys->{URI->new($cfg->{service})->canonical} || {};
80
81    $cfg = {
82        account_nickname => '',
83        default          => 0,
84        poll_for_tweets  => 1,
85        poll_for_dms     => 1,
86        publish_tweets   => 0,
87        show_mentions    => 1,
88        oauth_key        => $keys->{oauth_key},
89        oauth_secret     => $keys->{oauth_secret},
90        %$cfg,
91       };
92
93    my $self = {
94        'cfg'  => $cfg,
95        'twitter' => undef,
96        'last_id' => undef,
97        'last_direct' => undef,
98        'timer'        => undef,
99        'direct_timer' => undef
100    };
101
102    bless($self, $class);
103
104    my %twitter_args = @_;
105
106    my ($username, $password, $xauth);
107
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        }
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    }
131
132    my $timeline = eval { $self->{twitter}->home_timeline({count => 1}) };
133    warn "$@\n" if $@;
134
135    if(!defined($timeline) && !$xauth) {
136        $self->fail("Invalid credentials");
137    }
138
139    eval {
140        $self->{last_id} = $timeline->[0]{id};
141    };
142    $self->{last_id} = 1 unless defined($self->{last_id});
143
144    eval {
145        $self->{last_direct} = $self->{twitter}->direct_messages()->[0]{id};
146    };
147    warn "$@\n" if $@;
148    $self->{last_direct} = 1 unless defined($self->{last_direct});
149
150    eval {
151        $self->{twitter}->{ua}->timeout(1);
152    };
153    warn "$@\n" if $@;
154
155    $self->sleep(0);
156
157    return $self;
158}
159
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
170    my $weak = $self;
171    weaken($weak);
172
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
183    my $nickname = $self->{cfg}->{account_nickname};
184    if($self->{cfg}->{poll_for_tweets}) {
185        $self->{timer} = BarnOwl::Timer->new({
186            name     => "Twitter ($nickname) poll_for_tweets",
187            after    => $delay,
188            interval => 90,
189            cb       => sub { $weak->poll_twitter if $weak }
190           });
191    }
192
193    if($self->{cfg}->{poll_for_dms}) {
194        $self->{direct_timer} = BarnOwl::Timer->new({
195            name     => "Twitter ($nickname) poll_for_dms",
196            after    => $delay,
197            interval => 180,
198            cb       => sub { $weak->poll_direct if $weak }
199           });
200    }
201}
202
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
231sub twitter_error {
232    my $self = shift;
233
234    my $ratelimit = eval { $self->{twitter}->rate_limit_status };
235    BarnOwl::debug($@) if $@;
236    unless(defined($ratelimit) && ref($ratelimit) eq 'HASH') {
237        # Twitter's probably just sucking, try again later.
238        $self->sleep(5*60);
239        return;
240    }
241
242    if(exists($ratelimit->{remaining_hits})
243       && $ratelimit->{remaining_hits} <= 0) {
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}]" : "") .
249                        ": ratelimited until " . asctime(localtime($timeout)));
250    } elsif(exists($ratelimit->{error})) {
251        $self->sleep(60*20);
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
261    BarnOwl::debug("Polling " . $self->{cfg}->{account_nickname});
262
263    my $timeline = eval { $self->{twitter}->home_timeline( { since_id => $self->{last_id} } ) };
264    BarnOwl::debug($@) if $@;
265    unless(defined($timeline) && ref($timeline) eq 'ARRAY') {
266        $self->twitter_error();
267        return;
268    };
269
270    if ($self->{cfg}->{show_mentions}) {
271        my $mentions = eval { $self->{twitter}->mentions( { since_id => $self->{last_id} } ) };
272        BarnOwl::debug($@) if $@;
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
284    if ( scalar @$timeline ) {
285        for my $tweet ( reverse @$timeline ) {
286            if ( $tweet->{id} <= $self->{last_id} ) {
287                next;
288            }
289            my $orig = $tweet->{retweeted_status};
290            $orig = $tweet unless defined($orig);
291
292            my $msg = BarnOwl::Message->new(
293                type      => 'Twitter',
294                sender    => $orig->{user}{screen_name},
295                recipient => $self->{cfg}->{user} || $self->{user},
296                direction => 'in',
297                source    => decode_entities($orig->{source}),
298                location  => decode_entities($orig->{user}{location}||""),
299                body      => decode_entities($orig->{text}),
300                status_id => $tweet->{id},
301                service   => $self->{cfg}->{service},
302                account   => $self->{cfg}->{account_nickname},
303                $tweet->{retweeted_status} ? (retweeted_by => $tweet->{user}{screen_name}) : ()
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
318    BarnOwl::debug("Polling direct for " . $self->{cfg}->{account_nickname});
319
320    my $direct = eval { $self->{twitter}->direct_messages( { since_id => $self->{last_direct} } ) };
321    BarnOwl::debug($@) if $@;
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}),
338                private => 'true',
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
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
364sub twitter {
365    my $self = shift;
366
367    my $msg = _stripnl(shift);
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}) {
373        if(length($msg) > 140) {
374            die("Twitter: Message over 140 characters long.\n");
375        }
376        $self->twitter_command('update', {
377            status => $msg,
378            defined($reply_to) ? (in_reply_to_status_id => $reply_to) : ()
379           });
380    }
381    $self->poll_twitter if $self->{cfg}->{poll_for_tweets};
382}
383
384sub twitter_direct {
385    my $self = shift;
386
387    my $who = shift;
388    my $msg = _stripnl(shift);
389
390    if(defined $self->{twitter}) {
391        $self->twitter_command('new_direct_message', {
392            user => $who,
393            text => $msg
394           });
395        if(BarnOwl::getvar("displayoutgoing") eq 'on') {
396            my $tweet = BarnOwl::Message->new(
397                type      => 'Twitter',
398                sender    => $self->{cfg}->{user} || $self->{user},
399                recipient => $who, 
400                direction => 'out',
401                body      => $msg,
402                private => 'true',
403                service   => $self->{cfg}->{service},
404               );
405            BarnOwl::queue_message($tweet);
406        }
407    }
408}
409
410sub twitter_atreply {
411    my $self = shift;
412
413    my $to  = shift;
414    my $id  = shift;
415    my $msg = shift;
416    if(defined($id)) {
417        $self->twitter($msg, $id);
418    } else {
419        $self->twitter($msg);
420    }
421}
422
423sub twitter_retweet {
424    my $self = shift;
425    my $msg = shift;
426
427    if($msg->service ne $self->{cfg}->{service}) {
428        die("Cannot retweet a message from a different service.\n");
429    }
430    $self->twitter_command(retweet => $msg->{status_id});
431    $self->poll_twitter if $self->{cfg}->{poll_for_tweets};
432}
433
434sub twitter_follow {
435    my $self = shift;
436
437    my $who = shift;
438
439    my $user = $self->twitter_command('create_friend', $who);
440    # returns a string on error
441    if (defined $user && !ref $user) {
442        BarnOwl::message($user);
443    } else {
444        BarnOwl::message("Following " . $who);
445    }
446}
447
448sub twitter_unfollow {
449    my $self = shift;
450
451    my $who = shift;
452
453    my $user = $self->twitter_command('destroy_friend', $who);
454    # returns a string on error
455    if (defined $user && !ref $user) {
456        BarnOwl::message($user);
457    } else {
458        BarnOwl::message("No longer following " . $who);
459    }
460}
461
462sub nickname {
463    my $self = shift;
464    return $self->{cfg}->{account_nickname};
465}
466
4671;
Note: See TracBrowser for help on using the repository browser.