Bluesky

 view release on metacpan or  search on metacpan

Changes.md  view on Meta::CPAN


## [1.00] - 2026-03-09
### Added
- Added `oauth_helper` method to provide a streamlined, interactive OAuth flow with an optional built-in redirect listener.
- Implemented functional Chat (Direct Messages) support via PDS proxying (`atproto-proxy`).
- Added `threadgate` support to `createPost` via the `reply_gate` parameter, allowing users to control who can reply to their posts.
- Added `getKnownFollowers` method for advanced social discovery (mutual followers).
- Added `report` method for official content reporting via Ozone (`com.atproto.moderation.createReport`).
- Added robust service-aware routing in `_at_for` to handle repo, feed, and chat lexicons transparently.
- Updated examples: `eg/bsky_auth.pl` and `eg/bsky_chat.pl` are now fully operational.
- Implemented missing methods: `repost`, `deleteRepost`, `uploadBlob`, `deleteBlock`, `follow`, `deleteFollow`, `getFollows`, `getFollowers`, `getRepostedBy`.

### Changed
- Refactored all repository-related sugary methods (`repost`, `like`, `block`, `follow`, `createPost`, `upsertProfile`, etc.) to use new high-level `At.pm` helpers, resulting in a cleaner and more maintainable codebase.
- Standardized internal implementation to use accessor methods (e.g., `$self->at`) instead of direct field access, improving testability and robustness.

### Fixed
- Fixed `oauth_helper` to use standard `Mojolicious` objects instead of `Mojolicious::Lite`, resolving "Modification of a read-only value" errors.
- Corrected OAuth scopes to use `transition:generic` and `transition:chat.bsky` for reliable chat authorization.
- Fixed sender handle display in `bsky_chat.pl` by mapping member DIDs to handles.
- Support for Bookmarks: `getBookmarks`, `createBookmark`, `deleteBookmark`.

Changes.md  view on Meta::CPAN

- Notification features: `listNotifications`, `countUnreadNotifications`, `updateSeenNotifications`, `putNotificationPreferences`.
- Identity features: `resolveHandle`, `updateHandle`.
- Starter Pack support: `getStarterPack`, `getStarterPacks`, `getActorStarterPacks`.
- Drafts support: `getDrafts`, `createDraft`, `updateDraft`, `deleteDraft`.
- Chat (Direct Messages) support: `listConvos`, `getConvo`, `getConvoForMembers`, `getMessages`, `sendMessage`, `sendMessageToHandle`, `updateRead`, `muteConvo`, `unmuteConvo`.
- Video features: `getVideoUploadLimits`, `getVideoJobStatus`.
- Contact features: `importContacts`, `getContactMatches`.
- Miscellaneous methods: `describeServer`, `listRecords`, `getLabelerServices`.
- OAuth and Firehose wrapper methods.
- New examples (Bluesky auth, chat, firehose, etc.)
- Refactored `uploadFile` to use `HTTP::Tiny` directly, bypassing bugs in the underlying `At` module's UserAgent.

## [0.20] - 2024-12-31

### Added
- List, create, and delete records to block actors/accounts by name.
- Allow posts to be liked by their AT-URI.
- Allow likes to be deleted.
- Wrapping Bluesky's new `*.getTrendingTopics` lexicon.

## [0.19] - 2024-12-03

README.md  view on Meta::CPAN

    If undefined, the post is fetched to gather this for you.

## `deleteRepost( ... )`

```
$bsky->deleteRepost( 'at://did:plc:pwqewimhd3rxc4hg6ztwrcyj/app.bsky.feed.repost/3lcdwvquo7y25' );
```

Remove a repost record.

## `uploadBlob( ... )`

```perl
$bsky->uploadBlob( $data, mime_type => 'image/png' );
```

Upload a blob (file/data) to the PDS. This is a wrapper around `uploadFile`.

# Social Graph

Methods documented in this section deal with relationships between the authorized user and other members of the social
network.

## `block( ... )`

