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/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}