Subscribe and post RSS feeds to Bluesky
rss bluesky
at main 5.2 kB view raw
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}