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 )