Subscribe and post RSS feeds to Bluesky
rss bluesky
at main 8.9 kB view raw
1package main 2 3import ( 4 "context" 5 "flag" 6 "fmt" 7 "log" 8 "net/url" 9 "os" 10 "os/signal" 11 "strings" 12 "syscall" 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) 20 21const ( 22 defaultPollInterval = 15 * time.Minute 23 defaultStorageFile = "posted_items.txt" 24 maxPostLength = 299 // Bluesky has a 299 character limit 25) 26 27func 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()) 57 defer cancel() 58 59 sigChan := make(chan os.Signal, 1) 60 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) 61 62 go func() { 63 <-sigChan 64 log.Println("Received shutdown signal, stopping...") 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 118type 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 127func 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 165func (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 176func (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 } 193 } 194 195 // Process items in reverse order (oldest first) 196 newItemCount := 0 197 postedCount := 0 198 199 for i := len(items) - 1; i >= 0; i-- { 200 item := items[i] 201 202 // Skip if already posted 203 if m.store.IsPosted(item.GUID) { 204 continue 205 } 206 207 newItemCount++ 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 263 return nil 264} 265 266func formatPost(item *rss.FeedItem) string { 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 281 text := item.Title 282 if len(urls) > 0 { 283 text += "\n" + strings.Join(urls, "\n") 284 } 285 286 // Truncate if too long 287 if len(text) > maxPostLength { 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 296 } else { 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 } 303 } 304 } 305 306 return text 307} 308 309func truncateText(text string, maxLen int) string { 310 if len(text) <= maxLen { 311 return text 312 } 313 314 // Try to truncate at word boundary 315 truncated := text[:maxLen] 316 lastSpace := strings.LastIndex(truncated, " ") 317 if lastSpace > maxLen/2 { 318 return text[:lastSpace] 319 } 320 321 return truncated 322} 323 324// sanitizeHandle removes special characters from handle for use in filenames 325func sanitizeHandle(handle string) string { 326 // Replace dots and @ with underscores 327 sanitized := strings.ReplaceAll(handle, ".", "_") 328 sanitized = strings.ReplaceAll(sanitized, "@", "") 329 return sanitized 330}