Subscribe and post RSS feeds to Bluesky
rss bluesky

feat: support multi-accounts

+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 require ( 6 github.com/bluesky-social/indigo v0.0.0-20260103083015-78a1c1894f36 7 github.com/mmcdole/gofeed v1.3.0 8 ) 9 10 require (
··· 5 require ( 6 github.com/bluesky-social/indigo v0.0.0-20260103083015-78a1c1894f36 7 github.com/mmcdole/gofeed v1.3.0 8 + gopkg.in/yaml.v3 v3.0.1 9 ) 10 11 require (
+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 + }
+123 -94
main.go
··· 13 "time" 14 15 "pkg.rbrt.fr/bskyrss/internal/bluesky" 16 "pkg.rbrt.fr/bskyrss/internal/rss" 17 "pkg.rbrt.fr/bskyrss/internal/storage" 18 ) ··· 25 26 func main() { 27 // Command line flags 28 - feedURLs := flag.String("feed", "", "RSS/Atom feed URL(s) to monitor (comma-delimited for multiple feeds, required)") 29 - handle := flag.String("handle", "", "Bluesky handle (required)") 30 - password := flag.String("password", "", "Bluesky password (can also use BSKY_PASSWORD env var)") 31 - pds := flag.String("pds", "https://bsky.social", "Bluesky PDS server URL") 32 - pollInterval := flag.Duration("interval", defaultPollInterval, "Poll interval for checking RSS feed") 33 - storageFile := flag.String("storage", defaultStorageFile, "File to store posted item GUIDs") 34 dryRun := flag.Bool("dry-run", false, "Don't actually post to Bluesky, just show what would be posted") 35 flag.Parse() 36 37 - // Validate required flags 38 - if *feedURLs == "" { 39 - log.Fatal("Error: -feed flag is required") 40 - } 41 - if *handle == "" { 42 - log.Fatal("Error: -handle flag is required") 43 } 44 45 - // Parse comma-delimited feed URLs 46 - feeds := parseFeedURLs(*feedURLs) 47 - if len(feeds) == 0 { 48 - log.Fatal("Error: no valid feed URLs provided") 49 } 50 - log.Printf("Monitoring %d feed(s)", len(feeds)) 51 52 - // Get password from flag or environment variable 53 - bskyPassword := *password 54 - if bskyPassword == "" { 55 - bskyPassword = os.Getenv("BSKY_PASSWORD") 56 - if bskyPassword == "" { 57 - log.Fatal("Error: -password flag or BSKY_PASSWORD environment variable is required") 58 - } 59 } 60 61 - store, err := storage.New(*storageFile) 62 - if err != nil { 63 - log.Fatalf("Failed to initialize storage: %v", err) 64 } 65 - log.Printf("Storage initialized with %d previously posted items", store.Count()) 66 - 67 - rssChecker := rss.NewChecker() 68 - 69 - // Initialize Bluesky client (unless dry-run mode) 70 - var bskyClient *bluesky.Client 71 - if !*dryRun { 72 - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 73 - defer cancel() 74 - 75 - bskyClient, err = bluesky.NewClient(ctx, bluesky.Config{ 76 - Handle: *handle, 77 - Password: bskyPassword, 78 - PDS: *pds, 79 - }) 80 - if err != nil { 81 - log.Fatalf("Failed to initialize Bluesky client: %v", err) 82 - } 83 - log.Printf("Authenticated as @%s", bskyClient.GetHandle()) 84 - } else { 85 - log.Println("Running in DRY-RUN mode - no posts will be made") 86 - } 87 88 // Setup signal handling for graceful shutdown 89 ctx, cancel := context.WithCancel(context.Background()) ··· 98 cancel() 99 }() 100 101 - // Main loop 102 - log.Printf("Poll interval: %s", *pollInterval) 103 104 - ticker := time.NewTicker(*pollInterval) 105 defer ticker.Stop() 106 107 // Check immediately on startup 108 - if err := checkAndPostFeeds(ctx, rssChecker, bskyClient, store, feeds, *dryRun); err != nil { 109 - log.Printf("Error during initial check: %v", err) 110 } 111 112 - // Continue checking on interval (not first run anymore after first check) 113 for { 114 select { 115 case <-ctx.Done(): 116 return 117 case <-ticker.C: 118 - if err := checkAndPostFeeds(ctx, rssChecker, bskyClient, store, feeds, *dryRun); err != nil { 119 - log.Printf("Error during check: %v", err) 120 } 121 } 122 } 123 } 124 125 - // parseFeedURLs splits comma-delimited feed URLs and trims whitespace 126 - func parseFeedURLs(feedString string) []string { 127 - if feedString == "" { 128 - return nil 129 } 130 131 - parts := strings.Split(feedString, ",") 132 - feeds := make([]string, 0, len(parts)) 133 134 - for _, part := range parts { 135 - trimmed := strings.TrimSpace(part) 136 - if trimmed != "" { 137 - feeds = append(feeds, trimmed) 138 } 139 } 140 141 - return feeds 142 } 143 144 - func checkAndPostFeeds(ctx context.Context, rssChecker *rss.Checker, bskyClient *bluesky.Client, store *storage.Storage, feedURLs []string, dryRun bool) error { 145 - for _, feedURL := range feedURLs { 146 - if err := checkAndPost(ctx, rssChecker, bskyClient, store, feedURL, dryRun); err != nil { 147 - log.Printf("Error checking feed %s: %v", feedURL, err) 148 // Continue with other feeds even if one fails 149 } 150 } 151 - 152 return nil 153 } 154 155 - func checkAndPost(ctx context.Context, rssChecker *rss.Checker, bskyClient *bluesky.Client, store *storage.Storage, feedURL string, dryRun bool) error { 156 - log.Printf("Checking RSS feed: %s", feedURL) 157 158 - items, err := rssChecker.FetchLatestItems(ctx, feedURL) 159 if err != nil { 160 return fmt.Errorf("failed to fetch RSS items: %w", err) 161 } 162 163 - log.Printf("Found %d items in feed", len(items)) 164 165 // Check if this is the first time seeing this feed (no items from it in storage) 166 hasSeenFeedBefore := false 167 for _, item := range items { 168 - if store.IsPosted(item.GUID) { 169 hasSeenFeedBefore = true 170 break 171 } ··· 179 item := items[i] 180 181 // Skip if already posted 182 - if store.IsPosted(item.GUID) { 183 continue 184 } 185 ··· 187 188 // If this is first time seeing this feed, mark items as seen without posting 189 if !hasSeenFeedBefore { 190 - if err := store.MarkPosted(item.GUID); err != nil { 191 - log.Printf("Failed to mark item as seen: %v", err) 192 } 193 continue 194 } 195 196 - log.Printf("New item found: %s", item.Title) 197 198 // Create post text 199 postText := formatPost(item) 200 201 - if dryRun { 202 - log.Printf("[DRY-RUN] Would post:\n%s\n", postText) 203 postedCount++ 204 } else { 205 // Post to Bluesky 206 postCtx, cancel := context.WithTimeout(ctx, 30*time.Second) 207 - err := bskyClient.Post(postCtx, postText) 208 cancel() 209 210 if err != nil { 211 - log.Printf("Failed to post item '%s': %v", item.Title, err) 212 continue 213 } 214 215 - log.Printf("Successfully posted: %s", item.Title) 216 postedCount++ 217 } 218 219 // Mark as posted 220 - if err := store.MarkPosted(item.GUID); err != nil { 221 - log.Printf("Failed to mark item as posted: %v", err) 222 } 223 224 - // Rate limiting - wait a bit between posts to avoid overwhelming Bluesky 225 - if postedCount > 0 && !dryRun { 226 time.Sleep(2 * time.Second) 227 } 228 } 229 230 if !hasSeenFeedBefore { 231 if newItemCount > 0 { 232 - log.Printf("New feed detected: marked %d items as seen from %s (not posted)", newItemCount, feedURL) 233 } 234 } else { 235 if newItemCount == 0 { 236 - log.Printf("No new items in feed %s", feedURL) 237 } else { 238 - log.Printf("Processed %d new items from feed %s (%d posted)", newItemCount, feedURL, postedCount) 239 } 240 } 241 ··· 299 300 return truncated 301 }
··· 13 "time" 14 15 "pkg.rbrt.fr/bskyrss/internal/bluesky" 16 + "pkg.rbrt.fr/bskyrss/internal/config" 17 "pkg.rbrt.fr/bskyrss/internal/rss" 18 "pkg.rbrt.fr/bskyrss/internal/storage" 19 ) ··· 26 27 func main() { 28 // Command line flags 29 + configFile := flag.String("config", "config.yaml", "Path to configuration file (YAML)") 30 dryRun := flag.Bool("dry-run", false, "Don't actually post to Bluesky, just show what would be posted") 31 flag.Parse() 32 33 + // Load configuration from file 34 + if *configFile == "" { 35 + log.Fatal("Error: -config flag is required") 36 } 37 38 + cfg, err := config.LoadFromFile(*configFile) 39 + if err != nil { 40 + log.Fatalf("Failed to load config file: %v", err) 41 } 42 + log.Printf("Loaded configuration with %d account(s)", len(cfg.Accounts)) 43 44 + if *dryRun { 45 + log.Println("Running in DRY-RUN mode - no posts will be made") 46 } 47 48 + // Count total feeds across all accounts 49 + totalFeeds := 0 50 + for _, account := range cfg.Accounts { 51 + totalFeeds += len(account.Feeds) 52 } 53 + log.Printf("Monitoring %d feed(s) across %d account(s)", totalFeeds, len(cfg.Accounts)) 54 55 // Setup signal handling for graceful shutdown 56 ctx, cancel := context.WithCancel(context.Background()) ··· 65 cancel() 66 }() 67 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 + } 81 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) 93 defer ticker.Stop() 94 95 // Check immediately on startup 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 + } 100 } 101 102 + // Continue checking on interval 103 for { 104 select { 105 case <-ctx.Done(): 106 return 107 case <-ticker.C: 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 + } 112 } 113 } 114 } 115 } 116 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) 131 } 132 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 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) 152 } 153 } 154 155 + return &AccountManager{ 156 + account: account, 157 + bskyClient: bskyClient, 158 + rssChecker: rssChecker, 159 + store: store, 160 + dryRun: dryRun, 161 + }, nil 162 } 163 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) 169 // Continue with other feeds even if one fails 170 } 171 } 172 return nil 173 } 174 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) 178 179 + items, err := m.rssChecker.FetchLatestItems(ctx, feedURL) 180 if err != nil { 181 return fmt.Errorf("failed to fetch RSS items: %w", err) 182 } 183 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 } ··· 200 item := items[i] 201 202 // Skip if already posted 203 + if m.store.IsPosted(item.GUID) { 204 continue 205 } 206 ··· 208 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) 213 } 214 continue 215 } 216 217 + log.Printf("[@%s] New item found: %s", m.account.Handle, item.Title) 218 219 // Create post text 220 postText := formatPost(item) 221 222 + if m.dryRun { 223 + log.Printf("[@%s] [DRY-RUN] Would post:\n%s\n", m.account.Handle, postText) 224 postedCount++ 225 } else { 226 // Post to Bluesky 227 postCtx, cancel := context.WithTimeout(ctx, 30*time.Second) 228 + err := m.bskyClient.Post(postCtx, postText) 229 cancel() 230 231 if err != nil { 232 + log.Printf("[@%s] Failed to post item '%s': %v", m.account.Handle, item.Title, err) 233 continue 234 } 235 236 + log.Printf("[@%s] Successfully posted: %s", m.account.Handle, item.Title) 237 postedCount++ 238 } 239 240 // Mark as posted 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) 243 } 244 245 + // Rate limiting ourselves to not get rate limited. 246 + if postedCount > 0 && !m.dryRun { 247 time.Sleep(2 * time.Second) 248 } 249 } 250 251 if !hasSeenFeedBefore { 252 if newItemCount > 0 { 253 + log.Printf("[@%s] New feed detected: marked %d items as seen from %s (not posted)", m.account.Handle, newItemCount, feedURL) 254 } 255 } else { 256 if newItemCount == 0 { 257 + log.Printf("[@%s] No new items in feed %s", m.account.Handle, feedURL) 258 } else { 259 + log.Printf("[@%s] Processed %d new items from feed %s (%d posted)", m.account.Handle, newItemCount, feedURL, postedCount) 260 } 261 } 262 ··· 320 321 return truncated 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 package main 2 3 import ( 4 - "reflect" 5 "testing" 6 ) 7 8 - func TestParseFeedURLs(t *testing.T) { 9 tests := []struct { 10 name string 11 input string 12 - expected []string 13 }{ 14 { 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"}, 23 }, 24 { 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"}, 28 }, 29 { 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"}, 33 }, 34 { 35 - name: "empty string", 36 - input: "", 37 - expected: nil, 38 }, 39 { 40 - name: "only commas and spaces", 41 - input: " , , , ", 42 - expected: []string{}, 43 }, 44 { 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"}, 48 }, 49 { 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"}, 53 }, 54 { 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"}, 58 }, 59 } 60 61 for _, tt := range tests { 62 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) 66 } 67 }) 68 } 69 } 70 71 - func TestParseFeedURLsCount(t *testing.T) { 72 tests := []struct { 73 - name string 74 - input string 75 - expectedCount int 76 }{ 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}, 81 } 82 83 for _, tt := range tests { 84 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) 88 } 89 }) 90 }
··· 1 package main 2 3 import ( 4 "testing" 5 ) 6 7 + func TestSanitizeHandle(t *testing.T) { 8 tests := []struct { 9 name string 10 input string 11 + expected string 12 }{ 13 { 14 + name: "simple handle", 15 + input: "user.bsky.social", 16 + expected: "user_bsky_social", 17 }, 18 { 19 + name: "handle with multiple dots", 20 + input: "my.user.name.bsky.social", 21 + expected: "my_user_name_bsky_social", 22 }, 23 { 24 + name: "handle with @ prefix", 25 + input: "@user.bsky.social", 26 + expected: "user_bsky_social", 27 }, 28 { 29 + name: "handle with multiple @ symbols", 30 + input: "@@user.bsky.social", 31 + expected: "user_bsky_social", 32 }, 33 { 34 + name: "handle without dots", 35 + input: "user", 36 + expected: "user", 37 }, 38 { 39 + name: "handle with dots and @", 40 + input: "@test.user.example.com", 41 + expected: "test_user_example_com", 42 }, 43 { 44 + name: "empty string", 45 + input: "", 46 + expected: "", 47 }, 48 { 49 + name: "only special characters", 50 + input: "@.@.", 51 + expected: "__", 52 }, 53 } 54 55 for _, tt := range tests { 56 t.Run(tt.name, func(t *testing.T) { 57 + result := sanitizeHandle(tt.input) 58 + if result != tt.expected { 59 + t.Errorf("sanitizeHandle(%q) = %q, want %q", tt.input, result, tt.expected) 60 } 61 }) 62 } 63 } 64 65 + func TestTruncateText(t *testing.T) { 66 tests := []struct { 67 + name string 68 + text string 69 + maxLen int 70 + expected string 71 }{ 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 + }, 108 } 109 110 for _, tt := range tests { 111 t.Run(tt.name, func(t *testing.T) { 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) 115 } 116 }) 117 }
+88 -65
readme.md
··· 2 3 Tool to automatically post on Bluesky when new items are added to RSS/Atom feeds. 4 5 ## Installation 6 7 ### Go Binary ··· 15 ```bash 16 docker run -d \ 17 --name bksyrss \ 18 -v $(pwd)/data:/data \ 19 -e BSKY_PASSWORD="your-app-password" \ 20 bksyrss \ 21 - -feed "https://example.com/feed.xml" \ 22 - -handle "your-handle.bsky.social" \ 23 - -storage /data/posted_items.txt 24 ``` 25 26 ## Usage 27 28 - ### Basic Usage 29 30 - ```bash 31 - ./bksyrss \ 32 - -feed "https://example.com/feed.xml" \ 33 - -handle "your-handle.bsky.social" \ 34 - -password "your-app-password" 35 ``` 36 37 - ### Multiple Feeds 38 - 39 - Monitor multiple feeds by separating URLs with commas: 40 41 ```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" 46 ``` 47 48 - or 49 50 ```bash 51 - export BSKY_PASSWORD="your-app-password" 52 - ./bksyrss \ 53 - -feed "https://example.com/feed.xml" \ 54 - -handle "your-handle.bsky.social" 55 ``` 56 57 - ### Command Line Options 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 | 68 69 - \*Can be provided via `BSKY_PASSWORD` environment variable instead 70 71 - ### Examples 72 73 - #### Monitor multiple feeds 74 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 - ``` 80 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 88 ``` 89 90 - #### Test without posting (dry-run mode) 91 92 ```bash 93 - ./bksyrss \ 94 - -feed "https://example.com/feed.xml" \ 95 - -handle "your-handle.bsky.social" \ 96 - -dry-run 97 ``` 98 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 - ``` 107 108 ## Bluesky Authentication 109 ··· 113 114 1. Go to Bluesky Settings → App Passwords 115 2. Create a new App Password 116 - 3. Use this password with the `-password` flag or `BSKY_PASSWORD` environment variable 117 118 ### Self-hosted PDS 119 120 If you're using a self-hosted Personal Data Server: 121 122 - ```bash 123 - ./bksyrss \ 124 - -feed "https://example.com/feed.xml" \ 125 - -handle "your-handle.your-pds.com" \ 126 - -pds "https://your-pds.com" 127 ``` 128 129 ## Post Format ··· 145 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 147 The storage file contains one GUID per line and is safe to manually edit if needed. 148 149 ### First Run Behavior 150
··· 2 3 Tool to automatically post on Bluesky when new items are added to RSS/Atom feeds. 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 + 11 ## Installation 12 13 ### Go Binary ··· 21 ```bash 22 docker run -d \ 23 --name bksyrss \ 24 + -v $(pwd)/config.yaml:/config.yaml \ 25 -v $(pwd)/data:/data \ 26 -e BSKY_PASSWORD="your-app-password" \ 27 bksyrss \ 28 + -config /config.yaml 29 ``` 30 31 ## Usage 32 33 + ### Basic Setup 34 + 35 + bskyrss requires a YAML configuration file to run. Create a `config.yaml` file: 36 37 + ```yaml 38 + accounts: 39 + - handle: "your-handle.bsky.social" 40 + password: "${BSKY_PASSWORD}" 41 + feeds: 42 + - "https://example.com/feed.xml" 43 ``` 44 45 + Set your password as an environment variable: 46 47 ```bash 48 + export BSKY_PASSWORD="your-app-password" 49 ``` 50 51 + Run bskyrss: 52 53 ```bash 54 + ./bskyrss -config config.yaml 55 ``` 56 57 + ### Multiple Feeds to One Account 58 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 69 + ### Multiple Accounts with Different Feeds 70 71 + Map different feeds to different Bluesky accounts: 72 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" 80 81 + - handle: "personal.bsky.social" 82 + password: "${BSKY_PASSWORD_PERSONAL}" 83 + feeds: 84 + - "https://personal-blog.com/feed.xml" 85 86 + interval: "15m" 87 ``` 88 89 + Set environment variables: 90 91 ```bash 92 + export BSKY_PASSWORD_TECH="tech-app-password" 93 + export BSKY_PASSWORD_PERSONAL="personal-app-password" 94 + ./bskyrss -config config.yaml 95 ``` 96 97 + See [`config.example.yaml`](config.example.yaml) for a complete example with all options. 98 99 ## Bluesky Authentication 100 ··· 104 105 1. Go to Bluesky Settings → App Passwords 106 2. Create a new App Password 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 123 124 ### Self-hosted PDS 125 126 If you're using a self-hosted Personal Data Server: 127 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" 135 ``` 136 137 ## Post Format ··· 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. 154 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 + ``` 171 172 ### First Run Behavior 173