-
Notifications
You must be signed in to change notification settings - Fork 4
Add enhanced Bluesky posting workflow with contextual messaging and robust error handling #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 4 commits
234f7f7
456a58d
13c3e1a
6f7388e
8401a3f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| # Bluesky Posting Workflow | ||
|
|
||
| This directory contains the GitHub Actions workflow and script for automatically posting Utah Data Science Center events to Bluesky. | ||
|
|
||
| ## How it works | ||
|
|
||
| 1. **Schedule**: The workflow runs twice a week: | ||
| - Mondays at 9:00 AM MST | ||
| - Wednesdays at 8:00 AM MST | ||
|
|
||
| 2. **Data Source**: The script fetches the next upcoming event from the Utah Data Science Center Google Calendar (calendar ID: `ekol7ulqm14nv155angut2rlfo@group.calendar.google.com`) | ||
|
|
||
| 3. **Posting**: It formats the event information into a social media-friendly message and posts it to Bluesky using the AT Protocol | ||
|
|
||
| ## Setup Instructions | ||
|
|
||
| ### Required GitHub Secrets | ||
|
|
||
| To enable Bluesky posting, add the following secrets to your GitHub repository: | ||
|
|
||
| 1. Go to your repository Settings → Secrets and variables → Actions | ||
| 2. Add these repository secrets: | ||
| - `BLUESKY_USERNAME`: Your Bluesky handle (e.g., `username.bsky.social`) | ||
| - `BLUESKY_PASSWORD`: Your Bluesky app password (not your regular password!) | ||
|
|
||
| ### Creating a Bluesky App Password | ||
|
|
||
| 1. Log in to Bluesky | ||
| 2. Go to Settings → App Passwords | ||
| 3. Create a new app password for "GitHub Actions Bot" | ||
| 4. Use this app password (not your regular password) as `BLUESKY_PASSWORD` | ||
|
|
||
| ### Optional Secrets | ||
|
|
||
| - `GOOGLE_CALENDAR_API_KEY`: If you want to use a different Google Calendar API key (the script has a fallback to the existing key) | ||
|
|
||
| ## Manual Testing | ||
|
|
||
| You can manually trigger the workflow by: | ||
| 1. Going to the Actions tab in your repository | ||
| 2. Selecting "Post to Bluesky" | ||
| 3. Clicking "Run workflow" | ||
|
|
||
| ## Files | ||
|
|
||
| - `bluesky-post.yml`: GitHub Actions workflow definition | ||
| - `post_to_bluesky.py`: Python script that handles calendar fetching and Bluesky posting | ||
| - `README.md`: This documentation file | ||
|
|
||
| ## Message Format | ||
|
|
||
| The bot posts messages in this format: | ||
|
|
||
| ``` | ||
| 🔬 Join us for our next Utah Data Science Center event! | ||
| 📅 [Event Title] | ||
| ⏰ [Day, Month Date at Time] | ||
| 📍 [Location] | ||
|
|
||
| [Brief description...] | ||
|
|
||
| 🔗 More info: https://datascience.utah.edu/seminar | ||
| 📅 Calendar: [Google Calendar Link] | ||
|
|
||
| #DataScience #Utah #AI #MachineLearning | ||
| ``` | ||
|
|
||
| If no upcoming events are found, it posts a generic message directing people to the website. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,228 @@ | ||
| #!/usr/bin/env python3 | ||
| """ | ||
| Script to fetch the next Utah Data Science Center event from Google Calendar | ||
| and post it to Bluesky. | ||
| """ | ||
|
|
||
| import os | ||
| import sys | ||
| import json | ||
| import requests | ||
| import argparse | ||
| from datetime import datetime, timezone | ||
| from dateutil import parser | ||
| import logging | ||
|
|
||
| # Set up logging | ||
| logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') | ||
| logger = logging.getLogger(__name__) | ||
|
|
||
| class BlueSkyPoster: | ||
| def __init__(self, username, password): | ||
| self.username = username | ||
| self.password = password | ||
| self.session = None | ||
| self.base_url = "https://bsky.social" | ||
|
|
||
| def authenticate(self): | ||
| """Authenticate with Bluesky and get session token""" | ||
| try: | ||
| auth_url = f"{self.base_url}/xrpc/com.atproto.server.createSession" | ||
| auth_data = { | ||
| "identifier": self.username, | ||
| "password": self.password | ||
| } | ||
|
|
||
| response = requests.post(auth_url, json=auth_data) | ||
| response.raise_for_status() | ||
|
|
||
| self.session = response.json() | ||
| logger.info("Successfully authenticated with Bluesky") | ||
| return True | ||
|
|
||
| except requests.exceptions.RequestException as e: | ||
| logger.error(f"Failed to authenticate with Bluesky: {e}") | ||
| return False | ||
|
|
||
| def post_message(self, message): | ||
| """Post a message to Bluesky""" | ||
| if not self.session: | ||
| logger.error("Not authenticated. Call authenticate() first.") | ||
| return False | ||
|
|
||
| try: | ||
| post_url = f"{self.base_url}/xrpc/com.atproto.repo.createRecord" | ||
| headers = { | ||
| "Authorization": f"Bearer {self.session['accessJwt']}", | ||
| "Content-Type": "application/json" | ||
| } | ||
|
|
||
| # Create the post record | ||
| now = datetime.now(timezone.utc).isoformat() | ||
| post_data = { | ||
| "repo": self.session["did"], | ||
| "collection": "app.bsky.feed.post", | ||
| "record": { | ||
| "$type": "app.bsky.feed.post", | ||
| "text": message, | ||
| "createdAt": now | ||
| } | ||
| } | ||
|
|
||
| response = requests.post(post_url, headers=headers, json=post_data) | ||
| response.raise_for_status() | ||
|
|
||
| logger.info("Successfully posted to Bluesky") | ||
| return True | ||
|
|
||
| except requests.exceptions.RequestException as e: | ||
| logger.error(f"Failed to post to Bluesky: {e}") | ||
| return False | ||
|
|
||
| class GoogleCalendarFetcher: | ||
| def __init__(self, api_key): | ||
| self.api_key = api_key | ||
| self.calendar_id = "ekol7ulqm14nv155angut2rlfo@group.calendar.google.com" | ||
|
|
||
| def get_next_event(self): | ||
| """Fetch the next upcoming event from the Google Calendar""" | ||
| try: | ||
| # Current time in ISO format | ||
| time_min = datetime.now(timezone.utc).isoformat() | ||
|
|
||
| url = "https://www.googleapis.com/calendar/v3/calendars/{}/events".format(self.calendar_id) | ||
| params = { | ||
| "key": self.api_key, | ||
| "timeMin": time_min, | ||
| "maxResults": 1, | ||
| "singleEvents": True, | ||
| "orderBy": "startTime" | ||
| } | ||
|
|
||
| response = requests.get(url, params=params) | ||
| response.raise_for_status() | ||
|
|
||
| data = response.json() | ||
| events = data.get("items", []) | ||
|
|
||
| if not events: | ||
| logger.warning("No upcoming events found") | ||
| return None | ||
|
|
||
| event = events[0] | ||
| logger.info(f"Found next event: {event.get('summary', 'Unknown')}") | ||
| return event | ||
|
|
||
| except requests.exceptions.RequestException as e: | ||
| logger.error(f"Failed to fetch calendar events: {e}") | ||
| return None | ||
|
|
||
| def format_event_message(event): | ||
| """Format the event data into a Bluesky post message""" | ||
| if not event: | ||
| return "Join us for our next Utah Data Science Center event! Check our website for details: https://datascience.utah.edu/seminar" | ||
|
|
||
| # Extract event details | ||
| title = event.get("summary", "Utah Data Science Event") | ||
| description = event.get("description", "") | ||
| location = event.get("location", "") | ||
| html_link = event.get("htmlLink", "") | ||
|
|
||
| # Parse start time | ||
| start_time = None | ||
| if "start" in event: | ||
| if "dateTime" in event["start"]: | ||
| start_time = parser.parse(event["start"]["dateTime"]) | ||
| elif "date" in event["start"]: | ||
| start_time = parser.parse(event["start"]["date"]) | ||
|
|
||
| # Format the message | ||
| message_parts = ["🔬 Join us for our next Utah Data Science Center event!"] | ||
|
|
||
| message_parts.append(f"\n📅 {title}") | ||
|
|
||
| if start_time: | ||
| formatted_time = start_time.strftime("%A, %B %d at %I:%M %p") | ||
| message_parts.append(f"\n⏰ {formatted_time}") | ||
|
|
||
| if location and location.strip(): | ||
| message_parts.append(f"\n📍 {location}") | ||
|
|
||
| # Add a brief description if available (truncated for length) | ||
| if description and description.strip(): | ||
| # Remove markdown and HTML tags for a cleaner description | ||
| clean_desc = description.replace("*", "").replace("**", "").replace("#", "") | ||
| # Take first sentence or first 100 characters | ||
| if len(clean_desc) > 100: | ||
| clean_desc = clean_desc[:100] + "..." | ||
| message_parts.append(f"\n\n{clean_desc}") | ||
|
|
||
| message_parts.append(f"\n\n🔗 More info: https://datascience.utah.edu/seminar") | ||
|
|
||
| if html_link: | ||
| message_parts.append(f"\n📅 Calendar: {html_link}") | ||
|
|
||
| message_parts.append("\n\n#DataScience #Utah #AI #MachineLearning") | ||
|
|
||
| return "".join(message_parts) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here are some important notes about the message. It should include:
If it happens that the message is too long (check the Bluesky limits), then truncate the title only, using ellipses. Bluesky's API does not provide support for emojis and automatic links to topics via tag attributes "#", you need to use the documentation I provided.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've implemented all the requested message format improvements:
The message now includes proper contextual timing and rich text linking as requested. Commit: 8401a3f |
||
|
|
||
| def main(): | ||
| """Main function to orchestrate the calendar fetch and Bluesky post""" | ||
| # Parse command line arguments | ||
| parser = argparse.ArgumentParser(description="Post Utah Data Science Center events to Bluesky") | ||
| parser.add_argument("--dry-run", action="store_true", | ||
| help="Generate and display the message without posting to Bluesky") | ||
| args = parser.parse_args() | ||
|
|
||
| # Get environment variables | ||
| bluesky_username = os.getenv("BLUESKY_USERNAME") | ||
| bluesky_password = os.getenv("BLUESKY_PASSWORD") | ||
| google_api_key = os.getenv("GOOGLE_CALENDAR_API_KEY") | ||
|
|
||
| # In dry-run mode, we don't need Bluesky credentials | ||
| if not args.dry_run and (not bluesky_username or not bluesky_password): | ||
| logger.warning("Bluesky credentials not provided. Skipping post.") | ||
| logger.info("This is expected until the Bluesky credentials are added to GitHub secrets.") | ||
| logger.info("To add credentials, set BLUESKY_USERNAME and BLUESKY_PASSWORD in GitHub repository secrets.") | ||
| return | ||
|
|
||
| # Use the hardcoded API key if not provided as secret (fallback to existing key) | ||
| if not google_api_key: | ||
| google_api_key = "AIzaSyBq6TTVUkWGCs0vmGh1XlIuGn0w5dCtbsA" | ||
| logger.info("Using fallback Google Calendar API key") | ||
|
|
||
| # Fetch the next event | ||
| calendar_fetcher = GoogleCalendarFetcher(google_api_key) | ||
| next_event = calendar_fetcher.get_next_event() | ||
|
|
||
| # Format the message | ||
| message = format_event_message(next_event) | ||
|
|
||
| if args.dry_run: | ||
| logger.info("DRY RUN MODE - Message would be posted to Bluesky:") | ||
| print("\n" + "="*60) | ||
| print("BLUESKY POST PREVIEW") | ||
| print("="*60) | ||
| print(message) | ||
| print("="*60) | ||
| logger.info("Dry run completed successfully") | ||
| return | ||
|
|
||
| logger.info(f"Formatted message: {message}") | ||
|
|
||
| # Post to Bluesky | ||
| poster = BlueSkyPoster(bluesky_username, bluesky_password) | ||
|
|
||
| if poster.authenticate(): | ||
| success = poster.post_message(message) | ||
| if success: | ||
| logger.info("Successfully completed Bluesky post workflow") | ||
| else: | ||
| logger.error("Failed to post message to Bluesky") | ||
| sys.exit(1) | ||
| else: | ||
| logger.error("Failed to authenticate with Bluesky") | ||
| sys.exit(1) | ||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
| Original file line number | Diff line number | Diff line change | |||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,45 @@ | |||||||||||||||||||||||
| name: Post to Bluesky | |||||||||||||||||||||||
|
|
|||||||||||||||||||||||
| on: | |||||||||||||||||||||||
| schedule: | |||||||||||||||||||||||
| # Monday at 9 AM MST (16:00 UTC) - MST is UTC-7 | |||||||||||||||||||||||
| - cron: '0 16 * * 1' | |||||||||||||||||||||||
| # Wednesday at 8 AM MST (15:00 UTC) - MST is UTC-7 | |||||||||||||||||||||||
| - cron: '0 15 * * 3' | |||||||||||||||||||||||
| workflow_dispatch: # Allow manual triggering for testing | |||||||||||||||||||||||
| pull_request: # Run in dry-mode for PRs | |||||||||||||||||||||||
| push: # Run in dry-mode for commits | |||||||||||||||||||||||
|
|
|||||||||||||||||||||||
| jobs: | |||||||||||||||||||||||
| post-to-bluesky: | |||||||||||||||||||||||
| runs-on: ubuntu-latest | |||||||||||||||||||||||
|
|
|||||||||||||||||||||||
| steps: | |||||||||||||||||||||||
| - name: Checkout repository | |||||||||||||||||||||||
| uses: actions/checkout@v4 | |||||||||||||||||||||||
|
|
|||||||||||||||||||||||
| - name: Set up Python | |||||||||||||||||||||||
| uses: actions/setup-python@v4 | |||||||||||||||||||||||
| with: | |||||||||||||||||||||||
| python-version: '3.11' | |||||||||||||||||||||||
|
|
|||||||||||||||||||||||
| - name: Install dependencies | |||||||||||||||||||||||
| run: | | |||||||||||||||||||||||
| python -m pip install --upgrade pip | |||||||||||||||||||||||
| pip install requests python-dateutil | |||||||||||||||||||||||
|
|
|||||||||||||||||||||||
| - name: Post to Bluesky | |||||||||||||||||||||||
| env: | |||||||||||||||||||||||
| BLUESKY_USERNAME: ${{ secrets.BLUESKY_USERNAME }} | |||||||||||||||||||||||
| BLUESKY_PASSWORD: ${{ secrets.BLUESKY_PASSWORD }} | |||||||||||||||||||||||
| GOOGLE_CALENDAR_API_KEY: ${{ secrets.GOOGLE_CALENDAR_API_KEY }} | |||||||||||||||||||||||
| run: | | |||||||||||||||||||||||
| # Determine if this is a dry run (for PRs and pushes) | |||||||||||||||||||||||
| if [[ "${{ github.event_name }}" == "pull_request" || "${{ github.event_name }}" == "push" ]]; then | |||||||||||||||||||||||
| echo "Running in dry-mode (PR/push event) - will only generate and display the message" | |||||||||||||||||||||||
| python .github/scripts/post_to_bluesky.py --dry-run | |||||||||||||||||||||||
| else | |||||||||||||||||||||||
| echo "Running in posting mode (scheduled/manual trigger) - will post to Bluesky" | |||||||||||||||||||||||
| python .github/scripts/post_to_bluesky.py | |||||||||||||||||||||||
| fi | |||||||||||||||||||||||
| continue-on-error: true # Don't fail the workflow if Bluesky credentials aren't set up yet | |||||||||||||||||||||||
|
Comment on lines
+13
to
+45
Check warningCode scanning / CodeQL Workflow does not contain permissions Medium
Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
Copilot AutofixAI 8 months ago To fix the problem, explicitly set the minimal necessary permissions for the
Suggested changeset
1
.github/workflows/bluesky-post.yml
Copilot is powered by AI and may make mistakes. Always verify output.
Refresh and try again.
|
|||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,3 +17,8 @@ _site | |
|
|
||
| #pycharm | ||
| .idea | ||
|
|
||
| # Python | ||
| __pycache__/ | ||
| *.py[cod] | ||
| *$py.class | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If there's no event, then return an empty string. Then, no posting should be done.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed! The
format_event_messagefunction now returns an empty string when no event is found, and the main function checks for this and skips posting entirely.This ensures no posting occurs when there are no events to announce. Commit: 8401a3f