···2233## Overview
4455-This repository contains a Python script designed to automatically update your Bluesky avatar based on the current hour. The script uses environment variables for configuration and reads a JSON file of blob CIDs to determine the appropriate avatar. This script was inspired by [@dame.is](https://bsky.app/profile/dame.is)'s blog post ['How I made an automated dynamic avatar for my Bluesky profile'](https://dame.is/blog/how-i-made-an-automated-dynamic-avatar-for-my-bluesky-profile).
55+This repository contains a Python script that automatically updates your Bluesky avatar (and, optionally, your banner) based on the current hour. The script utilises environment variables for configuration and reads a JSON file mapping blob CIDs to specific hours. In addition to updating your avatar, the script performs several supportive functions including a health check of the API endpoint, comprehensive logging (both to console and to a rotating file system that deletes logs older than 30 days), and the automatic setup of a cron job to ensure regular updates. This project was inspired by [@dame.is](https://bsky.app/profile/dame.is)'s blog post ['How I made an automated dynamic avatar for my Bluesky profile'](https://dame.is/blog/how-i-made-an-automated-dynamic-avatar-for-my-bluesky-profile).
6677-The script has been tested and is fully functional. It was developed on macOS but is intended for deployment on Linux.
77+Developed primarily on macOS and intended for Linux deployment, this tool is designed to run within a virtual environment to isolate dependencies and ensure smooth operation.
8899## Prerequisites
10101111-Before running the script, ensure you have the following:
1111+Before running the script, please ensure you have the following:
12121313-- Python 3.6 or later installed. If using Ubuntu, run `sudo apt update && sudo apt install -y python3 python3-pip python3-dev`
1414-- The required Python packages (automatically installed if missing):
1313+- Python 3.6 or later installed. For Ubuntu, run:
1414+1515+ ```bash
1616+ sudo apt update && sudo apt install -y python3 python3-pip python3-dev
1717+ ```
1818+1919+- The following Python packages (automatically installed if missing):
1520 - `python-dotenv`
1621 - `atproto`
1722 - `requests`
1823 - `python-magic`
1924 - `python-crontab`
2025- A valid Bluesky account with the necessary API credentials.
2626+- The script must be executed within a virtual environment.
21272228## Installation
23292424-1. **Clone the repository:**
3030+1. **Clone the Repository:**
25312632 ```bash
2733 git clone https://github.com/ewanc26/bluesky-avatar-updater.git
2834 cd bluesky-avatar-updater
2935 ```
30363131-2. **Ensure the required package is installed (if necessary):**
3232-3333- Before creating the virtual environment, make sure that the `python3-venv` package is installed (this is necessary on Debian/Ubuntu systems to create virtual environments).
3737+2. **Ensure Virtual Environment Support:**
3838+ On Debian/Ubuntu systems, ensure that the `python3-venv` package is installed:
34393540 ```bash
3636- sudo apt install python3.10-venv # Adjust the version if necessary (e.g., python3.9-venv)
4141+ sudo apt install python3-venv # Adjust the version if necessary (e.g., python3.10-venv)
3742 ```
38433939-3. **Create a virtual environment and install dependencies:**
4040-4141- Now, create a new virtual environment and activate it. This isolates the package dependencies for your project.
4444+3. **Create and Activate a Virtual Environment:**
42454346 ```bash
4447 python3 -m venv .venv
4548 source .venv/bin/activate # On Windows use: .venv\Scripts\activate
4649 ```
47504848-4. **Install dependencies within the virtual environment:**
4949-5050- With the virtual environment activated, install the required packages listed in the `requirements.txt` file:
5151+4. **Install Dependencies:**
5252+ With the virtual environment activated, install the required packages:
51535254 ```bash
5355 pip install -r requirements.txt
5456 ```
55575656-5. **Configure environment variables:**
5757- - Place your `.env` file in the `../assets` directory relative to the script.
5858+5. **Configure Environment Variables:**
5959+ - Place your `.env` file in the `assets` directory.
5860 - The `.env` file should contain the following entries:
59616062 ```env
6163 ENDPOINT=your_endpoint
6264 HANDLE=your_handle
6363- PASSWORD=your_password (app passwords are recommended)
6565+ PASSWORD=your_password # App passwords are recommended
6466 DID=your_did
6767+ UPDATE_BANNER=true # Set to 'true' to update the banner, or 'false' otherwise
6568 ```
66696767-6. **Prepare the JSON file:**
6868- - Ensure that a `cids.json` file is located in the `../assets` directory. This file should map each hour (in two-digit format) to a corresponding blob CID. Example:
7070+6. **Prepare the JSON File:**
7171+ Ensure that a `cids.json` file is located in the `assets` directory. This file should map each hour (in two-digit format) to the corresponding blob CIDs for the avatar (and optionally, the banner). For example:
69727070- ```json
7171- {
7272- "00": "cid_for_midnight",
7373- "01": "cid_for_1am",
7474- "02": "cid_for_2am"
7575- }
7676- ```
7373+ ```json
7474+ {
7575+ "00": { "avatar": "cid_for_midnight", "banner": "banner_cid_for_midnight" },
7676+ "01": { "avatar": "cid_for_1am", "banner": "banner_cid_for_1am" }
7777+ }
7878+ ```
77797880## Usage
7981···8385python -u ./src/main.py
8486```
85878686-The script will:
8888+Upon execution, the script will:
87898888-- Load the environment configuration from `../assets/.env`.
8989-- Read the blob CIDs from `../assets/cids.json`.
9090-- Determine the current hour and select the appropriate blob CID.
9191-- Authenticate using the AT Protocol.
9292-- Update the Bluesky avatar accordingly.
9393-9494-Execution logs will be displayed directly in the console, and a log file will be created in the `./logs/` directory. The log file will rotate every 14 days, keeping up to 5 backup log files. Logs older than 30 days will be automatically deleted.
9090+- Verify that it is running within a virtual environment.
9191+- Load environment variables from the `.env` file located in the `assets` directory.
9292+- Read the blob CIDs from the `cids.json` file.
9393+- Determine the current hour and select the appropriate blob CIDs.
9494+- Perform a health check on the provided API endpoint.
9595+- Authenticate using the AT Protocol and update your Bluesky profile with the new avatar (and banner, if enabled).
9696+- Automatically set up a cron job to run the script every hour.
9797+- Log activity to both the console and a rotating log file in the `logs` directory. The log file rotates every 14 days (with up to 5 backups) and automatically deletes files older than 30 days.
95989699## Automating with Cron (Linux)
971009898-The script will automatically set up a cron job to run every hour. If you need to manually verify it, run:
101101+The script is designed to automatically configure a cron job to run at the top of every hour. To verify the cron job, use:
99102100103```bash
101104crontab -l
102105```
103106104104-If you need to remove or modify the cron job, use:
105105-106106-```bash
107107-crontab -e
108108-```
109109-110110-### Manually Set Up Cron Job
111111-112112-If you wish to manually set up the cron job instead of relying on the script, follow these steps:
107107+If you prefer to manually set up or modify the cron job, follow these steps:
1131081141091. Open the crontab editor:
115110···117112 crontab -e
118113 ```
119114120120-2. Add the following line to run the script every hour at the top of the hour:
115115+2. Add the following line (adjusting paths as necessary):
121116122117 ```bash
123118 0 * * * * /path/to/your/.venv/bin/python3 /path/to/bluesky-avatar-updater/src/main.py
124119 ```
125125-126126-Replace `/path/to/your/.venv/bin/python3` with the path to your virtual environment's Python interpreter and `/path/to/bluesky-avatar-updater/src/main.py` with the full path to the `main.py` script.
127120128121## Troubleshooting
129122130130-- **Environment variables not loading?** Ensure the `.env` file is correctly placed in `../assets/`.
131131-- **Script exits with missing dependencies?** The script will attempt to install missing packages, but you can manually install them using:
132132-123123+- **Virtual Environment Issues:** The script will exit if it is not run within a virtual environment. Ensure you activate your virtual environment before running the script.
124124+- **Environment Variables Not Loading:** Verify that the `.env` file is correctly placed in the `assets` directory and contains all required entries.
125125+- **Missing Dependencies:** If the script encounters missing packages, run:
126126+133127 ```bash
134128 pip install -r requirements.txt
135129 ```
136130137137-- **Endpoint not responding?** Verify that the Bluesky API endpoint is correct and accessible.
138138-- **Cron job not running?** Verify that the cron job was properly set up using `crontab -l` or set it up manually.
139139-- **Old logs not deleting?** Ensure the script has the necessary permissions to delete files in the `./logs/` directory.
131131+ within your virtual environment.
132132+- **Endpoint Issues:** Ensure that the provided API endpoint is correct and accessible. The script performs a health check and will log an error if the endpoint is unresponsive.
133133+- **Cron Job Not Running:** If the cron job is not automatically set up, check with `crontab -l` or set it up manually using `crontab -e`.
134134+- **Log File Management:** The script manages log rotation and deletion automatically. If logs are not being deleted as expected, verify the file permissions in the `logs` directory.
140135141136## License
142137143143-This project is released under the MIT License. See the [LICENSE](./LICENSE) file for full details.
138138+This project is released under the MIT License. Please refer to the [LICENSE](./LICENSE) file for full details.
+56-30
src/main.py
···3535def cleanup_old_logs(log_directory, days=30):
3636 """Deletes log files older than the specified number of days."""
3737 cutoff = time.time() - (days * 86400) # Convert days to seconds
3838- for log_file in glob.glob(os.path.join(log_directory, "avatar_update*.log")):
3838+ for log_file in glob.glob(os.path.join(log_directory, "update.log")):
3939 if os.path.isfile(log_file) and os.path.getmtime(log_file) < cutoff:
4040 os.remove(log_file)
4141 print(f"Deleted old log: {log_file}")
···4444cleanup_old_logs(log_dir, days=30)
45454646# Use a fixed log file name for the current log; TimedRotatingFileHandler will manage rotations.
4747-log_file_path = os.path.join(log_dir, "avatar_update.log")
4747+log_file_path = os.path.join(log_dir, "update.log")
48484949# Configure logging to both console and file with bi-weekly rotation
5050console_handler = logging.StreamHandler()
···77777878# Suppress httpx logging (this stops httpx internal logs)
7979logging.getLogger("httpx").setLevel(logging.WARNING) # Suppress INFO and DEBUG logs from httpx
8080-8181-# Log the start of the script
8282-logger.info("Avatar update script started.")
83808481def ensure_https(url):
8582 """Ensure the URL starts with https://."""
···145142 handle = os.getenv("HANDLE")
146143 password = os.getenv("PASSWORD")
147144 did = os.getenv("DID")
145145+ update_banner = os.getenv("UPDATE_BANNER", "false").lower() == "true"
148146149147 if not all([endpoint, handle, password, did]):
150148 logger.error("Missing environment variables. Ensure ENDPOINT, HANDLE, PASSWORD, and DID are set in .env file.")
···153151 "endpoint": endpoint,
154152 "handle": handle,
155153 "password": password,
156156- "did": did
154154+ "did": did,
155155+ "update_banner": update_banner
157156 }
158157159158def setup_cron_job():
···180179 logger.info("Cron job already exists.")
181180182181def main():
183183- """Main function to run the avatar update process."""
182182+ """Main function to run the avatar and banner update process."""
184183 # Set up the cron job (only once)
185185- setup_cron_job()
184184+ try:
185185+ setup_cron_job()
186186+ except Exception as e:
187187+ logger.error(f"Error setting cron job: {e}")
188188+ pass
186189187187- logger.info("Starting avatar update process...")
190190+ logger.info("Script started.")
191191+ logger.info("Starting update process...")
188192189193 # Load environment variables from the .env file
190194 if os.path.exists(ENV_PATH):
···213217 logger.error(f"Error loading cids.json from {JSON_PATH}: {e}")
214218 return
215219216216- # Determine the blob CID for the current hour
220220+ # Determine the blob CIDs for the current hour from the modified structure
217221 current_hour = datetime.now().strftime("%H")
218222 logger.info(f"Current hour: {current_hour}")
219219-220220- new_blob_cid = blob_dict.get(current_hour)
221221- if not new_blob_cid:
222222- logger.warning(f"No blob CID found for hour {current_hour}")
223223+224224+ current_entry = blob_dict.get(current_hour)
225225+ if not current_entry:
226226+ logger.warning(f"No entry found for hour {current_hour} in cids.json")
223227 return
224228225225- logger.info(f"Selected blob CID: {new_blob_cid}")
229229+ new_avatar_cid = current_entry.get("avatar")
230230+ new_banner_cid = current_entry.get("banner") if env_vars["update_banner"] else None
231231+232232+ if not new_avatar_cid:
233233+ logger.warning(f"No avatar CID found for hour {current_hour}")
234234+ return
235235+236236+ logger.info(f"Selected avatar CID: {new_avatar_cid}")
237237+ if env_vars["update_banner"]:
238238+ if new_banner_cid:
239239+ logger.info(f"Selected banner CID: {new_banner_cid}")
240240+ else:
241241+ logger.warning(f"UPDATE_BANNER is enabled, but no banner CID found for hour {current_hour}")
226242227243 # Authenticate with the endpoint
228244 client = Client(endpoint)
···233249 logger.error(f"Authentication failed: {e}")
234250 return
235251236236- # Fetch the current profile and update it with the new avatar
252252+ # Fetch the current profile and update it with the new avatar (and optionally banner)
237253 try:
238254 current_profile_record = client.app.bsky.actor.profile.get(
239255 client.me.did, "self"
240256 )
241257 current_profile = current_profile_record.value
242258 swap_record_cid = current_profile_record.cid
243243- logger.info(f"Current profile record fetched successfully.")
259259+ logger.info("Current profile record fetched successfully.")
244260 except BadRequestError:
245261 current_profile = swap_record_cid = None
246246- logger.warning(f"Failed to fetch current profile record.")
262262+ logger.warning("Failed to fetch current profile record.")
247263248248- old_description = old_display_name = None
249249- if current_profile:
250250- old_description = current_profile.description
251251- old_display_name = current_profile.display_name
264264+ old_description = current_profile.description if current_profile else None
265265+ old_display_name = current_profile.display_name if current_profile else None
266266+ old_banner = current_profile.banner if current_profile else None
252267253253- blob_metadata = get_blob_metadata(new_blob_cid, env_vars["did"], endpoint)
268268+ avatar_metadata = get_blob_metadata(new_avatar_cid, env_vars["did"], endpoint)
269269+ if avatar_metadata is None:
270270+ logger.error(f"Could not retrieve metadata for avatar blob CID: {new_avatar_cid}")
271271+ return
254272255255- if blob_metadata is None:
256256- logger.error(f"Could not retrieve metadata for blob CID: {new_blob_cid}")
257257- return
273273+ banner_metadata = None
274274+ if env_vars["update_banner"] and new_banner_cid:
275275+ banner_metadata = get_blob_metadata(new_banner_cid, env_vars["did"], endpoint)
276276+ if banner_metadata is None:
277277+ logger.warning(f"Could not retrieve metadata for banner blob CID: {new_banner_cid}")
278278+ banner_metadata = old_banner
279279+ else:
280280+ banner_metadata = old_banner
258281259259- # Update the profile with the new avatar
282282+ # Update the profile with the new avatar and optionally the new banner
260283 try:
261284 client.com.atproto.repo.put_record(
262285 models.ComAtprotoRepoPutRecord.Data(
···265288 rkey="self",
266289 swap_record=swap_record_cid,
267290 record=models.AppBskyActorProfile.Record(
268268- avatar=blob_metadata,
269269- banner=current_profile.banner if current_profile else None,
291291+ avatar=avatar_metadata,
292292+ banner=banner_metadata,
270293 description=old_description,
271294 display_name=old_display_name,
272295 ),
273296 )
274297 )
275275- logger.info(f"Avatar updated successfully with CID: {new_blob_cid}")
298298+ if env_vars["update_banner"] and new_banner_cid:
299299+ logger.info(f"Profile updated successfully with avatar CID {new_avatar_cid} and banner CID {new_banner_cid}")
300300+ else:
301301+ logger.info(f"Profile updated successfully with avatar CID: {new_avatar_cid}")
276302 except Exception as e:
277303 logger.error(f"Failed to update profile record: {e}")
278304