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 "$@"