Skip to content

Commit

Permalink
Add ATProto / Bluesky support
Browse files Browse the repository at this point in the history
  • Loading branch information
jlelse committed Nov 25, 2024
1 parent e5e0de2 commit 6c780d9
Show file tree
Hide file tree
Showing 7 changed files with 230 additions and 15 deletions.
186 changes: 186 additions & 0 deletions atproto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package main

import (
"cmp"
"context"
"fmt"
"net/http"
"regexp"
"time"

"github.com/carlmjohnson/requests"
"go.goblog.app/app/pkgs/contenttype"
)

func (a *goBlog) initAtproto() {
a.pPostHooks = append(a.pPostHooks, a.atprotoPost)
a.pDeleteHooks = append(a.pDeleteHooks, a.atprotoDelete)
a.pUndeleteHooks = append(a.pUndeleteHooks, a.atprotoPost)
}

func (at *configAtproto) enabled() bool {
if at == nil || !at.Enabled || at.Handle == "" || at.Password == "" {
return false
}
return true
}

const (
atprotoUriParam = "atprotouri"
atprotoUriPattern = `^at://([^/]+)/([^/]+)/([^/]+)$`
)

func (a *goBlog) atprotoPost(p *post) {
if atproto := a.getBlogFromPost(p).Atproto; atproto.enabled() && p.isPublicPublishedSectionPost() {
session, err := a.createAtprotoSession(atproto)
if err != nil {
a.error("Failed to create ATProto session", "err", err)
return
}
atp := a.toAtprotoPost(p)
resp, err := a.publishPost(atproto, session, atp)
if err != nil {
a.error("Failed to send post to ATProto", "err", err)
return
}
if resp.URI == "" {
// Not published
return
}
// Save URI to post
if err := a.db.replacePostParam(p.Path, atprotoUriParam, []string{resp.URI}); err != nil {
a.error("Failed to save ATProto URI", "err", err)
}
return
}
}

func (a *goBlog) atprotoDelete(p *post) {
if atproto := a.getBlogFromPost(p).Atproto; atproto.enabled() {
atprotouri := p.firstParameter(atprotoUriParam)
if atprotouri == "" {
return
}
// Delete record
session, err := a.createAtprotoSession(atproto)
if err != nil {
a.error("Failed to create ATProto session", "err", err)
return
}
if err := a.deleteAtprotoRecord(atproto, session, atprotouri); err != nil {
a.error("Failed to delete ATProto record", "err", err)
}
// Delete URI from post
if err := a.db.replacePostParam(p.Path, atprotoUriParam, []string{}); err != nil {
a.error("Failed to remove ATProto URI", "err", err)
}
return
}
}

func (at *configAtproto) pdsURL() string {
return cmp.Or(at.Pds, "https://bsky.social")
}

type atprotoSessionResponse struct {
AccessToken string `json:"accessJwt"` // JWT access token.
UserID string `json:"did"` // User identifier.
}

func (a *goBlog) createAtprotoSession(atproto *configAtproto) (*atprotoSessionResponse, error) {
var response atprotoSessionResponse
err := requests.URL(atproto.pdsURL() + "/xrpc/com.atproto.server.createSession").
Method(http.MethodPost).
Client(a.httpClient).
BodyJSON(map[string]string{
"identifier": atproto.Handle,
"password": atproto.Password,
}).
ContentType(contenttype.JSON).
ToJSON(&response).
Fetch(context.Background())
if err != nil {
return nil, err
}
return &response, nil
}

type atprotoPublishResponse struct {
URI string `json:"uri"`
}

func (a *goBlog) publishPost(atproto *configAtproto, session *atprotoSessionResponse, atpost *atprotoPost) (*atprotoPublishResponse, error) {
var resp atprotoPublishResponse
err := requests.URL(atproto.pdsURL()+"/xrpc/com.atproto.repo.createRecord").
Method(http.MethodPost).
Client(a.httpClient).
Header("Authorization", "Bearer "+session.AccessToken).
BodyJSON(map[string]any{
"repo": session.UserID,
"collection": "app.bsky.feed.post",
"record": atpost,
}).
ContentType(contenttype.JSON).
ToJSON(&resp).
Fetch(context.Background())
if err != nil {
return nil, err
}
return &resp, nil
}

func (a *goBlog) deleteAtprotoRecord(atproto *configAtproto, session *atprotoSessionResponse, uri string) error {
re := regexp.MustCompile(atprotoUriPattern)
matches := re.FindStringSubmatch(uri)
if matches == nil || len(matches) != 4 {
return fmt.Errorf("invalid URI format")
}
return requests.URL(atproto.pdsURL()+"/xrpc/com.atproto.repo.deleteRecord").
Method(http.MethodPost).
Client(a.httpClient).
Header("Authorization", "Bearer "+session.AccessToken).
BodyJSON(map[string]any{
"repo": matches[1],
"collection": matches[2],
"rkey": matches[3],
}).
ContentType(contenttype.JSON).
Fetch(context.Background())
}

