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}