bluesky

package module
v0.0.0-...-dd72fcf Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 6, 2023 License: BSD-3-Clause Imports: 16 Imported by: 1

README

Bluesky API client from Go

API Reference Go Report Card

This is a Bluesky client library written in Go. It requires a Bluesky account to connect through and an application password to authenticate with.

This library is highly opinionated and built around my personal preferences as to how Go code should look and behave. My goals are simplicity and security rather than flexibility.

Disclaimer: The state of the library is not even pre-alpha. Everything can change, everything can blow up, nothing may work, the whole thing might get abandoned. Don't expect API stability.

Authentication

In order to authenticate to the Bluesky server, you will need a login handle and an application password. The handle might be an email address or a username recognized by the Bluesky server. The password, however, must be an application key. For security reasons this we will reject credentials that allow full access to your user.

import "errors"
import "github.com/karalabe/go-bluesky"

var (
	blueskyHandle = "example.com"
	blueskyAppkey = "1234-5678-9abc-def0"
)

func main() {
	ctx := context.Background()

	client, err := bluesky.Dial(ctx, bluesky.ServerBskySocial)
	if err != nil {
		panic(err)
	}
	defer client.Close()

	err = client.Login(ctx, blueskyHandle, blueskyAppkey)
	switch {
		case errors.Is(err, bluesky.ErrMasterCredentials):
			panic("You're not allowed to use your full-access credentials, please create an appkey")
		case errors.Is(err, bluesky.ErrLoginUnauthorized):
			panic("Username of application password seems incorrect, please double check")
		case err != nil:
			panic("Something else went wrong, please look at the returned error")
	}
}

Of course, most of the time you won't care about the errors broken down like that. Logging the error and failing is probably enough in general, the introspection is meant for strange power uses.

The above code will create a client authenticated against the given Bluesky server. The client will automatically refresh the authorization token internally when it closes in on expiration. The auth will be attempted to be refreshed async without blocking API calls if there's enough time left, or by blocking if it would be cutting it too close to expiration (or already expired).

Profiles and images

Any user's profile can be retrieved via the bluesky.Client.FetchProfile method. This will return some basic metadata about the user.

profile, err := client.FetchProfile(ctx, "karalabe.bsky.social")
if err != nil {
	panic(err)
}
fmt.Println("Name:", profile.Name)
fmt.Println("  - Followers:", profile.FollowerCount)
fmt.Println("  - Follows:", profile.FolloweeCount)
fmt.Println("  - Posts:", profile.PostCount)

Certain fields, like avatars and banner images are not retrieved by default since they are probably large and most use cases don't need them. If the image URLs are not enough, the images themselves can also be retrieved lazily into image.Image fields.

if err := profile.ResolveAvatar(ctx); err != nil {
	panic(err)
}
fmt.Println("Avatar size:", profile.Avatar.Bounds())

if err := profile.ResolveBanner(ctx); err != nil {
	panic(err)
}
fmt.Println("Banner size:", profile.Banner.Bounds())

Social graph

Being a social network, there's not much fun without being able to hop though the social graph. A user profile is a good starting point to do that! The list of followers and followees of a user can both be retrieved lazily after fetching the profile.

fmt.Println("User followed by:")
if err := profile.ResolveFollowers(ctx); err != nil {
	panic(err)
}
for _, follower := range profile.Followers {
	fmt.Println("  -", follower)
}

fmt.Println("User follows:")
if err := profile.ResolveFollowees(ctx); err != nil {
    panic(err)
}
for _, followee := range profile.Followees {
	fmt.Println("  -", followee)
}

The above resolvers are elegant, self-contained methods, but if the follower/followee count of a user is significant, it might be suboptimal to just wait hoping for the method to eventually return without using up too much memory. A more powerful way is to request the follower/followees to be returned as a stream as they are crawled!

fmt.Println("User followed by:")
followerc, errc := profile.StreamFollowers(ctx)
for follower := range followerc { // Pull the users from the channel as they arrive
	fmt.Println("  -", follower)
}
if err := <-errc; err != nil {
	panic(err)
}

fmt.Println("User follows:")
followeec, errc := profile.StreamFollowees(ctx)
for followee := range followeec { // Pull the users from the channel as they arrive
	fmt.Println("  -", followee)
}
if err := <-errc; err != nil {
	panic(err)
}

Of course, as with the user profiles, follower and followee items also contain certain lazy resolvable fields like the profile picture. In order however to crawl the social graph further, you will need to fetch the profile of a follower/followee first and go from there.

Custom API calls

