1package WWW::Plurk;
2
3use warnings;
4use strict;
5
6use Carp;
7use DateTime::Format::Mail;
8use HTML::Tiny;
9use HTTP::Cookies;
10use JSON;
11use Data::Dumper;
12use LWP::UserAgent;
13use Time::Piece;
14use WWW::Plurk::Friend;
15use WWW::Plurk::Message;
16
17=head1 NAME
18
19WWW::Plurk - Unoffical plurk.com API
20
21=head1 VERSION
22
23This document describes WWW::Plurk version 0.02
24
25=cut
26
27our $VERSION = '0.02';
28
29=head1 SYNOPSIS
30
31    use WWW::Plurk;
32    my $plurk = WWW::Plurk->new;
33    $plurk->login( 'username', 'password' );
34    my $msg = $plurk->add_plurk( content => 'Hello, World' );
35
36=head1 DESCRIPTION
37
38This is an unofficial API for plurk.com. It uses the same interfaces
39that plurk itself uses internally which are not published and not
40necessarily stable. When Plurk publish a stable API this module will be
41updated to take advantage of it. In the mean time use with caution.
42
43Ryan Lim did the heavy lifting of reverse engineering the API. His PHP
44implementation can be found at L<http://code.google.com/p/rlplurkapi/>.
45
46If you'd like to lend a hand supporting the bits of Plurk that this API
47doesn't yet reach please feel free to send me a patch. The Plurk API
48Wiki at L<http://plurkwiki.badchemicals.net/> is a good source of
49information.
50
51=cut
52
53# Default API URIs
54
55use constant MAX_MESSAGE_LENGTH => 140;
56
57my $BASE_DEFAULT = 'http://www.plurk.com';
58
59my %PATH_DEFAULT = (
60    accept_friend     => '/Notifications/allow',
61    add_plurk         => '/TimeLine/addPlurk',
62    add_response      => '/Responses/add',
63    deny_friend       => '/Notifications/deny',
64    get_completion    => '/Users/getCompletion',
65    get_friends       => '/Users/getFriends',
66    get_plurks        => '/TimeLine/getPlurks',
67    get_responses     => '/Responses/get2',
68    get_unread_plurks => '/TimeLine/getUnreadPlurks',
69    home              => undef,
70    login             => '/Users/login?redirect_page=main',
71    notifications     => '/Notifications',
72);
73
74BEGIN {
75    my @ATTR = qw(
76      _base_uri
77      info
78      state
79      trace
80    );
81
82    my @INFO = qw(
83      display_name
84      full_name
85      gender
86      has_profile_image
87      id
88      is_channel
89      karma
90      location
91      nick_name
92      page_title
93      relationship
94      star_reward
95      uid
96    );
97
98    for my $attr ( @ATTR ) {
99        no strict 'refs';
100        *{$attr} = sub {
101            my $self = shift;
102            return $self->{$attr} unless @_;
103            return $self->{$attr} = shift;
104        };
105    }
106
107    for my $info ( @INFO ) {
108        no strict 'refs';
109        *{$info} = sub {
110            my $self = shift;
111            # Info attributes only available when logged in
112            $self->_logged_in;
113            return $self->info->{$info};
114        };
115    }
116}
117
118=head1 INTERFACE
119
120All methods throw errors in the event of any kind of failure. There's no
121need to check return values but you might want to wrap calls in an
122C<eval> block.
123
124=head2 C<< new >>
125
126Create a new C<< WWW::Plurk >>. Optionally accepts two arguments
127(username, password). If they are supplied it will attempt to login to
128Plurk. If no arguments are supplied C<login> must be called before
129attempting to access the service.
130
131    # Create and login
132    my $plurk = WWW::Plurk->new( 'user', 'pass' );
133
134    # Create then login afterwards
135    my $plurk = WWW::Plurk->new;
136    $plurk->login( 'user', 'pass' );
137
138=cut
139
140sub new {
141    my $class = shift;
142    my $self  = bless {
143        _base_uri => $BASE_DEFAULT,
144        path      => {%PATH_DEFAULT},
145        state     => 'init',
146        trace     => $ENV{PLURK_TRACE} ? 1 : 0,
147    }, $class;
148
149    if ( @_ ) {
150        croak "Need two arguments (user, pass) if any are supplied"
151          unless @_ == 2;
152        $self->login( @_ );
153    }
154
155    return $self;
156}
157
158sub _make_ua {
159    my $self = shift;
160    my $ua   = LWP::UserAgent->new;
161    $ua->agent( join ' ', __PACKAGE__, $VERSION );
162    $ua->cookie_jar( HTTP::Cookies->new );
163    return $ua;
164}
165
166sub _ua {
167    my $self = shift;
168    return $self->{_ua} ||= $self->_make_ua;
169}
170
171sub _trace {
172    my ( $self, @msgs ) = @_;
173    if ( $self->trace ) {
174        print STDERR "$_\n" for @msgs;
175    }
176}
177
178sub _raw_post {
179    my ( $self, $uri, $params ) = @_;
180    $self->_trace(
181        POST => $uri,
182        Data::Dumper->Dump( [$params], [qw($params)] )
183    );
184    my $resp = $self->_ua->post( $uri, $params );
185    $self->_trace( $resp->status_line );
186    return $resp;
187}
188
189sub _raw_get {
190    my ( $self, $uri ) = @_;
191    $self->_trace( GET => $uri );
192    my $resp = $self->_ua->get( $uri );
193    $self->_trace( $resp->status_line );
194    return $resp;
195}
196
197sub _cookies { shift->_ua->cookie_jar }
198
199sub _post {
200    my ( $self, $service, $params ) = @_;
201    my $resp
202      = $self->_raw_post( $self->_uri_for( $service ), $params || {} );
203    croak $resp->status_line
204      unless $resp->is_success
205          or $resp->is_redirect;
206    return $resp;
207}
208
209sub _json_post {
210    my $self = shift;
211    return $self->_decode_json( $self->_post( @_ )->content );
212}
213
214sub _get {
215    my ( $self, $service, $params ) = @_;
216    my $resp
217      = $self->_raw_get( $self->_uri_for( $service, $params || {} ) );
218    croak $resp->status_line
219      unless $resp->is_success
220          or $resp->is_redirect;
221    return $resp;
222}
223
224sub _json_get {
225    my $self = shift;
226    return $self->_decode_json( $self->_get( @_ )->content );
227}
228
229=head2 C<< login >>
230
231Attempt to login to a Plurk account. The two mandatory arguments are the
232username and password for the account to be accessed.
233
234    my $plurk = WWW::Plurk->new;
235    $plurk->login( 'user', 'pass' );
236
237=cut
238
239sub login {
240    my ( $self, $name, $pass ) = @_;
241
242    my $resp = $self->_post(
243        login => {
244            nick_name => $name,
245            password  => $pass,
246        }
247    );
248
249    my $ok = 0;
250    $self->_cookies->scan( sub { $ok++ if $_[1] eq 'plurkcookiea' } );
251    croak "Login for $name failed, no cookie returned"
252      unless $ok;
253
254    $self->_path_for( home => $resp->header( 'Location' )
255          || "/user/$name" );
256
257    $self->_parse_user_home;
258    $self->state( 'login' );
259}
260
261sub _parse_time {
262    my ( $self, $time ) = @_;
263    return DateTime::Format::Mail->parse_datetime( $time )->epoch;
264}
265
266# This is a bit of a bodge. Plurk doesn't return pure JSON; instead it
267# returns JavaScript that's nearly JSON apart from the fact that
268# timestamps are specified as 'new Date("...")'. So we need to hoist
269# those out of the text and replace them with the corresponding epoch
270# timestamp.
271#
272# Theoretically we could just do a search and replace. Because the Date
273# constructor contains a quoted string there's no danger of false
274# positives when someone happens to post a message that contains
275# matching text - because in that case the nested quotes would be
276# backslashed and the regex wouldn't match.
277#
278# Of course that didn't occur to me until /after/ I'd written the code
279# to pull all the string literals out of the text before replacing the
280# Date constructors...
281#
282# I'll leave that code in place because it's useful to have lying around
283# in case some future version of this routine has to handle embedded JS
284# that could collide with the contents of string literals.
285
286sub _decode_json {
287    my ( $self, $json ) = @_;
288
289    my %strings    = ();
290    my $next_token = 1;
291
292    my $tok = sub {
293        my $str = shift;
294        my $key = sprintf '#%d#', $next_token++;
295        $strings{$key} = $str;
296        return qq{"$key"};
297    };
298
299    # Stash string literals to avoid false positives
300    $json =~ s{ " ( (?: \\. | [^\\"]+ )* ) " }{ $tok->( $1 ) }xeg;
301
302    # Plurk actually returns JS rather than JSON.
303    $json =~ s{ new \s+ Date \s* \( \s* " (\#\d+\#) " \s* \) }
304        { $self->_parse_time( $strings{$1} ) }xeg;
305
306    # Replace string literals
307    $json =~ s{ " (\#\d+\#) " }{ qq{"$strings{$1}"} }xeg;
308
309    # Now we have JSON
310    return decode_json $json;
311}
312
313sub _parse_user_home {
314    my $self = shift;
315    my $resp = $self->_get( 'home' );
316    if ( $resp->content =~ /^\s*var\s+GLOBAL\s*=\s*(.+)$/m ) {
317        my $global = $self->_decode_json( $1 );
318        $self->info(
319            $global->{session_user}
320              or croak "No session_user data found"
321        );
322    }
323    else {
324        croak "Can't find GLOBAL data on user page";
325    }
326}
327
328=head2 C<< is_logged_in >>
329
330Returns a true value if we're currently logged in.
331
332    if ( $plurk->is_logged_in ) {
333        $plurk->add_plurk( content => 'w00t!' );
334    }
335
336=cut
337
338sub is_logged_in { shift->state eq 'login' }
339
340sub _logged_in {
341    my $self = shift;
342    croak "Please login first"
343      unless $self->is_logged_in;
344}
345
346=head2 C<< friends_for >>
347
348Return a user's friends.
349
350    my @friends = $plurk->friends_for( $uid );
351
352Pass the user id as either
353
354=over
355
356=item * an integer
357
358    my @friends = $plurk->friends_for( 12345 );
359
360=item * an object that has a method called C<uid>
361
362    # $some_user isa WWW::Plurk::Friend
363    my @friends = $plurk->friends_for( $some_user );
364
365=back
366
367Returns a list of L<WWW::Plurk::Friend> objects.
368
369=cut
370
371sub friends_for {
372    my $self = shift;
373    my $for = $self->_uid_cast( shift || $self );
374    $self->_logged_in;
375    my $friends
376      = $self->_json_get( get_completion => { user_id => $for } );
377    return map { WWW::Plurk::Friend->new( $self, $_, $friends->{$_} ) }
378      keys %$friends;
379}
380
381=head2 C<< friends >>
382
383Return the current user's friends. This
384
385    my @friends = $plurk->friends;
386
387is equivalent to
388
389    my @friends = $plurk->friends_for( $self->uid );
390
391=cut
392
393sub friends {
394    my $self = shift;
395    return $self->friends_for( $self );
396}
397
398=head2 C<< add_plurk >>
399
400Post a new plurk.
401
402    $plurk->add_plurk(
403        content => 'Hello, World'
404    );
405
406Arguments are supplied as a number of key, value pairs. The following
407arguments are recognised:
408
409=over
410
411=item * content - the message content
412
413=item * qualifier - the qualifier string ('is', 'says' etc)
414
415=item * lang - the (human) language for this Plurk
416
417=item * no_comments - true to disallow comments
418
419=item * limited_to - limit visibility
420
421=back
422
423The only mandatory argument is C<content> which should be a string of
424140 characters or fewer.
425
426C<qualifier> is first word of the message - which has special
427significance that you will understand if you have looked at the Plurk
428web interface. The following qualifiers are supported:
429
430  asks feels gives has hates is likes loves
431  says shares thinks wants was will wishes
432
433If omitted C<qualifier> defaults to ':' which signifies that you are
434posting a free-form message with no qualifier.
435
436C<lang> is the human language for this Plurk. It defaults to 'en'.
437Apologies to those posting in languages other than English.
438
439C<no_comments> should be true to lock the Plurk preventing comments from
440being made.
441
442C<limited_to> is an array of user ids (or objects with a method called
443C<uid>). If present the Plurk will only be visible to those users. To
444limit visibility of a Plurk to friends use:
445
446    my $msg = $plurk->add_plurk(
447        content => 'Hi chums',
448        limited_to => [ $plurk->friends ]
449    );
450
451Returns a L<WWW::Plurk::Message> representing the new Plurk.
452
453=cut
454
455sub _is_user {
456    my ( $self, $obj ) = @_;
457    return UNIVERSAL::can( $obj, 'can' ) && $obj->can( 'uid' );
458}
459
460sub _uid_cast {
461    my ( $self, $obj ) = @_;
462    return $self->_is_user( $obj ) ? $obj->uid : $obj;
463}
464
465sub _msg_common {
466    my ( $self, $cb, @args ) = @_;
467
468    croak "Needs a number of key => value pairs"
469      if @args & 1;
470    my %args = @args;
471
472    my $content = delete $args{content} || croak "Must have content";
473    my $lang    = delete $args{lang}    || 'en';
474    my $qualifier = delete $args{qualifier} || ':';
475
476    my @extras = $cb->( \%args );
477
478    if ( my @unknown = sort keys %args ) {
479        croak "Unknown parameter(s): ", join ',', @unknown;
480    }
481
482    if ( length $content > MAX_MESSAGE_LENGTH ) {
483        croak 'Plurks are limited to '
484          . MAX_MESSAGE_LENGTH
485          . ' characters';
486    }
487
488    return ( $content, $lang, $qualifier, @extras );
489}
490
491sub add_plurk {
492    my ( $self, @args ) = @_;
493
494    my ( $content, $lang, $qualifier, $no_comments, @limit )
495      = $self->_msg_common(
496        sub {
497            my $args        = shift;
498            my $no_comments = delete $args->{no_comments};
499            my @limit       = @{ delete $args->{limit} || [] };
500            return ( $no_comments, @limit );
501        },
502        @args
503      );
504
505    my $reply = $self->_json_post(
506        add_plurk => {
507            posted      => localtime()->datetime,
508            qualifier   => $qualifier,
509            content     => $content,
510            lang        => $lang,
511            uid         => $self->uid,
512            no_comments => ( $no_comments ? 1 : 0 ),
513            @limit
514            ? ( limited_to => '['
515                  . join( ',', map { $self->_uid_cast( $_ ) } @limit )
516                  . ']' )
517            : (),
518        }
519    );
520
521    if ( my $error = $reply->{error} ) {
522        croak "Error posting: $error";
523    }
524
525    return WWW::Plurk::Message->new( $self, $reply->{plurk} );
526}
527
528=head2 C<< plurks >>
529
530Get a list of recent Plurks for the logged in user. Returns an array of
531L<WWW::Plurk::Message> objects.
532
533    my @plurks = $plurk->plurks;
534
535Any arguments must be passed as key => value pairs. The following
536optional arguments are recognised:
537
538=over
539
540=item * uid - the user whose messages we want
541
542=item * date_from - the start date for retrieved messages
543
544=item * date_offset - er, not sure what this does :)
545
546=back
547
548As you may infer from the explanation of C<date_offset>, I'm not
549entirely sure how this interface works. I cargo-culted the options from
550the PHP version. If anyone can explain C<date_offset> please let me know
551and I'll update the documentation.
552
553=cut
554
555sub plurks {
556    my ( $self, @args ) = @_;
557    croak "Needs a number of key => value pairs"
558      if @args & 1;
559    my %args = @args;
560
561    my $uid = $self->_uid_cast( delete $args{uid} || $self );
562
563    my $date_from   = delete $args{date_from};
564    my $date_offset = delete $args{date_offset};
565
566    if ( my @extra = sort keys %args ) {
567        croak "Unknown parameter(s): ", join ',', @extra;
568    }
569
570    my $reply = $self->_json_post(
571        get_plurks => {
572            user_id => $uid,
573            defined $date_from
574            ? ( from_date => gmtime( $date_from )->datetime )
575            : (),
576            defined $date_offset
577            ? ( offset => gmtime( $date_offset )->datetime )
578            : (),
579        }
580    );
581
582    return
583      map { WWW::Plurk::Message->new( $self, $_ ) } @{ $reply || [] };
584}
585
586=head2 C<< unread_plurks >>
587
588Return a list of unread Plurks for the current user.
589
590=cut
591
592sub unread_plurks {
593    my $self = shift;
594    my $reply = $self->_json_post( get_unread_plurks => {} );
595    return
596      map { WWW::Plurk::Message->new( $self, $_ ) } @{ $reply || [] };
597}
598
599# Plurk returns an empty array rather than an empty hash if there
600# are no elements. D'you think it's written in PHP? :)
601#
602# (That's not a dig at PHP, but since arrays and hashes are the same
603# thing in PHP I assume the JSON encoder can't tell what an empty
604# hash/array is)
605
606sub _want_hash {
607    my ( $self, $hash, @keys ) = @_;
608    # Replace empty arrays with empty hashes at the top level of a hash.
609    for my $key ( @keys ) {
610        $hash->{$key} = {}
611          if !exists $hash->{$key}
612              || ( 'ARRAY' eq ref $hash->{$key}
613                  && @{ $hash->{$key} } == 0 );
614    }
615}
616
617=head2 C<< responses_for >>
618
619Get the responses for a Plurk. Returns a list of
620L<WWW::Plurk::Message> objects. Accepts a single argument which is the
621numeric ID of the Plurk whose responses we want.
622
623    my @responses = $plurk->responses_for( $msg->plurk_id );
624
625=cut
626
627sub responses_for {
628    my ( $self, $plurk_id ) = @_;
629
630    my $reply
631      = $self->_json_post( get_responses => { plurk_id => $plurk_id } );
632
633    $self->_want_hash( $reply, 'friends' );
634
635    my %friends = map {
636        $_ =>
637          WWW::Plurk::Friend->new( $self, $_, $reply->{friends}{$_} )
638    } keys %{ $reply->{friends} };
639
640    return map {
641        WWW::Plurk::Message->new( $self, $_, $friends{ $_->{user_id} } )
642    } @{ $reply->{responses} || [] };
643}
644
645=head2 C<< respond_to_plurk >>
646
647Post a response to an existing Plurk. The first argument must be the ID
648of the Plurk to respond to. Additional arguments are supplied as a
649number of key => value pairs. The following arguments are recognised:
650
651=over
652
653=item * content - the message content
654
655=item * qualifier - the qualifier string ('is', 'says' etc)
656
657=item * lang - the (human) language for this Plurk
658
659=back
660
661See C<add_plurk> for details of how these arguments are interpreted.
662
663    my $responce = $plurk->respond_to_plurk(
664        $plurk_id,
665        content => 'Nice!'
666    );
667
668Returns an L<WWW::Plurk::Message> representing the newly posted
669response.
670
671=cut
672
673sub respond_to_plurk {
674    my ( $self, $plurk_id, @args ) = @_;
675
676    my ( $content, $lang, $qualifier )
677      = $self->_msg_common( sub { () }, @args );
678
679    my $reply = $self->_json_post(
680        add_response => {
681            posted    => localtime()->datetime,
682            qualifier => $qualifier,
683            content   => $content,
684            lang      => $lang,
685            p_uid     => $self->uid,
686            plurk_id  => $plurk_id,
687            uid       => $self->uid,
688        }
689    );
690
691    if ( my $error = $reply->{error} ) {
692        croak "Error posting: $error";
693    }
694
695    return WWW::Plurk::Message->new( $self, $reply->{object} );
696}
697
698sub _path_for {
699    my ( $self, $service ) = ( shift, shift );
700    croak "Unknown service $service"
701      unless exists $PATH_DEFAULT{$service};
702    return $self->{path}{$service} unless @_;
703    return $self->{path}{$service} = shift;
704}
705
706sub _uri_for {
707    my ( $self, $service ) = ( shift, shift );
708    my $uri = $self->_path_for( $service );
709    $uri = $self->_base_uri . $uri if $uri !~ m{^http};
710    return $uri unless @_;
711    my $params = shift;
712    return join '?', $uri, HTML::Tiny->new->query_encode( $params );
713}
714
715=head2 Accessors
716
717The following accessors are available:
718
719=over
720
721=item * C<< info >> - the user info hash
722
723=item * C<< state >> - the state of this object (init or login)
724
725=item * C<< trace >> - set true to enable HTTP query tracing
726
727=item * C<< display_name >> - the user's display name
728
729=item * C<< full_name >> - the user's full name
730
731=item * C<< gender >> - the user's gender
732
733=item * C<< has_profile_image >> - has a profile image?
734
735=item * C<< id >> - appears to be a synonym for uid
736
737=item * C<< is_channel >> - unknown; anyone know?
738
739=item * C<< karma >> - user's karma score
740
741=item * C<< location >> - user's location
742
743=item * C<< nick_name >> - user's nick name
744
745=item * C<< page_title >> - unknown; anyone know?
746
747=item * C<< relationship >> - married, single, etc
748
749=item * C<< star_reward >> - ???
750
751=item * C<< uid >> - the user's ID
752
753=back
754
755=cut
756
7571;
758__END__
759
760=head1 CONFIGURATION AND ENVIRONMENT
761
762WWW::Plurk requires no configuration files or environment variables.
763
764=head1 DEPENDENCIES
765
766None.
767
768=head1 INCOMPATIBILITIES
769
770None reported.
771
772=head1 BUGS AND LIMITATIONS
773
774No bugs have been reported.
775
776Please report any bugs or feature requests to
777C<bug-www-plurk@rt.cpan.org>, or through the web interface at
778L<http://rt.cpan.org>.
779
780=head1 AUTHOR
781
782Andy Armstrong  C<< <andy.armstrong@messagesystems.com> >>
783
784L<< http://www.plurk.com/user/AndyArmstrong >>
785
786=head1 LICENCE AND COPYRIGHT
787
788This module is free software; you can redistribute it and/or
789modify it under the same terms as Perl itself. See L<perlartistic>.
790
791Copyright (c) 2008, Message Systems, Inc.
792All rights reserved.
793
794Redistribution and use in source and binary forms, with or
795without modification, are permitted provided that the following
796conditions are met:
797
798    * Redistributions of source code must retain the above copyright
799      notice, this list of conditions and the following disclaimer.
800    * Redistributions in binary form must reproduce the above copyright
801      notice, this list of conditions and the following disclaimer in
802      the documentation and/or other materials provided with the
803      distribution.
804    * Neither the name Message Systems, Inc. nor the names of its
805      contributors may be used to endorse or promote products derived
806      from this software without specific prior written permission.
807
808THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
809IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
810TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
811PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
812OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
813EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
814PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
815PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
816LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
817NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
818SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
819