App-bsky

 view release on metacpan or  search on metacpan

lib/App/bsky.pm  view on Meta::CPAN

                }
                else {
                    $self->err('unsure what to do with this banner; does not seem to be a URL or local file');
                }
                if ($banner) {
                    $self->say( 'uploaded banner... %d bytes', $banner->{size} );
                }
                else {
                    $self->say('failed to upload banner');
                }
            }
            my $res = $bsky->at->put_record(
                'app.bsky.actor.profile',
                'self',
                {   defined $displayName ? ( displayName => $displayName ) : (),
                    defined $description ? ( description => $description ) : (),
                    defined $avatar      ? ( avatar      => $avatar )      : (),
                    defined $banner      ? ( banner      => $banner )      : ()
                }
            );
            defined $res->{uri} ? $self->say( $res->{uri}->as_string ) : $self->err( $res->{message} );
        }

        method cmd_oauth ( $handle, @args ) {
            my $cli = $self;
            GetOptionsFromArray( \@args, 'redirect=s' => \my $redirect );
            $bsky->oauth_helper(
                handle => $handle,
                listen => 1,
                defined $redirect ? ( redirect => $redirect ) : (),
                on_success => sub ($bsky_obj) {
                    $config->{resume}  = $bsky_obj->session->_raw;
                    $config->{session} = $bsky_obj->session->_raw;
                    $cli->put_config;
                    $cli->say( "Authenticated as " . $bsky_obj->did );
                }
            );
        }

        method cmd_showsession (@args) {
            GetOptionsFromArray( \@args, 'json!' => \my $json );
            my $session = $bsky->session;
            unless ($session) {
                return $self->err("No active session. Run 'bsky oauth <handle>' or 'bsky login' first.");
            }
            if ($json) {
                $self->say( JSON::Tiny::to_json( $session->_raw ) );
            }
            else {
                $self->say( 'DID:    ' . $session->did );
                $self->say( 'Handle: ' . $session->handle );
                $self->say( 'Email:  ' . ( $session->email // 'N/A' ) );
                $self->say( 'Type:   ' . $session->token_type );
                $self->say( 'Scopes: ' . ( $session->scope // 'N/A' ) );
            }
            return 1;
        }

        method _dump_post ( $depth, $post ) {
            if ( builtin::blessed $post ) {
                if ( $post->isa('At::Lexicon::app::bsky::feed::threadViewPost') && builtin::blessed $post->parent ) {
                    $self->_dump_post( $depth++, $post->parent );
                    $post = $post->post;
                }
                elsif ( $post->isa('At::Lexicon::app::bsky::feed::threadViewPost') ) {
                    $self->_dump_post( $depth++, $post->post );
                    my $replies = $post->replies // [];
                    $self->_dump_post( $depth + 2, $_->post ) for @$replies;
                    return;
                }
            }

            #~ warn ref $post;
            #~ use Data::Dump;
            #~ ddx $post;
            # TODO: Support image embeds as raw links
            $self->say(
                '%s%s%s%s%s (%s)',
                ' ' x ( $depth * 4 ),
                color('red'), $post->{author}{handle},
                color('reset'),
                defined $post->{author}{displayName} ? ' [' . $post->{author}{displayName} . ']' : '',
                $post->{record}{createdAt}
            );
            if ( $post->{embed} && defined $post->{embed}{images} ) {    # TODO: Check $post->embed->$type to match 'app.bsky.embed.images#view'
                $self->say( '%s%s', ' ' x ( $depth * 4 ), $_->{fullsize} ) for @{ $post->{embed}{images} };
            }
            $self->say( '%s%s', ' ' x ( $depth * 4 ), $post->{record}{text} );
            $self->say(
                '%s ❤️ %d 💬 %d 🔄 %d %s',
                ' ' x ( $depth * 4 ),
                $post->{likeCount}, $post->{replyCount}, $post->{repostCount},
                ( builtin::blessed $post->{uri} ? $post->{uri}->as_string : $post->{uri} )
            );
            $self->say( '%s', ' ' x ( $depth * 4 ) );
        }

        method cmd_timeline (@args) {
            GetOptionsFromArray( \@args, 'json!' => \my $json );
            my $tl = $bsky->getTimeline();
            if ( builtin::blessed $tl && $tl->isa('At::Error') ) {
                return $self->err( "Error fetching timeline: " . $tl->message );
            }
            unless ( $tl && $tl->{feed} ) {
                return $self->say("Timeline is empty.");
            }
            if ($json) {
                $self->say( JSON::Tiny::to_json( $tl->{feed} ) );
            }
            else {
                for my $item ( @{ $tl->{feed} } ) {
                    my $depth = 0;
                    if ( $item->{reply} && $item->{reply}{parent} ) {
                        $self->_dump_post( $depth, $item->{reply}{parent} );
                        $depth = 1;
                    }
                    $self->_dump_post( $depth, $item->{post} );
                }
            }
            return scalar @{ $tl->{feed} };
        }
        method cmd_tl (@args) { $self->cmd_timeline(@args); }

        method cmd_stream(@args) {
            GetOptionsFromArray( \@args, 'json|j' => \my $json );

lib/App/bsky.pm  view on Meta::CPAN

                                return;
                            }

                            # Only process commit events for now
                            unless ( defined $header->{t} && $header->{t} eq '#commit' ) {
                                return;
                            }
                            for my $op ( @{ $body->{ops} } ) {
                                next unless $op->{action} eq 'create';
                                next unless $op->{path} =~ /^app\.bsky\.feed\.post\//;
                                try {
                                    # Decode the blocks to find the record
                                    require Archive::CAR::v1;
                                    my $car = Archive::CAR::v1->new();
                                    open my $cfh, '<:raw', \$body->{blocks};
                                    my %blocks = map { $_->{cid}->to_string => $_->{data} } $car->read($cfh)->blocks->@*;
                                    require Archive::CAR::CID;    # Ensure it's loaded for conversion
                                    my $cid_raw = $op->{cid};
                                    if ( ref $cid_raw eq 'HASH' && exists $cid_raw->{cid_raw} ) {
                                        $cid_raw = $cid_raw->{cid_raw};
                                    }
                                    my $target_cid_obj = Archive::CAR::CID->from_raw($cid_raw);
                                    my $record_bytes   = $blocks{ $target_cid_obj->to_string };
                                    next unless $record_bytes;
                                    require Codec::CBOR;
                                    my $codec  = Codec::CBOR->new();
                                    my $record = $codec->decode($record_bytes);
                                    next unless $record;
                                    my $repo = $body->{repo};
                                    my $ts   = $record->{createdAt} // '';
                                    $ts =~ s/T/ /;
                                    $ts =~ s/\..*Z//;

                                    # Queue for later rendering
                                    push @post_queue, { repo => $repo, record => $record, ts => $ts };
                                    $dids_to_resolve{$repo} = 1 unless exists $profile_cache{$repo};
                                    if ( $record->{reply} && $record->{reply}{parent} ) {
                                        my $parent_uri = $record->{reply}{parent}{uri};
                                        if ( $parent_uri =~ m[^at://(did:[^/]+)] ) {
                                            my $parent_did = $1;
                                            $dids_to_resolve{$parent_did} = 1 unless exists $profile_cache{$parent_did};
                                        }
                                    }
                                }
                                catch ($e) {
                                    warn "CAR/CBOR decoding error for op on repo " . $body->{repo} . ": $e";
                                }
                            }
                        }
                        catch ($e) {
                            warn "Error processing firehose event: $e";
                        }
                    }
                );
                $fh->start();
            };
            $start_stream->();
            Mojo::IOLoop->start unless Mojo::IOLoop->is_running;
        }

        method cmd_thread (@args) {
            GetOptionsFromArray( \@args, 'json!' => \my $json, 'n=i' => \my $number );
            $number //= ();
            my ($id) = @args;
            $id // return $self->cmd_help('thread');
            my $res = $bsky->getPostThread( uri => $id, depth => $number, parentHeight => $number );    # $uri, depth, $parentHeight
            return unless $res->{thread};
            return $self->say( JSON::Tiny::to_json $res->{thread} ) if $json;
            $self->_dump_post( 0, $res->{thread} );
        }

        method cmd_post ($text) {
            my $res = $bsky->createPost( text => $text );
            defined $res ? $self->say( $res->{uri} ) : 0;
        }

        method cmd_delete ($uri) {
            $uri = At::Protocol::URI->new($uri) unless builtin::blessed $uri;
            $bsky->at->delete_record( $uri->collection, $uri->rkey );
        }

        # TODO
        method cmd_like ( $uri, @args ) {    # can take the post uri
            GetOptionsFromArray( \@args, 'json!' => \my $json, 'cid=s' => \my $cid );
            my $res = $bsky->like( $uri, $cid );
            $res || $res->throw;
            $self->say( $json ? JSON::Tiny::to_json($res) : sprintf 'Liked! [id:%s]', $res->{uri}->as_string );
        }

        # TODO
        method cmd_unlike ( $uri, @args ) {    # can take the post uri or the like uri
            GetOptionsFromArray( \@args, 'json!' => \my $json, 'cid=s' => \my $cid );
            my $res = $bsky->deleteLike($uri);
            $res || $res->throw;
            $self->say( $json ? JSON::Tiny::to_json($res) : sprintf 'Removed like!' );
        }

        # TODO
        method cmd_likes ( $uri, @args ) {
            GetOptionsFromArray( \@args, 'json!' => \my $json );
            my @likes;
            my $cursor = ();
            do {
                my $likes = $bsky->at->get( 'app.bsky.feed.getLikes', { uri => $uri, limit => 100, cursor => $cursor } );
                push @likes, @{ $likes->{likes} };
                $cursor = $likes->{cursor};
            } while ($cursor);
            if ($json) {
                $self->say( JSON::Tiny::to_json \@likes );
            }
            else {
                $self->say(
                    '%s%s%s%s (%s)',
                    color('red'),   $_->{actor}{handle},
                    color('reset'), defined $_->{actor}{displayName} ? ' [' . $_->{actor}{displayName} . ']' : '',
                    $_->{createdAt}
                ) for @likes;
            }
            scalar @likes;
        }

        # TODO
        method cmd_repost ($uri) {
            my $res = $bsky->repost($uri);
            $res // return;
            $self->say( $res->{uri}->as_string );
        }

        # TODO



( run in 0.608 second using v1.01-cache-2.11-cpan-39bf76dae61 )