Subscribe and post RSS feeds to Bluesky
rss bluesky
at v1.0.1 7.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/rss" 17 "pkg.rbrt.fr/bskyrss/internal/storage" 18) 19 20const ( 21 defaultPollInterval = 15 * time.Minute 22 defaultStorageFile = "posted_items.txt" 23 maxPostLength = 299 // Bluesky has a 299 character limit 24) 25 26func 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()) 90 defer cancel() 91 92 sigChan := make(chan os.Signal, 1) 93 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) 94 95 go func() { 96 <-sigChan 97 log.Println("Received shutdown signal, stopping...") 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 126func 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 144func 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 155func 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 } 172 } 173 174 // Process items in reverse order (oldest first) 175 newItemCount := 0 176 postedCount := 0 177 178 for i := len(items) - 1; i >= 0; i-- { 179 item := items[i] 180 181 // Skip if already posted 182 if store.IsPosted(item.GUID) { 183 continue 184 } 185 186 newItemCount++ 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 242 return nil 243} 244 245func formatPost(item *rss.FeedItem) string { 246 // Collect all unique URLs 247 urls := []string{} 248 if item.Link != "" { 249 urls = append(urls, item.Link) 250 } 251 252 // Add GUID if it's a URL and different from link (e.g., HN comment links) 253 if item.GUID != "" && item.GUID != item.Link { 254 if u, err := url.Parse(item.GUID); err == nil && (u.Scheme == "http" || u.Scheme == "https") { 255 urls = append(urls, item.GUID) 256 } 257 } 258 259 // Build post: title + links 260 text := item.Title 261 if len(urls) > 0 { 262 text += "\n" + strings.Join(urls, "\n") 263 } 264 265 // Truncate if too long 266 if len(text) > maxPostLength { 267 linkText := "" 268 if len(urls) > 0 { 269 linkText = "\n" + strings.Join(urls, "\n") 270 } 271 272 availableForTitle := maxPostLength - len(linkText) - 3 // 3 for "..." 273 if availableForTitle > 20 { 274 text = truncateText(item.Title, availableForTitle) + "..." + linkText 275 } else { 276 // Title too long even truncated, use just first URL or truncated title 277 if len(urls) > 0 { 278 text = urls[0] 279 } else { 280 text = truncateText(item.Title, maxPostLength-3) + "..." 281 } 282 } 283 } 284 285 return text 286} 287 288func truncateText(text string, maxLen int) string { 289 if len(text) <= maxLen { 290 return text 291 } 292 293 // Try to truncate at word boundary 294 truncated := text[:maxLen] 295 lastSpace := strings.LastIndex(truncated, " ") 296 if lastSpace > maxLen/2 { 297 return text[:lastSpace] 298 } 299 300 return truncated 301}