-30
.env.example
-30
.env.example
···
1
-
# Bluesky RSS Post Configuration Example
2
-
# Module: pkg.rbrt.fr/bskyrss
3
-
# Copy this file to .env and fill in your values
4
-
5
-
# Bluesky credentials
6
-
# Your Bluesky handle (include the full domain, e.g., user.bsky.social)
7
-
BSKY_HANDLE=your-handle.bsky.social
8
-
9
-
# Your Bluesky App Password (NOT your main account password)
10
-
# Create an App Password at: Settings → App Passwords
11
-
BSKY_PASSWORD=your-app-password
12
-
13
-
# RSS Feed URL(s) to monitor
14
-
# This is the feed(s) that will be checked for new items
15
-
# For multiple feeds, separate with commas
16
-
RSS_FEED_URL=https://example.com/feed.xml
17
-
# RSS_FEED_URL=https://blog1.com/rss,https://blog2.com/atom.xml,https://news.com/feed
18
-
19
-
# Optional: Bluesky PDS server (default: https://bsky.social)
20
-
# Only change this if you're using a self-hosted PDS
21
-
# BSKY_PDS=https://bsky.social
22
-
23
-
# Optional: Poll interval (default: 15m)
24
-
# How often to check the RSS feed for new items
25
-
# Examples: 5m, 15m, 1h, 30s
26
-
# POLL_INTERVAL=15m
27
-
28
-
# Optional: Storage file location (default: posted_items.txt)
29
-
# File where posted item GUIDs are tracked to prevent duplicates
30
-
# STORAGE_FILE=posted_items.txt
+1
-1
.gitignore
+1
-1
.gitignore
+3
-3
Dockerfile
+3
-3
Dockerfile
···
1
1
FROM golang:1.25-alpine AS builder
2
2
WORKDIR /build
3
3
COPY . .
4
-
RUN go build -o bksyrss -ldflags="-w -s" .
4
+
RUN go install -ldflags="-w -s" .
5
5
6
6
FROM alpine:latest
7
7
RUN apk --no-cache add ca-certificates
8
8
WORKDIR /app
9
-
COPY --from=builder /build/bksyrss .
9
+
COPY --from=builder /go/bin/bskyrss .
10
10
VOLUME ["/data"]
11
-
ENTRYPOINT ["./bksyrss"]
11
+
ENTRYPOINT ["./bskyrss"]
+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
+61
internal/bluesky/client.go
+61
internal/bluesky/client.go
···
4
4
"context"
5
5
"fmt"
6
6
"strings"
7
+
"sync"
7
8
"time"
8
9
9
10
"github.com/bluesky-social/indigo/api/atproto"
···
17
18
xrpcClient *xrpc.Client
18
19
handle string
19
20
did string
21
+
config Config
22
+
mu sync.Mutex // protects token refresh
20
23
}
21
24
22
25
// Config holds configuration for Bluesky client
···
56
59
xrpcClient: xrpcClient,
57
60
handle: auth.Handle,
58
61
did: auth.Did,
62
+
config: cfg,
59
63
}, nil
60
64
}
61
65
···
85
89
86
90
_, err := atproto.RepoCreateRecord(ctx, c.xrpcClient, input)
87
91
if err != nil {
92
+
// Check if token expired and retry once after refresh
93
+
if c.isExpiredTokenError(err) {
94
+
if refreshErr := c.refreshSession(ctx); refreshErr != nil {
95
+
return fmt.Errorf("failed to create post: %w (refresh failed: %v)", err, refreshErr)
96
+
}
97
+
// Retry the post after refreshing
98
+
_, err = atproto.RepoCreateRecord(ctx, c.xrpcClient, input)
99
+
if err != nil {
100
+
return fmt.Errorf("failed to create post after refresh: %w", err)
101
+
}
102
+
return nil
103
+
}
88
104
return fmt.Errorf("failed to create post: %w", err)
105
+
}
106
+
107
+
return nil
108
+
}
109
+
110
+
// isExpiredTokenError checks if the error is due to an expired token
111
+
func (c *Client) isExpiredTokenError(err error) bool {
112
+
if err == nil {
113
+
return false
114
+
}
115
+
errStr := err.Error()
116
+
return strings.Contains(errStr, "ExpiredToken") || strings.Contains(errStr, "Token has expired")
117
+
}
118
+
119
+
// refreshSession refreshes the authentication session
120
+
func (c *Client) refreshSession(ctx context.Context) error {
121
+
c.mu.Lock()
122
+
defer c.mu.Unlock()
123
+
124
+
// Check if someone else already refreshed while we were waiting
125
+
if c.xrpcClient.Auth != nil && c.xrpcClient.Auth.RefreshJwt != "" {
126
+
// Try to use the refresh token
127
+
refresh, err := atproto.ServerRefreshSession(ctx, c.xrpcClient)
128
+
if err == nil {
129
+
c.xrpcClient.Auth.AccessJwt = refresh.AccessJwt
130
+
c.xrpcClient.Auth.RefreshJwt = refresh.RefreshJwt
131
+
return nil
132
+
}
133
+
// If refresh failed, fall through to re-authentication
134
+
}
135
+
136
+
// If refresh token doesn't work, re-authenticate with password
137
+
auth, err := atproto.ServerCreateSession(ctx, c.xrpcClient, &atproto.ServerCreateSession_Input{
138
+
Identifier: c.config.Handle,
139
+
Password: c.config.Password,
140
+
})
141
+
if err != nil {
142
+
return fmt.Errorf("failed to re-authenticate: %w", err)
143
+
}
144
+
145
+
c.xrpcClient.Auth = &xrpc.AuthInfo{
146
+
AccessJwt: auth.AccessJwt,
147
+
RefreshJwt: auth.RefreshJwt,
148
+
Handle: auth.Handle,
149
+
Did: auth.Did,
89
150
}
90
151
91
152
return nil
+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
+
}
+3
-10
internal/rss/feed.go
+3
-10
internal/rss/feed.go
···
30
30
}
31
31
32
32
// FetchLatestItems fetches the latest items from an RSS feed
33
-
func (c *Checker) FetchLatestItems(ctx context.Context, feedURL string, limit int) ([]*FeedItem, error) {
33
+
func (c *Checker) FetchLatestItems(ctx context.Context, feedURL string) ([]*FeedItem, error) {
34
34
feed, err := c.parser.ParseURLWithContext(feedURL, ctx)
35
35
if err != nil {
36
36
return nil, fmt.Errorf("failed to parse RSS feed: %w", err)
···
41
41
}
42
42
43
43
// Limit the number of items to return
44
-
maxItems := len(feed.Items)
45
-
if limit > 0 && limit < maxItems {
46
-
maxItems = limit
47
-
}
48
-
49
-
items := make([]*FeedItem, 0, maxItems)
50
-
for i := 0; i < maxItems; i++ {
51
-
item := feed.Items[i]
52
-
44
+
items := make([]*FeedItem, 0, len(feed.Items))
45
+
for _, item := range feed.Items {
53
46
published := time.Now()
54
47
if item.PublishedParsed != nil {
55
48
published = *item.PublishedParsed
+5
-17
internal/rss/feed_test.go
+5
-17
internal/rss/feed_test.go
···
53
53
54
54
// Test fetching all items
55
55
t.Run("FetchAllItems", func(t *testing.T) {
56
-
items, err := checker.FetchLatestItems(context.Background(), server.URL, 0)
56
+
items, err := checker.FetchLatestItems(context.Background(), server.URL)
57
57
if err != nil {
58
58
t.Fatalf("Failed to fetch items: %v", err)
59
59
}
···
74
74
}
75
75
})
76
76
77
-
// Test limiting items
78
-
t.Run("FetchLimitedItems", func(t *testing.T) {
79
-
items, err := checker.FetchLatestItems(context.Background(), server.URL, 2)
80
-
if err != nil {
81
-
t.Fatalf("Failed to fetch items: %v", err)
82
-
}
83
-
84
-
if len(items) != 2 {
85
-
t.Errorf("Expected 2 items, got %d", len(items))
86
-
}
87
-
})
88
-
89
77
// Test with context timeout
90
78
t.Run("ContextTimeout", func(t *testing.T) {
91
79
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
···
93
81
94
82
time.Sleep(2 * time.Millisecond) // Ensure context is expired
95
83
96
-
_, err := checker.FetchLatestItems(ctx, server.URL, 0)
84
+
_, err := checker.FetchLatestItems(ctx, server.URL)
97
85
if err == nil {
98
86
t.Error("Expected error with expired context, got nil")
99
87
}
···
103
91
func TestFetchLatestItems_InvalidURL(t *testing.T) {
104
92
checker := NewChecker()
105
93
106
-
_, err := checker.FetchLatestItems(context.Background(), "not-a-valid-url", 0)
94
+
_, err := checker.FetchLatestItems(context.Background(), "not-a-valid-url")
107
95
if err == nil {
108
96
t.Error("Expected error with invalid URL, got nil")
109
97
}
···
127
115
defer server.Close()
128
116
129
117
checker := NewChecker()
130
-
items, err := checker.FetchLatestItems(context.Background(), server.URL, 0)
118
+
items, err := checker.FetchLatestItems(context.Background(), server.URL)
131
119
132
120
if err != nil {
133
121
t.Fatalf("Expected no error with empty feed, got: %v", err)
···
163
151
defer server.Close()
164
152
165
153
checker := NewChecker()
166
-
items, err := checker.FetchLatestItems(context.Background(), server.URL, 0)
154
+
items, err := checker.FetchLatestItems(context.Background(), server.URL)
167
155
168
156
if err != nil {
169
157
t.Fatalf("Failed to fetch items: %v", err)
+164
-122
main.go
+164
-122
main.go
···
5
5
"flag"
6
6
"fmt"
7
7
"log"
8
+
"net/url"
8
9
"os"
9
10
"os/signal"
10
11
"strings"
···
12
13
"time"
13
14
14
15
"pkg.rbrt.fr/bskyrss/internal/bluesky"
16
+
"pkg.rbrt.fr/bskyrss/internal/config"
15
17
"pkg.rbrt.fr/bskyrss/internal/rss"
16
18
"pkg.rbrt.fr/bskyrss/internal/storage"
17
19
)
···
24
26
25
27
func main() {
26
28
// 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")
29
+
configFile := flag.String("config", "config.yaml", "Path to configuration file (YAML)")
33
30
dryRun := flag.Bool("dry-run", false, "Don't actually post to Bluesky, just show what would be posted")
34
31
flag.Parse()
35
32
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
-
}
33
+
// Load configuration from file
34
+
if *configFile == "" {
35
+
log.Fatal("Error: -config flag is required")
58
36
}
59
37
60
-
store, err := storage.New(*storageFile)
38
+
cfg, err := config.LoadFromFile(*configFile)
61
39
if err != nil {
62
-
log.Fatalf("Failed to initialize storage: %v", err)
40
+
log.Fatalf("Failed to load config file: %v", err)
63
41
}
42
+
log.Printf("Loaded configuration with %d account(s)", len(cfg.Accounts))
64
43
65
-
// Determine if this is the first run (no items in storage)
66
-
isFirstRun := store.Count() == 0
67
-
if isFirstRun {
68
-
log.Println("First run detected - will mark existing items as seen without posting")
69
-
} else {
70
-
log.Printf("Storage initialized with %d previously posted items", store.Count())
44
+
if *dryRun {
45
+
log.Println("Running in DRY-RUN mode - no posts will be made")
71
46
}
72
47
73
-
rssChecker := rss.NewChecker()
74
-
75
-
// Initialize Bluesky client (unless dry-run mode)
76
-
var bskyClient *bluesky.Client
77
-
if !*dryRun {
78
-
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
79
-
defer cancel()
80
-
81
-
bskyClient, err = bluesky.NewClient(ctx, bluesky.Config{
82
-
Handle: *handle,
83
-
Password: bskyPassword,
84
-
PDS: *pds,
85
-
})
86
-
if err != nil {
87
-
log.Fatalf("Failed to initialize Bluesky client: %v", err)
88
-
}
89
-
log.Printf("Authenticated as @%s", bskyClient.GetHandle())
90
-
} else {
91
-
log.Println("Running in DRY-RUN mode - no posts will be made")
48
+
// Count total feeds across all accounts
49
+
totalFeeds := 0
50
+
for _, account := range cfg.Accounts {
51
+
totalFeeds += len(account.Feeds)
92
52
}
53
+
log.Printf("Monitoring %d feed(s) across %d account(s)", totalFeeds, len(cfg.Accounts))
93
54
94
55
// Setup signal handling for graceful shutdown
95
56
ctx, cancel := context.WithCancel(context.Background())
···
104
65
cancel()
105
66
}()
106
67
107
-
// Main loop
108
-
log.Printf("Poll interval: %s", *pollInterval)
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
+
}
109
81
110
-
ticker := time.NewTicker(*pollInterval)
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)
111
93
defer ticker.Stop()
112
94
113
95
// Check immediately on startup
114
-
if err := checkAndPostFeeds(ctx, rssChecker, bskyClient, store, feeds, *dryRun, isFirstRun); err != nil {
115
-
log.Printf("Error during initial check: %v", err)
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
+
}
116
100
}
117
101
118
-
// Continue checking on interval (not first run anymore after first check)
102
+
// Continue checking on interval
119
103
for {
120
104
select {
121
105
case <-ctx.Done():
122
106
return
123
107
case <-ticker.C:
124
-
if err := checkAndPostFeeds(ctx, rssChecker, bskyClient, store, feeds, *dryRun, false); err != nil {
125
-
log.Printf("Error during check: %v", err)
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
+
}
126
112
}
127
113
}
128
114
}
129
115
}
130
116
131
-
// parseFeedURLs splits comma-delimited feed URLs and trims whitespace
132
-
func parseFeedURLs(feedString string) []string {
133
-
if feedString == "" {
134
-
return nil
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)
135
131
}
136
132
137
-
parts := strings.Split(feedString, ",")
138
-
feeds := make([]string, 0, len(parts))
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
139
140
-
for _, part := range parts {
141
-
trimmed := strings.TrimSpace(part)
142
-
if trimmed != "" {
143
-
feeds = append(feeds, trimmed)
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)
144
152
}
145
153
}
146
154
147
-
return feeds
155
+
return &AccountManager{
156
+
account: account,
157
+
bskyClient: bskyClient,
158
+
rssChecker: rssChecker,
159
+
store: store,
160
+
dryRun: dryRun,
161
+
}, nil
148
162
}
149
163
150
-
func checkAndPostFeeds(ctx context.Context, rssChecker *rss.Checker, bskyClient *bluesky.Client, store *storage.Storage, feedURLs []string, dryRun bool, isFirstRun bool) error {
151
-
for _, feedURL := range feedURLs {
152
-
if err := checkAndPost(ctx, rssChecker, bskyClient, store, feedURL, dryRun, isFirstRun); err != nil {
153
-
log.Printf("Error checking feed %s: %v", feedURL, err)
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)
154
169
// Continue with other feeds even if one fails
155
170
}
156
171
}
157
-
158
172
return nil
159
173
}
160
174
161
-
func checkAndPost(
162
-
ctx context.Context,
163
-
rssChecker *rss.Checker,
164
-
bskyClient *bluesky.Client,
165
-
store *storage.Storage,
166
-
feedURL string,
167
-
dryRun bool,
168
-
isFirstRun bool,
169
-
) error {
170
-
log.Printf("Checking RSS feed: %s", feedURL)
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)
171
178
172
-
limit := 20
173
-
items, err := rssChecker.FetchLatestItems(ctx, feedURL, limit)
179
+
items, err := m.rssChecker.FetchLatestItems(ctx, feedURL)
174
180
if err != nil {
175
181
return fmt.Errorf("failed to fetch RSS items: %w", err)
176
182
}
177
183
178
-
log.Printf("Found %d items in feed", len(items))
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
+
}
179
194
180
195
// Process items in reverse order (oldest first)
181
196
newItemCount := 0
···
185
200
item := items[i]
186
201
187
202
// Skip if already posted
188
-
if store.IsPosted(item.GUID) {
203
+
if m.store.IsPosted(item.GUID) {
189
204
continue
190
205
}
191
206
192
207
newItemCount++
193
208
194
-
// On first run, just mark items as seen without posting
195
-
if isFirstRun {
196
-
if err := store.MarkPosted(item.GUID); err != nil {
197
-
log.Printf("Failed to mark item as seen: %v", err)
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)
198
213
}
199
214
continue
200
215
}
201
216
202
-
log.Printf("New item found: %s", item.Title)
217
+
log.Printf("[@%s] New item found: %s", m.account.Handle, item.Title)
203
218
204
219
// Create post text
205
220
postText := formatPost(item)
206
221
207
-
if dryRun {
208
-
log.Printf("[DRY-RUN] Would post:\n%s\n", postText)
222
+
if m.dryRun {
223
+
log.Printf("[@%s] [DRY-RUN] Would post:\n%s\n", m.account.Handle, postText)
209
224
postedCount++
210
225
} else {
211
226
// Post to Bluesky
212
227
postCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
213
-
err := bskyClient.Post(postCtx, postText)
228
+
err := m.bskyClient.Post(postCtx, postText)
214
229
cancel()
215
230
216
231
if err != nil {
217
-
log.Printf("Failed to post item '%s': %v", item.Title, err)
232
+
log.Printf("[@%s] Failed to post item '%s': %v", m.account.Handle, item.Title, err)
218
233
continue
219
234
}
220
235
221
-
log.Printf("Successfully posted: %s", item.Title)
236
+
log.Printf("[@%s] Successfully posted: %s", m.account.Handle, item.Title)
222
237
postedCount++
223
238
}
224
239
225
240
// Mark as posted
226
-
if err := store.MarkPosted(item.GUID); err != nil {
227
-
log.Printf("Failed to mark item as posted: %v", err)
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)
228
243
}
229
244
230
-
// Rate limiting - wait a bit between posts to avoid overwhelming Bluesky
231
-
if postedCount > 0 && !dryRun && !isFirstRun {
245
+
// Rate limiting ourselves to not get rate limited.
246
+
if postedCount > 0 && !m.dryRun {
232
247
time.Sleep(2 * time.Second)
233
248
}
234
249
}
235
250
236
-
if isFirstRun {
251
+
if !hasSeenFeedBefore {
237
252
if newItemCount > 0 {
238
-
log.Printf("Marked %d items as seen from feed %s", newItemCount, feedURL)
253
+
log.Printf("[@%s] New feed detected: marked %d items as seen from %s (not posted)", m.account.Handle, newItemCount, feedURL)
239
254
}
240
255
} else {
241
256
if newItemCount == 0 {
242
-
log.Printf("No new items in feed %s", feedURL)
257
+
log.Printf("[@%s] No new items in feed %s", m.account.Handle, feedURL)
243
258
} else {
244
-
log.Printf("Processed %d new items from feed %s (%d posted)", newItemCount, feedURL, postedCount)
259
+
log.Printf("[@%s] Processed %d new items from feed %s (%d posted)", m.account.Handle, newItemCount, feedURL, postedCount)
245
260
}
246
261
}
247
262
···
249
264
}
250
265
251
266
func formatPost(item *rss.FeedItem) string {
252
-
// Start with title
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
253
281
text := item.Title
254
-
255
-
// Add link if available
256
-
if item.Link != "" {
257
-
text += "\n\n" + item.Link
282
+
if len(urls) > 0 {
283
+
text += "\n" + strings.Join(urls, "\n")
258
284
}
259
285
260
286
// Truncate if too long
261
287
if len(text) > maxPostLength {
262
-
// Try to truncate title intelligently
263
-
maxTitleLen := maxPostLength - len(item.Link) - 5 // 5 for "\n\n" and "..."
264
-
if maxTitleLen > 0 {
265
-
text = truncateText(item.Title, maxTitleLen) + "...\n\n" + item.Link
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
266
296
} else {
267
-
// If even with minimal title it's too long, just use the link
268
-
text = item.Link
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
+
}
269
303
}
270
304
}
271
305
···
286
320
287
321
return truncated
288
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
1
package main
2
2
3
3
import (
4
-
"reflect"
5
4
"testing"
6
5
)
7
6
8
-
func TestParseFeedURLs(t *testing.T) {
7
+
func TestSanitizeHandle(t *testing.T) {
9
8
tests := []struct {
10
9
name string
11
10
input string
12
-
expected []string
11
+
expected string
13
12
}{
14
13
{
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"},
14
+
name: "simple handle",
15
+
input: "user.bsky.social",
16
+
expected: "user_bsky_social",
23
17
},
24
18
{
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"},
19
+
name: "handle with multiple dots",
20
+
input: "my.user.name.bsky.social",
21
+
expected: "my_user_name_bsky_social",
28
22
},
29
23
{
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"},
24
+
name: "handle with @ prefix",
25
+
input: "@user.bsky.social",
26
+
expected: "user_bsky_social",
33
27
},
34
28
{
35
-
name: "empty string",
36
-
input: "",
37
-
expected: nil,
29
+
name: "handle with multiple @ symbols",
30
+
input: "@@user.bsky.social",
31
+
expected: "user_bsky_social",
38
32
},
39
33
{
40
-
name: "only commas and spaces",
41
-
input: " , , , ",
42
-
expected: []string{},
34
+
name: "handle without dots",
35
+
input: "user",
36
+
expected: "user",
43
37
},
44
38
{
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"},
39
+
name: "handle with dots and @",
40
+
input: "@test.user.example.com",
41
+
expected: "test_user_example_com",
48
42
},
49
43
{
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"},
44
+
name: "empty string",
45
+
input: "",
46
+
expected: "",
53
47
},
54
48
{
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"},
49
+
name: "only special characters",
50
+
input: "@.@.",
51
+
expected: "__",
58
52
},
59
53
}
60
54
61
55
for _, tt := range tests {
62
56
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)
57
+
result := sanitizeHandle(tt.input)
58
+
if result != tt.expected {
59
+
t.Errorf("sanitizeHandle(%q) = %q, want %q", tt.input, result, tt.expected)
66
60
}
67
61
})
68
62
}
69
63
}
70
64
71
-
func TestParseFeedURLsCount(t *testing.T) {
65
+
func TestTruncateText(t *testing.T) {
72
66
tests := []struct {
73
-
name string
74
-
input string
75
-
expectedCount int
67
+
name string
68
+
text string
69
+
maxLen int
70
+
expected string
76
71
}{
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},
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
+
},
81
108
}
82
109
83
110
for _, tt := range tests {
84
111
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)
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)
88
115
}
89
116
})
90
117
}
+88
-65
readme.md
+88
-65
readme.md
···
2
2
3
3
Tool to automatically post on Bluesky when new items are added to RSS/Atom feeds.
4
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
+
5
11
## Installation
6
12
7
13
### Go Binary
···
15
21
```bash
16
22
docker run -d \
17
23
--name bksyrss \
24
+
-v $(pwd)/config.yaml:/config.yaml \
18
25
-v $(pwd)/data:/data \
19
26
-e BSKY_PASSWORD="your-app-password" \
20
27
bksyrss \
21
-
-feed "https://example.com/feed.xml" \
22
-
-handle "your-handle.bsky.social" \
23
-
-storage /data/posted_items.txt
28
+
-config /config.yaml
24
29
```
25
30
26
31
## Usage
27
32
28
-
### Basic Usage
33
+
### Basic Setup
34
+
35
+
bskyrss requires a YAML configuration file to run. Create a `config.yaml` file:
29
36
30
-
```bash
31
-
./bksyrss \
32
-
-feed "https://example.com/feed.xml" \
33
-
-handle "your-handle.bsky.social" \
34
-
-password "your-app-password"
37
+
```yaml
38
+
accounts:
39
+
- handle: "your-handle.bsky.social"
40
+
password: "${BSKY_PASSWORD}"
41
+
feeds:
42
+
- "https://example.com/feed.xml"
35
43
```
36
44
37
-
### Multiple Feeds
38
-
39
-
Monitor multiple feeds by separating URLs with commas:
45
+
Set your password as an environment variable:
40
46
41
47
```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"
48
+
export BSKY_PASSWORD="your-app-password"
46
49
```
47
50
48
-
or
51
+
Run bskyrss:
49
52
50
53
```bash
51
-
export BSKY_PASSWORD="your-app-password"
52
-
./bksyrss \
53
-
-feed "https://example.com/feed.xml" \
54
-
-handle "your-handle.bsky.social"
54
+
./bskyrss -config config.yaml
55
55
```
56
56
57
-
### Command Line Options
57
+
### Multiple Feeds to One Account
58
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 |
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
68
69
-
\*Can be provided via `BSKY_PASSWORD` environment variable instead
69
+
### Multiple Accounts with Different Feeds
70
70
71
-
### Examples
71
+
Map different feeds to different Bluesky accounts:
72
72
73
-
#### Monitor multiple feeds
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"
74
80
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
-
```
81
+
- handle: "personal.bsky.social"
82
+
password: "${BSKY_PASSWORD_PERSONAL}"
83
+
feeds:
84
+
- "https://personal-blog.com/feed.xml"
80
85
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
86
+
interval: "15m"
88
87
```
89
88
90
-
#### Test without posting (dry-run mode)
89
+
Set environment variables:
91
90
92
91
```bash
93
-
./bksyrss \
94
-
-feed "https://example.com/feed.xml" \
95
-
-handle "your-handle.bsky.social" \
96
-
-dry-run
92
+
export BSKY_PASSWORD_TECH="tech-app-password"
93
+
export BSKY_PASSWORD_PERSONAL="personal-app-password"
94
+
./bskyrss -config config.yaml
97
95
```
98
96
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
-
```
97
+
See [`config.example.yaml`](config.example.yaml) for a complete example with all options.
107
98
108
99
## Bluesky Authentication
109
100
···
113
104
114
105
1. Go to Bluesky Settings → App Passwords
115
106
2. Create a new App Password
116
-
3. Use this password with the `-password` flag or `BSKY_PASSWORD` environment variable
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
117
123
118
124
### Self-hosted PDS
119
125
120
126
If you're using a self-hosted Personal Data Server:
121
127
122
-
```bash
123
-
./bksyrss \
124
-
-feed "https://example.com/feed.xml" \
125
-
-handle "your-handle.your-pds.com" \
126
-
-pds "https://your-pds.com"
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"
127
135
```
128
136
129
137
## Post Format
···
145
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.
146
154
147
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
+
```
148
171
149
172
### First Run Behavior
150
173