An engagement based washing machine that spins you round and round!
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial commit: ATProto profile picture rotation service

Brooke f890489f

+1499
+33
.dockerignore
··· 1 + # Git 2 + .git/ 3 + .gitignore 4 + 5 + # Configuration (will be mounted) 6 + config.yaml 7 + 8 + # State data (will be mounted) 9 + data/ 10 + 11 + # Documentation 12 + *.md 13 + LICENSE 14 + 15 + # IDE 16 + .idea/ 17 + .vscode/ 18 + 19 + # OS 20 + .DS_Store 21 + Thumbs.db 22 + 23 + # Binaries 24 + brooke-spin 25 + *.exe 26 + 27 + # Logs 28 + *.log 29 + 30 + # Docker 31 + Dockerfile 32 + docker-compose.yml 33 + .dockerignore
+41
.gitignore
··· 1 + # Binaries 2 + /brooke-spin 3 + *.exe 4 + *.dll 5 + *.so 6 + *.dylib 7 + 8 + # Test binary, built with `go test -c` 9 + *.test 10 + 11 + # Output of the go coverage tool 12 + *.out 13 + 14 + # Go workspace file 15 + go.work 16 + 17 + # Configuration (contains secrets) 18 + config.yaml 19 + 20 + # State data 21 + data/ 22 + *.db 23 + *.db-shm 24 + *.db-wal 25 + 26 + # Dependency directories 27 + vendor/ 28 + 29 + # IDE 30 + .idea/ 31 + .vscode/ 32 + *.swp 33 + *.swo 34 + *~ 35 + 36 + # OS 37 + .DS_Store 38 + Thumbs.db 39 + 40 + # Logs 41 + *.log
+46
Dockerfile
··· 1 + # Build stage 2 + FROM --platform=$BUILDPLATFORM golang:1.22-alpine AS builder 3 + 4 + # Install build dependencies 5 + RUN apk add --no-cache git gcc musl-dev 6 + 7 + WORKDIR /build 8 + 9 + # Copy go mod files 10 + COPY go.mod go.sum ./ 11 + RUN go mod download 12 + 13 + # Copy source code 14 + COPY . . 15 + 16 + # Build arguments for cross-compilation 17 + ARG TARGETOS 18 + ARG TARGETARCH 19 + 20 + # Build the binary 21 + RUN CGO_ENABLED=1 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ 22 + go build -ldflags="-w -s" -o brooke-spin ./cmd/brooke-spin 23 + 24 + # Runtime stage 25 + FROM alpine:latest 26 + 27 + # Install runtime dependencies 28 + RUN apk add --no-cache ca-certificates tzdata 29 + 30 + # Create non-root user 31 + RUN addgroup -g 1000 brooke && \ 32 + adduser -D -u 1000 -G brooke brooke 33 + 34 + WORKDIR /app 35 + 36 + # Copy binary from builder 37 + COPY --from=builder /build/brooke-spin /app/brooke-spin 38 + 39 + # Create data directory 40 + RUN mkdir -p /app/data && chown -R brooke:brooke /app 41 + 42 + # Switch to non-root user 43 + USER brooke 44 + 45 + # Default command 46 + CMD ["/app/brooke-spin", "-config", "/app/config.yaml"]
+55
Makefile
··· 1 + .PHONY: help build run clean docker-build docker-up docker-down docker-logs test 2 + 3 + # Default target 4 + help: 5 + @echo "Available targets:" 6 + @echo " build - Build the binary locally" 7 + @echo " run - Run the service locally" 8 + @echo " clean - Remove build artifacts" 9 + @echo " docker-build - Build Docker image" 10 + @echo " docker-up - Start Docker container" 11 + @echo " docker-down - Stop Docker container" 12 + @echo " docker-logs - View Docker container logs" 13 + @echo " test - Run tests" 14 + 15 + # Build the binary 16 + build: 17 + go build -o brooke-spin ./cmd/brooke-spin 18 + 19 + # Run the service locally 20 + run: build 21 + ./brooke-spin -config config.yaml 22 + 23 + # Clean build artifacts 24 + clean: 25 + rm -f brooke-spin 26 + go clean 27 + 28 + # Build Docker image 29 + docker-build: 30 + docker build -t brooke-spin:latest . 31 + 32 + # Build for Raspberry Pi (ARM64) 33 + docker-build-arm: 34 + docker buildx build --platform linux/arm64 -t brooke-spin:latest . 35 + 36 + # Start Docker container 37 + docker-up: 38 + docker-compose up -d 39 + 40 + # Stop Docker container 41 + docker-down: 42 + docker-compose down 43 + 44 + # View Docker logs 45 + docker-logs: 46 + docker-compose logs -f 47 + 48 + # Run tests 49 + test: 50 + go test -v ./... 51 + 52 + # Install dependencies 53 + deps: 54 + go mod download 55 + go mod tidy
+362
README.md
··· 1 + # brooke-spin 2 + 3 + A background service that automatically rotates your Bluesky profile picture by a configurable number of degrees each time you receive a notification. 4 + 5 + ## Features 6 + 7 + - **Automated Profile Picture Rotation**: Rotates your avatar every time you get likes, reposts, replies, follows, mentions, or quotes 8 + - **Configurable Rotation**: Set your preferred rotation angle (default: 2 degrees) 9 + - **Selective Notifications**: Choose which notification types trigger rotation 10 + - **State Persistence**: Tracks processed notifications to avoid duplicates 11 + - **Docker Support**: Runs as a lightweight container on Raspberry Pi (ARM64/ARMv7) 12 + - **Graceful Shutdown**: Handles SIGTERM/SIGINT properly 13 + - **Secure**: App password can be set via environment variable 14 + 15 + ## Prerequisites 16 + 17 + - A Bluesky account 18 + - An app password from Bluesky Settings > App Passwords 19 + - Docker and Docker Compose (for containerized deployment) 20 + - OR Go 1.22+ (for local development) 21 + 22 + ## Quick Start 23 + 24 + ### 1. Clone the Repository 25 + 26 + ```bash 27 + git clone <repository-url> 28 + cd brooke-spin 29 + ``` 30 + 31 + ### 2. Create Configuration 32 + 33 + Copy the example configuration and edit it: 34 + 35 + ```bash 36 + cp config.example.yaml config.yaml 37 + nano config.yaml 38 + ``` 39 + 40 + Update the following fields: 41 + - `bluesky.handle`: Your Bluesky handle (e.g., username.bsky.social) 42 + - `bluesky.app_password`: Your app password (or set via environment variable) 43 + - `rotation.degrees`: Rotation angle per notification (default: 2.0) 44 + 45 + ### 3. Deploy with Docker 46 + 47 + ```bash 48 + # Build and start the service 49 + docker-compose up -d 50 + 51 + # View logs 52 + docker-compose logs -f 53 + 54 + # Stop the service 55 + docker-compose down 56 + ``` 57 + 58 + ### 4. Monitor 59 + 60 + Watch the logs to see it working: 61 + 62 + ```bash 63 + docker-compose logs -f brooke-spin 64 + ``` 65 + 66 + You should see messages like: 67 + ``` 68 + Starting brooke-spin service... 69 + Authenticating with Bluesky... 70 + Authentication successful! 71 + Polling for notifications every 30s 72 + Found 2 new relevant notification(s) 73 + Downloading current avatar... 74 + Rotating avatar by 2.00 degrees... 75 + Uploading rotated avatar... 76 + Updating profile... 77 + ✓ Profile picture rotated successfully! (Total rotation: 42.00°) 78 + ``` 79 + 80 + ## Configuration 81 + 82 + ### Configuration File (`config.yaml`) 83 + 84 + ```yaml 85 + bluesky: 86 + handle: "yourhandle.bsky.social" 87 + app_password: "xxxx-xxxx-xxxx-xxxx" 88 + 89 + rotation: 90 + degrees: 2.0 # Rotation per notification 91 + 92 + polling: 93 + interval: "30s" # How often to check for notifications 94 + 95 + notifications: 96 + types: 97 + like: true 98 + repost: true 99 + reply: true 100 + follow: true 101 + mention: true 102 + quote: true 103 + 104 + state: 105 + storage: "sqlite" # or "json" 106 + path: "./data/state.db" 107 + ``` 108 + 109 + ### Environment Variables 110 + 111 + You can override configuration values with environment variables: 112 + 113 + - `BLUESKY_APP_PASSWORD`: Override the app password (recommended for Docker) 114 + 115 + Example: 116 + ```bash 117 + export BLUESKY_APP_PASSWORD="your-app-password" 118 + docker-compose up -d 119 + ``` 120 + 121 + Or in `docker-compose.yml`: 122 + ```yaml 123 + environment: 124 + - BLUESKY_APP_PASSWORD=${BLUESKY_APP_PASSWORD} 125 + ``` 126 + 127 + ## Advanced Usage 128 + 129 + ### Running Locally (Without Docker) 130 + 131 + ```bash 132 + # Install dependencies 133 + go mod download 134 + 135 + # Build 136 + go build -o brooke-spin ./cmd/brooke-spin 137 + 138 + # Run 139 + ./brooke-spin -config config.yaml 140 + ``` 141 + 142 + ### Building for Raspberry Pi 143 + 144 + ```bash 145 + # Using Docker buildx for ARM64 146 + docker buildx build --platform linux/arm64 -t brooke-spin:latest . 147 + 148 + # Or build natively on Raspberry Pi 149 + go build -o brooke-spin ./cmd/brooke-spin 150 + ``` 151 + 152 + ### Custom Polling Interval 153 + 154 + Adjust how often the service checks for new notifications: 155 + 156 + ```yaml 157 + polling: 158 + interval: "1m" # Check every minute 159 + # interval: "30s" # Check every 30 seconds (default) 160 + # interval: "5m" # Check every 5 minutes 161 + ``` 162 + 163 + ### Selective Notification Types 164 + 165 + Only rotate on specific interactions: 166 + 167 + ```yaml 168 + notifications: 169 + types: 170 + like: true # Rotate on likes 171 + repost: false # Ignore reposts 172 + reply: true # Rotate on replies 173 + follow: true # Rotate on follows 174 + mention: false # Ignore mentions 175 + quote: true # Rotate on quotes 176 + ``` 177 + 178 + ### Change Rotation Direction 179 + 180 + Use negative degrees to rotate counter-clockwise: 181 + 182 + ```yaml 183 + rotation: 184 + degrees: -2.0 # Rotate 2 degrees counter-clockwise 185 + ``` 186 + 187 + ### State Storage Options 188 + 189 + Choose between SQLite (default) or JSON: 190 + 191 + ```yaml 192 + state: 193 + storage: "sqlite" # Recommended: atomic writes, queryable 194 + path: "./data/state.db" 195 + 196 + # OR use JSON (simpler, but less robust) 197 + # storage: "json" 198 + # path: "./data/state.json" 199 + ``` 200 + 201 + ## Deployment on Raspberry Pi 202 + 203 + ### 1. Copy Files to Pi 204 + 205 + ```bash 206 + # From your local machine 207 + scp -r brooke-spin/ pi@raspberrypi.local:~/ 208 + ``` 209 + 210 + ### 2. SSH to Pi and Deploy 211 + 212 + ```bash 213 + ssh pi@raspberrypi.local 214 + cd ~/brooke-spin 215 + 216 + # Edit configuration 217 + nano config.yaml 218 + 219 + # Start the service 220 + docker-compose up -d 221 + 222 + # Check logs 223 + docker-compose logs -f 224 + ``` 225 + 226 + ### 3. Auto-Start on Boot 227 + 228 + The service will automatically restart with `restart: unless-stopped` in `docker-compose.yml`. 229 + 230 + To ensure Docker Compose starts on boot: 231 + 232 + ```bash 233 + # Enable Docker service 234 + sudo systemctl enable docker 235 + 236 + # Add to crontab 237 + crontab -e 238 + 239 + # Add this line: 240 + @reboot cd /home/pi/brooke-spin && docker-compose up -d 241 + ``` 242 + 243 + ## Troubleshooting 244 + 245 + ### Authentication Failed 246 + 247 + - Double-check your handle and app password 248 + - Make sure you're using an app password, not your main account password 249 + - Generate a new app password from Bluesky Settings > App Passwords 250 + 251 + ### Profile Picture Not Rotating 252 + 253 + - Check logs: `docker-compose logs -f` 254 + - Verify you're receiving notifications (test by liking one of your own posts from another account) 255 + - Ensure notification types are enabled in config 256 + - Check that your profile has an avatar set 257 + 258 + ### Container Won't Start 259 + 260 + ```bash 261 + # Check container status 262 + docker-compose ps 263 + 264 + # View full logs 265 + docker-compose logs 266 + 267 + # Rebuild the container 268 + docker-compose down 269 + docker-compose build --no-cache 270 + docker-compose up -d 271 + ``` 272 + 273 + ### State Not Persisting 274 + 275 + - Ensure the `./data` directory exists and is writable 276 + - Check volume mounts in `docker-compose.yml` 277 + - Verify the state path in `config.yaml` matches the mounted volume 278 + 279 + ## Development 280 + 281 + ### Project Structure 282 + 283 + ``` 284 + brooke-spin/ 285 + ├── cmd/ 286 + │ └── brooke-spin/ 287 + │ └── main.go # Entry point 288 + ├── internal/ 289 + │ ├── config/ 290 + │ │ └── config.go # Configuration loading 291 + │ ├── client/ 292 + │ │ └── bluesky.go # Bluesky API client 293 + │ ├── image/ 294 + │ │ └── processor.go # Image rotation 295 + │ └── state/ 296 + │ └── manager.go # State persistence 297 + ├── config.example.yaml # Example configuration 298 + ├── docker-compose.yml # Docker Compose config 299 + ├── Dockerfile # Docker image definition 300 + ├── Makefile # Build helpers 301 + └── README.md # This file 302 + ``` 303 + 304 + ### Running Tests 305 + 306 + ```bash 307 + make test 308 + ``` 309 + 310 + ### Building 311 + 312 + ```bash 313 + # Local build 314 + make build 315 + 316 + # Docker build 317 + make docker-build 318 + 319 + # Docker build for ARM (Raspberry Pi) 320 + make docker-build-arm 321 + ``` 322 + 323 + ## How It Works 324 + 325 + 1. **Polling**: Every N seconds (configurable), the service polls the Bluesky notifications API 326 + 2. **Filtering**: Unread notifications are filtered by configured types (like, repost, etc.) 327 + 3. **Detection**: If new relevant notifications are found, the rotation process begins 328 + 4. **Download**: Current profile picture is downloaded from Bluesky 329 + 5. **Rotation**: Image is rotated by configured degrees using Go image libraries 330 + 6. **Upload**: Rotated image is uploaded as a new blob 331 + 7. **Update**: Profile is updated with the new avatar blob reference 332 + 8. **State**: Notification cursor and cumulative rotation are saved to disk 333 + 334 + ## Security 335 + 336 + - **Never commit `config.yaml`**: It's in `.gitignore` by default 337 + - **Use environment variables**: Set `BLUESKY_APP_PASSWORD` instead of storing in config 338 + - **App passwords**: Always use app passwords, never your main account password 339 + - **Non-root container**: Docker image runs as non-root user for security 340 + 341 + ## License 342 + 343 + MIT License - See LICENSE file for details 344 + 345 + ## Contributing 346 + 347 + Contributions welcome! Please open an issue or pull request. 348 + 349 + ## Credits 350 + 351 + Built with: 352 + - [Go](https://golang.org/) 353 + - [disintegration/imaging](https://github.com/disintegration/imaging) for image processing 354 + - [mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) for state persistence 355 + - [gopkg.in/yaml.v3](https://gopkg.in/yaml.v3) for configuration 356 + 357 + ## Support 358 + 359 + Having issues? Please open an issue on GitHub with: 360 + - Your configuration (without passwords!) 361 + - Log output 362 + - Expected vs actual behavior
+185
cmd/brooke-spin/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "flag" 6 + "fmt" 7 + "log" 8 + "math" 9 + "os" 10 + "os/signal" 11 + "syscall" 12 + "time" 13 + 14 + "github.com/brooke/brooke-spin/internal/client" 15 + "github.com/brooke/brooke-spin/internal/config" 16 + "github.com/brooke/brooke-spin/internal/image" 17 + "github.com/brooke/brooke-spin/internal/state" 18 + ) 19 + 20 + func main() { 21 + configPath := flag.String("config", "config.yaml", "Path to configuration file") 22 + flag.Parse() 23 + 24 + // Load configuration 25 + cfg, err := config.Load(*configPath) 26 + if err != nil { 27 + log.Fatalf("Failed to load configuration: %v", err) 28 + } 29 + 30 + log.Println("Starting brooke-spin service...") 31 + 32 + // Initialize components 33 + blueskyClient := client.NewClient() 34 + imageProcessor := image.NewProcessor() 35 + stateManager, err := state.NewManager(cfg.State.Storage, cfg.State.Path) 36 + if err != nil { 37 + log.Fatalf("Failed to initialize state manager: %v", err) 38 + } 39 + defer stateManager.Close() 40 + 41 + // Authenticate with Bluesky 42 + log.Println("Authenticating with Bluesky...") 43 + if err := blueskyClient.Authenticate(cfg.Bluesky.Handle, cfg.Bluesky.AppPassword); err != nil { 44 + log.Fatalf("Authentication failed: %v", err) 45 + } 46 + log.Println("Authentication successful!") 47 + 48 + // Load state 49 + currentState, err := stateManager.Load() 50 + if err != nil { 51 + log.Fatalf("Failed to load state: %v", err) 52 + } 53 + 54 + if currentState.LastNotificationCursor != "" { 55 + log.Printf("Resuming from last cursor (cumulative rotation: %.2f°)", currentState.CumulativeRotation) 56 + } else { 57 + log.Println("Starting fresh - no previous state found") 58 + } 59 + 60 + // Setup graceful shutdown 61 + ctx, cancel := context.WithCancel(context.Background()) 62 + defer cancel() 63 + 64 + sigChan := make(chan os.Signal, 1) 65 + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) 66 + 67 + go func() { 68 + <-sigChan 69 + log.Println("\nReceived shutdown signal, cleaning up...") 70 + cancel() 71 + }() 72 + 73 + // Start polling loop 74 + ticker := time.NewTicker(cfg.GetPollingInterval()) 75 + defer ticker.Stop() 76 + 77 + // Run initial check immediately 78 + if err := checkAndRotate(ctx, cfg, blueskyClient, imageProcessor, stateManager, currentState); err != nil { 79 + log.Printf("Error during initial check: %v", err) 80 + } 81 + 82 + log.Printf("Polling for notifications every %s", cfg.GetPollingInterval()) 83 + 84 + for { 85 + select { 86 + case <-ctx.Done(): 87 + log.Println("Shutting down gracefully...") 88 + return 89 + case <-ticker.C: 90 + if err := checkAndRotate(ctx, cfg, blueskyClient, imageProcessor, stateManager, currentState); err != nil { 91 + log.Printf("Error during polling: %v", err) 92 + // Continue polling despite errors 93 + } 94 + } 95 + } 96 + } 97 + 98 + func checkAndRotate( 99 + ctx context.Context, 100 + cfg *config.Config, 101 + blueskyClient *client.Client, 102 + imageProcessor *image.Processor, 103 + stateManager *state.Manager, 104 + currentState *state.State, 105 + ) error { 106 + // Fetch notifications 107 + notifications, newCursor, err := blueskyClient.ListNotifications(currentState.LastNotificationCursor, 50) 108 + if err != nil { 109 + return fmt.Errorf("failed to fetch notifications: %w", err) 110 + } 111 + 112 + // Filter for unread notifications that match configured types 113 + var relevantNotifications []client.Notification 114 + for _, notif := range notifications { 115 + if !notif.IsRead && cfg.ShouldProcessNotification(notif.Reason) { 116 + relevantNotifications = append(relevantNotifications, notif) 117 + } 118 + } 119 + 120 + if len(relevantNotifications) == 0 { 121 + // No new relevant notifications 122 + return nil 123 + } 124 + 125 + log.Printf("Found %d new relevant notification(s)", len(relevantNotifications)) 126 + 127 + // Get current profile 128 + profile, err := blueskyClient.GetProfile() 129 + if err != nil { 130 + return fmt.Errorf("failed to get profile: %w", err) 131 + } 132 + 133 + // Get avatar URL 134 + avatarURL, err := blueskyClient.GetAvatarURL(profile) 135 + if err != nil { 136 + return fmt.Errorf("failed to get avatar URL: %w", err) 137 + } 138 + 139 + // Download current avatar 140 + log.Println("Downloading current avatar...") 141 + avatarData, err := imageProcessor.DownloadImage(avatarURL) 142 + if err != nil { 143 + return fmt.Errorf("failed to download avatar: %w", err) 144 + } 145 + 146 + // Rotate image 147 + log.Printf("Rotating avatar by %.2f degrees...", cfg.Rotation.Degrees) 148 + rotatedData, format, err := imageProcessor.RotateImage(avatarData, cfg.Rotation.Degrees) 149 + if err != nil { 150 + return fmt.Errorf("failed to rotate image: %w", err) 151 + } 152 + 153 + // Upload new avatar 154 + log.Println("Uploading rotated avatar...") 155 + blobRef, err := blueskyClient.UploadBlob(rotatedData, image.GetMimeType(format)) 156 + if err != nil { 157 + return fmt.Errorf("failed to upload blob: %w", err) 158 + } 159 + 160 + // Update profile with new avatar 161 + profile.Avatar = blobRef 162 + log.Println("Updating profile...") 163 + if err := blueskyClient.UpdateProfile(profile); err != nil { 164 + return fmt.Errorf("failed to update profile: %w", err) 165 + } 166 + 167 + // Update state 168 + currentState.LastNotificationCursor = newCursor 169 + currentState.LastProcessedAt = time.Now().Format(time.RFC3339) 170 + currentState.CumulativeRotation += cfg.Rotation.Degrees 171 + 172 + // Normalize cumulative rotation to 0-360 range 173 + currentState.CumulativeRotation = math.Mod(currentState.CumulativeRotation, 360) 174 + if currentState.CumulativeRotation < 0 { 175 + currentState.CumulativeRotation += 360 176 + } 177 + 178 + if err := stateManager.Save(currentState); err != nil { 179 + return fmt.Errorf("failed to save state: %w", err) 180 + } 181 + 182 + log.Printf("✓ Profile picture rotated successfully! (Total rotation: %.2f°)", currentState.CumulativeRotation) 183 + 184 + return nil 185 + }
+37
config.example.yaml
··· 1 + bluesky: 2 + # Your Bluesky handle (e.g., username.bsky.social) 3 + handle: "yourhandle.bsky.social" 4 + 5 + # App password from Bluesky Settings > App Passwords 6 + # IMPORTANT: You can also set this via the BLUESKY_APP_PASSWORD environment variable 7 + # Never commit your actual app password to version control! 8 + app_password: "xxxx-xxxx-xxxx-xxxx" 9 + 10 + rotation: 11 + # Number of degrees to rotate the profile picture per notification 12 + # Positive values rotate clockwise, negative values rotate counter-clockwise 13 + degrees: 2.0 14 + 15 + polling: 16 + # How often to check for new notifications 17 + # Valid time units: s (seconds), m (minutes), h (hours) 18 + # Examples: "30s", "1m", "5m" 19 + interval: "30s" 20 + 21 + notifications: 22 + # Which notification types should trigger a profile picture rotation 23 + types: 24 + like: true # Someone liked your post 25 + repost: true # Someone reposted your post 26 + reply: true # Someone replied to your post 27 + follow: true # Someone followed you 28 + mention: true # Someone mentioned you 29 + quote: true # Someone quoted your post 30 + 31 + state: 32 + # Storage backend: "sqlite" or "json" 33 + storage: "sqlite" 34 + 35 + # Path to state file (SQLite database or JSON file) 36 + # This file tracks which notifications have been processed 37 + path: "./data/state.db"
+36
docker-compose.yml
··· 1 + version: '3.8' 2 + 3 + services: 4 + brooke-spin: 5 + build: 6 + context: . 7 + dockerfile: Dockerfile 8 + container_name: brooke-spin 9 + restart: unless-stopped 10 + 11 + # Mount configuration and data 12 + volumes: 13 + - ./config.yaml:/app/config.yaml:ro 14 + - ./data:/app/data 15 + 16 + # Environment variables (optional, overrides config.yaml) 17 + environment: 18 + - BLUESKY_APP_PASSWORD=${BLUESKY_APP_PASSWORD} 19 + 20 + # Logging 21 + logging: 22 + driver: "json-file" 23 + options: 24 + max-size: "10m" 25 + max-file: "3" 26 + 27 + # Resource limits (adjust for your Raspberry Pi) 28 + # Uncomment and customize if needed 29 + # deploy: 30 + # resources: 31 + # limits: 32 + # cpus: '0.5' 33 + # memory: 128M 34 + # reservations: 35 + # cpus: '0.25' 36 + # memory: 64M
+11
go.mod
··· 1 + module github.com/brooke/brooke-spin 2 + 3 + go 1.25.5 4 + 5 + require ( 6 + github.com/disintegration/imaging v1.6.2 7 + github.com/mattn/go-sqlite3 v1.14.33 8 + gopkg.in/yaml.v3 v3.0.1 9 + ) 10 + 11 + require golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
+11
go.sum
··· 1 + github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= 2 + github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= 3 + github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= 4 + github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 5 + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= 6 + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 7 + golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 8 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 9 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 10 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 11 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+266
internal/client/bluesky.go
··· 1 + package client 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "time" 10 + ) 11 + 12 + const ( 13 + DefaultPDSURL = "https://bsky.social" 14 + ) 15 + 16 + // Client represents a Bluesky API client 17 + type Client struct { 18 + pdsURL string 19 + accessToken string 20 + did string 21 + client *http.Client 22 + } 23 + 24 + // NewClient creates a new Bluesky client 25 + func NewClient() *Client { 26 + return &Client{ 27 + pdsURL: DefaultPDSURL, 28 + client: &http.Client{ 29 + Timeout: 30 * time.Second, 30 + }, 31 + } 32 + } 33 + 34 + // Authenticate authenticates with the Bluesky API 35 + func (c *Client) Authenticate(handle, appPassword string) error { 36 + payload := map[string]string{ 37 + "identifier": handle, 38 + "password": appPassword, 39 + } 40 + 41 + data, err := json.Marshal(payload) 42 + if err != nil { 43 + return fmt.Errorf("failed to marshal auth payload: %w", err) 44 + } 45 + 46 + resp, err := c.client.Post( 47 + c.pdsURL+"/xrpc/com.atproto.server.createSession", 48 + "application/json", 49 + bytes.NewReader(data), 50 + ) 51 + if err != nil { 52 + return fmt.Errorf("authentication request failed: %w", err) 53 + } 54 + defer resp.Body.Close() 55 + 56 + if resp.StatusCode != http.StatusOK { 57 + body, _ := io.ReadAll(resp.Body) 58 + return fmt.Errorf("authentication failed: status %d, body: %s", resp.StatusCode, string(body)) 59 + } 60 + 61 + var result struct { 62 + AccessJwt string `json:"accessJwt"` 63 + Did string `json:"did"` 64 + } 65 + 66 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 67 + return fmt.Errorf("failed to decode auth response: %w", err) 68 + } 69 + 70 + c.accessToken = result.AccessJwt 71 + c.did = result.Did 72 + 73 + return nil 74 + } 75 + 76 + // Notification represents a Bluesky notification 77 + type Notification struct { 78 + URI string `json:"uri"` 79 + Cid string `json:"cid"` 80 + Author Actor `json:"author"` 81 + Reason string `json:"reason"` 82 + ReasonSubject string `json:"reasonSubject,omitempty"` 83 + Record any `json:"record"` 84 + IsRead bool `json:"isRead"` 85 + IndexedAt time.Time `json:"indexedAt"` 86 + } 87 + 88 + // Actor represents a Bluesky actor 89 + type Actor struct { 90 + Did string `json:"did"` 91 + Handle string `json:"handle"` 92 + } 93 + 94 + // ListNotifications fetches notifications 95 + func (c *Client) ListNotifications(cursor string, limit int) ([]Notification, string, error) { 96 + url := fmt.Sprintf("%s/xrpc/app.bsky.notification.listNotifications?limit=%d", c.pdsURL, limit) 97 + if cursor != "" { 98 + url += "&cursor=" + cursor 99 + } 100 + 101 + req, err := http.NewRequest("GET", url, nil) 102 + if err != nil { 103 + return nil, "", fmt.Errorf("failed to create request: %w", err) 104 + } 105 + 106 + req.Header.Set("Authorization", "Bearer "+c.accessToken) 107 + 108 + resp, err := c.client.Do(req) 109 + if err != nil { 110 + return nil, "", fmt.Errorf("request failed: %w", err) 111 + } 112 + defer resp.Body.Close() 113 + 114 + if resp.StatusCode != http.StatusOK { 115 + body, _ := io.ReadAll(resp.Body) 116 + return nil, "", fmt.Errorf("request failed: status %d, body: %s", resp.StatusCode, string(body)) 117 + } 118 + 119 + var result struct { 120 + Notifications []Notification `json:"notifications"` 121 + Cursor string `json:"cursor"` 122 + } 123 + 124 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 125 + return nil, "", fmt.Errorf("failed to decode response: %w", err) 126 + } 127 + 128 + return result.Notifications, result.Cursor, nil 129 + } 130 + 131 + // ProfileRecord represents a user profile record 132 + type ProfileRecord struct { 133 + DisplayName string `json:"displayName,omitempty"` 134 + Description string `json:"description,omitempty"` 135 + Avatar *BlobRef `json:"avatar,omitempty"` 136 + Banner *BlobRef `json:"banner,omitempty"` 137 + } 138 + 139 + // BlobRef represents a blob reference 140 + type BlobRef struct { 141 + Type string `json:"$type"` 142 + Ref CID `json:"ref"` 143 + MimeType string `json:"mimeType"` 144 + Size int `json:"size"` 145 + } 146 + 147 + // CID represents a content identifier 148 + type CID struct { 149 + Link string `json:"$link"` 150 + } 151 + 152 + // GetProfile fetches the current user's profile 153 + func (c *Client) GetProfile() (*ProfileRecord, error) { 154 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=app.bsky.actor.profile&rkey=self", 155 + c.pdsURL, c.did) 156 + 157 + req, err := http.NewRequest("GET", url, nil) 158 + if err != nil { 159 + return nil, fmt.Errorf("failed to create request: %w", err) 160 + } 161 + 162 + req.Header.Set("Authorization", "Bearer "+c.accessToken) 163 + 164 + resp, err := c.client.Do(req) 165 + if err != nil { 166 + return nil, fmt.Errorf("request failed: %w", err) 167 + } 168 + defer resp.Body.Close() 169 + 170 + if resp.StatusCode != http.StatusOK { 171 + body, _ := io.ReadAll(resp.Body) 172 + return nil, fmt.Errorf("request failed: status %d, body: %s", resp.StatusCode, string(body)) 173 + } 174 + 175 + var result struct { 176 + Value ProfileRecord `json:"value"` 177 + } 178 + 179 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 180 + return nil, fmt.Errorf("failed to decode response: %w", err) 181 + } 182 + 183 + return &result.Value, nil 184 + } 185 + 186 + // GetAvatarURL returns the URL for downloading the avatar blob 187 + func (c *Client) GetAvatarURL(profile *ProfileRecord) (string, error) { 188 + if profile.Avatar == nil { 189 + return "", fmt.Errorf("profile has no avatar") 190 + } 191 + 192 + return fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 193 + c.pdsURL, c.did, profile.Avatar.Ref.Link), nil 194 + } 195 + 196 + // UploadBlob uploads a blob to Bluesky 197 + func (c *Client) UploadBlob(data []byte, mimeType string) (*BlobRef, error) { 198 + url := c.pdsURL + "/xrpc/com.atproto.repo.uploadBlob" 199 + 200 + req, err := http.NewRequest("POST", url, bytes.NewReader(data)) 201 + if err != nil { 202 + return nil, fmt.Errorf("failed to create request: %w", err) 203 + } 204 + 205 + req.Header.Set("Authorization", "Bearer "+c.accessToken) 206 + req.Header.Set("Content-Type", mimeType) 207 + 208 + resp, err := c.client.Do(req) 209 + if err != nil { 210 + return nil, fmt.Errorf("request failed: %w", err) 211 + } 212 + defer resp.Body.Close() 213 + 214 + if resp.StatusCode != http.StatusOK { 215 + body, _ := io.ReadAll(resp.Body) 216 + return nil, fmt.Errorf("request failed: status %d, body: %s", resp.StatusCode, string(body)) 217 + } 218 + 219 + var result struct { 220 + Blob BlobRef `json:"blob"` 221 + } 222 + 223 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 224 + return nil, fmt.Errorf("failed to decode response: %w", err) 225 + } 226 + 227 + return &result.Blob, nil 228 + } 229 + 230 + // UpdateProfile updates the user's profile with a new avatar 231 + func (c *Client) UpdateProfile(profile *ProfileRecord) error { 232 + payload := map[string]any{ 233 + "repo": c.did, 234 + "collection": "app.bsky.actor.profile", 235 + "rkey": "self", 236 + "record": profile, 237 + } 238 + 239 + data, err := json.Marshal(payload) 240 + if err != nil { 241 + return fmt.Errorf("failed to marshal payload: %w", err) 242 + } 243 + 244 + url := c.pdsURL + "/xrpc/com.atproto.repo.putRecord" 245 + 246 + req, err := http.NewRequest("POST", url, bytes.NewReader(data)) 247 + if err != nil { 248 + return fmt.Errorf("failed to create request: %w", err) 249 + } 250 + 251 + req.Header.Set("Authorization", "Bearer "+c.accessToken) 252 + req.Header.Set("Content-Type", "application/json") 253 + 254 + resp, err := c.client.Do(req) 255 + if err != nil { 256 + return fmt.Errorf("request failed: %w", err) 257 + } 258 + defer resp.Body.Close() 259 + 260 + if resp.StatusCode != http.StatusOK { 261 + body, _ := io.ReadAll(resp.Body) 262 + return fmt.Errorf("request failed: status %d, body: %s", resp.StatusCode, string(body)) 263 + } 264 + 265 + return nil 266 + }
+138
internal/config/config.go
··· 1 + package config 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "time" 7 + 8 + "gopkg.in/yaml.v3" 9 + ) 10 + 11 + // Config represents the application configuration 12 + type Config struct { 13 + Bluesky BlueskyConfig `yaml:"bluesky"` 14 + Rotation RotationConfig `yaml:"rotation"` 15 + Polling PollingConfig `yaml:"polling"` 16 + Notifications NotificationsConfig `yaml:"notifications"` 17 + State StateConfig `yaml:"state"` 18 + } 19 + 20 + // BlueskyConfig contains Bluesky authentication settings 21 + type BlueskyConfig struct { 22 + Handle string `yaml:"handle"` 23 + AppPassword string `yaml:"app_password"` 24 + } 25 + 26 + // RotationConfig contains image rotation settings 27 + type RotationConfig struct { 28 + Degrees float64 `yaml:"degrees"` 29 + } 30 + 31 + // PollingConfig contains polling interval settings 32 + type PollingConfig struct { 33 + Interval string `yaml:"interval"` 34 + } 35 + 36 + // NotificationsConfig contains notification type filters 37 + type NotificationsConfig struct { 38 + Types NotificationTypes `yaml:"types"` 39 + } 40 + 41 + // NotificationTypes defines which notification types trigger rotation 42 + type NotificationTypes struct { 43 + Like bool `yaml:"like"` 44 + Repost bool `yaml:"repost"` 45 + Reply bool `yaml:"reply"` 46 + Follow bool `yaml:"follow"` 47 + Mention bool `yaml:"mention"` 48 + Quote bool `yaml:"quote"` 49 + } 50 + 51 + // StateConfig contains state persistence settings 52 + type StateConfig struct { 53 + Storage string `yaml:"storage"` 54 + Path string `yaml:"path"` 55 + } 56 + 57 + // Load reads configuration from a YAML file and applies environment variable overrides 58 + func Load(path string) (*Config, error) { 59 + // Read the config file 60 + data, err := os.ReadFile(path) 61 + if err != nil { 62 + return nil, fmt.Errorf("failed to read config file: %w", err) 63 + } 64 + 65 + // Parse YAML 66 + var cfg Config 67 + if err := yaml.Unmarshal(data, &cfg); err != nil { 68 + return nil, fmt.Errorf("failed to parse config file: %w", err) 69 + } 70 + 71 + // Apply environment variable overrides 72 + if appPassword := os.Getenv("BLUESKY_APP_PASSWORD"); appPassword != "" { 73 + cfg.Bluesky.AppPassword = appPassword 74 + } 75 + 76 + // Validate configuration 77 + if err := cfg.Validate(); err != nil { 78 + return nil, fmt.Errorf("invalid configuration: %w", err) 79 + } 80 + 81 + return &cfg, nil 82 + } 83 + 84 + // Validate checks if the configuration is valid 85 + func (c *Config) Validate() error { 86 + if c.Bluesky.Handle == "" { 87 + return fmt.Errorf("bluesky.handle is required") 88 + } 89 + 90 + if c.Bluesky.AppPassword == "" { 91 + return fmt.Errorf("bluesky.app_password is required (set via config or BLUESKY_APP_PASSWORD env var)") 92 + } 93 + 94 + if c.Rotation.Degrees == 0 { 95 + return fmt.Errorf("rotation.degrees must be non-zero") 96 + } 97 + 98 + // Validate polling interval can be parsed 99 + if _, err := time.ParseDuration(c.Polling.Interval); err != nil { 100 + return fmt.Errorf("invalid polling.interval: %w", err) 101 + } 102 + 103 + if c.State.Storage != "sqlite" && c.State.Storage != "json" { 104 + return fmt.Errorf("state.storage must be 'sqlite' or 'json'") 105 + } 106 + 107 + if c.State.Path == "" { 108 + return fmt.Errorf("state.path is required") 109 + } 110 + 111 + return nil 112 + } 113 + 114 + // GetPollingInterval returns the polling interval as a time.Duration 115 + func (c *Config) GetPollingInterval() time.Duration { 116 + duration, _ := time.ParseDuration(c.Polling.Interval) 117 + return duration 118 + } 119 + 120 + // ShouldProcessNotification checks if a notification type should trigger rotation 121 + func (c *Config) ShouldProcessNotification(notifType string) bool { 122 + switch notifType { 123 + case "like": 124 + return c.Notifications.Types.Like 125 + case "repost": 126 + return c.Notifications.Types.Repost 127 + case "reply": 128 + return c.Notifications.Types.Reply 129 + case "follow": 130 + return c.Notifications.Types.Follow 131 + case "mention": 132 + return c.Notifications.Types.Mention 133 + case "quote": 134 + return c.Notifications.Types.Quote 135 + default: 136 + return false 137 + } 138 + }
+90
internal/image/processor.go
··· 1 + package image 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "image" 7 + "image/jpeg" 8 + "image/png" 9 + "io" 10 + "net/http" 11 + 12 + "github.com/disintegration/imaging" 13 + ) 14 + 15 + // Processor handles image downloading, rotation, and encoding 16 + type Processor struct { 17 + client *http.Client 18 + } 19 + 20 + // NewProcessor creates a new image processor 21 + func NewProcessor() *Processor { 22 + return &Processor{ 23 + client: &http.Client{}, 24 + } 25 + } 26 + 27 + // DownloadImage downloads an image from a URL 28 + func (p *Processor) DownloadImage(url string) ([]byte, error) { 29 + resp, err := p.client.Get(url) 30 + if err != nil { 31 + return nil, fmt.Errorf("failed to download image: %w", err) 32 + } 33 + defer resp.Body.Close() 34 + 35 + if resp.StatusCode != http.StatusOK { 36 + return nil, fmt.Errorf("failed to download image: status %d", resp.StatusCode) 37 + } 38 + 39 + data, err := io.ReadAll(resp.Body) 40 + if err != nil { 41 + return nil, fmt.Errorf("failed to read image data: %w", err) 42 + } 43 + 44 + return data, nil 45 + } 46 + 47 + // RotateImage rotates an image by the specified degrees 48 + func (p *Processor) RotateImage(data []byte, degrees float64) ([]byte, string, error) { 49 + // Decode the image 50 + img, format, err := image.Decode(bytes.NewReader(data)) 51 + if err != nil { 52 + return nil, "", fmt.Errorf("failed to decode image: %w", err) 53 + } 54 + 55 + // Rotate the image 56 + rotated := imaging.Rotate(img, degrees, imaging.NearestNeighbor) 57 + 58 + // Encode the image back to bytes 59 + var buf bytes.Buffer 60 + switch format { 61 + case "jpeg", "jpg": 62 + if err := jpeg.Encode(&buf, rotated, &jpeg.Options{Quality: 95}); err != nil { 63 + return nil, "", fmt.Errorf("failed to encode jpeg: %w", err) 64 + } 65 + case "png": 66 + if err := png.Encode(&buf, rotated); err != nil { 67 + return nil, "", fmt.Errorf("failed to encode png: %w", err) 68 + } 69 + default: 70 + // Default to JPEG if format is unknown 71 + if err := jpeg.Encode(&buf, rotated, &jpeg.Options{Quality: 95}); err != nil { 72 + return nil, "", fmt.Errorf("failed to encode jpeg: %w", err) 73 + } 74 + format = "jpeg" 75 + } 76 + 77 + return buf.Bytes(), format, nil 78 + } 79 + 80 + // GetMimeType returns the MIME type for an image format 81 + func GetMimeType(format string) string { 82 + switch format { 83 + case "jpeg", "jpg": 84 + return "image/jpeg" 85 + case "png": 86 + return "image/png" 87 + default: 88 + return "image/jpeg" 89 + } 90 + }
+188
internal/state/manager.go
··· 1 + package state 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "fmt" 7 + "os" 8 + "path/filepath" 9 + "sync" 10 + 11 + _ "github.com/mattn/go-sqlite3" 12 + ) 13 + 14 + // Manager handles state persistence 15 + type Manager struct { 16 + storage string 17 + path string 18 + db *sql.DB 19 + mu sync.Mutex 20 + } 21 + 22 + // State represents the application state 23 + type State struct { 24 + LastNotificationCursor string `json:"last_notification_cursor"` 25 + LastProcessedAt string `json:"last_processed_at"` 26 + CumulativeRotation float64 `json:"cumulative_rotation"` 27 + } 28 + 29 + // NewManager creates a new state manager 30 + func NewManager(storage, path string) (*Manager, error) { 31 + m := &Manager{ 32 + storage: storage, 33 + path: path, 34 + } 35 + 36 + // Ensure the directory exists 37 + dir := filepath.Dir(path) 38 + if err := os.MkdirAll(dir, 0755); err != nil { 39 + return nil, fmt.Errorf("failed to create state directory: %w", err) 40 + } 41 + 42 + if storage == "sqlite" { 43 + db, err := sql.Open("sqlite3", path) 44 + if err != nil { 45 + return nil, fmt.Errorf("failed to open sqlite database: %w", err) 46 + } 47 + 48 + // Create table if it doesn't exist 49 + _, err = db.Exec(` 50 + CREATE TABLE IF NOT EXISTS state ( 51 + key TEXT PRIMARY KEY, 52 + value TEXT 53 + ) 54 + `) 55 + if err != nil { 56 + db.Close() 57 + return nil, fmt.Errorf("failed to create state table: %w", err) 58 + } 59 + 60 + m.db = db 61 + } 62 + 63 + return m, nil 64 + } 65 + 66 + // Load retrieves the current state 67 + func (m *Manager) Load() (*State, error) { 68 + m.mu.Lock() 69 + defer m.mu.Unlock() 70 + 71 + if m.storage == "sqlite" { 72 + return m.loadFromSQLite() 73 + } 74 + return m.loadFromJSON() 75 + } 76 + 77 + // Save persists the current state 78 + func (m *Manager) Save(state *State) error { 79 + m.mu.Lock() 80 + defer m.mu.Unlock() 81 + 82 + if m.storage == "sqlite" { 83 + return m.saveToSQLite(state) 84 + } 85 + return m.saveToJSON(state) 86 + } 87 + 88 + // Close closes the state manager and releases resources 89 + func (m *Manager) Close() error { 90 + if m.db != nil { 91 + return m.db.Close() 92 + } 93 + return nil 94 + } 95 + 96 + // loadFromSQLite loads state from SQLite database 97 + func (m *Manager) loadFromSQLite() (*State, error) { 98 + state := &State{} 99 + 100 + row := m.db.QueryRow("SELECT value FROM state WHERE key = ?", "last_notification_cursor") 101 + var cursor string 102 + if err := row.Scan(&cursor); err != nil && err != sql.ErrNoRows { 103 + return nil, fmt.Errorf("failed to load cursor: %w", err) 104 + } 105 + state.LastNotificationCursor = cursor 106 + 107 + row = m.db.QueryRow("SELECT value FROM state WHERE key = ?", "last_processed_at") 108 + var processedAt string 109 + if err := row.Scan(&processedAt); err != nil && err != sql.ErrNoRows { 110 + return nil, fmt.Errorf("failed to load processed_at: %w", err) 111 + } 112 + state.LastProcessedAt = processedAt 113 + 114 + row = m.db.QueryRow("SELECT value FROM state WHERE key = ?", "cumulative_rotation") 115 + var rotation string 116 + if err := row.Scan(&rotation); err != nil && err != sql.ErrNoRows { 117 + return nil, fmt.Errorf("failed to load cumulative_rotation: %w", err) 118 + } 119 + if rotation != "" { 120 + fmt.Sscanf(rotation, "%f", &state.CumulativeRotation) 121 + } 122 + 123 + return state, nil 124 + } 125 + 126 + // saveToSQLite saves state to SQLite database 127 + func (m *Manager) saveToSQLite(state *State) error { 128 + tx, err := m.db.Begin() 129 + if err != nil { 130 + return fmt.Errorf("failed to begin transaction: %w", err) 131 + } 132 + defer tx.Rollback() 133 + 134 + _, err = tx.Exec(` 135 + INSERT OR REPLACE INTO state (key, value) VALUES (?, ?) 136 + `, "last_notification_cursor", state.LastNotificationCursor) 137 + if err != nil { 138 + return fmt.Errorf("failed to save cursor: %w", err) 139 + } 140 + 141 + _, err = tx.Exec(` 142 + INSERT OR REPLACE INTO state (key, value) VALUES (?, ?) 143 + `, "last_processed_at", state.LastProcessedAt) 144 + if err != nil { 145 + return fmt.Errorf("failed to save processed_at: %w", err) 146 + } 147 + 148 + _, err = tx.Exec(` 149 + INSERT OR REPLACE INTO state (key, value) VALUES (?, ?) 150 + `, "cumulative_rotation", fmt.Sprintf("%f", state.CumulativeRotation)) 151 + if err != nil { 152 + return fmt.Errorf("failed to save cumulative_rotation: %w", err) 153 + } 154 + 155 + return tx.Commit() 156 + } 157 + 158 + // loadFromJSON loads state from JSON file 159 + func (m *Manager) loadFromJSON() (*State, error) { 160 + data, err := os.ReadFile(m.path) 161 + if err != nil { 162 + if os.IsNotExist(err) { 163 + return &State{}, nil 164 + } 165 + return nil, fmt.Errorf("failed to read state file: %w", err) 166 + } 167 + 168 + var state State 169 + if err := json.Unmarshal(data, &state); err != nil { 170 + return nil, fmt.Errorf("failed to parse state file: %w", err) 171 + } 172 + 173 + return &state, nil 174 + } 175 + 176 + // saveToJSON saves state to JSON file 177 + func (m *Manager) saveToJSON(state *State) error { 178 + data, err := json.MarshalIndent(state, "", " ") 179 + if err != nil { 180 + return fmt.Errorf("failed to marshal state: %w", err) 181 + } 182 + 183 + if err := os.WriteFile(m.path, data, 0644); err != nil { 184 + return fmt.Errorf("failed to write state file: %w", err) 185 + } 186 + 187 + return nil 188 + }