```
$bsky->block( 'sankorobinson.com' );

lib/Bluesky.pm  view on Meta::CPAN

        }

        method deleteRepost($url) {
            $url = At::Protocol::URI->new($url) unless builtin::blessed $url;
            if ( $url->collection eq 'app.bsky.feed.post' ) {
                my $post = $self->getPost($url);
                $url = $post->{viewer}{repost} // return;
            }
            $self->at->delete_record( 'app.bsky.feed.repost', $url->rkey );
        }
        method uploadBlob( $data, %opts ) { $self->at->upload_blob( $data, $opts{mime_type} // () ) }

        method createPost(%args) {

            # TODO:
            #   - recordWithMedia embed
            #
            my %post = (    # these are the required fields which every post must include
                '$type'   => 'app.bsky.feed.post',
                text      => $args{text}      // '',
                createdAt => $args{timestamp} // $self->at->_now->to_string    # trailing "Z" is preferred over "+00:00"

lib/Bluesky.pm  view on Meta::CPAN

                }
                if defined $args{labels};

            #~ com.atproto.label.defs#selfLabels
            # if this is a reply, get references to the parent and root
            $post{reply} = $self->getReplyRefs( $args{reply_to} ) if defined $args{reply_to};

            # embeds
            if ( defined $args{embed} ) {
                if ( defined $args{embed}{images} ) {
                    $post{embed} = $self->uploadImages( @{ $args{embed}{images} } );
                }
                elsif ( defined $args{embed}{video} ) {
                    $post{embed} = $self->uploadVideo( $args{embed}{video} );
                }
                elsif ( defined $args{embed}{url} ) {
                    $post{embed} = $self->fetch_embed_url_card( $args{embed}{url} );
                }
                elsif ( defined $args{embed}{ref} ) {
                    $post{embed} = $self->getEmbedRef( $args{embed}{ref} );
                }
            }
            my $res = $self->at->create_record( 'app.bsky.feed.post', \%post );

lib/Bluesky.pm  view on Meta::CPAN

            my $res = $self->at->get( 'com.atproto.repo.getRecord', $self->parse_uri($parent_uri) );
            $res || return;
            my $root = my $parent = $res;
            if ( $parent->{value}{reply} ) {
                $root = $self->at->get( 'com.atproto.repo.getRecord', $self->parse_uri( $parent->{value}{reply}{root}{uri} ) );
                $res ||= $parent;    # escape hatch
            }
            { root => { uri => $root->{uri}, cid => $root->{cid} }, parent => { uri => $parent->{uri}, cid => $parent->{cid} } };
        }

        method uploadFile( $bytes, $mime_type //= undef ) {
            if    ( builtin::blessed $bytes ) { $bytes = $bytes->slurp_raw }
            elsif ( ( $^O eq 'MSWin32' ? $bytes !~ m/[\x00<>:"\/\\|?*]/ : 1 ) && -e $bytes ) {
                $bytes = path($bytes)->slurp_raw;
            }

            # TODO: a non-naive implementation would strip EXIF metadata from JPEG files here by default
            my $determined_mime
                = defined $mime_type ? $mime_type :
                ( $bytes =~ /^GIF89a/ ? 'image/gif' :
                    $bytes =~ /^.{2}JFIF/                                  ? 'image/jpeg' :
                    $bytes =~ /^.{4}PNG\r\n\x1a\n/                         ? 'image/png' :
                    $bytes =~ /^.{8}BM/                                    ? 'image/bmp' :
                    $bytes =~ /^.{4}(II|MM)\x42\x4D/                       ? 'image/tiff' :
                    $bytes =~ /^.{4}8BPS/                                  ? 'image/psd' :
                    $bytes =~ /^data:image\/svg\+xml;/                     ? 'image/svg+xml' :
                    $bytes =~ /^.{4}ftypqt /                               ? 'video/quicktime' :
                    $bytes =~ /^.{4}ftyp(isom|mp4[12]?|MSNV|M4[v|a]|f4v)/i ? 'video/mp4' :
                    'application/octet-stream' );
            my $at_http = $self->at->http;
            my $url     = sprintf( '%s/xrpc/%s', $self->at->host, 'com.atproto.repo.uploadBlob' );
            my %headers = ( 'Content-Type' => $determined_mime, ( $at_http->auth ? ( 'Authorization' => $at_http->auth ) : () ), );
            $headers{DPoP} = $at_http->_generate_dpop_proof( $url, 'POST' ) if $at_http->token_type eq 'DPoP';
            state $http //= HTTP::Tiny->new;
            my $res     = $http->post( $url, { content => $bytes, headers => \%headers } );
            my $content = $res->{content};

            if ( $res->{success} ) {
                $content = decode_json($content) if $content && ( $res->{headers}{'content-type'} // '' ) =~ m[json];
                return $content->{blob};
            }
            my $msg = $res->{reason} // 'Unknown error';
            if ( $content && ( $res->{headers}{'content-type'} // '' ) =~ m[json] ) {
                my $json = decode_json($content);
                $msg .= ': ' . $json->{message} if $json->{message};
            }
            return At::Error->new( message => $msg, fatal => 1 );
        }

        method uploadImages(@images) {
            my @ret;
            for my $img (@images) {
                my $alt  = '';
                my $mime = ();
                if ( ( builtin::reftype($img) // '' ) eq 'HASH' ) {
                    $alt  = $img->{alt};
                    $mime = $img->{mime} // ();
                    $img  = $img->{image};
                }
                if ( builtin::blessed $img ) {

lib/Bluesky.pm  view on Meta::CPAN

                elsif ( ( $^O eq 'MSWin32' ? $img !~ m/[\x00<>:"\/\\|?*]/ : 1 ) && -e $img ) {
                    $img = path($img);
                    At::Error->new( message => 'image file size too large. 1000000 bytes maximum, got: ' . $img->size )->throw
                        if $img->size > 1000000;
                    $img = path($img)->slurp_raw;
                }
                else {
                    At::Error->new( message => 'image file size too large. 1000000 bytes maximum, got: ' . length $img )->throw
                        if length $img > 1000000;
                }
                my $blob = $self->uploadFile( $img, $mime );
                $blob || $blob->throw;
                push @ret, { alt => $alt, image => $blob };
            }
            { '$type' => 'app.bsky.embed.images', images => \@ret };
        }

        method uploadVideoCaption( $lang, $caption ) {
            if ( builtin::blessed $caption ) {
                At::Error->new( message => 'caption file size too large. 20000 bytes maximum, got: ' . $caption->size )->throw
                    if $caption->size > 20000;
                $caption = $caption->slurp_raw;
            }
            elsif ( ( $^O eq 'MSWin32' ? $caption !~ m/[\x00<>:"\/\\|?*]/ : 1 ) && -e $caption ) {
                $caption = path($caption);
                At::Error->new( message => 'caption file size too large. 20000 bytes maximum, got: ' . $caption->size )->throw
                    if $caption->size > 20000;
                $caption = path($caption)->slurp_raw;
            }
            else {
                At::Error->new( message => 'cation file size too large. 20000 bytes maximum, got: ' . length $caption )->throw
                    if length $caption > 20000;
            }
            my $blob = $self->uploadFile( $caption, 'text/vtt' );
            $blob || $blob->throw;
            { '$type' => 'app.bsky.embed.video#caption', lang => $lang, file => $blob };
        }

        method uploadVideo($vid) {
            my @ret;
            my ( $alt, $mime, $aspectRatio );
            my @captions;
            if ( ( builtin::reftype($vid) // '' ) eq 'HASH' ) {
                $alt         = $vid->{alt};
                $mime        = $vid->{mime} // ();
                $aspectRatio = $vid->{aspectRatio};
                @captions    = map { { lang => $_, file => $self->uploadFile( $vid->{captions}{$_}, 'text/vtt' ) } } keys %{ $vid->{captions} };
                $vid         = $vid->{video};
            }
            if ( builtin::blessed $vid ) {
                At::Error->new( message => 'video file size too large. 50000000 bytes maximum, got: ' . $vid->size )->throw if $vid->size > 50000000;
                $vid = $vid->slurp_raw;
            }
            elsif ( ( $^O eq 'MSWin32' ? $vid !~ m/[\x00<>:"\/\\|?*]/ : 1 ) && -e $vid ) {
                $vid = path($vid);
                At::Error->new( message => 'video file size too large. 50000000 bytes maximum, got: ' . $vid->size )->throw if $vid->size > 50000000;
                $vid = path($vid)->slurp_raw;
            }
            else {
                At::Error->new( message => 'video file size too large. 50000000 bytes maximum, got: ' . length $vid )->throw
                    if length $vid > 50000000;
            }
            my $blob = $self->uploadFile( $vid, $mime );
            $blob || return $blob->throw;
            return {
                '$type' => 'app.bsky.embed.video',
                video   => $blob,
                ( @captions            ? ( captions    => \@captions )   : () ), ( defined $alt ? ( alt => $alt ) : () ),
                ( defined $aspectRatio ? ( aspectRatio => $aspectRatio ) : () )
            };
        }

        method getEmbedRef($uri) {

lib/Bluesky.pm  view on Meta::CPAN

        method fetch_embed_url_card($url) {
            my %card = ( uri => $url, title => '', description => '' );
            state $http //= HTTP::Tiny->new;
            my $res = $http->get($url);
            if ( $res->{success} ) {
                ( $card{title} )       = $res->{content} =~ m[<title>(.*?)</title>.*</head>]is;
                ( $card{description} ) = ( $res->{content} =~ m[<meta name="description" content="(.*?)".+</meta>.*</head>]is ) // '';
                my ($image) = $res->{content} =~ m[<img.*?src="([^"]*)"[^>]*>(?:</img>)?]isp;
                if ( defined $image ) {
                    if ( $image =~ /^data:/ ) {
                        $card{thumb} = $self->uploadFile($image);
                    }
                    else {
                        $res = $http->get( URI->new_abs( $image, $url ) );
                        $card{thumb} = $res->{success} ? $self->uploadFile( $res->{content}, $res->{headers}{'content-type'} ) : ();
                    }
                }
            }
            { '$type' => 'app.bsky.embed.external', external => \%card };
        }
    }
};
#
1;

lib/Bluesky.pod  view on Meta::CPAN

If undefined, the post is fetched to gather this for you.

=back

=head2 C<deleteRepost( ... )>

    $bsky->deleteRepost( 'at://did:plc:pwqewimhd3rxc4hg6ztwrcyj/app.bsky.feed.repost/3lcdwvquo7y25' );

Remove a repost record.

=head2 C<uploadBlob( ... )>

    $bsky->uploadBlob( $data, mime_type => 'image/png' );

Upload a blob (file/data) to the PDS. This is a wrapper around C<uploadFile>.

=head1 Social Graph

Methods documented in this section deal with relationships between the authorized user and other members of the social
network.

=head2 C<block( ... )>

    $bsky->block( 'sankorobinson.com' );



( run in 1.548 second using v1.01-cache-2.11-cpan-b16cb0d3907 )