App-bsky

 view release on metacpan or  search on metacpan

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

            !$fatal;
        }

        method say ( $msg, @etc ) {
            $msg = @etc ? sprintf $msg, @etc : $msg;
            my $indent = $msg =~ /^(\s*)/ ? $1 : '';
            $msg = _wrap_and_indent( $config->{settings}{wrap} // 0, length $indent, $msg ) if length $msg;
            try { say $msg; }
            catch ($e) {

                # Stage 1 fallback: try explicit UTF-8 encode before syswrite
                try {
                    my $out = $msg . "\n";
                    utf8::encode($out) if utf8::is_utf8($out);
                    syswrite( STDOUT, $out );
                }
                catch ($e2) {

                    # Stage 2 fallback: aggressive ASCII sanitization
                    my $out = $msg;
                    utf8::encode($out) if utf8::is_utf8($out);
                    $out =~ s/[^\x20-\x7E]/ /g;
                    syswrite( STDOUT, $out . " [sanitized]\n" );
                }
            }
            1;
        }

        method run (@args) {
            $|++;
            return $self->err( 'No subcommand found. Try bsky --help', 1 ) unless scalar @args;
            my $cmd = shift @args;
            $cmd =~ m[^-(h|-help)$] ? $cmd = 'help' : $cmd =~ m[^-V$] ? $cmd = 'VERSION' : $cmd =~ m[^-(v|-version)$] ? $cmd = 'version' : ();
            {
                my $cmd = $cmd;
                $cmd =~ s[[^a-z]][]gi;
                if ( my $method = $self->can( 'cmd_' . $cmd ) ) {
                    return $method->( $self, @args );
                }
            }
            $self->err( 'Unknown subcommand found: ' . $cmd . '. Try bsky --help', 1 ) unless @args;
        }

        method cmd_showprofile (@args) {
            GetOptionsFromArray( \@args, 'json!' => \my $json, 'handle|H=s' => \my $handle );
            return $self->cmd_help('show-profile') if scalar @args;
            my $profile = $bsky->getProfile( $handle // $config->{session}{handle} );
            if ($json) {
                $self->say( JSON::Tiny::to_json($profile) );
            }
            else {
                $profile->throw unless $profile;
                $self->say( 'DID: %s',         $profile->{did} );
                $self->say( 'Handle: %s',      $profile->{handle} );
                $self->say( 'DisplayName: %s', $profile->{displayName} // '' );
                $self->say( 'Description: %s', $profile->{description} // '' );
                $self->say( 'Follows: %d',     $profile->{followsCount} );
                $self->say( 'Followers: %d',   $profile->{followersCount} );
                $self->say( 'Avatar: %s',      $profile->{avatar} ) if $profile->{avatar};
                $self->say( 'Banner: %s',      $profile->{banner} ) if $profile->{banner};
                $self->say('Blocks you: yes') if $profile->{viewer}{blockedBy} // ();
                $self->say('Following: yes')  if $profile->{viewer}{following} // ();
                $self->say('Muted: yes')      if $profile->{viewer}{muted}     // ();
            }
            1;
        }

        method cmd_updateprofile (@args) {
            GetOptionsFromArray(
                \@args,
                'avatar=s'      => \my $avatar,
                'banner=s'      => \my $banner,
                'name=s'        => \my $displayName,
                'description=s' => \my $description
            );
            $avatar // $banner // $displayName // $description // return $self->cmd_help('updateprofile');
            my $profile = $bsky->getProfile( $config->{session}{handle} );
            if ($profile) {    # Bluesky clears them if we do not set them every time
                $displayName //= $profile->{displayName};
                $description //= $profile->{description};
            }
            if ( defined $avatar ) {
                if ( $avatar =~ m[^https?://] ) {
                    my ( $content, $headers ) = $bsky->at->http->get($avatar);
                    use Carp;
                    $content // confess 'failed to download avatar from ' . $avatar;

                    # TODO: check content type HTTP::Tiny and Mojo::UserAgent do this differently
                    $avatar = $bsky->uploadFile( $content, $headers->{'content-type'} );
                }
                elsif ( -e $avatar ) {
                    use Path::Tiny;
                    $avatar = path($avatar)->slurp_raw;
                    my $type = substr( $avatar, 0, 2 ) eq pack 'H*',
                        'ffd8' ? 'image/jpeg' : substr( $avatar, 1, 3 ) eq 'PNG' ? 'image/png' : 'image/jpeg';    # XXX: Assume it's a jpeg?
                    $avatar = $bsky->uploadFile( $avatar, $type );
                }
                else {
                    $self->err('unsure what to do with this avatar; does not seem to be a URL or local file');
                }
                if ($avatar) {
                    $self->say( 'uploaded avatar... %d bytes', $avatar->{size} );
                }
                else {
                    $self->say('failed to upload avatar');
                }
            }
            if ( defined $banner ) {
                if ( $banner =~ m[^https?://] ) {
                    my ( $content, $headers ) = $bsky->at->http->get($banner);
                    use Carp;
                    $content // confess 'failed to download banner from ' . $banner;

                    # TODO: check content type HTTP::Tiny and Mojo::UserAgent do this differently
                    $banner = $bsky->uploadFile( $content, $headers->{'content-type'} );
                }
                elsif ( -e $banner ) {
                    use Path::Tiny;
                    $banner = path($banner)->slurp_raw;
                    my $type = substr( $banner, 0, 2 ) eq pack 'H*',
                        'ffd8' ? 'image/jpeg' : substr( $banner, 1, 3 ) eq 'PNG' ? 'image/png' : 'image/jpeg';    # XXX: Assume it's a jpeg?

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

            if ($json) {
                $self->say( JSON::Tiny::to_json \@follows );
            }
            else {
                for my $follow (@follows) {
                    $self->say(
                        sprintf '%s%s%s%s %s%s%s',
                        color('red'),  $follow->{handle}, color('reset'), defined $follow->{displayName} ? ' [' . $follow->{displayName} . ']' : '',
                        color('blue'), $follow->{did},    color('reset')
                    );
                }
            }
            return scalar @follows;
        }

        method cmd_followers (@args) {
            GetOptionsFromArray( \@args, 'json!' => \my $json, 'handle|H=s' => \my $handle );
            my @followers;
            my $cursor = ();
            do {
                my $followers = $bsky->at->get( 'app.bsky.graph.getFollowers',
                    { actor => $handle // $config->{session}{handle}, limit => 100, cursor => $cursor } );
                $followers // last;
                if ( defined $followers->{followers} ) {
                    push @followers, @{ $followers->{followers} };
                    $cursor = $followers->{cursor};
                }
            } while ($cursor);
            if ($json) {
                $self->say( JSON::Tiny::to_json [ map {$_} @followers ] );
            }
            else {
                my $len1 = my $len2 = 0;
                for (@followers) {
                    $len1 = length( $_->{handle} )      if length( $_->{handle} ) > $len1;
                    $len2 = length( $_->{displayName} ) if length( $_->{displayName} ) > $len2;
                }
                for my $follower (@followers) {
                    $self->say(
                        sprintf '%s%-' . ($len1) . 's %s%-' . ($len2) . 's %s%s%s',
                        color('red'),  $follower->{handle}, color('reset'), $follower->{displayName} // '',
                        color('blue'), $follower->{did},    color('reset')
                    );
                }
            }
            scalar @followers;
        }

        # TODO
        method cmd_block ($actor) {    # takes handle or did
            my $res = $bsky->block($actor);
            $res || $res->throw;
            $self->say( $res->{uri}->as_string );
        }

        # TODO
        method cmd_unblock ($actor) {    # takes handle or did
            my $profile = $bsky->getProfile($actor);
            my $uri     = $profile->{viewer}{blocking} // return $self->err("You are not blocking $actor");
            $bsky->deleteBlock($uri);
            $self->say("Unblocked $actor");
        }

        # TODO
        method cmd_blocks (@args) {
            GetOptionsFromArray( \@args, 'json!' => \my $json );
            my @blocks;
            my $cursor = ();
            do {
                my $follows = $bsky->at->get( 'app.bsky.graph.getBlocks', { limit => 100, cursor => $cursor } );
                push @blocks, @{ $follows->{blocks} };
                $cursor = $follows->{cursor};
            } while ($cursor);
            if ($json) {
                $self->say( JSON::Tiny::to_json \@blocks );
            }
            else {
                for my $follow (@blocks) {
                    $self->say(
                        sprintf '%s%s%s%s %s%s%s',
                        color('red'),  $follow->{handle}, color('reset'), defined $follow->{displayName} ? ' [' . $follow->{displayName} . ']' : '',
                        color('blue'), $follow->{did},    color('reset')
                    );
                }
            }
            return scalar @blocks;
        }

        method cmd_login ( $ident, $password, @args ) {
            GetOptionsFromArray( \@args, 'host=s' => \my $host );
            $bsky = Bluesky->new( defined $host ? ( service => $host ) : () );
            unless ( $bsky->login( $ident, $password ) ) {
                return $self->err( 'Failed to log in as ' . $ident, 1 );
            }
            $config->{resume}  = $bsky->session->_raw;
            $config->{session} = $bsky->session->_raw;
            $self->put_config;
            $self->say( 'Logged in' . ( $host ? ' at ' . $host : '' ) . ' as ' . color('red') . $ident . color('reset') . ' [' . $bsky->did . ']' );
        }

        method cmd_notifications (@args) {
            GetOptionsFromArray( \@args, 'all|a' => \my $all, 'json!' => \my $json );
            if ( !$all ) {
                my $notification_count = $bsky->at->get('app.bsky.notification.getUnreadCount');
                $notification_count || $notification_count->throw;
                return $self->say( $json ? '[]' : 'No unread notifications' ) unless $notification_count->{count};
            }
            my @notes;
            my $cursor = ();
            do {
                my $notes = $bsky->at->get( 'app.bsky.notification.listNotifications', { limit => 100, cursor => $cursor } );
                $notes || $notes->throw;
                push @notes, @{ $notes->{notifications} };
                $cursor = $all && $notes->{cursor} ? $notes->{cursor} : ();
            } while ($cursor);
            return $self->say( JSON::Tiny::to_json [ map {$_} @notes ] ) if $json;
            return $self->say('No notifications.') unless @notes;
            for my $note (@notes) {
                $self->say(
                    '%s%s%s%s %s', color('red'), $note->{author}{handle},
                    color('reset'),



( run in 2.274 seconds using v1.01-cache-2.11-cpan-40ba7b3775d )