···11+# Git
22+.git/
33+.gitignore
44+55+# Configuration (will be mounted)
66+config.yaml
77+88+# State data (will be mounted)
99+data/
1010+1111+# Documentation
1212+*.md
1313+LICENSE
1414+1515+# IDE
1616+.idea/
1717+.vscode/
1818+1919+# OS
2020+.DS_Store
2121+Thumbs.db
2222+2323+# Binaries
2424+brooke-spin
2525+*.exe
2626+2727+# Logs
2828+*.log
2929+3030+# Docker
3131+Dockerfile
3232+docker-compose.yml
3333+.dockerignore
+41
.gitignore
···11+# Binaries
22+/brooke-spin
33+*.exe
44+*.dll
55+*.so
66+*.dylib
77+88+# Test binary, built with `go test -c`
99+*.test
1010+1111+# Output of the go coverage tool
1212+*.out
1313+1414+# Go workspace file
1515+go.work
1616+1717+# Configuration (contains secrets)
1818+config.yaml
1919+2020+# State data
2121+data/
2222+*.db
2323+*.db-shm
2424+*.db-wal
2525+2626+# Dependency directories
2727+vendor/
2828+2929+# IDE
3030+.idea/
3131+.vscode/
3232+*.swp
3333+*.swo
3434+*~
3535+3636+# OS
3737+.DS_Store
3838+Thumbs.db
3939+4040+# Logs
4141+*.log
···11+.PHONY: help build run clean docker-build docker-up docker-down docker-logs test
22+33+# Default target
44+help:
55+ @echo "Available targets:"
66+ @echo " build - Build the binary locally"
77+ @echo " run - Run the service locally"
88+ @echo " clean - Remove build artifacts"
99+ @echo " docker-build - Build Docker image"
1010+ @echo " docker-up - Start Docker container"
1111+ @echo " docker-down - Stop Docker container"
1212+ @echo " docker-logs - View Docker container logs"
1313+ @echo " test - Run tests"
1414+1515+# Build the binary
1616+build:
1717+ go build -o brooke-spin ./cmd/brooke-spin
1818+1919+# Run the service locally
2020+run: build
2121+ ./brooke-spin -config config.yaml
2222+2323+# Clean build artifacts
2424+clean:
2525+ rm -f brooke-spin
2626+ go clean
2727+2828+# Build Docker image
2929+docker-build:
3030+ docker build -t brooke-spin:latest .
3131+3232+# Build for Raspberry Pi (ARM64)
3333+docker-build-arm:
3434+ docker buildx build --platform linux/arm64 -t brooke-spin:latest .
3535+3636+# Start Docker container
3737+docker-up:
3838+ docker-compose up -d
3939+4040+# Stop Docker container
4141+docker-down:
4242+ docker-compose down
4343+4444+# View Docker logs
4545+docker-logs:
4646+ docker-compose logs -f
4747+4848+# Run tests
4949+test:
5050+ go test -v ./...
5151+5252+# Install dependencies
5353+deps:
5454+ go mod download
5555+ go mod tidy
+362
README.md
···11+# brooke-spin
22+33+A background service that automatically rotates your Bluesky profile picture by a configurable number of degrees each time you receive a notification.
44+55+## Features
66+77+- **Automated Profile Picture Rotation**: Rotates your avatar every time you get likes, reposts, replies, follows, mentions, or quotes
88+- **Configurable Rotation**: Set your preferred rotation angle (default: 2 degrees)
99+- **Selective Notifications**: Choose which notification types trigger rotation
1010+- **State Persistence**: Tracks processed notifications to avoid duplicates
1111+- **Docker Support**: Runs as a lightweight container on Raspberry Pi (ARM64/ARMv7)
1212+- **Graceful Shutdown**: Handles SIGTERM/SIGINT properly
1313+- **Secure**: App password can be set via environment variable
1414+1515+## Prerequisites
1616+1717+- A Bluesky account
1818+- An app password from Bluesky Settings > App Passwords
1919+- Docker and Docker Compose (for containerized deployment)
2020+- OR Go 1.22+ (for local development)
2121+2222+## Quick Start
2323+2424+### 1. Clone the Repository
2525+2626+```bash
2727+git clone <repository-url>
2828+cd brooke-spin
2929+```
3030+3131+### 2. Create Configuration
3232+3333+Copy the example configuration and edit it:
3434+3535+```bash
3636+cp config.example.yaml config.yaml
3737+nano config.yaml
3838+```
3939+4040+Update the following fields:
4141+- `bluesky.handle`: Your Bluesky handle (e.g., username.bsky.social)
4242+- `bluesky.app_password`: Your app password (or set via environment variable)
4343+- `rotation.degrees`: Rotation angle per notification (default: 2.0)
4444+4545+### 3. Deploy with Docker
4646+4747+```bash
4848+# Build and start the service
4949+docker-compose up -d
5050+5151+# View logs
5252+docker-compose logs -f
5353+5454+# Stop the service
5555+docker-compose down
5656+```
5757+5858+### 4. Monitor
5959+6060+Watch the logs to see it working:
6161+6262+```bash
6363+docker-compose logs -f brooke-spin
6464+```
6565+6666+You should see messages like:
6767+```
6868+Starting brooke-spin service...
6969+Authenticating with Bluesky...
7070+Authentication successful!
7171+Polling for notifications every 30s
7272+Found 2 new relevant notification(s)
7373+Downloading current avatar...
7474+Rotating avatar by 2.00 degrees...
7575+Uploading rotated avatar...
7676+Updating profile...
7777+✓ Profile picture rotated successfully! (Total rotation: 42.00°)
7878+```
7979+8080+## Configuration
8181+8282+### Configuration File (`config.yaml`)
8383+8484+```yaml
8585+bluesky:
8686+ handle: "yourhandle.bsky.social"
8787+ app_password: "xxxx-xxxx-xxxx-xxxx"
8888+8989+rotation:
9090+ degrees: 2.0 # Rotation per notification
9191+9292+polling:
9393+ interval: "30s" # How often to check for notifications
9494+9595+notifications:
9696+ types:
9797+ like: true
9898+ repost: true
9999+ reply: true
100100+ follow: true
101101+ mention: true
102102+ quote: true
103103+104104+state:
105105+ storage: "sqlite" # or "json"
106106+ path: "./data/state.db"
107107+```
108108+109109+### Environment Variables
110110+111111+You can override configuration values with environment variables:
112112+113113+- `BLUESKY_APP_PASSWORD`: Override the app password (recommended for Docker)
114114+115115+Example:
116116+```bash
117117+export BLUESKY_APP_PASSWORD="your-app-password"
118118+docker-compose up -d
119119+```
120120+121121+Or in `docker-compose.yml`:
122122+```yaml
123123+environment:
124124+ - BLUESKY_APP_PASSWORD=${BLUESKY_APP_PASSWORD}
125125+```
126126+127127+## Advanced Usage
128128+129129+### Running Locally (Without Docker)
130130+131131+```bash
132132+# Install dependencies
133133+go mod download
134134+135135+# Build
136136+go build -o brooke-spin ./cmd/brooke-spin
137137+138138+# Run
139139+./brooke-spin -config config.yaml
140140+```
141141+142142+### Building for Raspberry Pi
143143+144144+```bash
145145+# Using Docker buildx for ARM64
146146+docker buildx build --platform linux/arm64 -t brooke-spin:latest .
147147+148148+# Or build natively on Raspberry Pi
149149+go build -o brooke-spin ./cmd/brooke-spin
150150+```
151151+152152+### Custom Polling Interval
153153+154154+Adjust how often the service checks for new notifications:
155155+156156+```yaml
157157+polling:
158158+ interval: "1m" # Check every minute
159159+ # interval: "30s" # Check every 30 seconds (default)
160160+ # interval: "5m" # Check every 5 minutes
161161+```
162162+163163+### Selective Notification Types
164164+165165+Only rotate on specific interactions:
166166+167167+```yaml
168168+notifications:
169169+ types:
170170+ like: true # Rotate on likes
171171+ repost: false # Ignore reposts
172172+ reply: true # Rotate on replies
173173+ follow: true # Rotate on follows
174174+ mention: false # Ignore mentions
175175+ quote: true # Rotate on quotes
176176+```
177177+178178+### Change Rotation Direction
179179+180180+Use negative degrees to rotate counter-clockwise:
181181+182182+```yaml
183183+rotation:
184184+ degrees: -2.0 # Rotate 2 degrees counter-clockwise
185185+```
186186+187187+### State Storage Options
188188+189189+Choose between SQLite (default) or JSON:
190190+191191+```yaml
192192+state:
193193+ storage: "sqlite" # Recommended: atomic writes, queryable
194194+ path: "./data/state.db"
195195+196196+ # OR use JSON (simpler, but less robust)
197197+ # storage: "json"
198198+ # path: "./data/state.json"
199199+```
200200+201201+## Deployment on Raspberry Pi
202202+203203+### 1. Copy Files to Pi
204204+205205+```bash
206206+# From your local machine
207207+scp -r brooke-spin/ pi@raspberrypi.local:~/
208208+```
209209+210210+### 2. SSH to Pi and Deploy
211211+212212+```bash
213213+ssh pi@raspberrypi.local
214214+cd ~/brooke-spin
215215+216216+# Edit configuration
217217+nano config.yaml
218218+219219+# Start the service
220220+docker-compose up -d
221221+222222+# Check logs
223223+docker-compose logs -f
224224+```
225225+226226+### 3. Auto-Start on Boot
227227+228228+The service will automatically restart with `restart: unless-stopped` in `docker-compose.yml`.
229229+230230+To ensure Docker Compose starts on boot:
231231+232232+```bash
233233+# Enable Docker service
234234+sudo systemctl enable docker
235235+236236+# Add to crontab
237237+crontab -e
238238+239239+# Add this line:
240240+@reboot cd /home/pi/brooke-spin && docker-compose up -d
241241+```
242242+243243+## Troubleshooting
244244+245245+### Authentication Failed
246246+247247+- Double-check your handle and app password
248248+- Make sure you're using an app password, not your main account password
249249+- Generate a new app password from Bluesky Settings > App Passwords
250250+251251+### Profile Picture Not Rotating
252252+253253+- Check logs: `docker-compose logs -f`
254254+- Verify you're receiving notifications (test by liking one of your own posts from another account)
255255+- Ensure notification types are enabled in config
256256+- Check that your profile has an avatar set
257257+258258+### Container Won't Start
259259+260260+```bash
261261+# Check container status
262262+docker-compose ps
263263+264264+# View full logs
265265+docker-compose logs
266266+267267+# Rebuild the container
268268+docker-compose down
269269+docker-compose build --no-cache
270270+docker-compose up -d
271271+```
272272+273273+### State Not Persisting
274274+275275+- Ensure the `./data` directory exists and is writable
276276+- Check volume mounts in `docker-compose.yml`
277277+- Verify the state path in `config.yaml` matches the mounted volume
278278+279279+## Development
280280+281281+### Project Structure
282282+283283+```
284284+brooke-spin/
285285+├── cmd/
286286+│ └── brooke-spin/
287287+│ └── main.go # Entry point
288288+├── internal/
289289+│ ├── config/
290290+│ │ └── config.go # Configuration loading
291291+│ ├── client/
292292+│ │ └── bluesky.go # Bluesky API client
293293+│ ├── image/
294294+│ │ └── processor.go # Image rotation
295295+│ └── state/
296296+│ └── manager.go # State persistence
297297+├── config.example.yaml # Example configuration
298298+├── docker-compose.yml # Docker Compose config
299299+├── Dockerfile # Docker image definition
300300+├── Makefile # Build helpers
301301+└── README.md # This file
302302+```
303303+304304+### Running Tests
305305+306306+```bash
307307+make test
308308+```
309309+310310+### Building
311311+312312+```bash
313313+# Local build
314314+make build
315315+316316+# Docker build
317317+make docker-build
318318+319319+# Docker build for ARM (Raspberry Pi)
320320+make docker-build-arm
321321+```
322322+323323+## How It Works
324324+325325+1. **Polling**: Every N seconds (configurable), the service polls the Bluesky notifications API
326326+2. **Filtering**: Unread notifications are filtered by configured types (like, repost, etc.)
327327+3. **Detection**: If new relevant notifications are found, the rotation process begins
328328+4. **Download**: Current profile picture is downloaded from Bluesky
329329+5. **Rotation**: Image is rotated by configured degrees using Go image libraries
330330+6. **Upload**: Rotated image is uploaded as a new blob
331331+7. **Update**: Profile is updated with the new avatar blob reference
332332+8. **State**: Notification cursor and cumulative rotation are saved to disk
333333+334334+## Security
335335+336336+- **Never commit `config.yaml`**: It's in `.gitignore` by default
337337+- **Use environment variables**: Set `BLUESKY_APP_PASSWORD` instead of storing in config
338338+- **App passwords**: Always use app passwords, never your main account password
339339+- **Non-root container**: Docker image runs as non-root user for security
340340+341341+## License
342342+343343+MIT License - See LICENSE file for details
344344+345345+## Contributing
346346+347347+Contributions welcome! Please open an issue or pull request.
348348+349349+## Credits
350350+351351+Built with:
352352+- [Go](https://golang.org/)
353353+- [disintegration/imaging](https://github.com/disintegration/imaging) for image processing
354354+- [mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) for state persistence
355355+- [gopkg.in/yaml.v3](https://gopkg.in/yaml.v3) for configuration
356356+357357+## Support
358358+359359+Having issues? Please open an issue on GitHub with:
360360+- Your configuration (without passwords!)
361361+- Log output
362362+- Expected vs actual behavior
+185
cmd/brooke-spin/main.go
···11+package main
22+33+import (
44+ "context"
55+ "flag"
66+ "fmt"
77+ "log"
88+ "math"
99+ "os"
1010+ "os/signal"
1111+ "syscall"
1212+ "time"
1313+1414+ "github.com/brooke/brooke-spin/internal/client"
1515+ "github.com/brooke/brooke-spin/internal/config"
1616+ "github.com/brooke/brooke-spin/internal/image"
1717+ "github.com/brooke/brooke-spin/internal/state"
1818+)
1919+2020+func main() {
2121+ configPath := flag.String("config", "config.yaml", "Path to configuration file")
2222+ flag.Parse()
2323+2424+ // Load configuration
2525+ cfg, err := config.Load(*configPath)
2626+ if err != nil {
2727+ log.Fatalf("Failed to load configuration: %v", err)
2828+ }
2929+3030+ log.Println("Starting brooke-spin service...")
3131+3232+ // Initialize components
3333+ blueskyClient := client.NewClient()
3434+ imageProcessor := image.NewProcessor()
3535+ stateManager, err := state.NewManager(cfg.State.Storage, cfg.State.Path)
3636+ if err != nil {
3737+ log.Fatalf("Failed to initialize state manager: %v", err)
3838+ }
3939+ defer stateManager.Close()
4040+4141+ // Authenticate with Bluesky
4242+ log.Println("Authenticating with Bluesky...")
4343+ if err := blueskyClient.Authenticate(cfg.Bluesky.Handle, cfg.Bluesky.AppPassword); err != nil {
4444+ log.Fatalf("Authentication failed: %v", err)
4545+ }
4646+ log.Println("Authentication successful!")
4747+4848+ // Load state
4949+ currentState, err := stateManager.Load()
5050+ if err != nil {
5151+ log.Fatalf("Failed to load state: %v", err)
5252+ }
5353+5454+ if currentState.LastNotificationCursor != "" {
5555+ log.Printf("Resuming from last cursor (cumulative rotation: %.2f°)", currentState.CumulativeRotation)
5656+ } else {
5757+ log.Println("Starting fresh - no previous state found")
5858+ }
5959+6060+ // Setup graceful shutdown
6161+ ctx, cancel := context.WithCancel(context.Background())
6262+ defer cancel()
6363+6464+ sigChan := make(chan os.Signal, 1)
6565+ signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
6666+6767+ go func() {
6868+ <-sigChan
6969+ log.Println("\nReceived shutdown signal, cleaning up...")
7070+ cancel()
7171+ }()
7272+7373+ // Start polling loop
7474+ ticker := time.NewTicker(cfg.GetPollingInterval())
7575+ defer ticker.Stop()
7676+7777+ // Run initial check immediately
7878+ if err := checkAndRotate(ctx, cfg, blueskyClient, imageProcessor, stateManager, currentState); err != nil {
7979+ log.Printf("Error during initial check: %v", err)
8080+ }
8181+8282+ log.Printf("Polling for notifications every %s", cfg.GetPollingInterval())
8383+8484+ for {
8585+ select {
8686+ case <-ctx.Done():
8787+ log.Println("Shutting down gracefully...")
8888+ return
8989+ case <-ticker.C:
9090+ if err := checkAndRotate(ctx, cfg, blueskyClient, imageProcessor, stateManager, currentState); err != nil {
9191+ log.Printf("Error during polling: %v", err)
9292+ // Continue polling despite errors
9393+ }
9494+ }
9595+ }
9696+}
9797+9898+func checkAndRotate(
9999+ ctx context.Context,
100100+ cfg *config.Config,
101101+ blueskyClient *client.Client,
102102+ imageProcessor *image.Processor,
103103+ stateManager *state.Manager,
104104+ currentState *state.State,
105105+) error {
106106+ // Fetch notifications
107107+ notifications, newCursor, err := blueskyClient.ListNotifications(currentState.LastNotificationCursor, 50)
108108+ if err != nil {
109109+ return fmt.Errorf("failed to fetch notifications: %w", err)
110110+ }
111111+112112+ // Filter for unread notifications that match configured types
113113+ var relevantNotifications []client.Notification
114114+ for _, notif := range notifications {
115115+ if !notif.IsRead && cfg.ShouldProcessNotification(notif.Reason) {
116116+ relevantNotifications = append(relevantNotifications, notif)
117117+ }
118118+ }
119119+120120+ if len(relevantNotifications) == 0 {
121121+ // No new relevant notifications
122122+ return nil
123123+ }
124124+125125+ log.Printf("Found %d new relevant notification(s)", len(relevantNotifications))
126126+127127+ // Get current profile
128128+ profile, err := blueskyClient.GetProfile()
129129+ if err != nil {
130130+ return fmt.Errorf("failed to get profile: %w", err)
131131+ }
132132+133133+ // Get avatar URL
134134+ avatarURL, err := blueskyClient.GetAvatarURL(profile)
135135+ if err != nil {
136136+ return fmt.Errorf("failed to get avatar URL: %w", err)
137137+ }
138138+139139+ // Download current avatar
140140+ log.Println("Downloading current avatar...")
141141+ avatarData, err := imageProcessor.DownloadImage(avatarURL)
142142+ if err != nil {
143143+ return fmt.Errorf("failed to download avatar: %w", err)
144144+ }
145145+146146+ // Rotate image
147147+ log.Printf("Rotating avatar by %.2f degrees...", cfg.Rotation.Degrees)
148148+ rotatedData, format, err := imageProcessor.RotateImage(avatarData, cfg.Rotation.Degrees)
149149+ if err != nil {
150150+ return fmt.Errorf("failed to rotate image: %w", err)
151151+ }
152152+153153+ // Upload new avatar
154154+ log.Println("Uploading rotated avatar...")
155155+ blobRef, err := blueskyClient.UploadBlob(rotatedData, image.GetMimeType(format))
156156+ if err != nil {
157157+ return fmt.Errorf("failed to upload blob: %w", err)
158158+ }
159159+160160+ // Update profile with new avatar
161161+ profile.Avatar = blobRef
162162+ log.Println("Updating profile...")
163163+ if err := blueskyClient.UpdateProfile(profile); err != nil {
164164+ return fmt.Errorf("failed to update profile: %w", err)
165165+ }
166166+167167+ // Update state
168168+ currentState.LastNotificationCursor = newCursor
169169+ currentState.LastProcessedAt = time.Now().Format(time.RFC3339)
170170+ currentState.CumulativeRotation += cfg.Rotation.Degrees
171171+172172+ // Normalize cumulative rotation to 0-360 range
173173+ currentState.CumulativeRotation = math.Mod(currentState.CumulativeRotation, 360)
174174+ if currentState.CumulativeRotation < 0 {
175175+ currentState.CumulativeRotation += 360
176176+ }
177177+178178+ if err := stateManager.Save(currentState); err != nil {
179179+ return fmt.Errorf("failed to save state: %w", err)
180180+ }
181181+182182+ log.Printf("✓ Profile picture rotated successfully! (Total rotation: %.2f°)", currentState.CumulativeRotation)
183183+184184+ return nil
185185+}
+37
config.example.yaml
···11+bluesky:
22+ # Your Bluesky handle (e.g., username.bsky.social)
33+ handle: "yourhandle.bsky.social"
44+55+ # App password from Bluesky Settings > App Passwords
66+ # IMPORTANT: You can also set this via the BLUESKY_APP_PASSWORD environment variable
77+ # Never commit your actual app password to version control!
88+ app_password: "xxxx-xxxx-xxxx-xxxx"
99+1010+rotation:
1111+ # Number of degrees to rotate the profile picture per notification
1212+ # Positive values rotate clockwise, negative values rotate counter-clockwise
1313+ degrees: 2.0
1414+1515+polling:
1616+ # How often to check for new notifications
1717+ # Valid time units: s (seconds), m (minutes), h (hours)
1818+ # Examples: "30s", "1m", "5m"
1919+ interval: "30s"
2020+2121+notifications:
2222+ # Which notification types should trigger a profile picture rotation
2323+ types:
2424+ like: true # Someone liked your post
2525+ repost: true # Someone reposted your post
2626+ reply: true # Someone replied to your post
2727+ follow: true # Someone followed you
2828+ mention: true # Someone mentioned you
2929+ quote: true # Someone quoted your post
3030+3131+state:
3232+ # Storage backend: "sqlite" or "json"
3333+ storage: "sqlite"
3434+3535+ # Path to state file (SQLite database or JSON file)
3636+ # This file tracks which notifications have been processed
3737+ path: "./data/state.db"