···11+# Bluesky RSS Post Configuration Example
22+# Module: pkg.rbrt.fr/bskyrss
33+# Copy this file to .env and fill in your values
44+55+# Bluesky credentials
66+# Your Bluesky handle (include the full domain, e.g., user.bsky.social)
77+BSKY_HANDLE=your-handle.bsky.social
88+99+# Your Bluesky App Password (NOT your main account password)
1010+# Create an App Password at: Settings → App Passwords
1111+BSKY_PASSWORD=your-app-password
1212+1313+# RSS Feed URL(s) to monitor
1414+# This is the feed(s) that will be checked for new items
1515+# For multiple feeds, separate with commas
1616+RSS_FEED_URL=https://example.com/feed.xml
1717+# RSS_FEED_URL=https://blog1.com/rss,https://blog2.com/atom.xml,https://news.com/feed
1818+1919+# Optional: Bluesky PDS server (default: https://bsky.social)
2020+# Only change this if you're using a self-hosted PDS
2121+# BSKY_PDS=https://bsky.social
2222+2323+# Optional: Poll interval (default: 15m)
2424+# How often to check the RSS feed for new items
2525+# Examples: 5m, 15m, 1h, 30s
2626+# POLL_INTERVAL=15m
2727+2828+# Optional: Storage file location (default: posted_items.txt)
2929+# File where posted item GUIDs are tracked to prevent duplicates
3030+# STORAGE_FILE=posted_items.txt
-17
.github/workflows/go-unit.yml
···11-name: Unit tests
22-on:
33- pull_request:
44-jobs:
55- tests:
66- runs-on: ubuntu-latest
77- steps:
88- - name: Check out source
99- uses: actions/checkout@v4
1010- - name: Set up Go
1111- uses: actions/setup-go@v5
1212- with:
1313- go-version: "stable"
1414- check-latest: true
1515- - name: Tests
1616- run: |
1717- go test ./... -v
···11+package bluesky
22+33+import (
44+ "context"
55+ "fmt"
66+ "strings"
77+ "time"
88+99+ "github.com/bluesky-social/indigo/api/atproto"
1010+ "github.com/bluesky-social/indigo/api/bsky"
1111+ lexutil "github.com/bluesky-social/indigo/lex/util"
1212+ "github.com/bluesky-social/indigo/xrpc"
1313+)
1414+1515+// Client handles Bluesky API operations
1616+type Client struct {
1717+ xrpcClient *xrpc.Client
1818+ handle string
1919+ did string
2020+}
2121+2222+// Config holds configuration for Bluesky client
2323+type Config struct {
2424+ Handle string
2525+ Password string
2626+ PDS string // Personal Data Server URL (default: https://bsky.social)
2727+}
2828+2929+// NewClient creates a new Bluesky client and authenticates
3030+func NewClient(ctx context.Context, cfg Config) (*Client, error) {
3131+ if cfg.PDS == "" {
3232+ cfg.PDS = "https://bsky.social"
3333+ }
3434+3535+ xrpcClient := &xrpc.Client{
3636+ Host: cfg.PDS,
3737+ }
3838+3939+ // Authenticate
4040+ auth, err := atproto.ServerCreateSession(ctx, xrpcClient, &atproto.ServerCreateSession_Input{
4141+ Identifier: cfg.Handle,
4242+ Password: cfg.Password,
4343+ })
4444+ if err != nil {
4545+ return nil, fmt.Errorf("failed to authenticate: %w", err)
4646+ }
4747+4848+ xrpcClient.Auth = &xrpc.AuthInfo{
4949+ AccessJwt: auth.AccessJwt,
5050+ RefreshJwt: auth.RefreshJwt,
5151+ Handle: auth.Handle,
5252+ Did: auth.Did,
5353+ }
5454+5555+ return &Client{
5656+ xrpcClient: xrpcClient,
5757+ handle: auth.Handle,
5858+ did: auth.Did,
5959+ }, nil
6060+}
6161+6262+// Post creates a new post on Bluesky
6363+func (c *Client) Post(ctx context.Context, text string) error {
6464+ // Create the post record
6565+ post := &bsky.FeedPost{
6666+ Text: text,
6767+ CreatedAt: time.Now().Format(time.RFC3339),
6868+ Langs: []string{"en"},
6969+ }
7070+7171+ // Detect and add facets for links
7272+ facets := c.detectFacets(text)
7373+ if len(facets) > 0 {
7474+ post.Facets = facets
7575+ }
7676+7777+ // Create the record
7878+ input := &atproto.RepoCreateRecord_Input{
7979+ Repo: c.did,
8080+ Collection: "app.bsky.feed.post",
8181+ Record: &lexutil.LexiconTypeDecoder{
8282+ Val: post,
8383+ },
8484+ }
8585+8686+ _, err := atproto.RepoCreateRecord(ctx, c.xrpcClient, input)
8787+ if err != nil {
8888+ return fmt.Errorf("failed to create post: %w", err)
8989+ }
9090+9191+ return nil
9292+}
9393+9494+// detectFacets detects links in text and creates facets for them
9595+func (c *Client) detectFacets(text string) []*bsky.RichtextFacet {
9696+ var facets []*bsky.RichtextFacet
9797+9898+ // Simple URL detection - looks for http:// or https://
9999+ words := strings.Fields(text)
100100+ currentPos := 0
101101+102102+ for _, word := range words {
103103+ // Find the position of this word in the original text
104104+ idx := strings.Index(text[currentPos:], word)
105105+ if idx == -1 {
106106+ continue
107107+ }
108108+ currentPos += idx
109109+110110+ // Check if it's a URL
111111+ if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
112112+ // Clean up any trailing punctuation
113113+ cleanURL := strings.TrimRight(word, ".,;:!?)")
114114+115115+ facet := &bsky.RichtextFacet{
116116+ Index: &bsky.RichtextFacet_ByteSlice{
117117+ ByteStart: int64(currentPos),
118118+ ByteEnd: int64(currentPos + len(cleanURL)),
119119+ },
120120+ Features: []*bsky.RichtextFacet_Features_Elem{
121121+ {
122122+ RichtextFacet_Link: &bsky.RichtextFacet_Link{
123123+ Uri: cleanURL,
124124+ },
125125+ },
126126+ },
127127+ }
128128+ facets = append(facets, facet)
129129+ }
130130+131131+ currentPos += len(word)
132132+ }
133133+134134+ return facets
135135+}
136136+137137+// GetHandle returns the authenticated user's handle
138138+func (c *Client) GetHandle() string {
139139+ return c.handle
140140+}
141141+142142+// GetDID returns the authenticated user's DID
143143+func (c *Client) GetDID() string {
144144+ return c.did
145145+}
+76
internal/rss/feed.go
···11+package rss
22+33+import (
44+ "context"
55+ "fmt"
66+ "time"
77+88+ "github.com/mmcdole/gofeed"
99+)
1010+1111+// FeedItem represents a single RSS feed item
1212+type FeedItem struct {
1313+ GUID string
1414+ Title string
1515+ Description string
1616+ Link string
1717+ Published time.Time
1818+}
1919+2020+// Checker handles RSS feed checking operations
2121+type Checker struct {
2222+ parser *gofeed.Parser
2323+}
2424+2525+// NewChecker creates a new RSS feed checker
2626+func NewChecker() *Checker {
2727+ return &Checker{
2828+ parser: gofeed.NewParser(),
2929+ }
3030+}
3131+3232+// FetchLatestItems fetches the latest items from an RSS feed
3333+func (c *Checker) FetchLatestItems(ctx context.Context, feedURL string, limit int) ([]*FeedItem, error) {
3434+ feed, err := c.parser.ParseURLWithContext(feedURL, ctx)
3535+ if err != nil {
3636+ return nil, fmt.Errorf("failed to parse RSS feed: %w", err)
3737+ }
3838+3939+ if feed == nil || len(feed.Items) == 0 {
4040+ return []*FeedItem{}, nil
4141+ }
4242+4343+ // Limit the number of items to return
4444+ maxItems := len(feed.Items)
4545+ if limit > 0 && limit < maxItems {
4646+ maxItems = limit
4747+ }
4848+4949+ items := make([]*FeedItem, 0, maxItems)
5050+ for i := 0; i < maxItems; i++ {
5151+ item := feed.Items[i]
5252+5353+ published := time.Now()
5454+ if item.PublishedParsed != nil {
5555+ published = *item.PublishedParsed
5656+ } else if item.UpdatedParsed != nil {
5757+ published = *item.UpdatedParsed
5858+ }
5959+6060+ // Use GUID if available, otherwise use link
6161+ guid := item.GUID
6262+ if guid == "" {
6363+ guid = item.Link
6464+ }
6565+6666+ items = append(items, &FeedItem{
6767+ GUID: guid,
6868+ Title: item.Title,
6969+ Description: item.Description,
7070+ Link: item.Link,
7171+ Published: published,
7272+ })
7373+ }
7474+7575+ return items, nil
7676+}
+180
internal/rss/feed_test.go
···11+package rss
22+33+import (
44+ "context"
55+ "net/http"
66+ "net/http/httptest"
77+ "testing"
88+ "time"
99+)
1010+1111+func TestFetchLatestItems(t *testing.T) {
1212+ // Create a test RSS feed
1313+ testFeed := `<?xml version="1.0" encoding="UTF-8"?>
1414+<rss version="2.0">
1515+ <channel>
1616+ <title>Test Feed</title>
1717+ <link>https://example.com</link>
1818+ <description>A test feed</description>
1919+ <item>
2020+ <title>Test Item 1</title>
2121+ <link>https://example.com/item1</link>
2222+ <description>Description for item 1</description>
2323+ <guid>item-1</guid>
2424+ <pubDate>Mon, 02 Jan 2023 15:04:05 GMT</pubDate>
2525+ </item>
2626+ <item>
2727+ <title>Test Item 2</title>
2828+ <link>https://example.com/item2</link>
2929+ <description>Description for item 2</description>
3030+ <guid>item-2</guid>
3131+ <pubDate>Mon, 02 Jan 2023 16:04:05 GMT</pubDate>
3232+ </item>
3333+ <item>
3434+ <title>Test Item 3</title>
3535+ <link>https://example.com/item3</link>
3636+ <description>Description for item 3</description>
3737+ <guid>item-3</guid>
3838+ <pubDate>Mon, 02 Jan 2023 17:04:05 GMT</pubDate>
3939+ </item>
4040+ </channel>
4141+</rss>`
4242+4343+ // Create a test server
4444+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4545+ w.Header().Set("Content-Type", "application/rss+xml")
4646+ w.WriteHeader(http.StatusOK)
4747+ w.Write([]byte(testFeed))
4848+ }))
4949+ defer server.Close()
5050+5151+ // Create checker
5252+ checker := NewChecker()
5353+5454+ // Test fetching all items
5555+ t.Run("FetchAllItems", func(t *testing.T) {
5656+ items, err := checker.FetchLatestItems(context.Background(), server.URL, 0)
5757+ if err != nil {
5858+ t.Fatalf("Failed to fetch items: %v", err)
5959+ }
6060+6161+ if len(items) != 3 {
6262+ t.Errorf("Expected 3 items, got %d", len(items))
6363+ }
6464+6565+ // Check first item
6666+ if items[0].Title != "Test Item 1" {
6767+ t.Errorf("Expected title 'Test Item 1', got '%s'", items[0].Title)
6868+ }
6969+ if items[0].Link != "https://example.com/item1" {
7070+ t.Errorf("Expected link 'https://example.com/item1', got '%s'", items[0].Link)
7171+ }
7272+ if items[0].GUID != "item-1" {
7373+ t.Errorf("Expected GUID 'item-1', got '%s'", items[0].GUID)
7474+ }
7575+ })
7676+7777+ // Test limiting items
7878+ t.Run("FetchLimitedItems", func(t *testing.T) {
7979+ items, err := checker.FetchLatestItems(context.Background(), server.URL, 2)
8080+ if err != nil {
8181+ t.Fatalf("Failed to fetch items: %v", err)
8282+ }
8383+8484+ if len(items) != 2 {
8585+ t.Errorf("Expected 2 items, got %d", len(items))
8686+ }
8787+ })
8888+8989+ // Test with context timeout
9090+ t.Run("ContextTimeout", func(t *testing.T) {
9191+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
9292+ defer cancel()
9393+9494+ time.Sleep(2 * time.Millisecond) // Ensure context is expired
9595+9696+ _, err := checker.FetchLatestItems(ctx, server.URL, 0)
9797+ if err == nil {
9898+ t.Error("Expected error with expired context, got nil")
9999+ }
100100+ })
101101+}
102102+103103+func TestFetchLatestItems_InvalidURL(t *testing.T) {
104104+ checker := NewChecker()
105105+106106+ _, err := checker.FetchLatestItems(context.Background(), "not-a-valid-url", 0)
107107+ if err == nil {
108108+ t.Error("Expected error with invalid URL, got nil")
109109+ }
110110+}
111111+112112+func TestFetchLatestItems_EmptyFeed(t *testing.T) {
113113+ emptyFeed := `<?xml version="1.0" encoding="UTF-8"?>
114114+<rss version="2.0">
115115+ <channel>
116116+ <title>Empty Feed</title>
117117+ <link>https://example.com</link>
118118+ <description>A feed with no items</description>
119119+ </channel>
120120+</rss>`
121121+122122+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
123123+ w.Header().Set("Content-Type", "application/rss+xml")
124124+ w.WriteHeader(http.StatusOK)
125125+ w.Write([]byte(emptyFeed))
126126+ }))
127127+ defer server.Close()
128128+129129+ checker := NewChecker()
130130+ items, err := checker.FetchLatestItems(context.Background(), server.URL, 0)
131131+132132+ if err != nil {
133133+ t.Fatalf("Expected no error with empty feed, got: %v", err)
134134+ }
135135+136136+ if len(items) != 0 {
137137+ t.Errorf("Expected 0 items, got %d", len(items))
138138+ }
139139+}
140140+141141+func TestFeedItem_GUIDFallback(t *testing.T) {
142142+ // Feed with no GUID, should use link instead
143143+ feedNoGUID := `<?xml version="1.0" encoding="UTF-8"?>
144144+<rss version="2.0">
145145+ <channel>
146146+ <title>Test Feed</title>
147147+ <link>https://example.com</link>
148148+ <description>Test</description>
149149+ <item>
150150+ <title>Item Without GUID</title>
151151+ <link>https://example.com/item-no-guid</link>
152152+ <description>Description</description>
153153+ <pubDate>Mon, 02 Jan 2023 15:04:05 GMT</pubDate>
154154+ </item>
155155+ </channel>
156156+</rss>`
157157+158158+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
159159+ w.Header().Set("Content-Type", "application/rss+xml")
160160+ w.WriteHeader(http.StatusOK)
161161+ w.Write([]byte(feedNoGUID))
162162+ }))
163163+ defer server.Close()
164164+165165+ checker := NewChecker()
166166+ items, err := checker.FetchLatestItems(context.Background(), server.URL, 0)
167167+168168+ if err != nil {
169169+ t.Fatalf("Failed to fetch items: %v", err)
170170+ }
171171+172172+ if len(items) != 1 {
173173+ t.Fatalf("Expected 1 item, got %d", len(items))
174174+ }
175175+176176+ // GUID should fall back to link
177177+ if items[0].GUID != "https://example.com/item-no-guid" {
178178+ t.Errorf("Expected GUID to fallback to link, got '%s'", items[0].GUID)
179179+ }
180180+}
+94
internal/storage/storage.go
···11+package storage
22+33+import (
44+ "bufio"
55+ "fmt"
66+ "os"
77+ "sync"
88+)
99+1010+// Storage handles persistence of posted item GUIDs
1111+type Storage struct {
1212+ filePath string
1313+ posted map[string]bool
1414+ mu sync.RWMutex
1515+}
1616+1717+// New creates a new storage instance
1818+func New(filePath string) (*Storage, error) {
1919+ s := &Storage{
2020+ filePath: filePath,
2121+ posted: make(map[string]bool),
2222+ }
2323+2424+ // Load existing posted items
2525+ if err := s.load(); err != nil {
2626+ return nil, fmt.Errorf("failed to load storage: %w", err)
2727+ }
2828+2929+ return s, nil
3030+}
3131+3232+// load reads the posted items from disk
3333+func (s *Storage) load() error {
3434+ file, err := os.Open(s.filePath)
3535+ if err != nil {
3636+ if os.IsNotExist(err) {
3737+ // File doesn't exist yet, that's okay
3838+ return nil
3939+ }
4040+ return err
4141+ }
4242+ defer file.Close()
4343+4444+ scanner := bufio.NewScanner(file)
4545+ for scanner.Scan() {
4646+ guid := scanner.Text()
4747+ if guid != "" {
4848+ s.posted[guid] = true
4949+ }
5050+ }
5151+5252+ return scanner.Err()
5353+}
5454+5555+// save writes the posted items to disk
5656+func (s *Storage) save() error {
5757+ file, err := os.Create(s.filePath)
5858+ if err != nil {
5959+ return err
6060+ }
6161+ defer file.Close()
6262+6363+ writer := bufio.NewWriter(file)
6464+ for guid := range s.posted {
6565+ if _, err := writer.WriteString(guid + "\n"); err != nil {
6666+ return err
6767+ }
6868+ }
6969+7070+ return writer.Flush()
7171+}
7272+7373+// IsPosted checks if a GUID has been posted
7474+func (s *Storage) IsPosted(guid string) bool {
7575+ s.mu.RLock()
7676+ defer s.mu.RUnlock()
7777+ return s.posted[guid]
7878+}
7979+8080+// MarkPosted marks a GUID as posted
8181+func (s *Storage) MarkPosted(guid string) error {
8282+ s.mu.Lock()
8383+ defer s.mu.Unlock()
8484+8585+ s.posted[guid] = true
8686+ return s.save()
8787+}
8888+8989+// Count returns the number of posted items
9090+func (s *Storage) Count() int {
9191+ s.mu.RLock()
9292+ defer s.mu.RUnlock()
9393+ return len(s.posted)
9494+}
+247
internal/storage/storage_test.go
···11+package storage
22+33+import (
44+ "os"
55+ "path/filepath"
66+ "testing"
77+)
88+99+func TestNew(t *testing.T) {
1010+ tempDir := t.TempDir()
1111+ storagePath := filepath.Join(tempDir, "test_storage.txt")
1212+1313+ store, err := New(storagePath)
1414+ if err != nil {
1515+ t.Fatalf("Failed to create storage: %v", err)
1616+ }
1717+1818+ if store == nil {
1919+ t.Fatal("Expected non-nil storage")
2020+ }
2121+2222+ if store.Count() != 0 {
2323+ t.Errorf("Expected empty storage, got count %d", store.Count())
2424+ }
2525+}
2626+2727+func TestMarkPosted(t *testing.T) {
2828+ tempDir := t.TempDir()
2929+ storagePath := filepath.Join(tempDir, "test_storage.txt")
3030+3131+ store, err := New(storagePath)
3232+ if err != nil {
3333+ t.Fatalf("Failed to create storage: %v", err)
3434+ }
3535+3636+ // Mark an item as posted
3737+ guid := "test-guid-123"
3838+ err = store.MarkPosted(guid)
3939+ if err != nil {
4040+ t.Fatalf("Failed to mark item as posted: %v", err)
4141+ }
4242+4343+ // Verify it's marked as posted
4444+ if !store.IsPosted(guid) {
4545+ t.Error("Expected item to be marked as posted")
4646+ }
4747+4848+ // Verify count
4949+ if store.Count() != 1 {
5050+ t.Errorf("Expected count 1, got %d", store.Count())
5151+ }
5252+}
5353+5454+func TestIsPosted(t *testing.T) {
5555+ tempDir := t.TempDir()
5656+ storagePath := filepath.Join(tempDir, "test_storage.txt")
5757+5858+ store, err := New(storagePath)
5959+ if err != nil {
6060+ t.Fatalf("Failed to create storage: %v", err)
6161+ }
6262+6363+ guid := "test-guid-456"
6464+6565+ // Should not be posted initially
6666+ if store.IsPosted(guid) {
6767+ t.Error("Expected item to not be posted initially")
6868+ }
6969+7070+ // Mark as posted
7171+ store.MarkPosted(guid)
7272+7373+ // Should be posted now
7474+ if !store.IsPosted(guid) {
7575+ t.Error("Expected item to be posted after marking")
7676+ }
7777+}
7878+7979+func TestPersistence(t *testing.T) {
8080+ tempDir := t.TempDir()
8181+ storagePath := filepath.Join(tempDir, "test_storage.txt")
8282+8383+ // Create first storage instance and add items
8484+ store1, err := New(storagePath)
8585+ if err != nil {
8686+ t.Fatalf("Failed to create first storage: %v", err)
8787+ }
8888+8989+ guids := []string{"guid-1", "guid-2", "guid-3"}
9090+ for _, guid := range guids {
9191+ if err := store1.MarkPosted(guid); err != nil {
9292+ t.Fatalf("Failed to mark item as posted: %v", err)
9393+ }
9494+ }
9595+9696+ // Create second storage instance (simulating restart)
9797+ store2, err := New(storagePath)
9898+ if err != nil {
9999+ t.Fatalf("Failed to create second storage: %v", err)
100100+ }
101101+102102+ // Verify all items are still marked as posted
103103+ for _, guid := range guids {
104104+ if !store2.IsPosted(guid) {
105105+ t.Errorf("Expected guid '%s' to be persisted", guid)
106106+ }
107107+ }
108108+109109+ // Verify count
110110+ if store2.Count() != len(guids) {
111111+ t.Errorf("Expected count %d, got %d", len(guids), store2.Count())
112112+ }
113113+}
114114+115115+func TestMarkPosted_Duplicate(t *testing.T) {
116116+ tempDir := t.TempDir()
117117+ storagePath := filepath.Join(tempDir, "test_storage.txt")
118118+119119+ store, err := New(storagePath)
120120+ if err != nil {
121121+ t.Fatalf("Failed to create storage: %v", err)
122122+ }
123123+124124+ guid := "duplicate-guid"
125125+126126+ // Mark twice
127127+ store.MarkPosted(guid)
128128+ store.MarkPosted(guid)
129129+130130+ // Should still only count as one
131131+ if store.Count() != 1 {
132132+ t.Errorf("Expected count 1 after duplicate mark, got %d", store.Count())
133133+ }
134134+135135+ // Should still be posted
136136+ if !store.IsPosted(guid) {
137137+ t.Error("Expected item to still be posted")
138138+ }
139139+}
140140+141141+func TestLoad_EmptyFile(t *testing.T) {
142142+ tempDir := t.TempDir()
143143+ storagePath := filepath.Join(tempDir, "empty.txt")
144144+145145+ // Create empty file
146146+ file, err := os.Create(storagePath)
147147+ if err != nil {
148148+ t.Fatalf("Failed to create empty file: %v", err)
149149+ }
150150+ file.Close()
151151+152152+ // Load storage
153153+ store, err := New(storagePath)
154154+ if err != nil {
155155+ t.Fatalf("Failed to load storage from empty file: %v", err)
156156+ }
157157+158158+ if store.Count() != 0 {
159159+ t.Errorf("Expected count 0 from empty file, got %d", store.Count())
160160+ }
161161+}
162162+163163+func TestLoad_FileWithEmptyLines(t *testing.T) {
164164+ tempDir := t.TempDir()
165165+ storagePath := filepath.Join(tempDir, "test_with_empty_lines.txt")
166166+167167+ // Create file with some empty lines
168168+ content := "guid-1\n\nguid-2\n\n\nguid-3\n"
169169+ if err := os.WriteFile(storagePath, []byte(content), 0644); err != nil {
170170+ t.Fatalf("Failed to write test file: %v", err)
171171+ }
172172+173173+ // Load storage
174174+ store, err := New(storagePath)
175175+ if err != nil {
176176+ t.Fatalf("Failed to load storage: %v", err)
177177+ }
178178+179179+ // Should only have 3 items (empty lines ignored)
180180+ if store.Count() != 3 {
181181+ t.Errorf("Expected count 3, got %d", store.Count())
182182+ }
183183+184184+ // Verify specific GUIDs
185185+ expectedGuids := []string{"guid-1", "guid-2", "guid-3"}
186186+ for _, guid := range expectedGuids {
187187+ if !store.IsPosted(guid) {
188188+ t.Errorf("Expected guid '%s' to be loaded", guid)
189189+ }
190190+ }
191191+}
192192+193193+func TestCount(t *testing.T) {
194194+ tempDir := t.TempDir()
195195+ storagePath := filepath.Join(tempDir, "test_storage.txt")
196196+197197+ store, err := New(storagePath)
198198+ if err != nil {
199199+ t.Fatalf("Failed to create storage: %v", err)
200200+ }
201201+202202+ // Initial count should be 0
203203+ if store.Count() != 0 {
204204+ t.Errorf("Expected initial count 0, got %d", store.Count())
205205+ }
206206+207207+ // Add items and check count
208208+ for i := 0; i < 5; i++ {
209209+ store.MarkPosted(string(rune('a' + i)))
210210+ expectedCount := i + 1
211211+ if store.Count() != expectedCount {
212212+ t.Errorf("After adding %d items, expected count %d, got %d", expectedCount, expectedCount, store.Count())
213213+ }
214214+ }
215215+}
216216+217217+func TestConcurrentAccess(t *testing.T) {
218218+ tempDir := t.TempDir()
219219+ storagePath := filepath.Join(tempDir, "concurrent_test.txt")
220220+221221+ store, err := New(storagePath)
222222+ if err != nil {
223223+ t.Fatalf("Failed to create storage: %v", err)
224224+ }
225225+226226+ // Simulate concurrent access
227227+ done := make(chan bool)
228228+ for i := 0; i < 10; i++ {
229229+ go func(id int) {
230230+ guid := string(rune('a' + id))
231231+ store.MarkPosted(guid)
232232+ _ = store.IsPosted(guid)
233233+ _ = store.Count()
234234+ done <- true
235235+ }(i)
236236+ }
237237+238238+ // Wait for all goroutines
239239+ for i := 0; i < 10; i++ {
240240+ <-done
241241+ }
242242+243243+ // All items should be present
244244+ if store.Count() != 10 {
245245+ t.Errorf("Expected count 10 after concurrent access, got %d", store.Count())
246246+ }
247247+}
+21
license
···11+MIT License
22+33+Copyright (c) 2026 Julien Robert and contributors
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+286-1
main.go
···11package main
2233-func main() {}
33+import (
44+ "context"
55+ "flag"
66+ "fmt"
77+ "log"
88+ "os"
99+ "os/signal"
1010+ "strings"
1111+ "syscall"
1212+ "time"
1313+1414+ "pkg.rbrt.fr/bskyrss/internal/bluesky"
1515+ "pkg.rbrt.fr/bskyrss/internal/rss"
1616+ "pkg.rbrt.fr/bskyrss/internal/storage"
1717+)
1818+1919+const (
2020+ defaultPollInterval = 15 * time.Minute
2121+ defaultStorageFile = "posted_items.txt"
2222+ maxPostLength = 299 // Bluesky has a 299 character limit
2323+)
2424+2525+func main() {
2626+ // Command line flags
2727+ feedURLs := flag.String("feed", "", "RSS/Atom feed URL(s) to monitor (comma-delimited for multiple feeds, required)")
2828+ handle := flag.String("handle", "", "Bluesky handle (required)")
2929+ password := flag.String("password", "", "Bluesky password (can also use BSKY_PASSWORD env var)")
3030+ pds := flag.String("pds", "https://bsky.social", "Bluesky PDS server URL")
3131+ pollInterval := flag.Duration("interval", defaultPollInterval, "Poll interval for checking RSS feed")
3232+ storageFile := flag.String("storage", defaultStorageFile, "File to store posted item GUIDs")
3333+ dryRun := flag.Bool("dry-run", false, "Don't actually post to Bluesky, just show what would be posted")
3434+ flag.Parse()
3535+3636+ // Validate required flags
3737+ if *feedURLs == "" {
3838+ log.Fatal("Error: -feed flag is required")
3939+ }
4040+ if *handle == "" {
4141+ log.Fatal("Error: -handle flag is required")
4242+ }
4343+4444+ // Parse comma-delimited feed URLs
4545+ feeds := parseFeedURLs(*feedURLs)
4646+ if len(feeds) == 0 {
4747+ log.Fatal("Error: no valid feed URLs provided")
4848+ }
4949+ log.Printf("Monitoring %d feed(s)", len(feeds))
5050+5151+ // Get password from flag or environment variable
5252+ bskyPassword := *password
5353+ if bskyPassword == "" {
5454+ bskyPassword = os.Getenv("BSKY_PASSWORD")
5555+ if bskyPassword == "" {
5656+ log.Fatal("Error: -password flag or BSKY_PASSWORD environment variable is required")
5757+ }
5858+ }
5959+6060+ store, err := storage.New(*storageFile)
6161+ if err != nil {
6262+ log.Fatalf("Failed to initialize storage: %v", err)
6363+ }
6464+6565+ // Determine if this is the first run (no items in storage)
6666+ isFirstRun := store.Count() == 0
6767+ if isFirstRun {
6868+ log.Println("First run detected - will mark existing items as seen without posting")
6969+ } else {
7070+ log.Printf("Storage initialized with %d previously posted items", store.Count())
7171+ }
7272+7373+ rssChecker := rss.NewChecker()
7474+7575+ // Initialize Bluesky client (unless dry-run mode)
7676+ var bskyClient *bluesky.Client
7777+ if !*dryRun {
7878+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
7979+ defer cancel()
8080+8181+ bskyClient, err = bluesky.NewClient(ctx, bluesky.Config{
8282+ Handle: *handle,
8383+ Password: bskyPassword,
8484+ PDS: *pds,
8585+ })
8686+ if err != nil {
8787+ log.Fatalf("Failed to initialize Bluesky client: %v", err)
8888+ }
8989+ log.Printf("Authenticated as @%s", bskyClient.GetHandle())
9090+ } else {
9191+ log.Println("Running in DRY-RUN mode - no posts will be made")
9292+ }
9393+9494+ // Setup signal handling for graceful shutdown
9595+ ctx, cancel := context.WithCancel(context.Background())
9696+ defer cancel()
9797+9898+ sigChan := make(chan os.Signal, 1)
9999+ signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
100100+101101+ go func() {
102102+ <-sigChan
103103+ log.Println("Received shutdown signal, stopping...")
104104+ cancel()
105105+ }()
106106+107107+ // Main loop
108108+ log.Printf("Poll interval: %s", *pollInterval)
109109+110110+ ticker := time.NewTicker(*pollInterval)
111111+ defer ticker.Stop()
112112+113113+ // Check immediately on startup
114114+ if err := checkAndPostFeeds(ctx, rssChecker, bskyClient, store, feeds, *dryRun, isFirstRun); err != nil {
115115+ log.Printf("Error during initial check: %v", err)
116116+ }
117117+118118+ // Continue checking on interval (not first run anymore after first check)
119119+ for {
120120+ select {
121121+ case <-ctx.Done():
122122+ return
123123+ case <-ticker.C:
124124+ if err := checkAndPostFeeds(ctx, rssChecker, bskyClient, store, feeds, *dryRun, false); err != nil {
125125+ log.Printf("Error during check: %v", err)
126126+ }
127127+ }
128128+ }
129129+}
130130+131131+// parseFeedURLs splits comma-delimited feed URLs and trims whitespace
132132+func parseFeedURLs(feedString string) []string {
133133+ if feedString == "" {
134134+ return nil
135135+ }
136136+137137+ parts := strings.Split(feedString, ",")
138138+ feeds := make([]string, 0, len(parts))
139139+140140+ for _, part := range parts {
141141+ trimmed := strings.TrimSpace(part)
142142+ if trimmed != "" {
143143+ feeds = append(feeds, trimmed)
144144+ }
145145+ }
146146+147147+ return feeds
148148+}
149149+150150+func checkAndPostFeeds(ctx context.Context, rssChecker *rss.Checker, bskyClient *bluesky.Client, store *storage.Storage, feedURLs []string, dryRun bool, isFirstRun bool) error {
151151+ for _, feedURL := range feedURLs {
152152+ if err := checkAndPost(ctx, rssChecker, bskyClient, store, feedURL, dryRun, isFirstRun); err != nil {
153153+ log.Printf("Error checking feed %s: %v", feedURL, err)
154154+ // Continue with other feeds even if one fails
155155+ }
156156+ }
157157+158158+ return nil
159159+}
160160+161161+func checkAndPost(
162162+ ctx context.Context,
163163+ rssChecker *rss.Checker,
164164+ bskyClient *bluesky.Client,
165165+ store *storage.Storage,
166166+ feedURL string,
167167+ dryRun bool,
168168+ isFirstRun bool,
169169+) error {
170170+ log.Printf("Checking RSS feed: %s", feedURL)
171171+172172+ limit := 20
173173+ items, err := rssChecker.FetchLatestItems(ctx, feedURL, limit)
174174+ if err != nil {
175175+ return fmt.Errorf("failed to fetch RSS items: %w", err)
176176+ }
177177+178178+ log.Printf("Found %d items in feed", len(items))
179179+180180+ // Process items in reverse order (oldest first)
181181+ newItemCount := 0
182182+ postedCount := 0
183183+184184+ for i := len(items) - 1; i >= 0; i-- {
185185+ item := items[i]
186186+187187+ // Skip if already posted
188188+ if store.IsPosted(item.GUID) {
189189+ continue
190190+ }
191191+192192+ newItemCount++
193193+194194+ // On first run, just mark items as seen without posting
195195+ if isFirstRun {
196196+ if err := store.MarkPosted(item.GUID); err != nil {
197197+ log.Printf("Failed to mark item as seen: %v", err)
198198+ }
199199+ continue
200200+ }
201201+202202+ log.Printf("New item found: %s", item.Title)
203203+204204+ // Create post text
205205+ postText := formatPost(item)
206206+207207+ if dryRun {
208208+ log.Printf("[DRY-RUN] Would post:\n%s\n", postText)
209209+ postedCount++
210210+ } else {
211211+ // Post to Bluesky
212212+ postCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
213213+ err := bskyClient.Post(postCtx, postText)
214214+ cancel()
215215+216216+ if err != nil {
217217+ log.Printf("Failed to post item '%s': %v", item.Title, err)
218218+ continue
219219+ }
220220+221221+ log.Printf("Successfully posted: %s", item.Title)
222222+ postedCount++
223223+ }
224224+225225+ // Mark as posted
226226+ if err := store.MarkPosted(item.GUID); err != nil {
227227+ log.Printf("Failed to mark item as posted: %v", err)
228228+ }
229229+230230+ // Rate limiting - wait a bit between posts to avoid overwhelming Bluesky
231231+ if postedCount > 0 && !dryRun && !isFirstRun {
232232+ time.Sleep(2 * time.Second)
233233+ }
234234+ }
235235+236236+ if isFirstRun {
237237+ if newItemCount > 0 {
238238+ log.Printf("Marked %d items as seen from feed %s", newItemCount, feedURL)
239239+ }
240240+ } else {
241241+ if newItemCount == 0 {
242242+ log.Printf("No new items in feed %s", feedURL)
243243+ } else {
244244+ log.Printf("Processed %d new items from feed %s (%d posted)", newItemCount, feedURL, postedCount)
245245+ }
246246+ }
247247+248248+ return nil
249249+}
250250+251251+func formatPost(item *rss.FeedItem) string {
252252+ // Start with title
253253+ text := item.Title
254254+255255+ // Add link if available
256256+ if item.Link != "" {
257257+ text += "\n\n" + item.Link
258258+ }
259259+260260+ // Truncate if too long
261261+ if len(text) > maxPostLength {
262262+ // Try to truncate title intelligently
263263+ maxTitleLen := maxPostLength - len(item.Link) - 5 // 5 for "\n\n" and "..."
264264+ if maxTitleLen > 0 {
265265+ text = truncateText(item.Title, maxTitleLen) + "...\n\n" + item.Link
266266+ } else {
267267+ // If even with minimal title it's too long, just use the link
268268+ text = item.Link
269269+ }
270270+ }
271271+272272+ return text
273273+}
274274+275275+func truncateText(text string, maxLen int) string {
276276+ if len(text) <= maxLen {
277277+ return text
278278+ }
279279+280280+ // Try to truncate at word boundary
281281+ truncated := text[:maxLen]
282282+ lastSpace := strings.LastIndex(truncated, " ")
283283+ if lastSpace > maxLen/2 {
284284+ return text[:lastSpace]
285285+ }
286286+287287+ return truncated
288288+}
···11-# Go Project Template
11+# Bluesky RSS Post
22+33+Tool to automatically post on Bluesky when new items are added to RSS/Atom feeds.
44+55+## Installation
66+77+### Go Binary
88+99+```bash
1010+go install pkg.rbrt.fr/bskyrss@latest
1111+```
1212+1313+### Docker
1414+1515+```bash
1616+docker run -d \
1717+ --name bksy-rss-post \
1818+ -v $(pwd)/data:/data \
1919+ -e BSKY_PASSWORD="your-app-password" \
2020+ bksy-rss-post \
2121+ -feed "https://example.com/feed.xml" \
2222+ -handle "your-handle.bsky.social" \
2323+ -storage /data/posted_items.txt
2424+```
2525+2626+## Usage
2727+2828+### Basic Usage
2929+3030+```bash
3131+./bksy-rss-post \
3232+ -feed "https://example.com/feed.xml" \
3333+ -handle "your-handle.bsky.social" \
3434+ -password "your-app-password"
3535+```
3636+3737+### Multiple Feeds
3838+3939+Monitor multiple feeds by separating URLs with commas:
4040+4141+```bash
4242+./bksy-rss-post \
4343+ -feed "https://blog1.com/feed.xml,https://blog2.com/rss,https://news.com/atom.xml" \
4444+ -handle "your-handle.bsky.social" \
4545+ -password "your-app-password"
4646+```
4747+4848+or
4949+5050+```bash
5151+export BSKY_PASSWORD="your-app-password"
5252+./bksy-rss-post \
5353+ -feed "https://example.com/feed.xml" \
5454+ -handle "your-handle.bsky.social"
5555+```
5656+5757+### Command Line Options
5858+5959+| Flag | Description | Required | Default |
6060+| ----------- | ------------------------------------------------- | -------- | ------------------- |
6161+| `-feed` | RSS/Atom feed URL(s) to monitor (comma-delimited) | Yes | - |
6262+| `-handle` | Bluesky handle (e.g., user.bsky.social) | Yes | - |
6363+| `-password` | Bluesky password or app password | Yes\* | - |
6464+| `-pds` | Bluesky PDS server URL | No | https://bsky.social |
6565+| `-interval` | Poll interval for checking RSS feed | No | 15m |
6666+| `-storage` | File to store posted item GUIDs | No | posted_items.txt |
6767+| `-dry-run` | Don't post, just show what would be posted | No | false |
6868+6969+\*Can be provided via `BSKY_PASSWORD` environment variable instead
7070+7171+### Examples
7272+7373+#### Monitor multiple feeds
7474+7575+```bash
7676+./bksy-rss-post \
7777+ -feed "https://blog.com/rss,https://news.com/atom.xml,https://podcast.com/feed" \
7878+ -handle "your-handle.bsky.social"
7979+```
8080+8181+#### Check feeds every 5 minutes
8282+8383+```bash
8484+./bksy-rss-post \
8585+ -feed "https://example.com/feed.xml" \
8686+ -handle "your-handle.bsky.social" \
8787+ -interval 5m
8888+```
8989+9090+#### Test without posting (dry-run mode)
9191+9292+```bash
9393+./bksy-rss-post \
9494+ -feed "https://example.com/feed.xml" \
9595+ -handle "your-handle.bsky.social" \
9696+ -dry-run
9797+```
9898+9999+#### Use custom storage file
100100+101101+```bash
102102+./bksy-rss-post \
103103+ -feed "https://example.com/feed.xml" \
104104+ -handle "your-handle.bsky.social" \
105105+ -storage /var/lib/bksy-rss-post/posted.txt
106106+```
107107+108108+## Bluesky Authentication
109109+110110+### App Passwords (Recommended)
111111+112112+It's recommended to use an App Password instead of your main account password:
113113+114114+1. Go to Bluesky Settings → App Passwords
115115+2. Create a new App Password
116116+3. Use this password with the `-password` flag or `BSKY_PASSWORD` environment variable
117117+118118+### Self-hosted PDS
119119+120120+If you're using a self-hosted Personal Data Server:
121121+122122+```bash
123123+./bksy-rss-post \
124124+ -feed "https://example.com/feed.xml" \
125125+ -handle "your-handle.your-pds.com" \
126126+ -pds "https://your-pds.com"
127127+```
128128+129129+## Post Format
130130+131131+Posts are formatted as follows:
132132+133133+```
134134+{Title}
135135+136136+{Link}
137137+```
138138+139139+- Posts are automatically truncated if they exceed Bluesky character limit
140140+- Links are automatically detected and formatted as clickable links
141141+- If the title is too long, it will be intelligently truncated at word boundaries
142142+143143+## Storage
144144+145145+The tool maintains a simple text file (default: `posted_items.txt`) containing the GUIDs of all posted items. This ensures that items are not posted multiple times, even if the tool is restarted.
146146+147147+The storage file contains one GUID per line and is safe to manually edit if needed.
148148+149149+### First Run Behavior
150150+151151+To prevent spam when starting the tool for the first time:
152152+153153+- **First Run (empty storage file)**: All existing feed items are marked as "seen" without posting. Only new items that appear _after_ the program starts will be posted.
154154+- **Subsequent Runs**: New items since the last check are posted normally (backfill from last posted item).
155155+156156+This ensures you don't flood Bluesky with the entire feed history when you first set up the tool.
157157+158158+## License
159159+160160+[MIT](license).