As with any client library, there will inevitably come the time when the user wants to call something that is not wrapped (or not yet implemented because it's a new server feature). For those power use cases, the library exposes a custom caller that can be used to tap directly into the atproto APIs.

The custom caller will provide the user with an xrpc.Client that has valid user credentials and the user can do arbitrary atproto calls with it.

client.CustomCall(func(api *xrpc.Client) error {
	_, err := atproto.ServerGetSession(context.Background(), api)
	return err
})

Note, the user should not retain the xprc.Client given to the callback as this is only a copy of the internal one and will not be updated with new JWT tokens when the old ones are expired.

Testing

Oh boy, you're gonna freak out 😅. Since there's no Go implementation of a Bluesky API server and PDS, there's nothing to run the test against... apart from the live system 😱.

To run the tests, you will have to provide authentication credentials to interact with the official Bluesky server. Needless to say, your testing account may not become the most popular with all the potential spam it might generate, so be prepared to lose it. ¯_(ツ)_/¯

To run the tests, set the GOBLUESKY_TEST_HANDLE, GOBLUESKY_TEST_PASSWD and GOBLUESKY_TEST_APPKEY env vars and run the tests via the normal Go workflow.

$ export GOBLUESKY_TEST_HANDLE=example.com
$ export GOBLUESKY_TEST_PASSWD=my-pass-phrase
$ export GOBLUESKY_TEST_APPKEY=1234-5678-9abc-def0

$ go test -v

License

3-Clause BSD

Documentation

Index

Constants

View Source
const (
	// ServerBskySocial is the original Bluesky server operated by the team.
	ServerBskySocial = "https://bsky.social"
)

Variables

View Source
var (
	// ErrLoginUnauthorized is returned from a login attempt if the credentials
	// are rejected by the server or the local client (master credentials).
	ErrLoginUnauthorized = errors.New("unauthorized")

	// ErrMasterCredentials is returned from a login attempt if the credentials
	// are valid on the Bluesky server, but they are the user's master password.
	// Since that is a security malpractice, this library forbids it.
	ErrMasterCredentials = errors.New("master credentials used")

	// ErrSessionExpired is returned from any API call if the underlying session
	// has expired and a new login from scratch is required.
	ErrSessionExpired = errors.New("session expired")
)

Functions

This section is empty.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is an API client attached to (and authenticated to) a Bluesky PDS instance.

func Dial

func Dial(ctx context.Context, server string) (*Client, error)

Dial connects to a remote Bluesky server and exchanges some basic information to ensure the connectivity works.

func DialWithClient

func DialWithClient(ctx context.Context, server string, client *http.Client) (*Client, error)

DialWithClient connects to a remote Bluesky server using a user supplied HTTP client and exchanges some basic information to ensure the connectivity works.

func (*Client) Close

func (c *Client) Close() error

Close terminates the client, shutting down all pending tasks and background operations.

func (*Client) CustomCall

func (c *Client) CustomCall(callback func(client *xrpc.Client) error) error

CustomCall is a wildcard method for executing atproto API calls that are not (yet?) implemented by this library. The user needs to provide a callback that will receive an XRPC client to do direct atproto calls through.

Note, the caller should not hold onto the xrpc.Client. The client is a copy of the internal one and will not receive JWT token updates, so it *will* be a dud after the JWT expiration time passes.

func (*Client) FetchProfile

func (c *Client) FetchProfile(ctx context.Context, id string) (*Profile, error)

FetchProfile retrieves all the metadata about a specific user.

Supported IDs are the Bluesky handles or atproto DIDs.

func (*Client) Login

func (c *Client) Login(ctx context.Context, handle string, appkey string) error

Login authenticates to the Bluesky server with the given handle and appkey.

Note, authenticating with a live password instead of an application key will be detected and rejected. For your security, this library will refuse to use your master credentials.

type Profile

type Profile struct {
	Handle string // User-friendly - unstable - identifier for the user
	DID    string // Machine friendly - stable - identifier for the user
	Name   string // Display name to use in various apps
	Bio    string // Profile description to use in various apps

	AvatarURL string      // CDN URL to the user's profile picture, empty if unset
	Avatar    image.Image // Profile picture, nil if unset or not yet resolved

	BannerURL string      // CDN URL to the user's banner picture, empty if unset
	Banner    image.Image // Banner picture, nil if unset ot not yet resolved

	FollowerCount uint    // Number of people who follow this user
	Followers     []*User // Actual list of followers, nil if not yet resolved
	FolloweeCount uint    // Number of people who this user follows
	Followees     []*User // Actual list of followees, nil if not yet resolved

	PostCount uint // Number of posts this user made
	// contains filtered or unexported fields
}

Profile represents a user profile on a Bluesky server.

func (*Profile) ResolveAvatar

func (p *Profile) ResolveAvatar(ctx context.Context) error

ResolveAvatar resolves the profile avatar from the server URL and injects it into the profile itself. If the avatar (URL) is unset, the method will return success and leave the image in the profile nil.

Note, the method will place a sanity limit on the maximum size of the image in bytes to avoid malicious content. You may use the ResolveAvatarWithLimit to override and potentially disable this protection.

func (*Profile) ResolveAvatarWithLimit

func (p *Profile) ResolveAvatarWithLimit(ctx context.Context, bytes uint64) error

ResolveAvatarWithLimit resolves the profile avatar from the server URL using a custom data download limit (set to 0 to disable entirely) and injects it into the profile itself. If the avatar (URL) is unset, the method will return success and leave the image in the profile nil.

func (*Profile) ResolveBanner

func (p *Profile) ResolveBanner(ctx context.Context) error

ResolveBanner resolves the profile banner from the server URL and injects it into the profile itself. If the banner (URL) is unset, the method will return success and leave the image in the profile nil.

Note, the method will place a sanity limit on the maximum size of the image in bytes to avoid malicious content. You may use the ResolveBannerWithLimit to override and potentially disable this protection.

func (*Profile) ResolveBannerWithLimit

func (p *Profile) ResolveBannerWithLimit(ctx context.Context, bytes uint64) error

ResolveBannerWithLimit resolves the profile banner from the server URL using a custom data download limit (set to 0 to disable entirely) and injects it into the profile itself. If the banner (URL) is unset, the method will return success and leave the image in the profile nil.

func (*Profile) ResolveFollowees

func (p *Profile) ResolveFollowees(ctx context.Context) error

ResolveFollowees resolves the full list of followees of a profile and injects it into the profile itself.

Note, since there is a fairly low limit on retrievable followees per API call, this method might take a while to complete on larger accounts. You may use the StreamFollowees to have finer control over the rate of retrievals, interruptions and memory usage.

func (*Profile) ResolveFollowers

func (p *Profile) ResolveFollowers(ctx context.Context) error

ResolveFollowers resolves the full list of followers of a profile and injects it into the profile itself.

Note, since there is a fairly low limit on retrievable followers per API call, this method might take a while to complete on larger accounts. You may use the StreamFollowers to have finer control over the rate of retrievals, interruptions and memory usage.

func (*Profile) StreamFollowees

func (p *Profile) StreamFollowees(ctx context.Context) (<-chan *User, <-chan error)

StreamFollowees gradually resolves the full list of followees of a profile, feeding them async into a result channel, closing the channel when there are no more followees left. An error channel is also returned and will receive (optionally, only ever one) error in case of a failure.

Note, this method is meant to process the followeer list as a stream, and will thus not populate the profile's followees field.

func (*Profile) StreamFollowers

func (p *Profile) StreamFollowers(ctx context.Context) (<-chan *User, <-chan error)

StreamFollowers gradually resolves the full list of followers of a profile, feeding them async into a result channel, closing the channel when there are no more followers left. An error channel is also returned and will receive (optionally, only ever one) error in case of a failure.

Note, this method is meant to process the follower list as a stream, and will thus not populate the profile's followers field.

func (*Profile) String

func (p *Profile) String() string

String implements the stringer interface to help debug things.

type User

type User struct {
	Handle string // User-friendly - unstable - identifier for the follower
	DID    string // Machine friendly - stable - identifier for the follower
	Name   string // Display name to use in various apps
	Bio    string // Profile description to use in various apps

	AvatarURL string      // CDN URL to the user's profile picture, empty if unset
	Avatar    image.Image // Profile picture, nil if unset or not yet fetched
	// contains filtered or unexported fields
}

User tracks some metadata about a user on a Bluesky server.

func (*User) ResolveAvatar

func (u *User) ResolveAvatar(ctx context.Context) error

ResolveAvatar resolves the user avatar from the server URL and injects it into the user itself. If the avatar (URL) is unset, the method will return success and leave the image in the user nil.

Note, the method will place a sanity limit on the maximum size of the image in bytes to avoid malicious content. You may use the ResolveAvatarWithLimit to override and potentially disable this protection.

func (*User) ResolveAvatarWithLimit

func (u *User) ResolveAvatarWithLimit(ctx context.Context, bytes uint64) error

ResolveAvatarWithLimit resolves the user avatar from the server URL using a custom data download limit (set to 0 to disable entirely) and injects it into the user itself. If the avatar (URL) is unset, the method will return success and leave the image in the user nil.

func (*User) String

func (u *User) String() string

String implements the stringer interface to help debug things.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL