···55accounts:
66 # First account - posts from multiple feeds
77 - handle: "user1.bsky.social"
88- # Password can be plain text (not recommended) or environment variable reference
99- # Use ${VAR_NAME} or $VAR_NAME format for environment variables
88+ # Password can be plain text or an environment variable reference.
99+ # Use ${VAR_NAME} or $VAR_NAME format for environment variables.
1010 password: "${BSKY_PASSWORD_USER1}"
1111 # Optional: Custom PDS server (defaults to https://bsky.social)
1212 pds: "https://bsky.social"
1313- # Optional: Custom storage file for this account (defaults to global storage with account suffix)
1313+ # Optional: Custom storage file for this account
1414 # storage: "user1_posted.txt"
1515 feeds:
1616+ # Feeds can be plain URLs ...
1617 - "https://example.com/feed.xml"
1718 - "https://blog.example.com/rss"
1818- - "https://news.example.com/atom.xml"
1919+ # ... or mappings with per-feed HTTP/pacing overrides.
2020+ # Any field from the global "defaults" block can be overridden here.
2121+ - url: "https://old.reddit.com/r/linux/top/.rss?t=week"
2222+ # Stagger requests to avoid bursting Reddit's rate limits.
2323+ min_delay: "3s"
2424+ max_delay: "9s"
2525+ base_backoff: "30s"
2626+ max_backoff: "10m"
2727+ honor_retry_after: true
19282020- # Second account - posts from different feeds
2929+ # Second account
2130 - handle: "user2.bsky.social"
2231 password: "${BSKY_PASSWORD_USER2}"
2332 feeds:
2433 - "https://another-blog.com/feed.xml"
2525- - "https://tech-news.com/rss"
3434+ - url: "https://old.reddit.com/r/selfhosted/top/.rss?t=week"
3535+ min_delay: "3s"
3636+ max_delay: "9s"
3737+ base_backoff: "30s"
3838+ max_backoff: "10m"
3939+ honor_retry_after: true
26402727- # Third account - posts from a single feed
4141+ # Third account - single feed, no overrides needed
2842 - handle: "user3.bsky.social"
2943 password: "${BSKY_PASSWORD_USER3}"
3044 pds: "https://custom-pds.example.com"
3145 feeds:
3246 - "https://personal-blog.com/feed.xml"
33473434-# Global settings (optional)
3535-# Poll interval for checking feeds (default: 15m)
3636-# Valid units: s, m, h (e.g., "30s", "5m", "1h")
4848+# Poll interval (default: 15m). Valid units: s, m, h
3749interval: "15m"
38503939-# Default storage file for tracking posted items (default: posted_items.txt)
4040-# When multiple accounts are configured, separate files will be created automatically
4141-# (e.g., posted_items_user1_bsky_social.txt, posted_items_user2_bsky_social.txt)
5151+# Storage file for tracking posted items (default: posted_items.txt).
5252+# With multiple accounts, per-account files are created automatically
5353+# (e.g. posted_items_user1_bsky_social.txt).
4254storage: "posted_items.txt"
5555+5656+# Global feed defaults - applied to every feed unless overridden per-feed above.
5757+# All fields are optional; hard-coded fallbacks are used when omitted.
5858+defaults:
5959+ # A descriptive User-Agent reduces rate-limiting on providers like Reddit.
6060+ user_agent: "bskyrss/1.0 (+https://pkg.rbrt.fr/bskyrss)"
6161+6262+ # Per-request HTTP timeout.
6363+ timeout: "30s"
6464+6565+ # Retry settings for transient HTTP errors (429, 5xx ...).
6666+ # Retries use exponential backoff: base_backoff * 2^attempt, capped at max_backoff.
6767+ max_retries: 3
6868+ base_backoff: "2s"
6969+ max_backoff: "2m"
7070+7171+ # Honour the Retry-After response header when the server sends it (e.g. Reddit 429).
7272+ honor_retry_after: true
7373+7474+ # Pre-fetch jitter: a random delay in [min_delay, max_delay] added before each
7575+ # request. Leave both unset (or 0s) to disable. Prefer setting this per-feed
7676+ # rather than globally so only rate-sensitive feeds are affected.
7777+ # min_delay: "0s"
7878+ # max_delay: "0s"
···205205 }
206206207207 // Add items and check count
208208- for i := 0; i < 5; i++ {
208208+ for i := range 5 {
209209 store.MarkPosted(string(rune('a' + i)))
210210 expectedCount := i + 1
211211 if store.Count() != expectedCount {
···225225226226 // Simulate concurrent access
227227 done := make(chan bool)
228228- for i := 0; i < 10; i++ {
228228+ for i := range 10 {
229229 go func(id int) {
230230 guid := string(rune('a' + id))
231231 store.MarkPosted(guid)
···236236 }
237237238238 // Wait for all goroutines
239239- for i := 0; i < 10; i++ {
239239+ for range 10 {
240240 <-done
241241 }
242242
+26-38
main.go
···3030 dryRun := flag.Bool("dry-run", false, "Don't actually post to Bluesky, just show what would be posted")
3131 flag.Parse()
32323333- // Load configuration from file
3433 if *configFile == "" {
3534 log.Fatal("Error: -config flag is required")
3635 }
···5251 }
5352 log.Printf("Monitoring %d feed(s) across %d account(s)", totalFeeds, len(cfg.Accounts))
54535555- // Setup signal handling for graceful shutdown
5654 ctx, cancel := context.WithCancel(context.Background())
5755 defer cancel()
5856···6866 // Initialize managers for each account
6967 managers := make([]*AccountManager, 0, len(cfg.Accounts))
7068 for i, account := range cfg.Accounts {
7171- // Determine storage file for this account
7269 storageFilePath := cfg.Storage
7370 if account.Storage != "" {
7471 storageFilePath = account.Storage
···7976 storageFilePath = fmt.Sprintf("%s_%s%s", base, sanitizeHandle(account.Handle), ext)
8077 }
81788282- manager, err := NewAccountManager(ctx, account, storageFilePath, *dryRun)
7979+ manager, err := NewAccountManager(ctx, cfg, account, storageFilePath, *dryRun)
8380 if err != nil {
8481 log.Fatalf("Failed to initialize account %d (%s): %v", i+1, account.Handle, err)
8582 }
···9996 }
10097 }
10198102102- // Continue checking on interval
10399 for {
104100 select {
105101 case <-ctx.Done():
···114110 }
115111}
116112117117-// AccountManager manages RSS checking and posting for a single Bluesky account
113113+// AccountManager manages RSS checking and posting for a single Bluesky account.
118114type AccountManager struct {
115115+ cfg *config.Config
119116 account config.Account
120117 bskyClient *bluesky.Client
121118 rssChecker *rss.Checker
···123120 dryRun bool
124121}
125122126126-// NewAccountManager creates a new account manager
127127-func NewAccountManager(ctx context.Context, account config.Account, storageFile string, dryRun bool) (*AccountManager, error) {
123123+// NewAccountManager creates a new AccountManager.
124124+func NewAccountManager(ctx context.Context, cfg *config.Config, account config.Account, storageFile string, dryRun bool) (*AccountManager, error) {
128125 store, err := storage.New(storageFile)
129126 if err != nil {
130127 return nil, fmt.Errorf("failed to initialize storage: %w", err)
···153150 }
154151155152 return &AccountManager{
153153+ cfg: cfg,
156154 account: account,
157155 bskyClient: bskyClient,
158156 rssChecker: rssChecker,
···161159 }, nil
162160}
163161164164-// CheckAndPost checks all feeds for this account and posts new items
162162+// CheckAndPost checks all feeds for this account and posts new items.
165163func (m *AccountManager) CheckAndPost(ctx context.Context) error {
166166- for _, feedURL := range m.account.Feeds {
167167- if err := m.checkAndPostFeed(ctx, feedURL); err != nil {
168168- log.Printf("[@%s] Error checking feed %s: %v", m.account.Handle, feedURL, err)
169169- // Continue with other feeds even if one fails
164164+ for _, feed := range m.account.Feeds {
165165+ if err := m.checkAndPostFeed(ctx, feed); err != nil {
166166+ log.Printf("[@%s] Error checking feed %s: %v", m.account.Handle, feed.URL, err)
167167+ // Continue with other feeds even if one fails.
170168 }
171169 }
172170 return nil
173171}
174172175175-// checkAndPostFeed checks a single feed and posts new items
176176-func (m *AccountManager) checkAndPostFeed(ctx context.Context, feedURL string) error {
177177- log.Printf("[@%s] Checking RSS feed: %s", m.account.Handle, feedURL)
173173+// checkAndPostFeed checks a single feed and posts new items.
174174+func (m *AccountManager) checkAndPostFeed(ctx context.Context, feed config.FeedConfig) error {
175175+ log.Printf("[@%s] Checking RSS feed: %s", m.account.Handle, feed.URL)
178176179179- items, err := m.rssChecker.FetchLatestItems(ctx, feedURL)
177177+ opts := m.cfg.Resolved(feed)
178178+179179+ items, err := m.rssChecker.FetchLatestItems(ctx, feed.URL, opts)
180180 if err != nil {
181181 return fmt.Errorf("failed to fetch RSS items: %w", err)
182182 }
183183184184 log.Printf("[@%s] Found %d items in feed", m.account.Handle, len(items))
185185186186- // Check if this is the first time seeing this feed (no items from it in storage)
186186+ // Check if this is the first time seeing this feed (no items in storage).
187187 hasSeenFeedBefore := false
188188 for _, item := range items {
189189 if m.store.IsPosted(item.GUID) {
···192192 }
193193 }
194194195195- // Process items in reverse order (oldest first)
195195+ // Process items in reverse order (oldest first).
196196 newItemCount := 0
197197 postedCount := 0
198198199199 for i := len(items) - 1; i >= 0; i-- {
200200 item := items[i]
201201202202- // Skip if already posted
203202 if m.store.IsPosted(item.GUID) {
204203 continue
205204 }
206205207206 newItemCount++
208207209209- // If this is first time seeing this feed, mark items as seen without posting
208208+ // First time seeing this feed: mark items as seen without posting.
210209 if !hasSeenFeedBefore {
211210 if err := m.store.MarkPosted(item.GUID); err != nil {
212211 log.Printf("[@%s] Failed to mark item as seen: %v", m.account.Handle, err)
···216215217216 log.Printf("[@%s] New item found: %s", m.account.Handle, item.Title)
218217219219- // Create post text
220218 postText := formatPost(item)
221219222220 if m.dryRun {
223221 log.Printf("[@%s] [DRY-RUN] Would post:\n%s\n", m.account.Handle, postText)
224222 postedCount++
225223 } else {
226226- // Post to Bluesky
227224 postCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
228225 err := m.bskyClient.Post(postCtx, postText)
229226 cancel()
···237234 postedCount++
238235 }
239236240240- // Mark as posted
241237 if err := m.store.MarkPosted(item.GUID); err != nil {
242238 log.Printf("[@%s] Failed to mark item as posted: %v", m.account.Handle, err)
243239 }
244240245245- // Rate limiting ourselves to not get rate limited.
241241+ // Small self-imposed delay between posts to avoid Bluesky rate limits.
246242 if postedCount > 0 && !m.dryRun {
247243 time.Sleep(2 * time.Second)
248244 }
···250246251247 if !hasSeenFeedBefore {
252248 if newItemCount > 0 {
253253- log.Printf("[@%s] New feed detected: marked %d items as seen from %s (not posted)", m.account.Handle, newItemCount, feedURL)
249249+ log.Printf("[@%s] New feed detected: marked %d items as seen from %s (not posted)", m.account.Handle, newItemCount, feed.URL)
254250 }
255251 } else {
256252 if newItemCount == 0 {
257257- log.Printf("[@%s] No new items in feed %s", m.account.Handle, feedURL)
253253+ log.Printf("[@%s] No new items in feed %s", m.account.Handle, feed.URL)
258254 } else {
259259- log.Printf("[@%s] Processed %d new items from feed %s (%d posted)", m.account.Handle, newItemCount, feedURL, postedCount)
255255+ log.Printf("[@%s] Processed %d new items from feed %s (%d posted)", m.account.Handle, newItemCount, feed.URL, postedCount)
260256 }
261257 }
262258···264260}
265261266262func formatPost(item *rss.FeedItem) string {
267267- // Collect all unique URLs
268263 urls := []string{}
269264 if item.Link != "" {
270265 urls = append(urls, item.Link)
271266 }
272267273273- // Add GUID if it's a URL and different from link (e.g., HN comment links)
268268+ // Add GUID if it's a URL and different from link (e.g. HN comment links).
274269 if item.GUID != "" && item.GUID != item.Link {
275270 if u, err := url.Parse(item.GUID); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
276271 urls = append(urls, item.GUID)
277272 }
278273 }
279274280280- // Build post: title + links
281275 text := item.Title
282276 if len(urls) > 0 {
283277 text += "\n" + strings.Join(urls, "\n")
284278 }
285279286286- // Truncate if too long
287280 if len(text) > maxPostLength {
288281 linkText := ""
289282 if len(urls) > 0 {
···294287 if availableForTitle > 20 {
295288 text = truncateText(item.Title, availableForTitle) + "..." + linkText
296289 } else {
297297- // Title too long even truncated, use just first URL or truncated title
298290 if len(urls) > 0 {
299291 text = urls[0]
300292 } else {
···310302 if len(text) <= maxLen {
311303 return text
312304 }
313313-314314- // Try to truncate at word boundary
315305 truncated := text[:maxLen]
316306 lastSpace := strings.LastIndex(truncated, " ")
317307 if lastSpace > maxLen/2 {
318308 return text[:lastSpace]
319309 }
320320-321310 return truncated
322311}
323312324324-// sanitizeHandle removes special characters from handle for use in filenames
313313+// sanitizeHandle replaces characters unsuitable for filenames.
325314func sanitizeHandle(handle string) string {
326326- // Replace dots and @ with underscores
327315 sanitized := strings.ReplaceAll(handle, ".", "_")
328316 sanitized = strings.ReplaceAll(sanitized, "@", "")
329317 return sanitized