the home of serif.blue

feat: add script

dunkirk.sh f57846a8 f2bd74f4

verified
Changed files
+1225
+1225
bsky-profile-updates.sh
···
+
#!/usr/bin/env bash
+
+
# Dynamic Profile Picture Updater
+
# Automatically updates your profile pictures across platforms based on time and weather
+
# Usage: ./auto_pfp.sh [options]
+
+
set -euo pipefail
+
+
# Default configuration
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
CONFIG_FILE="${SCRIPT_DIR}/config.json"
+
IMAGES_DIR="${SCRIPT_DIR}/rendered_timelines"
+
LOG_FILE="${SCRIPT_DIR}/auto_pfp.log"
+
SESSION_FILE="${SCRIPT_DIR}/.bluesky_session"
+
DEFAULT_TIMELINE="sunny"
+
+
# Colors for output
+
RED='\033[0;31m'
+
GREEN='\033[0;32m'
+
YELLOW='\033[1;33m'
+
BLUE='\033[0;34m'
+
NC='\033[0m' # No Color
+
+
# Logging function
+
log() {
+
local level="$1"
+
shift
+
local message="$*"
+
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
+
echo -e "${timestamp} - ${level} - ${message}" | tee -a "$LOG_FILE" >&2
+
}
+
+
log_info() { log "${BLUE}[INFO]${NC}" "$@"; }
+
log_success() { log "${GREEN}[SUCCESS]${NC}" "$@"; }
+
log_warning() { log "${YELLOW}[WARNING]${NC}" "$@"; }
+
log_error() { log "${RED}[ERROR]${NC}" "$@"; }
+
+
# Check dependencies
+
check_dependencies() {
+
local missing_deps=()
+
+
if ! command -v curl &> /dev/null; then
+
missing_deps+=("curl")
+
fi
+
+
if ! command -v jq &> /dev/null; then
+
missing_deps+=("jq")
+
fi
+
+
if ! command -v sha256sum &> /dev/null; then
+
missing_deps+=("sha256sum")
+
fi
+
+
if [ ${#missing_deps[@]} -ne 0 ]; then
+
log_error "Missing required dependencies:"
+
for dep in "${missing_deps[@]}"; do
+
echo " - $dep"
+
done
+
exit 1
+
fi
+
}
+
+
# Create default config file
+
create_default_config() {
+
cat > "$CONFIG_FILE" << 'EOF'
+
{
+
"platforms": {
+
"bluesky": {
+
"enabled": true,
+
"handle": "your-handle.bsky.social",
+
"password": "your-app-password"
+
},
+
"slack": {
+
"enabled": false,
+
"user_token": ""
+
}
+
},
+
"weather": {
+
"enabled": false,
+
"api_key": "",
+
"location": "auto",
+
"timeline_mapping": {
+
"clear": "sunny",
+
"clouds": "cloudy",
+
"rain": "rainy",
+
"drizzle": "rainy",
+
"thunderstorm": "stormy",
+
"snow": "snowy",
+
"mist": "cloudy",
+
"fog": "cloudy"
+
}
+
},
+
"settings": {
+
"default_timeline": "sunny",
+
"images_dir": "./rendered_timelines"
+
}
+
}
+
EOF
+
log_info "Created default config file: $CONFIG_FILE"
+
log_warning "Please edit the config file with your platform credentials"
+
}
+
+
# Load configuration
+
load_config() {
+
if [ ! -f "$CONFIG_FILE" ]; then
+
log_warning "Config file not found, creating default..."
+
create_default_config
+
exit 1
+
fi
+
+
# Read platform settings
+
BLUESKY_ENABLED=$(jq -r '.platforms.bluesky.enabled' "$CONFIG_FILE")
+
BLUESKY_HANDLE=$(jq -r '.platforms.bluesky.handle' "$CONFIG_FILE")
+
BLUESKY_PASSWORD=$(jq -r '.platforms.bluesky.password' "$CONFIG_FILE")
+
SLACK_ENABLED=$(jq -r '.platforms.slack.enabled' "$CONFIG_FILE")
+
SLACK_USER_TOKEN=$(jq -r '.platforms.slack.user_token' "$CONFIG_FILE")
+
+
# Read weather settings
+
WEATHER_ENABLED=$(jq -r '.weather.enabled' "$CONFIG_FILE")
+
WEATHER_API_KEY=$(jq -r '.weather.api_key' "$CONFIG_FILE")
+
WEATHER_LOCATION=$(jq -r '.weather.location' "$CONFIG_FILE")
+
+
# Read general settings
+
DEFAULT_TIMELINE=$(jq -r '.settings.default_timeline' "$CONFIG_FILE")
+
IMAGES_DIR=$(jq -r '.settings.images_dir' "$CONFIG_FILE")
+
+
# Validate at least one platform is enabled and configured
+
local enabled_platforms=()
+
+
# Check Bluesky
+
if [ "$BLUESKY_ENABLED" = "true" ]; then
+
if [ "$BLUESKY_HANDLE" = "your-handle.bsky.social" ] || [ "$BLUESKY_HANDLE" = "null" ] || [ -z "$BLUESKY_HANDLE" ]; then
+
log_warning "Bluesky enabled but handle not configured - will be skipped"
+
BLUESKY_ENABLED="false"
+
elif [ "$BLUESKY_PASSWORD" = "your-app-password" ] || [ "$BLUESKY_PASSWORD" = "null" ] || [ -z "$BLUESKY_PASSWORD" ]; then
+
log_warning "Bluesky enabled but password not configured - will be skipped"
+
BLUESKY_ENABLED="false"
+
else
+
enabled_platforms+=("Bluesky")
+
fi
+
fi
+
+
# Check Slack
+
if [ "$SLACK_ENABLED" = "true" ]; then
+
if [ -z "$SLACK_USER_TOKEN" ] || [ "$SLACK_USER_TOKEN" = "null" ]; then
+
log_warning "Slack enabled but no user token provided - will be skipped"
+
SLACK_ENABLED="false"
+
else
+
enabled_platforms+=("Slack")
+
fi
+
fi
+
+
# Ensure at least one platform is enabled
+
if [ ${#enabled_platforms[@]} -eq 0 ]; then
+
log_error "No platforms are properly configured. Please check your config file."
+
exit 1
+
fi
+
+
# Convert relative paths to absolute
+
if [[ ! "$IMAGES_DIR" =~ ^/ ]]; then
+
IMAGES_DIR="${SCRIPT_DIR}/${IMAGES_DIR}"
+
fi
+
+
log_info "Loaded configuration with enabled platforms: ${enabled_platforms[*]}"
+
}
+
+
# Authenticate with Bluesky
+
authenticate_bluesky() {
+
if [ "$BLUESKY_ENABLED" != "true" ]; then
+
return 1
+
fi
+
+
log_info "Authenticating with Bluesky..."
+
+
local auth_response
+
auth_response=$(curl -s -X POST \
+
"https://bsky.social/xrpc/com.atproto.server.createSession" \
+
-H "Content-Type: application/json" \
+
-d "{\"identifier\":\"$BLUESKY_HANDLE\",\"password\":\"$BLUESKY_PASSWORD\"}")
+
+
if echo "$auth_response" | jq -e '.accessJwt' > /dev/null 2>&1; then
+
echo "$auth_response" > "$SESSION_FILE"
+
log_success "Successfully authenticated with Bluesky"
+
return 0
+
else
+
log_error "Bluesky authentication failed: $(echo "$auth_response" | jq -r '.message // "Unknown error"')"
+
return 1
+
fi
+
}
+
+
# Get session token
+
get_session_token() {
+
if [ ! -f "$SESSION_FILE" ]; then
+
return 1
+
fi
+
+
# Check if session is still valid (sessions typically last 24 hours)
+
local session_age=$(($(date +%s) - $(stat -c %Y "$SESSION_FILE" 2>/dev/null || echo 0)))
+
if [ $session_age -gt 86400 ]; then # 24 hours
+
log_info "Session expired, re-authenticating..."
+
rm -f "$SESSION_FILE"
+
return 1
+
fi
+
+
jq -r '.accessJwt' "$SESSION_FILE" 2>/dev/null || return 1
+
}
+
+
# Calculate SHA256 hash of image file
+
calculate_image_hash() {
+
local image_path="$1"
+
if [ ! -f "$image_path" ]; then
+
return 1
+
fi
+
sha256sum "$image_path" | cut -d' ' -f1
+
}
+
+
# Get blob reference from ATProto record
+
get_cached_blob() {
+
local weather_type="$1"
+
local hour="$2"
+
local image_hash="$3"
+
local token="$4"
+
local did
+
+
did=$(jq -r '.did' "$SESSION_FILE")
+
local rkey="${weather_type}_hour_${hour}"
+
+
# Validate DID
+
if [ -z "$did" ] || [ "$did" = "null" ]; then
+
log_error "Could not get DID from session file"
+
return 1
+
fi
+
+
log_info "Checking for cached blob: $weather_type hour $hour (DID: ${did:0:20}...)"
+
+
# Try to get existing record
+
local record_response
+
record_response=$(curl -s \
+
"https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=$did&collection=pfp.updates.${weather_type}&rkey=$rkey" \
+
-H "Authorization: Bearer $token")
+
+
if echo "$record_response" | jq -e '.value' > /dev/null 2>&1; then
+
local stored_hash=$(echo "$record_response" | jq -r '.value.imageHash // empty')
+
local stored_blob=$(echo "$record_response" | jq -c '.value.blobRef // empty')
+
+
if [ "$stored_hash" = "$image_hash" ] && [ -n "$stored_blob" ] && [ "$stored_blob" != "empty" ]; then
+
log_success "Found cached blob with matching hash"
+
echo "$stored_blob"
+
return 0
+
else
+
log_info "Cached blob found but hash mismatch or missing blob reference"
+
return 1
+
fi
+
else
+
log_info "No cached blob record found"
+
return 1
+
fi
+
}
+
+
# Store blob reference in ATProto record
+
store_blob_reference() {
+
local weather_type="$1"
+
local hour="$2"
+
local image_hash="$3"
+
local blob_ref="$4"
+
local token="$5"
+
local did
+
+
did=$(jq -r '.did' "$SESSION_FILE")
+
local rkey="${weather_type}_hour_${hour}"
+
+
# Validate DID
+
if [ -z "$did" ] || [ "$did" = "null" ]; then
+
log_error "Could not get DID from session file"
+
return 1
+
fi
+
+
log_info "Storing blob reference for $weather_type hour $hour (DID: ${did:0:20}...)"
+
+
# Create record data
+
local record_data
+
record_data=$(jq -n \
+
--arg type_field "pfp.updates.${weather_type}" \
+
--arg hash "$image_hash" \
+
--argjson blob "$blob_ref" \
+
--arg weather "$weather_type" \
+
--arg hour "$hour" \
+
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)" \
+
'{
+
"$type": $type_field,
+
"timeline": $weather,
+
"hour": $hour,
+
"imageHash": $hash,
+
"blobRef": $blob,
+
"createdAt": $timestamp
+
}')
+
+
log_info "Record data: $record_data"
+
+
# Create the request payload using direct JSON construction
+
local request_payload
+
request_payload=$(jq -n \
+
--arg repo "$did" \
+
--arg collection "pfp.updates.${weather_type}" \
+
--arg rkey "$rkey" \
+
--argjson record "$record_data" \
+
'{
+
"repo": $repo,
+
"collection": $collection,
+
"rkey": $rkey,
+
"record": $record
+
}')
+
+
log_info "Request payload preview: $(echo "$request_payload" | jq -c '.' | head -c 200)..."
+
+
# Validate the payload has required fields
+
if ! echo "$request_payload" | jq -e '.repo' > /dev/null 2>&1; then
+
log_error "Request payload missing 'repo' field"
+
log_error "DID value: '$did'"
+
log_error "Full payload: $request_payload"
+
return 1
+
fi
+
+
# In dry run mode, don't actually store
+
if [ "${DRY_RUN:-false}" = "true" ]; then
+
log_info "DRY RUN MODE - Would store blob reference"
+
return 0
+
fi
+
+
# Store the record
+
local store_response
+
store_response=$(curl -s -X POST \
+
"https://bsky.social/xrpc/com.atproto.repo.putRecord" \
+
-H "Authorization: Bearer $token" \
+
-H "Content-Type: application/json" \
+
-d "$request_payload")
+
+
if echo "$store_response" | jq -e '.uri' > /dev/null 2>&1; then
+
log_success "Successfully stored blob reference"
+
return 0
+
else
+
log_error "Failed to store blob reference: $(echo "$store_response" | jq -r '.message // "Unknown error"')"
+
log_error "Full response: $store_response"
+
return 1
+
fi
+
}
+
+
# Upload image as blob
+
upload_blob() {
+
local image_path="$1"
+
local token="$2"
+
+
if [ ! -f "$image_path" ]; then
+
log_error "Image file not found: $image_path"
+
return 1
+
fi
+
+
# Check image file size (should be reasonable)
+
local file_size=$(stat -c%s "$image_path" 2>/dev/null || echo 0)
+
if [ "$file_size" -lt 1000 ]; then
+
log_error "Image file too small ($file_size bytes): $image_path"
+
return 1
+
fi
+
+
if [ "$file_size" -gt 10000000 ]; then # 10MB limit
+
log_error "Image file too large ($file_size bytes): $image_path"
+
return 1
+
fi
+
+
log_info "Uploading image: $(basename "$image_path") ($(numfmt --to=iec "$file_size"))"
+
+
local upload_response
+
upload_response=$(curl -s -X POST \
+
"https://bsky.social/xrpc/com.atproto.repo.uploadBlob" \
+
-H "Authorization: Bearer $token" \
+
-H "Content-Type: image/jpeg" \
+
--data-binary "@$image_path")
+
+
if echo "$upload_response" | jq -e '.blob' > /dev/null 2>&1; then
+
local blob_result
+
blob_result=$(echo "$upload_response" | jq -c '.blob')
+
log_success "Successfully uploaded image"
+
log_info "Blob data: $blob_result"
+
echo "$blob_result"
+
return 0
+
else
+
local error_type=$(echo "$upload_response" | jq -r '.error // "Unknown"')
+
local error_msg=$(echo "$upload_response" | jq -r '.message // "Unknown error"')
+
+
if [ "$error_type" = "ExpiredToken" ] || echo "$error_msg" | grep -qi "expired"; then
+
log_error "Token has expired - session needs refresh"
+
# Remove the expired session file so it will be regenerated
+
rm -f "$SESSION_FILE"
+
return 2 # Special return code for expired token
+
else
+
log_error "Failed to upload image: $error_msg"
+
log_error "Full response: $upload_response"
+
return 1
+
fi
+
fi
+
}
+
+
# Get or upload blob with caching
+
get_or_upload_blob() {
+
local image_path="$1"
+
local weather_type="$2"
+
local hour="$3"
+
local token="$4"
+
+
if [ ! -f "$image_path" ]; then
+
log_error "Image file not found: $image_path"
+
return 1
+
fi
+
+
# Calculate image hash
+
local image_hash
+
image_hash=$(calculate_image_hash "$image_path")
+
if [ -z "$image_hash" ]; then
+
log_error "Failed to calculate image hash"
+
return 1
+
fi
+
+
log_info "Image hash: $image_hash"
+
+
# Try to get cached blob
+
local cached_blob
+
if cached_blob=$(get_cached_blob "$weather_type" "$hour" "$image_hash" "$token"); then
+
log_success "Using cached blob reference"
+
echo "$cached_blob"
+
return 0
+
fi
+
+
# No cache hit, upload the blob
+
log_info "No valid cache found, uploading new blob..."
+
local new_blob
+
new_blob=$(upload_blob "$image_path" "$token")
+
+
if [ -n "$new_blob" ]; then
+
# Store the blob reference for future use
+
if store_blob_reference "$weather_type" "$hour" "$image_hash" "$new_blob" "$token"; then
+
log_success "Blob uploaded and cached successfully"
+
else
+
log_warning "Blob uploaded but failed to cache reference"
+
fi
+
echo "$new_blob"
+
return 0
+
else
+
log_error "Failed to upload blob"
+
return 1
+
fi
+
}
+
+
# List all cached blobs
+
list_cached_blobs() {
+
local token="$1"
+
local did
+
+
did=$(jq -r '.did' "$SESSION_FILE")
+
+
log_info "Listing cached blob records..."
+
+
# Get list of available timelines to check each collection
+
local timelines=()
+
if [ -d "$IMAGES_DIR" ]; then
+
for timeline_dir in "$IMAGES_DIR"/*; do
+
if [ -d "$timeline_dir" ]; then
+
timelines+=($(basename "$timeline_dir"))
+
fi
+
done
+
fi
+
+
local total_records=0
+
echo "Cached blob records:"
+
+
for timeline in "${timelines[@]}"; do
+
# List records in each timeline collection
+
local list_response
+
list_response=$(curl -s \
+
"https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=$did&collection=pfp.updates.${timeline}" \
+
-H "Authorization: Bearer $token" 2>/dev/null)
+
+
if echo "$list_response" | jq -e '.records' > /dev/null 2>&1; then
+
local timeline_count=$(echo "$list_response" | jq '.records | length')
+
if [ "$timeline_count" -gt 0 ]; then
+
echo " $timeline timeline:"
+
echo "$list_response" | jq -r '.records[] | " hour \(.value.hour) - Hash: \(.value.imageHash[0:12])... (Created: \(.value.createdAt))"'
+
total_records=$((total_records + timeline_count))
+
fi
+
fi
+
done
+
+
echo "Total cached records: $total_records"
+
}
+
+
# Clean up old cached blobs (optional maintenance function)
+
cleanup_cached_blobs() {
+
local token="$1"
+
local days_to_keep="${2:-30}" # Keep records for 30 days by default
+
local did
+
+
did=$(jq -r '.did' "$SESSION_FILE")
+
+
log_info "Cleaning up cached blobs older than $days_to_keep days..."
+
+
# Get current timestamp minus retention period
+
local cutoff_date
+
cutoff_date=$(date -u -d "$days_to_keep days ago" +%Y-%m-%dT%H:%M:%S.%3NZ)
+
+
# Get list of available timelines to check each collection
+
local timelines=()
+
if [ -d "$IMAGES_DIR" ]; then
+
for timeline_dir in "$IMAGES_DIR"/*; do
+
if [ -d "$timeline_dir" ]; then
+
timelines+=($(basename "$timeline_dir"))
+
fi
+
done
+
fi
+
+
local deleted_count=0
+
+
for timeline in "${timelines[@]}"; do
+
# List all records in this timeline collection
+
local list_response
+
list_response=$(curl -s \
+
"https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=$did&collection=pfp.updates.${timeline}" \
+
-H "Authorization: Bearer $token" 2>/dev/null)
+
+
if echo "$list_response" | jq -e '.records' > /dev/null 2>&1; then
+
# Find records older than cutoff
+
local old_records
+
old_records=$(echo "$list_response" | jq --arg cutoff "$cutoff_date" '.records[] | select(.value.createdAt < $cutoff)')
+
+
if [ -n "$old_records" ]; then
+
echo "$old_records" | jq -r '.uri' | while read -r record_uri; do
+
local rkey=$(echo "$record_uri" | sed 's/.*\///')
+
log_info "Deleting old record: $timeline/$rkey"
+
+
curl -s -X POST \
+
"https://bsky.social/xrpc/com.atproto.repo.deleteRecord" \
+
-H "Authorization: Bearer $token" \
+
-H "Content-Type: application/json" \
+
-d "{\"repo\":\"$did\",\"collection\":\"pfp.updates.${timeline}\",\"rkey\":\"$rkey\"}" > /dev/null
+
+
deleted_count=$((deleted_count + 1))
+
done
+
fi
+
fi
+
done
+
+
if [ "$deleted_count" -eq 0 ]; then
+
log_info "No old records found to clean up"
+
else
+
log_success "Deleted $deleted_count old records"
+
fi
+
}
+
+
# Update profile picture
+
update_profile_picture() {
+
local blob_ref="$1"
+
local token="$2"
+
local did
+
+
# Validate blob reference
+
if [ -z "$blob_ref" ] || [ "$blob_ref" = "null" ]; then
+
log_error "Invalid blob reference provided"
+
return 1
+
fi
+
+
# Validate blob reference format
+
if ! echo "$blob_ref" | jq -e '.ref' > /dev/null 2>&1; then
+
log_error "Blob reference missing required 'ref' field: $blob_ref"
+
return 1
+
fi
+
+
# Get DID from session
+
did=$(jq -r '.did' "$SESSION_FILE")
+
+
log_info "Updating profile picture..."
+
log_info "Using blob: $blob_ref"
+
+
# Get current profile
+
local current_profile
+
current_profile=$(curl -s \
+
"https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=$did&collection=app.bsky.actor.profile&rkey=self" \
+
-H "Authorization: Bearer $token")
+
+
log_info "Current profile response: $current_profile"
+
+
local profile_data
+
if echo "$current_profile" | jq -e '.value' > /dev/null 2>&1; then
+
# Update existing profile - PRESERVE ALL EXISTING FIELDS
+
profile_data=$(echo "$current_profile" | jq --argjson avatar "$blob_ref" '.value | .avatar = $avatar')
+
log_info "Updating existing profile (preserving existing fields)"
+
else
+
log_error "No existing profile found - cannot safely create new profile"
+
log_error "Please manually restore your profile in the Bluesky app first"
+
return 1
+
fi
+
+
log_info "Profile data to send: $profile_data"
+
+
# Validate profile data before sending
+
if ! echo "$profile_data" | jq -e '.avatar' > /dev/null 2>&1; then
+
log_error "Generated profile data is invalid"
+
return 1
+
fi
+
+
# Double-check we're preserving important fields
+
local display_name=$(echo "$profile_data" | jq -r '.displayName // empty')
+
local description=$(echo "$profile_data" | jq -r '.description // empty')
+
+
if [ -n "$display_name" ]; then
+
log_info "Preserving display name: $display_name"
+
fi
+
+
if [ -n "$description" ]; then
+
log_info "Preserving description: $(echo "$description" | head -c 50)..."
+
fi
+
+
# Create the request payload
+
local request_payload
+
request_payload=$(jq -n \
+
--arg repo "$did" \
+
--arg collection "app.bsky.actor.profile" \
+
--arg rkey "self" \
+
--argjson record "$profile_data" \
+
'{repo: $repo, collection: $collection, rkey: $rkey, record: $record}')
+
+
log_info "Request payload: $request_payload"
+
+
# In dry run mode, don't actually update
+
if [ "${DRY_RUN:-false}" = "true" ]; then
+
log_info "DRY RUN MODE - Would send profile update with avatar"
+
log_info "DRY RUN MODE - Profile fields would be preserved"
+
return 0
+
fi
+
+
# Update profile
+
local update_response
+
update_response=$(curl -s -X POST \
+
"https://bsky.social/xrpc/com.atproto.repo.putRecord" \
+
-H "Authorization: Bearer $token" \
+
-H "Content-Type: application/json" \
+
-d "$request_payload")
+
+
log_info "Update response: $update_response"
+
+
if echo "$update_response" | jq -e '.uri' > /dev/null 2>&1; then
+
log_success "Successfully updated profile picture"
+
return 0
+
else
+
log_error "Failed to update profile picture: $(echo "$update_response" | jq -r '.message // "Unknown error"')"
+
log_error "Full error response: $update_response"
+
return 1
+
fi
+
}
+
+
# Update Slack profile picture
+
update_slack_profile_picture() {
+
local image_path="$1"
+
+
if [ "$SLACK_ENABLED" != "true" ] || [ -z "$SLACK_USER_TOKEN" ] || [ "$SLACK_USER_TOKEN" = "null" ]; then
+
log_info "Slack integration disabled or not configured"
+
return 0
+
fi
+
+
if [ ! -f "$image_path" ]; then
+
log_error "Image file not found for Slack: $image_path"
+
return 1
+
fi
+
+
log_info "Updating Slack profile picture..."
+
+
# First, upload the image to Slack
+
local upload_response
+
upload_response=$(curl -s -X POST \
+
"https://slack.com/api/users.setPhoto" \
+
-H "Authorization: Bearer $SLACK_USER_TOKEN" \
+
-F "image=@$image_path")
+
+
if echo "$upload_response" | jq -e '.ok' > /dev/null 2>&1; then
+
local ok_status=$(echo "$upload_response" | jq -r '.ok')
+
if [ "$ok_status" = "true" ]; then
+
log_success "Successfully updated Slack profile picture"
+
return 0
+
else
+
local error_msg=$(echo "$upload_response" | jq -r '.error // "Unknown error"')
+
log_error "Failed to update Slack profile picture: $error_msg"
+
+
# Handle common errors
+
case "$error_msg" in
+
"invalid_auth")
+
log_error "Invalid Slack token - please check your user token"
+
;;
+
"not_authed")
+
log_error "Authentication failed - token may be expired"
+
;;
+
"missing_scope")
+
log_error "Token missing required scope - needs 'users.profile:write'"
+
;;
+
"too_large")
+
log_error "Image file too large for Slack"
+
;;
+
esac
+
return 1
+
fi
+
else
+
log_error "Invalid response from Slack API: $upload_response"
+
return 1
+
fi
+
}
+
+
# Get weather-based timeline
+
get_weather_timeline() {
+
if [ "$WEATHER_ENABLED" != "true" ] || [ -z "$WEATHER_API_KEY" ] || [ "$WEATHER_API_KEY" = "null" ]; then
+
log_info "Weather integration disabled, using default timeline: $DEFAULT_TIMELINE"
+
echo "$DEFAULT_TIMELINE"
+
return 0
+
fi
+
+
log_info "Fetching weather data..."
+
+
local lat lon
+
+
if [ "$WEATHER_LOCATION" = "auto" ]; then
+
# Auto-detect location from IP
+
local ip_data
+
ip_data=$(curl -s "http://ip-api.com/json/" --connect-timeout 10)
+
+
if echo "$ip_data" | jq -e '.lat' > /dev/null 2>&1; then
+
lat=$(echo "$ip_data" | jq -r '.lat')
+
lon=$(echo "$ip_data" | jq -r '.lon')
+
local city=$(echo "$ip_data" | jq -r '.city')
+
local country=$(echo "$ip_data" | jq -r '.country')
+
log_info "Auto-detected location: $city, $country"
+
else
+
log_warning "Could not auto-detect location, using default timeline"
+
echo "$DEFAULT_TIMELINE"
+
return 0
+
fi
+
else
+
# Use provided location (assume it's "lat,lon" or city name)
+
if [[ "$WEATHER_LOCATION" =~ ^-?[0-9]+\.?[0-9]*,-?[0-9]+\.?[0-9]*$ ]]; then
+
# It's coordinates
+
lat=$(echo "$WEATHER_LOCATION" | cut -d',' -f1)
+
lon=$(echo "$WEATHER_LOCATION" | cut -d',' -f2)
+
else
+
# It's a city name, geocode it
+
local geocode_response
+
geocode_response=$(curl -s "http://api.openweathermap.org/geo/1.0/direct?q=$WEATHER_LOCATION&limit=1&appid=$WEATHER_API_KEY")
+
+
if echo "$geocode_response" | jq -e '.[0].lat' > /dev/null 2>&1; then
+
lat=$(echo "$geocode_response" | jq -r '.[0].lat')
+
lon=$(echo "$geocode_response" | jq -r '.[0].lon')
+
log_info "Geocoded location: $WEATHER_LOCATION"
+
else
+
log_warning "Could not geocode location: $WEATHER_LOCATION"
+
echo "$DEFAULT_TIMELINE"
+
return 0
+
fi
+
fi
+
fi
+
+
# Get current weather
+
local weather_response
+
weather_response=$(curl -s "http://api.openweathermap.org/data/2.5/weather?lat=$lat&lon=$lon&appid=$WEATHER_API_KEY" --connect-timeout 10)
+
+
if echo "$weather_response" | jq -e '.weather[0].main' > /dev/null 2>&1; then
+
local weather_main=$(echo "$weather_response" | jq -r '.weather[0].main' | tr '[:upper:]' '[:lower:]')
+
local weather_desc=$(echo "$weather_response" | jq -r '.weather[0].description')
+
log_info "Current weather: $weather_desc"
+
+
# Map weather to timeline
+
local timeline
+
timeline=$(jq -r ".weather.timeline_mapping.\"$weather_main\" // \"$DEFAULT_TIMELINE\"" "$CONFIG_FILE")
+
+
# Check if the mapped timeline exists
+
if [ ! -d "$IMAGES_DIR/$timeline" ]; then
+
log_warning "Timeline '$timeline' not found, falling back to default: $DEFAULT_TIMELINE"
+
echo "$DEFAULT_TIMELINE"
+
else
+
log_info "Weather mapped to timeline: $timeline"
+
echo "$timeline"
+
fi
+
else
+
log_warning "Could not fetch weather data, using default timeline"
+
echo "$DEFAULT_TIMELINE"
+
fi
+
}
+
+
# Get current hour image path
+
get_hour_image_path() {
+
local timeline="$1"
+
local hour=$(date +%H)
+
# Remove leading zero to avoid octal interpretation, then pad with zero
+
local hour_decimal=$((10#$hour)) # Force decimal interpretation
+
local hour_padded=$(printf "%02d" "$hour_decimal")
+
local image_path="$IMAGES_DIR/$timeline/hour_${hour_padded}.jpg"
+
+
if [ -f "$image_path" ]; then
+
echo "$image_path"
+
return 0
+
else
+
log_warning "Image not found: $image_path" >&2
+
+
# Try fallback to default timeline
+
if [ "$timeline" != "$DEFAULT_TIMELINE" ]; then
+
local fallback_path="$IMAGES_DIR/$DEFAULT_TIMELINE/hour_${hour_padded}.jpg"
+
if [ -f "$fallback_path" ]; then
+
log_info "Using fallback image: $fallback_path" >&2
+
echo "$fallback_path"
+
return 0
+
fi
+
fi
+
+
return 1
+
fi
+
}
+
+
# List available timelines
+
list_timelines() {
+
if [ ! -d "$IMAGES_DIR" ]; then
+
log_error "Images directory not found: $IMAGES_DIR"
+
return 1
+
fi
+
+
echo "Available timelines:"
+
for timeline_dir in "$IMAGES_DIR"/*; do
+
if [ -d "$timeline_dir" ]; then
+
local timeline_name=$(basename "$timeline_dir")
+
local image_count=$(find "$timeline_dir" -name "hour_*.jpg" | wc -l)
+
echo " - $timeline_name ($image_count images)"
+
fi
+
done
+
}
+
+
# Test mode - show what would be used
+
test_mode() {
+
local timeline
+
timeline=$(get_weather_timeline)
+
+
local image_path
+
image_path=$(get_hour_image_path "$timeline")
+
+
local hour=$(date +%H)
+
+
echo "=== Test Mode ==="
+
echo "Current time: $(date)"
+
echo "Current hour: $hour"
+
echo "Weather enabled: $WEATHER_ENABLED"
+
echo "Selected timeline: $timeline"
+
echo "Image path: $image_path"
+
+
if [ -f "$image_path" ]; then
+
echo "✓ Image exists"
+
echo "Image size: $(du -h "$image_path" | cut -f1)"
+
+
# Show hash info in test mode
+
local test_hash=$(calculate_image_hash "$image_path")
+
echo "Image hash: $test_hash"
+
else
+
echo "✗ Image not found"
+
+
# Show available alternatives
+
echo ""
+
echo "Available timelines:"
+
list_timelines
+
return 1
+
fi
+
+
# Show weather info if enabled
+
if [ "$WEATHER_ENABLED" = "true" ] && [ -n "$WEATHER_API_KEY" ] && [ "$WEATHER_API_KEY" != "null" ]; then
+
echo ""
+
echo "Weather integration: enabled"
+
echo "Location setting: $WEATHER_LOCATION"
+
else
+
echo ""
+
echo "Weather integration: disabled (using default timeline)"
+
fi
+
+
# Show platform info
+
echo ""
+
echo "Enabled platforms:"
+
if [ "$BLUESKY_ENABLED" = "true" ]; then
+
echo " ✓ Bluesky ($BLUESKY_HANDLE)"
+
else
+
echo " ✗ Bluesky (disabled or not configured)"
+
fi
+
+
if [ "$SLACK_ENABLED" = "true" ]; then
+
echo " ✓ Slack"
+
else
+
echo " ✗ Slack (disabled or not configured)"
+
fi
+
+
# Show caching info
+
echo ""
+
echo "Blob caching: enabled for Bluesky uploads"
+
local hour_decimal=$((10#$hour))
+
local hour_padded=$(printf "%02d" "$hour_decimal")
+
echo "Cache key would be: ${timeline}_hour_${hour_padded}"
+
}
+
+
# Modified update_pfp function to use caching
+
update_pfp() {
+
log_info "Starting profile picture update..."
+
+
# Determine timeline based on weather
+
local timeline
+
timeline=$(get_weather_timeline)
+
log_info "Using timeline: $timeline"
+
+
# Get appropriate image for current hour
+
local image_path
+
image_path=$(get_hour_image_path "$timeline")
+
+
if [ -z "$image_path" ]; then
+
log_error "No suitable image found for current time"
+
return 1
+
fi
+
+
log_info "Selected image: $image_path"
+
+
# Get current hour for caching
+
local current_hour=$(date +%H)
+
local hour_decimal=$((10#$current_hour))
+
local hour_padded=$(printf "%02d" "$hour_decimal")
+
+
# Dry run mode - don't actually upload
+
if [ "${DRY_RUN:-false}" = "true" ]; then
+
log_info "DRY RUN MODE - Would upload: $image_path"
+
log_info "Image size: $(stat -c%s "$image_path" 2>/dev/null | numfmt --to=iec)"
+
local test_hash=$(calculate_image_hash "$image_path")
+
log_info "Image hash: $test_hash"
+
log_info "Would cache as: $timeline hour $hour_padded"
+
if [ "$BLUESKY_ENABLED" = "true" ]; then
+
log_info "DRY RUN MODE - Would update Bluesky profile"
+
fi
+
if [ "$SLACK_ENABLED" = "true" ]; then
+
log_info "DRY RUN MODE - Would update Slack profile"
+
fi
+
log_info "DRY RUN MODE - No changes made"
+
return 0
+
fi
+
+
local bluesky_success=false
+
local slack_success=false
+
+
# Update Bluesky with caching
+
if [ "$BLUESKY_ENABLED" = "true" ]; then
+
log_info "Updating Bluesky profile picture with caching..."
+
+
# Get session token
+
local token
+
token=$(get_session_token)
+
+
if [ -z "$token" ] || [ "$token" = "null" ]; then
+
log_info "No valid session found, authenticating..."
+
if ! authenticate_bluesky; then
+
log_error "Failed to authenticate with Bluesky"
+
else
+
token=$(get_session_token)
+
fi
+
fi
+
+
if [ -n "$token" ] && [ "$token" != "null" ]; then
+
# Get or upload blob with caching
+
local blob_ref
+
blob_ref=$(get_or_upload_blob "$image_path" "$timeline" "$hour_padded" "$token")
+
+
# If operation failed due to expired token, try re-authenticating once
+
if [ -z "$blob_ref" ]; then
+
log_info "Failed to get blob, trying to re-authenticate..."
+
rm -f "$SESSION_FILE" # Remove expired session
+
if authenticate_bluesky; then
+
token=$(get_session_token)
+
if [ -n "$token" ] && [ "$token" != "null" ]; then
+
blob_ref=$(get_or_upload_blob "$image_path" "$timeline" "$hour_padded" "$token")
+
fi
+
fi
+
fi
+
+
if [ -n "$blob_ref" ]; then
+
# Update profile picture
+
if update_profile_picture "$blob_ref" "$token"; then
+
bluesky_success=true
+
fi
+
fi
+
else
+
log_error "Could not obtain valid Bluesky session token"
+
fi
+
fi
+
+
# Update Slack (independent of Bluesky success)
+
if [ "$SLACK_ENABLED" = "true" ]; then
+
if update_slack_profile_picture "$image_path"; then
+
slack_success=true
+
fi
+
fi
+
+
# Report results
+
local updated_services=()
+
local failed_services=()
+
+
if [ "$BLUESKY_ENABLED" = "true" ]; then
+
if [ "$bluesky_success" = "true" ]; then
+
updated_services+=("Bluesky")
+
else
+
failed_services+=("Bluesky")
+
fi
+
fi
+
+
if [ "$SLACK_ENABLED" = "true" ]; then
+
if [ "$slack_success" = "true" ]; then
+
updated_services+=("Slack")
+
else
+
failed_services+=("Slack")
+
fi
+
fi
+
+
# Final status
+
if [ ${#updated_services[@]} -gt 0 ]; then
+
log_success "Successfully updated: ${updated_services[*]}"
+
fi
+
+
if [ ${#failed_services[@]} -gt 0 ]; then
+
log_error "Failed to update: ${failed_services[*]}"
+
fi
+
+
# Return success if at least one service updated
+
if [ ${#updated_services[@]} -gt 0 ]; then
+
return 0
+
else
+
return 1
+
fi
+
}
+
+
# Show help
+
show_help() {
+
cat << EOF
+
Dynamic Profile Picture Updater
+
+
Automatically updates your profile pictures across multiple platforms based on time and weather.
+
Uses ATProto record caching to avoid re-uploading identical images.
+
+
Usage: $0 [options]
+
+
Options:
+
-c, --config FILE Use custom config file (default: $CONFIG_FILE)
+
-t, --test Test mode - show what would be used without updating
+
-d, --dry-run Dry run - authenticate and prepare but don't actually update
+
-l, --list List available timelines
+
-f, --force TIMELINE Force use of specific timeline (ignore weather)
+
--list-cache List all cached blob references
+
--cleanup-cache [DAYS] Clean up cached blobs older than DAYS (default: 30)
+
--clear-cache Delete all cached blob references (USE WITH CAUTION)
+
-h, --help Show this help message
+
+
Configuration:
+
Edit $CONFIG_FILE to set your platform credentials and preferences.
+
+
Supported platforms:
+
- Bluesky: Set handle and app password
+
- Slack: Set user token (xoxp-...) with users.profile:write scope
+
+
Blob Caching:
+
Images are uploaded once and cached in ATProto records at:
+
pfp.updates.{timeline}.{timeline}_hour_{HH}
+
+
Each record contains:
+
- Image SHA256 hash for change detection
+
- Blob reference for reuse
+
- Metadata (timeline, hour, creation time)
+
+
Examples of cache locations:
+
- pfp.updates.sunny.sunny_hour_09
+
- pfp.updates.rainy.rainy_hour_14
+
- pfp.updates.cloudy.cloudy_hour_23
+
+
Examples:
+
$0 # Update profile pictures (uses cache when possible)
+
$0 --test # Test what would be used
+
$0 --list-cache # Show all cached blob references
+
$0 --cleanup-cache 7 # Remove cached blobs older than 7 days
+
$0 --force sunny # Force sunny timeline
+
+
For automated updates, add to crontab:
+
# Update 2 minutes after every hour
+
2 * * * * $0 >/dev/null 2>&1
+
EOF
+
}
+
+
# Parse command line arguments
+
parse_args() {
+
FORCE_TIMELINE=""
+
+
while [[ $# -gt 0 ]]; do
+
case $1 in
+
-c|--config)
+
CONFIG_FILE="$2"
+
shift 2
+
;;
+
-t|--test)
+
load_config
+
test_mode
+
exit $?
+
;;
+
-d|--dry-run)
+
DRY_RUN=true
+
shift
+
;;
+
-l|--list)
+
load_config
+
list_timelines
+
exit 0
+
;;
+
-f|--force)
+
FORCE_TIMELINE="$2"
+
shift 2
+
;;
+
--list-cache)
+
load_config
+
check_dependencies
+
+
# Get session token
+
local token
+
token=$(get_session_token)
+
if [ -z "$token" ] || [ "$token" = "null" ]; then
+
if ! authenticate_bluesky; then
+
log_error "Failed to authenticate with Bluesky"
+
exit 1
+
else
+
token=$(get_session_token)
+
fi
+
fi
+
+
list_cached_blobs "$token"
+
exit $?
+
;;
+
--cleanup-cache)
+
local cleanup_days="30"
+
if [[ "$2" =~ ^[0-9]+$ ]]; then
+
cleanup_days="$2"
+
shift
+
fi
+
+
load_config
+
check_dependencies
+
+
# Get session token
+
local token
+
token=$(get_session_token)
+
if [ -z "$token" ] || [ "$token" = "null" ]; then
+
if ! authenticate_bluesky; then
+
log_error "Failed to authenticate with Bluesky"
+
exit 1
+
else
+
token=$(get_session_token)
+
fi
+
fi
+
+
cleanup_cached_blobs "$token" "$cleanup_days"
+
exit $?
+
;;
+
--clear-cache)
+
echo "WARNING: This will delete ALL cached blob references!"
+
echo "You will need to re-upload all images on next use."
+
read -p "Are you sure? (y/N): " -n 1 -r
+
echo
+
if [[ $REPLY =~ ^[Yy]$ ]]; then
+
load_config
+
check_dependencies
+
+
# Get session token
+
local token
+
token=$(get_session_token)
+
if [ -z "$token" ] || [ "$token" = "null" ]; then
+
if ! authenticate_bluesky; then
+
log_error "Failed to authenticate with Bluesky"
+
exit 1
+
else
+
token=$(get_session_token)
+
fi
+
fi
+
+
cleanup_cached_blobs "$token" "0" # Delete all
+
log_success "Cache cleared"
+
else
+
log_info "Cache clear cancelled"
+
fi
+
exit 0
+
;;
+
-h|--help)
+
show_help
+
exit 0
+
;;
+
*)
+
echo "Unknown option: $1"
+
show_help
+
exit 1
+
;;
+
esac
+
done
+
}
+
+
# Override weather function if timeline is forced
+
if [ -n "${FORCE_TIMELINE:-}" ]; then
+
get_weather_timeline() {
+
echo "$FORCE_TIMELINE"
+
}
+
fi
+
+
# Main execution
+
main() {
+
parse_args "$@"
+
check_dependencies
+
load_config
+
+
if ! update_pfp; then
+
exit 1
+
fi
+
}
+
+
# Run main function
+
main "$@"