Subscribe and post RSS feeds to Bluesky
rss
bluesky
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}