the home of serif.blue
5
fork

Configure Feed

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

feat: add script

+1225
+1225
bsky-profile-updates.sh
··· 1 + #!/usr/bin/env bash 2 + 3 + # Dynamic Profile Picture Updater 4 + # Automatically updates your profile pictures across platforms based on time and weather 5 + # Usage: ./auto_pfp.sh [options] 6 + 7 + set -euo pipefail 8 + 9 + # Default configuration 10 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 11 + CONFIG_FILE="${SCRIPT_DIR}/config.json" 12 + IMAGES_DIR="${SCRIPT_DIR}/rendered_timelines" 13 + LOG_FILE="${SCRIPT_DIR}/auto_pfp.log" 14 + SESSION_FILE="${SCRIPT_DIR}/.bluesky_session" 15 + DEFAULT_TIMELINE="sunny" 16 + 17 + # Colors for output 18 + RED='\033[0;31m' 19 + GREEN='\033[0;32m' 20 + YELLOW='\033[1;33m' 21 + BLUE='\033[0;34m' 22 + NC='\033[0m' # No Color 23 + 24 + # Logging function 25 + log() { 26 + local level="$1" 27 + shift 28 + local message="$*" 29 + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') 30 + echo -e "${timestamp} - ${level} - ${message}" | tee -a "$LOG_FILE" >&2 31 + } 32 + 33 + log_info() { log "${BLUE}[INFO]${NC}" "$@"; } 34 + log_success() { log "${GREEN}[SUCCESS]${NC}" "$@"; } 35 + log_warning() { log "${YELLOW}[WARNING]${NC}" "$@"; } 36 + log_error() { log "${RED}[ERROR]${NC}" "$@"; } 37 + 38 + # Check dependencies 39 + check_dependencies() { 40 + local missing_deps=() 41 + 42 + if ! command -v curl &> /dev/null; then 43 + missing_deps+=("curl") 44 + fi 45 + 46 + if ! command -v jq &> /dev/null; then 47 + missing_deps+=("jq") 48 + fi 49 + 50 + if ! command -v sha256sum &> /dev/null; then 51 + missing_deps+=("sha256sum") 52 + fi 53 + 54 + if [ ${#missing_deps[@]} -ne 0 ]; then 55 + log_error "Missing required dependencies:" 56 + for dep in "${missing_deps[@]}"; do 57 + echo " - $dep" 58 + done 59 + exit 1 60 + fi 61 + } 62 + 63 + # Create default config file 64 + create_default_config() { 65 + cat > "$CONFIG_FILE" << 'EOF' 66 + { 67 + "platforms": { 68 + "bluesky": { 69 + "enabled": true, 70 + "handle": "your-handle.bsky.social", 71 + "password": "your-app-password" 72 + }, 73 + "slack": { 74 + "enabled": false, 75 + "user_token": "" 76 + } 77 + }, 78 + "weather": { 79 + "enabled": false, 80 + "api_key": "", 81 + "location": "auto", 82 + "timeline_mapping": { 83 + "clear": "sunny", 84 + "clouds": "cloudy", 85 + "rain": "rainy", 86 + "drizzle": "rainy", 87 + "thunderstorm": "stormy", 88 + "snow": "snowy", 89 + "mist": "cloudy", 90 + "fog": "cloudy" 91 + } 92 + }, 93 + "settings": { 94 + "default_timeline": "sunny", 95 + "images_dir": "./rendered_timelines" 96 + } 97 + } 98 + EOF 99 + log_info "Created default config file: $CONFIG_FILE" 100 + log_warning "Please edit the config file with your platform credentials" 101 + } 102 + 103 + # Load configuration 104 + load_config() { 105 + if [ ! -f "$CONFIG_FILE" ]; then 106 + log_warning "Config file not found, creating default..." 107 + create_default_config 108 + exit 1 109 + fi 110 + 111 + # Read platform settings 112 + BLUESKY_ENABLED=$(jq -r '.platforms.bluesky.enabled' "$CONFIG_FILE") 113 + BLUESKY_HANDLE=$(jq -r '.platforms.bluesky.handle' "$CONFIG_FILE") 114 + BLUESKY_PASSWORD=$(jq -r '.platforms.bluesky.password' "$CONFIG_FILE") 115 + SLACK_ENABLED=$(jq -r '.platforms.slack.enabled' "$CONFIG_FILE") 116 + SLACK_USER_TOKEN=$(jq -r '.platforms.slack.user_token' "$CONFIG_FILE") 117 + 118 + # Read weather settings 119 + WEATHER_ENABLED=$(jq -r '.weather.enabled' "$CONFIG_FILE") 120 + WEATHER_API_KEY=$(jq -r '.weather.api_key' "$CONFIG_FILE") 121 + WEATHER_LOCATION=$(jq -r '.weather.location' "$CONFIG_FILE") 122 + 123 + # Read general settings 124 + DEFAULT_TIMELINE=$(jq -r '.settings.default_timeline' "$CONFIG_FILE") 125 + IMAGES_DIR=$(jq -r '.settings.images_dir' "$CONFIG_FILE") 126 + 127 + # Validate at least one platform is enabled and configured 128 + local enabled_platforms=() 129 + 130 + # Check Bluesky 131 + if [ "$BLUESKY_ENABLED" = "true" ]; then 132 + if [ "$BLUESKY_HANDLE" = "your-handle.bsky.social" ] || [ "$BLUESKY_HANDLE" = "null" ] || [ -z "$BLUESKY_HANDLE" ]; then 133 + log_warning "Bluesky enabled but handle not configured - will be skipped" 134 + BLUESKY_ENABLED="false" 135 + elif [ "$BLUESKY_PASSWORD" = "your-app-password" ] || [ "$BLUESKY_PASSWORD" = "null" ] || [ -z "$BLUESKY_PASSWORD" ]; then 136 + log_warning "Bluesky enabled but password not configured - will be skipped" 137 + BLUESKY_ENABLED="false" 138 + else 139 + enabled_platforms+=("Bluesky") 140 + fi 141 + fi 142 + 143 + # Check Slack 144 + if [ "$SLACK_ENABLED" = "true" ]; then 145 + if [ -z "$SLACK_USER_TOKEN" ] || [ "$SLACK_USER_TOKEN" = "null" ]; then 146 + log_warning "Slack enabled but no user token provided - will be skipped" 147 + SLACK_ENABLED="false" 148 + else 149 + enabled_platforms+=("Slack") 150 + fi 151 + fi 152 + 153 + # Ensure at least one platform is enabled 154 + if [ ${#enabled_platforms[@]} -eq 0 ]; then 155 + log_error "No platforms are properly configured. Please check your config file." 156 + exit 1 157 + fi 158 + 159 + # Convert relative paths to absolute 160 + if [[ ! "$IMAGES_DIR" =~ ^/ ]]; then 161 + IMAGES_DIR="${SCRIPT_DIR}/${IMAGES_DIR}" 162 + fi 163 + 164 + log_info "Loaded configuration with enabled platforms: ${enabled_platforms[*]}" 165 + } 166 + 167 + # Authenticate with Bluesky 168 + authenticate_bluesky() { 169 + if [ "$BLUESKY_ENABLED" != "true" ]; then 170 + return 1 171 + fi 172 + 173 + log_info "Authenticating with Bluesky..." 174 + 175 + local auth_response 176 + auth_response=$(curl -s -X POST \ 177 + "https://bsky.social/xrpc/com.atproto.server.createSession" \ 178 + -H "Content-Type: application/json" \ 179 + -d "{\"identifier\":\"$BLUESKY_HANDLE\",\"password\":\"$BLUESKY_PASSWORD\"}") 180 + 181 + if echo "$auth_response" | jq -e '.accessJwt' > /dev/null 2>&1; then 182 + echo "$auth_response" > "$SESSION_FILE" 183 + log_success "Successfully authenticated with Bluesky" 184 + return 0 185 + else 186 + log_error "Bluesky authentication failed: $(echo "$auth_response" | jq -r '.message // "Unknown error"')" 187 + return 1 188 + fi 189 + } 190 + 191 + # Get session token 192 + get_session_token() { 193 + if [ ! -f "$SESSION_FILE" ]; then 194 + return 1 195 + fi 196 + 197 + # Check if session is still valid (sessions typically last 24 hours) 198 + local session_age=$(($(date +%s) - $(stat -c %Y "$SESSION_FILE" 2>/dev/null || echo 0))) 199 + if [ $session_age -gt 86400 ]; then # 24 hours 200 + log_info "Session expired, re-authenticating..." 201 + rm -f "$SESSION_FILE" 202 + return 1 203 + fi 204 + 205 + jq -r '.accessJwt' "$SESSION_FILE" 2>/dev/null || return 1 206 + } 207 + 208 + # Calculate SHA256 hash of image file 209 + calculate_image_hash() { 210 + local image_path="$1" 211 + if [ ! -f "$image_path" ]; then 212 + return 1 213 + fi 214 + sha256sum "$image_path" | cut -d' ' -f1 215 + } 216 + 217 + # Get blob reference from ATProto record 218 + get_cached_blob() { 219 + local weather_type="$1" 220 + local hour="$2" 221 + local image_hash="$3" 222 + local token="$4" 223 + local did 224 + 225 + did=$(jq -r '.did' "$SESSION_FILE") 226 + local rkey="${weather_type}_hour_${hour}" 227 + 228 + # Validate DID 229 + if [ -z "$did" ] || [ "$did" = "null" ]; then 230 + log_error "Could not get DID from session file" 231 + return 1 232 + fi 233 + 234 + log_info "Checking for cached blob: $weather_type hour $hour (DID: ${did:0:20}...)" 235 + 236 + # Try to get existing record 237 + local record_response 238 + record_response=$(curl -s \ 239 + "https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=$did&collection=pfp.updates.${weather_type}&rkey=$rkey" \ 240 + -H "Authorization: Bearer $token") 241 + 242 + if echo "$record_response" | jq -e '.value' > /dev/null 2>&1; then 243 + local stored_hash=$(echo "$record_response" | jq -r '.value.imageHash // empty') 244 + local stored_blob=$(echo "$record_response" | jq -c '.value.blobRef // empty') 245 + 246 + if [ "$stored_hash" = "$image_hash" ] && [ -n "$stored_blob" ] && [ "$stored_blob" != "empty" ]; then 247 + log_success "Found cached blob with matching hash" 248 + echo "$stored_blob" 249 + return 0 250 + else 251 + log_info "Cached blob found but hash mismatch or missing blob reference" 252 + return 1 253 + fi 254 + else 255 + log_info "No cached blob record found" 256 + return 1 257 + fi 258 + } 259 + 260 + # Store blob reference in ATProto record 261 + store_blob_reference() { 262 + local weather_type="$1" 263 + local hour="$2" 264 + local image_hash="$3" 265 + local blob_ref="$4" 266 + local token="$5" 267 + local did 268 + 269 + did=$(jq -r '.did' "$SESSION_FILE") 270 + local rkey="${weather_type}_hour_${hour}" 271 + 272 + # Validate DID 273 + if [ -z "$did" ] || [ "$did" = "null" ]; then 274 + log_error "Could not get DID from session file" 275 + return 1 276 + fi 277 + 278 + log_info "Storing blob reference for $weather_type hour $hour (DID: ${did:0:20}...)" 279 + 280 + # Create record data 281 + local record_data 282 + record_data=$(jq -n \ 283 + --arg type_field "pfp.updates.${weather_type}" \ 284 + --arg hash "$image_hash" \ 285 + --argjson blob "$blob_ref" \ 286 + --arg weather "$weather_type" \ 287 + --arg hour "$hour" \ 288 + --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)" \ 289 + '{ 290 + "$type": $type_field, 291 + "timeline": $weather, 292 + "hour": $hour, 293 + "imageHash": $hash, 294 + "blobRef": $blob, 295 + "createdAt": $timestamp 296 + }') 297 + 298 + log_info "Record data: $record_data" 299 + 300 + # Create the request payload using direct JSON construction 301 + local request_payload 302 + request_payload=$(jq -n \ 303 + --arg repo "$did" \ 304 + --arg collection "pfp.updates.${weather_type}" \ 305 + --arg rkey "$rkey" \ 306 + --argjson record "$record_data" \ 307 + '{ 308 + "repo": $repo, 309 + "collection": $collection, 310 + "rkey": $rkey, 311 + "record": $record 312 + }') 313 + 314 + log_info "Request payload preview: $(echo "$request_payload" | jq -c '.' | head -c 200)..." 315 + 316 + # Validate the payload has required fields 317 + if ! echo "$request_payload" | jq -e '.repo' > /dev/null 2>&1; then 318 + log_error "Request payload missing 'repo' field" 319 + log_error "DID value: '$did'" 320 + log_error "Full payload: $request_payload" 321 + return 1 322 + fi 323 + 324 + # In dry run mode, don't actually store 325 + if [ "${DRY_RUN:-false}" = "true" ]; then 326 + log_info "DRY RUN MODE - Would store blob reference" 327 + return 0 328 + fi 329 + 330 + # Store the record 331 + local store_response 332 + store_response=$(curl -s -X POST \ 333 + "https://bsky.social/xrpc/com.atproto.repo.putRecord" \ 334 + -H "Authorization: Bearer $token" \ 335 + -H "Content-Type: application/json" \ 336 + -d "$request_payload") 337 + 338 + if echo "$store_response" | jq -e '.uri' > /dev/null 2>&1; then 339 + log_success "Successfully stored blob reference" 340 + return 0 341 + else 342 + log_error "Failed to store blob reference: $(echo "$store_response" | jq -r '.message // "Unknown error"')" 343 + log_error "Full response: $store_response" 344 + return 1 345 + fi 346 + } 347 + 348 + # Upload image as blob 349 + upload_blob() { 350 + local image_path="$1" 351 + local token="$2" 352 + 353 + if [ ! -f "$image_path" ]; then 354 + log_error "Image file not found: $image_path" 355 + return 1 356 + fi 357 + 358 + # Check image file size (should be reasonable) 359 + local file_size=$(stat -c%s "$image_path" 2>/dev/null || echo 0) 360 + if [ "$file_size" -lt 1000 ]; then 361 + log_error "Image file too small ($file_size bytes): $image_path" 362 + return 1 363 + fi 364 + 365 + if [ "$file_size" -gt 10000000 ]; then # 10MB limit 366 + log_error "Image file too large ($file_size bytes): $image_path" 367 + return 1 368 + fi 369 + 370 + log_info "Uploading image: $(basename "$image_path") ($(numfmt --to=iec "$file_size"))" 371 + 372 + local upload_response 373 + upload_response=$(curl -s -X POST \ 374 + "https://bsky.social/xrpc/com.atproto.repo.uploadBlob" \ 375 + -H "Authorization: Bearer $token" \ 376 + -H "Content-Type: image/jpeg" \ 377 + --data-binary "@$image_path") 378 + 379 + if echo "$upload_response" | jq -e '.blob' > /dev/null 2>&1; then 380 + local blob_result 381 + blob_result=$(echo "$upload_response" | jq -c '.blob') 382 + log_success "Successfully uploaded image" 383 + log_info "Blob data: $blob_result" 384 + echo "$blob_result" 385 + return 0 386 + else 387 + local error_type=$(echo "$upload_response" | jq -r '.error // "Unknown"') 388 + local error_msg=$(echo "$upload_response" | jq -r '.message // "Unknown error"') 389 + 390 + if [ "$error_type" = "ExpiredToken" ] || echo "$error_msg" | grep -qi "expired"; then 391 + log_error "Token has expired - session needs refresh" 392 + # Remove the expired session file so it will be regenerated 393 + rm -f "$SESSION_FILE" 394 + return 2 # Special return code for expired token 395 + else 396 + log_error "Failed to upload image: $error_msg" 397 + log_error "Full response: $upload_response" 398 + return 1 399 + fi 400 + fi 401 + } 402 + 403 + # Get or upload blob with caching 404 + get_or_upload_blob() { 405 + local image_path="$1" 406 + local weather_type="$2" 407 + local hour="$3" 408 + local token="$4" 409 + 410 + if [ ! -f "$image_path" ]; then 411 + log_error "Image file not found: $image_path" 412 + return 1 413 + fi 414 + 415 + # Calculate image hash 416 + local image_hash 417 + image_hash=$(calculate_image_hash "$image_path") 418 + if [ -z "$image_hash" ]; then 419 + log_error "Failed to calculate image hash" 420 + return 1 421 + fi 422 + 423 + log_info "Image hash: $image_hash" 424 + 425 + # Try to get cached blob 426 + local cached_blob 427 + if cached_blob=$(get_cached_blob "$weather_type" "$hour" "$image_hash" "$token"); then 428 + log_success "Using cached blob reference" 429 + echo "$cached_blob" 430 + return 0 431 + fi 432 + 433 + # No cache hit, upload the blob 434 + log_info "No valid cache found, uploading new blob..." 435 + local new_blob 436 + new_blob=$(upload_blob "$image_path" "$token") 437 + 438 + if [ -n "$new_blob" ]; then 439 + # Store the blob reference for future use 440 + if store_blob_reference "$weather_type" "$hour" "$image_hash" "$new_blob" "$token"; then 441 + log_success "Blob uploaded and cached successfully" 442 + else 443 + log_warning "Blob uploaded but failed to cache reference" 444 + fi 445 + echo "$new_blob" 446 + return 0 447 + else 448 + log_error "Failed to upload blob" 449 + return 1 450 + fi 451 + } 452 + 453 + # List all cached blobs 454 + list_cached_blobs() { 455 + local token="$1" 456 + local did 457 + 458 + did=$(jq -r '.did' "$SESSION_FILE") 459 + 460 + log_info "Listing cached blob records..." 461 + 462 + # Get list of available timelines to check each collection 463 + local timelines=() 464 + if [ -d "$IMAGES_DIR" ]; then 465 + for timeline_dir in "$IMAGES_DIR"/*; do 466 + if [ -d "$timeline_dir" ]; then 467 + timelines+=($(basename "$timeline_dir")) 468 + fi 469 + done 470 + fi 471 + 472 + local total_records=0 473 + echo "Cached blob records:" 474 + 475 + for timeline in "${timelines[@]}"; do 476 + # List records in each timeline collection 477 + local list_response 478 + list_response=$(curl -s \ 479 + "https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=$did&collection=pfp.updates.${timeline}" \ 480 + -H "Authorization: Bearer $token" 2>/dev/null) 481 + 482 + if echo "$list_response" | jq -e '.records' > /dev/null 2>&1; then 483 + local timeline_count=$(echo "$list_response" | jq '.records | length') 484 + if [ "$timeline_count" -gt 0 ]; then 485 + echo " $timeline timeline:" 486 + echo "$list_response" | jq -r '.records[] | " hour \(.value.hour) - Hash: \(.value.imageHash[0:12])... (Created: \(.value.createdAt))"' 487 + total_records=$((total_records + timeline_count)) 488 + fi 489 + fi 490 + done 491 + 492 + echo "Total cached records: $total_records" 493 + } 494 + 495 + # Clean up old cached blobs (optional maintenance function) 496 + cleanup_cached_blobs() { 497 + local token="$1" 498 + local days_to_keep="${2:-30}" # Keep records for 30 days by default 499 + local did 500 + 501 + did=$(jq -r '.did' "$SESSION_FILE") 502 + 503 + log_info "Cleaning up cached blobs older than $days_to_keep days..." 504 + 505 + # Get current timestamp minus retention period 506 + local cutoff_date 507 + cutoff_date=$(date -u -d "$days_to_keep days ago" +%Y-%m-%dT%H:%M:%S.%3NZ) 508 + 509 + # Get list of available timelines to check each collection 510 + local timelines=() 511 + if [ -d "$IMAGES_DIR" ]; then 512 + for timeline_dir in "$IMAGES_DIR"/*; do 513 + if [ -d "$timeline_dir" ]; then 514 + timelines+=($(basename "$timeline_dir")) 515 + fi 516 + done 517 + fi 518 + 519 + local deleted_count=0 520 + 521 + for timeline in "${timelines[@]}"; do 522 + # List all records in this timeline collection 523 + local list_response 524 + list_response=$(curl -s \ 525 + "https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=$did&collection=pfp.updates.${timeline}" \ 526 + -H "Authorization: Bearer $token" 2>/dev/null) 527 + 528 + if echo "$list_response" | jq -e '.records' > /dev/null 2>&1; then 529 + # Find records older than cutoff 530 + local old_records 531 + old_records=$(echo "$list_response" | jq --arg cutoff "$cutoff_date" '.records[] | select(.value.createdAt < $cutoff)') 532 + 533 + if [ -n "$old_records" ]; then 534 + echo "$old_records" | jq -r '.uri' | while read -r record_uri; do 535 + local rkey=$(echo "$record_uri" | sed 's/.*\///') 536 + log_info "Deleting old record: $timeline/$rkey" 537 + 538 + curl -s -X POST \ 539 + "https://bsky.social/xrpc/com.atproto.repo.deleteRecord" \ 540 + -H "Authorization: Bearer $token" \ 541 + -H "Content-Type: application/json" \ 542 + -d "{\"repo\":\"$did\",\"collection\":\"pfp.updates.${timeline}\",\"rkey\":\"$rkey\"}" > /dev/null 543 + 544 + deleted_count=$((deleted_count + 1)) 545 + done 546 + fi 547 + fi 548 + done 549 + 550 + if [ "$deleted_count" -eq 0 ]; then 551 + log_info "No old records found to clean up" 552 + else 553 + log_success "Deleted $deleted_count old records" 554 + fi 555 + } 556 + 557 + # Update profile picture 558 + update_profile_picture() { 559 + local blob_ref="$1" 560 + local token="$2" 561 + local did 562 + 563 + # Validate blob reference 564 + if [ -z "$blob_ref" ] || [ "$blob_ref" = "null" ]; then 565 + log_error "Invalid blob reference provided" 566 + return 1 567 + fi 568 + 569 + # Validate blob reference format 570 + if ! echo "$blob_ref" | jq -e '.ref' > /dev/null 2>&1; then 571 + log_error "Blob reference missing required 'ref' field: $blob_ref" 572 + return 1 573 + fi 574 + 575 + # Get DID from session 576 + did=$(jq -r '.did' "$SESSION_FILE") 577 + 578 + log_info "Updating profile picture..." 579 + log_info "Using blob: $blob_ref" 580 + 581 + # Get current profile 582 + local current_profile 583 + current_profile=$(curl -s \ 584 + "https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=$did&collection=app.bsky.actor.profile&rkey=self" \ 585 + -H "Authorization: Bearer $token") 586 + 587 + log_info "Current profile response: $current_profile" 588 + 589 + local profile_data 590 + if echo "$current_profile" | jq -e '.value' > /dev/null 2>&1; then 591 + # Update existing profile - PRESERVE ALL EXISTING FIELDS 592 + profile_data=$(echo "$current_profile" | jq --argjson avatar "$blob_ref" '.value | .avatar = $avatar') 593 + log_info "Updating existing profile (preserving existing fields)" 594 + else 595 + log_error "No existing profile found - cannot safely create new profile" 596 + log_error "Please manually restore your profile in the Bluesky app first" 597 + return 1 598 + fi 599 + 600 + log_info "Profile data to send: $profile_data" 601 + 602 + # Validate profile data before sending 603 + if ! echo "$profile_data" | jq -e '.avatar' > /dev/null 2>&1; then 604 + log_error "Generated profile data is invalid" 605 + return 1 606 + fi 607 + 608 + # Double-check we're preserving important fields 609 + local display_name=$(echo "$profile_data" | jq -r '.displayName // empty') 610 + local description=$(echo "$profile_data" | jq -r '.description // empty') 611 + 612 + if [ -n "$display_name" ]; then 613 + log_info "Preserving display name: $display_name" 614 + fi 615 + 616 + if [ -n "$description" ]; then 617 + log_info "Preserving description: $(echo "$description" | head -c 50)..." 618 + fi 619 + 620 + # Create the request payload 621 + local request_payload 622 + request_payload=$(jq -n \ 623 + --arg repo "$did" \ 624 + --arg collection "app.bsky.actor.profile" \ 625 + --arg rkey "self" \ 626 + --argjson record "$profile_data" \ 627 + '{repo: $repo, collection: $collection, rkey: $rkey, record: $record}') 628 + 629 + log_info "Request payload: $request_payload" 630 + 631 + # In dry run mode, don't actually update 632 + if [ "${DRY_RUN:-false}" = "true" ]; then 633 + log_info "DRY RUN MODE - Would send profile update with avatar" 634 + log_info "DRY RUN MODE - Profile fields would be preserved" 635 + return 0 636 + fi 637 + 638 + # Update profile 639 + local update_response 640 + update_response=$(curl -s -X POST \ 641 + "https://bsky.social/xrpc/com.atproto.repo.putRecord" \ 642 + -H "Authorization: Bearer $token" \ 643 + -H "Content-Type: application/json" \ 644 + -d "$request_payload") 645 + 646 + log_info "Update response: $update_response" 647 + 648 + if echo "$update_response" | jq -e '.uri' > /dev/null 2>&1; then 649 + log_success "Successfully updated profile picture" 650 + return 0 651 + else 652 + log_error "Failed to update profile picture: $(echo "$update_response" | jq -r '.message // "Unknown error"')" 653 + log_error "Full error response: $update_response" 654 + return 1 655 + fi 656 + } 657 + 658 + # Update Slack profile picture 659 + update_slack_profile_picture() { 660 + local image_path="$1" 661 + 662 + if [ "$SLACK_ENABLED" != "true" ] || [ -z "$SLACK_USER_TOKEN" ] || [ "$SLACK_USER_TOKEN" = "null" ]; then 663 + log_info "Slack integration disabled or not configured" 664 + return 0 665 + fi 666 + 667 + if [ ! -f "$image_path" ]; then 668 + log_error "Image file not found for Slack: $image_path" 669 + return 1 670 + fi 671 + 672 + log_info "Updating Slack profile picture..." 673 + 674 + # First, upload the image to Slack 675 + local upload_response 676 + upload_response=$(curl -s -X POST \ 677 + "https://slack.com/api/users.setPhoto" \ 678 + -H "Authorization: Bearer $SLACK_USER_TOKEN" \ 679 + -F "image=@$image_path") 680 + 681 + if echo "$upload_response" | jq -e '.ok' > /dev/null 2>&1; then 682 + local ok_status=$(echo "$upload_response" | jq -r '.ok') 683 + if [ "$ok_status" = "true" ]; then 684 + log_success "Successfully updated Slack profile picture" 685 + return 0 686 + else 687 + local error_msg=$(echo "$upload_response" | jq -r '.error // "Unknown error"') 688 + log_error "Failed to update Slack profile picture: $error_msg" 689 + 690 + # Handle common errors 691 + case "$error_msg" in 692 + "invalid_auth") 693 + log_error "Invalid Slack token - please check your user token" 694 + ;; 695 + "not_authed") 696 + log_error "Authentication failed - token may be expired" 697 + ;; 698 + "missing_scope") 699 + log_error "Token missing required scope - needs 'users.profile:write'" 700 + ;; 701 + "too_large") 702 + log_error "Image file too large for Slack" 703 + ;; 704 + esac 705 + return 1 706 + fi 707 + else 708 + log_error "Invalid response from Slack API: $upload_response" 709 + return 1 710 + fi 711 + } 712 + 713 + # Get weather-based timeline 714 + get_weather_timeline() { 715 + if [ "$WEATHER_ENABLED" != "true" ] || [ -z "$WEATHER_API_KEY" ] || [ "$WEATHER_API_KEY" = "null" ]; then 716 + log_info "Weather integration disabled, using default timeline: $DEFAULT_TIMELINE" 717 + echo "$DEFAULT_TIMELINE" 718 + return 0 719 + fi 720 + 721 + log_info "Fetching weather data..." 722 + 723 + local lat lon 724 + 725 + if [ "$WEATHER_LOCATION" = "auto" ]; then 726 + # Auto-detect location from IP 727 + local ip_data 728 + ip_data=$(curl -s "http://ip-api.com/json/" --connect-timeout 10) 729 + 730 + if echo "$ip_data" | jq -e '.lat' > /dev/null 2>&1; then 731 + lat=$(echo "$ip_data" | jq -r '.lat') 732 + lon=$(echo "$ip_data" | jq -r '.lon') 733 + local city=$(echo "$ip_data" | jq -r '.city') 734 + local country=$(echo "$ip_data" | jq -r '.country') 735 + log_info "Auto-detected location: $city, $country" 736 + else 737 + log_warning "Could not auto-detect location, using default timeline" 738 + echo "$DEFAULT_TIMELINE" 739 + return 0 740 + fi 741 + else 742 + # Use provided location (assume it's "lat,lon" or city name) 743 + if [[ "$WEATHER_LOCATION" =~ ^-?[0-9]+\.?[0-9]*,-?[0-9]+\.?[0-9]*$ ]]; then 744 + # It's coordinates 745 + lat=$(echo "$WEATHER_LOCATION" | cut -d',' -f1) 746 + lon=$(echo "$WEATHER_LOCATION" | cut -d',' -f2) 747 + else 748 + # It's a city name, geocode it 749 + local geocode_response 750 + geocode_response=$(curl -s "http://api.openweathermap.org/geo/1.0/direct?q=$WEATHER_LOCATION&limit=1&appid=$WEATHER_API_KEY") 751 + 752 + if echo "$geocode_response" | jq -e '.[0].lat' > /dev/null 2>&1; then 753 + lat=$(echo "$geocode_response" | jq -r '.[0].lat') 754 + lon=$(echo "$geocode_response" | jq -r '.[0].lon') 755 + log_info "Geocoded location: $WEATHER_LOCATION" 756 + else 757 + log_warning "Could not geocode location: $WEATHER_LOCATION" 758 + echo "$DEFAULT_TIMELINE" 759 + return 0 760 + fi 761 + fi 762 + fi 763 + 764 + # Get current weather 765 + local weather_response 766 + weather_response=$(curl -s "http://api.openweathermap.org/data/2.5/weather?lat=$lat&lon=$lon&appid=$WEATHER_API_KEY" --connect-timeout 10) 767 + 768 + if echo "$weather_response" | jq -e '.weather[0].main' > /dev/null 2>&1; then 769 + local weather_main=$(echo "$weather_response" | jq -r '.weather[0].main' | tr '[:upper:]' '[:lower:]') 770 + local weather_desc=$(echo "$weather_response" | jq -r '.weather[0].description') 771 + log_info "Current weather: $weather_desc" 772 + 773 + # Map weather to timeline 774 + local timeline 775 + timeline=$(jq -r ".weather.timeline_mapping.\"$weather_main\" // \"$DEFAULT_TIMELINE\"" "$CONFIG_FILE") 776 + 777 + # Check if the mapped timeline exists 778 + if [ ! -d "$IMAGES_DIR/$timeline" ]; then 779 + log_warning "Timeline '$timeline' not found, falling back to default: $DEFAULT_TIMELINE" 780 + echo "$DEFAULT_TIMELINE" 781 + else 782 + log_info "Weather mapped to timeline: $timeline" 783 + echo "$timeline" 784 + fi 785 + else 786 + log_warning "Could not fetch weather data, using default timeline" 787 + echo "$DEFAULT_TIMELINE" 788 + fi 789 + } 790 + 791 + # Get current hour image path 792 + get_hour_image_path() { 793 + local timeline="$1" 794 + local hour=$(date +%H) 795 + # Remove leading zero to avoid octal interpretation, then pad with zero 796 + local hour_decimal=$((10#$hour)) # Force decimal interpretation 797 + local hour_padded=$(printf "%02d" "$hour_decimal") 798 + local image_path="$IMAGES_DIR/$timeline/hour_${hour_padded}.jpg" 799 + 800 + if [ -f "$image_path" ]; then 801 + echo "$image_path" 802 + return 0 803 + else 804 + log_warning "Image not found: $image_path" >&2 805 + 806 + # Try fallback to default timeline 807 + if [ "$timeline" != "$DEFAULT_TIMELINE" ]; then 808 + local fallback_path="$IMAGES_DIR/$DEFAULT_TIMELINE/hour_${hour_padded}.jpg" 809 + if [ -f "$fallback_path" ]; then 810 + log_info "Using fallback image: $fallback_path" >&2 811 + echo "$fallback_path" 812 + return 0 813 + fi 814 + fi 815 + 816 + return 1 817 + fi 818 + } 819 + 820 + # List available timelines 821 + list_timelines() { 822 + if [ ! -d "$IMAGES_DIR" ]; then 823 + log_error "Images directory not found: $IMAGES_DIR" 824 + return 1 825 + fi 826 + 827 + echo "Available timelines:" 828 + for timeline_dir in "$IMAGES_DIR"/*; do 829 + if [ -d "$timeline_dir" ]; then 830 + local timeline_name=$(basename "$timeline_dir") 831 + local image_count=$(find "$timeline_dir" -name "hour_*.jpg" | wc -l) 832 + echo " - $timeline_name ($image_count images)" 833 + fi 834 + done 835 + } 836 + 837 + # Test mode - show what would be used 838 + test_mode() { 839 + local timeline 840 + timeline=$(get_weather_timeline) 841 + 842 + local image_path 843 + image_path=$(get_hour_image_path "$timeline") 844 + 845 + local hour=$(date +%H) 846 + 847 + echo "=== Test Mode ===" 848 + echo "Current time: $(date)" 849 + echo "Current hour: $hour" 850 + echo "Weather enabled: $WEATHER_ENABLED" 851 + echo "Selected timeline: $timeline" 852 + echo "Image path: $image_path" 853 + 854 + if [ -f "$image_path" ]; then 855 + echo "✓ Image exists" 856 + echo "Image size: $(du -h "$image_path" | cut -f1)" 857 + 858 + # Show hash info in test mode 859 + local test_hash=$(calculate_image_hash "$image_path") 860 + echo "Image hash: $test_hash" 861 + else 862 + echo "✗ Image not found" 863 + 864 + # Show available alternatives 865 + echo "" 866 + echo "Available timelines:" 867 + list_timelines 868 + return 1 869 + fi 870 + 871 + # Show weather info if enabled 872 + if [ "$WEATHER_ENABLED" = "true" ] && [ -n "$WEATHER_API_KEY" ] && [ "$WEATHER_API_KEY" != "null" ]; then 873 + echo "" 874 + echo "Weather integration: enabled" 875 + echo "Location setting: $WEATHER_LOCATION" 876 + else 877 + echo "" 878 + echo "Weather integration: disabled (using default timeline)" 879 + fi 880 + 881 + # Show platform info 882 + echo "" 883 + echo "Enabled platforms:" 884 + if [ "$BLUESKY_ENABLED" = "true" ]; then 885 + echo " ✓ Bluesky ($BLUESKY_HANDLE)" 886 + else 887 + echo " ✗ Bluesky (disabled or not configured)" 888 + fi 889 + 890 + if [ "$SLACK_ENABLED" = "true" ]; then 891 + echo " ✓ Slack" 892 + else 893 + echo " ✗ Slack (disabled or not configured)" 894 + fi 895 + 896 + # Show caching info 897 + echo "" 898 + echo "Blob caching: enabled for Bluesky uploads" 899 + local hour_decimal=$((10#$hour)) 900 + local hour_padded=$(printf "%02d" "$hour_decimal") 901 + echo "Cache key would be: ${timeline}_hour_${hour_padded}" 902 + } 903 + 904 + # Modified update_pfp function to use caching 905 + update_pfp() { 906 + log_info "Starting profile picture update..." 907 + 908 + # Determine timeline based on weather 909 + local timeline 910 + timeline=$(get_weather_timeline) 911 + log_info "Using timeline: $timeline" 912 + 913 + # Get appropriate image for current hour 914 + local image_path 915 + image_path=$(get_hour_image_path "$timeline") 916 + 917 + if [ -z "$image_path" ]; then 918 + log_error "No suitable image found for current time" 919 + return 1 920 + fi 921 + 922 + log_info "Selected image: $image_path" 923 + 924 + # Get current hour for caching 925 + local current_hour=$(date +%H) 926 + local hour_decimal=$((10#$current_hour)) 927 + local hour_padded=$(printf "%02d" "$hour_decimal") 928 + 929 + # Dry run mode - don't actually upload 930 + if [ "${DRY_RUN:-false}" = "true" ]; then 931 + log_info "DRY RUN MODE - Would upload: $image_path" 932 + log_info "Image size: $(stat -c%s "$image_path" 2>/dev/null | numfmt --to=iec)" 933 + local test_hash=$(calculate_image_hash "$image_path") 934 + log_info "Image hash: $test_hash" 935 + log_info "Would cache as: $timeline hour $hour_padded" 936 + if [ "$BLUESKY_ENABLED" = "true" ]; then 937 + log_info "DRY RUN MODE - Would update Bluesky profile" 938 + fi 939 + if [ "$SLACK_ENABLED" = "true" ]; then 940 + log_info "DRY RUN MODE - Would update Slack profile" 941 + fi 942 + log_info "DRY RUN MODE - No changes made" 943 + return 0 944 + fi 945 + 946 + local bluesky_success=false 947 + local slack_success=false 948 + 949 + # Update Bluesky with caching 950 + if [ "$BLUESKY_ENABLED" = "true" ]; then 951 + log_info "Updating Bluesky profile picture with caching..." 952 + 953 + # Get session token 954 + local token 955 + token=$(get_session_token) 956 + 957 + if [ -z "$token" ] || [ "$token" = "null" ]; then 958 + log_info "No valid session found, authenticating..." 959 + if ! authenticate_bluesky; then 960 + log_error "Failed to authenticate with Bluesky" 961 + else 962 + token=$(get_session_token) 963 + fi 964 + fi 965 + 966 + if [ -n "$token" ] && [ "$token" != "null" ]; then 967 + # Get or upload blob with caching 968 + local blob_ref 969 + blob_ref=$(get_or_upload_blob "$image_path" "$timeline" "$hour_padded" "$token") 970 + 971 + # If operation failed due to expired token, try re-authenticating once 972 + if [ -z "$blob_ref" ]; then 973 + log_info "Failed to get blob, trying to re-authenticate..." 974 + rm -f "$SESSION_FILE" # Remove expired session 975 + if authenticate_bluesky; then 976 + token=$(get_session_token) 977 + if [ -n "$token" ] && [ "$token" != "null" ]; then 978 + blob_ref=$(get_or_upload_blob "$image_path" "$timeline" "$hour_padded" "$token") 979 + fi 980 + fi 981 + fi 982 + 983 + if [ -n "$blob_ref" ]; then 984 + # Update profile picture 985 + if update_profile_picture "$blob_ref" "$token"; then 986 + bluesky_success=true 987 + fi 988 + fi 989 + else 990 + log_error "Could not obtain valid Bluesky session token" 991 + fi 992 + fi 993 + 994 + # Update Slack (independent of Bluesky success) 995 + if [ "$SLACK_ENABLED" = "true" ]; then 996 + if update_slack_profile_picture "$image_path"; then 997 + slack_success=true 998 + fi 999 + fi 1000 + 1001 + # Report results 1002 + local updated_services=() 1003 + local failed_services=() 1004 + 1005 + if [ "$BLUESKY_ENABLED" = "true" ]; then 1006 + if [ "$bluesky_success" = "true" ]; then 1007 + updated_services+=("Bluesky") 1008 + else 1009 + failed_services+=("Bluesky") 1010 + fi 1011 + fi 1012 + 1013 + if [ "$SLACK_ENABLED" = "true" ]; then 1014 + if [ "$slack_success" = "true" ]; then 1015 + updated_services+=("Slack") 1016 + else 1017 + failed_services+=("Slack") 1018 + fi 1019 + fi 1020 + 1021 + # Final status 1022 + if [ ${#updated_services[@]} -gt 0 ]; then 1023 + log_success "Successfully updated: ${updated_services[*]}" 1024 + fi 1025 + 1026 + if [ ${#failed_services[@]} -gt 0 ]; then 1027 + log_error "Failed to update: ${failed_services[*]}" 1028 + fi 1029 + 1030 + # Return success if at least one service updated 1031 + if [ ${#updated_services[@]} -gt 0 ]; then 1032 + return 0 1033 + else 1034 + return 1 1035 + fi 1036 + } 1037 + 1038 + # Show help 1039 + show_help() { 1040 + cat << EOF 1041 + Dynamic Profile Picture Updater 1042 + 1043 + Automatically updates your profile pictures across multiple platforms based on time and weather. 1044 + Uses ATProto record caching to avoid re-uploading identical images. 1045 + 1046 + Usage: $0 [options] 1047 + 1048 + Options: 1049 + -c, --config FILE Use custom config file (default: $CONFIG_FILE) 1050 + -t, --test Test mode - show what would be used without updating 1051 + -d, --dry-run Dry run - authenticate and prepare but don't actually update 1052 + -l, --list List available timelines 1053 + -f, --force TIMELINE Force use of specific timeline (ignore weather) 1054 + --list-cache List all cached blob references 1055 + --cleanup-cache [DAYS] Clean up cached blobs older than DAYS (default: 30) 1056 + --clear-cache Delete all cached blob references (USE WITH CAUTION) 1057 + -h, --help Show this help message 1058 + 1059 + Configuration: 1060 + Edit $CONFIG_FILE to set your platform credentials and preferences. 1061 + 1062 + Supported platforms: 1063 + - Bluesky: Set handle and app password 1064 + - Slack: Set user token (xoxp-...) with users.profile:write scope 1065 + 1066 + Blob Caching: 1067 + Images are uploaded once and cached in ATProto records at: 1068 + pfp.updates.{timeline}.{timeline}_hour_{HH} 1069 + 1070 + Each record contains: 1071 + - Image SHA256 hash for change detection 1072 + - Blob reference for reuse 1073 + - Metadata (timeline, hour, creation time) 1074 + 1075 + Examples of cache locations: 1076 + - pfp.updates.sunny.sunny_hour_09 1077 + - pfp.updates.rainy.rainy_hour_14 1078 + - pfp.updates.cloudy.cloudy_hour_23 1079 + 1080 + Examples: 1081 + $0 # Update profile pictures (uses cache when possible) 1082 + $0 --test # Test what would be used 1083 + $0 --list-cache # Show all cached blob references 1084 + $0 --cleanup-cache 7 # Remove cached blobs older than 7 days 1085 + $0 --force sunny # Force sunny timeline 1086 + 1087 + For automated updates, add to crontab: 1088 + # Update 2 minutes after every hour 1089 + 2 * * * * $0 >/dev/null 2>&1 1090 + EOF 1091 + } 1092 + 1093 + # Parse command line arguments 1094 + parse_args() { 1095 + FORCE_TIMELINE="" 1096 + 1097 + while [[ $# -gt 0 ]]; do 1098 + case $1 in 1099 + -c|--config) 1100 + CONFIG_FILE="$2" 1101 + shift 2 1102 + ;; 1103 + -t|--test) 1104 + load_config 1105 + test_mode 1106 + exit $? 1107 + ;; 1108 + -d|--dry-run) 1109 + DRY_RUN=true 1110 + shift 1111 + ;; 1112 + -l|--list) 1113 + load_config 1114 + list_timelines 1115 + exit 0 1116 + ;; 1117 + -f|--force) 1118 + FORCE_TIMELINE="$2" 1119 + shift 2 1120 + ;; 1121 + --list-cache) 1122 + load_config 1123 + check_dependencies 1124 + 1125 + # Get session token 1126 + local token 1127 + token=$(get_session_token) 1128 + if [ -z "$token" ] || [ "$token" = "null" ]; then 1129 + if ! authenticate_bluesky; then 1130 + log_error "Failed to authenticate with Bluesky" 1131 + exit 1 1132 + else 1133 + token=$(get_session_token) 1134 + fi 1135 + fi 1136 + 1137 + list_cached_blobs "$token" 1138 + exit $? 1139 + ;; 1140 + --cleanup-cache) 1141 + local cleanup_days="30" 1142 + if [[ "$2" =~ ^[0-9]+$ ]]; then 1143 + cleanup_days="$2" 1144 + shift 1145 + fi 1146 + 1147 + load_config 1148 + check_dependencies 1149 + 1150 + # Get session token 1151 + local token 1152 + token=$(get_session_token) 1153 + if [ -z "$token" ] || [ "$token" = "null" ]; then 1154 + if ! authenticate_bluesky; then 1155 + log_error "Failed to authenticate with Bluesky" 1156 + exit 1 1157 + else 1158 + token=$(get_session_token) 1159 + fi 1160 + fi 1161 + 1162 + cleanup_cached_blobs "$token" "$cleanup_days" 1163 + exit $? 1164 + ;; 1165 + --clear-cache) 1166 + echo "WARNING: This will delete ALL cached blob references!" 1167 + echo "You will need to re-upload all images on next use." 1168 + read -p "Are you sure? (y/N): " -n 1 -r 1169 + echo 1170 + if [[ $REPLY =~ ^[Yy]$ ]]; then 1171 + load_config 1172 + check_dependencies 1173 + 1174 + # Get session token 1175 + local token 1176 + token=$(get_session_token) 1177 + if [ -z "$token" ] || [ "$token" = "null" ]; then 1178 + if ! authenticate_bluesky; then 1179 + log_error "Failed to authenticate with Bluesky" 1180 + exit 1 1181 + else 1182 + token=$(get_session_token) 1183 + fi 1184 + fi 1185 + 1186 + cleanup_cached_blobs "$token" "0" # Delete all 1187 + log_success "Cache cleared" 1188 + else 1189 + log_info "Cache clear cancelled" 1190 + fi 1191 + exit 0 1192 + ;; 1193 + -h|--help) 1194 + show_help 1195 + exit 0 1196 + ;; 1197 + *) 1198 + echo "Unknown option: $1" 1199 + show_help 1200 + exit 1 1201 + ;; 1202 + esac 1203 + done 1204 + } 1205 + 1206 + # Override weather function if timeline is forced 1207 + if [ -n "${FORCE_TIMELINE:-}" ]; then 1208 + get_weather_timeline() { 1209 + echo "$FORCE_TIMELINE" 1210 + } 1211 + fi 1212 + 1213 + # Main execution 1214 + main() { 1215 + parse_args "$@" 1216 + check_dependencies 1217 + load_config 1218 + 1219 + if ! update_pfp; then 1220 + exit 1 1221 + fi 1222 + } 1223 + 1224 + # Run main function 1225 + main "$@"