In this blog post, I will show you how to write programs in Go that post to Bluesky. Bluesky is a micro-blogging platform. Users can share short posts containing text, images, and videos. Bluesky implements the AT Protocol, an open communication protocol for distributed social networks.
The developers of the AT Protocol provide official libraries for Python, TypeScript, and Go.
Setup ¶
After you set up a new Go program with go mod init
, you can add the Bluesky library to your project
with the following command:
go get github.com/bluesky-social/indigo
Authentication ¶
To post a message to Bluesky, we need to authenticate with the Bluesky server. For this, we need the DID (Decentralized Identifier) of the user that we use for posting and the password.
The DID is a unique identifier that is used to identify users on the Bluesky network. It's different from the handle,
which is a human-readable username that can be changed by the user. The DID is permanent and cannot be changed.
The handle you see in the Bluesky app is just a friendly alias for the DID.
To retrieve a Bluesky DID from a handle, you can use the AT Protocol's resolveHandle
method.
You can use the following curl command to fetch the DID of a handle:
curl -X GET "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=<handle>"
The command above only works when you have a bsky.social
handle. If you run your own PDS server, you need
to send the request to your PDS server.
https://<my.domain.com>/xrpc/com.atproto.identity.resolveHandle?handle=<my-handle>
You can also resolve a handle with the SDKs. Here is an example in Go:
client := &xrpc.Client{Host: "https://bsky.social"}
handle, err := atproto.IdentityResolveHandle(context.Background(), client, "<my-handle>")
if err != nil {
return fmt.Errorf("failed to resolve handle: %w", err)
}
fmt.Println("Resolved handle:", handle.Did)
As with the curl command, if you use a self-hosted PDS server, you need to set the host in the client configuration to your PDS server.
For the password, you could use the password that you set up the account with. But it's better to use an app password. An app password has most of the same abilities as the user's account password. However, they're restricted from destructive actions such as account deletion or account migration. They are also restricted from creating additional app passwords. Additionally, you can specify if the app password should be able to access direct messages.
To create an app password, log in to the Bluesky app and go to Settings, then Privacy and Security, and then App Passwords.
For the following programs, I put the DID and the app password in a .env
file.
BLUESKY_IDENTIFIER=did:...
BLUESKY_PASSWORD=w...
Hello World ¶
With everything in place, we can start writing our first program. The following program posts a simple text message to Bluesky.
To read the .env file, I added the godotenv
library to the project
with this command: go get github.com/joho/godotenv
. The following code reads the .env file and sets up the client.
Replace bsky.social
with the address of your self-hosted PDS server if you use one.
client := &xrpc.Client{
Host: "https://bsky.social",
}
// Load .env file
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
did := os.Getenv("BLUESKY_IDENTIFIER")
appPassword := os.Getenv("BLUESKY_PASSWORD")
Next, the code sends a request with the DID and app password to the Bluesky server to create a session.
If the request is successful, the response contains a JWT token that can be used to authenticate future requests.
For that, a new client authClient
is created with the JWT token. Further requests will use this client.
auth, err := atproto.ServerCreateSession(
context.Background(),
client,
&atproto.ServerCreateSession_Input{
Identifier: did,
Password: appPassword,
},
)
if err != nil {
log.Fatal("Failed to authenticate:", err)
}
authClient := xrpc.Client{
Host: client.Host,
Auth: &xrpc.AuthInfo{AccessJwt: auth.AccessJwt},
}
The last part of the program creates a new post by creating a FeedPost
struct with the text of the message and
the creation time. The program then sends a request to the Bluesky server to post the message.
currentTime := time.Now()
post := &bsky.FeedPost{
Text: fmt.Sprintf("👋 Hello World! Posted from my Go program at %s", currentTime.Format("15:04:05")),
CreatedAt: currentTime.Format(time.RFC3339),
}
_, err = atproto.RepoCreateRecord(
context.Background(),
&authClient,
&atproto.RepoCreateRecord_Input{
Repo: auth.Did,
Collection: "app.bsky.feed.post",
Record: &util.LexiconTypeDecoder{Val: post},
},
)
If you run the program and everything is set up correctly, you should see a new post in the user's feed.
Earthquake ¶
In the next example, I show you how to create a program that posts a message to Bluesky every time an earthquake happens. The program will run periodically, fetch the latest earthquake data from the USGS Earthquake Hazards Program as a CSV file, filter the data, and post a message.
To prevent any duplicate posts, the program stores the IDs of the posted earthquakes in an embedded database.
I use Pebble for this. I added the database library with the command go get github.com/cockroachdb/pebble
to the project.
The first part of the program downloads the CSV file and reads the earthquake data into a slice of Earthquake
structs.
Thanks to the support for CSV files in the Go standard library (encoding/csv
), this is quite easy to do.
type Earthquake struct {
Time string
Mag float64
Status string
ID string
Place string
Type string
}
func main() {
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
resp, err := http.Get("https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_hour.csv")
if err != nil {
log.Fatal("Failed to download CSV:", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Fatalf("Unexpected status code: %d", resp.StatusCode)
}
reader := csv.NewReader(resp.Body)
headers, err := reader.Read()
if err != nil {
log.Fatal("Failed to read CSV header:", err)
}
var earthquakes []Earthquake
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
log.Fatal("Failed to read CSV record:", err)
}
quakeMap := make(map[string]string)
for i, h := range headers {
quakeMap[h] = record[i]
}
mag, err := strconv.ParseFloat(quakeMap["mag"], 64)
if err != nil {
log.Printf("Skipping invalid magnitude '%s' for ID %s", quakeMap["mag"], quakeMap["id"])
continue
}
earthquakes = append(earthquakes, Earthquake{
Time: quakeMap["time"],
Mag: mag,
Status: quakeMap["status"],
ID: quakeMap["id"],
Place: quakeMap["place"],
Type: quakeMap["type"],
})
}
After reading the data, the program filters the earthquakes by magnitude and status. We only want to post earthquakes with a magnitude of 5 or higher that have been reviewed. After that, the code opens the embedded database for storing the IDs of the posted earthquakes.
var filtered []Earthquake
for _, q := range earthquakes {
if q.Mag >= 5 && q.Status == "reviewed" {
filtered = append(filtered, q)
}
}
db, err := pebble.Open("quake-db", &pebble.Options{})
if err != nil {
log.Fatal("Failed to open database:", err)
}
defer db.Close()
The program now loops over the filtered earthquakes. For each earthquake, it checks if the ID is already in the database. If it's not, the program posts a message to Bluesky and stores the ID in the database.
for _, q := range filtered {
key := []byte(q.ID)
_, closer, err := db.Get(key)
if err == nil {
closer.Close()
continue
}
if !errors.Is(err, pebble.ErrNotFound) {
log.Printf("Database error for ID %s: %v", q.ID, err)
continue
}
t, err := time.Parse("2006-01-02T15:04:05Z", q.Time)
if err != nil {
log.Printf("Failed to parse time for ID %s: %v", q.ID, err)
continue
}
isoTimestamp := t.Format("2006-01-02 15:04:05 UTC")
msg := fmt.Sprintf("%.1f magnitude %s\n%s\n%s\nhttps://earthquake.usgs.gov/earthquakes/eventpage/%s/executive",
q.Mag, q.Type, isoTimestamp, q.Place, q.ID)
if err := postToBluesky(msg); err != nil {
log.Printf("Failed to post for ID %s: %v", q.ID, err)
continue
}
if err := db.Set(key, []byte("posted"), &pebble.WriteOptions{}); err != nil {
log.Printf("Failed to store ID %s: %v", q.ID, err)
}
}
_ = db.Flush()
For posting the message to Bluesky, the code calls the postToBluesky
method.
The first part of this method is similar to the one in the Hello World program. It creates a session with the Bluesky server and instantiates an authenticated client with the JWT token.
authClient := xrpc.Client{
Host: client.Host,
Auth: &xrpc.AuthInfo{AccessJwt: auth.AccessJwt},
}
This message contains a link to the USGS website with more information about the earthquake. Creating a link is done with a facet. Bluesky currently supports three kinds of facets:
app.bsky.richtext.facet#link
: A link to some resource. Has the uri attribute.app.bsky.richtext.facet#mention
: A mention of a user. Produces a notification for the mentioned user. Has the did attribute.app.bsky.richtext.facet#tag
: A hashtag. Has the tag attribute.
To create a facet, you need to know the range of the string you want to decorate.
The range has an inclusive start and an exclusive end. Note that the positions are byte positions, not character positions.
Bluesky uses UTF-8 code units to index facets. UTF-8 is a variable-length encoding that uses 1 to 4 bytes per character.
In this example, the message contains only ASCII characters, which are each encoded as a single byte in UTF-8.
Therefore, in this code, we can use strings.Index
and len
to determine the start and end positions of the text that
should be decorated with a link.
For more information on facets, see the Bluesky documentation.
linkStartPos := strings.Index(text, "https://")
linkEndPos := len(text)
link := text[linkStartPos:linkEndPos]
linkFacet := &bsky.RichtextFacet{
Features: []*bsky.RichtextFacet_Features_Elem{
{
RichtextFacet_Link: &bsky.RichtextFacet_Link{
Uri: link,
},
},
},
Index: &bsky.RichtextFacet_ByteSlice{
ByteEnd: int64(linkEndPos),
ByteStart: int64(linkStartPos),
},
}
After creating the facet, the rest of the code looks similar to the Hello World program. The only difference is that the language of the post is set to English and the facet is added to the post. Setting the language of a post is recommended, as it helps users that filter their feed by language.
post := &bsky.FeedPost{
Text: text,
Langs: []string{"en"},
CreatedAt: time.Now().Format(time.RFC3339),
Facets: []*bsky.RichtextFacet{linkFacet},
}
_, err = atproto.RepoCreateRecord(
context.Background(),
&authClient,
&atproto.RepoCreateRecord_Input{
Repo: auth.Did,
Collection: "app.bsky.feed.post",
Record: &util.LexiconTypeDecoder{Val: post},
},
)
Conclusion ¶
In this blog post, I showed you how to write a Go program that posts to Bluesky. The Go SDK makes it easy to interact with the Bluesky server. You can use the SDK for much more than just posting messages. It supports all the features of the AT Protocol, such as creating and updating records, following users, liking posts, and sending direct messages.
I hope this blog post was helpful to you. If you have any questions or feedback, feel free to reach out to me on Bluesky.
The earthquake bot I showed you in this blog post is live on this Bluesky account: @earthquake.rasc.ch
.