Subscribe and post RSS feeds to Bluesky
rss bluesky

Compare changes

Choose any two refs to compare.

-30
.env.example
··· 1 - # Bluesky RSS Post Configuration Example 2 - # Module: pkg.rbrt.fr/bskyrss 3 - # Copy this file to .env and fill in your values 4 - 5 - # Bluesky credentials 6 - # Your Bluesky handle (include the full domain, e.g., user.bsky.social) 7 - BSKY_HANDLE=your-handle.bsky.social 8 - 9 - # Your Bluesky App Password (NOT your main account password) 10 - # Create an App Password at: Settings → App Passwords 11 - BSKY_PASSWORD=your-app-password 12 - 13 - # RSS Feed URL(s) to monitor 14 - # This is the feed(s) that will be checked for new items 15 - # For multiple feeds, separate with commas 16 - RSS_FEED_URL=https://example.com/feed.xml 17 - # RSS_FEED_URL=https://blog1.com/rss,https://blog2.com/atom.xml,https://news.com/feed 18 - 19 - # Optional: Bluesky PDS server (default: https://bsky.social) 20 - # Only change this if you're using a self-hosted PDS 21 - # BSKY_PDS=https://bsky.social 22 - 23 - # Optional: Poll interval (default: 15m) 24 - # How often to check the RSS feed for new items 25 - # Examples: 5m, 15m, 1h, 30s 26 - # POLL_INTERVAL=15m 27 - 28 - # Optional: Storage file location (default: posted_items.txt) 29 - # File where posted item GUIDs are tracked to prevent duplicates 30 - # STORAGE_FILE=posted_items.txt
+1 -1
.gitignore
··· 4 4 *.dll 5 5 *.so 6 6 *.dylib 7 - bksy-rss-post 7 + bskyrss 8 8 9 9 # Test binary, built with `go test -c` 10 10 *.test
+3 -3
Dockerfile
··· 1 1 FROM golang:1.25-alpine AS builder 2 2 WORKDIR /build 3 3 COPY . . 4 - RUN go build -o bksyrss -ldflags="-w -s" . 4 + RUN go install -ldflags="-w -s" . 5 5 6 6 FROM alpine:latest 7 7 RUN apk --no-cache add ca-certificates 8 8 WORKDIR /app 9 - COPY --from=builder /build/bksyrss . 9 + COPY --from=builder /go/bin/bskyrss . 10 10 VOLUME ["/data"] 11 - ENTRYPOINT ["./bksyrss"] 11 + ENTRYPOINT ["./bskyrss"]
+42
config.example.yaml
··· 1 + # bskyrss configuration file 2 + # This file allows you to map different RSS/Atom feeds to different Bluesky accounts 3 + 4 + # List of accounts with their associated feeds 5 + accounts: 6 + # First account - posts from multiple feeds 7 + - handle: "user1.bsky.social" 8 + # Password can be plain text (not recommended) or environment variable reference 9 + # Use ${VAR_NAME} or $VAR_NAME format for environment variables 10 + password: "${BSKY_PASSWORD_USER1}" 11 + # Optional: Custom PDS server (defaults to https://bsky.social) 12 + pds: "https://bsky.social" 13 + # Optional: Custom storage file for this account (defaults to global storage with account suffix) 14 + # storage: "user1_posted.txt" 15 + feeds: 16 + - "https://example.com/feed.xml" 17 + - "https://blog.example.com/rss" 18 + - "https://news.example.com/atom.xml" 19 + 20 + # Second account - posts from different feeds 21 + - handle: "user2.bsky.social" 22 + password: "${BSKY_PASSWORD_USER2}" 23 + feeds: 24 + - "https://another-blog.com/feed.xml" 25 + - "https://tech-news.com/rss" 26 + 27 + # Third account - posts from a single feed 28 + - handle: "user3.bsky.social" 29 + password: "${BSKY_PASSWORD_USER3}" 30 + pds: "https://custom-pds.example.com" 31 + feeds: 32 + - "https://personal-blog.com/feed.xml" 33 + 34 + # Global settings (optional) 35 + # Poll interval for checking feeds (default: 15m) 36 + # Valid units: s, m, h (e.g., "30s", "5m", "1h") 37 + interval: "15m" 38 + 39 + # Default storage file for tracking posted items (default: posted_items.txt) 40 + # When multiple accounts are configured, separate files will be created automatically 41 + # (e.g., posted_items_user1_bsky_social.txt, posted_items_user2_bsky_social.txt) 42 + storage: "posted_items.txt"
+1
go.mod
··· 5 5 require ( 6 6 github.com/bluesky-social/indigo v0.0.0-20260103083015-78a1c1894f36 7 7 github.com/mmcdole/gofeed v1.3.0 8 + gopkg.in/yaml.v3 v3.0.1 8 9 ) 9 10 10 11 require (
+61
internal/bluesky/client.go
··· 4 4 "context" 5 5 "fmt" 6 6 "strings" 7 + "sync" 7 8 "time" 8 9 9 10 "github.com/bluesky-social/indigo/api/atproto" ··· 17 18 xrpcClient *xrpc.Client 18 19 handle string 19 20 did string 21 + config Config 22 + mu sync.Mutex // protects token refresh 20 23 } 21 24 22 25 // Config holds configuration for Bluesky client ··· 56 59 xrpcClient: xrpcClient, 57 60 handle: auth.Handle, 58 61 did: auth.Did, 62 + config: cfg, 59 63 }, nil 60 64 } 61 65 ··· 85 89 86 90 _, err := atproto.RepoCreateRecord(ctx, c.xrpcClient, input) 87 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 + } 88 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 111 + func (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 120 + func (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, 89 150 } 90 151 91 152 return nil
+106
internal/config/config.go
··· 1 + package config 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "strings" 7 + "time" 8 + 9 + "gopkg.in/yaml.v3" 10 + ) 11 + 12 + // Config represents the application configuration 13 + type Config struct { 14 + Accounts []Account `yaml:"accounts"` 15 + Interval time.Duration `yaml:"interval,omitempty"` 16 + Storage string `yaml:"storage,omitempty"` 17 + } 18 + 19 + // Account represents a Bluesky account with its associated feeds 20 + type Account struct { 21 + Handle string `yaml:"handle"` 22 + Password string `yaml:"password,omitempty"` 23 + PDS string `yaml:"pds,omitempty"` 24 + Feeds []string `yaml:"feeds"` 25 + Storage string `yaml:"storage,omitempty"` // Optional per-account storage file 26 + } 27 + 28 + // LoadFromFile loads configuration from a YAML file 29 + func LoadFromFile(path string) (*Config, error) { 30 + data, err := os.ReadFile(path) 31 + if err != nil { 32 + return nil, fmt.Errorf("failed to read config file: %w", err) 33 + } 34 + 35 + var cfg Config 36 + if err := yaml.Unmarshal(data, &cfg); err != nil { 37 + return nil, fmt.Errorf("failed to parse config file: %w", err) 38 + } 39 + 40 + if err := cfg.Validate(); err != nil { 41 + return nil, fmt.Errorf("invalid configuration: %w", err) 42 + } 43 + 44 + // Process environment variables in passwords 45 + for i := range cfg.Accounts { 46 + cfg.Accounts[i].Password = expandEnvVar(cfg.Accounts[i].Password) 47 + } 48 + 49 + // Set defaults 50 + if cfg.Interval == 0 { 51 + cfg.Interval = 15 * time.Minute 52 + } 53 + if cfg.Storage == "" { 54 + cfg.Storage = "posted_items.txt" 55 + } 56 + 57 + return &cfg, nil 58 + } 59 + 60 + // Validate checks if the configuration is valid 61 + func (c *Config) Validate() error { 62 + if len(c.Accounts) == 0 { 63 + return fmt.Errorf("at least one account is required") 64 + } 65 + 66 + for i, account := range c.Accounts { 67 + if account.Handle == "" { 68 + return fmt.Errorf("account %d: handle is required", i) 69 + } 70 + if len(account.Feeds) == 0 { 71 + return fmt.Errorf("account %d (%s): at least one feed is required", i, account.Handle) 72 + } 73 + if account.Password == "" { 74 + return fmt.Errorf("account %d (%s): password is required", i, account.Handle) 75 + } 76 + } 77 + 78 + return nil 79 + } 80 + 81 + // expandEnvVar expands environment variable references in the format ${VAR_NAME} or $VAR_NAME 82 + func expandEnvVar(value string) string { 83 + if value == "" { 84 + return value 85 + } 86 + 87 + // Handle ${VAR_NAME} format 88 + if strings.HasPrefix(value, "${") && strings.HasSuffix(value, "}") { 89 + varName := value[2 : len(value)-1] 90 + if envVal := os.Getenv(varName); envVal != "" { 91 + return envVal 92 + } 93 + return value 94 + } 95 + 96 + // Handle $VAR_NAME format 97 + if strings.HasPrefix(value, "$") { 98 + varName := value[1:] 99 + if envVal := os.Getenv(varName); envVal != "" { 100 + return envVal 101 + } 102 + return value 103 + } 104 + 105 + return value 106 + }
+256
internal/config/config_test.go
··· 1 + package config 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + "time" 8 + ) 9 + 10 + func TestLoadFromFile(t *testing.T) { 11 + // Create a temporary config file 12 + tmpDir := t.TempDir() 13 + configPath := filepath.Join(tmpDir, "config.yaml") 14 + 15 + configContent := ` 16 + accounts: 17 + - handle: "user1.bsky.social" 18 + password: "password1" 19 + pds: "https://bsky.social" 20 + feeds: 21 + - "https://feed1.com/rss" 22 + - "https://feed2.com/atom" 23 + - handle: "user2.bsky.social" 24 + password: "password2" 25 + feeds: 26 + - "https://feed3.com/rss" 27 + 28 + interval: "10m" 29 + storage: "custom_storage.txt" 30 + ` 31 + 32 + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { 33 + t.Fatalf("Failed to write config file: %v", err) 34 + } 35 + 36 + cfg, err := LoadFromFile(configPath) 37 + if err != nil { 38 + t.Fatalf("LoadFromFile failed: %v", err) 39 + } 40 + 41 + // Verify accounts 42 + if len(cfg.Accounts) != 2 { 43 + t.Errorf("Expected 2 accounts, got %d", len(cfg.Accounts)) 44 + } 45 + 46 + // Verify first account 47 + if cfg.Accounts[0].Handle != "user1.bsky.social" { 48 + t.Errorf("Expected handle 'user1.bsky.social', got '%s'", cfg.Accounts[0].Handle) 49 + } 50 + if cfg.Accounts[0].Password != "password1" { 51 + t.Errorf("Expected password 'password1', got '%s'", cfg.Accounts[0].Password) 52 + } 53 + if len(cfg.Accounts[0].Feeds) != 2 { 54 + t.Errorf("Expected 2 feeds for account 1, got %d", len(cfg.Accounts[0].Feeds)) 55 + } 56 + 57 + // Verify second account 58 + if cfg.Accounts[1].Handle != "user2.bsky.social" { 59 + t.Errorf("Expected handle 'user2.bsky.social', got '%s'", cfg.Accounts[1].Handle) 60 + } 61 + if len(cfg.Accounts[1].Feeds) != 1 { 62 + t.Errorf("Expected 1 feed for account 2, got %d", len(cfg.Accounts[1].Feeds)) 63 + } 64 + 65 + // Verify global settings 66 + if cfg.Interval != 10*time.Minute { 67 + t.Errorf("Expected interval 10m, got %v", cfg.Interval) 68 + } 69 + if cfg.Storage != "custom_storage.txt" { 70 + t.Errorf("Expected storage 'custom_storage.txt', got '%s'", cfg.Storage) 71 + } 72 + } 73 + 74 + func TestLoadFromFileWithEnvVars(t *testing.T) { 75 + // Set environment variable 76 + os.Setenv("TEST_PASSWORD", "env-password") 77 + defer os.Unsetenv("TEST_PASSWORD") 78 + 79 + tmpDir := t.TempDir() 80 + configPath := filepath.Join(tmpDir, "config.yaml") 81 + 82 + configContent := ` 83 + accounts: 84 + - handle: "user1.bsky.social" 85 + password: "${TEST_PASSWORD}" 86 + feeds: 87 + - "https://feed1.com/rss" 88 + ` 89 + 90 + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { 91 + t.Fatalf("Failed to write config file: %v", err) 92 + } 93 + 94 + cfg, err := LoadFromFile(configPath) 95 + if err != nil { 96 + t.Fatalf("LoadFromFile failed: %v", err) 97 + } 98 + 99 + if cfg.Accounts[0].Password != "env-password" { 100 + t.Errorf("Expected password 'env-password' from env var, got '%s'", cfg.Accounts[0].Password) 101 + } 102 + } 103 + 104 + func TestLoadFromFileDefaults(t *testing.T) { 105 + tmpDir := t.TempDir() 106 + configPath := filepath.Join(tmpDir, "config.yaml") 107 + 108 + // Minimal config with only required fields 109 + configContent := ` 110 + accounts: 111 + - handle: "user1.bsky.social" 112 + password: "password1" 113 + feeds: 114 + - "https://feed1.com/rss" 115 + ` 116 + 117 + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { 118 + t.Fatalf("Failed to write config file: %v", err) 119 + } 120 + 121 + cfg, err := LoadFromFile(configPath) 122 + if err != nil { 123 + t.Fatalf("LoadFromFile failed: %v", err) 124 + } 125 + 126 + // Check defaults 127 + if cfg.Interval != 15*time.Minute { 128 + t.Errorf("Expected default interval 15m, got %v", cfg.Interval) 129 + } 130 + if cfg.Storage != "posted_items.txt" { 131 + t.Errorf("Expected default storage 'posted_items.txt', got '%s'", cfg.Storage) 132 + } 133 + 134 + } 135 + 136 + func TestLoadFromFileInvalid(t *testing.T) { 137 + tests := []struct { 138 + name string 139 + content string 140 + wantErr bool 141 + }{ 142 + { 143 + name: "no accounts", 144 + content: ` 145 + interval: "15m" 146 + `, 147 + wantErr: true, 148 + }, 149 + { 150 + name: "account without handle", 151 + content: ` 152 + accounts: 153 + - password: "password1" 154 + feeds: 155 + - "https://feed1.com/rss" 156 + `, 157 + wantErr: true, 158 + }, 159 + { 160 + name: "account without password", 161 + content: ` 162 + accounts: 163 + - handle: "user1.bsky.social" 164 + feeds: 165 + - "https://feed1.com/rss" 166 + `, 167 + wantErr: true, 168 + }, 169 + { 170 + name: "account without feeds", 171 + content: ` 172 + accounts: 173 + - handle: "user1.bsky.social" 174 + password: "password1" 175 + feeds: [] 176 + `, 177 + wantErr: true, 178 + }, 179 + } 180 + 181 + for _, tt := range tests { 182 + t.Run(tt.name, func(t *testing.T) { 183 + tmpDir := t.TempDir() 184 + configPath := filepath.Join(tmpDir, "config.yaml") 185 + 186 + if err := os.WriteFile(configPath, []byte(tt.content), 0644); err != nil { 187 + t.Fatalf("Failed to write config file: %v", err) 188 + } 189 + 190 + _, err := LoadFromFile(configPath) 191 + if (err != nil) != tt.wantErr { 192 + t.Errorf("LoadFromFile() error = %v, wantErr %v", err, tt.wantErr) 193 + } 194 + }) 195 + } 196 + } 197 + 198 + func TestExpandEnvVar(t *testing.T) { 199 + tests := []struct { 200 + name string 201 + input string 202 + envKey string 203 + envValue string 204 + want string 205 + }{ 206 + { 207 + name: "expand ${VAR}", 208 + input: "${TEST_VAR}", 209 + envKey: "TEST_VAR", 210 + envValue: "test-value", 211 + want: "test-value", 212 + }, 213 + { 214 + name: "expand $VAR", 215 + input: "$TEST_VAR", 216 + envKey: "TEST_VAR", 217 + envValue: "test-value", 218 + want: "test-value", 219 + }, 220 + { 221 + name: "no expansion plain text", 222 + input: "plain-text", 223 + envKey: "TEST_VAR", 224 + envValue: "test-value", 225 + want: "plain-text", 226 + }, 227 + { 228 + name: "env var not set ${VAR}", 229 + input: "${NONEXISTENT}", 230 + envKey: "OTHER_VAR", 231 + envValue: "test-value", 232 + want: "${NONEXISTENT}", 233 + }, 234 + { 235 + name: "env var not set $VAR", 236 + input: "$NONEXISTENT", 237 + envKey: "OTHER_VAR", 238 + envValue: "test-value", 239 + want: "$NONEXISTENT", 240 + }, 241 + } 242 + 243 + for _, tt := range tests { 244 + t.Run(tt.name, func(t *testing.T) { 245 + if tt.envKey != "" { 246 + os.Setenv(tt.envKey, tt.envValue) 247 + defer os.Unsetenv(tt.envKey) 248 + } 249 + 250 + got := expandEnvVar(tt.input) 251 + if got != tt.want { 252 + t.Errorf("expandEnvVar(%q) = %q, want %q", tt.input, got, tt.want) 253 + } 254 + }) 255 + } 256 + }
+3 -10
internal/rss/feed.go
··· 30 30 } 31 31 32 32 // FetchLatestItems fetches the latest items from an RSS feed 33 - func (c *Checker) FetchLatestItems(ctx context.Context, feedURL string, limit int) ([]*FeedItem, error) { 33 + func (c *Checker) FetchLatestItems(ctx context.Context, feedURL string) ([]*FeedItem, error) { 34 34 feed, err := c.parser.ParseURLWithContext(feedURL, ctx) 35 35 if err != nil { 36 36 return nil, fmt.Errorf("failed to parse RSS feed: %w", err) ··· 41 41 } 42 42 43 43 // Limit the number of items to return 44 - maxItems := len(feed.Items) 45 - if limit > 0 && limit < maxItems { 46 - maxItems = limit 47 - } 48 - 49 - items := make([]*FeedItem, 0, maxItems) 50 - for i := 0; i < maxItems; i++ { 51 - item := feed.Items[i] 52 - 44 + items := make([]*FeedItem, 0, len(feed.Items)) 45 + for _, item := range feed.Items { 53 46 published := time.Now() 54 47 if item.PublishedParsed != nil { 55 48 published = *item.PublishedParsed
+5 -17
internal/rss/feed_test.go
··· 53 53 54 54 // Test fetching all items 55 55 t.Run("FetchAllItems", func(t *testing.T) { 56 - items, err := checker.FetchLatestItems(context.Background(), server.URL, 0) 56 + items, err := checker.FetchLatestItems(context.Background(), server.URL) 57 57 if err != nil { 58 58 t.Fatalf("Failed to fetch items: %v", err) 59 59 } ··· 74 74 } 75 75 }) 76 76 77 - // Test limiting items 78 - t.Run("FetchLimitedItems", func(t *testing.T) { 79 - items, err := checker.FetchLatestItems(context.Background(), server.URL, 2) 80 - if err != nil { 81 - t.Fatalf("Failed to fetch items: %v", err) 82 - } 83 - 84 - if len(items) != 2 { 85 - t.Errorf("Expected 2 items, got %d", len(items)) 86 - } 87 - }) 88 - 89 77 // Test with context timeout 90 78 t.Run("ContextTimeout", func(t *testing.T) { 91 79 ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) ··· 93 81 94 82 time.Sleep(2 * time.Millisecond) // Ensure context is expired 95 83 96 - _, err := checker.FetchLatestItems(ctx, server.URL, 0) 84 + _, err := checker.FetchLatestItems(ctx, server.URL) 97 85 if err == nil { 98 86 t.Error("Expected error with expired context, got nil") 99 87 } ··· 103 91 func TestFetchLatestItems_InvalidURL(t *testing.T) { 104 92 checker := NewChecker() 105 93 106 - _, err := checker.FetchLatestItems(context.Background(), "not-a-valid-url", 0) 94 + _, err := checker.FetchLatestItems(context.Background(), "not-a-valid-url") 107 95 if err == nil { 108 96 t.Error("Expected error with invalid URL, got nil") 109 97 } ··· 127 115 defer server.Close() 128 116 129 117 checker := NewChecker() 130 - items, err := checker.FetchLatestItems(context.Background(), server.URL, 0) 118 + items, err := checker.FetchLatestItems(context.Background(), server.URL) 131 119 132 120 if err != nil { 133 121 t.Fatalf("Expected no error with empty feed, got: %v", err) ··· 163 151 defer server.Close() 164 152 165 153 checker := NewChecker() 166 - items, err := checker.FetchLatestItems(context.Background(), server.URL, 0) 154 + items, err := checker.FetchLatestItems(context.Background(), server.URL) 167 155 168 156 if err != nil { 169 157 t.Fatalf("Failed to fetch items: %v", err)
+164 -122
main.go
··· 5 5 "flag" 6 6 "fmt" 7 7 "log" 8 + "net/url" 8 9 "os" 9 10 "os/signal" 10 11 "strings" ··· 12 13 "time" 13 14 14 15 "pkg.rbrt.fr/bskyrss/internal/bluesky" 16 + "pkg.rbrt.fr/bskyrss/internal/config" 15 17 "pkg.rbrt.fr/bskyrss/internal/rss" 16 18 "pkg.rbrt.fr/bskyrss/internal/storage" 17 19 ) ··· 24 26 25 27 func main() { 26 28 // Command line flags 27 - feedURLs := flag.String("feed", "", "RSS/Atom feed URL(s) to monitor (comma-delimited for multiple feeds, required)") 28 - handle := flag.String("handle", "", "Bluesky handle (required)") 29 - password := flag.String("password", "", "Bluesky password (can also use BSKY_PASSWORD env var)") 30 - pds := flag.String("pds", "https://bsky.social", "Bluesky PDS server URL") 31 - pollInterval := flag.Duration("interval", defaultPollInterval, "Poll interval for checking RSS feed") 32 - storageFile := flag.String("storage", defaultStorageFile, "File to store posted item GUIDs") 29 + configFile := flag.String("config", "config.yaml", "Path to configuration file (YAML)") 33 30 dryRun := flag.Bool("dry-run", false, "Don't actually post to Bluesky, just show what would be posted") 34 31 flag.Parse() 35 32 36 - // Validate required flags 37 - if *feedURLs == "" { 38 - log.Fatal("Error: -feed flag is required") 39 - } 40 - if *handle == "" { 41 - log.Fatal("Error: -handle flag is required") 42 - } 43 - 44 - // Parse comma-delimited feed URLs 45 - feeds := parseFeedURLs(*feedURLs) 46 - if len(feeds) == 0 { 47 - log.Fatal("Error: no valid feed URLs provided") 48 - } 49 - log.Printf("Monitoring %d feed(s)", len(feeds)) 50 - 51 - // Get password from flag or environment variable 52 - bskyPassword := *password 53 - if bskyPassword == "" { 54 - bskyPassword = os.Getenv("BSKY_PASSWORD") 55 - if bskyPassword == "" { 56 - log.Fatal("Error: -password flag or BSKY_PASSWORD environment variable is required") 57 - } 33 + // Load configuration from file 34 + if *configFile == "" { 35 + log.Fatal("Error: -config flag is required") 58 36 } 59 37 60 - store, err := storage.New(*storageFile) 38 + cfg, err := config.LoadFromFile(*configFile) 61 39 if err != nil { 62 - log.Fatalf("Failed to initialize storage: %v", err) 40 + log.Fatalf("Failed to load config file: %v", err) 63 41 } 42 + log.Printf("Loaded configuration with %d account(s)", len(cfg.Accounts)) 64 43 65 - // Determine if this is the first run (no items in storage) 66 - isFirstRun := store.Count() == 0 67 - if isFirstRun { 68 - log.Println("First run detected - will mark existing items as seen without posting") 69 - } else { 70 - log.Printf("Storage initialized with %d previously posted items", store.Count()) 44 + if *dryRun { 45 + log.Println("Running in DRY-RUN mode - no posts will be made") 71 46 } 72 47 73 - rssChecker := rss.NewChecker() 74 - 75 - // Initialize Bluesky client (unless dry-run mode) 76 - var bskyClient *bluesky.Client 77 - if !*dryRun { 78 - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 79 - defer cancel() 80 - 81 - bskyClient, err = bluesky.NewClient(ctx, bluesky.Config{ 82 - Handle: *handle, 83 - Password: bskyPassword, 84 - PDS: *pds, 85 - }) 86 - if err != nil { 87 - log.Fatalf("Failed to initialize Bluesky client: %v", err) 88 - } 89 - log.Printf("Authenticated as @%s", bskyClient.GetHandle()) 90 - } else { 91 - log.Println("Running in DRY-RUN mode - no posts will be made") 48 + // Count total feeds across all accounts 49 + totalFeeds := 0 50 + for _, account := range cfg.Accounts { 51 + totalFeeds += len(account.Feeds) 92 52 } 53 + log.Printf("Monitoring %d feed(s) across %d account(s)", totalFeeds, len(cfg.Accounts)) 93 54 94 55 // Setup signal handling for graceful shutdown 95 56 ctx, cancel := context.WithCancel(context.Background()) ··· 104 65 cancel() 105 66 }() 106 67 107 - // Main loop 108 - log.Printf("Poll interval: %s", *pollInterval) 68 + // Initialize managers for each account 69 + managers := make([]*AccountManager, 0, len(cfg.Accounts)) 70 + for i, account := range cfg.Accounts { 71 + // Determine storage file for this account 72 + storageFilePath := cfg.Storage 73 + if account.Storage != "" { 74 + storageFilePath = account.Storage 75 + } else if len(cfg.Accounts) > 1 { 76 + // For multiple accounts, use separate storage files by default 77 + ext := ".txt" 78 + base := strings.TrimSuffix(cfg.Storage, ext) 79 + storageFilePath = fmt.Sprintf("%s_%s%s", base, sanitizeHandle(account.Handle), ext) 80 + } 109 81 110 - ticker := time.NewTicker(*pollInterval) 82 + manager, err := NewAccountManager(ctx, account, storageFilePath, *dryRun) 83 + if err != nil { 84 + log.Fatalf("Failed to initialize account %d (%s): %v", i+1, account.Handle, err) 85 + } 86 + managers = append(managers, manager) 87 + log.Printf("Initialized account: @%s with %d feed(s) (storage: %s)", account.Handle, len(account.Feeds), storageFilePath) 88 + } 89 + 90 + log.Printf("Poll interval: %s", cfg.Interval) 91 + 92 + ticker := time.NewTicker(cfg.Interval) 111 93 defer ticker.Stop() 112 94 113 95 // Check immediately on startup 114 - if err := checkAndPostFeeds(ctx, rssChecker, bskyClient, store, feeds, *dryRun, isFirstRun); err != nil { 115 - log.Printf("Error during initial check: %v", err) 96 + for _, manager := range managers { 97 + if err := manager.CheckAndPost(ctx); err != nil { 98 + log.Printf("Error during initial check for @%s: %v", manager.account.Handle, err) 99 + } 116 100 } 117 101 118 - // Continue checking on interval (not first run anymore after first check) 102 + // Continue checking on interval 119 103 for { 120 104 select { 121 105 case <-ctx.Done(): 122 106 return 123 107 case <-ticker.C: 124 - if err := checkAndPostFeeds(ctx, rssChecker, bskyClient, store, feeds, *dryRun, false); err != nil { 125 - log.Printf("Error during check: %v", err) 108 + for _, manager := range managers { 109 + if err := manager.CheckAndPost(ctx); err != nil { 110 + log.Printf("Error during check for @%s: %v", manager.account.Handle, err) 111 + } 126 112 } 127 113 } 128 114 } 129 115 } 130 116 131 - // parseFeedURLs splits comma-delimited feed URLs and trims whitespace 132 - func parseFeedURLs(feedString string) []string { 133 - if feedString == "" { 134 - return nil 117 + // AccountManager manages RSS checking and posting for a single Bluesky account 118 + type AccountManager struct { 119 + account config.Account 120 + bskyClient *bluesky.Client 121 + rssChecker *rss.Checker 122 + store *storage.Storage 123 + dryRun bool 124 + } 125 + 126 + // NewAccountManager creates a new account manager 127 + func NewAccountManager(ctx context.Context, account config.Account, storageFile string, dryRun bool) (*AccountManager, error) { 128 + store, err := storage.New(storageFile) 129 + if err != nil { 130 + return nil, fmt.Errorf("failed to initialize storage: %w", err) 135 131 } 136 132 137 - parts := strings.Split(feedString, ",") 138 - feeds := make([]string, 0, len(parts)) 133 + rssChecker := rss.NewChecker() 134 + 135 + var bskyClient *bluesky.Client 136 + if !dryRun { 137 + authCtx, cancel := context.WithTimeout(ctx, 30*time.Second) 138 + defer cancel() 139 139 140 - for _, part := range parts { 141 - trimmed := strings.TrimSpace(part) 142 - if trimmed != "" { 143 - feeds = append(feeds, trimmed) 140 + pds := account.PDS 141 + if pds == "" { 142 + pds = "https://bsky.social" 143 + } 144 + 145 + bskyClient, err = bluesky.NewClient(authCtx, bluesky.Config{ 146 + Handle: account.Handle, 147 + Password: account.Password, 148 + PDS: pds, 149 + }) 150 + if err != nil { 151 + return nil, fmt.Errorf("failed to initialize Bluesky client: %w", err) 144 152 } 145 153 } 146 154 147 - return feeds 155 + return &AccountManager{ 156 + account: account, 157 + bskyClient: bskyClient, 158 + rssChecker: rssChecker, 159 + store: store, 160 + dryRun: dryRun, 161 + }, nil 148 162 } 149 163 150 - func checkAndPostFeeds(ctx context.Context, rssChecker *rss.Checker, bskyClient *bluesky.Client, store *storage.Storage, feedURLs []string, dryRun bool, isFirstRun bool) error { 151 - for _, feedURL := range feedURLs { 152 - if err := checkAndPost(ctx, rssChecker, bskyClient, store, feedURL, dryRun, isFirstRun); err != nil { 153 - log.Printf("Error checking feed %s: %v", feedURL, err) 164 + // CheckAndPost checks all feeds for this account and posts new items 165 + func (m *AccountManager) CheckAndPost(ctx context.Context) error { 166 + for _, feedURL := range m.account.Feeds { 167 + if err := m.checkAndPostFeed(ctx, feedURL); err != nil { 168 + log.Printf("[@%s] Error checking feed %s: %v", m.account.Handle, feedURL, err) 154 169 // Continue with other feeds even if one fails 155 170 } 156 171 } 157 - 158 172 return nil 159 173 } 160 174 161 - func checkAndPost( 162 - ctx context.Context, 163 - rssChecker *rss.Checker, 164 - bskyClient *bluesky.Client, 165 - store *storage.Storage, 166 - feedURL string, 167 - dryRun bool, 168 - isFirstRun bool, 169 - ) error { 170 - log.Printf("Checking RSS feed: %s", feedURL) 175 + // checkAndPostFeed checks a single feed and posts new items 176 + func (m *AccountManager) checkAndPostFeed(ctx context.Context, feedURL string) error { 177 + log.Printf("[@%s] Checking RSS feed: %s", m.account.Handle, feedURL) 171 178 172 - limit := 20 173 - items, err := rssChecker.FetchLatestItems(ctx, feedURL, limit) 179 + items, err := m.rssChecker.FetchLatestItems(ctx, feedURL) 174 180 if err != nil { 175 181 return fmt.Errorf("failed to fetch RSS items: %w", err) 176 182 } 177 183 178 - log.Printf("Found %d items in feed", len(items)) 184 + log.Printf("[@%s] Found %d items in feed", m.account.Handle, len(items)) 185 + 186 + // Check if this is the first time seeing this feed (no items from it in storage) 187 + hasSeenFeedBefore := false 188 + for _, item := range items { 189 + if m.store.IsPosted(item.GUID) { 190 + hasSeenFeedBefore = true 191 + break 192 + } 193 + } 179 194 180 195 // Process items in reverse order (oldest first) 181 196 newItemCount := 0 ··· 185 200 item := items[i] 186 201 187 202 // Skip if already posted 188 - if store.IsPosted(item.GUID) { 203 + if m.store.IsPosted(item.GUID) { 189 204 continue 190 205 } 191 206 192 207 newItemCount++ 193 208 194 - // On first run, just mark items as seen without posting 195 - if isFirstRun { 196 - if err := store.MarkPosted(item.GUID); err != nil { 197 - log.Printf("Failed to mark item as seen: %v", err) 209 + // If this is first time seeing this feed, mark items as seen without posting 210 + if !hasSeenFeedBefore { 211 + if err := m.store.MarkPosted(item.GUID); err != nil { 212 + log.Printf("[@%s] Failed to mark item as seen: %v", m.account.Handle, err) 198 213 } 199 214 continue 200 215 } 201 216 202 - log.Printf("New item found: %s", item.Title) 217 + log.Printf("[@%s] New item found: %s", m.account.Handle, item.Title) 203 218 204 219 // Create post text 205 220 postText := formatPost(item) 206 221 207 - if dryRun { 208 - log.Printf("[DRY-RUN] Would post:\n%s\n", postText) 222 + if m.dryRun { 223 + log.Printf("[@%s] [DRY-RUN] Would post:\n%s\n", m.account.Handle, postText) 209 224 postedCount++ 210 225 } else { 211 226 // Post to Bluesky 212 227 postCtx, cancel := context.WithTimeout(ctx, 30*time.Second) 213 - err := bskyClient.Post(postCtx, postText) 228 + err := m.bskyClient.Post(postCtx, postText) 214 229 cancel() 215 230 216 231 if err != nil { 217 - log.Printf("Failed to post item '%s': %v", item.Title, err) 232 + log.Printf("[@%s] Failed to post item '%s': %v", m.account.Handle, item.Title, err) 218 233 continue 219 234 } 220 235 221 - log.Printf("Successfully posted: %s", item.Title) 236 + log.Printf("[@%s] Successfully posted: %s", m.account.Handle, item.Title) 222 237 postedCount++ 223 238 } 224 239 225 240 // Mark as posted 226 - if err := store.MarkPosted(item.GUID); err != nil { 227 - log.Printf("Failed to mark item as posted: %v", err) 241 + if err := m.store.MarkPosted(item.GUID); err != nil { 242 + log.Printf("[@%s] Failed to mark item as posted: %v", m.account.Handle, err) 228 243 } 229 244 230 - // Rate limiting - wait a bit between posts to avoid overwhelming Bluesky 231 - if postedCount > 0 && !dryRun && !isFirstRun { 245 + // Rate limiting ourselves to not get rate limited. 246 + if postedCount > 0 && !m.dryRun { 232 247 time.Sleep(2 * time.Second) 233 248 } 234 249 } 235 250 236 - if isFirstRun { 251 + if !hasSeenFeedBefore { 237 252 if newItemCount > 0 { 238 - log.Printf("Marked %d items as seen from feed %s", newItemCount, feedURL) 253 + log.Printf("[@%s] New feed detected: marked %d items as seen from %s (not posted)", m.account.Handle, newItemCount, feedURL) 239 254 } 240 255 } else { 241 256 if newItemCount == 0 { 242 - log.Printf("No new items in feed %s", feedURL) 257 + log.Printf("[@%s] No new items in feed %s", m.account.Handle, feedURL) 243 258 } else { 244 - log.Printf("Processed %d new items from feed %s (%d posted)", newItemCount, feedURL, postedCount) 259 + log.Printf("[@%s] Processed %d new items from feed %s (%d posted)", m.account.Handle, newItemCount, feedURL, postedCount) 245 260 } 246 261 } 247 262 ··· 249 264 } 250 265 251 266 func formatPost(item *rss.FeedItem) string { 252 - // Start with title 267 + // Collect all unique URLs 268 + urls := []string{} 269 + if item.Link != "" { 270 + urls = append(urls, item.Link) 271 + } 272 + 273 + // Add GUID if it's a URL and different from link (e.g., HN comment links) 274 + if item.GUID != "" && item.GUID != item.Link { 275 + if u, err := url.Parse(item.GUID); err == nil && (u.Scheme == "http" || u.Scheme == "https") { 276 + urls = append(urls, item.GUID) 277 + } 278 + } 279 + 280 + // Build post: title + links 253 281 text := item.Title 254 - 255 - // Add link if available 256 - if item.Link != "" { 257 - text += "\n\n" + item.Link 282 + if len(urls) > 0 { 283 + text += "\n" + strings.Join(urls, "\n") 258 284 } 259 285 260 286 // Truncate if too long 261 287 if len(text) > maxPostLength { 262 - // Try to truncate title intelligently 263 - maxTitleLen := maxPostLength - len(item.Link) - 5 // 5 for "\n\n" and "..." 264 - if maxTitleLen > 0 { 265 - text = truncateText(item.Title, maxTitleLen) + "...\n\n" + item.Link 288 + linkText := "" 289 + if len(urls) > 0 { 290 + linkText = "\n" + strings.Join(urls, "\n") 291 + } 292 + 293 + availableForTitle := maxPostLength - len(linkText) - 3 // 3 for "..." 294 + if availableForTitle > 20 { 295 + text = truncateText(item.Title, availableForTitle) + "..." + linkText 266 296 } else { 267 - // If even with minimal title it's too long, just use the link 268 - text = item.Link 297 + // Title too long even truncated, use just first URL or truncated title 298 + if len(urls) > 0 { 299 + text = urls[0] 300 + } else { 301 + text = truncateText(item.Title, maxPostLength-3) + "..." 302 + } 269 303 } 270 304 } 271 305 ··· 286 320 287 321 return truncated 288 322 } 323 + 324 + // sanitizeHandle removes special characters from handle for use in filenames 325 + func sanitizeHandle(handle string) string { 326 + // Replace dots and @ with underscores 327 + sanitized := strings.ReplaceAll(handle, ".", "_") 328 + sanitized = strings.ReplaceAll(sanitized, "@", "") 329 + return sanitized 330 + }
+73 -46
main_test.go
··· 1 1 package main 2 2 3 3 import ( 4 - "reflect" 5 4 "testing" 6 5 ) 7 6 8 - func TestParseFeedURLs(t *testing.T) { 7 + func TestSanitizeHandle(t *testing.T) { 9 8 tests := []struct { 10 9 name string 11 10 input string 12 - expected []string 11 + expected string 13 12 }{ 14 13 { 15 - name: "single feed", 16 - input: "https://example.com/feed.xml", 17 - expected: []string{"https://example.com/feed.xml"}, 18 - }, 19 - { 20 - name: "multiple feeds", 21 - input: "https://example.com/feed1.xml,https://example.com/feed2.xml", 22 - expected: []string{"https://example.com/feed1.xml", "https://example.com/feed2.xml"}, 14 + name: "simple handle", 15 + input: "user.bsky.social", 16 + expected: "user_bsky_social", 23 17 }, 24 18 { 25 - name: "multiple feeds with spaces", 26 - input: "https://example.com/feed1.xml, https://example.com/feed2.xml, https://example.com/feed3.xml", 27 - expected: []string{"https://example.com/feed1.xml", "https://example.com/feed2.xml", "https://example.com/feed3.xml"}, 19 + name: "handle with multiple dots", 20 + input: "my.user.name.bsky.social", 21 + expected: "my_user_name_bsky_social", 28 22 }, 29 23 { 30 - name: "feeds with extra whitespace", 31 - input: " https://example.com/feed1.xml , https://example.com/feed2.xml ", 32 - expected: []string{"https://example.com/feed1.xml", "https://example.com/feed2.xml"}, 24 + name: "handle with @ prefix", 25 + input: "@user.bsky.social", 26 + expected: "user_bsky_social", 33 27 }, 34 28 { 35 - name: "empty string", 36 - input: "", 37 - expected: nil, 29 + name: "handle with multiple @ symbols", 30 + input: "@@user.bsky.social", 31 + expected: "user_bsky_social", 38 32 }, 39 33 { 40 - name: "only commas and spaces", 41 - input: " , , , ", 42 - expected: []string{}, 34 + name: "handle without dots", 35 + input: "user", 36 + expected: "user", 43 37 }, 44 38 { 45 - name: "trailing comma", 46 - input: "https://example.com/feed1.xml,https://example.com/feed2.xml,", 47 - expected: []string{"https://example.com/feed1.xml", "https://example.com/feed2.xml"}, 39 + name: "handle with dots and @", 40 + input: "@test.user.example.com", 41 + expected: "test_user_example_com", 48 42 }, 49 43 { 50 - name: "leading comma", 51 - input: ",https://example.com/feed1.xml,https://example.com/feed2.xml", 52 - expected: []string{"https://example.com/feed1.xml", "https://example.com/feed2.xml"}, 44 + name: "empty string", 45 + input: "", 46 + expected: "", 53 47 }, 54 48 { 55 - name: "mixed RSS and Atom feeds", 56 - input: "https://blog.com/rss,https://news.com/atom.xml,https://site.com/feed", 57 - expected: []string{"https://blog.com/rss", "https://news.com/atom.xml", "https://site.com/feed"}, 49 + name: "only special characters", 50 + input: "@.@.", 51 + expected: "__", 58 52 }, 59 53 } 60 54 61 55 for _, tt := range tests { 62 56 t.Run(tt.name, func(t *testing.T) { 63 - result := parseFeedURLs(tt.input) 64 - if !reflect.DeepEqual(result, tt.expected) { 65 - t.Errorf("parseFeedURLs(%q) = %v, want %v", tt.input, result, tt.expected) 57 + result := sanitizeHandle(tt.input) 58 + if result != tt.expected { 59 + t.Errorf("sanitizeHandle(%q) = %q, want %q", tt.input, result, tt.expected) 66 60 } 67 61 }) 68 62 } 69 63 } 70 64 71 - func TestParseFeedURLsCount(t *testing.T) { 65 + func TestTruncateText(t *testing.T) { 72 66 tests := []struct { 73 - name string 74 - input string 75 - expectedCount int 67 + name string 68 + text string 69 + maxLen int 70 + expected string 76 71 }{ 77 - {"one feed", "https://example.com/feed.xml", 1}, 78 - {"two feeds", "https://a.com/feed,https://b.com/feed", 2}, 79 - {"five feeds", "https://1.com/f,https://2.com/f,https://3.com/f,https://4.com/f,https://5.com/f", 5}, 80 - {"empty", "", 0}, 72 + { 73 + name: "no truncation needed", 74 + text: "short text", 75 + maxLen: 20, 76 + expected: "short text", 77 + }, 78 + { 79 + name: "truncate at word boundary", 80 + text: "this is a long text that needs truncation", 81 + maxLen: 20, 82 + expected: "this is a long text", 83 + }, 84 + { 85 + name: "truncate without word boundary", 86 + text: "verylongtextwithoutspaces", 87 + maxLen: 10, 88 + expected: "verylongte", 89 + }, 90 + { 91 + name: "exact length", 92 + text: "exactly ten", 93 + maxLen: 11, 94 + expected: "exactly ten", 95 + }, 96 + { 97 + name: "empty string", 98 + text: "", 99 + maxLen: 10, 100 + expected: "", 101 + }, 102 + { 103 + name: "truncate with space at end", 104 + text: "text with space ", 105 + maxLen: 10, 106 + expected: "text with", 107 + }, 81 108 } 82 109 83 110 for _, tt := range tests { 84 111 t.Run(tt.name, func(t *testing.T) { 85 - result := parseFeedURLs(tt.input) 86 - if len(result) != tt.expectedCount { 87 - t.Errorf("parseFeedURLs(%q) returned %d feeds, want %d", tt.input, len(result), tt.expectedCount) 112 + result := truncateText(tt.text, tt.maxLen) 113 + if result != tt.expected { 114 + t.Errorf("truncateText(%q, %d) = %q, want %q", tt.text, tt.maxLen, result, tt.expected) 88 115 } 89 116 }) 90 117 }
+88 -65
readme.md
··· 2 2 3 3 Tool to automatically post on Bluesky when new items are added to RSS/Atom feeds. 4 4 5 + Supports: 6 + 7 + - Multiple feeds posting to a single account 8 + - Different feeds posting to different accounts 9 + - Flexible configuration via YAML config file 10 + 5 11 ## Installation 6 12 7 13 ### Go Binary ··· 15 21 ```bash 16 22 docker run -d \ 17 23 --name bksyrss \ 24 + -v $(pwd)/config.yaml:/config.yaml \ 18 25 -v $(pwd)/data:/data \ 19 26 -e BSKY_PASSWORD="your-app-password" \ 20 27 bksyrss \ 21 - -feed "https://example.com/feed.xml" \ 22 - -handle "your-handle.bsky.social" \ 23 - -storage /data/posted_items.txt 28 + -config /config.yaml 24 29 ``` 25 30 26 31 ## Usage 27 32 28 - ### Basic Usage 33 + ### Basic Setup 34 + 35 + bskyrss requires a YAML configuration file to run. Create a `config.yaml` file: 29 36 30 - ```bash 31 - ./bksyrss \ 32 - -feed "https://example.com/feed.xml" \ 33 - -handle "your-handle.bsky.social" \ 34 - -password "your-app-password" 37 + ```yaml 38 + accounts: 39 + - handle: "your-handle.bsky.social" 40 + password: "${BSKY_PASSWORD}" 41 + feeds: 42 + - "https://example.com/feed.xml" 35 43 ``` 36 44 37 - ### Multiple Feeds 38 - 39 - Monitor multiple feeds by separating URLs with commas: 45 + Set your password as an environment variable: 40 46 41 47 ```bash 42 - ./bksyrss \ 43 - -feed "https://blog1.com/feed.xml,https://blog2.com/rss,https://news.com/atom.xml" \ 44 - -handle "your-handle.bsky.social" \ 45 - -password "your-app-password" 48 + export BSKY_PASSWORD="your-app-password" 46 49 ``` 47 50 48 - or 51 + Run bskyrss: 49 52 50 53 ```bash 51 - export BSKY_PASSWORD="your-app-password" 52 - ./bksyrss \ 53 - -feed "https://example.com/feed.xml" \ 54 - -handle "your-handle.bsky.social" 54 + ./bskyrss -config config.yaml 55 55 ``` 56 56 57 - ### Command Line Options 57 + ### Multiple Feeds to One Account 58 58 59 - | Flag | Description | Required | Default | 60 - | ----------- | ------------------------------------------------- | -------- | ------------------- | 61 - | `-feed` | RSS/Atom feed URL(s) to monitor (comma-delimited) | Yes | - | 62 - | `-handle` | Bluesky handle (e.g., user.bsky.social) | Yes | - | 63 - | `-password` | Bluesky password or app password | Yes\* | - | 64 - | `-pds` | Bluesky PDS server URL | No | https://bsky.social | 65 - | `-interval` | Poll interval for checking RSS feed | No | 15m | 66 - | `-storage` | File to store posted item GUIDs | No | posted_items.txt | 67 - | `-dry-run` | Don't post, just show what would be posted | No | false | 59 + ```yaml 60 + accounts: 61 + - handle: "your-handle.bsky.social" 62 + password: "${BSKY_PASSWORD}" 63 + feeds: 64 + - "https://example.com/feed.xml" 65 + - "https://blog.example.com/rss" 66 + - "https://news.example.com/atom.xml" 67 + ``` 68 68 69 - \*Can be provided via `BSKY_PASSWORD` environment variable instead 69 + ### Multiple Accounts with Different Feeds 70 70 71 - ### Examples 71 + Map different feeds to different Bluesky accounts: 72 72 73 - #### Monitor multiple feeds 73 + ```yaml 74 + accounts: 75 + - handle: "tech-news.bsky.social" 76 + password: "${BSKY_PASSWORD_TECH}" 77 + feeds: 78 + - "https://hackernews.com/rss" 79 + - "https://techcrunch.com/feed" 74 80 75 - ```bash 76 - ./bksyrss \ 77 - -feed "https://blog.com/rss,https://news.com/atom.xml,https://podcast.com/feed" \ 78 - -handle "your-handle.bsky.social" 79 - ``` 81 + - handle: "personal.bsky.social" 82 + password: "${BSKY_PASSWORD_PERSONAL}" 83 + feeds: 84 + - "https://personal-blog.com/feed.xml" 80 85 81 - #### Check feeds every 5 minutes 82 - 83 - ```bash 84 - ./bksyrss \ 85 - -feed "https://example.com/feed.xml" \ 86 - -handle "your-handle.bsky.social" \ 87 - -interval 5m 86 + interval: "15m" 88 87 ``` 89 88 90 - #### Test without posting (dry-run mode) 89 + Set environment variables: 91 90 92 91 ```bash 93 - ./bksyrss \ 94 - -feed "https://example.com/feed.xml" \ 95 - -handle "your-handle.bsky.social" \ 96 - -dry-run 92 + export BSKY_PASSWORD_TECH="tech-app-password" 93 + export BSKY_PASSWORD_PERSONAL="personal-app-password" 94 + ./bskyrss -config config.yaml 97 95 ``` 98 96 99 - #### Use custom storage file 100 - 101 - ```bash 102 - ./bksyrss \ 103 - -feed "https://example.com/feed.xml" \ 104 - -handle "your-handle.bsky.social" \ 105 - -storage /var/lib/bksyrss/posted.txt 106 - ``` 97 + See [`config.example.yaml`](config.example.yaml) for a complete example with all options. 107 98 108 99 ## Bluesky Authentication 109 100 ··· 113 104 114 105 1. Go to Bluesky Settings → App Passwords 115 106 2. Create a new App Password 116 - 3. Use this password with the `-password` flag or `BSKY_PASSWORD` environment variable 107 + 3. Use this password in your config file (via environment variable) 108 + 109 + ### Environment Variables 110 + 111 + Passwords should be provided via environment variables for security: 112 + 113 + ```yaml 114 + accounts: 115 + - handle: "user.bsky.social" 116 + password: "${BSKY_PASSWORD}" # References $BSKY_PASSWORD env var 117 + ``` 118 + 119 + Supported formats: 120 + 121 + - `${VAR_NAME}` - Standard format 122 + - `$VAR_NAME` - Short format 117 123 118 124 ### Self-hosted PDS 119 125 120 126 If you're using a self-hosted Personal Data Server: 121 127 122 - ```bash 123 - ./bksyrss \ 124 - -feed "https://example.com/feed.xml" \ 125 - -handle "your-handle.your-pds.com" \ 126 - -pds "https://your-pds.com" 128 + ```yaml 129 + accounts: 130 + - handle: "your-handle.your-pds.com" 131 + password: "${BSKY_PASSWORD}" 132 + pds: "https://your-pds.com" 133 + feeds: 134 + - "https://example.com/feed.xml" 127 135 ``` 128 136 129 137 ## Post Format ··· 145 153 The tool maintains a simple text file (default: `posted_items.txt`) containing the GUIDs of all posted items. This ensures that items are not posted multiple times, even if the tool is restarted. 146 154 147 155 The storage file contains one GUID per line and is safe to manually edit if needed. 156 + 157 + ### Multiple Accounts 158 + 159 + When using multiple accounts, separate storage files are automatically created for each account (e.g., `posted_items_user1_bsky_social.txt`, `posted_items_user2_bsky_social.txt`). This ensures that each account tracks its own posted items independently. 160 + 161 + You can also specify custom storage files per account in the configuration file: 162 + 163 + ```yaml 164 + accounts: 165 + - handle: "user1.bsky.social" 166 + storage: "custom_storage_user1.txt" 167 + password: "${BSKY_PASSWORD_1}" 168 + feeds: 169 + - "https://feed1.com/rss" 170 + ``` 148 171 149 172 ### First Run Behavior 150 173