source: perl/modules/Twitter/lib/BarnOwl/Module/Twitter/Handle.pm @ 834dc31

Last change on this file since 834dc31 was 834dc31, checked in by Alex Dehnert <adehnert@mit.edu>, 7 years ago
Add tweet URL to the "i" popup AFAICT, the info popup just shows the attributes of the perl message object, so we set an otherwise-unused "url" attribute on the message. Sadly, I don't think there's a mechanism for displaying the return value of a method instead.
  • Property mode set to 100644
File size: 13.1 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 $url = $self->{cfg}->{service} . '/' . $orig->{user}{screen_name} . '/status/' . $tweet->{id};
293            my $msg = BarnOwl::Message->new(
294                type      => 'Twitter',
295                sender    => $orig->{user}{screen_name},
296                recipient => $self->{cfg}->{user} || $self->{user},
297                direction => 'in',
298                source    => decode_entities($orig->{source}),
299                location  => decode_entities($orig->{user}{location}||""),
300                body      => decode_entities($orig->{text}),
301                status_id => $tweet->{id},
302                url       => $url,
303                service   => $self->{cfg}->{service},
304                account   => $self->{cfg}->{account_nickname},
305                $tweet->{retweeted_status} ? (retweeted_by => $tweet->{user}{screen_name}) : ()
306               );
307            BarnOwl::queue_message($msg);
308        }
309        $self->{last_id} = $timeline->[0]{id} if $timeline->[0]{id} > $self->{last_id};
310    } else {
311        # BarnOwl::message("No new tweets...");
312    }
313}
314
315sub poll_direct {
316    my $self = shift;
317
318    return unless BarnOwl::getvar('twitter:poll') eq 'on';
319
320    BarnOwl::debug("Polling direct for " . $self->{cfg}->{account_nickname});
321
322    my $direct = eval { $self->{twitter}->direct_messages( { since_id => $self->{last_direct} } ) };
323    BarnOwl::debug($@) if $@;
324    unless(defined($direct) && ref($direct) eq 'ARRAY') {
325        $self->twitter_error();
326        return;
327    };
328    if ( scalar @$direct ) {
329        for my $tweet ( reverse @$direct ) {
330            if ( $tweet->{id} <= $self->{last_direct} ) {
331                next;
332            }
333            my $msg = BarnOwl::Message->new(
334                type      => 'Twitter',
335                sender    => $tweet->{sender}{screen_name},
336                recipient => $self->{cfg}->{user} || $self->{user},
337                direction => 'in',
338                location  => decode_entities($tweet->{sender}{location}||""),
339                body      => decode_entities($tweet->{text}),
340                private => 'true',
341                service   => $self->{cfg}->{service},
342                account   => $self->{cfg}->{account_nickname},
343               );
344            BarnOwl::queue_message($msg);
345        }
346        $self->{last_direct} = $direct->[0]{id} if $direct->[0]{id} > $self->{last_direct};
347    } else {
348        # BarnOwl::message("No new tweets...");
349    }
350}
351
352sub _stripnl {
353    my $msg = shift;
354
355    # strip non-newline whitespace around newlines
356    $msg =~ s/[^\n\S]*(\n+)[^\n\S]*/$1/sg;
357    # change single newlines to a single space; leave multiple newlines
358    $msg =~ s/([^\n])\n([^\n])/$1 $2/sg;
359    # strip leading and trailing whitespace
360    $msg =~ s/\s+$//s;
361    $msg =~ s/^\s+//s;
362
363    return $msg;
364}
365
366sub twitter {
367    my $self = shift;
368
369    my $msg = _stripnl(shift);
370    my $reply_to = shift;
371
372    if($msg =~ m{\Ad\s+([^\s])+(.*)}sm) {
373        $self->twitter_direct($1, $2);
374    } elsif(defined $self->{twitter}) {
375        $self->twitter_command('update', {
376            status => $msg,
377            defined($reply_to) ? (in_reply_to_status_id => $reply_to) : ()
378           });
379    }
380    $self->poll_twitter if $self->{cfg}->{poll_for_tweets};
381}
382
383sub twitter_direct {
384    my $self = shift;
385
386    my $who = shift;
387    my $msg = _stripnl(shift);
388
389    if(defined $self->{twitter}) {
390        $self->twitter_command('new_direct_message', {
391            user => $who,
392            text => $msg
393           });
394        if(BarnOwl::getvar("displayoutgoing") eq 'on') {
395            my $tweet = BarnOwl::Message->new(
396                type      => 'Twitter',
397                sender    => $self->{cfg}->{user} || $self->{user},
398                recipient => $who, 
399                direction => 'out',
400                body      => $msg,
401                private => 'true',
402                service   => $self->{cfg}->{service},
403               );
404            BarnOwl::queue_message($tweet);
405        }
406    }
407}
408
409sub twitter_atreply {
410    my $self = shift;
411
412    my $to  = shift;
413    my $id  = shift;
414    my $msg = shift;
415    if(defined($id)) {
416        $self->twitter($msg, $id);
417    } else {
418        $self->twitter($msg);
419    }
420}
421
422sub twitter_retweet {
423    my $self = shift;
424    my $msg = shift;
425
426    if($msg->service ne $self->{cfg}->{service}) {
427        die("Cannot retweet a message from a different service.\n");
428    }
429    $self->twitter_command(retweet => $msg->{status_id});
430    $self->poll_twitter if $self->{cfg}->{poll_for_tweets};
431}
432
433sub twitter_favorite {
434    my $self = shift;
435    my $msg = shift;
436
437    if($msg->service ne $self->{cfg}->{service}) {
438        die("Cannot favorite a message from a different service.\n");
439    }
440    $self->twitter_command(create_favorite => $msg->{status_id});
441}
442
443
444sub twitter_follow {
445    my $self = shift;
446
447    my $who = shift;
448
449    my $user = $self->twitter_command('create_friend', $who);
450    # returns a string on error
451    if (defined $user && !ref $user) {
452        BarnOwl::message($user);
453    } else {
454        BarnOwl::message("Following " . $who);
455    }
456}
457
458sub twitter_unfollow {
459    my $self = shift;
460
461    my $who = shift;
462
463    my $user = $self->twitter_command('destroy_friend', $who);
464    # returns a string on error
465    if (defined $user && !ref $user) {
466        BarnOwl::message($user);
467    } else {
468        BarnOwl::message("No longer following " . $who);
469    }
470}
471
472sub nickname {
473    my $self = shift;
474    return $self->{cfg}->{account_nickname};
475}
476
4771;
Note: See TracBrowser for help on using the repository browser.