the home of serif.blue
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 7set -euo pipefail 8 9# Default configuration 10SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 11CONFIG_FILE="${SCRIPT_DIR}/config.json" 12IMAGES_DIR="${SCRIPT_DIR}/rendered_timelines" 13LOG_FILE="${SCRIPT_DIR}/auto_pfp.log" 14SESSION_FILE="${SCRIPT_DIR}/.bluesky_session" 15DEFAULT_TIMELINE="sunny" 16 17# Colors for output 18RED='\033[0;31m' 19GREEN='\033[0;32m' 20YELLOW='\033[1;33m' 21BLUE='\033[0;34m' 22NC='\033[0m' # No Color 23 24# Logging function 25log() { 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 33log_info() { log "${BLUE}[INFO]${NC}" "$@"; } 34log_success() { log "${GREEN}[SUCCESS]${NC}" "$@"; } 35log_warning() { log "${YELLOW}[WARNING]${NC}" "$@"; } 36log_error() { log "${RED}[ERROR]${NC}" "$@"; } 37 38# Check dependencies 39check_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 64create_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} 98EOF 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 104load_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 168authenticate_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 192get_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 209calculate_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 218get_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 261store_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 349upload_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 404get_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 454list_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) 496cleanup_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 558update_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 659update_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 714get_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 792get_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 821list_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 838test_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 905update_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 1039show_help() { 1040 cat << EOF 1041Dynamic Profile Picture Updater 1042 1043Automatically updates your profile pictures across multiple platforms based on time and weather. 1044Uses ATProto record caching to avoid re-uploading identical images. 1045 1046Usage: $0 [options] 1047 1048Options: 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 1059Configuration: 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 1080Examples: 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 1087For automated updates, add to crontab: 1088 # Update 2 minutes after every hour 1089 2 * * * * $0 >/dev/null 2>&1 1090EOF 1091} 1092 1093# Parse command line arguments 1094parse_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 1207if [ -n "${FORCE_TIMELINE:-}" ]; then 1208 get_weather_timeline() { 1209 echo "$FORCE_TIMELINE" 1210 } 1211fi 1212 1213# Main execution 1214main() { 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 1225main "$@"