Subscribe and post RSS feeds to Bluesky
rss bluesky
1package main 2 3import ( 4 "context" 5 "flag" 6 "fmt" 7 "log" 8 "os" 9 "os/signal" 10 "strings" 11 "syscall" 12 "time" 13 14 "pkg.rbrt.fr/bskyrss/internal/bluesky" 15 "pkg.rbrt.fr/bskyrss/internal/rss" 16 "pkg.rbrt.fr/bskyrss/internal/storage" 17) 18 19const ( 20 defaultPollInterval = 15 * time.Minute 21 defaultStorageFile = "posted_items.txt" 22 maxPostLength = 299 // Bluesky has a 299 character limit 23) 24 25func main() { 26 // 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") 33 dryRun := flag.Bool("dry-run", false, "Don't actually post to Bluesky, just show what would be posted") 34 flag.Parse() 35 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 } 58 } 59 60 store, err := storage.New(*storageFile) 61 if err != nil { 62 log.Fatalf("Failed to initialize storage: %v", err) 63 } 64 log.Printf("Storage initialized with %d previously posted items", store.Count()) 65 66 rssChecker := rss.NewChecker() 67 68 // Initialize Bluesky client (unless dry-run mode) 69 var bskyClient *bluesky.Client 70 if !*dryRun { 71 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 72 defer cancel() 73 74 bskyClient, err = bluesky.NewClient(ctx, bluesky.Config{ 75 Handle: *handle, 76 Password: bskyPassword, 77 PDS: *pds, 78 }) 79 if err != nil { 80 log.Fatalf("Failed to initialize Bluesky client: %v", err) 81 } 82 log.Printf("Authenticated as @%s", bskyClient.GetHandle()) 83 } else { 84 log.Println("Running in DRY-RUN mode - no posts will be made") 85 } 86 87 // Setup signal handling for graceful shutdown 88 ctx, cancel := context.WithCancel(context.Background()) 89 defer cancel() 90 91 sigChan := make(chan os.Signal, 1) 92 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) 93 94 go func() { 95 <-sigChan 96 log.Println("Received shutdown signal, stopping...") 97 cancel() 98 }() 99 100 // Main loop 101 log.Printf("Poll interval: %s", *pollInterval) 102 103 ticker := time.NewTicker(*pollInterval) 104 defer ticker.Stop() 105 106 // Check immediately on startup 107 if err := checkAndPostFeeds(ctx, rssChecker, bskyClient, store, feeds, *dryRun); err != nil { 108 log.Printf("Error during initial check: %v", err) 109 } 110 111 // Continue checking on interval (not first run anymore after first check) 112 for { 113 select { 114 case <-ctx.Done(): 115 return 116 case <-ticker.C: 117 if err := checkAndPostFeeds(ctx, rssChecker, bskyClient, store, feeds, *dryRun); err != nil { 118 log.Printf("Error during check: %v", err) 119 } 120 } 121 } 122} 123 124// parseFeedURLs splits comma-delimited feed URLs and trims whitespace 125func parseFeedURLs(feedString string) []string { 126 if feedString == "" { 127 return nil 128 } 129 130 parts := strings.Split(feedString, ",") 131 feeds := make([]string, 0, len(parts)) 132 133 for _, part := range parts { 134 trimmed := strings.TrimSpace(part) 135 if trimmed != "" { 136 feeds = append(feeds, trimmed) 137 } 138 } 139 140 return feeds 141} 142 143func checkAndPostFeeds(ctx context.Context, rssChecker *rss.Checker, bskyClient *bluesky.Client, store *storage.Storage, feedURLs []string, dryRun bool) error { 144 for _, feedURL := range feedURLs { 145 if err := checkAndPost(ctx, rssChecker, bskyClient, store, feedURL, dryRun); err != nil { 146 log.Printf("Error checking feed %s: %v", feedURL, err) 147 // Continue with other feeds even if one fails 148 } 149 } 150 151 return nil 152} 153 154func checkAndPost(ctx context.Context, rssChecker *rss.Checker, bskyClient *bluesky.Client, store *storage.Storage, feedURL string, dryRun bool) error { 155 log.Printf("Checking RSS feed: %s", feedURL) 156 157 items, err := rssChecker.FetchLatestItems(ctx, feedURL) 158 if err != nil { 159 return fmt.Errorf("failed to fetch RSS items: %w", err) 160 } 161 162 log.Printf("Found %d items in feed", len(items)) 163 164 // Check if this is the first time seeing this feed (no items from it in storage) 165 hasSeenFeedBefore := false 166 for _, item := range items { 167 if store.IsPosted(item.GUID) { 168 hasSeenFeedBefore = true 169 break 170 } 171 } 172 173 // Process items in reverse order (oldest first) 174 newItemCount := 0 175 postedCount := 0 176 177 for i := len(items) - 1; i >= 0; i-- { 178 item := items[i] 179 180 // Skip if already posted 181 if store.IsPosted(item.GUID) { 182 continue 183 } 184 185 newItemCount++ 186 187 // If this is first time seeing this feed, mark items as seen without posting 188 if !hasSeenFeedBefore { 189 if err := store.MarkPosted(item.GUID); err != nil { 190 log.Printf("Failed to mark item as seen: %v", err) 191 } 192 continue 193 } 194 195 log.Printf("New item found: %s", item.Title) 196 197 // Create post text 198 postText := formatPost(item) 199 200 if dryRun { 201 log.Printf("[DRY-RUN] Would post:\n%s\n", postText) 202 postedCount++ 203 } else { 204 // Post to Bluesky 205 postCtx, cancel := context.WithTimeout(ctx, 30*time.Second) 206 err := bskyClient.Post(postCtx, postText) 207 cancel() 208 209 if err != nil { 210 log.Printf("Failed to post item '%s': %v", item.Title, err) 211 continue 212 } 213 214 log.Printf("Successfully posted: %s", item.Title) 215 postedCount++ 216 } 217 218 // Mark as posted 219 if err := store.MarkPosted(item.GUID); err != nil { 220 log.Printf("Failed to mark item as posted: %v", err) 221 } 222 223 // Rate limiting - wait a bit between posts to avoid overwhelming Bluesky 224 if postedCount > 0 && !dryRun { 225 time.Sleep(2 * time.Second) 226 } 227 } 228 229 if !hasSeenFeedBefore { 230 if newItemCount > 0 { 231 log.Printf("New feed detected: marked %d items as seen from %s (not posted)", newItemCount, feedURL) 232 } 233 } else { 234 if newItemCount == 0 { 235 log.Printf("No new items in feed %s", feedURL) 236 } else { 237 log.Printf("Processed %d new items from feed %s (%d posted)", newItemCount, feedURL, postedCount) 238 } 239 } 240 241 return nil 242} 243 244func formatPost(item *rss.FeedItem) string { 245 // Start with title 246 text := item.Title 247 248 // Add link if available 249 if item.Link != "" { 250 text += "\n\n" + item.Link 251 } 252 253 // Truncate if too long 254 if len(text) > maxPostLength { 255 // Try to truncate title intelligently 256 maxTitleLen := maxPostLength - len(item.Link) - 5 // 5 for "\n\n" and "..." 257 if maxTitleLen > 0 { 258 text = truncateText(item.Title, maxTitleLen) + "...\n\n" + item.Link 259 } else { 260 // If even with minimal title it's too long, just use the link 261 text = item.Link 262 } 263 } 264 265 return text 266} 267 268func truncateText(text string, maxLen int) string { 269 if len(text) <= maxLen { 270 return text 271 } 272 273 // Try to truncate at word boundary 274 truncated := text[:maxLen] 275 lastSpace := strings.LastIndex(truncated, " ") 276 if lastSpace > maxLen/2 { 277 return text[:lastSpace] 278 } 279 280 return truncated 281}