Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .github/scripts/README.md
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.
228 changes: 228 additions & 0 deletions .github/scripts/post_to_bluesky.py
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"
Copy link
Copy Markdown
Member

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed! The format_event_message function now returns an empty string when no event is found, and the main function checks for this and skips posting entirely.

if not message:
    logger.info("No upcoming events found. Skipping post.")
    return

This ensures no posting occurs when there are no events to announce. Commit: 8401a3f


# 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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here are some important notes about the message. It should include:

  • The date/day, e.g., "Join us Wednesday, Sep 3" if it is the Monday message, or "Join us today" if it is the Wednesday message.
  • The speaker.
  • The title of the talk.
  • The #datascience and #ai tags.
  • Link to the datascience seminar website. For this, you should use Bluesky's richtext facets. The text in the message should be something like "more details here", with the word "here" linking to https://datascience.utah.edu/seminar

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've implemented all the requested message format improvements:

  • ✅ Date/day context: "Join us Wednesday, Sep 3" for Monday messages, "Join us today" for Wednesday messages
  • ✅ Speaker extraction from event descriptions using regex patterns
  • ✅ Title inclusion in the formatted message
  • ✅ Updated hashtags to use #datascience and #ai specifically
  • ✅ Implemented Bluesky richtext facets for proper linking - "More details here" with "here" linking to https://datascience.utah.edu/seminar

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()
45 changes: 45 additions & 0 deletions .github/workflows/bluesky-post.yml
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 warning

Code 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 Autofix

AI 8 months ago

To fix the problem, explicitly set the minimal necessary permissions for the GITHUB_TOKEN used in this workflow to avoid using the potentially broader repository or organization-wide defaults. This is achieved by adding a permissions: block in the workflow yaml. Place the permissions: block at the root-level of the YAML file to apply to all jobs within the workflow, unless some jobs individually require more/other permissions, which is not the case here. The most restrictive reasonable value for workflows that only read repository contents is permissions: contents: read. This should be added after the name: and before the on: (trigger) block, typically at the top of the file.

Suggested changeset 1
.github/workflows/bluesky-post.yml

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/.github/workflows/bluesky-post.yml b/.github/workflows/bluesky-post.yml
--- a/.github/workflows/bluesky-post.yml
+++ b/.github/workflows/bluesky-post.yml
@@ -1,4 +1,6 @@
 name: Post to Bluesky
+permissions:
+  contents: read
 
 on:
   schedule:
EOF
@@ -1,4 +1,6 @@
name: Post to Bluesky
permissions:
contents: read

on:
schedule:
Copilot is powered by AI and may make mistakes. Always verify output.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,8 @@ _site

#pycharm
.idea

# Python
__pycache__/
*.py[cod]
*$py.class