+42
config.example.yaml
+42
config.example.yaml
···
···
1
+
# bskyrss configuration file
2
+
# This file allows you to map different RSS/Atom feeds to different Bluesky accounts
3
+
4
+
# List of accounts with their associated feeds
5
+
accounts:
6
+
# First account - posts from multiple feeds
7
+
- handle: "user1.bsky.social"
8
+
# Password can be plain text (not recommended) or environment variable reference
9
+
# Use ${VAR_NAME} or $VAR_NAME format for environment variables
10
+
password: "${BSKY_PASSWORD_USER1}"
11
+
# Optional: Custom PDS server (defaults to https://bsky.social)
12
+
pds: "https://bsky.social"
13
+
# Optional: Custom storage file for this account (defaults to global storage with account suffix)
14
+
# storage: "user1_posted.txt"
15
+
feeds:
16
+
- "https://example.com/feed.xml"
17
+
- "https://blog.example.com/rss"
18
+
- "https://news.example.com/atom.xml"
19
+
20
+
# Second account - posts from different feeds
21
+
- handle: "user2.bsky.social"
22
+
password: "${BSKY_PASSWORD_USER2}"
23
+
feeds:
24
+
- "https://another-blog.com/feed.xml"
25
+
- "https://tech-news.com/rss"
26
+
27
+
# Third account - posts from a single feed
28
+
- handle: "user3.bsky.social"
29
+
password: "${BSKY_PASSWORD_USER3}"
30
+
pds: "https://custom-pds.example.com"
31
+
feeds:
32
+
- "https://personal-blog.com/feed.xml"
33
+
34
+
# Global settings (optional)
35
+
# Poll interval for checking feeds (default: 15m)
36
+
# Valid units: s, m, h (e.g., "30s", "5m", "1h")
37
+
interval: "15m"
38
+
39
+
# Default storage file for tracking posted items (default: posted_items.txt)
40
+
# When multiple accounts are configured, separate files will be created automatically
41
+
# (e.g., posted_items_user1_bsky_social.txt, posted_items_user2_bsky_social.txt)
42
+
storage: "posted_items.txt"
+1
go.mod
+1
go.mod
+106
internal/config/config.go
+106
internal/config/config.go
···
···
1
+
package config
2
+
3
+
import (
4
+
"fmt"
5
+
"os"
6
+
"strings"
7
+
"time"
8
+
9
+
"gopkg.in/yaml.v3"
10
+
)
11
+
12
+
// Config represents the application configuration
13
+
type Config struct {
14
+
Accounts []Account `yaml:"accounts"`
15
+
Interval time.Duration `yaml:"interval,omitempty"`
16
+
Storage string `yaml:"storage,omitempty"`
17
+
}
18
+
19
+
// Account represents a Bluesky account with its associated feeds
20
+
type Account struct {
21
+
Handle string `yaml:"handle"`
22
+
Password string `yaml:"password,omitempty"`
23
+
PDS string `yaml:"pds,omitempty"`
24
+
Feeds []string `yaml:"feeds"`
25
+
Storage string `yaml:"storage,omitempty"` // Optional per-account storage file
26
+
}
27
+
28
+
// LoadFromFile loads configuration from a YAML file
29
+
func LoadFromFile(path string) (*Config, error) {
30
+
data, err := os.ReadFile(path)
31
+
if err != nil {
32
+
return nil, fmt.Errorf("failed to read config file: %w", err)
33
+
}
34
+
35
+
var cfg Config
36
+
if err := yaml.Unmarshal(data, &cfg); err != nil {
37
+
return nil, fmt.Errorf("failed to parse config file: %w", err)
38
+
}
39
+
40
+
if err := cfg.Validate(); err != nil {
41
+
return nil, fmt.Errorf("invalid configuration: %w", err)
42
+
}
43
+
44
+
// Process environment variables in passwords
45
+
for i := range cfg.Accounts {
46
+
cfg.Accounts[i].Password = expandEnvVar(cfg.Accounts[i].Password)
47
+
}
48
+
49
+
// Set defaults
50
+
if cfg.Interval == 0 {
51
+
cfg.Interval = 15 * time.Minute
52
+
}
53
+
if cfg.Storage == "" {
54
+
cfg.Storage = "posted_items.txt"
55
+
}
56
+
57
+
return &cfg, nil
58
+
}
59
+
60
+
// Validate checks if the configuration is valid
61
+
func (c *Config) Validate() error {
62
+
if len(c.Accounts) == 0 {
63
+
return fmt.Errorf("at least one account is required")
64
+
}
65
+
66
+
for i, account := range c.Accounts {
67
+
if account.Handle == "" {
68
+
return fmt.Errorf("account %d: handle is required", i)
69
+
}
70
+
if len(account.Feeds) == 0 {
71
+
return fmt.Errorf("account %d (%s): at least one feed is required", i, account.Handle)
72
+
}
73
+
if account.Password == "" {
74
+
return fmt.Errorf("account %d (%s): password is required", i, account.Handle)
75
+
}
76
+
}
77
+
78
+
return nil
79
+
}
80
+
81
+
// expandEnvVar expands environment variable references in the format ${VAR_NAME} or $VAR_NAME
82
+
func expandEnvVar(value string) string {
83
+
if value == "" {
84
+
return value
85
+
}
86
+
87
+
// Handle ${VAR_NAME} format
88
+
if strings.HasPrefix(value, "${") && strings.HasSuffix(value, "}") {
89
+
varName := value[2 : len(value)-1]
90
+
if envVal := os.Getenv(varName); envVal != "" {
91
+
return envVal
92
+
}
93
+
return value
94
+
}
95
+
96
+
// Handle $VAR_NAME format
97
+
if strings.HasPrefix(value, "$") {
98
+
varName := value[1:]
99
+
if envVal := os.Getenv(varName); envVal != "" {
100
+
return envVal
101
+
}
102
+
return value
103
+
}
104
+
105
+
return value
106
+
}
+256
internal/config/config_test.go
+256
internal/config/config_test.go
···
···
1
+
package config
2
+
3
+
import (
4
+
"os"
5
+
"path/filepath"
6
+
"testing"
7
+
"time"
8
+
)
9
+
10
+
func TestLoadFromFile(t *testing.T) {
11
+
// Create a temporary config file
12
+
tmpDir := t.TempDir()
13
+
configPath := filepath.Join(tmpDir, "config.yaml")
14
+
15
+
configContent := `
16
+
accounts:
17
+
- handle: "user1.bsky.social"
18
+
password: "password1"
19
+
pds: "https://bsky.social"
20
+
feeds:
21
+
- "https://feed1.com/rss"
22
+
- "https://feed2.com/atom"
23
+
- handle: "user2.bsky.social"
24
+
password: "password2"
25
+
feeds:
26
+
- "https://feed3.com/rss"
27
+
28
+
interval: "10m"
29
+
storage: "custom_storage.txt"
30
+
`
31
+
32
+
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
33
+
t.Fatalf("Failed to write config file: %v", err)
34
+
}
35
+
36
+
cfg, err := LoadFromFile(configPath)
37
+
if err != nil {
38
+
t.Fatalf("LoadFromFile failed: %v", err)
39
+
}
40
+
41
+
// Verify accounts
42
+
if len(cfg.Accounts) != 2 {
43
+
t.Errorf("Expected 2 accounts, got %d", len(cfg.Accounts))
44
+
}
45
+
46
+
// Verify first account
47
+
if cfg.Accounts[0].Handle != "user1.bsky.social" {
48
+
t.Errorf("Expected handle 'user1.bsky.social', got '%s'", cfg.Accounts[0].Handle)
49
+
}
50
+
if cfg.Accounts[0].Password != "password1" {
51
+
t.Errorf("Expected password 'password1', got '%s'", cfg.Accounts[0].Password)
52
+
}
53
+
if len(cfg.Accounts[0].Feeds) != 2 {
54
+
t.Errorf("Expected 2 feeds for account 1, got %d", len(cfg.Accounts[0].Feeds))
55
+
}
56
+
57
+
// Verify second account
58
+
if cfg.Accounts[1].Handle != "user2.bsky.social" {
59
+
t.Errorf("Expected handle 'user2.bsky.social', got '%s'", cfg.Accounts[1].Handle)
60
+
}
61
+
if len(cfg.Accounts[1].Feeds) != 1 {
62
+
t.Errorf("Expected 1 feed for account 2, got %d", len(cfg.Accounts[1].Feeds))
63
+
}
64
+
65
+
// Verify global settings
66
+
if cfg.Interval != 10*time.Minute {
67
+
t.Errorf("Expected interval 10m, got %v", cfg.Interval)
68
+
}
69
+
if cfg.Storage != "custom_storage.txt" {
70
+
t.Errorf("Expected storage 'custom_storage.txt', got '%s'", cfg.Storage)
71
+
}
72
+
}
73
+
74
+
func TestLoadFromFileWithEnvVars(t *testing.T) {
75
+
// Set environment variable
76
+
os.Setenv("TEST_PASSWORD", "env-password")
77
+
defer os.Unsetenv("TEST_PASSWORD")
78
+
79
+
tmpDir := t.TempDir()
80
+
configPath := filepath.Join(tmpDir, "config.yaml")
81
+
82
+
configContent := `
83
+
accounts:
84
+
- handle: "user1.bsky.social"
85
+
password: "${TEST_PASSWORD}"
86
+
feeds:
87
+
- "https://feed1.com/rss"
88
+
`
89
+
90
+
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
91
+
t.Fatalf("Failed to write config file: %v", err)
92
+
}
93
+
94
+
cfg, err := LoadFromFile(configPath)
95
+
if err != nil {
96
+
t.Fatalf("LoadFromFile failed: %v", err)
97
+
}
98
+
99
+
if cfg.Accounts[0].Password != "env-password" {
100
+
t.Errorf("Expected password 'env-password' from env var, got '%s'", cfg.Accounts[0].Password)
101
+
}
102
+
}
103
+
104
+
func TestLoadFromFileDefaults(t *testing.T) {
105
+
tmpDir := t.TempDir()
106
+
configPath := filepath.Join(tmpDir, "config.yaml")
107
+
108
+
// Minimal config with only required fields
109
+
configContent := `
110
+
accounts:
111
+
- handle: "user1.bsky.social"
112
+
password: "password1"
113
+
feeds:
114
+
- "https://feed1.com/rss"
115
+
`
116
+
117
+
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
118
+
t.Fatalf("Failed to write config file: %v", err)
119
+
}
120
+
121
+
cfg, err := LoadFromFile(configPath)
122
+
if err != nil {
123
+
t.Fatalf("LoadFromFile failed: %v", err)
124
+
}
125
+
126
+
// Check defaults
127
+
if cfg.Interval != 15*time.Minute {
128
+
t.Errorf("Expected default interval 15m, got %v", cfg.Interval)
129
+
}
130
+
if cfg.Storage != "posted_items.txt" {
131
+
t.Errorf("Expected default storage 'posted_items.txt', got '%s'", cfg.Storage)
132
+
}
133
+
134
+
}
135
+
136
+
func TestLoadFromFileInvalid(t *testing.T) {
137
+
tests := []struct {
138
+
name string
139
+
content string
140
+
wantErr bool
141
+
}{
142
+
{
143
+
name: "no accounts",
144
+
content: `
145
+
interval: "15m"
146
+
`,
147
+
wantErr: true,
148
+
},
149
+
{
150
+
name: "account without handle",
151
+
content: `
152
+
accounts:
153
+
- password: "password1"
154
+
feeds:
155
+
- "https://feed1.com/rss"
156
+
`,
157
+
wantErr: true,
158
+
},
159
+
{
160
+
name: "account without password",
161
+
content: `
162
+
accounts:
163
+
- handle: "user1.bsky.social"
164
+
feeds:
165
+
- "https://feed1.com/rss"
166
+
`,
167
+
wantErr: true,
168
+
},
169
+
{
170
+
name: "account without feeds",
171
+
content: `
172
+
accounts:
173
+
- handle: "user1.bsky.social"
174
+
password: "password1"
175
+
feeds: []
176
+
`,
177
+
wantErr: true,
178
+
},
179
+
}
180
+
181
+
for _, tt := range tests {
182
+
t.Run(tt.name, func(t *testing.T) {
183
+
tmpDir := t.TempDir()
184
+
configPath := filepath.Join(tmpDir, "config.yaml")
185
+
186
+
if err := os.WriteFile(configPath, []byte(tt.content), 0644); err != nil {
187
+
t.Fatalf("Failed to write config file: %v", err)
188
+
}
189
+
190
+
_, err := LoadFromFile(configPath)
191
+
if (err != nil) != tt.wantErr {
192
+
t.Errorf("LoadFromFile() error = %v, wantErr %v", err, tt.wantErr)
193
+
}
194
+
})
195
+
}
196
+
}
197
+
198
+
func TestExpandEnvVar(t *testing.T) {
199
+
tests := []struct {
200
+
name string
201
+
input string
202
+
envKey string
203
+
envValue string
204
+
want string
205
+
}{
206
+
{
207
+
name: "expand ${VAR}",
208
+
input: "${TEST_VAR}",
209
+
envKey: "TEST_VAR",
210
+
envValue: "test-value",
211
+
want: "test-value",
212
+
},
213
+
{
214
+
name: "expand $VAR",
215
+
input: "$TEST_VAR",
216
+
envKey: "TEST_VAR",
217
+
envValue: "test-value",
218
+
want: "test-value",
219
+
},
220
+
{
221
+
name: "no expansion plain text",
222
+
input: "plain-text",
223
+
envKey: "TEST_VAR",
224
+
envValue: "test-value",
225
+
want: "plain-text",
226
+
},
227
+
{
228
+
name: "env var not set ${VAR}",
229
+
input: "${NONEXISTENT}",
230
+
envKey: "OTHER_VAR",
231
+
envValue: "test-value",
232
+
want: "${NONEXISTENT}",
233
+
},
234
+
{
235
+
name: "env var not set $VAR",
236
+
input: "$NONEXISTENT",
237
+
envKey: "OTHER_VAR",
238
+
envValue: "test-value",
239
+
want: "$NONEXISTENT",
240
+
},
241
+
}
242
+
243
+
for _, tt := range tests {
244
+
t.Run(tt.name, func(t *testing.T) {
245
+
if tt.envKey != "" {
246
+
os.Setenv(tt.envKey, tt.envValue)
247
+
defer os.Unsetenv(tt.envKey)
248
+
}
249
+
250
+
got := expandEnvVar(tt.input)
251
+
if got != tt.want {
252
+
t.Errorf("expandEnvVar(%q) = %q, want %q", tt.input, got, tt.want)
253
+
}
254
+
})
255
+
}
256
+
}
+123
-94
main.go
+123
-94
main.go
···
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
)
···
25
26
func 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())
···
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
126
-
func 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
144
-
func 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
155
-
func 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
}
···
179
item := items[i]
180
181
// Skip if already posted
182
-
if store.IsPosted(item.GUID) {
183
continue
184
}
185
···
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
···
299
300
return truncated
301
}
···
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
)
···
26
27
func 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())
···
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
118
+
type 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
127
+
func 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
165
+
func (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
176
+
func (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
}
···
200
item := items[i]
201
202
// Skip if already posted
203
+
if m.store.IsPosted(item.GUID) {
204
continue
205
}
206
···
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
···
320
321
return truncated
322
}
323
+
324
+
// sanitizeHandle removes special characters from handle for use in filenames
325
+
func sanitizeHandle(handle string) string {
326
+
// Replace dots and @ with underscores
327
+
sanitized := strings.ReplaceAll(handle, ".", "_")
328
+
sanitized = strings.ReplaceAll(sanitized, "@", "")
329
+
return sanitized
330
+
}
+73
-46
main_test.go
+73
-46
main_test.go
···
1
package main
2
3
import (
4
-
"reflect"
5
"testing"
6
)
7
8
-
func TestParseFeedURLs(t *testing.T) {
9
tests := []struct {
10
name string
11
input string
12
-
expected []string
13
}{
14
{
15
-
name: "single feed",
16
-
input: "https://example.com/feed.xml",
17
-
expected: []string{"https://example.com/feed.xml"},
18
-
},
19
-
{
20
-
name: "multiple feeds",
21
-
input: "https://example.com/feed1.xml,https://example.com/feed2.xml",
22
-
expected: []string{"https://example.com/feed1.xml", "https://example.com/feed2.xml"},
23
},
24
{
25
-
name: "multiple feeds with spaces",
26
-
input: "https://example.com/feed1.xml, https://example.com/feed2.xml, https://example.com/feed3.xml",
27
-
expected: []string{"https://example.com/feed1.xml", "https://example.com/feed2.xml", "https://example.com/feed3.xml"},
28
},
29
{
30
-
name: "feeds with extra whitespace",
31
-
input: " https://example.com/feed1.xml , https://example.com/feed2.xml ",
32
-
expected: []string{"https://example.com/feed1.xml", "https://example.com/feed2.xml"},
33
},
34
{
35
-
name: "empty string",
36
-
input: "",
37
-
expected: nil,
38
},
39
{
40
-
name: "only commas and spaces",
41
-
input: " , , , ",
42
-
expected: []string{},
43
},
44
{
45
-
name: "trailing comma",
46
-
input: "https://example.com/feed1.xml,https://example.com/feed2.xml,",
47
-
expected: []string{"https://example.com/feed1.xml", "https://example.com/feed2.xml"},
48
},
49
{
50
-
name: "leading comma",
51
-
input: ",https://example.com/feed1.xml,https://example.com/feed2.xml",
52
-
expected: []string{"https://example.com/feed1.xml", "https://example.com/feed2.xml"},
53
},
54
{
55
-
name: "mixed RSS and Atom feeds",
56
-
input: "https://blog.com/rss,https://news.com/atom.xml,https://site.com/feed",
57
-
expected: []string{"https://blog.com/rss", "https://news.com/atom.xml", "https://site.com/feed"},
58
},
59
}
60
61
for _, tt := range tests {
62
t.Run(tt.name, func(t *testing.T) {
63
-
result := parseFeedURLs(tt.input)
64
-
if !reflect.DeepEqual(result, tt.expected) {
65
-
t.Errorf("parseFeedURLs(%q) = %v, want %v", tt.input, result, tt.expected)
66
}
67
})
68
}
69
}
70
71
-
func TestParseFeedURLsCount(t *testing.T) {
72
tests := []struct {
73
-
name string
74
-
input string
75
-
expectedCount int
76
}{
77
-
{"one feed", "https://example.com/feed.xml", 1},
78
-
{"two feeds", "https://a.com/feed,https://b.com/feed", 2},
79
-
{"five feeds", "https://1.com/f,https://2.com/f,https://3.com/f,https://4.com/f,https://5.com/f", 5},
80
-
{"empty", "", 0},
81
}
82
83
for _, tt := range tests {
84
t.Run(tt.name, func(t *testing.T) {
85
-
result := parseFeedURLs(tt.input)
86
-
if len(result) != tt.expectedCount {
87
-
t.Errorf("parseFeedURLs(%q) returned %d feeds, want %d", tt.input, len(result), tt.expectedCount)
88
}
89
})
90
}
···
1
package main
2
3
import (
4
"testing"
5
)
6
7
+
func TestSanitizeHandle(t *testing.T) {
8
tests := []struct {
9
name string
10
input string
11
+
expected string
12
}{
13
{
14
+
name: "simple handle",
15
+
input: "user.bsky.social",
16
+
expected: "user_bsky_social",
17
},
18
{
19
+
name: "handle with multiple dots",
20
+
input: "my.user.name.bsky.social",
21
+
expected: "my_user_name_bsky_social",
22
},
23
{
24
+
name: "handle with @ prefix",
25
+
input: "@user.bsky.social",
26
+
expected: "user_bsky_social",
27
},
28
{
29
+
name: "handle with multiple @ symbols",
30
+
input: "@@user.bsky.social",
31
+
expected: "user_bsky_social",
32
},
33
{
34
+
name: "handle without dots",
35
+
input: "user",
36
+
expected: "user",
37
},
38
{
39
+
name: "handle with dots and @",
40
+
input: "@test.user.example.com",
41
+
expected: "test_user_example_com",
42
},
43
{
44
+
name: "empty string",
45
+
input: "",
46
+
expected: "",
47
},
48
{
49
+
name: "only special characters",
50
+
input: "@.@.",
51
+
expected: "__",
52
},
53
}
54
55
for _, tt := range tests {
56
t.Run(tt.name, func(t *testing.T) {
57
+
result := sanitizeHandle(tt.input)
58
+
if result != tt.expected {
59
+
t.Errorf("sanitizeHandle(%q) = %q, want %q", tt.input, result, tt.expected)
60
}
61
})
62
}
63
}
64
65
+
func TestTruncateText(t *testing.T) {
66
tests := []struct {
67
+
name string
68
+
text string
69
+
maxLen int
70
+
expected string
71
}{
72
+
{
73
+
name: "no truncation needed",
74
+
text: "short text",
75
+
maxLen: 20,
76
+
expected: "short text",
77
+
},
78
+
{
79
+
name: "truncate at word boundary",
80
+
text: "this is a long text that needs truncation",
81
+
maxLen: 20,
82
+
expected: "this is a long text",
83
+
},
84
+
{
85
+
name: "truncate without word boundary",
86
+
text: "verylongtextwithoutspaces",
87
+
maxLen: 10,
88
+
expected: "verylongte",
89
+
},
90
+
{
91
+
name: "exact length",
92
+
text: "exactly ten",
93
+
maxLen: 11,
94
+
expected: "exactly ten",
95
+
},
96
+
{
97
+
name: "empty string",
98
+
text: "",
99
+
maxLen: 10,
100
+
expected: "",
101
+
},
102
+
{
103
+
name: "truncate with space at end",
104
+
text: "text with space ",
105
+
maxLen: 10,
106
+
expected: "text with",
107
+
},
108
}
109
110
for _, tt := range tests {
111
t.Run(tt.name, func(t *testing.T) {
112
+
result := truncateText(tt.text, tt.maxLen)
113
+
if result != tt.expected {
114
+
t.Errorf("truncateText(%q, %d) = %q, want %q", tt.text, tt.maxLen, result, tt.expected)
115
}
116
})
117
}
+88
-65
readme.md
+88
-65
readme.md
···
2
3
Tool to automatically post on Bluesky when new items are added to RSS/Atom feeds.
4
5
## Installation
6
7
### Go Binary
···
15
```bash
16
docker run -d \
17
--name bksyrss \
18
-v $(pwd)/data:/data \
19
-e BSKY_PASSWORD="your-app-password" \
20
bksyrss \
21
-
-feed "https://example.com/feed.xml" \
22
-
-handle "your-handle.bsky.social" \
23
-
-storage /data/posted_items.txt
24
```
25
26
## Usage
27
28
-
### Basic Usage
29
30
-
```bash
31
-
./bksyrss \
32
-
-feed "https://example.com/feed.xml" \
33
-
-handle "your-handle.bsky.social" \
34
-
-password "your-app-password"
35
```
36
37
-
### Multiple Feeds
38
-
39
-
Monitor multiple feeds by separating URLs with commas:
40
41
```bash
42
-
./bksyrss \
43
-
-feed "https://blog1.com/feed.xml,https://blog2.com/rss,https://news.com/atom.xml" \
44
-
-handle "your-handle.bsky.social" \
45
-
-password "your-app-password"
46
```
47
48
-
or
49
50
```bash
51
-
export BSKY_PASSWORD="your-app-password"
52
-
./bksyrss \
53
-
-feed "https://example.com/feed.xml" \
54
-
-handle "your-handle.bsky.social"
55
```
56
57
-
### Command Line Options
58
59
-
| Flag | Description | Required | Default |
60
-
| ----------- | ------------------------------------------------- | -------- | ------------------- |
61
-
| `-feed` | RSS/Atom feed URL(s) to monitor (comma-delimited) | Yes | - |
62
-
| `-handle` | Bluesky handle (e.g., user.bsky.social) | Yes | - |
63
-
| `-password` | Bluesky password or app password | Yes\* | - |
64
-
| `-pds` | Bluesky PDS server URL | No | https://bsky.social |
65
-
| `-interval` | Poll interval for checking RSS feed | No | 15m |
66
-
| `-storage` | File to store posted item GUIDs | No | posted_items.txt |
67
-
| `-dry-run` | Don't post, just show what would be posted | No | false |
68
69
-
\*Can be provided via `BSKY_PASSWORD` environment variable instead
70
71
-
### Examples
72
73
-
#### Monitor multiple feeds
74
75
-
```bash
76
-
./bksyrss \
77
-
-feed "https://blog.com/rss,https://news.com/atom.xml,https://podcast.com/feed" \
78
-
-handle "your-handle.bsky.social"
79
-
```
80
81
-
#### Check feeds every 5 minutes
82
-
83
-
```bash
84
-
./bksyrss \
85
-
-feed "https://example.com/feed.xml" \
86
-
-handle "your-handle.bsky.social" \
87
-
-interval 5m
88
```
89
90
-
#### Test without posting (dry-run mode)
91
92
```bash
93
-
./bksyrss \
94
-
-feed "https://example.com/feed.xml" \
95
-
-handle "your-handle.bsky.social" \
96
-
-dry-run
97
```
98
99
-
#### Use custom storage file
100
-
101
-
```bash
102
-
./bksyrss \
103
-
-feed "https://example.com/feed.xml" \
104
-
-handle "your-handle.bsky.social" \
105
-
-storage /var/lib/bksyrss/posted.txt
106
-
```
107
108
## Bluesky Authentication
109
···
113
114
1. Go to Bluesky Settings → App Passwords
115
2. Create a new App Password
116
-
3. Use this password with the `-password` flag or `BSKY_PASSWORD` environment variable
117
118
### Self-hosted PDS
119
120
If you're using a self-hosted Personal Data Server:
121
122
-
```bash
123
-
./bksyrss \
124
-
-feed "https://example.com/feed.xml" \
125
-
-handle "your-handle.your-pds.com" \
126
-
-pds "https://your-pds.com"
127
```
128
129
## Post Format
···
145
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.
146
147
The storage file contains one GUID per line and is safe to manually edit if needed.
148
149
### First Run Behavior
150
···
2
3
Tool to automatically post on Bluesky when new items are added to RSS/Atom feeds.
4
5
+
Supports:
6
+
7
+
- Multiple feeds posting to a single account
8
+
- Different feeds posting to different accounts
9
+
- Flexible configuration via YAML config file
10
+
11
## Installation
12
13
### Go Binary
···
21
```bash
22
docker run -d \
23
--name bksyrss \
24
+
-v $(pwd)/config.yaml:/config.yaml \
25
-v $(pwd)/data:/data \
26
-e BSKY_PASSWORD="your-app-password" \
27
bksyrss \
28
+
-config /config.yaml
29
```
30
31
## Usage
32
33
+
### Basic Setup
34
+
35
+
bskyrss requires a YAML configuration file to run. Create a `config.yaml` file:
36
37
+
```yaml
38
+
accounts:
39
+
- handle: "your-handle.bsky.social"
40
+
password: "${BSKY_PASSWORD}"
41
+
feeds:
42
+
- "https://example.com/feed.xml"
43
```
44
45
+
Set your password as an environment variable:
46
47
```bash
48
+
export BSKY_PASSWORD="your-app-password"
49
```
50
51
+
Run bskyrss:
52
53
```bash
54
+
./bskyrss -config config.yaml
55
```
56
57
+
### Multiple Feeds to One Account
58
59
+
```yaml
60
+
accounts:
61
+
- handle: "your-handle.bsky.social"
62
+
password: "${BSKY_PASSWORD}"
63
+
feeds:
64
+
- "https://example.com/feed.xml"
65
+
- "https://blog.example.com/rss"
66
+
- "https://news.example.com/atom.xml"
67
+
```
68
69
+
### Multiple Accounts with Different Feeds
70
71
+
Map different feeds to different Bluesky accounts:
72
73
+
```yaml
74
+
accounts:
75
+
- handle: "tech-news.bsky.social"
76
+
password: "${BSKY_PASSWORD_TECH}"
77
+
feeds:
78
+
- "https://hackernews.com/rss"
79
+
- "https://techcrunch.com/feed"
80
81
+
- handle: "personal.bsky.social"
82
+
password: "${BSKY_PASSWORD_PERSONAL}"
83
+
feeds:
84
+
- "https://personal-blog.com/feed.xml"
85
86
+
interval: "15m"
87
```
88
89
+
Set environment variables:
90
91
```bash
92
+
export BSKY_PASSWORD_TECH="tech-app-password"
93
+
export BSKY_PASSWORD_PERSONAL="personal-app-password"
94
+
./bskyrss -config config.yaml
95
```
96
97
+
See [`config.example.yaml`](config.example.yaml) for a complete example with all options.
98
99
## Bluesky Authentication
100
···
104
105
1. Go to Bluesky Settings → App Passwords
106
2. Create a new App Password
107
+
3. Use this password in your config file (via environment variable)
108
+
109
+
### Environment Variables
110
+
111
+
Passwords should be provided via environment variables for security:
112
+
113
+
```yaml
114
+
accounts:
115
+
- handle: "user.bsky.social"
116
+
password: "${BSKY_PASSWORD}" # References $BSKY_PASSWORD env var
117
+
```
118
+
119
+
Supported formats:
120
+
121
+
- `${VAR_NAME}` - Standard format
122
+
- `$VAR_NAME` - Short format
123
124
### Self-hosted PDS
125
126
If you're using a self-hosted Personal Data Server:
127
128
+
```yaml
129
+
accounts:
130
+
- handle: "your-handle.your-pds.com"
131
+
password: "${BSKY_PASSWORD}"
132
+
pds: "https://your-pds.com"
133
+
feeds:
134
+
- "https://example.com/feed.xml"
135
```
136
137
## Post Format
···
153
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.
154
155
The storage file contains one GUID per line and is safe to manually edit if needed.
156
+
157
+
### Multiple Accounts
158
+
159
+
When using multiple accounts, separate storage files are automatically created for each account (e.g., `posted_items_user1_bsky_social.txt`, `posted_items_user2_bsky_social.txt`). This ensures that each account tracks its own posted items independently.
160
+
161
+
You can also specify custom storage files per account in the configuration file:
162
+
163
+
```yaml
164
+
accounts:
165
+
- handle: "user1.bsky.social"
166
+
storage: "custom_storage_user1.txt"
167
+
password: "${BSKY_PASSWORD_1}"
168
+
feeds:
169
+
- "https://feed1.com/rss"
170
+
```
171
172
### First Run Behavior
173