File Coverage

lib/Bluesky.pm
Criterion Covered Total %
statement 29 47 61.7
branch 0 2 0.0
condition 0 3 0.0
subroutine 10 12 83.3
pod n/a
total 39 64 60.9


line stmt bran cond sub pod time code
1             package Bluesky 1.01 {
2 3     3   681130 use v5.40;
  3         13  
3 3     3   20 use Carp qw[carp];
  3         12  
  3         229  
4 3     3   995 use bytes;
  3         1050  
  3         25  
5 3     3   126 use feature 'class';
  3         5  
  3         522  
6 3     3   16 no warnings 'experimental::class', 'experimental::try';
  3         5  
  3         158  
7 3     3   3426 use At;
  3         550936  
  3         137  
8 3     3   29 use Path::Tiny;
  3         4  
  3         197  
9 3     3   19 use HTTP::Tiny;
  3         4  
  3         50  
10 3     3   16 use URI;
  3         8  
  3         56  
11 3     3   12 use JSON::PP;
  3         6  
  3         36009  
12             #
13             class Bluesky {
14             field $service : param //= 'https://bsky.social';
15             field $chat_service : param //= 'https://api.bsky.chat';
16             field $at : reader = At->new( host => $service );
17             #
18             method _at_for ($method) {
19             if ( $method =~ /^chat\.bsky\./ ) {
20              
21             # Chat requests are proxied via the PDS.
22             # Service ID fragment (#bsky_chat) is required.
23             say '[DEBUG] [Bluesky] Proxying chat request...' if $ENV{DEBUG};
24             $self->at->http->at_protocol_proxy('did:web:api.bsky.chat#bsky_chat');
25             return $at;
26             }
27              
28             # Ensure proxy is NOT used for standard repo/feed calls
29             $self->at->http->at_protocol_proxy(undef);
30             return $at;
31             }
32             method login ( $identifier, $password ) { $self->at->login( $identifier, $password ); }
33              
34             method resume( $accessJwt, $refreshJwt, $token_type = 'Bearer', $dpop_key_jwk = (), $client_id = (), $handle = (), $pds = (), $scope = () ) {
35             $self->at->resume( $accessJwt, $refreshJwt, $token_type, $dpop_key_jwk, $client_id, $handle, $pds, $scope );
36             }
37              
38             method oauth_start( $handle, $client_id, $redirect_uri, $scope = 'atproto' ) {
39             return $self->at->oauth_start( $handle, $client_id, $redirect_uri, $scope );
40             }
41             method oauth_callback( $code, $state ) { return $self->at->oauth_callback( $code, $state ); }
42              
43             method oauth_helper (%args) {
44             my $handle = $args{handle} or die 'Handle is required for oauth_helper';
45              
46             # client_id base MUST be localhost for the PDS to recognize it as metadata-less
47             my $client_id_base = $args{client_id} // 'http://localhost';
48              
49             # RFC 8252 requires loopback IP (127.0.0.1) for the actual redirect
50             my $redirect_uri = $args{redirect} // 'http://127.0.0.1:8888/callback';
51              
52             # Transitional scopes for chat access via PDS proxy
53             my $scope = $args{scope} // 'atproto transition:generic transition:chat.bsky';
54 0     0     my $on_success = $args{on_success} // sub { say 'Success! Session established.' };
55              
56             # Build the magic localhost Client ID manually to ensure %20 encoding for spaces
57             require URI::Escape;
58             my $scope_encoded = URI::Escape::uri_escape($scope);
59             my $redirect_encoded = URI::Escape::uri_escape($redirect_uri);
60             my $client_id = "$client_id_base?scope=$scope_encoded&redirect_uri=$redirect_encoded";
61             say "Starting OAuth flow for $handle...";
62             my $auth_url = $self->oauth_start( $handle, $client_id, $redirect_uri, $scope );
63             say 'Please open the following URL in your browser:';
64             say "\n $auth_url\n";
65              
66             if ( $args{listen} ) {
67              
68             # Attempt to start a local server to catch the callback
69             try {
70             require Mojolicious;
71             my $app = Mojolicious->new();
72             my $port = 8888;
73             $port = $1 if $redirect_uri =~ /:(\d+)/;
74             my $path = URI->new($redirect_uri)->path || '/';
75 0           $app->routes->get($path)->to(
76 0     0     cb => sub ($c) {
  0            
77 0           my $code = $c->param('code');
78 0           my $state = $c->param('state');
79 0 0 0       if ( $code && $state ) {
80 0           say 'Exchanging code for tokens...';
81 0           try {
82 0           $self->oauth_callback( $code, $state );
83 0           $on_success->($self);
84 0           $c->render( text => '

Success!

You can close this window and return to the terminal.

' );
85              
86             # Stop loop after response
87 0           require Mojo::IOLoop;
88 0           Mojo::IOLoop->timer( 1 => sub { exit 0 } );
  0            
89             }
90             catch ($e) {
91 0           $c->render( text => 'OAuth Callback failed: ' . $e, status => 500 );
92 0           die $e;
93             }
94             }
95             else {
96 0           $c->render( text => 'Error: Missing code or state.', status => 400 );
97             }
98             }
99             );
100             say "Starting local server on port $port to catch the callback...";
101             $app->log->level('error');
102             require Mojo::Server::Daemon;
103              
104             # Listen on all interfaces but specific to the redirect_uri port
105             my $daemon = Mojo::Server::Daemon->new( app => $app, listen => [ 'http://127.0.0.1:' . $port ] );
106             $daemon->run();
107             return; # Exit method if listener ran (it blocks until finished or exits)
108             }
109             catch ($e) {
110             warn "Could not start listener: $e\nFalling back to manual input.\n";
111             }
112             }
113              
114             # Manual fallback (only reached if listen is false or failed)
115             say 'After authorizing, you will be redirected to a URL that looks like:';
116             say "$redirect_uri?code=...&state=...";
117             say "\nPlease paste the FULL redirect URL here:";
118             my $callback_url = ;
119             chomp $callback_url;
120             my ($code) = $callback_url =~ /[?&]code=([^&]+)/;
121             my ($state) = $callback_url =~ /[?&]state=([^&]+)/;
122              
123             if ( $code && $state ) {
124             $self->oauth_callback( $code, $state );
125             $on_success->($self);
126             }
127             else {
128             die "Could not find code and state in the provided URL.\n";
129             }
130             }
131             method firehose( $callback, $url = () ) { $self->at->firehose( $callback, $url ); }
132             method session () { $self->at->session; }
133             #
134             method did() { $self->at->did }
135              
136             # Feeds and content
137             method getTrendingTopics(%args) {
138             $self->_at_for('app.bsky.unspecced.getTrendingTopics')->get( 'app.bsky.unspecced.getTrendingTopics' => \%args );
139             }
140             method getTimeline(%args) { $self->_at_for('app.bsky.feed.getTimeline')->get( 'app.bsky.feed.getTimeline' => \%args ); }
141             method getAuthorFeed(%args) { $self->_at_for('app.bsky.feed.getAuthorFeed')->get( 'app.bsky.feed.getAuthorFeed' => \%args ); }
142             method getPostThread(%args) { $self->_at_for('app.bsky.feed.getPostThread')->get( 'app.bsky.feed.getPostThread' => \%args ); }
143              
144             method getFeed( $feed, %args ) {
145             my $res = $self->_at_for('app.bsky.feed.getFeed')->get( 'app.bsky.feed.getFeed' => { feed => $feed, %args } );
146             $res ? $res->{feed} // () : $res;
147             }
148              
149             method getFeedSkeleton( $feed, %args ) {
150             my $res = $self->_at_for('app.bsky.feed.getFeedSkeleton')->get( 'app.bsky.feed.getFeedSkeleton' => { feed => $feed, %args } );
151             $res ? $res->{feed} // () : $res;
152             }
153              
154             method getPost($uri) {
155             my $res = $self->_at_for('app.bsky.feed.getPosts')
156             ->get( 'app.bsky.feed.getPosts' => { uris => [ builtin::blessed $uri ? $uri->as_string : $uri ] } );
157             $res ? $res->{posts}[0] // () : $res;
158             }
159              
160             method getPosts(@uris) {
161             my $res = $self->_at_for('app.bsky.feed.getPosts')
162             ->get( 'app.bsky.feed.getPosts' => { uris => [ map { builtin::blessed $_ ? $_->as_string : $_ } @uris ] } );
163             $res ? $res->{posts} // () : $res;
164             }
165             method getLikes(%args) { $self->_at_for('app.bsky.feed.getLikes')->get( 'app.bsky.feed.getLikes' => \%args ); }
166              
167             method getBookmarks(%args) {
168             my $res = $self->_at_for('app.bsky.bookmark.getBookmarks')->get( 'app.bsky.bookmark.getBookmarks' => \%args );
169             $res ? $res->{bookmarks} // () : $res;
170             }
171              
172             method createBookmark( $uri, $cid //= () ) {
173             if ( !defined $cid ) {
174             my $post = $self->_at_for('app.bsky.feed.getPosts')->get( 'app.bsky.feed.getPosts' => { uris => [$uri] } );
175             $post || $post->throw;
176             $cid = $post->{posts}[0]{cid};
177             }
178             $self->_at_for('app.bsky.bookmark.createBookmark')->post( 'app.bsky.bookmark.createBookmark' => { uri => $uri, cid => $cid } );
179             }
180              
181             method deleteBookmark($uri) {
182             $self->_at_for('app.bsky.bookmark.deleteBookmark')->post( 'app.bsky.bookmark.deleteBookmark' => { uri => $uri } );
183             }
184              
185             method getQuotes(%args) {
186             my $res = $self->_at_for('app.bsky.feed.getQuotes')->get( 'app.bsky.feed.getQuotes' => \%args );
187             $res ? $res->{quotes} // () : $res;
188             }
189              
190             method getActorLikes(%args) {
191             my $res = $self->_at_for('app.bsky.feed.getActorLikes')->get( 'app.bsky.feed.getActorLikes' => \%args );
192             $res ? $res->{feed} // () : $res;
193             }
194              
195             method searchPosts(%args) {
196             my $res = $self->_at_for('app.bsky.feed.searchPosts')->get( 'app.bsky.feed.searchPosts' => \%args );
197             $res ? $res->{posts} // () : $res;
198             }
199              
200             method getSuggestedFeeds(%args) {
201             my $res = $self->_at_for('app.bsky.feed.getSuggestedFeeds')->get( 'app.bsky.feed.getSuggestedFeeds' => \%args );
202             $res ? $res->{feeds} // () : $res;
203             }
204             method describeFeedGenerator() { $self->_at_for('app.bsky.feed.describeFeedGenerator')->get('app.bsky.feed.describeFeedGenerator') }
205              
206             method getFeedGenerator($generator) {
207             $self->_at_for('app.bsky.feed.getFeedGenerator')->get( 'app.bsky.feed.getFeedGenerator' => { feed => $generator } );
208             }
209              
210             method getFeedGenerators(%args) {
211             my $res = $self->_at_for('app.bsky.feed.getFeedGenerators')->get( 'app.bsky.feed.getFeedGenerators' => \%args );
212             $res ? $res->{feeds} // () : $res;
213             }
214              
215             method getActorFeeds(%args) {
216             my $res = $self->_at_for('app.bsky.feed.getActorFeeds')->get( 'app.bsky.feed.getActorFeeds' => \%args );
217             $res ? $res->{feeds} // () : $res;
218             }
219              
220             method getRepostedBy(%args) {
221             my $res = $self->_at_for('app.bsky.feed.getRepostedBy')->get( 'app.bsky.feed.getRepostedBy' => \%args );
222             $res ? $res->{repostedBy} // () : $res;
223             }
224              
225             method repost( $uri, $cid //= () ) {
226             if ( !defined $cid ) {
227             my $post = $self->_at_for('app.bsky.feed.getPosts')->get( 'app.bsky.feed.getPosts' => { uris => [$uri] } );
228             $post || $post->throw;
229             $cid = $post->{posts}[0]{cid};
230             }
231             $self->at->create_record( 'app.bsky.feed.repost', { subject => { uri => $uri, cid => $cid }, createdAt => $self->at->_now->to_string } );
232             }
233              
234             method deleteRepost($url) {
235             $url = At::Protocol::URI->new($url) unless builtin::blessed $url;
236             if ( $url->collection eq 'app.bsky.feed.post' ) {
237             my $post = $self->getPost($url);
238             $url = $post->{viewer}{repost} // return;
239             }
240             $self->at->delete_record( 'app.bsky.feed.repost', $url->rkey );
241             }
242             method uploadBlob( $data, %opts ) { $self->at->upload_blob( $data, $opts{mime_type} // () ) }
243              
244             method createPost(%args) {
245              
246             # TODO:
247             # - recordWithMedia embed
248             #
249             my %post = ( # these are the required fields which every post must include
250             '$type' => 'app.bsky.feed.post',
251             text => $args{text} // '',
252             createdAt => $args{timestamp} // $self->at->_now->to_string # trailing "Z" is preferred over "+00:00"
253             );
254              
255             # indicate included languages (optional)
256             $post{langs} = [ ( ( builtin::reftype( $args{lang} ) // '' ) eq 'ARRAY' ) ? @{ $args{lang} } : $args{lang} ] if defined $args{lang};
257              
258             # parse out mentions and URLs as "facets"
259             if ( length $post{text} > 0 ) {
260             my @facets = $self->parse_facets( $post{text} );
261             $post{facets} = \@facets if @facets;
262             }
263              
264             # additional tags (up to 8)
265             $post{tags} = [ ( builtin::reftype( $args{tags} ) // '' ) eq 'ARRAY' ? @{ $args{tags} } : $args{tags} ] if defined $args{tags};
266              
267             # metadata tags on an atproto record, published by the author within the record (up to 10)
268             $post{labels} = {
269             '$type' => 'com.atproto.label.defs#selfLabels',
270             values => [
271             map { { '$type' => 'com.atproto.label.defs#selfLabel', val => $_ } }
272             ( ( builtin::reftype( $args{labels} ) // '' ) eq 'ARRAY' ? @{ $args{labels} } : $args{labels} )
273             ]
274             }
275             if defined $args{labels};
276              
277             #~ com.atproto.label.defs#selfLabels
278             # if this is a reply, get references to the parent and root
279             $post{reply} = $self->getReplyRefs( $args{reply_to} ) if defined $args{reply_to};
280              
281             # embeds
282             if ( defined $args{embed} ) {
283             if ( defined $args{embed}{images} ) {
284             $post{embed} = $self->uploadImages( @{ $args{embed}{images} } );
285             }
286             elsif ( defined $args{embed}{video} ) {
287             $post{embed} = $self->uploadVideo( $args{embed}{video} );
288             }
289             elsif ( defined $args{embed}{url} ) {
290             $post{embed} = $self->fetch_embed_url_card( $args{embed}{url} );
291             }
292             elsif ( defined $args{embed}{ref} ) {
293             $post{embed} = $self->getEmbedRef( $args{embed}{ref} );
294             }
295             }
296             my $res = $self->at->create_record( 'app.bsky.feed.post', \%post );
297              
298             # If reply_gate is requested, create a threadgate record
299             if ( $res && $res->{uri} && $args{reply_gate} ) {
300             my $post_uri = At::Protocol::URI->new( $res->{uri} );
301             my @allow;
302             if ( ref $args{reply_gate} eq 'ARRAY' ) {
303             for my $type ( @{ $args{reply_gate} } ) {
304             if ( $type eq 'mention' ) { push @allow, { '$type' => 'app.bsky.feed.threadgate#mentionRule' }; }
305             elsif ( $type eq 'following' ) { push @allow, { '$type' => 'app.bsky.feed.threadgate#followingRule' }; }
306             elsif ( $type eq 'list' ) { push @allow, { '$type' => 'app.bsky.feed.threadgate#listRule', list => $args{reply_gate_list} }; }
307             }
308             }
309             $self->at->create_record( 'app.bsky.feed.threadgate',
310             { post => $post_uri->as_string, allow => \@allow, createdAt => $self->at->_now->to_string, },
311             $post_uri->rkey ); # Must match post rkey
312             }
313              
314             # If post_gate is requested, create a postgate record
315             if ( $res && $res->{uri} && $args{post_gate} ) {
316             my $post_uri = At::Protocol::URI->new( $res->{uri} );
317             my @embedding_rules;
318             if ( ref $args{post_gate} eq 'ARRAY' ) {
319             for my $rule ( @{ $args{post_gate} } ) {
320             if ( $rule eq 'disable' ) {
321             push @embedding_rules, { '$type' => 'app.bsky.feed.postgate#disableRule' };
322             }
323             }
324             }
325             $self->at->create_record( 'app.bsky.feed.postgate',
326             { post => $post_uri->as_string, embeddingRules => \@embedding_rules, createdAt => $self->at->_now->to_string, },
327             $post_uri->rkey );
328             }
329             return $res;
330             }
331              
332             method deletePost($at_uri) {
333             $at_uri = At::Protocol::URI->new($at_uri) unless builtin::blessed $at_uri;
334             $self->at->delete_record( 'app.bsky.feed.post', $at_uri->rkey );
335              
336             # Automatically try to delete gates too
337             $self->at->delete_record( 'app.bsky.feed.threadgate', $at_uri->rkey );
338             $self->at->delete_record( 'app.bsky.feed.postgate', $at_uri->rkey );
339             }
340              
341             method like( $uri, $cid //= () ) {
342             if ( !defined $cid ) {
343             my $post = $self->_at_for('app.bsky.feed.getPosts')->get( 'app.bsky.feed.getPosts' => { uris => [$uri] } );
344             $post || $post->throw;
345             $cid = $post->{posts}[0]{cid};
346             }
347             $self->at->create_record(
348             'app.bsky.feed.like',
349             { '$type' => 'app.bsky.feed.like',
350             subject => { # com.atproto.repo.strongRef
351             uri => $uri,
352             cid => $cid
353             },
354             createdAt => $self->at->_now->to_string
355             }
356             );
357             }
358              
359             method deleteLike($url) {
360             $url = At::Protocol::URI->new($url) unless builtin::blessed $url;
361             if ( $url->collection eq 'app.bsky.feed.post' ) {
362             my $post = $self->getPost($url);
363             $url = $post->{viewer}{like} // return;
364             }
365             $self->at->delete_record( 'app.bsky.feed.like', $url->rkey );
366             }
367              
368             # Social graph
369             method block($actor) {
370             my $profile = $self->getProfile($actor);
371             $profile->{did} // return;
372             $self->at->create_record( 'app.bsky.graph.block', { createdAt => $self->at->_now->to_string, subject => $profile->{did} } );
373             }
374              
375             method getBlocks(%args) {
376             my $res = $self->_at_for('app.bsky.graph.getBlocks')->get( 'app.bsky.graph.getBlocks' => \%args );
377             $res ? $res->{blocks} : $res;
378             }
379              
380             method deleteBlock($url) {
381             $url = At::Protocol::URI->new($url) unless builtin::blessed $url;
382             $self->at->delete_record( 'app.bsky.graph.block', $url->rkey );
383             }
384              
385             method follow($subject) {
386             my $profile = $self->getProfile($subject);
387             $profile->{did} // return;
388             $self->at->create_record( 'app.bsky.graph.follow',
389             { '$type' => 'app.bsky.graph.follow', subject => $profile->{did}, createdAt => $self->at->_now->to_string } );
390             }
391              
392             method deleteFollow($url) {
393             $url = At::Protocol::URI->new($url) unless builtin::blessed $url;
394             $self->at->delete_record( 'app.bsky.graph.follow', $url->rkey );
395             }
396              
397             method getFollows( $actor, %args ) {
398             my $res = $self->_at_for('app.bsky.graph.getFollows')->get( 'app.bsky.graph.getFollows' => { actor => $actor, %args } );
399             $res ? $res->{follows} : $res;
400             }
401              
402             method getFollowers( $actor, %args ) {
403             my $res = $self->_at_for('app.bsky.graph.getFollowers')->get( 'app.bsky.graph.getFollowers' => { actor => $actor, %args } );
404             $res ? $res->{followers} : $res;
405             }
406              
407             method getKnownFollowers( $actor, %args ) {
408             my $res = $self->_at_for('app.bsky.graph.getKnownFollowers')->get( 'app.bsky.graph.getKnownFollowers' => { actor => $actor, %args } );
409             $res ? $res->{followers} : $res;
410             }
411              
412             method getRelationships(%args) {
413             $args{actor} //= $self->at->did;
414             if ( exists $args{actors} && !exists $args{others} ) {
415             $args{others} = delete $args{actors};
416             }
417             my $res = $self->_at_for('app.bsky.graph.getRelationships')->get( 'app.bsky.graph.getRelationships' => \%args );
418             $res ? $res->{relationships} : $res;
419             }
420              
421             method getMutes(%args) {
422             my $res = $self->_at_for('app.bsky.graph.getMutes')->get( 'app.bsky.graph.getMutes' => \%args );
423             $res ? $res->{mutes} // () : $res;
424             }
425             method muteThread($uri) { $self->_at_for('app.bsky.graph.muteThread')->post( 'app.bsky.graph.muteThread' => { root => $uri } ) }
426             method unmuteThread($uri) { $self->_at_for('app.bsky.graph.unmuteThread')->post( 'app.bsky.graph.unmuteThread' => { root => $uri } ) }
427              
428             method getLists( $actor, %args ) {
429             my $res = $self->_at_for('app.bsky.graph.getLists')->get( 'app.bsky.graph.getLists' => { actor => $actor, %args } );
430             $res ? $res->{lists} // () : $res;
431             }
432              
433             method getList( $list, %args ) {
434             my $res = $self->_at_for('app.bsky.graph.getList')->get( 'app.bsky.graph.getList' => { list => $list, %args } );
435             $res ? $res->{items} // () : $res;
436             }
437              
438             method getStarterPack($uri) {
439             $self->_at_for('app.bsky.graph.getStarterPack')->get( 'app.bsky.graph.getStarterPack' => { starterPack => $uri } );
440             }
441              
442             method getStarterPacks(@uris) {
443             my $res = $self->_at_for('app.bsky.graph.getStarterPacks')->get( 'app.bsky.graph.getStarterPacks' => { uris => \@uris } );
444             $res ? $res->{starterPacks} // () : $res;
445             }
446              
447             method getActorStarterPacks( $actor, %args ) {
448             my $res
449             = $self->_at_for('app.bsky.graph.getActorStarterPacks')->get( 'app.bsky.graph.getActorStarterPacks' => { actor => $actor, %args } );
450             $res ? $res->{starterPacks} // () : $res;
451             }
452              
453             # Actors
454             method getProfile($actor) { $self->_at_for('app.bsky.actor.getProfile')->get( 'app.bsky.actor.getProfile' => { actor => $actor } ) }
455              
456             method getPreferences() {
457             my $res = $self->_at_for('app.bsky.actor.getPreferences')->get('app.bsky.actor.getPreferences');
458             $res ? $res->{preferences} : $res;
459             }
460              
461             method putPreferences($preferences) {
462             $self->_at_for('app.bsky.actor.putPreferences')->post( 'app.bsky.actor.putPreferences' => { preferences => $preferences } );
463             }
464              
465             method upsertProfile($cb) {
466             my $profile = $self->_at_for('com.atproto.repo.getRecord')
467             ->get( 'com.atproto.repo.getRecord' => { repo => $self->at->did, collection => 'app.bsky.actor.profile', rkey => 'self' } );
468             my %existing = $profile ? %{ $profile->{value} } : ( '$type' => 'app.bsky.actor.profile' );
469             my $updated = $cb->(%existing);
470             my $res = $self->at->put_record( 'app.bsky.actor.profile', 'self', $updated, $profile ? $profile->{cid} : () );
471             $res // 1;
472             }
473              
474             method getProfiles(%args) {
475             my $res = $self->_at_for('app.bsky.actor.getProfiles')->get( 'app.bsky.actor.getProfiles' => \%args );
476             return $res ? ( $res->{profiles} // [] ) : [];
477             }
478              
479             method getSuggestions(%args) {
480             my $res = $self->_at_for('app.bsky.actor.getSuggestions')->get( 'app.bsky.actor.getSuggestions' => \%args );
481             $res ? $res->{actors} : $res;
482             }
483              
484             method searchActors(%args) {
485             my $res = $self->_at_for('app.bsky.actor.searchActors')->get( 'app.bsky.actor.searchActors' => \%args );
486             $res ? $res->{actors} : $res;
487             }
488              
489             method searchActorsTypeahead(%args) {
490             my $res = $self->_at_for('app.bsky.actor.searchActorsTypeahead')->get( 'app.bsky.actor.searchActorsTypeahead' => \%args );
491             $res ? $res->{actors} : $res;
492             }
493             method mute($actor) { $self->_at_for('app.bsky.graph.muteActor')->post( 'app.bsky.graph.muteActor' => { actor => $actor } ) }
494             method unmute($actor) { $self->_at_for('app.bsky.graph.unmuteActor')->post( 'app.bsky.graph.unmuteActor' => { actor => $actor } ) }
495              
496             method muteModList($listUri) {
497             $self->_at_for('app.bsky.graph.muteActorList')->post( 'app.bsky.graph.muteActorList' => { list => $listUri } );
498             }
499              
500             method unmuteModList($listUri) {
501             $self->_at_for('app.bsky.graph.unmuteActorList')->post( 'app.bsky.graph.unmuteActorList' => { list => $listUri } );
502             }
503              
504             method blockModList($listUri) {
505             $self->at->create_record( 'app.bsky.graph.listblock',
506             { '$type' => 'app.bsky.graph.listblock', subject => $listUri, createdAt => $self->at->_now->to_string } );
507             }
508              
509             # Moderation
510             method report ( $subject, $reason_type, $reason = () ) {
511             $self->_at_for('com.atproto.moderation.createReport')
512             ->post( 'com.atproto.moderation.createReport' =>
513             { subject => $subject, reasonType => $reason_type, defined $reason ? ( reason => $reason ) : () } );
514             }
515              
516             method unblockModList($url) {
517             $url = At::Protocol::URI->new($url) unless builtin::blessed $url;
518             $self->at->delete_record( 'app.bsky.graph.listblock', $url->rkey );
519             }
520              
521             # Notifications
522             method listNotifications(%args) {
523             my $res = $self->at->get( 'app.bsky.notification.listNotifications' => \%args );
524             $res ? $res->{notifications} : $res;
525             }
526              
527             method countUnreadNotifications() {
528             my $res = $self->at->get('app.bsky.notification.getUnreadCount');
529             $res ? $res->{count} : $res;
530             }
531              
532             method updateSeenNotifications( $seenAt = undef ) {
533             my $res = $self->at->post( 'app.bsky.notification.updateSeen' => { seenAt => $seenAt // $self->at->_now->to_string } );
534             $res // 1;
535             }
536              
537             # Identity
538             method resolveHandle($handle) {
539             my $res = $self->at->get( 'com.atproto.identity.resolveHandle' => { handle => $handle } );
540             $res ? $res->{did} : $res;
541             }
542              
543             method updateHandle($handle) {
544             $self->at->post( 'com.atproto.identity.updateHandle' => { handle => $handle } );
545             }
546             method describeServer() { $self->at->get('com.atproto.server.describeServer') }
547              
548             method listRecords(%args) {
549             my $res = $self->at->get( 'com.atproto.repo.listRecords' => \%args );
550             $res ? $res->{records} // () : $res;
551             }
552              
553             method getLabelerServices(%args) {
554             my $res = $self->at->get( 'app.bsky.labeler.getServices' => \%args );
555             $res ? $res->{views} // () : $res;
556             }
557              
558             # Chat
559             method listConvos(%args) {
560             my $res = $self->_at_for('chat.bsky.convo.listConvos')->get( 'chat.bsky.convo.listConvos' => \%args );
561             $res ? $res->{convos} // () : $res;
562             }
563              
564             method getConvo($convoId) {
565             my $res = $self->_at_for('chat.bsky.convo.getConvo')->get( 'chat.bsky.convo.getConvo' => { convoId => $convoId } );
566             $res ? $res->{convo} // () : $res;
567             }
568              
569             method getConvoForMembers(%args) {
570             my $res = $self->_at_for('chat.bsky.convo.getConvoForMembers')->get( 'chat.bsky.convo.getConvoForMembers' => \%args );
571             $res ? $res->{convo} // () : $res;
572             }
573              
574             method getMessages(%args) {
575             my $res = $self->_at_for('chat.bsky.convo.getMessages')->get( 'chat.bsky.convo.getMessages' => \%args );
576             $res ? $res->{messages} // () : $res;
577             }
578              
579             method sendMessage( $convoId, $message ) {
580             $self->_at_for('chat.bsky.convo.sendMessage')->post( 'chat.bsky.convo.sendMessage' => { convoId => $convoId, message => $message } );
581             }
582              
583             method acceptConvo($convoId) {
584             $self->_at_for('chat.bsky.convo.acceptConvo')->post( 'chat.bsky.convo.acceptConvo' => { convoId => $convoId } );
585             }
586              
587             method leaveConvo($convoId) {
588             $self->_at_for('chat.bsky.convo.leaveConvo')->post( 'chat.bsky.convo.leaveConvo' => { convoId => $convoId } );
589             }
590              
591             method updateRead( $convoId, $messageId = undef ) {
592             $self->_at_for('chat.bsky.convo.updateRead')->post( 'chat.bsky.convo.updateRead' => { convoId => $convoId, messageId => $messageId } );
593             }
594             method muteConvo($convoId) { $self->_at_for('chat.bsky.convo.muteConvo')->post( 'chat.bsky.convo.muteConvo' => { convoId => $convoId } ) }
595              
596             method unmuteConvo($convoId) {
597             $self->_at_for('chat.bsky.convo.unmuteConvo')->post( 'chat.bsky.convo.unmuteConvo' => { convoId => $convoId } );
598             }
599              
600             method addReaction( $convoId, $messageId, $reaction ) {
601             $self->_at_for('chat.bsky.convo.addReaction')
602             ->post( 'chat.bsky.convo.addReaction' => { convoId => $convoId, messageId => $messageId, reaction => $reaction } );
603             }
604              
605             method removeReaction( $convoId, $messageId, $reaction ) {
606             $self->_at_for('chat.bsky.convo.removeReaction')
607             ->post( 'chat.bsky.convo.removeReaction' => { convoId => $convoId, messageId => $messageId, reaction => $reaction } );
608             }
609              
610             method deleteMessageForSelf( $convoId, $messageId ) {
611             $self->_at_for('chat.bsky.convo.deleteMessageForSelf')
612             ->post( 'chat.bsky.convo.deleteMessageForSelf' => { convoId => $convoId, messageId => $messageId } );
613             }
614              
615             method getConvoAvailability(%args) {
616             $self->_at_for('chat.bsky.convo.getConvoAvailability')->get( 'chat.bsky.convo.getConvoAvailability' => \%args );
617             }
618             method getLog(%args) { $self->_at_for('chat.bsky.convo.getLog')->get( 'chat.bsky.convo.getLog' => \%args ) }
619              
620             # Utils
621             method parse_mentions($text) {
622             my @spans;
623             push @spans, { start => $-[1], handle => $2, end => $+[1] }
624             while $text =~ /(?:\A|\W)(@(([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))/g;
625             @spans;
626             }
627              
628             method parse_urls($text) {
629             my @spans;
630              
631             # partial/naive URL regex based on: https://stackoverflow.com/a/3809435
632             # tweaked to disallow some training punctuation
633             push @spans, { start => $-[1], url => $1, end => $+[1] }
634             while $text
635             =~ /(?:\A|\W)(https?:\/\/(www\.)?[-a-zA-Z0-9\@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9\(\)\@:%_\+.~#?&\/\/=]*[-a-zA-Z0-9@%_\+~#\/\/=])?)/g;
636             @spans;
637             }
638              
639             method parse_tags($text) {
640             my @spans;
641             push @spans, { start => $-[1], tag => $2, end => $+[1] } while $text =~ /(?:\A|\W)(#+(\w{1, 640}))/g;
642             @spans;
643             }
644              
645             method parse_facets($text) {
646             my @facets;
647             for my $m ( $self->parse_mentions($text) ) {
648             my $res = $self->at->get( 'com.atproto.identity.resolveHandle', { handle => $m->{handle} } );
649              
650             # if handle cannot be resolved, just skip it. Bluesky will display it as plain text
651             $res || next;
652             push @facets,
653             {
654             index => { byteStart => $m->{start}, byteEnd => $m->{end} },
655             features => [ { '$type' => 'app.bsky.richtext.facet#mention', did => $res->{did} } ]
656             };
657             }
658             for my $m ( $self->parse_urls($text) ) {
659             push @facets,
660             {
661             index => { byteStart => $m->{start}, byteEnd => $m->{end} },
662             features => [ { '$type' => 'app.bsky.richtext.facet#link', uri => $m->{url} } ]
663             };
664             }
665             for my $m ( $self->parse_tags($text) ) {
666             push @facets,
667             {
668             index => { byteStart => $m->{start}, byteEnd => $m->{end} },
669             features => [ { '$type' => 'app.bsky.richtext.facet#tag', tag => $m->{tag} } ]
670             };
671             }
672             @facets;
673             }
674              
675             method parse_uri($uri) {
676             require At::Protocol::URI; # Should already be loaded but...
677             $uri = At::Protocol::URI->new($uri) unless builtin::blessed $uri;
678             { repo => $uri->host, collection => $uri->collection, rkey => $uri->rkey };
679             }
680              
681             method getReplyRefs($parent_uri) {
682             my $res = $self->at->get( 'com.atproto.repo.getRecord', $self->parse_uri($parent_uri) );
683             $res || return;
684             my $root = my $parent = $res;
685             if ( $parent->{value}{reply} ) {
686             $root = $self->at->get( 'com.atproto.repo.getRecord', $self->parse_uri( $parent->{value}{reply}{root}{uri} ) );
687             $res ||= $parent; # escape hatch
688             }
689             { root => { uri => $root->{uri}, cid => $root->{cid} }, parent => { uri => $parent->{uri}, cid => $parent->{cid} } };
690             }
691              
692             method uploadFile( $bytes, $mime_type //= undef ) {
693             if ( builtin::blessed $bytes ) { $bytes = $bytes->slurp_raw }
694             elsif ( ( $^O eq 'MSWin32' ? $bytes !~ m/[\x00<>:"\/\\|?*]/ : 1 ) && -e $bytes ) {
695             $bytes = path($bytes)->slurp_raw;
696             }
697              
698             # TODO: a non-naive implementation would strip EXIF metadata from JPEG files here by default
699             my $determined_mime
700             = defined $mime_type ? $mime_type :
701             ( $bytes =~ /^GIF89a/ ? 'image/gif' :
702             $bytes =~ /^.{2}JFIF/ ? 'image/jpeg' :
703             $bytes =~ /^.{4}PNG\r\n\x1a\n/ ? 'image/png' :
704             $bytes =~ /^.{8}BM/ ? 'image/bmp' :
705             $bytes =~ /^.{4}(II|MM)\x42\x4D/ ? 'image/tiff' :
706             $bytes =~ /^.{4}8BPS/ ? 'image/psd' :
707             $bytes =~ /^data:image\/svg\+xml;/ ? 'image/svg+xml' :
708             $bytes =~ /^.{4}ftypqt / ? 'video/quicktime' :
709             $bytes =~ /^.{4}ftyp(isom|mp4[12]?|MSNV|M4[v|a]|f4v)/i ? 'video/mp4' :
710             'application/octet-stream' );
711             my $at_http = $self->at->http;
712             my $url = sprintf( '%s/xrpc/%s', $self->at->host, 'com.atproto.repo.uploadBlob' );
713             my %headers = ( 'Content-Type' => $determined_mime, ( $at_http->auth ? ( 'Authorization' => $at_http->auth ) : () ), );
714             $headers{DPoP} = $at_http->_generate_dpop_proof( $url, 'POST' ) if $at_http->token_type eq 'DPoP';
715             state $http //= HTTP::Tiny->new;
716             my $res = $http->post( $url, { content => $bytes, headers => \%headers } );
717             my $content = $res->{content};
718              
719             if ( $res->{success} ) {
720             $content = decode_json($content) if $content && ( $res->{headers}{'content-type'} // '' ) =~ m[json];
721             return $content->{blob};
722             }
723             my $msg = $res->{reason} // 'Unknown error';
724             if ( $content && ( $res->{headers}{'content-type'} // '' ) =~ m[json] ) {
725             my $json = decode_json($content);
726             $msg .= ': ' . $json->{message} if $json->{message};
727             }
728             return At::Error->new( message => $msg, fatal => 1 );
729             }
730              
731             method uploadImages(@images) {
732             my @ret;
733             for my $img (@images) {
734             my $alt = '';
735             my $mime = ();
736             if ( ( builtin::reftype($img) // '' ) eq 'HASH' ) {
737             $alt = $img->{alt};
738             $mime = $img->{mime} // ();
739             $img = $img->{image};
740             }
741             if ( builtin::blessed $img ) {
742             At::Error->new( message => 'image file size too large. 1000000 bytes maximum, got: ' . $img->size )->throw
743             if $img->size > 1000000;
744             $img = $img->slurp_raw;
745             }
746             elsif ( ( $^O eq 'MSWin32' ? $img !~ m/[\x00<>:"\/\\|?*]/ : 1 ) && -e $img ) {
747             $img = path($img);
748             At::Error->new( message => 'image file size too large. 1000000 bytes maximum, got: ' . $img->size )->throw
749             if $img->size > 1000000;
750             $img = path($img)->slurp_raw;
751             }
752             else {
753             At::Error->new( message => 'image file size too large. 1000000 bytes maximum, got: ' . length $img )->throw
754             if length $img > 1000000;
755             }
756             my $blob = $self->uploadFile( $img, $mime );
757             $blob || $blob->throw;
758             push @ret, { alt => $alt, image => $blob };
759             }
760             { '$type' => 'app.bsky.embed.images', images => \@ret };
761             }
762              
763             method uploadVideoCaption( $lang, $caption ) {
764             if ( builtin::blessed $caption ) {
765             At::Error->new( message => 'caption file size too large. 20000 bytes maximum, got: ' . $caption->size )->throw
766             if $caption->size > 20000;
767             $caption = $caption->slurp_raw;
768             }
769             elsif ( ( $^O eq 'MSWin32' ? $caption !~ m/[\x00<>:"\/\\|?*]/ : 1 ) && -e $caption ) {
770             $caption = path($caption);
771             At::Error->new( message => 'caption file size too large. 20000 bytes maximum, got: ' . $caption->size )->throw
772             if $caption->size > 20000;
773             $caption = path($caption)->slurp_raw;
774             }
775             else {
776             At::Error->new( message => 'cation file size too large. 20000 bytes maximum, got: ' . length $caption )->throw
777             if length $caption > 20000;
778             }
779             my $blob = $self->uploadFile( $caption, 'text/vtt' );
780             $blob || $blob->throw;
781             { '$type' => 'app.bsky.embed.video#caption', lang => $lang, file => $blob };
782             }
783              
784             method uploadVideo($vid) {
785             my @ret;
786             my ( $alt, $mime, $aspectRatio );
787             my @captions;
788             if ( ( builtin::reftype($vid) // '' ) eq 'HASH' ) {
789             $alt = $vid->{alt};
790             $mime = $vid->{mime} // ();
791             $aspectRatio = $vid->{aspectRatio};
792             @captions = map { { lang => $_, file => $self->uploadFile( $vid->{captions}{$_}, 'text/vtt' ) } } keys %{ $vid->{captions} };
793             $vid = $vid->{video};
794             }
795             if ( builtin::blessed $vid ) {
796             At::Error->new( message => 'video file size too large. 50000000 bytes maximum, got: ' . $vid->size )->throw if $vid->size > 50000000;
797             $vid = $vid->slurp_raw;
798             }
799             elsif ( ( $^O eq 'MSWin32' ? $vid !~ m/[\x00<>:"\/\\|?*]/ : 1 ) && -e $vid ) {
800             $vid = path($vid);
801             At::Error->new( message => 'video file size too large. 50000000 bytes maximum, got: ' . $vid->size )->throw if $vid->size > 50000000;
802             $vid = path($vid)->slurp_raw;
803             }
804             else {
805             At::Error->new( message => 'video file size too large. 50000000 bytes maximum, got: ' . length $vid )->throw
806             if length $vid > 50000000;
807             }
808             my $blob = $self->uploadFile( $vid, $mime );
809             $blob || return $blob->throw;
810             return {
811             '$type' => 'app.bsky.embed.video',
812             video => $blob,
813             ( @captions ? ( captions => \@captions ) : () ), ( defined $alt ? ( alt => $alt ) : () ),
814             ( defined $aspectRatio ? ( aspectRatio => $aspectRatio ) : () )
815             };
816             }
817              
818             method getEmbedRef($uri) {
819             my $res = $self->at->get( 'com.atproto.repo.getRecord', $self->parse_uri($uri) );
820             $res || return;
821             { '$type' => 'app.bsky.embed.record', record => { uri => $res->{uri}, cid => $res->{cid} } };
822             }
823              
824             method fetch_embed_url_card($url) {
825             my %card = ( uri => $url, title => '', description => '' );
826             state $http //= HTTP::Tiny->new;
827             my $res = $http->get($url);
828             if ( $res->{success} ) {
829             ( $card{title} ) = $res->{content} =~ m[(.*?).*]is;
830             ( $card{description} ) = ( $res->{content} =~ m[.*]is ) // '';
831             my ($image) = $res->{content} =~ m[]*>(?:)?]isp;
832             if ( defined $image ) {
833             if ( $image =~ /^data:/ ) {
834             $card{thumb} = $self->uploadFile($image);
835             }
836             else {
837             $res = $http->get( URI->new_abs( $image, $url ) );
838             $card{thumb} = $res->{success} ? $self->uploadFile( $res->{content}, $res->{headers}{'content-type'} ) : ();
839             }
840             }
841             }
842             { '$type' => 'app.bsky.embed.external', external => \%card };
843             }
844             }
845             };
846             #
847             1;