File Coverage

blib/lib/App/bsky.pm
Criterion Covered Total %
statement 60 191 31.4
branch 1 70 1.4
condition 0 37 0.0
subroutine 20 26 76.9
pod n/a
total 81 324 25.0


line stmt bran cond sub pod time code
1             package App::bsky 0.05 {
2 2     2   459351 use v5.38;
  2         8  
3 2     2   9 use utf8;
  2         5  
  2         23  
4 2     2   1287 use Bluesky;
  2         404825  
  2         131  
5 2     2   20 use experimental 'class', 'try';
  2         8  
  2         15  
6 2     2   592 no warnings 'experimental';
  2         5  
  2         99  
7 2     2   1711 use open qw[:std :encoding(UTF-8)];
  2         2428  
  2         11  
8             $|++;
9             class App::bsky::CLI 0.05 {
10 2     2   35389 use JSON::Tiny qw[/code_json/];
  2         5475  
  2         349  
11 2     2   28 use Path::Tiny;
  2         27  
  2         142  
12 2     2   1087 use File::HomeDir;
  2         10390  
  2         135  
13 2     2   1209 use Getopt::Long qw[GetOptionsFromArray];
  2         22717  
  2         10  
14 2     2   1634 use Term::ANSIColor;
  2         21780  
  2         4494  
15             #
16             field $bsky = Bluesky->new();
17             field $config;
18             field $config_file : param //= path( File::HomeDir->my_data )->absolute->child('.bsky');
19             #
20             ADJUST {
21             if ( $^O eq 'MSWin32' ) {
22             try {
23             require Win32::Console;
24             Win32::Console::OutputCP(65001);
25             }
26             catch ($e) {
27              
28             #~ warn $e;
29             #~ warn 'We may have issues with non-ASCII display';
30             }
31             binmode STDOUT, ':encoding(UTF-8)';
32             binmode STDERR, ':encoding(UTF-8)';
33             }
34             $self->get_config;
35             if ( defined $config->{resume}{accessJwt} && defined $config->{resume}{refreshJwt} ) {
36             my $res = $bsky->resume(
37             $config->{resume}{accessJwt},
38             $config->{resume}{refreshJwt},
39             $config->{resume}{token_type} // 'Bearer',
40             $config->{resume}{dpop_key_jwk},
41             $config->{resume}{client_id},
42             $config->{resume}{handle},
43             $config->{resume}{pds},
44             $config->{resume}{scope}
45             );
46              
47             # If resume automatically refreshed, update our config
48             # Also, if the session is expired, try to refresh it manually
49             if ( $bsky->session && builtin::blessed( $bsky->session ) && $bsky->session->isa('At::Protocol::Session') ) {
50             my $access = $bsky->at->_decode_token( $bsky->session->accessJwt );
51             if ( $access && time > $access->{exp} ) {
52             $bsky->at->oauth_refresh;
53             }
54             $config->{resume} = $bsky->session->_raw;
55             $self->put_config;
56             }
57             }
58             elsif ( defined $config->{login}{identifier} && defined $config->{login}{password} ) {
59             if ( $bsky->login( $config->{login}{identifier}, $config->{login}{password} ) &&
60             builtin::blessed( $bsky->session ) &&
61             $bsky->session->isa('At::Protocol::Session') ) {
62             $config->{resume} = $bsky->session->_raw;
63             $self->put_config;
64             }
65             }
66             $config->{session}
67             = ( $bsky->session && builtin::blessed( $bsky->session ) && $bsky->session->isa('At::Protocol::Session') ) ? $bsky->session->_raw :
68             undef;
69             $config->{settings} //= { wrap => 0 };
70             $self->put_config;
71             }
72              
73             method config() {
74             $self->get_config if !$config && $config_file->is_file && $config_file->size;
75             $config;
76             }
77              
78             method DESTROY ( $global //= 0 ) {
79             return unless $config;
80              
81             #~ $self->put_config;
82             }
83             #
84             method get_config() {
85             $config = ( $config_file->is_file && $config_file->size ) ? decode_json $config_file->slurp_utf8 : {};
86             }
87             method put_config() { $config_file->spew_utf8( JSON::Tiny::to_json $config ); }
88              
89             sub _wrap_and_indent {
90 5     5   10 my ( $width, $indent, $string ) = @_;
91 5         8 my $size = $width - $indent;
92 5         8 my $indentation = ' ' x $indent;
93 5 50       169 $string =~ s[(.{1,$size})(\s+|$)][$1\n]g if $size > 0;
94              
95             #~ $string =~ s[^\s+|\n(\s+)][$1//'']gme; # Preserve leading whitespace
96 5         35 $string =~ s/^/$indentation/gm;
97 5         64 return $string;
98             }
99              
100             method err ( $msg, $fatal //= 0 ) {
101             my $indent = $msg =~ /^(\s*)/ ? $1 : '';
102             $msg = _wrap_and_indent( $config->{settings}{wrap} // 0, length $indent, $msg ) if length $msg;
103             die "$msg\n" if $fatal;
104             warn "$msg\n";
105             !$fatal;
106             }
107              
108             method say ( $msg, @etc ) {
109             $msg = @etc ? sprintf $msg, @etc : $msg;
110             my $indent = $msg =~ /^(\s*)/ ? $1 : '';
111             $msg = _wrap_and_indent( $config->{settings}{wrap} // 0, length $indent, $msg ) if length $msg;
112             try { say $msg; }
113             catch ($e) {
114              
115             # Stage 1 fallback: try explicit UTF-8 encode before syswrite
116             try {
117             my $out = $msg . "\n";
118             utf8::encode($out) if utf8::is_utf8($out);
119             syswrite( STDOUT, $out );
120             }
121             catch ($e2) {
122              
123             # Stage 2 fallback: aggressive ASCII sanitization
124             my $out = $msg;
125             utf8::encode($out) if utf8::is_utf8($out);
126             $out =~ s/[^\x20-\x7E]/ /g;
127             syswrite( STDOUT, $out . " [sanitized]\n" );
128             }
129             }
130             1;
131             }
132              
133             method run (@args) {
134             $|++;
135             return $self->err( 'No subcommand found. Try bsky --help', 1 ) unless scalar @args;
136             my $cmd = shift @args;
137             $cmd =~ m[^-(h|-help)$] ? $cmd = 'help' : $cmd =~ m[^-V$] ? $cmd = 'VERSION' : $cmd =~ m[^-(v|-version)$] ? $cmd = 'version' : ();
138             {
139             my $cmd = $cmd;
140             $cmd =~ s[[^a-z]][]gi;
141             if ( my $method = $self->can( 'cmd_' . $cmd ) ) {
142             return $method->( $self, @args );
143             }
144             }
145             $self->err( 'Unknown subcommand found: ' . $cmd . '. Try bsky --help', 1 ) unless @args;
146             }
147              
148             method cmd_showprofile (@args) {
149             GetOptionsFromArray( \@args, 'json!' => \my $json, 'handle|H=s' => \my $handle );
150             return $self->cmd_help('show-profile') if scalar @args;
151             my $profile = $bsky->getProfile( $handle // $config->{session}{handle} );
152             if ($json) {
153             $self->say( JSON::Tiny::to_json($profile) );
154             }
155             else {
156             $profile->throw unless $profile;
157             $self->say( 'DID: %s', $profile->{did} );
158             $self->say( 'Handle: %s', $profile->{handle} );
159             $self->say( 'DisplayName: %s', $profile->{displayName} // '' );
160             $self->say( 'Description: %s', $profile->{description} // '' );
161             $self->say( 'Follows: %d', $profile->{followsCount} );
162             $self->say( 'Followers: %d', $profile->{followersCount} );
163             $self->say( 'Avatar: %s', $profile->{avatar} ) if $profile->{avatar};
164             $self->say( 'Banner: %s', $profile->{banner} ) if $profile->{banner};
165             $self->say('Blocks you: yes') if $profile->{viewer}{blockedBy} // ();
166             $self->say('Following: yes') if $profile->{viewer}{following} // ();
167             $self->say('Muted: yes') if $profile->{viewer}{muted} // ();
168             }
169             1;
170             }
171              
172             method cmd_updateprofile (@args) {
173             GetOptionsFromArray(
174             \@args,
175             'avatar=s' => \my $avatar,
176             'banner=s' => \my $banner,
177             'name=s' => \my $displayName,
178             'description=s' => \my $description
179             );
180             $avatar // $banner // $displayName // $description // return $self->cmd_help('updateprofile');
181             my $profile = $bsky->getProfile( $config->{session}{handle} );
182             if ($profile) { # Bluesky clears them if we do not set them every time
183             $displayName //= $profile->{displayName};
184             $description //= $profile->{description};
185             }
186             if ( defined $avatar ) {
187             if ( $avatar =~ m[^https?://] ) {
188             my ( $content, $headers ) = $bsky->at->http->get($avatar);
189 2     2   16 use Carp;
  2         3  
  2         321  
190             $content // confess 'failed to download avatar from ' . $avatar;
191              
192             # TODO: check content type HTTP::Tiny and Mojo::UserAgent do this differently
193             $avatar = $bsky->uploadFile( $content, $headers->{'content-type'} );
194             }
195             elsif ( -e $avatar ) {
196 2     2   12 use Path::Tiny;
  2         4  
  2         518  
197             $avatar = path($avatar)->slurp_raw;
198             my $type = substr( $avatar, 0, 2 ) eq pack 'H*',
199             'ffd8' ? 'image/jpeg' : substr( $avatar, 1, 3 ) eq 'PNG' ? 'image/png' : 'image/jpeg'; # XXX: Assume it's a jpeg?
200             $avatar = $bsky->uploadFile( $avatar, $type );
201             }
202             else {
203             $self->err('unsure what to do with this avatar; does not seem to be a URL or local file');
204             }
205             if ($avatar) {
206             $self->say( 'uploaded avatar... %d bytes', $avatar->{size} );
207             }
208             else {
209             $self->say('failed to upload avatar');
210             }
211             }
212             if ( defined $banner ) {
213             if ( $banner =~ m[^https?://] ) {
214             my ( $content, $headers ) = $bsky->at->http->get($banner);
215 2     2   11 use Carp;
  2         3  
  2         271  
216             $content // confess 'failed to download banner from ' . $banner;
217              
218             # TODO: check content type HTTP::Tiny and Mojo::UserAgent do this differently
219             $banner = $bsky->uploadFile( $content, $headers->{'content-type'} );
220             }
221             elsif ( -e $banner ) {
222 2     2   10 use Path::Tiny;
  2         13  
  2         16556  
223             $banner = path($banner)->slurp_raw;
224             my $type = substr( $banner, 0, 2 ) eq pack 'H*',
225             'ffd8' ? 'image/jpeg' : substr( $banner, 1, 3 ) eq 'PNG' ? 'image/png' : 'image/jpeg'; # XXX: Assume it's a jpeg?
226             $banner = $bsky->uploadFile( $banner, $type );
227             }
228             else {
229             $self->err('unsure what to do with this banner; does not seem to be a URL or local file');
230             }
231             if ($banner) {
232             $self->say( 'uploaded banner... %d bytes', $banner->{size} );
233             }
234             else {
235             $self->say('failed to upload banner');
236             }
237             }
238             my $res = $bsky->at->put_record(
239             'app.bsky.actor.profile',
240             'self',
241             { defined $displayName ? ( displayName => $displayName ) : (),
242             defined $description ? ( description => $description ) : (),
243             defined $avatar ? ( avatar => $avatar ) : (),
244             defined $banner ? ( banner => $banner ) : ()
245             }
246             );
247             defined $res->{uri} ? $self->say( $res->{uri}->as_string ) : $self->err( $res->{message} );
248             }
249              
250             method cmd_oauth ( $handle, @args ) {
251             my $cli = $self;
252             GetOptionsFromArray( \@args, 'redirect=s' => \my $redirect );
253 0           $bsky->oauth_helper(
254             handle => $handle,
255             listen => 1,
256             defined $redirect ? ( redirect => $redirect ) : (),
257 0     0     on_success => sub ($bsky_obj) {
  0            
258 0           $config->{resume} = $bsky_obj->session->_raw;
259 0           $config->{session} = $bsky_obj->session->_raw;
260 0           $cli->put_config;
261 0           $cli->say( "Authenticated as " . $bsky_obj->did );
262             }
263             );
264             }
265              
266             method cmd_showsession (@args) {
267             GetOptionsFromArray( \@args, 'json!' => \my $json );
268             my $session = $bsky->session;
269             unless ($session) {
270             return $self->err("No active session. Run 'bsky oauth ' or 'bsky login' first.");
271             }
272             if ($json) {
273             $self->say( JSON::Tiny::to_json( $session->_raw ) );
274             }
275             else {
276             $self->say( 'DID: ' . $session->did );
277             $self->say( 'Handle: ' . $session->handle );
278             $self->say( 'Email: ' . ( $session->email // 'N/A' ) );
279             $self->say( 'Type: ' . $session->token_type );
280             $self->say( 'Scopes: ' . ( $session->scope // 'N/A' ) );
281             }
282             return 1;
283             }
284              
285             method _dump_post ( $depth, $post ) {
286             if ( builtin::blessed $post ) {
287             if ( $post->isa('At::Lexicon::app::bsky::feed::threadViewPost') && builtin::blessed $post->parent ) {
288             $self->_dump_post( $depth++, $post->parent );
289             $post = $post->post;
290             }
291             elsif ( $post->isa('At::Lexicon::app::bsky::feed::threadViewPost') ) {
292             $self->_dump_post( $depth++, $post->post );
293             my $replies = $post->replies // [];
294             $self->_dump_post( $depth + 2, $_->post ) for @$replies;
295             return;
296             }
297             }
298              
299             #~ warn ref $post;
300             #~ use Data::Dump;
301             #~ ddx $post;
302             # TODO: Support image embeds as raw links
303             $self->say(
304             '%s%s%s%s%s (%s)',
305             ' ' x ( $depth * 4 ),
306             color('red'), $post->{author}{handle},
307             color('reset'),
308             defined $post->{author}{displayName} ? ' [' . $post->{author}{displayName} . ']' : '',
309             $post->{record}{createdAt}
310             );
311             if ( $post->{embed} && defined $post->{embed}{images} ) { # TODO: Check $post->embed->$type to match 'app.bsky.embed.images#view'
312             $self->say( '%s%s', ' ' x ( $depth * 4 ), $_->{fullsize} ) for @{ $post->{embed}{images} };
313             }
314             $self->say( '%s%s', ' ' x ( $depth * 4 ), $post->{record}{text} );
315             $self->say(
316             '%s ❤️ %d 💬 %d 🔄 %d %s',
317             ' ' x ( $depth * 4 ),
318             $post->{likeCount}, $post->{replyCount}, $post->{repostCount},
319             ( builtin::blessed $post->{uri} ? $post->{uri}->as_string : $post->{uri} )
320             );
321             $self->say( '%s', ' ' x ( $depth * 4 ) );
322             }
323              
324             method cmd_timeline (@args) {
325             GetOptionsFromArray( \@args, 'json!' => \my $json );
326             my $tl = $bsky->getTimeline();
327             if ( builtin::blessed $tl && $tl->isa('At::Error') ) {
328             return $self->err( "Error fetching timeline: " . $tl->message );
329             }
330             unless ( $tl && $tl->{feed} ) {
331             return $self->say("Timeline is empty.");
332             }
333             if ($json) {
334             $self->say( JSON::Tiny::to_json( $tl->{feed} ) );
335             }
336             else {
337             for my $item ( @{ $tl->{feed} } ) {
338             my $depth = 0;
339             if ( $item->{reply} && $item->{reply}{parent} ) {
340             $self->_dump_post( $depth, $item->{reply}{parent} );
341             $depth = 1;
342             }
343             $self->_dump_post( $depth, $item->{post} );
344             }
345             }
346             return scalar @{ $tl->{feed} };
347             }
348             method cmd_tl (@args) { $self->cmd_timeline(@args); }
349              
350             method cmd_stream(@args) {
351             GetOptionsFromArray( \@args, 'json|j' => \my $json );
352             require Mojo::IOLoop; # Ensure Mojo is available for the event loop
353             require Archive::CAR::CID;
354             require Archive::CAR;
355             require Codec::CBOR;
356              
357             # Keep the loop alive even if the connection drops briefly
358 0 0   0     my $keepalive = Mojo::IOLoop->recurring( 60 => sub { $self->say("[DEBUG] Firehose loop keepalive...") if $ENV{DEBUG}; } );
359             my %profile_cache;
360             my @profile_lru;
361             my $MAX_CACHE = 1000;
362 0     0     my $cache_profile = sub ($p) {
  0            
  0            
363 0           my $did = $p->{did};
364 0 0         if ( exists $profile_cache{$did} ) {
365 0           @profile_lru = grep { $_ ne $did } @profile_lru;
  0            
366             }
367 0           push @profile_lru, $did;
368 0           $profile_cache{$did} = $p;
369 0 0         if ( @profile_lru > $MAX_CACHE ) {
370 0           my $oldest = shift @profile_lru;
371 0           delete $profile_cache{$oldest};
372             }
373             };
374             my @post_queue;
375             my %dids_to_resolve;
376             my %did_fail_count;
377             my $render_queue = sub {
378 0   0 0     my @to_resolve = grep { ( $did_fail_count{$_} // 0 ) < 5 } keys %dids_to_resolve;
  0            
379 0 0         if (@to_resolve) {
380 0 0         say "[DEBUG] Resolving " . scalar(@to_resolve) . " DIDs..." if $ENV{DEBUG};
381 0           while (@to_resolve) {
382 0           my @chunk = splice @to_resolve, 0, 25;
383 0           my $res = $bsky->getProfiles( actors => \@chunk );
384 0 0 0       if ( ref $res eq 'ARRAY' || ( ref $res eq 'HASH' && $res->{profiles} ) ) {
      0        
385 0 0         my @profiles = ref $res eq 'ARRAY' ? @$res : @{ $res->{profiles} };
  0            
386 0 0         say "[DEBUG] Resolved " . scalar(@profiles) . " profiles" if $ENV{DEBUG};
387 0           for my $p (@profiles) {
388 0           $cache_profile->($p);
389 0           delete $dids_to_resolve{ $p->{did} };
390 0           delete $did_fail_count{ $p->{did} };
391             }
392              
393             # If some didn't come back in the response, they might be invalid or deleted
394             # We'll increment their fail count if they are still in dids_to_resolve
395 0           for my $did (@chunk) {
396 0 0         if ( exists $dids_to_resolve{$did} ) {
397 0           $did_fail_count{$did}++;
398             }
399             }
400             }
401             else {
402 0 0 0       say "[DEBUG] getProfiles failed: " . ( $res // 'undef' ) if $ENV{DEBUG};
403              
404             # Increment fail count for the whole chunk
405 0           for my $did (@chunk) {
406 0           $did_fail_count{$did}++;
407             }
408             }
409             }
410             }
411 0 0         if (@post_queue) {
412 0 0         say "[DEBUG] Processing post queue with " . scalar(@post_queue) . " items" if $ENV{DEBUG};
413 0           @post_queue = sort { $a->{record}{createdAt} cmp $b->{record}{createdAt} } @post_queue;
  0            
414             }
415 0           while (@post_queue) {
416 0           my $item = shift @post_queue;
417 0           my $repo = $item->{repo};
418 0           my $record = $item->{record};
419 0           my $ts = $item->{ts};
420 0           my $author = $profile_cache{$repo};
421 0 0 0       my $handle = ( ref $author eq 'HASH' ) ? ( $author->{handle} // $repo ) : $repo;
422 0 0 0       my $name = ( ref $author eq 'HASH' ) ? ( $author->{displayName} // '' ) : '';
423 0   0       my $text = $record->{text} // '[no text]';
424 0           my $reply_info = '';
425              
426 0 0 0       if ( $record->{reply} && $record->{reply}{parent} ) {
427 0           my $parent_uri = $record->{reply}{parent}{uri};
428 0 0         if ( $parent_uri =~ m[^at://(did:[^/]+)] ) {
429 0           my $parent_did = $1;
430 0           my $parent_profile = $profile_cache{$parent_did};
431 0 0         my $parent_handle = ( ref $parent_profile eq 'HASH' ) ? $parent_profile->{handle} : $parent_did;
432 0           $reply_info = color('white') . " [in reply to \@" . $parent_handle . "]";
433             }
434             }
435 0           try {
436 0           $self->say( '%s%s %s (%s)%s%s', color('white'), $ts, $name, '@' . $handle, $reply_info, color('reset') );
437 0           my $indented = $text;
438 0           $indented =~ s/^/ /mg;
439 0           $self->say($indented);
440 0           $self->say("");
441             }
442             catch ($e) {
443 0           try {
444 0           my $out = $text;
445 0 0         utf8::encode($out) if utf8::is_utf8($out);
446 0           $out =~ s/[^\x20-\x7E]/ /g;
447 0           $self->say( '%s%s %s (%s)%s [sanitized]', color('white'), $ts, $name, '@' . $handle, $reply_info );
448 0           my $indented = $out;
449 0           $indented =~ s/^/ /mg;
450 0           $self->say($indented);
451 0           $self->say("");
452             }
453             catch ($e2) { }
454             }
455             }
456             };
457              
458             # Trigger rendering every 5 seconds
459 0     0     Mojo::IOLoop->recurring( 5 => sub { $render_queue->() } );
460             my $start_stream;
461             $start_stream = sub {
462 0 0 0 0     $self->say('[DEBUG] Starting firehose stream...') if $ENV{DEBUG} || 1;
463             my $fh = $bsky->firehose(
464             sub ( $header, $body, $err ) {
465 0           try {
466 0 0         if ( defined $err ) {
467 0           warn 'Firehose error: ' . $err;
468              
469             # Always try to reconnect if not explicitly fatal
470 0 0         if ( !$err->fatal ) {
471 0 0 0       $self->say('[DEBUG] Attempting to reconnect in 5 seconds...') if $ENV{DEBUG} || 1;
472 0           Mojo::IOLoop->timer( 5 => sub { $start_stream->() } );
  0            
473             }
474             else {
475 0 0 0       $self->say('[DEBUG] Fatal firehose error. Exiting.') if $ENV{DEBUG} || 1;
476 0           Mojo::IOLoop->remove($keepalive);
477 0           Mojo::IOLoop->stop;
478             }
479 0           return;
480             }
481 0 0         if ($json) {
482 0           $self->say( JSON::Tiny::to_json( { header => $header, body => $body } ) );
483 0           return;
484             }
485              
486             # Only process commit events for now
487 0 0 0       unless ( defined $header->{t} && $header->{t} eq '#commit' ) {
488 0           return;
489             }
490 0           for my $op ( @{ $body->{ops} } ) {
  0            
491 0 0         next unless $op->{action} eq 'create';
492 0 0         next unless $op->{path} =~ /^app\.bsky\.feed\.post\//;
493 0           try {
494             # Decode the blocks to find the record
495 0           require Archive::CAR::v1;
496 0           my $car = Archive::CAR::v1->new();
497 0           open my $cfh, '<:raw', \$body->{blocks};
498 0           my %blocks = map { $_->{cid}->to_string => $_->{data} } $car->read($cfh)->blocks->@*;
  0            
499 0           require Archive::CAR::CID; # Ensure it's loaded for conversion
500 0           my $cid_raw = $op->{cid};
501 0 0 0       if ( ref $cid_raw eq 'HASH' && exists $cid_raw->{cid_raw} ) {
502 0           $cid_raw = $cid_raw->{cid_raw};
503             }
504 0           my $target_cid_obj = Archive::CAR::CID->from_raw($cid_raw);
505 0           my $record_bytes = $blocks{ $target_cid_obj->to_string };
506 0 0         next unless $record_bytes;
507 0           require Codec::CBOR;
508 0           my $codec = Codec::CBOR->new();
509 0           my $record = $codec->decode($record_bytes);
510 0 0         next unless $record;
511 0           my $repo = $body->{repo};
512 0   0       my $ts = $record->{createdAt} // '';
513 0           $ts =~ s/T/ /;
514 0           $ts =~ s/\..*Z//;
515              
516             # Queue for later rendering
517 0           push @post_queue, { repo => $repo, record => $record, ts => $ts };
518 0 0         $dids_to_resolve{$repo} = 1 unless exists $profile_cache{$repo};
519 0 0 0       if ( $record->{reply} && $record->{reply}{parent} ) {
520 0           my $parent_uri = $record->{reply}{parent}{uri};
521 0 0         if ( $parent_uri =~ m[^at://(did:[^/]+)] ) {
522 0           my $parent_did = $1;
523 0 0         $dids_to_resolve{$parent_did} = 1 unless exists $profile_cache{$parent_did};
524             }
525             }
526             }
527             catch ($e) {
528 0           warn "CAR/CBOR decoding error for op on repo " . $body->{repo} . ": $e";
529             }
530             }
531             }
532             catch ($e) {
533 0           warn "Error processing firehose event: $e";
534             }
535             }
536 0           );
537 0           $fh->start();
538             };
539             $start_stream->();
540             Mojo::IOLoop->start unless Mojo::IOLoop->is_running;
541             }
542              
543             method cmd_thread (@args) {
544             GetOptionsFromArray( \@args, 'json!' => \my $json, 'n=i' => \my $number );
545             $number //= ();
546             my ($id) = @args;
547             $id // return $self->cmd_help('thread');
548             my $res = $bsky->getPostThread( uri => $id, depth => $number, parentHeight => $number ); # $uri, depth, $parentHeight
549             return unless $res->{thread};
550             return $self->say( JSON::Tiny::to_json $res->{thread} ) if $json;
551             $self->_dump_post( 0, $res->{thread} );
552             }
553              
554             method cmd_post ($text) {
555             my $res = $bsky->createPost( text => $text );
556             defined $res ? $self->say( $res->{uri} ) : 0;
557             }
558              
559             method cmd_delete ($uri) {
560             $uri = At::Protocol::URI->new($uri) unless builtin::blessed $uri;
561             $bsky->at->delete_record( $uri->collection, $uri->rkey );
562             }
563              
564             # TODO
565             method cmd_like ( $uri, @args ) { # can take the post uri
566             GetOptionsFromArray( \@args, 'json!' => \my $json, 'cid=s' => \my $cid );
567             my $res = $bsky->like( $uri, $cid );
568             $res || $res->throw;
569             $self->say( $json ? JSON::Tiny::to_json($res) : sprintf 'Liked! [id:%s]', $res->{uri}->as_string );
570             }
571              
572             # TODO
573             method cmd_unlike ( $uri, @args ) { # can take the post uri or the like uri
574             GetOptionsFromArray( \@args, 'json!' => \my $json, 'cid=s' => \my $cid );
575             my $res = $bsky->deleteLike($uri);
576             $res || $res->throw;
577             $self->say( $json ? JSON::Tiny::to_json($res) : sprintf 'Removed like!' );
578             }
579              
580             # TODO
581             method cmd_likes ( $uri, @args ) {
582             GetOptionsFromArray( \@args, 'json!' => \my $json );
583             my @likes;
584             my $cursor = ();
585             do {
586             my $likes = $bsky->at->get( 'app.bsky.feed.getLikes', { uri => $uri, limit => 100, cursor => $cursor } );
587             push @likes, @{ $likes->{likes} };
588             $cursor = $likes->{cursor};
589             } while ($cursor);
590             if ($json) {
591             $self->say( JSON::Tiny::to_json \@likes );
592             }
593             else {
594             $self->say(
595             '%s%s%s%s (%s)',
596             color('red'), $_->{actor}{handle},
597             color('reset'), defined $_->{actor}{displayName} ? ' [' . $_->{actor}{displayName} . ']' : '',
598             $_->{createdAt}
599             ) for @likes;
600             }
601             scalar @likes;
602             }
603              
604             # TODO
605             method cmd_repost ($uri) {
606             my $res = $bsky->repost($uri);
607             $res // return;
608             $self->say( $res->{uri}->as_string );
609             }
610              
611             # TODO
612             method cmd_reposts ( $uri, @args ) {
613             GetOptionsFromArray( \@args, 'json!' => \my $json );
614             my @reposts;
615             my $cursor = ();
616             do {
617             my $reposts = $bsky->at->get( 'app.bsky.feed.getRepostedBy', { uri => $uri, limit => 100, cursor => $cursor } );
618             push @reposts, @{ $reposts->{repostedBy} };
619             $cursor = $reposts->{cursor};
620             } while ($cursor);
621             if ($json) {
622             $self->say( JSON::Tiny::to_json \@reposts );
623             }
624             else {
625             $self->say( '%s%s%s%s', color('red'), $_->{handle}, color('reset'), defined $_->{displayName} ? ' [' . $_->{displayName} . ']' : '' )
626             for @reposts;
627             }
628             scalar @reposts;
629             }
630              
631             # TODO
632             method cmd_follow ($actor) { # takes handle or did
633             my $res = $bsky->follow($actor);
634             $res || $res->throw;
635             $self->say( $res->{uri}->as_string );
636             }
637              
638             # TODO
639             method cmd_unfollow ($actor) { # takes handle or did
640             my $profile = $bsky->getProfile($actor);
641             my $uri = $profile->{viewer}{following} // return $self->err("You are not following $actor");
642             $bsky->deleteFollow($uri);
643             $self->say("Unfollowed $actor");
644             }
645              
646             # TODO
647             method cmd_follows (@args) {
648             GetOptionsFromArray( \@args, 'json!' => \my $json, 'handle|H=s' => \my $handle );
649             my @follows;
650             my $cursor = ();
651             do {
652             my $follows = $bsky->at->get( 'app.bsky.graph.getFollows',
653             { actor => $handle // $config->{session}{handle}, limit => 100, cursor => $cursor } );
654             push @follows, @{ $follows->{follows} };
655             $cursor = $follows->{cursor};
656             } while ($cursor);
657             if ($json) {
658             $self->say( JSON::Tiny::to_json \@follows );
659             }
660             else {
661             for my $follow (@follows) {
662             $self->say(
663             sprintf '%s%s%s%s %s%s%s',
664             color('red'), $follow->{handle}, color('reset'), defined $follow->{displayName} ? ' [' . $follow->{displayName} . ']' : '',
665             color('blue'), $follow->{did}, color('reset')
666             );
667             }
668             }
669             return scalar @follows;
670             }
671              
672             method cmd_followers (@args) {
673             GetOptionsFromArray( \@args, 'json!' => \my $json, 'handle|H=s' => \my $handle );
674             my @followers;
675             my $cursor = ();
676             do {
677             my $followers = $bsky->at->get( 'app.bsky.graph.getFollowers',
678             { actor => $handle // $config->{session}{handle}, limit => 100, cursor => $cursor } );
679             $followers // last;
680             if ( defined $followers->{followers} ) {
681             push @followers, @{ $followers->{followers} };
682             $cursor = $followers->{cursor};
683             }
684             } while ($cursor);
685             if ($json) {
686             $self->say( JSON::Tiny::to_json [ map {$_} @followers ] );
687             }
688             else {
689             my $len1 = my $len2 = 0;
690             for (@followers) {
691             $len1 = length( $_->{handle} ) if length( $_->{handle} ) > $len1;
692             $len2 = length( $_->{displayName} ) if length( $_->{displayName} ) > $len2;
693             }
694             for my $follower (@followers) {
695             $self->say(
696             sprintf '%s%-' . ($len1) . 's %s%-' . ($len2) . 's %s%s%s',
697             color('red'), $follower->{handle}, color('reset'), $follower->{displayName} // '',
698             color('blue'), $follower->{did}, color('reset')
699             );
700             }
701             }
702             scalar @followers;
703             }
704              
705             # TODO
706             method cmd_block ($actor) { # takes handle or did
707             my $res = $bsky->block($actor);
708             $res || $res->throw;
709             $self->say( $res->{uri}->as_string );
710             }
711              
712             # TODO
713             method cmd_unblock ($actor) { # takes handle or did
714             my $profile = $bsky->getProfile($actor);
715             my $uri = $profile->{viewer}{blocking} // return $self->err("You are not blocking $actor");
716             $bsky->deleteBlock($uri);
717             $self->say("Unblocked $actor");
718             }
719              
720             # TODO
721             method cmd_blocks (@args) {
722             GetOptionsFromArray( \@args, 'json!' => \my $json );
723             my @blocks;
724             my $cursor = ();
725             do {
726             my $follows = $bsky->at->get( 'app.bsky.graph.getBlocks', { limit => 100, cursor => $cursor } );
727             push @blocks, @{ $follows->{blocks} };
728             $cursor = $follows->{cursor};
729             } while ($cursor);
730             if ($json) {
731             $self->say( JSON::Tiny::to_json \@blocks );
732             }
733             else {
734             for my $follow (@blocks) {
735             $self->say(
736             sprintf '%s%s%s%s %s%s%s',
737             color('red'), $follow->{handle}, color('reset'), defined $follow->{displayName} ? ' [' . $follow->{displayName} . ']' : '',
738             color('blue'), $follow->{did}, color('reset')
739             );
740             }
741             }
742             return scalar @blocks;
743             }
744              
745             method cmd_login ( $ident, $password, @args ) {
746             GetOptionsFromArray( \@args, 'host=s' => \my $host );
747             $bsky = Bluesky->new( defined $host ? ( service => $host ) : () );
748             unless ( $bsky->login( $ident, $password ) ) {
749             return $self->err( 'Failed to log in as ' . $ident, 1 );
750             }
751             $config->{resume} = $bsky->session->_raw;
752             $config->{session} = $bsky->session->_raw;
753             $self->put_config;
754             $self->say( 'Logged in' . ( $host ? ' at ' . $host : '' ) . ' as ' . color('red') . $ident . color('reset') . ' [' . $bsky->did . ']' );
755             }
756              
757             method cmd_notifications (@args) {
758             GetOptionsFromArray( \@args, 'all|a' => \my $all, 'json!' => \my $json );
759             if ( !$all ) {
760             my $notification_count = $bsky->at->get('app.bsky.notification.getUnreadCount');
761             $notification_count || $notification_count->throw;
762             return $self->say( $json ? '[]' : 'No unread notifications' ) unless $notification_count->{count};
763             }
764             my @notes;
765             my $cursor = ();
766             do {
767             my $notes = $bsky->at->get( 'app.bsky.notification.listNotifications', { limit => 100, cursor => $cursor } );
768             $notes || $notes->throw;
769             push @notes, @{ $notes->{notifications} };
770             $cursor = $all && $notes->{cursor} ? $notes->{cursor} : ();
771             } while ($cursor);
772             return $self->say( JSON::Tiny::to_json [ map {$_} @notes ] ) if $json;
773             return $self->say('No notifications.') unless @notes;
774             for my $note (@notes) {
775             $self->say(
776             '%s%s%s%s %s', color('red'), $note->{author}{handle},
777             color('reset'),
778             defined $note->{author}{displayName} ? ' [' . $note->{author}{displayName} . ']' : '',
779             $note->{author}{did}
780             );
781             $self->say(
782             ' %s',
783             $note->{reason} eq 'like' ? 'liked ' . $note->{record}{subject}{uri} :
784             $note->{reason} eq 'repost' ? 'reposted ' . $note->{record}{subject}{uri} :
785             $note->{reason} eq 'follow' ? 'followed you' :
786             $note->{reason} eq 'mention' ? 'mentioned you at ' . $note->{record}{subject}{uri} :
787             $note->{reason} eq 'reply' ? 'replied at ' . $note->{record}{subject}{uri} :
788             $note->{reason} eq 'quote' ? 'quoted you at ' . $note->{record}{subject}{uri} :
789             'unknown notification: ' . $note->{reason}
790             );
791             }
792             scalar @notes;
793             }
794              
795             method cmd_notif (@args) {
796             $self->cmd_notifications(@args);
797             }
798              
799             method cmd_listapppasswords (@args) {
800             GetOptionsFromArray( \@args, 'json!' => \my $json );
801             my $passwords = $bsky->at->get('com.atproto.server.listAppPasswords');
802             $passwords || $passwords->throw;
803             my @passwords = @{ $passwords->{passwords} };
804             if ($json) {
805             $self->say( JSON::Tiny::to_json [ map {$_} @passwords ] );
806             }
807             elsif (@passwords) {
808             $self->say( '%s%s (%s)', $_->{privileged} ? '*' : ' ', $_->{name}, $_->{createdAt} ) for @passwords;
809             }
810             else {
811             $self->say('No app passwords found');
812             }
813             scalar @passwords;
814             }
815              
816             method cmd_addapppassword ($name) {
817             my $res = $bsky->at->post( 'com.atproto.server.createAppPassword', { name => $name } );
818             $res || $res->throw;
819             if ( $res->{appPassword} ) {
820             $self->say( 'App name: %s', $res->{appPassword}{name} );
821             $self->say( 'Password: %s', $res->{appPassword}{password} );
822             }
823             1;
824             }
825              
826             method cmd_revokeapppassword ($name) {
827             $bsky->at->post( 'com.atproto.server.revokeAppPassword', { name => $name } ) ? 1 : 0;
828             }
829              
830             method cmd_config ( $field //= (), $value //= () ) {
831             unless ( defined $field ) {
832             $self->say('Current config:');
833             for my $k ( sort keys %{ $config->{settings} } ) {
834             $self->say( ' %-20s %s', $k . ':', $config->{settings}{$k} );
835             }
836             }
837             elsif ( defined $field && defined $config->{settings}{$field} ) {
838             if ( defined $value ) {
839             $config->{settings}{$field} = $value;
840             $self->put_config;
841             $self->say( 'Config value %s set to %s', $field, $value );
842             }
843             else {
844             $self->say( $config->{settings}{$field} );
845             }
846             }
847             else {
848             return $self->err( 'Unknown config field: ' . $field, 1 );
849             }
850             return 1;
851             }
852              
853             method cmd_help ( $command //= () ) { # cribbed from App::cpm::CLI
854             open my $fh, '>', \my $out;
855             if ( !defined $command ) {
856 2     2   1308 use Pod::Text::Color;
  2         84719  
  2         276  
857             Pod::Text::Color->new->parse_from_file( path($0)->absolute->stringify, $fh );
858             }
859             else {
860 2     2   44 BEGIN { $Pod::Usage::Formatter = 'Pod::Text::Color'; }
861 2     2   1087 use Pod::Usage;
  2         7235  
  2         1676  
862             $command = 'timeline' if $command eq 'tl';
863             $command = 'notifications' if $command eq 'notif';
864             pod2usage( -output => $fh, -verbose => 99, -sections => [ 'Usage', 'Commands/' . $command ], -exitval => 'noexit' );
865             $out =~ s[^[ ]{6}][ ]mg;
866             $out =~ s[\s+$][]gs;
867             }
868             return $self->say($out);
869             }
870              
871             method cmd_chat (@args) {
872             my $convos = $bsky->listConvos();
873             return $self->err( 'Failed to list conversations: ' . $convos->message ) if ref $convos eq 'At::Error';
874             return $self->say('No active conversations.') unless @$convos;
875             for my $convo (@$convos) {
876             my $members = join ', ', map { $_->{handle} } @{ $convo->{members} };
877             $self->say( '[%s] members: %s', $convo->{id}, $members );
878             my $messages = $bsky->getMessages( convoId => $convo->{id}, limit => 3 );
879             next if ref $messages eq 'At::Error';
880             my %handles = map { $_->{did} => $_->{handle} } @{ $convo->{members} };
881             for my $msg (@$messages) {
882             my $text = $msg->{text} // '[Non-text message]';
883             my $sender = $handles{ $msg->{sender}{did} } // $msg->{sender}{did};
884             $self->say( ' [%s] %s: %s', $msg->{sentAt}, $sender, $text );
885             }
886             }
887             return 1;
888             }
889              
890             method cmd_dm (@args) {
891             GetOptionsFromArray( \@args, 'json!' => \my $json, 'handle|H=s' => \my $handle, 'text|m=s' => \my $text );
892             return $self->cmd_help('dm') if scalar @args || !length $handle;
893             my $did = $bsky->resolveHandle($handle);
894             return $self->err("Could not resolve handle '$handle'") unless $did;
895             my $convo_res = $bsky->getConvoForMembers( members => [$did] );
896             return $self->err( 'Could not initiate conversation: ' . $convo_res->message ) if ref $convo_res eq 'At::Error';
897             my $res = $bsky->sendMessage( $convo_res->{id}, { text => $text } );
898             return $self->err( 'Failed to send message: ' . $res->message ) if ref $res eq 'At::Error';
899             $self->say( "Message sent to $handle. Convo ID: " . $res->{id} );
900             return 1;
901             }
902              
903             method cmd_VERSION() {
904             $self->cmd_version;
905 2     2   15 use Config qw[%Config];
  2         3  
  2         1014  
906             $self->say($_)
907             for ' %Config:',
908             ( map {" $_=$Config{$_}"}
909             grep { defined $Config{$_} }
910             sort
911             qw[archname installsitelib installsitebin installman1dir installman3dir sitearchexp sitelibexp vendorarch vendorlibexp archlibexp privlibexp]
912             ), ' %ENV:', ( map {" $_=$ENV{$_}"} sort grep {/^PERL/} keys %ENV ), ' @INC:',
913             ( map {" $_"} grep { ref $_ ne 'CODE' } @INC );
914             1;
915             }
916              
917             method cmd_version() {
918             $self->say($_)
919             for 'bsky v' . $App::bsky::VERSION, 'Bluesky.pm v' . $Bluesky::VERSION, 'At.pm v' . $At::VERSION, 'perl ' . $^V;
920             1;
921             }
922             };
923             }
924             1;