Subscribe and post RSS feeds to Bluesky
rss
bluesky
1package bluesky
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "sync"
8 "time"
9
10 "github.com/bluesky-social/indigo/api/atproto"
11 "github.com/bluesky-social/indigo/api/bsky"
12 lexutil "github.com/bluesky-social/indigo/lex/util"
13 "github.com/bluesky-social/indigo/xrpc"
14)
15
16// Client handles Bluesky API operations
17type Client struct {
18 xrpcClient *xrpc.Client
19 handle string
20 did string
21 config Config
22 mu sync.Mutex // protects token refresh
23}
24
25// Config holds configuration for Bluesky client
26type Config struct {
27 Handle string
28 Password string
29 PDS string // Personal Data Server URL (default: https://bsky.social)
30}
31
32// NewClient creates a new Bluesky client and authenticates
33func NewClient(ctx context.Context, cfg Config) (*Client, error) {
34 if cfg.PDS == "" {
35 cfg.PDS = "https://bsky.social"
36 }
37
38 xrpcClient := &xrpc.Client{
39 Host: cfg.PDS,
40 }
41
42 // Authenticate
43 auth, err := atproto.ServerCreateSession(ctx, xrpcClient, &atproto.ServerCreateSession_Input{
44 Identifier: cfg.Handle,
45 Password: cfg.Password,
46 })
47 if err != nil {
48 return nil, fmt.Errorf("failed to authenticate: %w", err)
49 }
50
51 xrpcClient.Auth = &xrpc.AuthInfo{
52 AccessJwt: auth.AccessJwt,
53 RefreshJwt: auth.RefreshJwt,
54 Handle: auth.Handle,
55 Did: auth.Did,
56 }
57
58 return &Client{
59 xrpcClient: xrpcClient,
60 handle: auth.Handle,
61 did: auth.Did,
62 config: cfg,
63 }, nil
64}
65
66// Post creates a new post on Bluesky
67func (c *Client) Post(ctx context.Context, text string) error {
68 // Create the post record
69 post := &bsky.FeedPost{
70 Text: text,
71 CreatedAt: time.Now().Format(time.RFC3339),
72 Langs: []string{"en"},
73 }
74
75 // Detect and add facets for links
76 facets := c.detectFacets(text)
77 if len(facets) > 0 {
78 post.Facets = facets
79 }
80
81 // Create the record
82 input := &atproto.RepoCreateRecord_Input{
83 Repo: c.did,
84 Collection: "app.bsky.feed.post",
85 Record: &lexutil.LexiconTypeDecoder{
86 Val: post,
87 },
88 }
89
90 _, err := atproto.RepoCreateRecord(ctx, c.xrpcClient, input)
91 if err != nil {
92 // Check if token expired and retry once after refresh
93 if c.isExpiredTokenError(err) {
94 if refreshErr := c.refreshSession(ctx); refreshErr != nil {
95 return fmt.Errorf("failed to create post: %w (refresh failed: %v)", err, refreshErr)
96 }
97 // Retry the post after refreshing
98 _, err = atproto.RepoCreateRecord(ctx, c.xrpcClient, input)
99 if err != nil {
100 return fmt.Errorf("failed to create post after refresh: %w", err)
101 }
102 return nil
103 }
104 return fmt.Errorf("failed to create post: %w", err)
105 }
106
107 return nil
108}
109
110// isExpiredTokenError checks if the error is due to an expired token
111func (c *Client) isExpiredTokenError(err error) bool {
112 if err == nil {
113 return false
114 }
115 errStr := err.Error()
116 return strings.Contains(errStr, "ExpiredToken") || strings.Contains(errStr, "Token has expired")
117}
118
119// refreshSession refreshes the authentication session
120func (c *Client) refreshSession(ctx context.Context) error {
121 c.mu.Lock()
122 defer c.mu.Unlock()
123
124 // Check if someone else already refreshed while we were waiting
125 if c.xrpcClient.Auth != nil && c.xrpcClient.Auth.RefreshJwt != "" {
126 // Try to use the refresh token
127 refresh, err := atproto.ServerRefreshSession(ctx, c.xrpcClient)
128 if err == nil {
129 c.xrpcClient.Auth.AccessJwt = refresh.AccessJwt
130 c.xrpcClient.Auth.RefreshJwt = refresh.RefreshJwt
131 return nil
132 }
133 // If refresh failed, fall through to re-authentication
134 }
135
136 // If refresh token doesn't work, re-authenticate with password
137 auth, err := atproto.ServerCreateSession(ctx, c.xrpcClient, &atproto.ServerCreateSession_Input{
138 Identifier: c.config.Handle,
139 Password: c.config.Password,
140 })
141 if err != nil {
142 return fmt.Errorf("failed to re-authenticate: %w", err)
143 }
144
145 c.xrpcClient.Auth = &xrpc.AuthInfo{
146 AccessJwt: auth.AccessJwt,
147 RefreshJwt: auth.RefreshJwt,
148 Handle: auth.Handle,
149 Did: auth.Did,
150 }
151
152 return nil
153}
154
155// detectFacets detects links in text and creates facets for them
156func (c *Client) detectFacets(text string) []*bsky.RichtextFacet {
157 var facets []*bsky.RichtextFacet
158
159 // Simple URL detection - looks for http:// or https://
160 words := strings.Fields(text)
161 currentPos := 0
162
163 for _, word := range words {
164 // Find the position of this word in the original text
165 idx := strings.Index(text[currentPos:], word)
166 if idx == -1 {
167 continue
168 }
169 currentPos += idx
170
171 // Check if it's a URL
172 if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
173 // Clean up any trailing punctuation
174 cleanURL := strings.TrimRight(word, ".,;:!?)")
175
176 facet := &bsky.RichtextFacet{
177 Index: &bsky.RichtextFacet_ByteSlice{
178 ByteStart: int64(currentPos),
179 ByteEnd: int64(currentPos + len(cleanURL)),
180 },
181 Features: []*bsky.RichtextFacet_Features_Elem{
182 {
183 RichtextFacet_Link: &bsky.RichtextFacet_Link{
184 Uri: cleanURL,
185 },
186 },
187 },
188 }
189 facets = append(facets, facet)
190 }
191
192 currentPos += len(word)
193 }
194
195 return facets
196}
197
198// GetHandle returns the authenticated user's handle
199func (c *Client) GetHandle() string {
200 return c.handle
201}
202
203// GetDID returns the authenticated user's DID
204func (c *Client) GetDID() string {
205 return c.did
206}