type atprotoPost struct {
Type string `json:"$type"` // Type of the post.
Text string `json:"text"` // Text content of the post.
CreatedAt string `json:"createdAt"` // ISO 8601 timestamp of post creation.
Langs []string `json:"langs,omitempty"` // Optional languages the post supports.
Embed *atprotoEmbed `json:"embed,omitempty"`
}

type atprotoEmbed struct {
Type string `json:"$type"` // Type of the post.
External *atprotoEmbedExternal `json:"external,omitempty"`
}

type atprotoEmbedExternal struct {
URI string `json:"uri"`
Title string `json:"title"`
Description string `json:"description"`
}

func (a *goBlog) toAtprotoPost(p *post) *atprotoPost {
bc := a.getBlogFromPost(p)
return &atprotoPost{
Type: "app.bsky.feed.post",
Text: "",
CreatedAt: cmp.Or(toLocalSafe(p.Published), time.Now().Format(time.RFC3339)),
Langs: []string{bc.Lang},
Embed: &atprotoEmbed{
Type: "app.bsky.embed.external",
External: &atprotoEmbedExternal{
URI: a.getFullAddress(p.Path),
Title: cmp.Or(p.RenderedTitle, a.fallbackTitle(p), "-"),
Description: cmp.Or(a.postSummary(p), "-"),
},
},
}
}
8 changes: 8 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ type configBlog struct {
Map *configGeoMap `mapstructure:"map"`
Contact *configContact `mapstructure:"contact"`
Announcement *configAnnouncement `mapstructure:"announcement"`
Atproto *configAtproto `mapstructure:"atproto"`
// Configs read from database
hideOldContentWarning bool
hideShareButton bool
Expand Down Expand Up @@ -361,6 +362,13 @@ type configRobotsTxt struct {
BlockedBots []string `mapstructure:"blockedBots"`
}

type configAtproto struct {
Enabled bool `mapstructure:"enabled"`
Pds string `mapstructure:"pds"`
Handle string `mapstructure:"handle"`
Password string `mapstructure:"password"`
}

func (a *goBlog) loadConfigFile(file string) error {
// Use viper to load the config file
v := viper.New()
Expand Down
14 changes: 14 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,20 @@ This configuration creates a Fediverse account at `@[email protected]` wit
✅ Followers
❌ Following

## ATProto / Bluesky Support

Unlike ActivityPub, GoBlog won't act as a PDS (Personal Data Server), but will use your PDS's API to create a post with a link to newly published posts on your blog. As the Bluesky App View doesn't support showing updates yet, support for sending updates also isn't implemented yet.

In your blog config, add the following section:

```yaml
atproto:
enabled: true # Enable
pds: https://bsky.social # PDS, bsky.social is the default
handle: example.com # ATProto "username"
password: TOKEN # The password for the handle, on Bluesky create an app password
```

## Redirects & Aliases

Activate redirects by adding a `pathRedirects` section to your configuration file:
Expand Down
6 changes: 6 additions & 0 deletions example-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,12 @@ blogs:
enabled: true # Enable
chatId: "@telegram" # Chat ID, usually channel username
botToken: BOT-TOKEN # Telegram Bot Token
# Send link post to Bluesky / ATProto
atproto:
enabled: true # Enable
pds: https://bsky.social # PDS, bsky.social is the default
handle: example.com # ATProto "username"
password: TOKEN # The password for the handle, on Bluesky create an app password
# Comments
comments:
enabled: true # Enable comments
Expand Down
10 changes: 5 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ require (
github.com/alecthomas/chroma/v2 v2.14.0
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500
github.com/carlmjohnson/requests v0.24.2
github.com/carlmjohnson/requests v0.24.3
github.com/coder/websocket v1.8.12
github.com/dchest/captcha v1.0.0
github.com/dmulholl/mp3lib v1.0.0
github.com/elnormous/contenttype v1.0.4
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.21.3
github.com/go-ap/activitypub v0.0.0-20241114170014-e897df079e3d
github.com/go-ap/client v0.0.0-20241114180623-d0658a04422c
github.com/go-ap/activitypub v0.0.0-20241124171425-a40ea88b2b60
github.com/go-ap/client v0.0.0-20241124171814-fdaae75e78ba
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73
github.com/go-chi/chi/v5 v5.1.0
github.com/go-fed/httpsig v1.1.0
Expand Down Expand Up @@ -50,8 +50,8 @@ require (
github.com/sourcegraph/conc v0.3.0
github.com/spf13/cast v1.7.0
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0
github.com/tdewolff/minify/v2 v2.21.1
github.com/stretchr/testify v1.10.0
github.com/tdewolff/minify/v2 v2.21.2
github.com/tiptophelmet/cspolicy v0.1.1
github.com/tkrajina/gpxgo v1.4.0
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80
Expand Down
20 changes: 10 additions & 10 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMc
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 h1:6lhrsTEnloDPXyeZBvSYvQf8u86jbKehZPVDDlkgDl4=
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M=
github.com/carlmjohnson/requests v0.24.2 h1:JDakhAmTIKL/qL/1P7Kkc2INGBJIkIFP6xUeUmPzLso=
github.com/carlmjohnson/requests v0.24.2/go.mod h1:duYA/jDnyZ6f3xbcF5PpZ9N8clgopubP2nK5i6MVMhU=
github.com/carlmjohnson/requests v0.24.3 h1:LYcM/jVIVPkioigMjEAnBACXl2vb42TVqiC8EYNoaXQ=
github.com/carlmjohnson/requests v0.24.3/go.mod h1:duYA/jDnyZ6f3xbcF5PpZ9N8clgopubP2nK5i6MVMhU=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
Expand Down Expand Up @@ -67,10 +67,10 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/go-ap/activitypub v0.0.0-20241114170014-e897df079e3d h1:dIKWJ4tlGOE5Su/B7wcK9tAVbln4w/l88G47RVj3T2w=
github.com/go-ap/activitypub v0.0.0-20241114170014-e897df079e3d/go.mod h1:rpIPGre4qtTgSpVT0zz3hycAMuLtUt7BNngVNpyXhL8=
github.com/go-ap/client v0.0.0-20241114180623-d0658a04422c h1:+QbdY6Kcm2/9P2j+R1KT8ZyL/M9ClZDEHtOI5ilvSTU=
github.com/go-ap/client v0.0.0-20241114180623-d0658a04422c/go.mod h1:pmwMHroEma9F9+MM/4W5Xi9lbX1LsCiEAWIFydN2RgA=
github.com/go-ap/activitypub v0.0.0-20241124171425-a40ea88b2b60 h1:w8AlrtiSHNuBnCCQhVFL1wJVtFs0aLYd0arqTOWIjGI=
github.com/go-ap/activitypub v0.0.0-20241124171425-a40ea88b2b60/go.mod h1:rpIPGre4qtTgSpVT0zz3hycAMuLtUt7BNngVNpyXhL8=
github.com/go-ap/client v0.0.0-20241124171814-fdaae75e78ba h1:9vWxJAbH2qNlWn1AsO/bMysFwST9h3UnnxwAGOKjusc=
github.com/go-ap/client v0.0.0-20241124171814-fdaae75e78ba/go.mod h1:o5fcBVtw11g7nfNR/sDxnMULeyDIhl7emKFWMxe/tvE=
github.com/go-ap/errors v0.0.0-20240910140019-1e9d33cc1568 h1:eQEXAzWEijFbwtm/Hr2EtFbM0LvATRd1ltpDb+mfjQk=
github.com/go-ap/errors v0.0.0-20240910140019-1e9d33cc1568/go.mod h1:Vkh+Z3f24K8nMsJKXo1FHn5ebPsXvB/WDH5JRtYqdNo=
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 h1:GMKIYXyXPGIp+hYiWOhfqK4A023HdgisDT4YGgf99mw=
Expand Down Expand Up @@ -248,12 +248,12 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tdewolff/minify/v2 v2.21.1 h1:AAf5iltw6+KlUvjRNPAPrANIXl3XEJNBBzuZom5iCAM=
github.com/tdewolff/minify/v2 v2.21.1/go.mod h1:PoqFH8ugcuTUvKqVM9vOqXw4msxvuhL/DTmV5ZXhSCI=
github.com/tdewolff/minify/v2 v2.21.2 h1:VfTvmGVtBYhMTlUAeHtXM7XOsW0JT/6uMwUPPqgUs9k=
github.com/tdewolff/minify/v2 v2.21.2/go.mod h1:Olje3eHdBnrMjINKffDsil/3NV98Iv7MhWf7556WQVg=
github.com/tdewolff/parse/v2 v2.7.19 h1:7Ljh26yj+gdLFEq/7q9LT4SYyKtwQX4ocNrj45UCePg=
github.com/tdewolff/parse/v2 v2.7.19/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
Expand Down
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ func (app *goBlog) initComponents() {
}
app.initWebmention()
app.initTelegram()
app.initAtproto()
app.initBlogStats()
app.initTTS()
app.initSessions()
Expand Down

0 comments on commit 6c780d9

Please sign in to comment.