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

Last change on this file since 140429f was 140429f, checked in by Nelson Elhage <nelhage@nelhage.com>, 8 years ago
Implement :twitter-favorite
  • Property mode set to 100644
File size: 12.9 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(strftime);
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 " . strftime('%c', 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        $self->twitter_command('update', {
374            status => $msg,
375            defined($reply_to) ? (in_reply_to_status_id => $reply_to) : ()
376           });
377    }
378    $self->poll_twitter if $self->{cfg}->{poll_for_tweets};
379}
380
381sub twitter_direct {
382    my $self = shift;
383
384    my $who = shift;
385    my $msg = _stripnl(shift);
386
387    if(defined $self->{twitter}) {
388        $self->twitter_command('new_direct_message', {
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,
399                private => 'true',
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)) {
414        $self->twitter($msg, $id);
415    } else {
416        $self->twitter($msg);
417    }
418}
419
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});
428    $self->poll_twitter if $self->{cfg}->{poll_for_tweets};
429}
430
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
442sub twitter_follow {
443    my $self = shift;
444
445    my $who = shift;
446
447    my $user = $self->twitter_command('create_friend', $who);
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
461    my $user = $self->twitter_command('destroy_friend', $who);
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
470sub nickname {
471    my $self = shift;
472    return $self->{cfg}->{account_nickname};
473}
474
4751;
Note: See TracBrowser for help on using the repository browser.