Group
Extension

Bluesky-Poster/lib/Bluesky/Poster.pm

package Bluesky::Poster;

use strict;
use warnings;

use Carp;
use LWP::UserAgent;
use JSON::MaybeXS qw(encode_json decode_json);
use Object::Configure;
use Params::Validate::Strict;
use Params::Get;
use URI;

=head1 NAME

Bluesky::Poster - Simple interface for posting to Bluesky (AT Protocol)

=head1 SYNOPSIS

  use Bluesky::Poster;

  my $poster = Bluesky::Poster->new(
	  identifier	 => 'your-identifier.bsky.social',
	  password => 'abcd-efgh-ijkl-mnop',
  );

  my $result = $poster->post("Hello from Perl!");
  print "Post URI: $result->{uri}\n";

=head1 DESCRIPTION

I've all but given up with X/Twitter.
It's API is overly complex and no longer freely available,
so I'm trying Bluesky.

This module authenticates with Bluesky using app passwords and posts text
messages using the AT Protocol API.

=head1 METHODS

=head2 new(identifier => ..., password => ...)

Constructs a new poster object and logs in.
The indentifier and password can also be read in from a configuration file,
as per L<Object::Configure>.

=cut

our $VERSION = '0.02';

sub new {
	my $proto = shift;
	my $class = ref($proto) || $proto;

	# Allow the identified and password to be read from a file
        my $params = Params::Validate::Strict::validate_strict({
		args => Object::Configure::configure($class, Params::Get::get_params(undef, @_ ? \@_ : undef)),
		schema => {
			# letters, numbers and full stops
			'identifier' => { type => 'string', 'min' => 2, matches => qr/^[a-zA-Z0-9.]+$/i },
			# 16 character hex 4-4-4-4
			'password' => { type => 'string', 'min' => 19, 'max' => 19, matches => qr/^[a-z0-9]{4}(?:\-[a-z0-9]{4}){3}$/ },
			'logger' => {},
			'config_path' => {}
		}
	});

	for my $required (qw(identifier password)) {
		if(!defined($params->{$required})) {
			if(my $logger = $params->{'logger'}) {
				$logger->error("Missing required parameter: $required");
			}
			croak "Missing required parameter: $required"
		}
	}

	my $self = {
		%{$params},
		agent	=> LWP::UserAgent->new,
		json => JSON::MaybeXS->new()->utf8->canonical,
		session	=> undef,
	};

	bless $self, $class;

	$self->_login();

	return $self;
}

sub _login {
	my $self = shift;

	my $ua = $self->{agent};

	my $res = $ua->post(
		'https://bsky.social/xrpc/com.atproto.server.createSession',
		'Content-Type' => 'application/json',
		Content => $self->{json}->encode({
			identifier => $self->{identifier},
			password => $self->{password},
		}),
	);

	unless ($res->is_success) {
		if(my $logger = $self->{'logger'}) {
			$logger->error('Login failed: ', $res->status_line, "\n", $res->decoded_content());
		}
		croak('Login failed: ', $res->status_line, "\n", $res->decoded_content());
	}

	$self->{session} = $self->{json}->decode($res->decoded_content);
}

=head2 post($text)

Posts the given text to your Bluesky feed.

=cut

sub post {
	my $self = shift;
	my $params = Params::Get::get_params('text', @_);
	my $text = $params->{'text'};

	if(!defined($text)) {
		if(my $logger = $self->{'logger'}) {
			$logger->error('Text is required');
		}
		croak 'Text is required';
	}

	my $iso_timestamp = _iso8601(time());

	my $payload = {
		repo => $self->{session}{did},
		collection => 'app.bsky.feed.post',
		record => {
			'$type' => 'app.bsky.feed.post',
			text => $text,
			createdAt => $iso_timestamp,
		},
	};

	my $res = $self->{agent}->post(
		'https://bsky.social/xrpc/com.atproto.repo.createRecord',
		'Content-Type' => 'application/json',
		'Authorization' => 'Bearer ' . $self->{session}{accessJwt},
		Content => $self->{json}->encode($payload),
	);

	unless ($res->is_success) {
		if(my $logger = $self->{'logger'}) {
			$logger->error('Post failed: ' . $res->status_line . "\n" . $res->decoded_content());
		}
		croak('Post failed: ', $res->status_line, "\n", $res->decoded_content());
	}

	return $self->{json}->decode($res->decoded_content);
}

sub _iso8601 {
	my $t = $_[0];
	my @gmt = gmtime($t);

	return sprintf(
		"%04d-%02d-%02dT%02d:%02d:%02dZ",
		$gmt[5]+1900, $gmt[4]+1, $gmt[3],
		$gmt[2], $gmt[1], $gmt[0],
	);
}

1;

=head1 AUTHOR

Nigel Horne, with some help from ChatGPT

=head1 SUPPORT

This module is provided as-is without any warranty.

=head1 LICENSE

This is free software; you can redistribute it and/or modify it under
the same terms as Perl itself.

=cut

__END__


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.