From 47aea1bf261f40ad4c38bc061c2a49b4a41e6b67 Mon Sep 17 00:00:00 2001 From: Jeremy Manning Date: Sun, 7 Dec 2025 06:17:30 -0500 Subject: [PATCH 1/7] Add CDL onboarding Slack bot infrastructure (#181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements automated onboarding/offboarding workflows: Services: - GitHubService: Org invitations, team management, username validation - CalendarService: Google Calendar sharing with configurable permissions - ImageService: Hand-drawn green border processing for profile photos - BioService: Claude API integration for bio editing to CDL style Handlers: - /cdl-onboard command with interactive forms - Admin approval workflow with team selection - /cdl-offboard command (generates checklist, doesn't auto-remove) Tests: - Model tests (13 passing) - Image service tests (19 passing) - GitHub service tests (requires API token) - Bio service tests (requires Anthropic key) See issue #181 for full implementation plan. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 38 ++ scripts/onboarding/.env.example | 59 ++ scripts/onboarding/__init__.py | 8 + scripts/onboarding/bot.py | 169 +++++ scripts/onboarding/config.py | 185 ++++++ scripts/onboarding/handlers/__init__.py | 1 + scripts/onboarding/handlers/approval.py | 425 ++++++++++++ scripts/onboarding/handlers/offboard.py | 416 ++++++++++++ scripts/onboarding/handlers/onboard.py | 621 ++++++++++++++++++ scripts/onboarding/models/__init__.py | 5 + .../onboarding/models/onboarding_request.py | 146 ++++ scripts/onboarding/requirements.txt | 27 + scripts/onboarding/services/__init__.py | 8 + scripts/onboarding/services/bio_service.py | 237 +++++++ .../onboarding/services/calendar_service.py | 231 +++++++ scripts/onboarding/services/github_service.py | 248 +++++++ scripts/onboarding/services/image_service.py | 322 +++++++++ tests/test_onboarding/__init__.py | 12 + tests/test_onboarding/conftest.py | 95 +++ tests/test_onboarding/test_bio_service.py | 288 ++++++++ tests/test_onboarding/test_github_service.py | 212 ++++++ tests/test_onboarding/test_image_service.py | 365 ++++++++++ tests/test_onboarding/test_models.py | 264 ++++++++ 23 files changed, 4382 insertions(+) create mode 100644 scripts/onboarding/.env.example create mode 100644 scripts/onboarding/__init__.py create mode 100644 scripts/onboarding/bot.py create mode 100644 scripts/onboarding/config.py create mode 100644 scripts/onboarding/handlers/__init__.py create mode 100644 scripts/onboarding/handlers/approval.py create mode 100644 scripts/onboarding/handlers/offboard.py create mode 100644 scripts/onboarding/handlers/onboard.py create mode 100644 scripts/onboarding/models/__init__.py create mode 100644 scripts/onboarding/models/onboarding_request.py create mode 100644 scripts/onboarding/requirements.txt create mode 100644 scripts/onboarding/services/__init__.py create mode 100644 scripts/onboarding/services/bio_service.py create mode 100644 scripts/onboarding/services/calendar_service.py create mode 100644 scripts/onboarding/services/github_service.py create mode 100644 scripts/onboarding/services/image_service.py create mode 100644 tests/test_onboarding/__init__.py create mode 100644 tests/test_onboarding/conftest.py create mode 100644 tests/test_onboarding/test_bio_service.py create mode 100644 tests/test_onboarding/test_github_service.py create mode 100644 tests/test_onboarding/test_image_service.py create mode 100644 tests/test_onboarding/test_models.py diff --git a/.gitignore b/.gitignore index d68e292..3ba8185 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,41 @@ html_test/ # Playwright MCP screenshots .playwright-mcp/ + +# CDL Onboarding Bot +scripts/onboarding/.env +scripts/onboarding/output/ +scripts/onboarding/*.json +*.credentials.json +service-account*.json + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +.pytest_cache/ +.coverage +htmlcov/ + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ diff --git a/scripts/onboarding/.env.example b/scripts/onboarding/.env.example new file mode 100644 index 0000000..ce629c8 --- /dev/null +++ b/scripts/onboarding/.env.example @@ -0,0 +1,59 @@ +# CDL Onboarding Bot Configuration +# Copy this file to .env and fill in the values +# NEVER commit the actual .env file to the repository + +# ============================================================================= +# REQUIRED: Slack Configuration +# ============================================================================= + +# Slack Bot OAuth Token (starts with xoxb-) +# Get from: https://api.slack.com/apps > Your App > OAuth & Permissions +SLACK_BOT_TOKEN=xoxb-your-bot-token + +# Slack App-Level Token for Socket Mode (starts with xapp-) +# Get from: https://api.slack.com/apps > Your App > Basic Information > App-Level Tokens +SLACK_APP_TOKEN=xapp-your-app-token + +# Slack User ID of the admin (lab director) +# Find by clicking on profile > More > Copy member ID +SLACK_ADMIN_USER_ID=U12345678 + +# ============================================================================= +# REQUIRED: GitHub Configuration +# ============================================================================= + +# GitHub Personal Access Token with admin:org scope +# Create at: https://github.com/settings/tokens +# Required scopes: admin:org, repo (for team management) +GITHUB_TOKEN=ghp_your_token_here + +# GitHub Organization name (default: ContextLab) +GITHUB_ORG_NAME=ContextLab + +# Default team to add new members to +GITHUB_DEFAULT_TEAM=Lab default + +# ============================================================================= +# OPTIONAL: Google Calendar Configuration +# ============================================================================= + +# Path to Google service account credentials JSON file +# Create at: https://console.cloud.google.com/iam-admin/serviceaccounts +# The service account needs to be granted access to each calendar +GOOGLE_CREDENTIALS_FILE=/path/to/service-account.json + +# Calendar IDs (found in calendar settings > Integrate calendar) +GOOGLE_CALENDAR_CONTEXTUAL_DYNAMICS_LAB=primary@group.calendar.google.com +GOOGLE_CALENDAR_OUT_OF_LAB=outoflab@group.calendar.google.com +GOOGLE_CALENDAR_CDL_RESOURCES=resources@group.calendar.google.com + +# ============================================================================= +# OPTIONAL: Anthropic Configuration (for bio editing) +# ============================================================================= + +# Anthropic API key for Claude +# Get from: https://console.anthropic.com/account/keys +ANTHROPIC_API_KEY=sk-ant-your-key-here + +# Model to use (default: claude-sonnet-4-20250514) +ANTHROPIC_MODEL=claude-sonnet-4-20250514 diff --git a/scripts/onboarding/__init__.py b/scripts/onboarding/__init__.py new file mode 100644 index 0000000..b5a8e06 --- /dev/null +++ b/scripts/onboarding/__init__.py @@ -0,0 +1,8 @@ +""" +CDL Onboarding Bot +Contextual Dynamics Laboratory, Dartmouth College + +Automates the lab member onboarding process via Slack. +""" + +__version__ = "0.1.0" diff --git a/scripts/onboarding/bot.py b/scripts/onboarding/bot.py new file mode 100644 index 0000000..dafd12b --- /dev/null +++ b/scripts/onboarding/bot.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +""" +CDL Onboarding Bot - Main Entry Point + +A Slack bot for automating the onboarding process for new CDL lab members. + +Features: +- /cdl-onboard @user - Start onboarding a new member (admin only) +- /cdl-offboard - Start offboarding process (self or admin) +- Interactive forms for collecting member information +- Admin approval workflow for all actions +- GitHub organization invitations +- Google Calendar sharing +- Photo processing (hand-drawn green borders) +- Bio editing via Claude API + +Usage: + python -m scripts.onboarding.bot + +Environment Variables Required: + SLACK_BOT_TOKEN - Slack bot OAuth token (xoxb-...) + SLACK_APP_TOKEN - Slack app-level token (xapp-...) + SLACK_ADMIN_USER_ID - Slack user ID of the admin + GITHUB_TOKEN - GitHub personal access token with admin:org scope + +Optional Environment Variables: + GOOGLE_CREDENTIALS_FILE - Path to Google service account JSON + GOOGLE_CALENDAR_* - Calendar IDs for each calendar + ANTHROPIC_API_KEY - For bio editing feature +""" + +import logging +import sys +from pathlib import Path + +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +from .config import get_config, Config +from .handlers.onboard import register_onboard_handlers +from .handlers.approval import register_approval_handlers +from .handlers.offboard import register_offboard_handlers + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[ + logging.StreamHandler(sys.stdout), + ], +) +logger = logging.getLogger(__name__) + + +def create_app(config: Config) -> App: + """ + Create and configure the Slack Bolt app. + + Args: + config: Application configuration + + Returns: + Configured Slack Bolt App instance + """ + app = App(token=config.slack.bot_token) + + # Register all handlers + register_onboard_handlers(app, config) + register_approval_handlers(app, config) + register_offboard_handlers(app, config) + + # Add a health check command + @app.command("/cdl-ping") + def handle_ping(ack, respond): + """Simple health check command.""" + ack() + respond("Pong! CDL Onboarding Bot is running.") + + # Add help command + @app.command("/cdl-help") + def handle_help(ack, respond, command): + """Show help information.""" + ack() + + user_id = command["user_id"] + is_admin = user_id == config.slack.admin_user_id + + help_text = """*CDL Onboarding Bot Help* + +*Available Commands:* + +`/cdl-onboard @user` - Start onboarding a new lab member + • Opens a form for the member to fill out their info + • Collects GitHub username, bio, photo, and website URL + • Sends request to admin for approval + +`/cdl-offboard` - Start the offboarding process + • Can be initiated by the member leaving or by admin + • Admin selects which access to revoke + • Generates a checklist for manual steps + +`/cdl-ping` - Check if the bot is running + +`/cdl-help` - Show this help message +""" + + if is_admin: + help_text += """ +*Admin-Only Features:* +• Approve/reject onboarding requests +• Select GitHub teams for new members +• Initiate offboarding for any member +• Request changes to submitted information +""" + + respond(help_text) + + # Log errors + @app.error + def handle_error(error, body, logger): + """Handle errors in Slack event processing.""" + logger.exception(f"Error processing Slack event: {error}") + logger.debug(f"Request body: {body}") + + logger.info("Slack app created and handlers registered") + return app + + +def main(): + """Main entry point for the bot.""" + logger.info("Starting CDL Onboarding Bot...") + + try: + config = get_config() + logger.info("Configuration loaded successfully") + + # Log which optional services are available + if config.google_calendar: + logger.info("Google Calendar integration enabled") + else: + logger.warning("Google Calendar integration not configured") + + if config.anthropic: + logger.info("Anthropic bio editing enabled") + else: + logger.warning("Anthropic bio editing not configured") + + # Create the app + app = create_app(config) + + # Start the bot in Socket Mode + handler = SocketModeHandler(app, config.slack.app_token) + logger.info("Bot started in Socket Mode. Press Ctrl+C to stop.") + handler.start() + + except ValueError as e: + logger.error(f"Configuration error: {e}") + logger.error("Please check your environment variables and .env file") + sys.exit(1) + except KeyboardInterrupt: + logger.info("Bot stopped by user") + sys.exit(0) + except Exception as e: + logger.exception(f"Unexpected error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/onboarding/config.py b/scripts/onboarding/config.py new file mode 100644 index 0000000..5da5d18 --- /dev/null +++ b/scripts/onboarding/config.py @@ -0,0 +1,185 @@ +""" +Configuration management for the CDL Onboarding Bot. + +Loads credentials from environment variables or .env file. +Never commit credentials to the repository. +""" + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +# Try to load dotenv if available +try: + from dotenv import load_dotenv + # Look for .env in the onboarding directory or repo root + env_paths = [ + Path(__file__).parent / ".env", + Path(__file__).parent.parent.parent / ".env", + ] + for env_path in env_paths: + if env_path.exists(): + load_dotenv(env_path) + break +except ImportError: + pass # dotenv not installed, rely on environment variables + + +@dataclass +class SlackConfig: + """Slack bot configuration.""" + bot_token: str # xoxb-... + app_token: str # xapp-... + admin_user_id: str # Slack user ID of the admin (Jeremy) + + @classmethod + def from_env(cls) -> "SlackConfig": + """Load Slack configuration from environment variables.""" + bot_token = os.environ.get("SLACK_BOT_TOKEN") + app_token = os.environ.get("SLACK_APP_TOKEN") + admin_user_id = os.environ.get("SLACK_ADMIN_USER_ID") + + if not bot_token: + raise ValueError("SLACK_BOT_TOKEN environment variable is required") + if not app_token: + raise ValueError("SLACK_APP_TOKEN environment variable is required") + if not admin_user_id: + raise ValueError("SLACK_ADMIN_USER_ID environment variable is required") + + return cls( + bot_token=bot_token, + app_token=app_token, + admin_user_id=admin_user_id, + ) + + +@dataclass +class GitHubConfig: + """GitHub configuration.""" + token: str # Personal access token with admin:org scope + org_name: str = "ContextLab" + default_team: str = "Lab default" + + @classmethod + def from_env(cls) -> "GitHubConfig": + """Load GitHub configuration from environment variables.""" + token = os.environ.get("GITHUB_TOKEN") + + if not token: + raise ValueError("GITHUB_TOKEN environment variable is required") + + return cls( + token=token, + org_name=os.environ.get("GITHUB_ORG_NAME", "ContextLab"), + default_team=os.environ.get("GITHUB_DEFAULT_TEAM", "Lab default"), + ) + + +@dataclass +class GoogleCalendarConfig: + """Google Calendar configuration.""" + credentials_file: str # Path to service account JSON file + calendars: dict # Calendar names to IDs mapping + + # Default calendar permissions + DEFAULT_PERMISSIONS = { + "Contextual Dynamics Lab": "reader", # Read-only + "Out of lab": "writer", # Edit + "CDL Resources": "writer", # Edit + } + + @classmethod + def from_env(cls) -> "GoogleCalendarConfig": + """Load Google Calendar configuration from environment variables.""" + credentials_file = os.environ.get("GOOGLE_CREDENTIALS_FILE") + + if not credentials_file: + raise ValueError("GOOGLE_CREDENTIALS_FILE environment variable is required") + + if not Path(credentials_file).exists(): + raise ValueError(f"Google credentials file not found: {credentials_file}") + + # Calendar IDs should be set as environment variables + calendars = {} + for name in ["Contextual Dynamics Lab", "Out of lab", "CDL Resources"]: + env_key = f"GOOGLE_CALENDAR_{name.upper().replace(' ', '_')}" + calendar_id = os.environ.get(env_key) + if calendar_id: + calendars[name] = calendar_id + + return cls( + credentials_file=credentials_file, + calendars=calendars, + ) + + +@dataclass +class AnthropicConfig: + """Anthropic API configuration for bio editing.""" + api_key: str + model: str = "claude-sonnet-4-20250514" + + @classmethod + def from_env(cls) -> "AnthropicConfig": + """Load Anthropic configuration from environment variables.""" + api_key = os.environ.get("ANTHROPIC_API_KEY") + + if not api_key: + raise ValueError("ANTHROPIC_API_KEY environment variable is required") + + return cls( + api_key=api_key, + model=os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-20250514"), + ) + + +@dataclass +class Config: + """Main configuration container.""" + slack: SlackConfig + github: GitHubConfig + google_calendar: Optional[GoogleCalendarConfig] + anthropic: Optional[AnthropicConfig] + + # Image processing settings + border_color: tuple = (0, 105, 62) # Dartmouth green RGB + border_width: int = 8 + + # Local storage for processed files + output_dir: Path = Path(__file__).parent / "output" + + @classmethod + def from_env(cls) -> "Config": + """Load all configuration from environment variables.""" + # Required configs + slack = SlackConfig.from_env() + github = GitHubConfig.from_env() + + # Optional configs (gracefully handle missing) + try: + google_calendar = GoogleCalendarConfig.from_env() + except ValueError: + google_calendar = None + + try: + anthropic = AnthropicConfig.from_env() + except ValueError: + anthropic = None + + config = cls( + slack=slack, + github=github, + google_calendar=google_calendar, + anthropic=anthropic, + ) + + # Ensure output directory exists + config.output_dir.mkdir(parents=True, exist_ok=True) + + return config + + +def get_config() -> Config: + """Get the current configuration.""" + return Config.from_env() diff --git a/scripts/onboarding/handlers/__init__.py b/scripts/onboarding/handlers/__init__.py new file mode 100644 index 0000000..9cc6c0a --- /dev/null +++ b/scripts/onboarding/handlers/__init__.py @@ -0,0 +1 @@ +"""Slack event and command handlers.""" diff --git a/scripts/onboarding/handlers/approval.py b/scripts/onboarding/handlers/approval.py new file mode 100644 index 0000000..72dc22a --- /dev/null +++ b/scripts/onboarding/handlers/approval.py @@ -0,0 +1,425 @@ +""" +Approval workflow handlers for Slack. + +Handles admin approval/rejection of onboarding requests. +""" + +import logging +from typing import Optional + +from slack_bolt import App +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from ..config import Config, GoogleCalendarConfig +from ..models.onboarding_request import OnboardingRequest, OnboardingStatus +from ..services.github_service import GitHubService +from ..services.calendar_service import CalendarService +from .onboard import get_request, save_request, delete_request + +logger = logging.getLogger(__name__) + + +def register_approval_handlers(app: App, config: Config): + """Register all approval-related handlers with the Slack app.""" + + github_service = GitHubService(config.github.token, config.github.org_name) + + calendar_service = None + if config.google_calendar: + calendar_service = CalendarService( + config.google_calendar.credentials_file, + config.google_calendar.calendars, + ) + + @app.action("approve_onboarding") + def handle_approve(ack, body, client: WebClient, action): + """Handle approval of an onboarding request.""" + ack() + + user_id = action["value"] + admin_id = body["user"]["id"] + + # Verify admin + if admin_id != config.slack.admin_user_id: + return + + request = get_request(user_id) + if not request: + logger.error(f"No request found for user {user_id}") + return + + # Get selected teams from the message + # Parse from the state or use defaults + selected_team_ids = _get_selected_teams(body) + + request.github_teams = selected_team_ids + request.approved_by = admin_id + request.update_status(OnboardingStatus.GITHUB_PENDING) + save_request(request) + + # Update the approval message + _update_approval_message(client, body, "Approved", request) + + # Process the approval + _process_approval(client, config, request, github_service, calendar_service) + + @app.action("reject_onboarding") + def handle_reject(ack, body, client: WebClient, action): + """Handle rejection of an onboarding request.""" + ack() + + user_id = action["value"] + admin_id = body["user"]["id"] + + # Verify admin + if admin_id != config.slack.admin_user_id: + return + + request = get_request(user_id) + if not request: + return + + request.update_status(OnboardingStatus.REJECTED) + save_request(request) + + # Update the approval message + _update_approval_message(client, body, "Rejected", request) + + # Notify the user + try: + client.chat_postMessage( + channel=request.slack_channel_id, + text="Your onboarding request was not approved. Please contact the lab admin for more information.", + ) + except SlackApiError as e: + logger.error(f"Error notifying user of rejection: {e}") + + # Clean up + delete_request(user_id) + + @app.action("request_changes_onboarding") + def handle_request_changes(ack, body, client: WebClient, action): + """Handle request for changes to an onboarding request.""" + ack() + + user_id = action["value"] + admin_id = body["user"]["id"] + + # Verify admin + if admin_id != config.slack.admin_user_id: + return + + request = get_request(user_id) + if not request: + return + + # Open a modal for the admin to specify what changes are needed + try: + client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": f"request_changes_modal_{user_id}", + "private_metadata": user_id, + "title": {"type": "plain_text", "text": "Request Changes"}, + "submit": {"type": "plain_text", "text": "Send"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "blocks": [ + { + "type": "input", + "block_id": "changes_block", + "element": { + "type": "plain_text_input", + "action_id": "changes_input", + "multiline": True, + "placeholder": { + "type": "plain_text", + "text": "Describe the changes needed...", + }, + }, + "label": { + "type": "plain_text", + "text": "What changes are needed?", + }, + }, + ], + }, + ) + except SlackApiError as e: + logger.error(f"Error opening changes modal: {e}") + + @app.view_regex(r"request_changes_modal_.*") + def handle_changes_modal(ack, body, client: WebClient, view): + """Handle submission of the request changes modal.""" + ack() + + user_id = view["private_metadata"] + request = get_request(user_id) + if not request: + return + + changes_text = view["state"]["values"]["changes_block"]["changes_input"]["value"] + + request.update_status(OnboardingStatus.PENDING_INFO) + save_request(request) + + # Notify the user + try: + client.chat_postMessage( + channel=request.slack_channel_id, + text="The admin has requested some changes to your onboarding information.", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":memo: *Changes Requested*\n\nThe lab admin has requested the following changes:", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f">{changes_text}", + }, + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Update Information"}, + "action_id": "open_onboarding_form", + }, + ], + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error notifying user of changes: {e}") + + @app.action("github_teams_select") + def handle_teams_select(ack, body): + """Handle GitHub team selection (just acknowledge, we'll read the value on approval).""" + ack() + + +def _get_selected_teams(body: dict) -> list[int]: + """Extract selected team IDs from the message state.""" + try: + state = body.get("state", {}).get("values", {}) + for block_id, block_data in state.items(): + if "github_teams_select" in block_data: + selected = block_data["github_teams_select"].get("selected_options", []) + return [int(opt["value"]) for opt in selected] + except (KeyError, ValueError) as e: + logger.warning(f"Error parsing team selection: {e}") + + return [] + + +def _update_approval_message(client: WebClient, body: dict, status: str, request: OnboardingRequest): + """Update the approval message to show the result.""" + channel = body["channel"]["id"] + ts = body["message"]["ts"] + + try: + client.chat_update( + channel=channel, + ts=ts, + text=f"Onboarding request from {request.name} - {status}", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f":white_check_mark: *Onboarding Request - {status}*\n\n" + f"*Member:* {request.name} (<@{request.slack_user_id}>)\n" + f"*GitHub:* `{request.github_username}`", + }, + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"Status: {request.status.value}", + }, + ], + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error updating approval message: {e}") + + +def _process_approval( + client: WebClient, + config: Config, + request: OnboardingRequest, + github_service: GitHubService, + calendar_service: Optional[CalendarService], +): + """Process an approved onboarding request.""" + results = [] + errors = [] + + # 1. Send GitHub invitation + success, error = github_service.invite_user( + request.github_username, + request.github_teams, + ) + if success: + results.append(f":white_check_mark: GitHub invitation sent to `{request.github_username}`") + request.github_invitation_sent = True + else: + errors.append(f":x: GitHub invitation failed: {error}") + save_request(request) + + # 2. Send calendar invitations + if calendar_service and request.email: + request.update_status(OnboardingStatus.CALENDAR_PENDING) + save_request(request) + + # Use default permissions + permissions = GoogleCalendarConfig.DEFAULT_PERMISSIONS.copy() + request.calendar_permissions = permissions + + calendar_results = calendar_service.share_multiple_calendars( + email=request.email, + calendar_permissions=permissions, + ) + + for calendar_name, (cal_success, cal_error) in calendar_results.items(): + if cal_success: + results.append(f":white_check_mark: Calendar '{calendar_name}' shared") + else: + errors.append(f":x: Calendar '{calendar_name}' failed: {cal_error}") + + request.calendar_invites_sent = True + save_request(request) + else: + if not calendar_service: + errors.append(":warning: Calendar service not configured") + if not request.email: + errors.append(":warning: No email address for calendar invitations") + + # 3. Prepare website content + request.update_status(OnboardingStatus.READY_FOR_WEBSITE) + save_request(request) + + website_ready = bool(request.bio_edited and request.photo_processed_path) + if website_ready: + results.append(":white_check_mark: Photo and bio ready for website") + else: + missing = [] + if not request.bio_edited: + missing.append("edited bio") + if not request.photo_processed_path: + missing.append("processed photo") + errors.append(f":warning: Website content incomplete: missing {', '.join(missing)}") + + # Notify admin of results + summary_blocks = [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": f"Onboarding Progress: {request.name}", + }, + }, + ] + + if results: + summary_blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Completed:*\n" + "\n".join(results), + }, + }) + + if errors: + summary_blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Issues:*\n" + "\n".join(errors), + }, + }) + + # Add website update instructions if ready + if website_ready: + summary_blocks.append({"type": "divider"}) + summary_blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Website Update:*\n" + f"The processed photo has been saved to: `{request.photo_processed_path}`\n\n" + f"*Edited bio:*\n>{request.bio_edited}", + }, + }) + + try: + client.chat_postMessage( + channel=config.slack.admin_user_id, + text=f"Onboarding progress for {request.name}", + blocks=summary_blocks, + ) + except SlackApiError as e: + logger.error(f"Error sending progress update: {e}") + + # Notify the new member + member_blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":tada: *Your onboarding has been approved!*", + }, + }, + ] + + if request.github_invitation_sent: + member_blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":octocat: *GitHub:* Check your email for an invitation to join the ContextLab organization. " + "Once you accept, you'll have access to our repositories.", + }, + }) + + if request.calendar_invites_sent: + member_blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":calendar: *Calendars:* You should receive invitations to the lab calendars shortly. " + "Add them to your Google Calendar to stay up to date.", + }, + }) + + member_blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":globe_with_meridians: *Website:* Your profile will be added to context-lab.com soon!", + }, + }) + + try: + client.chat_postMessage( + channel=request.slack_channel_id, + text="Your onboarding has been approved!", + blocks=member_blocks, + ) + except SlackApiError as e: + logger.error(f"Error notifying member: {e}") + + # Mark as completed + if not errors: + request.update_status(OnboardingStatus.COMPLETED) + save_request(request) diff --git a/scripts/onboarding/handlers/offboard.py b/scripts/onboarding/handlers/offboard.py new file mode 100644 index 0000000..e75fc30 --- /dev/null +++ b/scripts/onboarding/handlers/offboard.py @@ -0,0 +1,416 @@ +""" +Offboarding workflow handlers for Slack. + +Handles the process of removing lab members: +- Prompts admin for what access to revoke +- Does NOT automatically remove anyone (per requirements) +- Provides guidance for manual steps (website removal) +""" + +import logging +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional + +from slack_bolt import App +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from ..config import Config + +logger = logging.getLogger(__name__) + + +@dataclass +class OffboardingRequest: + """Tracks an offboarding request.""" + slack_user_id: str + name: str + initiated_by: str + github_username: str = "" + email: str = "" + remove_github: bool = False + remove_calendars: bool = False + created_at: datetime = field(default_factory=datetime.now) + + +# In-memory storage for offboarding requests +_offboarding_requests: dict[str, OffboardingRequest] = {} + + +def register_offboard_handlers(app: App, config: Config): + """Register all offboarding-related handlers with the Slack app.""" + + @app.command("/cdl-offboard") + def handle_offboard_command(ack, command, client: WebClient, respond): + """Handle the /cdl-offboard slash command.""" + ack() + + user_id = command["user_id"] + text = command.get("text", "").strip() + + # This command can be initiated by the member themselves or the admin + is_admin = user_id == config.slack.admin_user_id + + # If text contains a user mention and caller is admin, offboard that user + target_user_id = user_id # Default to self + if text.startswith("<@") and ">" in text and is_admin: + target_user_id = text.split("<@")[1].split("|")[0].split(">")[0] + + # Get user info + try: + user_info = client.users_info(user=target_user_id) + user_name = user_info["user"]["real_name"] or user_info["user"]["name"] + user_email = user_info["user"].get("profile", {}).get("email", "") + except SlackApiError as e: + respond(f"Error getting user info: {e}") + return + + # If self-initiated, send request to admin + if target_user_id == user_id and not is_admin: + _send_offboarding_request_to_admin(client, config, target_user_id, user_name, user_email) + respond( + "Your offboarding request has been sent to the lab admin. " + "They will confirm what access should be revoked or retained." + ) + return + + # If admin-initiated, show the confirmation dialog + _send_offboarding_confirmation(client, config, target_user_id, user_name, user_email, command["trigger_id"]) + respond(f"Opening offboarding options for {user_name}...") + + @app.action("confirm_offboarding") + def handle_confirm_offboarding(ack, body, client: WebClient, action): + """Handle confirmation of offboarding actions.""" + ack() + + admin_id = body["user"]["id"] + if admin_id != config.slack.admin_user_id: + return + + user_id = action["value"] + request = _offboarding_requests.get(user_id) + + if not request: + logger.error(f"No offboarding request found for {user_id}") + return + + # Get checkbox selections from the state + state = body.get("state", {}).get("values", {}) + + remove_github = False + remove_calendars = False + + for block_id, block_data in state.items(): + if "offboard_options" in block_data: + selected = block_data["offboard_options"].get("selected_options", []) + for opt in selected: + if opt["value"] == "github": + remove_github = True + elif opt["value"] == "calendars": + remove_calendars = True + + request.remove_github = remove_github + request.remove_calendars = remove_calendars + + # Process the offboarding + _process_offboarding(client, config, request) + + # Update the message + _update_offboarding_message(client, body, request) + + @app.action("cancel_offboarding") + def handle_cancel_offboarding(ack, body, client: WebClient, action): + """Handle cancellation of offboarding.""" + ack() + + user_id = action["value"] + _offboarding_requests.pop(user_id, None) + + # Update the message + channel = body["channel"]["id"] + ts = body["message"]["ts"] + + try: + client.chat_update( + channel=channel, + ts=ts, + text="Offboarding cancelled", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":x: Offboarding cancelled. No changes were made.", + }, + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error updating message: {e}") + + @app.action("offboard_options") + def handle_offboard_options(ack): + """Handle checkbox selection (just acknowledge).""" + ack() + + +def _send_offboarding_request_to_admin( + client: WebClient, + config: Config, + user_id: str, + user_name: str, + user_email: str, +): + """Send an offboarding request to the admin for confirmation.""" + request = OffboardingRequest( + slack_user_id=user_id, + name=user_name, + initiated_by=user_id, + email=user_email, + ) + _offboarding_requests[user_id] = request + + blocks = [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":wave: Offboarding Request", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{user_name}* (<@{user_id}>) has initiated the offboarding process.", + }, + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Select what access to revoke:*\n\n" + "_Note: Some lab members may continue to collaborate on projects after leaving. " + "Only revoke access that is no longer needed._", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Select options:", + }, + "accessory": { + "type": "checkboxes", + "action_id": "offboard_options", + "options": [ + { + "text": {"type": "plain_text", "text": "Remove from GitHub organization"}, + "value": "github", + "description": {"type": "plain_text", "text": "Revoke access to ContextLab repos"}, + }, + { + "text": {"type": "plain_text", "text": "Remove calendar access"}, + "value": "calendars", + "description": {"type": "plain_text", "text": "Revoke access to lab calendars"}, + }, + ], + }, + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": ":information_source: Website profile removal must be done manually in Squarespace " + "(or will be automated once the GitHub Pages migration is complete).", + }, + ], + }, + {"type": "divider"}, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Confirm Offboarding"}, + "style": "danger", + "action_id": "confirm_offboarding", + "value": user_id, + "confirm": { + "title": {"type": "plain_text", "text": "Confirm Offboarding"}, + "text": { + "type": "mrkdwn", + "text": f"Are you sure you want to proceed with offboarding {user_name}?", + }, + "confirm": {"type": "plain_text", "text": "Yes, proceed"}, + "deny": {"type": "plain_text", "text": "Cancel"}, + }, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Cancel"}, + "action_id": "cancel_offboarding", + "value": user_id, + }, + ], + }, + ] + + try: + client.chat_postMessage( + channel=config.slack.admin_user_id, + text=f"Offboarding request from {user_name}", + blocks=blocks, + ) + except SlackApiError as e: + logger.error(f"Error sending offboarding request: {e}") + + +def _send_offboarding_confirmation( + client: WebClient, + config: Config, + user_id: str, + user_name: str, + user_email: str, + trigger_id: str, +): + """Show offboarding confirmation dialog (admin-initiated).""" + # This is the same as the member-initiated flow but sent directly + _send_offboarding_request_to_admin(client, config, user_id, user_name, user_email) + + +def _process_offboarding(client: WebClient, config: Config, request: OffboardingRequest): + """Process the offboarding actions.""" + results = [] + errors = [] + + # Note: We intentionally do NOT automatically remove users + # We just prepare instructions for the admin + + if request.remove_github: + results.append( + f":octocat: *GitHub:* Please manually remove `{request.github_username or request.name}` " + f"from the ContextLab organization at:\n" + f"https://github.com/orgs/ContextLab/people" + ) + + if request.remove_calendars: + results.append( + f":calendar: *Calendars:* Please remove `{request.email}` from the following calendars:\n" + f"• Contextual Dynamics Lab\n" + f"• Out of lab\n" + f"• CDL Resources" + ) + + # Always include website instructions + results.append( + f":globe_with_meridians: *Website:* Please remove {request.name}'s profile from:\n" + f"https://www.context-lab.com/people\n" + f"(Or from the GitHub Pages people-site repo once migrated)" + ) + + # Send summary to admin + summary_blocks = [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": f"Offboarding Checklist: {request.name}", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Please complete the following manual steps:", + }, + }, + ] + + for item in results: + summary_blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": item, + }, + }) + + try: + client.chat_postMessage( + channel=config.slack.admin_user_id, + text=f"Offboarding checklist for {request.name}", + blocks=summary_blocks, + ) + except SlackApiError as e: + logger.error(f"Error sending offboarding checklist: {e}") + + # Notify the departing member + try: + dm_response = client.conversations_open(users=[request.slack_user_id]) + dm_channel = dm_response["channel"]["id"] + + client.chat_postMessage( + channel=dm_channel, + text="Your offboarding has been processed", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":wave: *Offboarding Confirmed*\n\n" + "The lab admin has been notified and will process your offboarding. " + "Thank you for your contributions to the CDL!", + }, + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "If you have any questions or need continued access for ongoing collaborations, " + "please contact the lab admin.", + }, + ], + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error notifying departing member: {e}") + + +def _update_offboarding_message(client: WebClient, body: dict, request: OffboardingRequest): + """Update the offboarding message to show completion.""" + channel = body["channel"]["id"] + ts = body["message"]["ts"] + + actions = [] + if request.remove_github: + actions.append("GitHub access") + if request.remove_calendars: + actions.append("Calendar access") + + actions_text = ", ".join(actions) if actions else "No access revoked" + + try: + client.chat_update( + channel=channel, + ts=ts, + text=f"Offboarding processed for {request.name}", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f":white_check_mark: *Offboarding Processed: {request.name}*\n\n" + f"Actions to take: {actions_text}\n" + f"A checklist has been sent with manual steps to complete.", + }, + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error updating offboarding message: {e}") diff --git a/scripts/onboarding/handlers/onboard.py b/scripts/onboarding/handlers/onboard.py new file mode 100644 index 0000000..831dc37 --- /dev/null +++ b/scripts/onboarding/handlers/onboard.py @@ -0,0 +1,621 @@ +""" +Onboarding workflow handlers for Slack. + +Manages the multi-step onboarding process: +1. Collect member information +2. Validate GitHub username +3. Request admin approval +4. Process invitations and bio/photo +""" + +import json +import logging +import os +import tempfile +from pathlib import Path +from typing import Optional + +from slack_bolt import App +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from ..config import Config +from ..models.onboarding_request import OnboardingRequest, OnboardingStatus +from ..services.github_service import GitHubService +from ..services.image_service import ImageService +from ..services.bio_service import BioService + +logger = logging.getLogger(__name__) + +# In-memory storage for active onboarding requests +# In production, this should be persisted to a database +_active_requests: dict[str, OnboardingRequest] = {} + + +def get_request(user_id: str) -> Optional[OnboardingRequest]: + """Get an active onboarding request for a user.""" + return _active_requests.get(user_id) + + +def save_request(request: OnboardingRequest): + """Save an onboarding request.""" + _active_requests[request.slack_user_id] = request + + +def delete_request(user_id: str): + """Delete an onboarding request.""" + _active_requests.pop(user_id, None) + + +def register_onboard_handlers(app: App, config: Config): + """Register all onboarding-related handlers with the Slack app.""" + + github_service = GitHubService(config.github.token, config.github.org_name) + image_service = ImageService(config.border_color, config.border_width) + bio_service = None + if config.anthropic: + bio_service = BioService(config.anthropic.api_key, config.anthropic.model) + + # Slash command to start onboarding + @app.command("/cdl-onboard") + def handle_onboard_command(ack, command, client: WebClient, respond): + """Handle the /cdl-onboard slash command.""" + ack() + + user_id = command["user_id"] + text = command.get("text", "").strip() + + # Check if user is admin + if user_id != config.slack.admin_user_id: + respond("Only the lab admin can initiate onboarding.") + return + + # Parse mentioned user if provided + target_user_id = None + if text.startswith("<@") and ">" in text: + # Extract user ID from mention like <@U12345|username> + target_user_id = text.split("<@")[1].split("|")[0].split(">")[0] + + if not target_user_id: + respond( + "Please specify the Slack user to onboard. " + "Usage: `/cdl-onboard @username`" + ) + return + + # Get user info + try: + user_info = client.users_info(user=target_user_id) + user_name = user_info["user"]["real_name"] or user_info["user"]["name"] + user_email = user_info["user"].get("profile", {}).get("email", "") + except SlackApiError as e: + respond(f"Error getting user info: {e}") + return + + # Check if user already has an active request + existing_request = get_request(target_user_id) + if existing_request: + respond( + f"User <@{target_user_id}> already has an active onboarding request " + f"(status: {existing_request.status.value})" + ) + return + + # Open DM with the new member + try: + dm_response = client.conversations_open(users=[target_user_id]) + dm_channel = dm_response["channel"]["id"] + except SlackApiError as e: + respond(f"Error opening DM with user: {e}") + return + + # Create onboarding request + request = OnboardingRequest( + slack_user_id=target_user_id, + slack_channel_id=dm_channel, + name=user_name, + email=user_email, + ) + save_request(request) + + # Send welcome message to the new member + welcome_blocks = _build_welcome_message(user_name) + try: + client.chat_postMessage( + channel=dm_channel, + text=f"Welcome to the CDL, {user_name}!", + blocks=welcome_blocks, + ) + except SlackApiError as e: + logger.error(f"Error sending welcome message: {e}") + + respond(f"Started onboarding for <@{target_user_id}>. They've been sent the welcome message.") + + # Handle the onboarding form submission + @app.view("onboarding_form") + def handle_onboarding_form(ack, body, client: WebClient, view): + """Handle submission of the onboarding information form.""" + ack() + + user_id = body["user"]["id"] + request = get_request(user_id) + + if not request: + logger.error(f"No onboarding request found for user {user_id}") + return + + # Extract form values + values = view["state"]["values"] + + # Get GitHub username + github_username = values.get("github_block", {}).get("github_input", {}).get("value", "") + + # Get bio + bio_raw = values.get("bio_block", {}).get("bio_input", {}).get("value", "") + + # Get website URL (optional) + website_url = values.get("website_block", {}).get("website_input", {}).get("value", "") + + # Validate GitHub username + is_valid, error_msg = github_service.validate_username(github_username) + + if not is_valid: + # Send error message and re-prompt + try: + client.chat_postMessage( + channel=request.slack_channel_id, + text=f"The GitHub username '{github_username}' was not found. Please check and try again.", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f":warning: *GitHub username not found*\n\nThe username `{github_username}` doesn't exist on GitHub. Please double-check the spelling and try again.", + }, + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Try Again"}, + "action_id": "retry_github_username", + } + ], + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error sending validation error: {e}") + return + + # Update the request + request.github_username = github_username + request.bio_raw = bio_raw + request.website_url = website_url + request.update_status(OnboardingStatus.PENDING_APPROVAL) + save_request(request) + + # Process the bio if we have the service + if bio_service and bio_raw: + edited_bio, bio_error = bio_service.edit_bio(bio_raw, request.name) + if edited_bio: + request.bio_edited = edited_bio + save_request(request) + + # Send confirmation to the new member + try: + client.chat_postMessage( + channel=request.slack_channel_id, + text="Thanks! Your information has been submitted for approval.", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":white_check_mark: *Information Received*\n\nYour onboarding information has been submitted. The lab admin will review it shortly.", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*What's next:*\n• GitHub: Invitation to ContextLab organization\n• Calendar: Access to lab calendars\n• Website: Your photo and bio will be added", + }, + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error sending confirmation: {e}") + + # Send approval request to admin + _send_approval_request(client, config, request, github_service) + + # Handle retry GitHub username button + @app.action("retry_github_username") + def handle_retry_github(ack, body, client: WebClient): + """Handle the retry GitHub username button.""" + ack() + user_id = body["user"]["id"] + request = get_request(user_id) + + if request: + # Re-open the form modal + _open_onboarding_form(client, body["trigger_id"], request) + + # Handle file uploads (for photo) + @app.event("file_shared") + def handle_file_shared(event, client: WebClient, say): + """Handle when a file is shared in a DM with the bot.""" + file_id = event.get("file_id") + channel_id = event.get("channel_id") + user_id = event.get("user_id") + + request = get_request(user_id) + if not request or request.slack_channel_id != channel_id: + return # Not an onboarding conversation + + # Get file info + try: + file_info = client.files_info(file=file_id) + file_data = file_info["file"] + except SlackApiError as e: + logger.error(f"Error getting file info: {e}") + return + + # Check if it's an image + mimetype = file_data.get("mimetype", "") + if not mimetype.startswith("image/"): + say( + channel=channel_id, + text="Please upload an image file (JPEG, PNG, etc.) for your profile photo.", + ) + return + + # Download the file + file_url = file_data.get("url_private_download") + if not file_url: + logger.error("No download URL for file") + return + + try: + # Download using Slack token + import requests + + headers = {"Authorization": f"Bearer {config.slack.bot_token}"} + response = requests.get(file_url, headers=headers) + response.raise_for_status() + + # Save to temp file + suffix = Path(file_data.get("name", "photo.jpg")).suffix + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: + tmp.write(response.content) + tmp_path = Path(tmp.name) + + # Validate the image + is_valid, error_msg = image_service.validate_image(tmp_path) + if not is_valid: + say(channel=channel_id, text=f"Image validation failed: {error_msg}") + tmp_path.unlink() + return + + # Save original path + request.photo_original_path = tmp_path + save_request(request) + + # Process the photo + output_path = config.output_dir / f"{request.slack_user_id}_photo.png" + processed_path = image_service.add_hand_drawn_border( + tmp_path, output_path, seed=hash(request.slack_user_id) + ) + request.photo_processed_path = processed_path + save_request(request) + + # Upload the processed photo back to show the user + try: + client.files_upload_v2( + channel=channel_id, + file=str(processed_path), + title="Your processed profile photo", + initial_comment=":camera: Here's how your photo will look on the website with the CDL border!", + ) + except SlackApiError as e: + logger.error(f"Error uploading processed photo: {e}") + + say( + channel=channel_id, + text="Photo received and processed! If you're happy with it, we'll use this for the website.", + ) + + except Exception as e: + logger.error(f"Error processing photo: {e}") + say(channel=channel_id, text=f"Error processing photo: {e}") + + # Button to open the onboarding form + @app.action("open_onboarding_form") + def handle_open_form(ack, body, client: WebClient): + """Handle the button to open the onboarding form.""" + ack() + user_id = body["user"]["id"] + request = get_request(user_id) + + if request: + _open_onboarding_form(client, body["trigger_id"], request) + + +def _build_welcome_message(user_name: str) -> list: + """Build the welcome message blocks.""" + return [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f":wave: *Welcome to the Contextual Dynamics Lab, {user_name}!*", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "I'm the CDL onboarding bot. I'll help you get set up with:\n\n" + "• *GitHub:* Access to the ContextLab organization\n" + "• *Calendars:* Access to lab calendars\n" + "• *Website:* Adding your profile to context-lab.com", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "To get started, I'll need some information from you. Click the button below to fill out the form.", + }, + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Start Onboarding"}, + "style": "primary", + "action_id": "open_onboarding_form", + } + ], + }, + ] + + +def _open_onboarding_form(client: WebClient, trigger_id: str, request: OnboardingRequest): + """Open the onboarding information form modal.""" + try: + client.views_open( + trigger_id=trigger_id, + view={ + "type": "modal", + "callback_id": "onboarding_form", + "title": {"type": "plain_text", "text": "CDL Onboarding"}, + "submit": {"type": "plain_text", "text": "Submit"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Please provide the following information for your CDL profile.", + }, + }, + { + "type": "input", + "block_id": "github_block", + "element": { + "type": "plain_text_input", + "action_id": "github_input", + "placeholder": { + "type": "plain_text", + "text": "e.g., octocat", + }, + }, + "label": { + "type": "plain_text", + "text": "GitHub Username", + }, + "hint": { + "type": "plain_text", + "text": "Your GitHub username (not email). We'll invite you to the ContextLab organization.", + }, + }, + { + "type": "input", + "block_id": "bio_block", + "element": { + "type": "plain_text_input", + "action_id": "bio_input", + "multiline": True, + "placeholder": { + "type": "plain_text", + "text": "Tell us about yourself and your research interests...", + }, + }, + "label": { + "type": "plain_text", + "text": "Short Bio", + }, + "hint": { + "type": "plain_text", + "text": "3-4 sentences about you. We'll edit it for style consistency.", + }, + }, + { + "type": "input", + "block_id": "website_block", + "optional": True, + "element": { + "type": "plain_text_input", + "action_id": "website_input", + "placeholder": { + "type": "plain_text", + "text": "https://your-website.com", + }, + }, + "label": { + "type": "plain_text", + "text": "Personal Website (optional)", + }, + "hint": { + "type": "plain_text", + "text": "If you have a personal website, we'll link to it from your profile.", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":camera: *Photo:* After submitting this form, please upload a profile photo by sending it as a message in this conversation.", + }, + }, + ], + }, + ) + except SlackApiError as e: + logger.error(f"Error opening modal: {e}") + + +def _send_approval_request( + client: WebClient, + config: Config, + request: OnboardingRequest, + github_service: GitHubService, +): + """Send an approval request to the admin.""" + # Get GitHub teams for the checkboxes + teams = github_service.get_teams() + + # Build team options + team_options = [] + initial_options = [] + for team in teams: + option = { + "text": {"type": "plain_text", "text": team["name"]}, + "value": str(team["id"]), + } + team_options.append(option) + if team["name"] == config.github.default_team: + initial_options.append(option) + + # Build the approval message + blocks = [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":clipboard: New Onboarding Request", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{request.name}* (<@{request.slack_user_id}>) has submitted their onboarding information.", + }, + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*GitHub Username:* `{request.github_username}`\n" + f"*Email:* {request.email or 'Not provided'}\n" + f"*Website:* {request.website_url or 'None'}", + }, + }, + ] + + # Add bio section + if request.bio_raw: + bio_preview = request.bio_raw[:300] + "..." if len(request.bio_raw) > 300 else request.bio_raw + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Original Bio:*\n>{bio_preview}", + }, + }) + + if request.bio_edited: + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Edited Bio (for website):*\n>{request.bio_edited}", + }, + }) + + blocks.append({"type": "divider"}) + + # GitHub team selection + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Select GitHub teams to add this member to:*", + }, + "accessory": { + "type": "checkboxes", + "action_id": "github_teams_select", + "options": team_options[:10], # Slack limits to 10 options + "initial_options": initial_options, + }, + }) + + # Calendar permissions (using default values) + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Calendar Permissions (defaults):*\n" + "• Contextual Dynamics Lab: Read-only\n" + "• Out of lab: Edit\n" + "• CDL Resources: Edit", + }, + }) + + blocks.append({"type": "divider"}) + + # Action buttons + blocks.append({ + "type": "actions", + "block_id": f"approval_actions_{request.slack_user_id}", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Approve"}, + "style": "primary", + "action_id": "approve_onboarding", + "value": request.slack_user_id, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Reject"}, + "style": "danger", + "action_id": "reject_onboarding", + "value": request.slack_user_id, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Request Changes"}, + "action_id": "request_changes_onboarding", + "value": request.slack_user_id, + }, + ], + }) + + # Send to admin + try: + result = client.chat_postMessage( + channel=config.slack.admin_user_id, + text=f"New onboarding request from {request.name}", + blocks=blocks, + ) + request.admin_approval_message_ts = result["ts"] + save_request(request) + except SlackApiError as e: + logger.error(f"Error sending approval request: {e}") diff --git a/scripts/onboarding/models/__init__.py b/scripts/onboarding/models/__init__.py new file mode 100644 index 0000000..6109c0a --- /dev/null +++ b/scripts/onboarding/models/__init__.py @@ -0,0 +1,5 @@ +"""Data models for onboarding.""" + +from .onboarding_request import OnboardingRequest, OnboardingStatus + +__all__ = ["OnboardingRequest", "OnboardingStatus"] diff --git a/scripts/onboarding/models/onboarding_request.py b/scripts/onboarding/models/onboarding_request.py new file mode 100644 index 0000000..5bb49d1 --- /dev/null +++ b/scripts/onboarding/models/onboarding_request.py @@ -0,0 +1,146 @@ +""" +Data models for onboarding requests. +""" + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Optional + + +class OnboardingStatus(Enum): + """Status of an onboarding request.""" + PENDING_INFO = "pending_info" # Waiting for member to provide info + PENDING_APPROVAL = "pending_approval" # Waiting for admin approval + GITHUB_PENDING = "github_pending" # GitHub invite sent, awaiting acceptance + CALENDAR_PENDING = "calendar_pending" # Calendar invites being sent + PHOTO_PENDING = "photo_pending" # Waiting for photo upload + PROCESSING = "processing" # Processing bio/photo + READY_FOR_WEBSITE = "ready_for_website" # Ready for website update + COMPLETED = "completed" + REJECTED = "rejected" + ERROR = "error" + + +@dataclass +class OnboardingRequest: + """ + Represents an onboarding request for a new lab member. + + Tracks all information needed to complete the onboarding process. + """ + # Slack identifiers + slack_user_id: str + slack_channel_id: str # DM channel with the new member + + # Basic info + name: str = "" + email: str = "" + + # GitHub + github_username: str = "" + github_teams: list = field(default_factory=list) + github_invitation_sent: bool = False + + # Google Calendar + calendar_permissions: dict = field(default_factory=dict) + calendar_invites_sent: bool = False + + # Website info + bio_raw: str = "" + bio_edited: str = "" + website_url: str = "" + + # Photo + photo_original_path: Optional[Path] = None + photo_processed_path: Optional[Path] = None + + # Status tracking + status: OnboardingStatus = OnboardingStatus.PENDING_INFO + created_at: datetime = field(default_factory=datetime.now) + updated_at: datetime = field(default_factory=datetime.now) + error_message: str = "" + + # Admin approval tracking + admin_approval_message_ts: str = "" # Timestamp of the approval message in Slack + approved_by: str = "" # Admin who approved + + def update_status(self, new_status: OnboardingStatus, error_message: str = ""): + """Update the status and timestamp.""" + self.status = new_status + self.updated_at = datetime.now() + if error_message: + self.error_message = error_message + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + return { + "slack_user_id": self.slack_user_id, + "slack_channel_id": self.slack_channel_id, + "name": self.name, + "email": self.email, + "github_username": self.github_username, + "github_teams": self.github_teams, + "github_invitation_sent": self.github_invitation_sent, + "calendar_permissions": self.calendar_permissions, + "calendar_invites_sent": self.calendar_invites_sent, + "bio_raw": self.bio_raw, + "bio_edited": self.bio_edited, + "website_url": self.website_url, + "photo_original_path": str(self.photo_original_path) if self.photo_original_path else None, + "photo_processed_path": str(self.photo_processed_path) if self.photo_processed_path else None, + "status": self.status.value, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + "error_message": self.error_message, + "admin_approval_message_ts": self.admin_approval_message_ts, + "approved_by": self.approved_by, + } + + @classmethod + def from_dict(cls, data: dict) -> "OnboardingRequest": + """Create from dictionary.""" + return cls( + slack_user_id=data["slack_user_id"], + slack_channel_id=data["slack_channel_id"], + name=data.get("name", ""), + email=data.get("email", ""), + github_username=data.get("github_username", ""), + github_teams=data.get("github_teams", []), + github_invitation_sent=data.get("github_invitation_sent", False), + calendar_permissions=data.get("calendar_permissions", {}), + calendar_invites_sent=data.get("calendar_invites_sent", False), + bio_raw=data.get("bio_raw", ""), + bio_edited=data.get("bio_edited", ""), + website_url=data.get("website_url", ""), + photo_original_path=Path(data["photo_original_path"]) if data.get("photo_original_path") else None, + photo_processed_path=Path(data["photo_processed_path"]) if data.get("photo_processed_path") else None, + status=OnboardingStatus(data.get("status", "pending_info")), + created_at=datetime.fromisoformat(data["created_at"]) if data.get("created_at") else datetime.now(), + updated_at=datetime.fromisoformat(data["updated_at"]) if data.get("updated_at") else datetime.now(), + error_message=data.get("error_message", ""), + admin_approval_message_ts=data.get("admin_approval_message_ts", ""), + approved_by=data.get("approved_by", ""), + ) + + def get_summary(self) -> str: + """Get a human-readable summary of the request.""" + lines = [ + f"*Name:* {self.name or 'Not provided'}", + f"*Email:* {self.email or 'Not provided'}", + f"*GitHub:* {self.github_username or 'Not provided'}", + f"*Status:* {self.status.value}", + ] + + if self.github_teams: + lines.append(f"*GitHub Teams:* {', '.join(self.github_teams)}") + + if self.bio_raw: + bio_preview = self.bio_raw[:100] + "..." if len(self.bio_raw) > 100 else self.bio_raw + lines.append(f"*Bio:* {bio_preview}") + + if self.website_url: + lines.append(f"*Website:* {self.website_url}") + + return "\n".join(lines) diff --git a/scripts/onboarding/requirements.txt b/scripts/onboarding/requirements.txt new file mode 100644 index 0000000..57bf9b2 --- /dev/null +++ b/scripts/onboarding/requirements.txt @@ -0,0 +1,27 @@ +# CDL Onboarding Bot Dependencies + +# Slack integration +slack-bolt>=1.18.0 +slack-sdk>=3.21.0 + +# GitHub integration +PyGithub>=2.1.0 + +# Google Calendar integration +google-api-python-client>=2.100.0 +google-auth>=2.23.0 +google-auth-oauthlib>=1.1.0 +google-auth-httplib2>=0.1.1 + +# Image processing +Pillow>=10.0.0 + +# Bio editing (Claude API) +anthropic>=0.7.0 + +# Configuration +python-dotenv>=1.0.0 + +# Testing +pytest>=7.4.0 +pytest-asyncio>=0.21.0 diff --git a/scripts/onboarding/services/__init__.py b/scripts/onboarding/services/__init__.py new file mode 100644 index 0000000..81baeba --- /dev/null +++ b/scripts/onboarding/services/__init__.py @@ -0,0 +1,8 @@ +"""Service modules for external integrations.""" + +from .github_service import GitHubService +from .calendar_service import CalendarService +from .image_service import ImageService +from .bio_service import BioService + +__all__ = ["GitHubService", "CalendarService", "ImageService", "BioService"] diff --git a/scripts/onboarding/services/bio_service.py b/scripts/onboarding/services/bio_service.py new file mode 100644 index 0000000..0f5fb1e --- /dev/null +++ b/scripts/onboarding/services/bio_service.py @@ -0,0 +1,237 @@ +""" +Bio editing service using Claude API. + +Edits member bios to follow CDL style guidelines: +- Third person voice +- Uses first names only +- 3-4 sentences maximum +- Clear, engaging, fun style +- No inappropriate or private information +""" + +import logging +import re +from typing import Optional + +import anthropic + +logger = logging.getLogger(__name__) + + +class BioService: + """Service for editing member bios using Claude API.""" + + # Style guidelines for bio editing + STYLE_GUIDELINES = """ +Style guidelines for CDL lab member bios: +1. Use third person voice (e.g., "Jane studies..." not "I study...") +2. Use first names only after the first mention +3. Keep it to 3-4 sentences maximum +4. Write in a clear, engaging, and fun style +5. Focus on research interests and personality +6. Remove any private information (addresses, phone numbers, personal emails) +7. Remove any inappropriate content +8. Match the tone of existing CDL bios - professional but personable +""" + + # Example bios for few-shot learning + EXAMPLE_BIOS = """ +Example edited bios from the CDL website: + +Example 1: +"Jeremy is an Associate Professor of Psychological and Brain Sciences at Dartmouth and directs the Contextual Dynamics Lab. He enjoys thinking about brains, computers, and cats." + +Example 2: +"Paxton graduated from Dartmouth in 2019 with a BA in neuroscience and is continuing his research in the lab. He's interested in how we represent and understand narratives and how those processes relate to memory." + +Example 3: +"Lucy joined the lab as a research assistant after graduating from Dartmouth. She's excited to explore computational approaches to understanding memory and cognition." +""" + + def __init__(self, api_key: str, model: str = "claude-sonnet-4-20250514"): + """ + Initialize the bio service. + + Args: + api_key: Anthropic API key + model: Claude model to use + """ + self.client = anthropic.Anthropic(api_key=api_key) + self.model = model + + def edit_bio(self, raw_bio: str, name: str) -> tuple[str, Optional[str]]: + """ + Edit a bio to match CDL style guidelines. + + Args: + raw_bio: The original bio text from the user + name: The member's full name + + Returns: + Tuple of (edited_bio, error_message) + """ + if not raw_bio.strip(): + return "", "No bio text provided" + + # Extract first name for the prompt + first_name = name.split()[0] if name else "the member" + + prompt = f"""Please edit the following bio to match our lab's style guidelines. + +{self.STYLE_GUIDELINES} + +{self.EXAMPLE_BIOS} + +Member's name: {name} +First name to use: {first_name} + +Original bio: +{raw_bio} + +Please provide ONLY the edited bio text, with no additional commentary, explanations, or quotation marks. The bio should be ready to publish as-is.""" + + try: + message = self.client.messages.create( + model=self.model, + max_tokens=500, + messages=[{"role": "user", "content": prompt}], + ) + + edited_bio = message.content[0].text.strip() + + # Clean up any stray quotation marks + edited_bio = edited_bio.strip('"\'') + + # Validate the output + is_valid, validation_error = self._validate_bio(edited_bio, first_name) + if not is_valid: + logger.warning(f"Bio validation warning: {validation_error}") + + logger.info(f"Edited bio for {name}: {len(raw_bio)} -> {len(edited_bio)} chars") + return edited_bio, None + + except anthropic.APIError as e: + error_msg = f"Claude API error: {e}" + logger.error(error_msg) + return "", error_msg + except Exception as e: + error_msg = f"Error editing bio: {e}" + logger.error(error_msg) + return "", error_msg + + def _validate_bio(self, bio: str, first_name: str) -> tuple[bool, Optional[str]]: + """ + Validate that an edited bio meets our guidelines. + + Args: + bio: The edited bio text + first_name: The member's first name + + Returns: + Tuple of (is_valid, warning_message) + """ + warnings = [] + + # Check length (rough sentence count) + sentences = [s.strip() for s in re.split(r'[.!?]+', bio) if s.strip()] + if len(sentences) > 5: + warnings.append(f"Bio has {len(sentences)} sentences (recommended: 3-4)") + + # Check for first-person pronouns + first_person_pattern = r'\b(I|me|my|myself|we|us|our|ourselves)\b' + if re.search(first_person_pattern, bio, re.IGNORECASE): + warnings.append("Bio contains first-person pronouns") + + # Check that the first name is used + if first_name.lower() not in bio.lower(): + warnings.append(f"Bio doesn't mention '{first_name}'") + + # Check for potential private info patterns + phone_pattern = r'\b\d{3}[-.\s]?\d{3}[-.\s]?\d{4}\b' + email_pattern = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b' + + if re.search(phone_pattern, bio): + warnings.append("Bio may contain a phone number") + if re.search(email_pattern, bio): + warnings.append("Bio may contain an email address") + + if warnings: + return False, "; ".join(warnings) + return True, None + + def suggest_improvements(self, bio: str, name: str) -> tuple[str, Optional[str]]: + """ + Get suggestions for improving a bio without fully rewriting it. + + Args: + bio: The current bio text + name: The member's full name + + Returns: + Tuple of (suggestions, error_message) + """ + prompt = f"""Review this lab member bio and suggest specific improvements. + +{self.STYLE_GUIDELINES} + +Member's name: {name} + +Current bio: +{bio} + +Please provide a brief list of specific suggestions for improvement. Focus on: +1. Tone and voice +2. Length appropriateness +3. Content that should be added or removed +4. Any style issues + +Keep your response concise and actionable.""" + + try: + message = self.client.messages.create( + model=self.model, + max_tokens=500, + messages=[{"role": "user", "content": prompt}], + ) + + suggestions = message.content[0].text.strip() + return suggestions, None + + except Exception as e: + error_msg = f"Error getting suggestions: {e}" + logger.error(error_msg) + return "", error_msg + + def check_for_private_info(self, text: str) -> list[str]: + """ + Check text for potential private or inappropriate information. + + Args: + text: Text to check + + Returns: + List of warnings about potential private info + """ + warnings = [] + + # Phone numbers + phone_pattern = r'\b\d{3}[-.\s]?\d{3}[-.\s]?\d{4}\b' + if re.search(phone_pattern, text): + warnings.append("Possible phone number detected") + + # Email addresses + email_pattern = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b' + if re.search(email_pattern, text): + warnings.append("Possible email address detected") + + # Street addresses (basic pattern) + address_pattern = r'\b\d+\s+[A-Za-z]+\s+(Street|St|Avenue|Ave|Road|Rd|Drive|Dr|Lane|Ln|Court|Ct|Boulevard|Blvd)\b' + if re.search(address_pattern, text, re.IGNORECASE): + warnings.append("Possible street address detected") + + # Social security numbers + ssn_pattern = r'\b\d{3}[-.\s]?\d{2}[-.\s]?\d{4}\b' + if re.search(ssn_pattern, text): + warnings.append("Possible SSN detected") + + return warnings diff --git a/scripts/onboarding/services/calendar_service.py b/scripts/onboarding/services/calendar_service.py new file mode 100644 index 0000000..edf6a92 --- /dev/null +++ b/scripts/onboarding/services/calendar_service.py @@ -0,0 +1,231 @@ +""" +Google Calendar service for sharing calendars. + +Handles: +- Calendar listing +- Sharing calendars with users +- Managing permissions (ACL) +""" + +import logging +from typing import Optional + +from google.oauth2.service_account import Credentials +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + +logger = logging.getLogger(__name__) + + +class CalendarService: + """Service for Google Calendar operations.""" + + SCOPES = ["https://www.googleapis.com/auth/calendar"] + + # Permission role mappings + ROLE_READER = "reader" # Can see event details + ROLE_WRITER = "writer" # Can create, edit, delete events + ROLE_OWNER = "owner" # Full control + + def __init__(self, credentials_file: str, calendars: Optional[dict] = None): + """ + Initialize the Calendar service. + + Args: + credentials_file: Path to the Google service account JSON file + calendars: Optional dictionary mapping calendar names to IDs + """ + self.credentials_file = credentials_file + self.calendars = calendars or {} + self._service = None + + @property + def service(self): + """Get the Calendar API service (lazy loaded).""" + if self._service is None: + creds = Credentials.from_service_account_file( + self.credentials_file, scopes=self.SCOPES + ) + self._service = build("calendar", "v3", credentials=creds) + return self._service + + def list_calendars(self) -> list[dict]: + """ + List all calendars accessible to the service account. + + Returns: + List of calendar dictionaries with id, summary, and description + """ + calendars = [] + try: + calendar_list = self.service.calendarList().list().execute() + for calendar in calendar_list.get("items", []): + calendars.append({ + "id": calendar["id"], + "summary": calendar.get("summary", ""), + "description": calendar.get("description", ""), + "access_role": calendar.get("accessRole", ""), + }) + logger.info(f"Retrieved {len(calendars)} calendars") + except HttpError as e: + logger.error(f"Error listing calendars: {e}") + return calendars + + def get_calendar_id(self, name: str) -> Optional[str]: + """ + Get a calendar ID by its name. + + Args: + name: Calendar name (as configured) + + Returns: + Calendar ID or None if not found + """ + return self.calendars.get(name) + + def share_calendar( + self, + calendar_id: str, + email: str, + role: str = ROLE_READER, + send_notifications: bool = True, + ) -> tuple[bool, Optional[str]]: + """ + Share a calendar with a user. + + Args: + calendar_id: The calendar's ID + email: User's email to share with + role: Permission level (reader, writer, owner) + send_notifications: Whether to send email notification + + Returns: + Tuple of (success, error_message) + """ + try: + acl_rule = { + "scope": {"type": "user", "value": email}, + "role": role, + } + + result = ( + self.service.acl() + .insert( + calendarId=calendar_id, + body=acl_rule, + sendNotifications=send_notifications, + ) + .execute() + ) + + logger.info( + f"Shared calendar {calendar_id} with {email} as {role} " + f"(rule ID: {result.get('id')})" + ) + return True, None + + except HttpError as e: + error_msg = f"Error sharing calendar with {email}: {e}" + logger.error(error_msg) + return False, error_msg + + def share_multiple_calendars( + self, + email: str, + calendar_permissions: dict[str, str], + send_notifications: bool = True, + ) -> dict[str, tuple[bool, Optional[str]]]: + """ + Share multiple calendars with a user. + + Args: + email: User's email to share with + calendar_permissions: Dictionary mapping calendar names to roles + send_notifications: Whether to send email notifications + + Returns: + Dictionary mapping calendar names to (success, error_message) tuples + """ + results = {} + + for calendar_name, role in calendar_permissions.items(): + calendar_id = self.get_calendar_id(calendar_name) + if not calendar_id: + results[calendar_name] = ( + False, + f"Calendar '{calendar_name}' not configured", + ) + continue + + success, error = self.share_calendar( + calendar_id=calendar_id, + email=email, + role=role, + send_notifications=send_notifications, + ) + results[calendar_name] = (success, error) + + return results + + def remove_calendar_access( + self, calendar_id: str, email: str + ) -> tuple[bool, Optional[str]]: + """ + Remove a user's access to a calendar. + + Args: + calendar_id: The calendar's ID + email: User's email to remove + + Returns: + Tuple of (success, error_message) + """ + try: + # First, find the ACL rule ID for this user + acl_list = self.service.acl().list(calendarId=calendar_id).execute() + + rule_id = None + for rule in acl_list.get("items", []): + scope = rule.get("scope", {}) + if scope.get("type") == "user" and scope.get("value") == email: + rule_id = rule.get("id") + break + + if not rule_id: + return True, None # User doesn't have access, nothing to remove + + # Delete the ACL rule + self.service.acl().delete(calendarId=calendar_id, ruleId=rule_id).execute() + + logger.info(f"Removed {email}'s access to calendar {calendar_id}") + return True, None + + except HttpError as e: + error_msg = f"Error removing calendar access for {email}: {e}" + logger.error(error_msg) + return False, error_msg + + def get_user_permissions(self, calendar_id: str, email: str) -> Optional[str]: + """ + Get a user's current permission level for a calendar. + + Args: + calendar_id: The calendar's ID + email: User's email + + Returns: + Permission role or None if no access + """ + try: + acl_list = self.service.acl().list(calendarId=calendar_id).execute() + + for rule in acl_list.get("items", []): + scope = rule.get("scope", {}) + if scope.get("type") == "user" and scope.get("value") == email: + return rule.get("role") + + return None + + except HttpError as e: + logger.error(f"Error getting permissions for {email}: {e}") + return None diff --git a/scripts/onboarding/services/github_service.py b/scripts/onboarding/services/github_service.py new file mode 100644 index 0000000..cc08e45 --- /dev/null +++ b/scripts/onboarding/services/github_service.py @@ -0,0 +1,248 @@ +""" +GitHub service for organization management. + +Handles: +- Username validation +- Team listing +- Organization invitations +""" + +import logging +from typing import Optional + +from github import Github, GithubException +from github.NamedUser import NamedUser +from github.Organization import Organization +from github.Team import Team + +logger = logging.getLogger(__name__) + + +class GitHubService: + """Service for GitHub organization operations.""" + + def __init__(self, token: str, org_name: str = "ContextLab"): + """ + Initialize the GitHub service. + + Args: + token: GitHub personal access token with admin:org scope + org_name: Name of the GitHub organization + """ + self.github = Github(token) + self.org_name = org_name + self._org: Optional[Organization] = None + + @property + def org(self) -> Organization: + """Get the organization object (lazy loaded).""" + if self._org is None: + self._org = self.github.get_organization(self.org_name) + return self._org + + def validate_username(self, username: str) -> tuple[bool, Optional[str]]: + """ + Check if a GitHub username exists and is valid. + + Args: + username: GitHub username to validate + + Returns: + Tuple of (is_valid, error_message) + """ + try: + user = self.github.get_user(username) + # Access a property to ensure the user exists + _ = user.login + logger.info(f"Validated GitHub username: {username}") + return True, None + except GithubException as e: + if e.status == 404: + error_msg = f"GitHub user '{username}' not found" + logger.warning(error_msg) + return False, error_msg + else: + error_msg = f"Error validating GitHub user '{username}': {e}" + logger.error(error_msg) + return False, error_msg + + def get_user(self, username: str) -> Optional[NamedUser]: + """ + Get a GitHub user by username. + + Args: + username: GitHub username + + Returns: + NamedUser object or None if not found + """ + try: + return self.github.get_user(username) + except GithubException: + return None + + def get_teams(self) -> list[dict]: + """ + Get all teams in the organization. + + Returns: + List of team dictionaries with id, name, slug, and description + """ + teams = [] + try: + for team in self.org.get_teams(): + teams.append({ + "id": team.id, + "name": team.name, + "slug": team.slug, + "description": team.description or "", + }) + logger.info(f"Retrieved {len(teams)} teams from {self.org_name}") + except GithubException as e: + logger.error(f"Error retrieving teams: {e}") + return teams + + def get_team_by_name(self, team_name: str) -> Optional[Team]: + """ + Get a team by its name. + + Args: + team_name: Name of the team + + Returns: + Team object or None if not found + """ + try: + for team in self.org.get_teams(): + if team.name == team_name: + return team + except GithubException as e: + logger.error(f"Error finding team '{team_name}': {e}") + return None + + def get_team_by_id(self, team_id: int) -> Optional[Team]: + """ + Get a team by its ID. + + Args: + team_id: ID of the team + + Returns: + Team object or None if not found + """ + try: + return self.org.get_team(team_id) + except GithubException as e: + logger.error(f"Error getting team {team_id}: {e}") + return None + + def check_membership(self, username: str) -> bool: + """ + Check if a user is already a member of the organization. + + Args: + username: GitHub username + + Returns: + True if the user is a member, False otherwise + """ + try: + return self.org.has_in_members(self.github.get_user(username)) + except GithubException: + return False + + def invite_user( + self, + username: str, + team_ids: Optional[list[int]] = None, + role: str = "direct_member", + ) -> tuple[bool, Optional[str]]: + """ + Invite a user to the organization and optionally to specific teams. + + Args: + username: GitHub username to invite + team_ids: List of team IDs to add the user to + role: Role in the organization (direct_member, admin, billing_manager) + + Returns: + Tuple of (success, error_message) + """ + try: + user = self.github.get_user(username) + + # Check if already a member + if self.check_membership(username): + logger.info(f"User {username} is already a member of {self.org_name}") + # If they're already a member, just add to teams + if team_ids: + for team_id in team_ids: + team = self.get_team_by_id(team_id) + if team: + team.add_membership(user, role="member") + logger.info(f"Added {username} to team {team.name}") + return True, None + + # Get team objects + teams = [] + if team_ids: + for team_id in team_ids: + team = self.get_team_by_id(team_id) + if team: + teams.append(team) + + # Send invitation + if teams: + self.org.invite_user(user=user, role=role, teams=teams) + else: + self.org.invite_user(user=user, role=role) + + logger.info(f"Sent organization invitation to {username}") + return True, None + + except GithubException as e: + error_msg = f"Error inviting {username}: {e}" + logger.error(error_msg) + return False, error_msg + + def remove_member(self, username: str) -> tuple[bool, Optional[str]]: + """ + Remove a user from the organization. + + Note: This should only be done after admin confirmation. + + Args: + username: GitHub username to remove + + Returns: + Tuple of (success, error_message) + """ + try: + user = self.github.get_user(username) + self.org.remove_from_membership(user) + logger.info(f"Removed {username} from {self.org_name}") + return True, None + except GithubException as e: + error_msg = f"Error removing {username}: {e}" + logger.error(error_msg) + return False, error_msg + + def get_pending_invitations(self) -> list[dict]: + """ + Get list of pending organization invitations. + + Returns: + List of invitation dictionaries + """ + invitations = [] + try: + for inv in self.org.invitations(): + invitations.append({ + "id": inv.id, + "login": inv.login, + "email": inv.email, + "created_at": inv.created_at.isoformat() if inv.created_at else None, + }) + except GithubException as e: + logger.error(f"Error getting invitations: {e}") + return invitations diff --git a/scripts/onboarding/services/image_service.py b/scripts/onboarding/services/image_service.py new file mode 100644 index 0000000..ab6d70e --- /dev/null +++ b/scripts/onboarding/services/image_service.py @@ -0,0 +1,322 @@ +""" +Image processing service for adding hand-drawn borders to member photos. + +The borders match the style used on https://www.context-lab.com/people +with Dartmouth green color and slight variations for a hand-drawn look. +""" + +import logging +import math +import random +from pathlib import Path +from typing import Optional, Union + +from PIL import Image, ImageDraw + +logger = logging.getLogger(__name__) + + +class ImageService: + """Service for processing member profile photos.""" + + # Dartmouth green RGB + DARTMOUTH_GREEN = (0, 105, 62) + + # Default settings + DEFAULT_BORDER_WIDTH = 8 + DEFAULT_OUTPUT_SIZE = (400, 400) # Square output + + def __init__( + self, + border_color: tuple = DARTMOUTH_GREEN, + border_width: int = DEFAULT_BORDER_WIDTH, + ): + """ + Initialize the image service. + + Args: + border_color: RGB tuple for border color + border_width: Width of the border in pixels + """ + self.border_color = border_color + self.border_width = border_width + + def add_hand_drawn_border( + self, + input_path: Union[str, Path], + output_path: Union[str, Path], + border_width: Optional[int] = None, + wobble_amount: float = 1.5, + seed: Optional[int] = None, + ) -> Path: + """ + Add a hand-drawn style green border to an image. + + The border has slight random variations to simulate a hand-drawn look, + making each image unique while maintaining consistency. + + Args: + input_path: Path to the input image + output_path: Path for the output image + border_width: Width of the border (uses default if not specified) + wobble_amount: Maximum pixels of random variation (0 = straight lines) + seed: Random seed for reproducible results + + Returns: + Path to the processed image + """ + if seed is not None: + random.seed(seed) + + input_path = Path(input_path) + output_path = Path(output_path) + border_width = border_width or self.border_width + + # Open and process the image + img = Image.open(input_path) + + # Convert to RGBA for transparency support + if img.mode != "RGBA": + img = img.convert("RGBA") + + # Make it square (center crop if needed) + img = self._make_square(img) + + # Resize to standard size + img = img.resize(self.DEFAULT_OUTPUT_SIZE, Image.Resampling.LANCZOS) + + width, height = img.size + + # Create a new image with space for the border + border_padding = border_width + int(wobble_amount) + 2 + new_size = (width + 2 * border_padding, height + 2 * border_padding) + new_img = Image.new("RGBA", new_size, (255, 255, 255, 0)) + + # Paste the original image centered + new_img.paste(img, (border_padding, border_padding)) + + # Draw the hand-drawn border + draw = ImageDraw.Draw(new_img) + self._draw_wobbly_border( + draw, + offset=border_padding, + width=width, + height=height, + stroke_width=border_width, + wobble=wobble_amount, + ) + + # Save the result + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Convert to RGB if saving as JPEG + if output_path.suffix.lower() in [".jpg", ".jpeg"]: + # Create white background + rgb_img = Image.new("RGB", new_img.size, (255, 255, 255)) + rgb_img.paste(new_img, mask=new_img.split()[3] if new_img.mode == "RGBA" else None) + rgb_img.save(output_path, quality=95) + else: + new_img.save(output_path) + + logger.info(f"Processed image saved to {output_path}") + return output_path + + def _make_square(self, img: Image.Image) -> Image.Image: + """Center crop an image to make it square.""" + width, height = img.size + + if width == height: + return img + + # Determine crop dimensions + size = min(width, height) + left = (width - size) // 2 + top = (height - size) // 2 + right = left + size + bottom = top + size + + return img.crop((left, top, right, bottom)) + + def _draw_wobbly_border( + self, + draw: ImageDraw.ImageDraw, + offset: int, + width: int, + height: int, + stroke_width: int, + wobble: float, + ): + """ + Draw a border with hand-drawn wobble effect. + + Uses multiple overlapping lines with slight variations to create + a natural, hand-drawn appearance. + """ + # Draw multiple passes for a more natural look + for pass_num in range(3): + # Slightly vary the stroke width for each pass + current_width = stroke_width - pass_num + + if current_width <= 0: + continue + + # Generate wobbly points for each edge + # Top edge + top_points = self._generate_wobbly_line( + start=(offset, offset), + end=(offset + width, offset), + wobble=wobble, + step=4, + ) + + # Right edge + right_points = self._generate_wobbly_line( + start=(offset + width, offset), + end=(offset + width, offset + height), + wobble=wobble, + step=4, + ) + + # Bottom edge + bottom_points = self._generate_wobbly_line( + start=(offset + width, offset + height), + end=(offset, offset + height), + wobble=wobble, + step=4, + ) + + # Left edge + left_points = self._generate_wobbly_line( + start=(offset, offset + height), + end=(offset, offset), + wobble=wobble, + step=4, + ) + + # Draw the lines + for points in [top_points, right_points, bottom_points, left_points]: + if len(points) >= 2: + draw.line(points, fill=self.border_color, width=current_width) + + def _generate_wobbly_line( + self, + start: tuple, + end: tuple, + wobble: float, + step: int = 4, + ) -> list: + """ + Generate points for a wobbly line between two points. + + Args: + start: Starting point (x, y) + end: Ending point (x, y) + wobble: Maximum random offset in pixels + step: Distance between points + + Returns: + List of (x, y) tuples + """ + points = [] + + dx = end[0] - start[0] + dy = end[1] - start[1] + length = math.sqrt(dx * dx + dy * dy) + + if length == 0: + return [start, end] + + # Number of segments + num_points = max(2, int(length / step)) + + for i in range(num_points + 1): + t = i / num_points + + # Base position + x = start[0] + t * dx + y = start[1] + t * dy + + # Add wobble (perpendicular to line direction) + if 0 < i < num_points: # Don't wobble endpoints + # Perpendicular direction + perp_x = -dy / length + perp_y = dx / length + + # Random offset with smooth variation + offset = random.uniform(-wobble, wobble) + + # Apply Perlin-like smoothing using sin waves + smooth_factor = math.sin(t * math.pi) * 0.5 + 0.5 + offset *= smooth_factor + + x += perp_x * offset + y += perp_y * offset + + points.append((x, y)) + + return points + + def process_photo( + self, + input_path: Union[str, Path], + output_dir: Union[str, Path], + member_id: str, + ) -> Path: + """ + Process a member's photo for the website. + + Args: + input_path: Path to the original photo + output_dir: Directory to save processed photos + member_id: Unique identifier for the member (used in filename) + + Returns: + Path to the processed photo + """ + input_path = Path(input_path) + output_dir = Path(output_dir) + + # Generate output filename + output_filename = f"{member_id}_bordered.png" + output_path = output_dir / output_filename + + # Process with a random but reproducible seed based on member_id + seed = hash(member_id) % (2**32) + + return self.add_hand_drawn_border( + input_path=input_path, + output_path=output_path, + seed=seed, + ) + + def validate_image(self, image_path: Union[str, Path]) -> tuple[bool, Optional[str]]: + """ + Validate that an image is suitable for processing. + + Args: + image_path: Path to the image file + + Returns: + Tuple of (is_valid, error_message) + """ + image_path = Path(image_path) + + if not image_path.exists(): + return False, f"Image file not found: {image_path}" + + try: + with Image.open(image_path) as img: + width, height = img.size + + # Check minimum size + if width < 200 or height < 200: + return False, f"Image too small ({width}x{height}). Minimum is 200x200." + + # Check format + if img.format not in ["JPEG", "PNG", "GIF", "WEBP"]: + return False, f"Unsupported image format: {img.format}" + + return True, None + + except Exception as e: + return False, f"Error reading image: {e}" diff --git a/tests/test_onboarding/__init__.py b/tests/test_onboarding/__init__.py new file mode 100644 index 0000000..022c0eb --- /dev/null +++ b/tests/test_onboarding/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the CDL Onboarding Bot. + +IMPORTANT: These tests use REAL API calls as per project requirements. +Ensure you have valid credentials set up before running tests. + +Test Categories: +- test_github_service.py: GitHub API integration tests +- test_calendar_service.py: Google Calendar API tests +- test_image_service.py: Image processing tests +- test_bio_service.py: Claude API bio editing tests +- test_models.py: Data model tests +""" diff --git a/tests/test_onboarding/conftest.py b/tests/test_onboarding/conftest.py new file mode 100644 index 0000000..5a42845 --- /dev/null +++ b/tests/test_onboarding/conftest.py @@ -0,0 +1,95 @@ +""" +Pytest configuration and fixtures for onboarding tests. + +IMPORTANT: These tests use REAL API calls. Ensure credentials are configured. + +Environment variables required for full test suite: +- GITHUB_TOKEN: For GitHub API tests +- ANTHROPIC_API_KEY: For Claude bio editing tests +- GOOGLE_CREDENTIALS_FILE: For Calendar tests (optional) + +Tests will skip if required credentials are not available. +""" + +import os +import tempfile +from pathlib import Path + +import pytest + +# Ensure scripts package is importable +import sys +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + + +@pytest.fixture +def github_token(): + """Get GitHub token from environment.""" + token = os.environ.get("GITHUB_TOKEN") + if not token: + pytest.skip("GITHUB_TOKEN not set - skipping GitHub API tests") + return token + + +@pytest.fixture +def anthropic_api_key(): + """Get Anthropic API key from environment.""" + key = os.environ.get("ANTHROPIC_API_KEY") + if not key: + pytest.skip("ANTHROPIC_API_KEY not set - skipping Claude API tests") + return key + + +@pytest.fixture +def google_credentials_file(): + """Get Google credentials file path from environment.""" + path = os.environ.get("GOOGLE_CREDENTIALS_FILE") + if not path or not Path(path).exists(): + pytest.skip("GOOGLE_CREDENTIALS_FILE not set or file not found - skipping Calendar tests") + return path + + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for test outputs.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +@pytest.fixture +def sample_image(temp_dir): + """Create a sample test image.""" + from PIL import Image + + # Create a simple test image + img = Image.new("RGB", (400, 400), color=(100, 150, 200)) + img_path = temp_dir / "test_photo.png" + img.save(img_path) + return img_path + + +@pytest.fixture +def test_email(): + """Get test email address from environment or use default.""" + return os.environ.get("TEST_EMAIL", "contextualdynamicslab@gmail.com") + + +@pytest.fixture +def github_service(github_token): + """Create a GitHubService instance for testing.""" + from scripts.onboarding.services.github_service import GitHubService + return GitHubService(github_token, "ContextLab") + + +@pytest.fixture +def image_service(): + """Create an ImageService instance for testing.""" + from scripts.onboarding.services.image_service import ImageService + return ImageService() + + +@pytest.fixture +def bio_service(anthropic_api_key): + """Create a BioService instance for testing.""" + from scripts.onboarding.services.bio_service import BioService + return BioService(anthropic_api_key) diff --git a/tests/test_onboarding/test_bio_service.py b/tests/test_onboarding/test_bio_service.py new file mode 100644 index 0000000..8eeb0ce --- /dev/null +++ b/tests/test_onboarding/test_bio_service.py @@ -0,0 +1,288 @@ +""" +Tests for the BioService. + +IMPORTANT: These tests make REAL Claude API calls. +Requires ANTHROPIC_API_KEY environment variable to be set. + +Tests verify: +- Bio editing produces third-person text +- Private information is detected and removed +- Output follows style guidelines +""" + +from pathlib import Path +import sys + +import pytest + +# Ensure scripts package is importable +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from scripts.onboarding.services.bio_service import BioService + + +class TestBioServiceInit: + """Tests for BioService initialization.""" + + def test_init_with_valid_key(self, anthropic_api_key): + """Test initialization with a valid API key.""" + service = BioService(anthropic_api_key) + assert service.client is not None + assert service.model == "claude-sonnet-4-20250514" + + def test_init_with_custom_model(self, anthropic_api_key): + """Test initialization with a custom model.""" + service = BioService(anthropic_api_key, model="claude-3-haiku-20240307") + assert service.model == "claude-3-haiku-20240307" + + +class TestBioEditing: + """Tests for bio editing functionality.""" + + def test_edit_first_person_bio(self, bio_service): + """Test converting first-person bio to third-person.""" + raw_bio = """ + I am a graduate student studying computational neuroscience. + I love working with brain data and developing machine learning models. + In my free time, I enjoy hiking and playing chess. + """ + + edited_bio, error = bio_service.edit_bio(raw_bio, "Jane Smith") + + assert error is None + assert edited_bio != "" + + # Should be in third person (no "I", "me", "my") + lower_bio = edited_bio.lower() + # Check that first person pronouns are removed + # (May have some in quotes or other contexts, so this is a soft check) + assert "jane" in lower_bio or "smith" in lower_bio + + print(f"Original: {raw_bio}") + print(f"Edited: {edited_bio}") + + def test_edit_long_bio_gets_shortened(self, bio_service): + """Test that overly long bios get shortened.""" + raw_bio = """ + I am a postdoctoral researcher in the lab. I completed my PhD at MIT where + I studied the neural basis of memory consolidation. My dissertation focused + on how the hippocampus interacts with the cortex during sleep. I used a + combination of electrophysiology, optogenetics, and computational modeling + to understand these processes. Before my PhD, I completed my undergraduate + degree at Stanford where I majored in biology and minored in computer science. + I am particularly interested in how we can use AI to analyze neural data. + In addition to my research, I enjoy teaching and mentoring students. + Outside of the lab, I am an avid rock climber and have climbed in Yosemite, + Red Rocks, and various locations throughout Europe. I also enjoy cooking, + especially Italian cuisine, and have recently taken up pottery. + """ + + edited_bio, error = bio_service.edit_bio(raw_bio, "Alex Johnson") + + assert error is None + assert edited_bio != "" + + # Count sentences (rough approximation) + sentences = [s.strip() for s in edited_bio.split('.') if s.strip()] + # Should be condensed to roughly 3-4 sentences + assert len(sentences) <= 6, f"Bio has {len(sentences)} sentences, expected 3-4" + + print(f"Edited bio ({len(sentences)} sentences): {edited_bio}") + + def test_edit_bio_uses_first_name(self, bio_service): + """Test that edited bio uses the member's first name.""" + raw_bio = "I study neural networks and machine learning." + + edited_bio, error = bio_service.edit_bio(raw_bio, "Maria Garcia") + + assert error is None + assert "maria" in edited_bio.lower() + + print(f"Edited: {edited_bio}") + + def test_edit_empty_bio(self, bio_service): + """Test handling of empty bio.""" + edited_bio, error = bio_service.edit_bio("", "Test User") + + assert edited_bio == "" + assert error is not None + assert "no bio" in error.lower() + + def test_edit_whitespace_only_bio(self, bio_service): + """Test handling of whitespace-only bio.""" + edited_bio, error = bio_service.edit_bio(" \n\t ", "Test User") + + assert edited_bio == "" + assert error is not None + + +class TestPrivateInfoDetection: + """Tests for private information detection.""" + + def test_detect_phone_number(self, bio_service): + """Test detection of phone numbers.""" + text = "Call me at 555-123-4567 for more info." + warnings = bio_service.check_for_private_info(text) + + assert len(warnings) > 0 + assert any("phone" in w.lower() for w in warnings) + + def test_detect_phone_number_with_dots(self, bio_service): + """Test detection of phone numbers with dots.""" + text = "My number is 555.123.4567." + warnings = bio_service.check_for_private_info(text) + + assert len(warnings) > 0 + + def test_detect_email_address(self, bio_service): + """Test detection of email addresses.""" + text = "Email me at person@example.com for questions." + warnings = bio_service.check_for_private_info(text) + + assert len(warnings) > 0 + assert any("email" in w.lower() for w in warnings) + + def test_detect_street_address(self, bio_service): + """Test detection of street addresses.""" + text = "I live at 123 Main Street in Boston." + warnings = bio_service.check_for_private_info(text) + + assert len(warnings) > 0 + assert any("address" in w.lower() for w in warnings) + + def test_detect_ssn(self, bio_service): + """Test detection of social security numbers.""" + text = "My SSN is 123-45-6789." + warnings = bio_service.check_for_private_info(text) + + assert len(warnings) > 0 + assert any("ssn" in w.lower() for w in warnings) + + def test_no_false_positives_clean_text(self, bio_service): + """Test that clean text doesn't trigger warnings.""" + text = "I am a researcher interested in computational neuroscience and machine learning." + warnings = bio_service.check_for_private_info(text) + + assert len(warnings) == 0 + + +class TestBioValidation: + """Tests for bio validation functionality.""" + + def test_validate_good_bio(self, bio_service): + """Test validation of a properly formatted bio.""" + bio = "Alex is a graduate student studying computational neuroscience. She enjoys working on machine learning problems." + + is_valid, warning = bio_service._validate_bio(bio, "Alex") + + assert is_valid is True + assert warning is None + + def test_validate_bio_too_long(self, bio_service): + """Test validation catches overly long bios.""" + bio = "Sentence one. Sentence two. Sentence three. Sentence four. Sentence five. Sentence six. Sentence seven." + + is_valid, warning = bio_service._validate_bio(bio, "Test") + + # Should flag as too long + assert is_valid is False + assert warning is not None + assert "sentences" in warning.lower() + + def test_validate_bio_first_person(self, bio_service): + """Test validation catches first-person pronouns.""" + bio = "I am a researcher and I study brains." + + is_valid, warning = bio_service._validate_bio(bio, "Test") + + assert is_valid is False + assert warning is not None + assert "first-person" in warning.lower() + + def test_validate_bio_missing_name(self, bio_service): + """Test validation catches missing name.""" + bio = "This person studies neuroscience." + + is_valid, warning = bio_service._validate_bio(bio, "Alex") + + assert is_valid is False + assert warning is not None + assert "alex" in warning.lower() + + def test_validate_bio_with_email(self, bio_service): + """Test validation catches email in bio.""" + bio = "Alex studies neuroscience. Contact: alex@example.com" + + is_valid, warning = bio_service._validate_bio(bio, "Alex") + + assert is_valid is False + assert warning is not None + assert "email" in warning.lower() + + +class TestSuggestImprovements: + """Tests for bio improvement suggestions.""" + + def test_suggest_improvements_for_long_bio(self, bio_service): + """Test getting suggestions for a long bio.""" + bio = """ + Jane is a researcher who studies many things. She completed her PhD at a + prestigious university where she worked on neural networks. Her dissertation + covered multiple topics including memory, learning, and attention. She has + published many papers and presented at numerous conferences. In her free time, + she enjoys hiking, reading, cooking, traveling, and spending time with friends. + She is also interested in science communication and public outreach. + """ + + suggestions, error = bio_service.suggest_improvements(bio, "Jane Doe") + + assert error is None + assert suggestions != "" + assert len(suggestions) > 20 # Should have substantive feedback + + print(f"Suggestions: {suggestions}") + + def test_suggest_improvements_returns_actionable_feedback(self, bio_service): + """Test that suggestions are actionable.""" + bio = "I study brains." + + suggestions, error = bio_service.suggest_improvements(bio, "Test Person") + + assert error is None + assert suggestions != "" + + print(f"Suggestions for short bio: {suggestions}") + + +class TestEdgeCases: + """Tests for edge cases and error handling.""" + + def test_edit_bio_special_characters(self, bio_service): + """Test handling bio with special characters.""" + raw_bio = "I study café culture & its effects on productivity! My research uses ü and é." + + edited_bio, error = bio_service.edit_bio(raw_bio, "Marie Müller") + + assert error is None + assert edited_bio != "" + + def test_edit_bio_unicode_name(self, bio_service): + """Test handling names with unicode characters.""" + raw_bio = "I am a researcher from Japan studying memory." + + edited_bio, error = bio_service.edit_bio(raw_bio, "Yuki Tanaka") + + assert error is None + assert "yuki" in edited_bio.lower() or "tanaka" in edited_bio.lower() + + def test_edit_bio_very_short(self, bio_service): + """Test editing a very short bio.""" + raw_bio = "I study brains." + + edited_bio, error = bio_service.edit_bio(raw_bio, "Sam Lee") + + assert error is None + assert edited_bio != "" + assert len(edited_bio) >= len(raw_bio) # Should at least be as long + + print(f"Short bio edited: {edited_bio}") diff --git a/tests/test_onboarding/test_github_service.py b/tests/test_onboarding/test_github_service.py new file mode 100644 index 0000000..f71eaad --- /dev/null +++ b/tests/test_onboarding/test_github_service.py @@ -0,0 +1,212 @@ +""" +Tests for the GitHubService. + +IMPORTANT: These tests make REAL GitHub API calls. +Requires GITHUB_TOKEN environment variable to be set. + +Tests are designed to be safe: +- Username validation only queries public GitHub API +- Team listing only reads org data +- No invitations are actually sent during testing +""" + +from pathlib import Path +import sys + +import pytest + +# Ensure scripts package is importable +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from scripts.onboarding.services.github_service import GitHubService + + +class TestGitHubServiceInit: + """Tests for GitHubService initialization.""" + + def test_init_with_valid_token(self, github_token): + """Test initialization with a valid token.""" + service = GitHubService(github_token, "ContextLab") + assert service.org_name == "ContextLab" + assert service.github is not None + assert service.org is not None + + def test_init_with_invalid_org(self, github_token): + """Test initialization with invalid organization name.""" + # This should raise an error or handle gracefully + with pytest.raises(Exception): + GitHubService(github_token, "nonexistent-org-that-does-not-exist-12345") + + +class TestUsernameValidation: + """Tests for GitHub username validation.""" + + def test_validate_existing_username(self, github_service): + """Test validating a known existing GitHub username.""" + # Using 'octocat' which is GitHub's official test account + is_valid, error = github_service.validate_username("octocat") + assert is_valid is True + assert error is None + + def test_validate_nonexistent_username(self, github_service): + """Test validating a username that doesn't exist.""" + # Use a very unlikely username + is_valid, error = github_service.validate_username("this-user-definitely-does-not-exist-12345678") + assert is_valid is False + assert error is not None + assert "not found" in error.lower() or "does not exist" in error.lower() + + def test_validate_empty_username(self, github_service): + """Test validating an empty username.""" + is_valid, error = github_service.validate_username("") + assert is_valid is False + assert error is not None + + def test_validate_username_with_spaces(self, github_service): + """Test validating a username with invalid characters.""" + is_valid, error = github_service.validate_username("user name") + assert is_valid is False + + def test_validate_contextlab_member(self, github_service): + """Test validating a known ContextLab member (jeremymanning).""" + is_valid, error = github_service.validate_username("jeremymanning") + assert is_valid is True + assert error is None + + +class TestTeamListing: + """Tests for GitHub organization team listing.""" + + def test_get_teams_returns_list(self, github_service): + """Test that get_teams returns a list.""" + teams = github_service.get_teams() + assert isinstance(teams, list) + + def test_get_teams_contains_expected_teams(self, github_service): + """Test that known teams are in the list.""" + teams = github_service.get_teams() + team_names = [team["name"] for team in teams] + + # ContextLab should have at least some teams + assert len(teams) > 0 + + # Each team should have id, name, and description + for team in teams: + assert "id" in team + assert "name" in team + assert "description" in team + assert isinstance(team["id"], int) + assert isinstance(team["name"], str) + + def test_get_teams_includes_lab_default(self, github_service): + """Test that 'Lab default' team exists.""" + teams = github_service.get_teams() + team_names = [team["name"] for team in teams] + + # Check for common team names that should exist + # Note: Actual team names depend on the org setup + assert len(team_names) > 0 + + +class TestMembershipChecks: + """Tests for membership status checking.""" + + def test_check_membership_existing_member(self, github_service): + """Test checking membership of a known member.""" + # jeremymanning should be a member of ContextLab + is_member = github_service.check_membership("jeremymanning") + assert is_member is True + + def test_check_membership_non_member(self, github_service): + """Test checking membership of a non-member.""" + # octocat is probably not a member of ContextLab + is_member = github_service.check_membership("octocat") + assert is_member is False + + def test_check_membership_nonexistent_user(self, github_service): + """Test checking membership of nonexistent user.""" + is_member = github_service.check_membership("nonexistent-user-12345678") + assert is_member is False + + +class TestPendingInvitations: + """Tests for pending invitation listing.""" + + def test_get_pending_invitations_returns_list(self, github_service): + """Test that get_pending_invitations returns a list.""" + invitations = github_service.get_pending_invitations() + assert isinstance(invitations, list) + + def test_pending_invitations_format(self, github_service): + """Test the format of pending invitations.""" + invitations = github_service.get_pending_invitations() + + # May be empty, but if not, should have expected fields + for inv in invitations: + assert "login" in inv + assert "email" in inv + assert "invited_at" in inv + + +class TestInvitationSafety: + """Tests to verify invitation functions have proper safeguards. + + NOTE: These tests verify the function signature and error handling, + but do NOT actually send invitations. + """ + + def test_invite_user_validates_username(self, github_service): + """Test that invite_user validates the username first.""" + # Try to invite a nonexistent user - should fail validation + success, error = github_service.invite_user( + "nonexistent-user-that-does-not-exist-12345678", + team_ids=[] + ) + assert success is False + assert error is not None + + def test_invite_user_with_empty_username(self, github_service): + """Test that invite_user rejects empty username.""" + success, error = github_service.invite_user("", team_ids=[]) + assert success is False + assert error is not None + + +class TestRemoveMemberSafety: + """Tests for remove_member safety. + + NOTE: These tests verify error handling but do NOT remove anyone. + """ + + def test_remove_nonexistent_user(self, github_service): + """Test removing a user that doesn't exist.""" + success, error = github_service.remove_member("nonexistent-user-12345678") + # Should fail gracefully + assert success is False + + +class TestAPIResponseFormat: + """Tests to verify API responses are in expected format.""" + + def test_github_user_info_format(self, github_service): + """Test that user info has expected fields.""" + # Directly access the GitHub API through the service + user = github_service.github.get_user("octocat") + + # Verify expected attributes exist + assert hasattr(user, "login") + assert hasattr(user, "name") + assert hasattr(user, "email") + assert hasattr(user, "avatar_url") + assert hasattr(user, "html_url") + + # Verify login is correct + assert user.login == "octocat" + + def test_org_info_accessible(self, github_service): + """Test that organization info is accessible.""" + org = github_service.org + + assert hasattr(org, "login") + assert hasattr(org, "name") + assert org.login == "ContextLab" diff --git a/tests/test_onboarding/test_image_service.py b/tests/test_onboarding/test_image_service.py new file mode 100644 index 0000000..2e23f81 --- /dev/null +++ b/tests/test_onboarding/test_image_service.py @@ -0,0 +1,365 @@ +""" +Tests for the ImageService. + +These tests verify actual image processing with real files. +No external API calls required. +""" + +import tempfile +from pathlib import Path +import sys + +import pytest +from PIL import Image + +# Ensure scripts package is importable +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from scripts.onboarding.services.image_service import ImageService + + +class TestImageServiceInit: + """Tests for ImageService initialization.""" + + def test_default_initialization(self): + """Test default initialization values.""" + service = ImageService() + assert service.border_color == (0, 105, 62) # Dartmouth green + assert service.border_width == 8 + + def test_custom_border_color(self): + """Test custom border color.""" + custom_color = (255, 0, 0) # Red + service = ImageService(border_color=custom_color) + assert service.border_color == custom_color + + def test_custom_border_width(self): + """Test custom border width.""" + service = ImageService(border_width=12) + assert service.border_width == 12 + + +class TestImageValidation: + """Tests for image validation.""" + + def test_validate_valid_png(self, temp_dir): + """Test validating a valid PNG image.""" + service = ImageService() + + # Create a valid test image + img = Image.new("RGB", (400, 400), color=(100, 150, 200)) + img_path = temp_dir / "test.png" + img.save(img_path, format="PNG") + + is_valid, error = service.validate_image(img_path) + assert is_valid is True + assert error is None + + def test_validate_valid_jpeg(self, temp_dir): + """Test validating a valid JPEG image.""" + service = ImageService() + + img = Image.new("RGB", (300, 300), color=(100, 150, 200)) + img_path = temp_dir / "test.jpg" + img.save(img_path, format="JPEG") + + is_valid, error = service.validate_image(img_path) + assert is_valid is True + assert error is None + + def test_validate_image_too_small(self, temp_dir): + """Test validating an image that is too small.""" + service = ImageService() + + img = Image.new("RGB", (100, 100), color=(100, 150, 200)) + img_path = temp_dir / "small.png" + img.save(img_path, format="PNG") + + is_valid, error = service.validate_image(img_path) + assert is_valid is False + assert "too small" in error.lower() + + def test_validate_nonexistent_file(self, temp_dir): + """Test validating a file that doesn't exist.""" + service = ImageService() + fake_path = temp_dir / "nonexistent.png" + + is_valid, error = service.validate_image(fake_path) + assert is_valid is False + assert "not found" in error.lower() + + +class TestHandDrawnBorder: + """Tests for hand-drawn border processing.""" + + def test_add_border_creates_output(self, temp_dir): + """Test that processing creates an output file.""" + service = ImageService() + + # Create input image + input_img = Image.new("RGB", (400, 400), color=(200, 200, 200)) + input_path = temp_dir / "input.png" + input_img.save(input_path) + + output_path = temp_dir / "output.png" + + result = service.add_hand_drawn_border(input_path, output_path) + + assert result == output_path + assert output_path.exists() + + def test_output_is_larger_than_input(self, temp_dir): + """Test that output has space for border (is larger).""" + service = ImageService() + + # Create input image + input_img = Image.new("RGB", (400, 400), color=(200, 200, 200)) + input_path = temp_dir / "input.png" + input_img.save(input_path) + + output_path = temp_dir / "output.png" + service.add_hand_drawn_border(input_path, output_path) + + output_img = Image.open(output_path) + # Output should be larger due to border padding + assert output_img.size[0] > 400 + assert output_img.size[1] > 400 + + def test_border_contains_green(self, temp_dir): + """Test that the border contains Dartmouth green color.""" + service = ImageService() + + # Create input image (white) + input_img = Image.new("RGB", (400, 400), color=(255, 255, 255)) + input_path = temp_dir / "input.png" + input_img.save(input_path) + + output_path = temp_dir / "output.png" + service.add_hand_drawn_border(input_path, output_path) + + output_img = Image.open(output_path).convert("RGB") + + # Check corners (where border should be) + # The border should contain Dartmouth green (0, 105, 62) + # Check a few pixels in the border area + found_green = False + dartmouth_green = (0, 105, 62) + + # Sample the border area + for x in range(20): + for y in range(20): + pixel = output_img.getpixel((x, y)) + if pixel == dartmouth_green: + found_green = True + break + if found_green: + break + + assert found_green, "Dartmouth green not found in border area" + + def test_reproducible_with_seed(self, temp_dir): + """Test that results are reproducible with same seed.""" + service = ImageService() + + # Create input image + input_img = Image.new("RGB", (400, 400), color=(128, 128, 128)) + input_path = temp_dir / "input.png" + input_img.save(input_path) + + output1_path = temp_dir / "output1.png" + output2_path = temp_dir / "output2.png" + + # Process twice with same seed + service.add_hand_drawn_border(input_path, output1_path, seed=42) + service.add_hand_drawn_border(input_path, output2_path, seed=42) + + # Images should be identical + img1 = Image.open(output1_path) + img2 = Image.open(output2_path) + + # Compare pixel by pixel (sample a few) + for x in range(0, img1.size[0], 50): + for y in range(0, img1.size[1], 50): + assert img1.getpixel((x, y)) == img2.getpixel((x, y)) + + def test_different_seeds_produce_different_results(self, temp_dir): + """Test that different seeds produce different results.""" + service = ImageService() + + # Create input image + input_img = Image.new("RGB", (400, 400), color=(128, 128, 128)) + input_path = temp_dir / "input.png" + input_img.save(input_path) + + output1_path = temp_dir / "output1.png" + output2_path = temp_dir / "output2.png" + + # Process with different seeds + service.add_hand_drawn_border(input_path, output1_path, seed=42) + service.add_hand_drawn_border(input_path, output2_path, seed=123) + + img1 = Image.open(output1_path) + img2 = Image.open(output2_path) + + # Find at least one difference in the border region + differences_found = False + for x in range(10, 30): # Border region + for y in range(10, 30): + if img1.getpixel((x, y)) != img2.getpixel((x, y)): + differences_found = True + break + if differences_found: + break + + assert differences_found, "Different seeds should produce different wobble patterns" + + def test_jpeg_output(self, temp_dir): + """Test output as JPEG format.""" + service = ImageService() + + # Create input PNG + input_img = Image.new("RGB", (400, 400), color=(200, 200, 200)) + input_path = temp_dir / "input.png" + input_img.save(input_path) + + output_path = temp_dir / "output.jpg" + service.add_hand_drawn_border(input_path, output_path) + + assert output_path.exists() + # Verify it's actually a JPEG + with Image.open(output_path) as img: + assert img.format == "JPEG" + + +class TestMakeSquare: + """Tests for the square cropping functionality.""" + + def test_already_square(self, temp_dir): + """Test that square images are unchanged.""" + service = ImageService() + + # Create square image + img = Image.new("RGB", (400, 400), color=(200, 200, 200)) + input_path = temp_dir / "square.png" + img.save(input_path) + + output_path = temp_dir / "output.png" + service.add_hand_drawn_border(input_path, output_path) + + # Output should be processed normally + assert output_path.exists() + + def test_landscape_becomes_square(self, temp_dir): + """Test that landscape images are cropped to square.""" + service = ImageService() + + # Create wide landscape image + img = Image.new("RGB", (600, 400), color=(200, 200, 200)) + input_path = temp_dir / "landscape.png" + img.save(input_path) + + output_path = temp_dir / "output.png" + service.add_hand_drawn_border(input_path, output_path) + + # The base image should be square before border is added + # Output will be larger due to border, but underlying image is 400x400 + assert output_path.exists() + + def test_portrait_becomes_square(self, temp_dir): + """Test that portrait images are cropped to square.""" + service = ImageService() + + # Create tall portrait image + img = Image.new("RGB", (400, 600), color=(200, 200, 200)) + input_path = temp_dir / "portrait.png" + img.save(input_path) + + output_path = temp_dir / "output.png" + service.add_hand_drawn_border(input_path, output_path) + + assert output_path.exists() + + +class TestProcessPhoto: + """Tests for the process_photo convenience method.""" + + def test_process_photo_creates_file(self, temp_dir): + """Test that process_photo creates the expected output file.""" + service = ImageService() + + # Create input image + input_img = Image.new("RGB", (500, 500), color=(150, 150, 150)) + input_path = temp_dir / "original.png" + input_img.save(input_path) + + output_dir = temp_dir / "output" + output_dir.mkdir() + + result = service.process_photo(input_path, output_dir, "test_member") + + assert result.exists() + assert "test_member" in result.name + assert "_bordered" in result.name + + def test_process_photo_reproducible_by_member_id(self, temp_dir): + """Test that same member_id produces same result.""" + service = ImageService() + + # Create input image + input_img = Image.new("RGB", (500, 500), color=(150, 150, 150)) + input_path = temp_dir / "original.png" + input_img.save(input_path) + + output_dir1 = temp_dir / "output1" + output_dir2 = temp_dir / "output2" + output_dir1.mkdir() + output_dir2.mkdir() + + result1 = service.process_photo(input_path, output_dir1, "same_member") + result2 = service.process_photo(input_path, output_dir2, "same_member") + + # Images should be identical (same seed from same member_id) + img1 = Image.open(result1) + img2 = Image.open(result2) + + # Sample comparison + for x in range(0, min(img1.size[0], 200), 25): + for y in range(0, min(img1.size[1], 200), 25): + assert img1.getpixel((x, y)) == img2.getpixel((x, y)) + + +class TestCustomWobble: + """Tests for wobble amount configuration.""" + + def test_zero_wobble_straight_lines(self, temp_dir): + """Test that zero wobble produces straighter borders.""" + service = ImageService() + + input_img = Image.new("RGB", (400, 400), color=(255, 255, 255)) + input_path = temp_dir / "input.png" + input_img.save(input_path) + + # Process with no wobble + output_path = temp_dir / "straight.png" + service.add_hand_drawn_border(input_path, output_path, wobble_amount=0, seed=42) + + # Process with wobble + output_wobble_path = temp_dir / "wobbly.png" + service.add_hand_drawn_border(input_path, output_wobble_path, wobble_amount=5.0, seed=42) + + # Both should exist + assert output_path.exists() + assert output_wobble_path.exists() + + # They should be different + img_straight = Image.open(output_path) + img_wobbly = Image.open(output_wobble_path) + + # Find differences in border region + differences = 0 + for x in range(10, 30): + for y in range(10, 30): + if img_straight.getpixel((x, y)) != img_wobbly.getpixel((x, y)): + differences += 1 + + assert differences > 0, "Wobble setting should affect the output" diff --git a/tests/test_onboarding/test_models.py b/tests/test_onboarding/test_models.py new file mode 100644 index 0000000..6fe159e --- /dev/null +++ b/tests/test_onboarding/test_models.py @@ -0,0 +1,264 @@ +""" +Tests for the OnboardingRequest model. + +These tests do not require external API calls. +""" + +import json +from datetime import datetime +from pathlib import Path +import sys + +import pytest + +# Ensure scripts package is importable +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from scripts.onboarding.models.onboarding_request import OnboardingRequest, OnboardingStatus + + +class TestOnboardingStatus: + """Tests for OnboardingStatus enum.""" + + def test_all_statuses_have_values(self): + """Verify all expected status values exist.""" + expected_statuses = [ + "pending_info", + "pending_approval", + "github_pending", + "calendar_pending", + "ready_for_website", + "completed", + "rejected", + ] + actual_statuses = [s.value for s in OnboardingStatus] + for status in expected_statuses: + assert status in actual_statuses, f"Missing status: {status}" + + def test_status_from_string(self): + """Test creating status from string value.""" + status = OnboardingStatus("pending_info") + assert status == OnboardingStatus.PENDING_INFO + + +class TestOnboardingRequest: + """Tests for OnboardingRequest dataclass.""" + + def test_create_minimal_request(self): + """Test creating a request with minimal required fields.""" + request = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + ) + assert request.slack_user_id == "U12345678" + assert request.slack_channel_id == "C87654321" + assert request.name == "Test User" + assert request.status == OnboardingStatus.PENDING_INFO + assert request.github_username == "" + assert request.email == "" + assert request.github_teams == [] + assert request.github_invitation_sent is False + assert request.calendar_invites_sent is False + + def test_create_full_request(self): + """Test creating a request with all fields.""" + request = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + email="test@example.com", + github_username="testuser", + bio_raw="I am a researcher", + bio_edited="Test is a researcher", + website_url="https://example.com", + photo_original_path="/path/to/original.jpg", + photo_processed_path="/path/to/processed.png", + github_teams=[1, 2, 3], + calendar_permissions={"Lab Calendar": "reader"}, + status=OnboardingStatus.PENDING_APPROVAL, + ) + assert request.email == "test@example.com" + assert request.github_username == "testuser" + assert request.bio_raw == "I am a researcher" + assert request.bio_edited == "Test is a researcher" + assert request.website_url == "https://example.com" + assert request.github_teams == [1, 2, 3] + assert request.calendar_permissions == {"Lab Calendar": "reader"} + assert request.status == OnboardingStatus.PENDING_APPROVAL + + def test_update_status(self): + """Test status update functionality.""" + request = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + ) + assert request.status == OnboardingStatus.PENDING_INFO + + # Update status + request.update_status(OnboardingStatus.PENDING_APPROVAL) + assert request.status == OnboardingStatus.PENDING_APPROVAL + + # Update again + request.update_status(OnboardingStatus.GITHUB_PENDING) + assert request.status == OnboardingStatus.GITHUB_PENDING + + def test_update_status_with_error(self): + """Test status update with error message.""" + request = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + ) + + request.update_status(OnboardingStatus.ERROR, "Something went wrong") + assert request.status == OnboardingStatus.ERROR + assert request.error_message == "Something went wrong" + + def test_serialization_to_dict(self): + """Test converting request to dictionary.""" + request = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + email="test@example.com", + github_username="testuser", + ) + data = request.to_dict() + + assert isinstance(data, dict) + assert data["slack_user_id"] == "U12345678" + assert data["slack_channel_id"] == "C87654321" + assert data["name"] == "Test User" + assert data["email"] == "test@example.com" + assert data["github_username"] == "testuser" + assert data["status"] == "pending_info" + assert "created_at" in data + assert "updated_at" in data + + def test_deserialization_from_dict(self): + """Test creating request from dictionary.""" + data = { + "slack_user_id": "U12345678", + "slack_channel_id": "C87654321", + "name": "Test User", + "email": "test@example.com", + "github_username": "testuser", + "bio_raw": "I am a researcher", + "bio_edited": "Test is a researcher", + "website_url": "https://example.com", + "photo_original_path": "", + "photo_processed_path": "", + "github_teams": [1, 2], + "calendar_permissions": {"Lab": "reader"}, + "status": "pending_approval", + "github_invitation_sent": True, + "calendar_invites_sent": False, + "approved_by": "UADMIN123", + "created_at": "2024-01-15T10:30:00", + "updated_at": "2024-01-15T11:00:00", + } + + request = OnboardingRequest.from_dict(data) + + assert request.slack_user_id == "U12345678" + assert request.name == "Test User" + assert request.email == "test@example.com" + assert request.github_username == "testuser" + assert request.status == OnboardingStatus.PENDING_APPROVAL + assert request.github_invitation_sent is True + assert request.approved_by == "UADMIN123" + + def test_roundtrip_serialization(self): + """Test that to_dict and from_dict are inverses.""" + original = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + email="test@example.com", + github_username="testuser", + bio_raw="Original bio", + bio_edited="Edited bio", + website_url="https://example.com", + github_teams=[1, 2, 3], + calendar_permissions={"Cal1": "reader", "Cal2": "writer"}, + ) + original.update_status(OnboardingStatus.PENDING_APPROVAL) + original.github_invitation_sent = True + + # Serialize and deserialize + data = original.to_dict() + restored = OnboardingRequest.from_dict(data) + + # Verify key fields match + assert restored.slack_user_id == original.slack_user_id + assert restored.name == original.name + assert restored.email == original.email + assert restored.github_username == original.github_username + assert restored.bio_raw == original.bio_raw + assert restored.bio_edited == original.bio_edited + assert restored.website_url == original.website_url + assert restored.github_teams == original.github_teams + assert restored.calendar_permissions == original.calendar_permissions + assert restored.status == original.status + assert restored.github_invitation_sent == original.github_invitation_sent + + def test_json_serialization(self): + """Test that to_dict output is JSON serializable.""" + request = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + ) + request.update_status(OnboardingStatus.PENDING_APPROVAL) + + # Should not raise + json_str = json.dumps(request.to_dict()) + assert isinstance(json_str, str) + + # Should be able to parse back + parsed = json.loads(json_str) + assert parsed["slack_user_id"] == "U12345678" + + def test_error_message_field(self): + """Test error message field functionality.""" + request = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + ) + assert request.error_message == "" + + data = request.to_dict() + assert "error_message" in data + restored = OnboardingRequest.from_dict(data) + assert restored.error_message == "" + + def test_created_at_timestamp(self): + """Test that created_at is set automatically.""" + before = datetime.now() + request = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + ) + after = datetime.now() + + assert before <= request.created_at <= after + + def test_updated_at_changes_on_status_update(self): + """Test that updated_at changes when status is updated.""" + request = OnboardingRequest( + slack_user_id="U12345678", + slack_channel_id="C87654321", + name="Test User", + ) + original_updated = request.updated_at + + # Small delay to ensure time difference + import time + time.sleep(0.01) + + request.update_status(OnboardingStatus.PENDING_APPROVAL) + assert request.updated_at > original_updated From 2ee738d910a606f5dddc14f23e2648abec463bc5 Mon Sep 17 00:00:00 2001 From: Jeremy Manning Date: Sun, 7 Dec 2025 06:18:17 -0500 Subject: [PATCH 2/7] Add README documentation for onboarding bot (#181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents setup process, usage, and architecture. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- scripts/onboarding/README.md | Bin 0 -> 4409 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 scripts/onboarding/README.md diff --git a/scripts/onboarding/README.md b/scripts/onboarding/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c23b0d4698902e5ffa0f649363d56d66c1e846cf GIT binary patch literal 4409 zcma)AO>ZN)5nbfi=Nxk=SU@5NmW*UKAMj~3lE$Nq$1*HAz#<4Fs+*P=cayzMO0x3n z^NM7DSe_)~i{0Iv!%gXo(+ z?5(WUm&t$@QfHQqGEYq*;mlgGmE;U*d5pfXYHadcrq15bt#Y6D8)ADc3VB5vWv-~) z8HZm-xv|PgdV0Kr(ceX`GU22>H==_QG^MCTPoj@k()|f4f$0A3j1ge#C33%H)#{=70s^l+wRY?+Ap)`pS zTOLFntI#Q6U55C|S70n~Sc$!rQ^;0En^R0YF*w+x50X7w>B~UJopA9a9APC7<^@>` zF2;cbx1gUpey z7r^7F+>X|%(Hgks8r~c$KzEJZOevE;4H;J4ue9{*7yR1#-R7e`Y=27+vMQ9dYxLU~f>OBD}+ADRLyw zL#+FKC-c&uU}uiR56NGeYG2II`4zVsy7Pp@FLtJK>E79k7ez$uQ^9;KvzFS!mUa#1I|3AY>g4$(l;QU zHT9YQofHN7Fhiwk`XQ7KJY0bDE@PT1S(>f^KZ9(6hDZdLAz*X~I+UKucg)qFmyC3g zV`ba9qFI-lJt(&_|3XJ_FUDr$nRHHqOy1ZiHorj6RR@)VP14ltwd2;Cn4`{($ox!8 zE14k{C2~uDTg>l?k;_fyNtUEYfr*-7eQWZrXI{y7M~D&|+#<@6cDu@yD&=uPU*w6- zf{aXrKDQ>9Ls75ia<^pk&ClJ+f4<9Q`9xd7^L97HlU`dMu;e37?es>=pZV^1PQ2tixzjI@t4)d`Es?Kf0^EMdoiB{`ieMxHLsh&a5&^a5e-HU53K$t@Gd{|d4tAbwxijEz2DB3pPxRqu46wwjFutFrY#}` zCpBEp!n&Q$Z||n7@#Azdykeh}amaKJfm7;^py(R6e-y`PL8SHEYY zX0p#FJl5Bv`{n1y`NM3yLdjQOra$wdPv5FKD7Pk`i7X)xpidNwue#`^D$&$|o(5H| z5*QeE=w4l@9xFAF-Q!++ba;u_I5@c-bjiREK~=)6Rfj|9$Jzruv1|)i^d!y#snwm( zoHi(l8*kC&efof58$@LAw#1M@Me_WHSz?a3NHFRP4}I>-RvL1$(aJfQy{8P8R*n4e z@k+=DEDACPlri!O34%q*BhJ(;m-f1n%shT>&s!1INKVg=ObXg{6b@OIX<3w@OkXU| zWOkm5c0BoHgn%iR;(%0jvl17ET-0gvq1#fX>KR-UjK5(-AG(PQp}vu9$VAj?9C;;E zsSZ#Tjp7O_eu<=cYVnxBgQb1}t%DiIQ=&krF*NvfX7uk|jjNiX?4>_iq#YJXU|pm0 zQ&OJTEZAQ2pQ{#5;n{CvhhVCKC!v|&EM}EK^__nFAy=^vTX(VN>;CLV-3*o8A&1NF zIA<8h?>G1eW`6JeT}yw@79jce{#o?<)c@}@a`rqmqqj#@x`VPz`C{g&A%URfUcT-m zKl#lMKk$W+Q}yPjM_&abE5#yVCkJn(&xjA}^U-M3}t6GaUGO`w{?N)re zfW>fJ@1(gHuNXIguXop@iEtvWPIzGi03zj7va9!S!G~UbFXhpW#L6x184yT~Rjo+6 zFkq{*8zIaalXtl16ZGhT@5l9(wCyHu#wR|*8XK`$xZ?W0?n;gtSqqu&acMcxy#Xc@ z3@-?>MrZL&0k=tjXU&oV+7Ve$Tg`8I+%uG01pdFP?11Jig@SiFcgjJ$O7^$!uaLaa mL!}OUM^yURP$sz*kYzp?f&#VSUas+v1h4L{XtB7zO#TD;g|6BF literal 0 HcmV?d00001 From 77b7a4a2410cc01447cabb868931173ab0e44050 Mon Sep 17 00:00:00 2001 From: Jeremy Manning Date: Sun, 7 Dec 2025 06:43:56 -0500 Subject: [PATCH 3/7] Fix test encoding and lazy-load org validation (#181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix UTF-8 encoding in test_bio_service.py special chars test - Fix test_github_service.py to access org property for validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tests/test_onboarding/test_bio_service.py | 4 ++-- tests/test_onboarding/test_github_service.py | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_onboarding/test_bio_service.py b/tests/test_onboarding/test_bio_service.py index 8eeb0ce..1d9af56 100644 --- a/tests/test_onboarding/test_bio_service.py +++ b/tests/test_onboarding/test_bio_service.py @@ -259,9 +259,9 @@ class TestEdgeCases: def test_edit_bio_special_characters(self, bio_service): """Test handling bio with special characters.""" - raw_bio = "I study café culture & its effects on productivity! My research uses ü and é." + raw_bio = "I study café culture & its effects on productivity! My research uses α and β." - edited_bio, error = bio_service.edit_bio(raw_bio, "Marie Müller") + edited_bio, error = bio_service.edit_bio(raw_bio, "Marie Müller") assert error is None assert edited_bio != "" diff --git a/tests/test_onboarding/test_github_service.py b/tests/test_onboarding/test_github_service.py index f71eaad..77ebf87 100644 --- a/tests/test_onboarding/test_github_service.py +++ b/tests/test_onboarding/test_github_service.py @@ -33,9 +33,11 @@ def test_init_with_valid_token(self, github_token): def test_init_with_invalid_org(self, github_token): """Test initialization with invalid organization name.""" - # This should raise an error or handle gracefully + # The org is lazy loaded, so we need to access it to trigger the error + service = GitHubService(github_token, "nonexistent-org-that-does-not-exist-12345") with pytest.raises(Exception): - GitHubService(github_token, "nonexistent-org-that-does-not-exist-12345") + # Accessing the org property should raise an exception + _ = service.org class TestUsernameValidation: From ffc4fa108e9a70504d1567929a33b248989fbb50 Mon Sep 17 00:00:00 2001 From: Jeremy Manning Date: Sun, 7 Dec 2025 09:50:03 -0500 Subject: [PATCH 4/7] Fix: use re.compile() for view regex pattern in approval handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit slack-bolt doesn't have view_regex method, use app.view() with re.compile() instead. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- scripts/onboarding/handlers/approval.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/onboarding/handlers/approval.py b/scripts/onboarding/handlers/approval.py index 72dc22a..4ea95a1 100644 --- a/scripts/onboarding/handlers/approval.py +++ b/scripts/onboarding/handlers/approval.py @@ -5,6 +5,7 @@ """ import logging +import re from typing import Optional from slack_bolt import App @@ -149,7 +150,7 @@ def handle_request_changes(ack, body, client: WebClient, action): except SlackApiError as e: logger.error(f"Error opening changes modal: {e}") - @app.view_regex(r"request_changes_modal_.*") + @app.view(re.compile(r"request_changes_modal_.*")) def handle_changes_modal(ack, body, client: WebClient, view): """Handle submission of the request changes modal.""" ack() From a963d1f7fc45e2eecd19b49e5683dbd3d0a7b9dd Mon Sep 17 00:00:00 2001 From: Jeremy Manning Date: Sun, 7 Dec 2025 09:53:35 -0500 Subject: [PATCH 5/7] Fix README.md encoding - remove binary characters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file had embedded binary control characters that made it display incorrectly. Rewrote with clean UTF-8 text. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- scripts/onboarding/README.md | Bin 4409 -> 4495 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/scripts/onboarding/README.md b/scripts/onboarding/README.md index c23b0d4698902e5ffa0f649363d56d66c1e846cf..6a8c67fe054be702b2294b0dd4531479d4bfb4fb 100644 GIT binary patch delta 234 zcmdm~)UUijn~(F+lsS*4Gyu_LJw6R2UL>D^GF(m}BQY-}C$*?ppX7Rk837faapnL3 delta 148 zcmeBI-l?=fn~zzBfnl-|m1QQU0%d>(flWHYZw$7JL%;w`I|`UE M166LW6A)ws0Lw!iBme*a From 633c30dd248fcdb8aae8e520506b6b01e7df7365 Mon Sep 17 00:00:00 2001 From: Jeremy Manning Date: Sun, 7 Dec 2025 10:20:39 -0500 Subject: [PATCH 6/7] Add Workflow Builder integration for member-initiated onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add workflow_listener.py to listen for "Join the lab!" workflow output messages sent to admin, parse form data, and create interactive approval forms - Add workflow_step.py with optional custom workflow steps for advanced integration - Update approval.py with workflow-specific approval/reject/request-changes handlers - Add workflow link to lab_manual.tex so new members can click to start onboarding - Update README with Workflow Builder integration documentation The bot now supports two onboarding methods: 1. Member-initiated via "Join the lab!" workflow (recommended) 2. Admin-initiated via /cdl-onboard @user command 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lab_manual.pdf | Bin 237032 -> 237147 bytes lab_manual.tex | 2 +- scripts/onboarding/README.md | 67 ++- scripts/onboarding/bot.py | 4 + scripts/onboarding/handlers/approval.py | 265 +++++++++ .../onboarding/handlers/workflow_listener.py | 504 ++++++++++++++++ scripts/onboarding/handlers/workflow_step.py | 546 ++++++++++++++++++ 7 files changed, 1380 insertions(+), 8 deletions(-) create mode 100644 scripts/onboarding/handlers/workflow_listener.py create mode 100644 scripts/onboarding/handlers/workflow_step.py diff --git a/lab_manual.pdf b/lab_manual.pdf index 95b13ce54c2f4c83c6b389f1b2543e82105501e8..8ba3bcac12fb5e86de19c329ca65e26f0a03e6fc 100644 GIT binary patch delta 17144 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 LH3}sqMNdWw*Jb5l delta 17033 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 Person who started the workflow + - `github_username` -> GitHub username field from form + - `bio` -> Bio field from form + - `website_url` -> Website field from form + - `photo_url` -> Photo URL if collected + +### Connecting to "Leave the Lab" Workflow + +1. Create a new "Leave the lab" workflow or add to existing +2. Add the "Process CDL Offboarding" step +3. Map: + - `submitter_id` -> Person leaving the lab + ## Architecture ``` scripts/onboarding/ ├── bot.py # Main entry point ├── config.py # Configuration management +├── manifest.json # Slack app manifest (with functions) ├── handlers/ │ ├── onboard.py # /cdl-onboard command handling │ ├── approval.py # Admin approval workflow -│ └── offboard.py # /cdl-offboard command handling +│ ├── offboard.py # /cdl-offboard command handling +│ └── workflow_step.py # Workflow Builder custom steps ├── models/ │ └── onboarding_request.py # Data models └── services/ diff --git a/scripts/onboarding/bot.py b/scripts/onboarding/bot.py index dafd12b..16adca3 100644 --- a/scripts/onboarding/bot.py +++ b/scripts/onboarding/bot.py @@ -40,6 +40,8 @@ from .handlers.onboard import register_onboard_handlers from .handlers.approval import register_approval_handlers from .handlers.offboard import register_offboard_handlers +from .handlers.workflow_step import register_workflow_step_handlers +from .handlers.workflow_listener import register_workflow_listener_handlers # Configure logging logging.basicConfig( @@ -68,6 +70,8 @@ def create_app(config: Config) -> App: register_onboard_handlers(app, config) register_approval_handlers(app, config) register_offboard_handlers(app, config) + register_workflow_step_handlers(app, config) + register_workflow_listener_handlers(app, config) # Add a health check command @app.command("/cdl-ping") diff --git a/scripts/onboarding/handlers/approval.py b/scripts/onboarding/handlers/approval.py index 4ea95a1..a8a6115 100644 --- a/scripts/onboarding/handlers/approval.py +++ b/scripts/onboarding/handlers/approval.py @@ -205,6 +205,271 @@ def handle_teams_select(ack, body): """Handle GitHub team selection (just acknowledge, we'll read the value on approval).""" ack() + # ========== Workflow-initiated onboarding approval handlers ========== + # These handlers are for approving onboarding requests that came through + # Workflow Builder workflows (member-initiated) rather than slash commands. + + @app.action("approve_workflow_onboarding") + def handle_workflow_approve(ack, body, client: WebClient, action, complete, fail): + """Handle approval of a workflow-initiated onboarding request.""" + ack() + + user_id = action["value"] + admin_id = body["user"]["id"] + + # Verify admin + if admin_id != config.slack.admin_user_id: + return + + request = get_request(user_id) + if not request: + logger.error(f"No request found for user {user_id}") + return + + # Get selected teams from the message + selected_team_ids = _get_selected_teams(body) + + request.github_teams = selected_team_ids + request.approved_by = admin_id + request.update_status(OnboardingStatus.GITHUB_PENDING) + save_request(request) + + # Update the approval message + _update_approval_message(client, body, "Approved", request) + + # Process the approval + _process_approval(client, config, request, github_service, calendar_service) + + # Complete the workflow step (if it came from a workflow) + try: + from .workflow_step import get_workflow_execution, delete_workflow_execution + execution = get_workflow_execution(user_id) + if execution and complete: + complete({ + "status": "approved", + "github_username": request.github_username, + "name": request.name, + }) + delete_workflow_execution(user_id) + except Exception as e: + logger.warning(f"Could not complete workflow step: {e}") + + @app.action("reject_workflow_onboarding") + def handle_workflow_reject(ack, body, client: WebClient, action, complete, fail): + """Handle rejection of a workflow-initiated onboarding request.""" + ack() + + user_id = action["value"] + admin_id = body["user"]["id"] + + # Verify admin + if admin_id != config.slack.admin_user_id: + return + + request = get_request(user_id) + if not request: + return + + request.update_status(OnboardingStatus.REJECTED) + save_request(request) + + # Update the approval message + _update_approval_message(client, body, "Rejected", request) + + # Notify the user + try: + client.chat_postMessage( + channel=request.slack_channel_id, + text="Your onboarding request was not approved. Please contact the lab admin for more information.", + ) + except SlackApiError as e: + logger.error(f"Error notifying user of rejection: {e}") + + # Fail the workflow step (if it came from a workflow) + try: + from .workflow_step import get_workflow_execution, delete_workflow_execution + execution = get_workflow_execution(user_id) + if execution and fail: + fail("Onboarding request was rejected by admin") + delete_workflow_execution(user_id) + except Exception as e: + logger.warning(f"Could not fail workflow step: {e}") + + # Clean up + delete_request(user_id) + + @app.action("request_changes_workflow_onboarding") + def handle_workflow_request_changes(ack, body, client: WebClient, action): + """Handle request for changes to a workflow-initiated onboarding request.""" + ack() + + user_id = action["value"] + admin_id = body["user"]["id"] + + # Verify admin + if admin_id != config.slack.admin_user_id: + return + + request = get_request(user_id) + if not request: + return + + # Open a modal for the admin to specify what changes are needed + try: + client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": f"workflow_changes_modal_{user_id}", + "private_metadata": user_id, + "title": {"type": "plain_text", "text": "Request Changes"}, + "submit": {"type": "plain_text", "text": "Send"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "blocks": [ + { + "type": "input", + "block_id": "changes_block", + "element": { + "type": "plain_text_input", + "action_id": "changes_input", + "multiline": True, + "placeholder": { + "type": "plain_text", + "text": "Describe the changes needed...", + }, + }, + "label": { + "type": "plain_text", + "text": "What changes are needed?", + }, + }, + ], + }, + ) + except SlackApiError as e: + logger.error(f"Error opening changes modal: {e}") + + @app.view(re.compile(r"workflow_changes_modal_.*")) + def handle_workflow_changes_modal(ack, body, client: WebClient, view): + """Handle submission of the workflow changes request modal.""" + ack() + + user_id = view["private_metadata"] + request = get_request(user_id) + if not request: + return + + changes_text = view["state"]["values"]["changes_block"]["changes_input"]["value"] + + request.update_status(OnboardingStatus.PENDING_INFO) + save_request(request) + + # Notify the user + try: + client.chat_postMessage( + channel=request.slack_channel_id, + text="The admin has requested some changes to your onboarding information.", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":memo: *Changes Requested*\n\nThe lab admin has requested the following changes:", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f">{changes_text}", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Please reply to this message with the updated information, or re-run the \"Join the lab\" workflow.", + }, + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error notifying user of changes: {e}") + + @app.action("start_offboarding_workflow") + def handle_start_offboarding(ack, body, client: WebClient, action): + """Handle the start offboarding button from workflow notification.""" + ack() + + user_id = action["value"] + admin_id = body["user"]["id"] + + # Verify admin + if admin_id != config.slack.admin_user_id: + return + + # Open modal to select what to revoke + try: + client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": f"offboarding_modal_{user_id}", + "private_metadata": user_id, + "title": {"type": "plain_text", "text": "Offboarding"}, + "submit": {"type": "plain_text", "text": "Process"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"Select what access to revoke for <@{user_id}>:", + }, + }, + { + "type": "input", + "block_id": "revoke_options", + "element": { + "type": "checkboxes", + "action_id": "revoke_checkboxes", + "options": [ + { + "text": {"type": "plain_text", "text": "Remove from GitHub org"}, + "value": "github", + }, + { + "text": {"type": "plain_text", "text": "Remove calendar access"}, + "value": "calendar", + }, + { + "text": {"type": "plain_text", "text": "Move to alumni on website"}, + "value": "website_alumni", + }, + ], + "initial_options": [ + { + "text": {"type": "plain_text", "text": "Remove from GitHub org"}, + "value": "github", + }, + { + "text": {"type": "plain_text", "text": "Remove calendar access"}, + "value": "calendar", + }, + { + "text": {"type": "plain_text", "text": "Move to alumni on website"}, + "value": "website_alumni", + }, + ], + }, + "label": {"type": "plain_text", "text": "Access to revoke"}, + }, + ], + }, + ) + except SlackApiError as e: + logger.error(f"Error opening offboarding modal: {e}") + def _get_selected_teams(body: dict) -> list[int]: """Extract selected team IDs from the message state.""" diff --git a/scripts/onboarding/handlers/workflow_listener.py b/scripts/onboarding/handlers/workflow_listener.py new file mode 100644 index 0000000..26b49f9 --- /dev/null +++ b/scripts/onboarding/handlers/workflow_listener.py @@ -0,0 +1,504 @@ +""" +Workflow Builder message listener. + +Listens for messages from the existing "Join the lab!" Workflow Builder workflow +and processes them to create onboarding requests. + +The workflow sends two messages to the admin: +1. Step 4: GitHub username and Gmail address +2. Step 7: Name, bio, and personal website + +This handler listens for both messages, combines the data, and sends +an interactive approval form to the admin. +""" + +import logging +import re +from typing import Optional + +from slack_bolt import App +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from ..config import Config +from ..models.onboarding_request import OnboardingRequest, OnboardingStatus +from ..services.github_service import GitHubService +from ..services.bio_service import BioService +from .onboard import get_request, save_request, delete_request + +logger = logging.getLogger(__name__) + +# Temporary storage for partial onboarding data (keyed by Slack user ID) +# This holds the first form submission until we receive the second +_partial_requests: dict[str, dict] = {} + + +def get_partial_request(user_id: str) -> Optional[dict]: + """Get partial onboarding data for a user.""" + return _partial_requests.get(user_id) + + +def save_partial_request(user_id: str, data: dict): + """Save partial onboarding data.""" + _partial_requests[user_id] = data + + +def delete_partial_request(user_id: str): + """Delete partial onboarding data.""" + _partial_requests.pop(user_id, None) + + +def register_workflow_listener_handlers(app: App, config: Config): + """Register handlers that listen for Workflow Builder output messages.""" + + github_service = GitHubService(config.github.token, config.github.org_name) + bio_service = None + if config.anthropic: + bio_service = BioService(config.anthropic.api_key, config.anthropic.model) + + @app.event("message") + def handle_workflow_message(event, client: WebClient, say): + """ + Listen for messages from Workflow Builder. + + We're looking for messages sent to the admin that match the pattern: + "CDL Onboarding submission from [Person]" + """ + # Only process messages sent to the admin + channel = event.get("channel") + channel_type = event.get("channel_type") + + # Check if this is a DM to the admin (im = direct message) + if channel_type != "im": + return + + # Get the message text + text = event.get("text", "") + + # Check if this is a workflow message + if "CDL Onboarding" not in text and "submission from" not in text: + return + + # Check for bot_id to identify workflow messages (workflows post as bots) + bot_id = event.get("bot_id") + if not bot_id: + return + + logger.info(f"Detected potential workflow message: {text[:100]}...") + + # Try to parse the message + # The workflow sends messages with the person who submitted as a link + # Format: "CDL Onboarding submission from <@U12345|username>" + + # Extract the user ID from the message + user_match = re.search(r"submission from\s+<@([A-Z0-9]+)", text) + if not user_match: + logger.debug("Could not extract user ID from workflow message") + return + + submitter_id = user_match.group(1) + logger.info(f"Workflow submission from user: {submitter_id}") + + # Parse the form fields from the message + # Messages have format like: + # "What's your GitHub username?\nAnswer to: What's your GitHub username?" + # or similar patterns + + parsed_data = _parse_workflow_message(text) + + if not parsed_data: + logger.warning(f"Could not parse workflow message fields") + return + + logger.info(f"Parsed workflow data: {parsed_data}") + + # Determine which form this is (first or second) + has_github = "github_username" in parsed_data + has_bio = "bio" in parsed_data or "name" in parsed_data + + if has_github and not has_bio: + # This is the first form (Step 4) - GitHub and email + logger.info(f"Received first workflow form for {submitter_id}") + + # Store partial data + partial = get_partial_request(submitter_id) or {} + partial.update(parsed_data) + partial["submitter_id"] = submitter_id + save_partial_request(submitter_id, partial) + + # Acknowledge receipt but wait for second form + try: + client.chat_postMessage( + channel=channel, + text=f":white_check_mark: Received GitHub info for <@{submitter_id}>. Waiting for website info...", + thread_ts=event.get("ts"), # Reply in thread + ) + except SlackApiError as e: + logger.error(f"Error sending acknowledgment: {e}") + + elif has_bio: + # This is the second form (Step 7) - name, bio, website + logger.info(f"Received second workflow form for {submitter_id}") + + # Get partial data from first form + partial = get_partial_request(submitter_id) or {} + partial.update(parsed_data) + partial["submitter_id"] = submitter_id + + # Now we have all the data - process it + _process_complete_workflow_submission( + client, config, submitter_id, partial, + github_service, bio_service, channel + ) + + # Clean up partial data + delete_partial_request(submitter_id) + + else: + # Unknown form type - store what we have + logger.warning(f"Unknown workflow form type, storing data") + partial = get_partial_request(submitter_id) or {} + partial.update(parsed_data) + partial["submitter_id"] = submitter_id + save_partial_request(submitter_id, partial) + + +def _parse_workflow_message(text: str) -> dict: + """ + Parse form fields from a Workflow Builder message. + + The message format is typically: + "CDL Onboarding submission from <@U123|name> + + What's your GitHub username? + Answer to: What's your GitHub username? + + What's your GMail address (include @gmail.com or @dartmouth.edu)? + Answer to: What's your GMail address..." + + Returns a dict with parsed field names and values. + """ + result = {} + + # Split by lines and look for question/answer pairs + lines = text.split("\n") + + current_question = None + for i, line in enumerate(lines): + line = line.strip() + + # Look for GitHub username + if "github username" in line.lower(): + # Next line starting with "Answer to:" contains the value + for j in range(i + 1, min(i + 3, len(lines))): + next_line = lines[j].strip() + if next_line.startswith("Answer to:"): + # The answer is after "Answer to: [question]?" + # But actually the answer IS the repeated text - extract it + # Format: "Answer to: What's your GitHub username?" + # The actual answer comes after this in the Slack message rendering + pass + elif next_line and not next_line.startswith("What") and "?" not in next_line: + # This might be the actual answer + result["github_username"] = next_line + break + + # Look for email/Gmail + if "gmail" in line.lower() or "email" in line.lower(): + for j in range(i + 1, min(i + 3, len(lines))): + next_line = lines[j].strip() + if next_line and "@" in next_line and not next_line.startswith("Answer"): + result["email"] = next_line + break + elif next_line.startswith("Answer to:") and "@" in next_line: + # Extract email from answer line + email_match = re.search(r'[\w\.-]+@[\w\.-]+\.\w+', next_line) + if email_match: + result["email"] = email_match.group(0) + break + + # Look for name + if "name listed on the lab website" in line.lower() or "how do you want your name" in line.lower(): + for j in range(i + 1, min(i + 3, len(lines))): + next_line = lines[j].strip() + if next_line and not next_line.startswith("Answer") and not next_line.startswith("What") and not next_line.startswith("Please") and not next_line.startswith("Do you"): + result["name"] = next_line + break + + # Look for bio + if "bio" in line.lower() and "sentence" in line.lower(): + for j in range(i + 1, min(i + 3, len(lines))): + next_line = lines[j].strip() + if next_line and not next_line.startswith("Answer") and not next_line.startswith("What") and not next_line.startswith("Do you") and len(next_line) > 20: + result["bio"] = next_line + break + + # Look for website + if "personal website" in line.lower(): + for j in range(i + 1, min(i + 3, len(lines))): + next_line = lines[j].strip() + if next_line and ("http" in next_line or "www" in next_line or next_line == "blank" or not next_line.startswith("Answer")): + if next_line.lower() != "blank" and next_line: + result["website_url"] = next_line + break + + # Alternative parsing: look for the cyan/blue "Answer to:" formatted text + # In Slack's rendering, the answers appear as linked text + # Pattern: field label followed by cyan text with the answer + + # Try regex patterns for common formats + github_patterns = [ + r"GitHub username[?\s]*\n*(?:Answer to:[^\n]*)?\n*([A-Za-z0-9_-]+)", + r"GitHub username[?\s]*:?\s*([A-Za-z0-9_-]+)", + ] + for pattern in github_patterns: + match = re.search(pattern, text, re.IGNORECASE) + if match and "github_username" not in result: + result["github_username"] = match.group(1).strip() + break + + email_patterns = [ + r"(?:gmail|email)[^@\n]*?(?:Answer to:[^\n]*)?\n*([\w\.-]+@[\w\.-]+\.\w+)", + r"([\w\.-]+@(?:gmail\.com|dartmouth\.edu))", + ] + for pattern in email_patterns: + match = re.search(pattern, text, re.IGNORECASE) + if match and "email" not in result: + result["email"] = match.group(1).strip() + break + + return result + + +def _process_complete_workflow_submission( + client: WebClient, + config: Config, + submitter_id: str, + data: dict, + github_service: GitHubService, + bio_service: Optional[BioService], + admin_channel: str, +): + """Process a complete workflow submission and send approval request.""" + + github_username = data.get("github_username", "") + email = data.get("email", "") + name = data.get("name", "") + bio_raw = data.get("bio", "") + website_url = data.get("website_url", "") + + logger.info(f"Processing complete workflow submission for {submitter_id}") + logger.info(f" GitHub: {github_username}, Email: {email}") + logger.info(f" Name: {name}, Bio: {bio_raw[:50] if bio_raw else 'N/A'}...") + + # Get user info from Slack if name not provided + if not name: + try: + user_info = client.users_info(user=submitter_id) + name = user_info["user"]["real_name"] or user_info["user"]["name"] + if not email: + email = user_info["user"].get("profile", {}).get("email", "") + except SlackApiError as e: + logger.warning(f"Could not get user info: {e}") + name = "Unknown" + + # Validate GitHub username + if github_username: + is_valid, error_msg = github_service.validate_username(github_username) + if not is_valid: + try: + client.chat_postMessage( + channel=admin_channel, + text=f":warning: GitHub username `{github_username}` for <@{submitter_id}> is invalid: {error_msg}", + ) + except SlackApiError: + pass + # Continue anyway - admin can handle it + + # Open DM channel with the new member + try: + dm_response = client.conversations_open(users=[submitter_id]) + dm_channel = dm_response["channel"]["id"] + except SlackApiError as e: + logger.error(f"Error opening DM with user: {e}") + dm_channel = None + + # Create onboarding request + request = OnboardingRequest( + slack_user_id=submitter_id, + slack_channel_id=dm_channel or admin_channel, + name=name, + email=email, + github_username=github_username, + bio_raw=bio_raw, + website_url=website_url, + ) + + # Process bio if service available + if bio_service and bio_raw: + edited_bio, bio_error = bio_service.edit_bio(bio_raw, name) + if edited_bio: + request.bio_edited = edited_bio + else: + logger.warning(f"Bio editing failed: {bio_error}") + + request.update_status(OnboardingStatus.PENDING_APPROVAL) + save_request(request) + + # Send approval request to admin + _send_workflow_approval_request(client, config, request, github_service, admin_channel) + + +def _send_workflow_approval_request( + client: WebClient, + config: Config, + request: OnboardingRequest, + github_service: GitHubService, + channel: str, +): + """Send an approval request to the admin channel.""" + + # Get GitHub teams for the checkboxes + teams = github_service.get_teams() + + # Build team options + team_options = [] + initial_options = [] + for team in teams: + option = { + "text": {"type": "plain_text", "text": team["name"]}, + "value": str(team["id"]), + } + team_options.append(option) + if team["name"] == config.github.default_team: + initial_options.append(option) + + # Build the approval message + blocks = [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":clipboard: New Member - Workflow Submission", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{request.name}* (<@{request.slack_user_id}>) submitted the \"Join the lab\" workflow.", + }, + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*GitHub Username:* `{request.github_username or 'Not provided'}`\n" + f"*Email:* {request.email or 'Not provided'}\n" + f"*Website:* {request.website_url or 'None'}", + }, + }, + ] + + # Add bio section + if request.bio_raw: + bio_preview = request.bio_raw[:300] + "..." if len(request.bio_raw) > 300 else request.bio_raw + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Original Bio:*\n>{bio_preview}", + }, + }) + + if request.bio_edited: + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Edited Bio (CDL style):*\n>{request.bio_edited}", + }, + }) + + # Photo status + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":camera: *Photo:* Waiting for DM (workflow asks them to send it to you)", + }, + }) + + blocks.append({"type": "divider"}) + + # GitHub team selection + if team_options: + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Select GitHub teams:*", + }, + "accessory": { + "type": "checkboxes", + "action_id": "github_teams_select", + "options": team_options[:10], + "initial_options": initial_options if initial_options else None, + }, + }) + + # Calendar permissions info + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Calendar Permissions (defaults):*\n" + "• Contextual Dynamics Lab: Read-only\n" + "• Out of lab: Edit\n" + "• CDL Resources: Edit", + }, + }) + + blocks.append({"type": "divider"}) + + # Action buttons + blocks.append({ + "type": "actions", + "block_id": f"workflow_approval_actions_{request.slack_user_id}", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Approve & Send Invites"}, + "style": "primary", + "action_id": "approve_workflow_onboarding", + "value": request.slack_user_id, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Reject"}, + "style": "danger", + "action_id": "reject_workflow_onboarding", + "value": request.slack_user_id, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Request Changes"}, + "action_id": "request_changes_workflow_onboarding", + "value": request.slack_user_id, + }, + ], + }) + + # Send to admin + try: + result = client.chat_postMessage( + channel=channel, + text=f"New member request from {request.name}", + blocks=blocks, + ) + request.admin_approval_message_ts = result["ts"] + save_request(request) + logger.info(f"Sent approval request for {request.name}") + except SlackApiError as e: + logger.error(f"Error sending approval request: {e}") diff --git a/scripts/onboarding/handlers/workflow_step.py b/scripts/onboarding/handlers/workflow_step.py new file mode 100644 index 0000000..f0e04c5 --- /dev/null +++ b/scripts/onboarding/handlers/workflow_step.py @@ -0,0 +1,546 @@ +""" +Custom Workflow Step handlers for Slack Workflow Builder integration. + +This module provides custom steps that can be added to Workflow Builder workflows +for onboarding and offboarding processes. + +The "Process Onboarding" step receives form data from a workflow and sends +an approval request to the admin. The workflow remains paused until approved. +""" + +import logging +import os +import tempfile +from pathlib import Path +from typing import Optional + +import requests +from slack_bolt import App, Complete, Fail +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from ..config import Config +from ..models.onboarding_request import OnboardingRequest, OnboardingStatus +from ..services.github_service import GitHubService +from ..services.image_service import ImageService +from ..services.bio_service import BioService +from .onboard import get_request, save_request, delete_request + +logger = logging.getLogger(__name__) + +# Store workflow execution context for completing after admin approval +_pending_workflow_executions: dict[str, dict] = {} + + +def get_workflow_execution(user_id: str) -> Optional[dict]: + """Get pending workflow execution context for a user.""" + return _pending_workflow_executions.get(user_id) + + +def save_workflow_execution(user_id: str, context: dict): + """Save workflow execution context.""" + _pending_workflow_executions[user_id] = context + + +def delete_workflow_execution(user_id: str): + """Delete workflow execution context.""" + _pending_workflow_executions.pop(user_id, None) + + +def register_workflow_step_handlers(app: App, config: Config): + """Register custom workflow step handlers with the Slack app.""" + + github_service = GitHubService(config.github.token, config.github.org_name) + image_service = ImageService(config.border_color, config.border_width) + bio_service = None + if config.anthropic: + bio_service = BioService(config.anthropic.api_key, config.anthropic.model) + + @app.function("cdl_onboarding_step") + def handle_onboarding_step(inputs: dict, fail: Fail, client: WebClient, context, body: dict): + """ + Handle the CDL onboarding workflow step. + + This function is triggered when the custom step is executed in a workflow. + It receives the form data collected by previous workflow steps and sends + an approval request to the admin. + + Expected inputs (from workflow variables): + - submitter_id: Slack user ID of the new member + - name: Full name + - github_username: GitHub username + - bio: Short bio text + - website_url: Optional personal website + - photo_url: Optional URL to profile photo (from file upload) + + The workflow will remain paused until complete() or fail() is called. + """ + try: + submitter_id = inputs.get("submitter_id") + name = inputs.get("name", "") + github_username = inputs.get("github_username", "") + bio_raw = inputs.get("bio", "") + website_url = inputs.get("website_url", "") + photo_url = inputs.get("photo_url", "") + email = inputs.get("email", "") + + logger.info(f"Onboarding step triggered for {name} ({submitter_id})") + + if not submitter_id: + fail("Missing submitter ID") + return + + if not github_username: + fail("Missing GitHub username") + return + + # Validate GitHub username + is_valid, error_msg = github_service.validate_username(github_username) + if not is_valid: + fail(f"Invalid GitHub username '{github_username}': {error_msg}") + return + + # Get user info from Slack if name not provided + if not name: + try: + user_info = client.users_info(user=submitter_id) + name = user_info["user"]["real_name"] or user_info["user"]["name"] + if not email: + email = user_info["user"].get("profile", {}).get("email", "") + except SlackApiError as e: + logger.warning(f"Could not get user info: {e}") + name = "Unknown" + + # Open DM channel with the new member + try: + dm_response = client.conversations_open(users=[submitter_id]) + dm_channel = dm_response["channel"]["id"] + except SlackApiError as e: + logger.error(f"Error opening DM with user: {e}") + fail(f"Could not open DM with user: {e}") + return + + # Create onboarding request + request = OnboardingRequest( + slack_user_id=submitter_id, + slack_channel_id=dm_channel, + name=name, + email=email, + github_username=github_username, + bio_raw=bio_raw, + website_url=website_url, + ) + + # Process bio if service available + if bio_service and bio_raw: + edited_bio, bio_error = bio_service.edit_bio(bio_raw, name) + if edited_bio: + request.bio_edited = edited_bio + else: + logger.warning(f"Bio editing failed: {bio_error}") + + # Process photo if provided + if photo_url: + try: + processed_path = _process_photo_from_url( + photo_url, submitter_id, config, image_service + ) + if processed_path: + request.photo_processed_path = processed_path + except Exception as e: + logger.warning(f"Photo processing failed: {e}") + + request.update_status(OnboardingStatus.PENDING_APPROVAL) + save_request(request) + + # Save workflow execution context for completing after approval + # The function_execution_id is needed to complete() or fail() later + function_execution_id = body.get("function_data", {}).get("execution_id") + save_workflow_execution(submitter_id, { + "execution_id": function_execution_id, + "inputs": inputs, + "context": context, + }) + + # Send acknowledgment to the new member + try: + client.chat_postMessage( + channel=dm_channel, + text="Your onboarding information has been received!", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":white_check_mark: *Welcome to CDL!*\n\n" + "Your onboarding information has been submitted. " + "The lab admin will review it shortly.", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*What's next:*\n" + f"• GitHub: Invitation to ContextLab organization\n" + f"• Calendar: Access to lab calendars\n" + f"• Website: Your profile will be added to context-lab.com", + }, + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error sending acknowledgment: {e}") + + # Send approval request to admin + _send_workflow_approval_request(client, config, request, github_service) + + # NOTE: We do NOT call complete() here! + # The workflow stays paused until admin approves/rejects. + # complete() will be called from the approval handler. + + except Exception as e: + logger.exception(f"Error in onboarding step: {e}") + fail(f"Onboarding step failed: {e}") + + @app.function("cdl_offboarding_step") + def handle_offboarding_step(inputs: dict, fail: Fail, client: WebClient, complete: Complete): + """ + Handle the CDL offboarding workflow step. + + Expected inputs: + - submitter_id: Slack user ID of the departing member + - name: Full name (optional, can be looked up) + + This step notifies the admin and generates an offboarding checklist. + """ + try: + submitter_id = inputs.get("submitter_id") + name = inputs.get("name", "") + + if not submitter_id: + fail("Missing submitter ID") + return + + # Get user info if name not provided + if not name: + try: + user_info = client.users_info(user=submitter_id) + name = user_info["user"]["real_name"] or user_info["user"]["name"] + except SlackApiError: + name = "Unknown" + + logger.info(f"Offboarding step triggered for {name} ({submitter_id})") + + # Send offboarding notification to admin + try: + client.chat_postMessage( + channel=config.slack.admin_user_id, + text=f"Offboarding request from {name}", + blocks=[ + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":wave: Offboarding Request", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{name}* (<@{submitter_id}>) has initiated the offboarding process.", + }, + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Offboarding Checklist:*\n" + ":ballot_box_with_check: Remove from GitHub ContextLab organization\n" + ":ballot_box_with_check: Remove from lab calendars\n" + ":ballot_box_with_check: Update website (remove or move to alumni)\n" + ":ballot_box_with_check: Transfer any relevant files/data\n" + ":ballot_box_with_check: Update mailing lists", + }, + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Start Offboarding"}, + "style": "primary", + "action_id": "start_offboarding_workflow", + "value": submitter_id, + }, + ], + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error sending offboarding notification: {e}") + fail(f"Could not send offboarding notification: {e}") + return + + # Send confirmation to departing member + try: + dm_response = client.conversations_open(users=[submitter_id]) + dm_channel = dm_response["channel"]["id"] + client.chat_postMessage( + channel=dm_channel, + text="Thank you for your time with CDL!", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":wave: *Thank you for your time with CDL!*\n\n" + "The lab admin has been notified about your departure. " + "They'll handle the access revocations and website updates.", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "If you have any questions or need anything, " + "feel free to reach out to the lab admin.", + }, + }, + ], + ) + except SlackApiError as e: + logger.warning(f"Could not send confirmation to departing member: {e}") + + # Complete the workflow step + complete({"status": "notified", "member_name": name}) + + except Exception as e: + logger.exception(f"Error in offboarding step: {e}") + fail(f"Offboarding step failed: {e}") + + +def _process_photo_from_url( + photo_url: str, + user_id: str, + config: Config, + image_service: ImageService, +) -> Optional[Path]: + """Download and process a photo from URL.""" + try: + # Download the file + headers = {"Authorization": f"Bearer {config.slack.bot_token}"} + response = requests.get(photo_url, headers=headers) + response.raise_for_status() + + # Save to temp file + with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp: + tmp.write(response.content) + tmp_path = Path(tmp.name) + + # Validate the image + is_valid, error_msg = image_service.validate_image(tmp_path) + if not is_valid: + tmp_path.unlink() + logger.warning(f"Image validation failed: {error_msg}") + return None + + # Process the photo + output_path = config.output_dir / f"{user_id}_photo.png" + processed_path = image_service.add_hand_drawn_border( + tmp_path, output_path, seed=hash(user_id) + ) + + # Clean up temp file + tmp_path.unlink() + + return processed_path + + except Exception as e: + logger.error(f"Error processing photo: {e}") + return None + + +def _send_workflow_approval_request( + client: WebClient, + config: Config, + request: OnboardingRequest, + github_service: GitHubService, +): + """Send an approval request to the admin (for workflow-initiated onboarding).""" + # Get GitHub teams for the checkboxes + teams = github_service.get_teams() + + # Build team options + team_options = [] + initial_options = [] + for team in teams: + option = { + "text": {"type": "plain_text", "text": team["name"]}, + "value": str(team["id"]), + } + team_options.append(option) + if team["name"] == config.github.default_team: + initial_options.append(option) + + # Build the approval message + blocks = [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":clipboard: New Member - Join the Lab Request", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{request.name}* (<@{request.slack_user_id}>) has submitted the \"Join the lab\" form.", + }, + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*GitHub Username:* `{request.github_username}`\n" + f"*Email:* {request.email or 'Not provided'}\n" + f"*Website:* {request.website_url or 'None'}", + }, + }, + ] + + # Add bio section + if request.bio_raw: + bio_preview = request.bio_raw[:300] + "..." if len(request.bio_raw) > 300 else request.bio_raw + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Original Bio:*\n>{bio_preview}", + }, + }) + + if request.bio_edited: + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Edited Bio (for website):*\n>{request.bio_edited}", + }, + }) + + # Photo status + if request.photo_processed_path: + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":camera: *Photo:* Received and processed with CDL border", + }, + }) + else: + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":camera: *Photo:* Not yet uploaded", + }, + }) + + blocks.append({"type": "divider"}) + + # GitHub team selection + if team_options: + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Select GitHub teams to add this member to:*", + }, + "accessory": { + "type": "checkboxes", + "action_id": "github_teams_select", + "options": team_options[:10], # Slack limits to 10 options + "initial_options": initial_options if initial_options else None, + }, + }) + + # Calendar permissions info + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Calendar Permissions (defaults):*\n" + "• Contextual Dynamics Lab: Read-only\n" + "• Out of lab: Edit\n" + "• CDL Resources: Edit", + }, + }) + + blocks.append({"type": "divider"}) + + # Action buttons - using workflow-specific action IDs + blocks.append({ + "type": "actions", + "block_id": f"workflow_approval_actions_{request.slack_user_id}", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Approve"}, + "style": "primary", + "action_id": "approve_workflow_onboarding", + "value": request.slack_user_id, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Reject"}, + "style": "danger", + "action_id": "reject_workflow_onboarding", + "value": request.slack_user_id, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Request Changes"}, + "action_id": "request_changes_workflow_onboarding", + "value": request.slack_user_id, + }, + ], + }) + + # Send to admin + try: + result = client.chat_postMessage( + channel=config.slack.admin_user_id, + text=f"New member request from {request.name}", + blocks=blocks, + ) + request.admin_approval_message_ts = result["ts"] + save_request(request) + except SlackApiError as e: + logger.error(f"Error sending approval request: {e}") + + +def complete_workflow_onboarding(user_id: str, success: bool, outputs: dict = None): + """ + Complete a pending workflow onboarding step. + + Called from the approval handler after admin approves/rejects. + + Args: + user_id: Slack user ID of the onboarding member + success: Whether the onboarding was approved + outputs: Output values to return to the workflow + """ + execution = get_workflow_execution(user_id) + if not execution: + logger.warning(f"No workflow execution found for user {user_id}") + return False + + # The complete/fail functions would need to be called with the execution context + # This is handled by storing the context and using Slack's API + delete_workflow_execution(user_id) + return True From 4379894a7e96584d66343f7d77ec15ceed106eec Mon Sep 17 00:00:00 2001 From: Jeremy Manning Date: Thu, 18 Dec 2025 20:15:26 -0500 Subject: [PATCH 7/7] Add website integration, idempotency, startup queue, and lab manual docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Website Integration: - Add WebsiteService for GitHub API operations on contextlab.github.io - Create/update PRs for people.xlsx, photos, and CV entries - Support all role types (Grad, Undergrad, Postdoc, Lab Manager, etc.) - Add website approval handlers with preview/edit modals Idempotency Fixes: - Check for existing members in people.xlsx before adding - Check for existing CV entries before inserting - Check alumni sheets before adding departing members State Persistence: - Add storage.py for JSON-based request persistence - Requests now survive bot restarts Startup Queue: - Scan admin DM history on startup for missed submissions - Track processed message timestamps - Add "Reprocess Now" and "Dismiss" buttons for missed items Documentation: - Add "Onboarding and offboarding" section to lab_manual.tex 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 6 + lab_manual.pdf | Bin 237147 -> 238946 bytes lab_manual.tex | 32 + scripts/onboarding/bot.py | 11 + scripts/onboarding/handlers/approval.py | 30 +- scripts/onboarding/handlers/offboard.py | 45 +- scripts/onboarding/handlers/onboard.py | 152 +++- .../onboarding/handlers/website_approval.py | 809 ++++++++++++++++++ .../onboarding/handlers/workflow_listener.py | 25 +- scripts/onboarding/handlers/workflow_step.py | 2 +- .../onboarding/models/onboarding_request.py | 32 + .../onboarding/services/website_service.py | 656 ++++++++++++++ scripts/onboarding/startup_queue.py | 411 +++++++++ scripts/onboarding/storage.py | 114 +++ 14 files changed, 2288 insertions(+), 37 deletions(-) create mode 100644 scripts/onboarding/handlers/website_approval.py create mode 100644 scripts/onboarding/services/website_service.py create mode 100644 scripts/onboarding/startup_queue.py create mode 100644 scripts/onboarding/storage.py diff --git a/.gitignore b/.gitignore index 3ba8185..d8c6c57 100644 --- a/.gitignore +++ b/.gitignore @@ -33,8 +33,11 @@ html_test/ scripts/onboarding/.env scripts/onboarding/output/ scripts/onboarding/*.json +scripts/onboarding/secrets/ +scripts/onboarding/data/ *.credentials.json service-account*.json +google-credentials.json # Python __pycache__/ @@ -66,3 +69,6 @@ venv/ ENV/ env/ .venv/ + +# Claude session notes +notes/ diff --git a/lab_manual.pdf b/lab_manual.pdf index 8ba3bcac12fb5e86de19c329ca65e26f0a03e6fc..f87d0cb12a2b0f06f5f4764506395760fdaf265a 100644 GIT binary patch delta 49553 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 IC;{`o00>ETjQ{`u delta 47741 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 N4tPpRNo6Vc{|m}(VXy!I diff --git a/lab_manual.tex b/lab_manual.tex index be0f47f..fe665e7 100644 --- a/lab_manual.tex +++ b/lab_manual.tex @@ -866,6 +866,38 @@ \subsection{Authorship guidelines} feel you don't have the appropriate account type, please communicate your concerns to \director. +\newthought{Onboarding and offboarding} +\label{sec:onboarding} + +\noindent The lab uses an automated onboarding system to help new members get set up quickly and consistently. When you initiate the ``Join the lab!'' workflow in Slack, you'll be asked to provide: +\begin{itemize} +\item Your GitHub username +\item Your email address (preferably Gmail or Dartmouth) +\item Your preferred name for the lab website +\item A short bio (2--3 sentences about yourself and your research interests) +\item A link to your personal website (optional) +\end{itemize} + +\marginnote{\texttt{NOTE:} After submitting the workflow, please also send a profile photo to \director~via Slack DM. Once approved, your photo and bio will be added to the lab website.} + +Once you submit this information, \director~will review your submission and approve your onboarding. The system will then automatically: +\begin{itemize} +\item Send you an invitation to the \href{https://github.com/ContextLab}{ContextLab GitHub organization} +\item Grant you access to the lab's Google Calendars (Contextual Dynamics Lab, Out of lab, and CDL Resources) +\item Add your profile to the \href{https://contextlab.github.io}{lab website} via a pull request +\item Update the lab's internal records +\end{itemize} + +You will receive notifications in Slack as each step completes. If any issues arise during the process, \director~will reach out to help resolve them. + +\textbf{When leaving the lab,} please notify \director~in advance via Slack to initiate the offboarding process. The offboarding process will: +\begin{itemize} +\item Move your profile to the alumni section of the lab website +\item Revoke access to GitHub repositories and calendars (as appropriate) +\item Update lab records +\end{itemize} +You may be asked to provide your current position and institution URL for the alumni listing. + \newthought{Setting up your development environment} \label{sec:dev-environment} diff --git a/scripts/onboarding/bot.py b/scripts/onboarding/bot.py index 16adca3..01dd363 100644 --- a/scripts/onboarding/bot.py +++ b/scripts/onboarding/bot.py @@ -42,6 +42,8 @@ from .handlers.offboard import register_offboard_handlers from .handlers.workflow_step import register_workflow_step_handlers from .handlers.workflow_listener import register_workflow_listener_handlers +from .handlers.website_approval import register_website_approval_handlers +from .startup_queue import process_startup_queue, StartupQueueProcessor, register_startup_queue_handlers # Configure logging logging.basicConfig( @@ -72,6 +74,8 @@ def create_app(config: Config) -> App: register_offboard_handlers(app, config) register_workflow_step_handlers(app, config) register_workflow_listener_handlers(app, config) + register_website_approval_handlers(app, config) + register_startup_queue_handlers(app, config) # Add a health check command @app.command("/cdl-ping") @@ -152,6 +156,13 @@ def main(): # Create the app app = create_app(config) + # Process any missed workflow submissions from when bot was offline + from slack_sdk import WebClient + client = WebClient(token=config.slack.bot_token) + missed_count = process_startup_queue(client, config) + if missed_count > 0: + logger.info(f"Queued {missed_count} missed workflow submissions for processing") + # Start the bot in Socket Mode handler = SocketModeHandler(app, config.slack.app_token) logger.info("Bot started in Socket Mode. Press Ctrl+C to stop.") diff --git a/scripts/onboarding/handlers/approval.py b/scripts/onboarding/handlers/approval.py index a8a6115..39e6b6b 100644 --- a/scripts/onboarding/handlers/approval.py +++ b/scripts/onboarding/handlers/approval.py @@ -16,7 +16,7 @@ from ..models.onboarding_request import OnboardingRequest, OnboardingStatus from ..services.github_service import GitHubService from ..services.calendar_service import CalendarService -from .onboard import get_request, save_request, delete_request +from ..storage import get_request, save_request, delete_request logger = logging.getLogger(__name__) @@ -615,18 +615,32 @@ def _process_approval( }, }) - # Add website update instructions if ready + # Add website preview button if ready if website_ready: + request.update_status(OnboardingStatus.WEBSITE_PENDING) + save_request(request) + summary_blocks.append({"type": "divider"}) summary_blocks.append({ "type": "section", "text": { "type": "mrkdwn", - "text": "*Website Update:*\n" - f"The processed photo has been saved to: `{request.photo_processed_path}`\n\n" - f"*Edited bio:*\n>{request.bio_edited}", + "text": ":globe_with_meridians: *Website Profile Ready*\n" + "Photo and bio are ready. Click below to preview and create a PR.", }, }) + summary_blocks.append({ + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Preview Website Changes"}, + "style": "primary", + "action_id": "preview_website_changes", + "value": request.slack_user_id, + }, + ], + }) try: client.chat_postMessage( @@ -685,7 +699,7 @@ def _process_approval( except SlackApiError as e: logger.error(f"Error notifying member: {e}") - # Mark as completed - if not errors: + # Mark as completed only if no errors and no pending website PR + if not errors and not website_ready: request.update_status(OnboardingStatus.COMPLETED) - save_request(request) + save_request(request) diff --git a/scripts/onboarding/handlers/offboard.py b/scripts/onboarding/handlers/offboard.py index e75fc30..a2f91aa 100644 --- a/scripts/onboarding/handlers/offboard.py +++ b/scripts/onboarding/handlers/offboard.py @@ -31,6 +31,7 @@ class OffboardingRequest: email: str = "" remove_github: bool = False remove_calendars: bool = False + move_to_alumni: bool = False created_at: datetime = field(default_factory=datetime.now) @@ -100,6 +101,7 @@ def handle_confirm_offboarding(ack, body, client: WebClient, action): remove_github = False remove_calendars = False + move_to_alumni = False for block_id, block_data in state.items(): if "offboard_options" in block_data: @@ -109,9 +111,12 @@ def handle_confirm_offboarding(ack, body, client: WebClient, action): remove_github = True elif opt["value"] == "calendars": remove_calendars = True + elif opt["value"] == "website_alumni": + move_to_alumni = True request.remove_github = remove_github request.remove_calendars = remove_calendars + request.move_to_alumni = move_to_alumni # Process the offboarding _process_offboarding(client, config, request) @@ -216,6 +221,11 @@ def _send_offboarding_request_to_admin( "value": "calendars", "description": {"type": "plain_text", "text": "Revoke access to lab calendars"}, }, + { + "text": {"type": "plain_text", "text": "Move to alumni on website"}, + "value": "website_alumni", + "description": {"type": "plain_text", "text": "Update website and CV to show as alumni"}, + }, ], }, }, @@ -305,12 +315,13 @@ def _process_offboarding(client: WebClient, config: Config, request: Offboarding f"• CDL Resources" ) - # Always include website instructions - results.append( - f":globe_with_meridians: *Website:* Please remove {request.name}'s profile from:\n" - f"https://www.context-lab.com/people\n" - f"(Or from the GitHub Pages people-site repo once migrated)" - ) + # Website instructions (only if not using automated alumni flow) + if not request.move_to_alumni: + results.append( + f":globe_with_meridians: *Website:* Please remove {request.name}'s profile from:\n" + f"https://www.context-lab.com/people\n" + f"(Or use the alumni workflow to move them to the alumni section)" + ) # Send summary to admin summary_blocks = [ @@ -339,6 +350,28 @@ def _process_offboarding(client: WebClient, config: Config, request: Offboarding }, }) + # Add alumni collection button if requested + if request.move_to_alumni: + summary_blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":mortar_board: *Website Alumni Update:* Click the button below to collect alumni info and create a PR.", + }, + }) + summary_blocks.append({ + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Collect Alumni Info"}, + "style": "primary", + "action_id": "collect_alumni_info", + "value": request.slack_user_id, + }, + ], + }) + try: client.chat_postMessage( channel=config.slack.admin_user_id, diff --git a/scripts/onboarding/handlers/onboard.py b/scripts/onboarding/handlers/onboard.py index 831dc37..28e4e9a 100644 --- a/scripts/onboarding/handlers/onboard.py +++ b/scripts/onboarding/handlers/onboard.py @@ -12,6 +12,7 @@ import logging import os import tempfile +from datetime import datetime from pathlib import Path from typing import Optional @@ -24,28 +25,10 @@ from ..services.github_service import GitHubService from ..services.image_service import ImageService from ..services.bio_service import BioService +from ..storage import get_request, save_request, delete_request logger = logging.getLogger(__name__) -# In-memory storage for active onboarding requests -# In production, this should be persisted to a database -_active_requests: dict[str, OnboardingRequest] = {} - - -def get_request(user_id: str) -> Optional[OnboardingRequest]: - """Get an active onboarding request for a user.""" - return _active_requests.get(user_id) - - -def save_request(request: OnboardingRequest): - """Save an onboarding request.""" - _active_requests[request.slack_user_id] = request - - -def delete_request(user_id: str): - """Delete an onboarding request.""" - _active_requests.pop(user_id, None) - def register_onboard_handlers(app: App, config: Config): """Register all onboarding-related handlers with the Slack app.""" @@ -147,6 +130,12 @@ def handle_onboarding_form(ack, body, client: WebClient, view): # Extract form values values = view["state"]["values"] + # Get role info + role = values.get("role_block", {}).get("role_select", {}).get("selected_option", {}).get("value", "") + grad_type = values.get("grad_type_block", {}).get("grad_type_select", {}).get("selected_option", {}) + grad_type = grad_type.get("value", "") if grad_type else "" + grad_field = values.get("grad_field_block", {}).get("grad_field_input", {}).get("value", "") + # Get GitHub username github_username = values.get("github_block", {}).get("github_input", {}).get("value", "") @@ -156,6 +145,65 @@ def handle_onboarding_form(ack, body, client: WebClient, view): # Get website URL (optional) website_url = values.get("website_block", {}).get("website_input", {}).get("value", "") + # Validate role-specific fields + if role == "Graduate Student" and not grad_type: + try: + client.chat_postMessage( + channel=request.slack_channel_id, + text="Please select your graduate program type (Doctoral or Masters).", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":warning: *Missing Information*\n\nAs a Graduate Student, please select whether you're in a Doctoral or Masters program.", + }, + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Update Form"}, + "action_id": "retry_github_username", + } + ], + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error sending validation error: {e}") + return + + if grad_type == "Masters" and not grad_field: + try: + client.chat_postMessage( + channel=request.slack_channel_id, + text="Please provide your Masters program field.", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":warning: *Missing Information*\n\nAs a Masters student, please provide your program/field (e.g., Quantitative Biomedical Sciences).", + }, + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Update Form"}, + "action_id": "retry_github_username", + } + ], + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error sending validation error: {e}") + return + # Validate GitHub username is_valid, error_msg = github_service.validate_username(github_username) @@ -193,6 +241,10 @@ def handle_onboarding_form(ack, body, client: WebClient, view): request.github_username = github_username request.bio_raw = bio_raw request.website_url = website_url + request.role = role + request.grad_type = grad_type + request.grad_field = grad_field + request.start_year = datetime.now().year request.update_status(OnboardingStatus.PENDING_APPROVAL) save_request(request) @@ -403,6 +455,51 @@ def _open_onboarding_form(client: WebClient, trigger_id: str, request: Onboardin "text": "Please provide the following information for your CDL profile.", }, }, + { + "type": "input", + "block_id": "role_block", + "element": { + "type": "static_select", + "action_id": "role_select", + "placeholder": {"type": "plain_text", "text": "Select your role"}, + "options": [ + {"text": {"type": "plain_text", "text": "Graduate Student"}, "value": "Graduate Student"}, + {"text": {"type": "plain_text", "text": "Undergraduate"}, "value": "Undergraduate"}, + {"text": {"type": "plain_text", "text": "Postdoctoral Researcher"}, "value": "Postdoctoral Researcher"}, + {"text": {"type": "plain_text", "text": "Lab Manager"}, "value": "Lab Manager"}, + {"text": {"type": "plain_text", "text": "Research Scientist"}, "value": "Research Scientist"}, + ], + }, + "label": {"type": "plain_text", "text": "Your Role in the Lab"}, + }, + { + "type": "input", + "block_id": "grad_type_block", + "optional": True, + "element": { + "type": "static_select", + "action_id": "grad_type_select", + "placeholder": {"type": "plain_text", "text": "Select program type"}, + "options": [ + {"text": {"type": "plain_text", "text": "Doctoral"}, "value": "Doctoral"}, + {"text": {"type": "plain_text", "text": "Masters"}, "value": "Masters"}, + ], + }, + "label": {"type": "plain_text", "text": "Graduate Program Type"}, + "hint": {"type": "plain_text", "text": "Required for Graduate Students only"}, + }, + { + "type": "input", + "block_id": "grad_field_block", + "optional": True, + "element": { + "type": "plain_text_input", + "action_id": "grad_field_input", + "placeholder": {"type": "plain_text", "text": "e.g., Quantitative Biomedical Sciences"}, + }, + "label": {"type": "plain_text", "text": "Masters Program/Field"}, + "hint": {"type": "plain_text", "text": "Required for Masters students only"}, + }, { "type": "input", "block_id": "github_block", @@ -479,6 +576,20 @@ def _open_onboarding_form(client: WebClient, trigger_id: str, request: Onboardin logger.error(f"Error opening modal: {e}") +def _format_role_display(request: OnboardingRequest) -> str: + """Format role for display, including grad type/field if applicable.""" + if not request.role: + return "Not provided" + + role_str = request.role + if request.role == "Graduate Student" and request.grad_type: + role_str = f"Graduate Student ({request.grad_type})" + if request.grad_type == "Masters" and request.grad_field: + role_str += f" - {request.grad_field}" + + return role_str + + def _send_approval_request( client: WebClient, config: Config, @@ -524,7 +635,8 @@ def _send_approval_request( "type": "mrkdwn", "text": f"*GitHub Username:* `{request.github_username}`\n" f"*Email:* {request.email or 'Not provided'}\n" - f"*Website:* {request.website_url or 'None'}", + f"*Website:* {request.website_url or 'None'}\n" + f"*Role:* {_format_role_display(request)}", }, }, ] diff --git a/scripts/onboarding/handlers/website_approval.py b/scripts/onboarding/handlers/website_approval.py new file mode 100644 index 0000000..3ac4206 --- /dev/null +++ b/scripts/onboarding/handlers/website_approval.py @@ -0,0 +1,809 @@ +""" +Website approval handlers for Slack. + +Handles the admin preview and approval flow for website changes: +- Onboarding: Preview member profile before PR creation +- Offboarding: Preview alumni transition before PR creation +""" + +import logging +import re +from datetime import datetime +from typing import Optional + +from slack_bolt import App +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from ..config import Config +from ..models.onboarding_request import OnboardingRequest, OnboardingStatus +from ..services.website_service import ( + WebsiteService, + WebsiteContent, + AlumniContent, + MemberRole, + GradType, + build_cv_entry, + build_cv_update_for_offboarding, +) +from ..storage import get_request, save_request + +logger = logging.getLogger(__name__) + +# Storage for pending website operations +_pending_website_ops: dict[str, dict] = {} + + +def register_website_approval_handlers(app: App, config: Config): + """Register website approval handlers.""" + + website_service = None + if config.github: + website_service = WebsiteService(config.github.token) + + # ========== Onboarding Website Handlers ========== + + @app.action("preview_website_changes") + def handle_preview(ack, body, client: WebClient, action): + """Show admin preview of website changes before PR creation.""" + ack() + + user_id = action["value"] + admin_id = body["user"]["id"] + + if admin_id != config.slack.admin_user_id: + return + + request = get_request(user_id) + if not request: + logger.error(f"No request found for user {user_id}") + return + + _open_website_preview_modal(client, body["trigger_id"], request) + + @app.view(re.compile(r"website_preview_modal_.*")) + def handle_preview_submission(ack, body, client: WebClient, view): + """Handle submission of the website preview modal.""" + ack() + + user_id = view["private_metadata"] + request = get_request(user_id) + if not request: + return + + values = view["state"]["values"] + + # Extract edited values + edited_name = values.get("name_block", {}).get("name_input", {}).get("value", request.name) + edited_role = values.get("role_block", {}).get("role_select", {}).get("selected_option", {}).get("value", request.role) + edited_grad_type = values.get("grad_type_block", {}).get("grad_type_select", {}).get("selected_option", {}).get("value", request.grad_type) if "grad_type_block" in values else request.grad_type + edited_grad_field = values.get("grad_field_block", {}).get("grad_field_input", {}).get("value", request.grad_field) if "grad_field_block" in values else request.grad_field + edited_bio = values.get("bio_block", {}).get("bio_input", {}).get("value", request.bio_edited or request.bio_raw) + edited_website = values.get("website_block", {}).get("website_input", {}).get("value", request.website_url) + + # Store edited content + _pending_website_ops[user_id] = { + "name": edited_name, + "role": edited_role, + "grad_type": edited_grad_type, + "grad_field": edited_grad_field, + "bio": edited_bio, + "website_url": edited_website, + "request": request, + } + + # Send confirmation message + _send_website_confirmation(client, config, user_id, edited_name, edited_role, + edited_grad_type, edited_grad_field, edited_bio, edited_website) + + @app.action("create_website_pr") + def handle_create_pr(ack, body, client: WebClient, action): + """Create the website PR after admin confirmation.""" + ack() + + user_id = action["value"] + admin_id = body["user"]["id"] + + if admin_id != config.slack.admin_user_id: + return + + pending = _pending_website_ops.get(user_id) + if not pending or not website_service: + client.chat_postMessage( + channel=config.slack.admin_user_id, + text=":x: Error: No pending website operation found or website service not configured.", + ) + return + + request = pending["request"] + + # Build website content + content = WebsiteContent( + name=pending["name"], + name_url=pending["website_url"] if pending["website_url"] else None, + role=pending["role"], + bio=pending["bio"], + image_filename=website_service.generate_image_filename(pending["name"]), + ) + + # Read image data if available + if request.photo_processed_path and request.photo_processed_path.exists(): + with open(request.photo_processed_path, "rb") as f: + content.image_data = f.read() + + # Build CV entry if applicable + cv_entry = None + cv_section = None + try: + role_enum = MemberRole(pending["role"]) + grad_type_enum = GradType(pending["grad_type"]) if pending["grad_type"] else None + year = request.start_year if request.start_year else datetime.now().year + + cv_entry, cv_section = build_cv_entry( + name=pending["name"], + role=role_enum, + grad_type=grad_type_enum, + grad_field=pending["grad_field"], + year=year, + ) + except ValueError: + logger.warning(f"Could not map role '{pending['role']}' to CV section") + + # Create the PR + success, result, branch = website_service.create_onboarding_pr( + content=content, + cv_entry=cv_entry, + cv_section=cv_section, + slack_user_id=user_id, + ) + + if success: + # Update message + try: + client.chat_update( + channel=body["channel"]["id"], + ts=body["message"]["ts"], + text=f":white_check_mark: Website PR created for {pending['name']}", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f":white_check_mark: *Website PR Created*\n\n" + f"<{result}|View Pull Request>\n\n" + f"The PR will add {pending['name']} to the lab website" + + (f" and CV" if cv_entry else "") + + ". Review and merge to publish.", + }, + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error updating message: {e}") + + # Update request + request.website_pr_url = result + request.website_branch = branch + request.update_status(OnboardingStatus.WEBSITE_PR_CREATED) + save_request(request) + + # Notify member + try: + client.chat_postMessage( + channel=request.slack_channel_id, + text=":globe_with_meridians: Your profile is being added to the lab website! " + "It will appear shortly after admin review.", + ) + except SlackApiError: + pass + else: + client.chat_postMessage( + channel=config.slack.admin_user_id, + text=f":x: Error creating website PR: {result}", + ) + + # Cleanup + _pending_website_ops.pop(user_id, None) + + @app.action("edit_website_content") + def handle_edit_content(ack, body, client: WebClient, action): + """Re-open the preview modal for editing.""" + ack() + + user_id = action["value"] + request = get_request(user_id) + if request: + _open_website_preview_modal(client, body["trigger_id"], request) + + @app.action("request_member_changes") + def handle_request_member_changes(ack, body, client: WebClient, action): + """Request the member to update their information.""" + ack() + + user_id = action["value"] + admin_id = body["user"]["id"] + + if admin_id != config.slack.admin_user_id: + return + + try: + client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": f"website_member_changes_modal_{user_id}", + "private_metadata": user_id, + "title": {"type": "plain_text", "text": "Request Changes"}, + "submit": {"type": "plain_text", "text": "Send to Member"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "blocks": [ + { + "type": "input", + "block_id": "changes_block", + "element": { + "type": "plain_text_input", + "action_id": "changes_input", + "multiline": True, + "placeholder": { + "type": "plain_text", + "text": "What changes should the member make? (e.g., need different photo, bio too long, etc.)", + }, + }, + "label": {"type": "plain_text", "text": "Changes Needed"}, + }, + ], + }, + ) + except SlackApiError as e: + logger.error(f"Error opening changes modal: {e}") + + @app.view(re.compile(r"website_member_changes_modal_.*")) + def handle_member_changes_submission(ack, body, client: WebClient, view): + """Send change request to member.""" + ack() + + user_id = view["private_metadata"] + request = get_request(user_id) + if not request: + return + + changes_text = view["state"]["values"]["changes_block"]["changes_input"]["value"] + + try: + client.chat_postMessage( + channel=request.slack_channel_id, + text="The admin has requested changes to your website profile.", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":memo: *Changes Requested for Website Profile*", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f">{changes_text}", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Please reply with the updated information or upload a new photo.", + }, + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error sending change request: {e}") + + request.update_status(OnboardingStatus.PENDING_INFO) + save_request(request) + + # ========== Offboarding Website Handlers ========== + + @app.action("collect_alumni_info") + def handle_collect_alumni_info(ack, body, client: WebClient, action): + """Initiate alumni info collection from admin.""" + ack() + + user_id = action["value"] + + try: + client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": f"initiate_alumni_collection_{user_id}", + "private_metadata": user_id, + "title": {"type": "plain_text", "text": "Alumni Info"}, + "submit": {"type": "plain_text", "text": "Send to Member"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"This will send a form to <@{user_id}> to collect their alumni information.", + }, + }, + { + "type": "input", + "block_id": "years_block", + "element": { + "type": "plain_text_input", + "action_id": "years_input", + "placeholder": {"type": "plain_text", "text": "e.g., 2020-2024"}, + }, + "label": {"type": "plain_text", "text": "Years Active"}, + }, + { + "type": "input", + "block_id": "alumni_sheet_block", + "element": { + "type": "static_select", + "action_id": "alumni_sheet_select", + "options": [ + {"text": {"type": "plain_text", "text": "Graduate Alumni"}, "value": "alumni_grads"}, + {"text": {"type": "plain_text", "text": "Undergraduate Alumni"}, "value": "alumni_undergrads"}, + {"text": {"type": "plain_text", "text": "Postdoc Alumni"}, "value": "alumni_postdocs"}, + {"text": {"type": "plain_text", "text": "Lab Manager Alumni"}, "value": "alumni_managers"}, + ], + }, + "label": {"type": "plain_text", "text": "Alumni Category"}, + }, + ], + }, + ) + except SlackApiError as e: + logger.error(f"Error opening alumni collection modal: {e}") + + @app.view(re.compile(r"initiate_alumni_collection_.*")) + def handle_alumni_collection_initiation(ack, body, client: WebClient, view): + """Send alumni info request to departing member.""" + ack() + + user_id = view["private_metadata"] + values = view["state"]["values"] + + years = values.get("years_block", {}).get("years_input", {}).get("value", "") + alumni_sheet = values.get("alumni_sheet_block", {}).get("alumni_sheet_select", {}).get("selected_option", {}).get("value", "alumni_grads") + + _pending_website_ops[f"offboard_{user_id}"] = { + "years": years, + "alumni_sheet": alumni_sheet, + } + + try: + dm_response = client.conversations_open(users=[user_id]) + dm_channel = dm_response["channel"]["id"] + + client.chat_postMessage( + channel=dm_channel, + text="Please provide your alumni information.", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":wave: *Alumni Information Request*\n\n" + "As you transition from the lab, we'd like to update the website with your alumni information.", + }, + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Provide Alumni Info"}, + "style": "primary", + "action_id": "open_alumni_form", + "value": user_id, + }, + ], + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error sending alumni form request: {e}") + + @app.action("open_alumni_form") + def handle_open_alumni_form(ack, body, client: WebClient, action): + """Open the alumni information form.""" + ack() + + user_id = body["user"]["id"] + + try: + client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": f"alumni_form_{user_id}", + "private_metadata": user_id, + "title": {"type": "plain_text", "text": "Alumni Information"}, + "submit": {"type": "plain_text", "text": "Submit"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "blocks": [ + { + "type": "input", + "block_id": "position_block", + "element": { + "type": "plain_text_input", + "action_id": "position_input", + "placeholder": {"type": "plain_text", "text": "e.g., Postdoc at MIT"}, + }, + "label": {"type": "plain_text", "text": "Current Position"}, + }, + { + "type": "input", + "block_id": "position_url_block", + "optional": True, + "element": { + "type": "plain_text_input", + "action_id": "position_url_input", + "placeholder": {"type": "plain_text", "text": "https://..."}, + }, + "label": {"type": "plain_text", "text": "Position URL (optional)"}, + }, + ], + }, + ) + except SlackApiError as e: + logger.error(f"Error opening alumni form: {e}") + + @app.view(re.compile(r"alumni_form_.*")) + def handle_alumni_form_submission(ack, body, client: WebClient, view): + """Handle alumni form submission.""" + ack() + + user_id = view["private_metadata"] + values = view["state"]["values"] + + position = values.get("position_block", {}).get("position_input", {}).get("value", "") + position_url = values.get("position_url_block", {}).get("position_url_input", {}).get("value", "") + + pending = _pending_website_ops.get(f"offboard_{user_id}", {}) + + try: + user_info = client.users_info(user=user_id) + name = user_info["user"]["real_name"] or user_info["user"]["name"] + except SlackApiError: + name = "Unknown" + + _pending_website_ops[f"offboard_{user_id}"] = { + **pending, + "name": name, + "current_position": position, + "current_position_url": position_url, + } + + _send_alumni_preview(client, config, user_id, name, pending.get("years", ""), + position, position_url, pending.get("alumni_sheet", "alumni_grads")) + + @app.action("create_offboarding_pr") + def handle_create_offboarding_pr(ack, body, client: WebClient, action): + """Create the offboarding PR.""" + ack() + + user_id = action["value"] + admin_id = body["user"]["id"] + + if admin_id != config.slack.admin_user_id: + return + + pending = _pending_website_ops.get(f"offboard_{user_id}") + if not pending or not website_service: + client.chat_postMessage( + channel=config.slack.admin_user_id, + text=":x: Error: No pending offboarding operation found.", + ) + return + + alumni_content = AlumniContent( + name=pending["name"], + years=pending.get("years", ""), + current_position=pending.get("current_position", ""), + current_position_url=pending.get("current_position_url"), + ) + + # Build CV update if we have role info + cv_update = None + # Note: CV update would require knowing the member's original role and start year + # This would need to be stored or looked up + + success, result, branch = website_service.create_offboarding_pr( + member_name=pending["name"], + alumni_content=alumni_content, + alumni_sheet=pending.get("alumni_sheet", "alumni_grads"), + cv_update=cv_update, + slack_user_id=user_id, + ) + + if success: + try: + client.chat_update( + channel=body["channel"]["id"], + ts=body["message"]["ts"], + text=f":white_check_mark: Offboarding PR created for {pending['name']}", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f":white_check_mark: *Website Offboarding PR Created*\n\n" + f"<{result}|View Pull Request>\n\n" + f"This PR moves {pending['name']} to alumni. Review and merge to publish.", + }, + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error updating message: {e}") + else: + client.chat_postMessage( + channel=config.slack.admin_user_id, + text=f":x: Error creating offboarding PR: {result}", + ) + + _pending_website_ops.pop(f"offboard_{user_id}", None) + + +def _open_website_preview_modal(client: WebClient, trigger_id: str, request: OnboardingRequest): + """Open modal showing preview of website content.""" + + role_options = [ + {"text": {"type": "plain_text", "text": role.value}, "value": role.value} + for role in MemberRole + ] + + # Find initial role option + initial_role = None + for opt in role_options: + if opt["value"] == request.role: + initial_role = opt + break + + blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":globe_with_meridians: *Preview Website Profile*\n\n" + "Review and edit the content below before creating the PR.", + }, + }, + { + "type": "input", + "block_id": "name_block", + "element": { + "type": "plain_text_input", + "action_id": "name_input", + "initial_value": request.name, + }, + "label": {"type": "plain_text", "text": "Name (as displayed on website)"}, + }, + { + "type": "input", + "block_id": "role_block", + "element": { + "type": "static_select", + "action_id": "role_select", + "options": role_options, + **({"initial_option": initial_role} if initial_role else {}), + }, + "label": {"type": "plain_text", "text": "Role"}, + }, + ] + + # Add grad type if role is Graduate Student + if request.role == MemberRole.GRAD_STUDENT.value: + grad_type_options = [ + {"text": {"type": "plain_text", "text": "Doctoral"}, "value": "Doctoral"}, + {"text": {"type": "plain_text", "text": "Masters"}, "value": "Masters"}, + ] + initial_grad = None + for opt in grad_type_options: + if opt["value"] == request.grad_type: + initial_grad = opt + break + + blocks.append({ + "type": "input", + "block_id": "grad_type_block", + "element": { + "type": "static_select", + "action_id": "grad_type_select", + "options": grad_type_options, + **({"initial_option": initial_grad} if initial_grad else {}), + }, + "label": {"type": "plain_text", "text": "Graduate Type"}, + }) + + if request.grad_type == "Masters": + blocks.append({ + "type": "input", + "block_id": "grad_field_block", + "optional": True, + "element": { + "type": "plain_text_input", + "action_id": "grad_field_input", + "initial_value": request.grad_field or "", + "placeholder": {"type": "plain_text", "text": "e.g., Quantitative Biomedical Sciences"}, + }, + "label": {"type": "plain_text", "text": "Field of Study"}, + }) + + blocks.extend([ + { + "type": "input", + "block_id": "bio_block", + "element": { + "type": "plain_text_input", + "action_id": "bio_input", + "multiline": True, + "initial_value": request.bio_edited or request.bio_raw, + }, + "label": {"type": "plain_text", "text": "Bio"}, + }, + { + "type": "input", + "block_id": "website_block", + "optional": True, + "element": { + "type": "plain_text_input", + "action_id": "website_input", + "initial_value": request.website_url or "", + }, + "label": {"type": "plain_text", "text": "Personal Website URL"}, + }, + ]) + + # Photo status + if request.photo_processed_path and request.photo_processed_path.exists(): + blocks.append({ + "type": "context", + "elements": [{"type": "mrkdwn", "text": f":camera: Photo ready: `{request.photo_processed_path.name}`"}], + }) + else: + blocks.append({ + "type": "context", + "elements": [{"type": "mrkdwn", "text": ":warning: No photo processed yet"}], + }) + + try: + client.views_open( + trigger_id=trigger_id, + view={ + "type": "modal", + "callback_id": f"website_preview_modal_{request.slack_user_id}", + "private_metadata": request.slack_user_id, + "title": {"type": "plain_text", "text": "Website Preview"}, + "submit": {"type": "plain_text", "text": "Continue"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "blocks": blocks, + }, + ) + except SlackApiError as e: + logger.error(f"Error opening preview modal: {e}") + + +def _send_website_confirmation( + client: WebClient, + config: Config, + user_id: str, + name: str, + role: str, + grad_type: str, + grad_field: str, + bio: str, + website: str, +): + """Send confirmation message with Create PR button.""" + + role_display = role + if grad_type: + role_display += f" ({grad_type})" + if grad_field: + role_display += f" - {grad_field}" + + try: + client.chat_postMessage( + channel=config.slack.admin_user_id, + text=f"Ready to create website PR for {name}", + blocks=[ + { + "type": "header", + "text": {"type": "plain_text", "text": ":globe_with_meridians: Website PR Ready"}, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{name}* ({role_display})\n\n" + f"*Bio:*\n>{bio}\n\n" + f"*Website:* {website or 'None'}", + }, + }, + {"type": "divider"}, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Create PR"}, + "style": "primary", + "action_id": "create_website_pr", + "value": user_id, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Edit Content"}, + "action_id": "edit_website_content", + "value": user_id, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Request Member Changes"}, + "action_id": "request_member_changes", + "value": user_id, + }, + ], + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error sending confirmation: {e}") + + +def _send_alumni_preview( + client: WebClient, + config: Config, + user_id: str, + name: str, + years: str, + position: str, + position_url: str, + alumni_sheet: str, +): + """Send alumni preview to admin for confirmation.""" + try: + client.chat_postMessage( + channel=config.slack.admin_user_id, + text=f"Alumni transition ready for {name}", + blocks=[ + { + "type": "header", + "text": {"type": "plain_text", "text": ":wave: Alumni Transition Ready"}, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{name}*\n\n" + f"*Years:* {years}\n" + f"*Current Position:* {position}\n" + f"*Position URL:* {position_url or 'None'}\n" + f"*Alumni Sheet:* {alumni_sheet}", + }, + }, + {"type": "divider"}, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Create Offboarding PR"}, + "style": "primary", + "action_id": "create_offboarding_pr", + "value": user_id, + }, + ], + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error sending alumni preview: {e}") diff --git a/scripts/onboarding/handlers/workflow_listener.py b/scripts/onboarding/handlers/workflow_listener.py index 26b49f9..bfa569b 100644 --- a/scripts/onboarding/handlers/workflow_listener.py +++ b/scripts/onboarding/handlers/workflow_listener.py @@ -24,10 +24,22 @@ from ..models.onboarding_request import OnboardingRequest, OnboardingStatus from ..services.github_service import GitHubService from ..services.bio_service import BioService -from .onboard import get_request, save_request, delete_request +from ..storage import get_request, save_request, delete_request +from ..startup_queue import StartupQueueProcessor logger = logging.getLogger(__name__) +# Global processor instance for marking messages as processed +_startup_processor: Optional[StartupQueueProcessor] = None + + +def _get_startup_processor(client, config) -> StartupQueueProcessor: + """Get or create the startup processor for tracking processed messages.""" + global _startup_processor + if _startup_processor is None: + _startup_processor = StartupQueueProcessor(client, config) + return _startup_processor + # Temporary storage for partial onboarding data (keyed by Slack user ID) # This holds the first form submission until we receive the second _partial_requests: dict[str, dict] = {} @@ -116,6 +128,10 @@ def handle_workflow_message(event, client: WebClient, say): has_github = "github_username" in parsed_data has_bio = "bio" in parsed_data or "name" in parsed_data + # Get the startup processor to mark messages as processed + processor = _get_startup_processor(client, config) + msg_ts = event.get("ts", "") + if has_github and not has_bio: # This is the first form (Step 4) - GitHub and email logger.info(f"Received first workflow form for {submitter_id}") @@ -126,6 +142,9 @@ def handle_workflow_message(event, client: WebClient, say): partial["submitter_id"] = submitter_id save_partial_request(submitter_id, partial) + # Mark as processed + processor.mark_processed(msg_ts) + # Acknowledge receipt but wait for second form try: client.chat_postMessage( @@ -151,7 +170,8 @@ def handle_workflow_message(event, client: WebClient, say): github_service, bio_service, channel ) - # Clean up partial data + # Mark as processed and clean up partial data + processor.mark_processed(msg_ts) delete_partial_request(submitter_id) else: @@ -161,6 +181,7 @@ def handle_workflow_message(event, client: WebClient, say): partial.update(parsed_data) partial["submitter_id"] = submitter_id save_partial_request(submitter_id, partial) + processor.mark_processed(msg_ts) def _parse_workflow_message(text: str) -> dict: diff --git a/scripts/onboarding/handlers/workflow_step.py b/scripts/onboarding/handlers/workflow_step.py index f0e04c5..e4f14dd 100644 --- a/scripts/onboarding/handlers/workflow_step.py +++ b/scripts/onboarding/handlers/workflow_step.py @@ -24,7 +24,7 @@ from ..services.github_service import GitHubService from ..services.image_service import ImageService from ..services.bio_service import BioService -from .onboard import get_request, save_request, delete_request +from ..storage import get_request, save_request, delete_request logger = logging.getLogger(__name__) diff --git a/scripts/onboarding/models/onboarding_request.py b/scripts/onboarding/models/onboarding_request.py index 5bb49d1..4363d6d 100644 --- a/scripts/onboarding/models/onboarding_request.py +++ b/scripts/onboarding/models/onboarding_request.py @@ -18,6 +18,8 @@ class OnboardingStatus(Enum): PHOTO_PENDING = "photo_pending" # Waiting for photo upload PROCESSING = "processing" # Processing bio/photo READY_FOR_WEBSITE = "ready_for_website" # Ready for website update + WEBSITE_PENDING = "website_pending" # Waiting for website PR approval + WEBSITE_PR_CREATED = "website_pr_created" # Website PR has been created COMPLETED = "completed" REJECTED = "rejected" ERROR = "error" @@ -47,11 +49,21 @@ class OnboardingRequest: calendar_permissions: dict = field(default_factory=dict) calendar_invites_sent: bool = False + # Role and position info + role: str = "" # Graduate Student, Undergraduate, etc. + grad_type: str = "" # "Doctoral" or "Masters" (for grad students) + grad_field: str = "" # Field for Masters students (e.g., "Quantitative Biomedical Sciences") + start_year: int = 0 # Year joined the lab (for CV entry) + # Website info bio_raw: str = "" bio_edited: str = "" website_url: str = "" + # Website PR tracking + website_pr_url: str = "" + website_branch: str = "" + # Photo photo_original_path: Optional[Path] = None photo_processed_path: Optional[Path] = None @@ -85,9 +97,15 @@ def to_dict(self) -> dict: "github_invitation_sent": self.github_invitation_sent, "calendar_permissions": self.calendar_permissions, "calendar_invites_sent": self.calendar_invites_sent, + "role": self.role, + "grad_type": self.grad_type, + "grad_field": self.grad_field, + "start_year": self.start_year, "bio_raw": self.bio_raw, "bio_edited": self.bio_edited, "website_url": self.website_url, + "website_pr_url": self.website_pr_url, + "website_branch": self.website_branch, "photo_original_path": str(self.photo_original_path) if self.photo_original_path else None, "photo_processed_path": str(self.photo_processed_path) if self.photo_processed_path else None, "status": self.status.value, @@ -111,9 +129,15 @@ def from_dict(cls, data: dict) -> "OnboardingRequest": github_invitation_sent=data.get("github_invitation_sent", False), calendar_permissions=data.get("calendar_permissions", {}), calendar_invites_sent=data.get("calendar_invites_sent", False), + role=data.get("role", ""), + grad_type=data.get("grad_type", ""), + grad_field=data.get("grad_field", ""), + start_year=data.get("start_year", 0), bio_raw=data.get("bio_raw", ""), bio_edited=data.get("bio_edited", ""), website_url=data.get("website_url", ""), + website_pr_url=data.get("website_pr_url", ""), + website_branch=data.get("website_branch", ""), photo_original_path=Path(data["photo_original_path"]) if data.get("photo_original_path") else None, photo_processed_path=Path(data["photo_processed_path"]) if data.get("photo_processed_path") else None, status=OnboardingStatus(data.get("status", "pending_info")), @@ -133,6 +157,14 @@ def get_summary(self) -> str: f"*Status:* {self.status.value}", ] + if self.role: + role_str = self.role + if self.grad_type: + role_str += f" ({self.grad_type})" + if self.grad_field: + role_str += f" - {self.grad_field}" + lines.append(f"*Role:* {role_str}") + if self.github_teams: lines.append(f"*GitHub Teams:* {', '.join(self.github_teams)}") diff --git a/scripts/onboarding/services/website_service.py b/scripts/onboarding/services/website_service.py new file mode 100644 index 0000000..82a5dce --- /dev/null +++ b/scripts/onboarding/services/website_service.py @@ -0,0 +1,656 @@ +""" +Website service for managing lab website PRs. + +Handles: +- Reading/writing people.xlsx (members and alumni sheets) +- Uploading photos to images/people/ +- Creating branches and PRs for website changes +""" + +import base64 +import io +import logging +import re +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Optional, Tuple + +import openpyxl +from github import Github, GithubException +from github.Repository import Repository + +logger = logging.getLogger(__name__) + + +class MemberRole(Enum): + """Valid roles for lab members.""" + GRAD_STUDENT = "Graduate Student" + UNDERGRAD = "Undergraduate" + POSTDOC = "Postdoctoral Researcher" + LAB_MANAGER = "Lab Manager" + RESEARCH_SCIENTIST = "Research Scientist" + + +class GradType(Enum): + """Graduate student types.""" + DOCTORAL = "Doctoral" + MASTERS = "Masters" + + +@dataclass +class WebsiteContent: + """Content to be added/updated on the website.""" + name: str + name_url: Optional[str] = None + role: str = "" + bio: str = "" + links_html: str = "" + image_filename: str = "" + image_data: Optional[bytes] = None + + +@dataclass +class AlumniContent: + """Content for alumni entry.""" + name: str + name_url: Optional[str] = None + years: str = "" + current_position: str = "" + current_position_url: Optional[str] = None + + +class WebsiteService: + """Service for managing contextlab.github.io website content.""" + + WEBSITE_REPO = "ContextLab/contextlab.github.io" + PEOPLE_FILE = "data/people.xlsx" + IMAGES_PATH = "images/people" + CV_FILE = "documents/JRM_CV.tex" + MEMBERS_SHEET = "members" + + # Column indices in members sheet (0-indexed) + MEMBER_COLS = { + "image": 0, + "name": 1, + "name_url": 2, + "role": 3, + "bio": 4, + "links_html": 5, + } + + # Column indices in alumni sheets + ALUMNI_COLS = { + "name": 0, + "name_url": 1, + "years": 2, + "current_position": 3, + "current_position_url": 4, + } + + # Map roles to alumni sheets + ALUMNI_SHEET_MAP = { + MemberRole.GRAD_STUDENT: "alumni_grads", + MemberRole.UNDERGRAD: "alumni_undergrads", + MemberRole.POSTDOC: "alumni_postdocs", + MemberRole.LAB_MANAGER: "alumni_managers", + MemberRole.RESEARCH_SCIENTIST: "alumni_managers", + } + + def __init__(self, token: str): + """Initialize website service with GitHub token.""" + self.github = Github(token) + self._repo: Optional[Repository] = None + + @property + def repo(self) -> Repository: + """Get the website repository (lazy loaded).""" + if self._repo is None: + self._repo = self.github.get_repo(self.WEBSITE_REPO) + return self._repo + + def get_people_xlsx(self, ref: str = "main") -> openpyxl.Workbook: + """Download and parse people.xlsx from the repository.""" + contents = self.repo.get_contents(self.PEOPLE_FILE, ref=ref) + xlsx_data = base64.b64decode(contents.content) + return openpyxl.load_workbook(io.BytesIO(xlsx_data)) + + def get_current_members(self) -> list[dict]: + """Get list of current members from the spreadsheet.""" + wb = self.get_people_xlsx() + ws = wb[self.MEMBERS_SHEET] + members = [] + + for row in ws.iter_rows(min_row=2, values_only=True): + name = row[self.MEMBER_COLS["name"]] + if name: + members.append({ + "image": row[self.MEMBER_COLS["image"]] or "", + "name": name, + "name_url": row[self.MEMBER_COLS["name_url"]] or "", + "role": row[self.MEMBER_COLS["role"]] or "", + "bio": row[self.MEMBER_COLS["bio"]] or "", + "links_html": row[self.MEMBER_COLS["links_html"]] or "", + }) + return members + + def get_alumni_sheets(self) -> list[str]: + """Get list of alumni sheet names.""" + wb = self.get_people_xlsx() + return [name for name in wb.sheetnames if name.startswith("alumni_")] + + def find_member_by_name(self, name: str) -> Optional[Tuple[int, dict]]: + """Find a member by name and return (row_index, data).""" + wb = self.get_people_xlsx() + ws = wb[self.MEMBERS_SHEET] + + for idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): + member_name = row[self.MEMBER_COLS["name"]] + if member_name and member_name.lower() == name.lower(): + return idx, { + "image": row[self.MEMBER_COLS["image"]] or "", + "name": member_name, + "name_url": row[self.MEMBER_COLS["name_url"]] or "", + "role": row[self.MEMBER_COLS["role"]] or "", + "bio": row[self.MEMBER_COLS["bio"]] or "", + "links_html": row[self.MEMBER_COLS["links_html"]] or "", + } + return None + + def generate_image_filename(self, name: str) -> str: + """Generate image filename from member name.""" + # Convert "First Last" to "first_last.png" + # Handle multiple spaces and special characters + clean_name = re.sub(r"[^\w\s]", "", name) + parts = clean_name.lower().split() + return "_".join(parts) + ".png" + + def create_onboarding_pr( + self, + content: WebsiteContent, + cv_entry: Optional[str] = None, + cv_section: Optional[str] = None, + slack_user_id: str = "", + ) -> Tuple[bool, Optional[str], Optional[str]]: + """ + Create a PR to add a new member to the website. + + Args: + content: Website content for the new member + cv_entry: LaTeX entry to add to CV (e.g., "\\item Name (Doctoral student; 2025 -- )") + cv_section: CV section to add to ("Postdoctoral Advisees", "Graduate Advisees", "Undergraduate Advisees") + slack_user_id: Slack user ID for branch naming + + Returns: + Tuple of (success, pr_url or error_message, branch_name) + """ + branch_name = f"onboarding/{slack_user_id}-{content.name.replace(' ', '-').lower()}" + + try: + # Get default branch ref + main_ref = self.repo.get_git_ref("heads/main") + main_sha = main_ref.object.sha + + # Create new branch + try: + self.repo.create_git_ref(f"refs/heads/{branch_name}", main_sha) + except GithubException as e: + if e.status == 422: # Branch already exists + self.repo.get_git_ref(f"heads/{branch_name}").delete() + self.repo.create_git_ref(f"refs/heads/{branch_name}", main_sha) + else: + raise + + # 1. Upload image if provided + if content.image_data and content.image_filename: + image_path = f"{self.IMAGES_PATH}/{content.image_filename}" + try: + # Check if file exists + existing = self.repo.get_contents(image_path, ref=branch_name) + self.repo.update_file( + path=image_path, + message=f"Update photo for {content.name}", + content=content.image_data, + sha=existing.sha, + branch=branch_name, + ) + except GithubException: + # File doesn't exist, create it + self.repo.create_file( + path=image_path, + message=f"Add photo for {content.name}", + content=content.image_data, + branch=branch_name, + ) + + # 2. Update people.xlsx + wb = self.get_people_xlsx(ref=branch_name) + ws = wb[self.MEMBERS_SHEET] + + # Check if member already exists (idempotency) + existing_row = None + for idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): + member_name = row[self.MEMBER_COLS["name"]] + if member_name and member_name.lower() == content.name.lower(): + existing_row = idx + logger.info(f"Member {content.name} already exists at row {idx}, updating") + break + + if existing_row: + # Update existing row + target_row = existing_row + else: + # Add new row + target_row = ws.max_row + 1 + + ws.cell(row=target_row, column=1, value=content.image_filename) + ws.cell(row=target_row, column=2, value=content.name) + ws.cell(row=target_row, column=3, value=content.name_url or None) + ws.cell(row=target_row, column=4, value=content.role) + ws.cell(row=target_row, column=5, value=content.bio) + ws.cell(row=target_row, column=6, value=content.links_html or None) + + # Save to bytes + xlsx_bytes = io.BytesIO() + wb.save(xlsx_bytes) + xlsx_content = xlsx_bytes.getvalue() + + # Get current file SHA and update + people_file = self.repo.get_contents(self.PEOPLE_FILE, ref=branch_name) + self.repo.update_file( + path=self.PEOPLE_FILE, + message=f"Add {content.name} to members", + content=xlsx_content, + sha=people_file.sha, + branch=branch_name, + ) + + # 3. Update CV if entry provided + if cv_entry and cv_section: + self._add_cv_entry(branch_name, cv_entry, cv_section, content.name) + + # 4. Create PR + pr_body = self._build_onboarding_pr_body(content, cv_entry) + pr = self.repo.create_pull( + title=f"Add {content.name} to lab members", + body=pr_body, + head=branch_name, + base="main", + ) + + logger.info(f"Created website PR: {pr.html_url}") + return True, pr.html_url, branch_name + + except GithubException as e: + error_msg = f"Error creating website PR: {e}" + logger.error(error_msg) + # Cleanup branch on failure + try: + self.repo.get_git_ref(f"heads/{branch_name}").delete() + except Exception: + pass + return False, error_msg, None + + def create_offboarding_pr( + self, + member_name: str, + alumni_content: AlumniContent, + alumni_sheet: str, + cv_update: Optional[Tuple[str, str]] = None, + slack_user_id: str = "", + ) -> Tuple[bool, Optional[str], Optional[str]]: + """ + Create a PR to move a member to alumni. + + Args: + member_name: Current name in members sheet + alumni_content: Content for alumni entry + alumni_sheet: Target alumni sheet (e.g., "alumni_grads") + cv_update: Tuple of (old_entry_pattern, new_entry) for CV update + slack_user_id: Slack user ID for branch naming + + Returns: + Tuple of (success, pr_url or error_message, branch_name) + """ + branch_name = f"offboarding/{slack_user_id}-{member_name.replace(' ', '-').lower()}" + + try: + # Get default branch ref + main_ref = self.repo.get_git_ref("heads/main") + main_sha = main_ref.object.sha + + # Create new branch + try: + self.repo.create_git_ref(f"refs/heads/{branch_name}", main_sha) + except GithubException as e: + if e.status == 422: + self.repo.get_git_ref(f"heads/{branch_name}").delete() + self.repo.create_git_ref(f"refs/heads/{branch_name}", main_sha) + else: + raise + + # Update people.xlsx + wb = self.get_people_xlsx(ref=branch_name) + members_ws = wb[self.MEMBERS_SHEET] + + # Find and remove from members + member_row = None + for idx, row in enumerate(members_ws.iter_rows(min_row=2, values_only=True), start=2): + name = row[self.MEMBER_COLS["name"]] + if name and name.lower() == member_name.lower(): + member_row = idx + break + + if member_row: + members_ws.delete_rows(member_row) + + # Add to alumni sheet + if alumni_sheet not in wb.sheetnames: + logger.warning(f"Alumni sheet {alumni_sheet} not found, creating it") + wb.create_sheet(alumni_sheet) + + alumni_ws = wb[alumni_sheet] + + # Check if alumni entry already exists (idempotency) + existing_alumni_row = None + for idx, row in enumerate(alumni_ws.iter_rows(min_row=2, values_only=True), start=2): + alumni_name = row[0] # Name is always first column + if alumni_name and alumni_name.lower() == alumni_content.name.lower(): + existing_alumni_row = idx + logger.info(f"Alumni {alumni_content.name} already exists at row {idx}, updating") + break + + if existing_alumni_row: + target_row = existing_alumni_row + else: + target_row = alumni_ws.max_row + 1 + + # Alumni sheets have different columns for undergrads + if alumni_sheet == "alumni_undergrads": + # Undergrads only have: name, years + alumni_ws.cell(row=target_row, column=1, value=alumni_content.name) + alumni_ws.cell(row=target_row, column=2, value=alumni_content.years) + else: + alumni_ws.cell(row=target_row, column=1, value=alumni_content.name) + alumni_ws.cell(row=target_row, column=2, value=alumni_content.name_url or None) + alumni_ws.cell(row=target_row, column=3, value=alumni_content.years) + alumni_ws.cell(row=target_row, column=4, value=alumni_content.current_position) + alumni_ws.cell(row=target_row, column=5, value=alumni_content.current_position_url or None) + + # Save to bytes + xlsx_bytes = io.BytesIO() + wb.save(xlsx_bytes) + xlsx_content = xlsx_bytes.getvalue() + + # Update file + people_file = self.repo.get_contents(self.PEOPLE_FILE, ref=branch_name) + self.repo.update_file( + path=self.PEOPLE_FILE, + message=f"Move {member_name} to alumni", + content=xlsx_content, + sha=people_file.sha, + branch=branch_name, + ) + + # Update CV if provided + if cv_update: + old_pattern, new_entry = cv_update + self._update_cv_entry(branch_name, old_pattern, new_entry, member_name) + + # Create PR + pr_body = self._build_offboarding_pr_body(member_name, alumni_content, cv_update) + pr = self.repo.create_pull( + title=f"Move {member_name} to alumni ({alumni_sheet})", + body=pr_body, + head=branch_name, + base="main", + ) + + logger.info(f"Created offboarding PR: {pr.html_url}") + return True, pr.html_url, branch_name + + except GithubException as e: + error_msg = f"Error creating offboarding PR: {e}" + logger.error(error_msg) + try: + self.repo.get_git_ref(f"heads/{branch_name}").delete() + except Exception: + pass + return False, error_msg, None + + def _cv_entry_exists(self, cv_content: str, name: str, section: str) -> bool: + """Check if a CV entry already exists for this person in the section.""" + # Look for \item Name in the section + section_marker = f"\\textit{{{section}}}:" + if section_marker not in cv_content: + return False + + section_idx = cv_content.index(section_marker) + + # Find the end of this section (next \textit or \end{enumerate}) + next_section = cv_content.find("\\textit{", section_idx + 1) + end_enumerate = cv_content.find("\\end{etaremune}", section_idx) + + if next_section == -1: + section_end = end_enumerate + elif end_enumerate == -1: + section_end = next_section + else: + section_end = min(next_section, end_enumerate) + + section_content = cv_content[section_idx:section_end] + + # Check for \item Name (case-insensitive, handles variations like middle names) + # Match pattern: \item FirstName ... LastName + name_parts = name.split() + if len(name_parts) >= 2: + first_name = name_parts[0] + last_name = name_parts[-1] + pattern = rf"\\item\s+{re.escape(first_name)}.*{re.escape(last_name)}" + if re.search(pattern, section_content, re.IGNORECASE): + return True + + return False + + def _add_cv_entry(self, branch: str, entry: str, section: str, name: str): + """Add an entry to the CV LaTeX file.""" + try: + cv_file = self.repo.get_contents(self.CV_FILE, ref=branch) + cv_content = base64.b64decode(cv_file.content).decode("utf-8") + + # Check if entry already exists + if self._cv_entry_exists(cv_content, name, section): + logger.info(f"CV entry for {name} already exists in {section}, skipping") + return + + # Find the section and add entry at top of list + section_marker = f"\\textit{{{section}}}:" + if section_marker not in cv_content: + logger.warning(f"CV section '{section}' not found") + return + + # Find \begin{etaremune} after the section marker + section_idx = cv_content.index(section_marker) + begin_idx = cv_content.index("\\begin{etaremune}", section_idx) + insert_idx = cv_content.index("\n", begin_idx) + 1 + + # Insert the new entry + new_content = cv_content[:insert_idx] + entry + "\n" + cv_content[insert_idx:] + + self.repo.update_file( + path=self.CV_FILE, + message=f"Add {name} to CV mentorship section", + content=new_content.encode("utf-8"), + sha=cv_file.sha, + branch=branch, + ) + except Exception as e: + logger.error(f"Error adding CV entry: {e}") + + def _update_cv_entry(self, branch: str, old_pattern: str, new_entry: str, name: str): + """Update an existing entry in the CV LaTeX file.""" + try: + cv_file = self.repo.get_contents(self.CV_FILE, ref=branch) + cv_content = base64.b64decode(cv_file.content).decode("utf-8") + + # Replace the old entry with the new one + if old_pattern in cv_content: + new_content = cv_content.replace(old_pattern, new_entry) + self.repo.update_file( + path=self.CV_FILE, + message=f"Update {name} CV entry for alumni transition", + content=new_content.encode("utf-8"), + sha=cv_file.sha, + branch=branch, + ) + else: + logger.warning(f"CV entry pattern not found: {old_pattern}") + except Exception as e: + logger.error(f"Error updating CV entry: {e}") + + def delete_branch(self, branch_name: str) -> bool: + """Delete a branch (for cleanup after PR merge/close).""" + try: + ref = self.repo.get_git_ref(f"heads/{branch_name}") + ref.delete() + return True + except GithubException: + return False + + def _build_onboarding_pr_body(self, content: WebsiteContent, cv_entry: Optional[str]) -> str: + """Build PR description for onboarding.""" + body = f"""## New Lab Member: {content.name} + +**Role:** {content.role} + +**Bio:** +> {content.bio} + +**Website:** {content.name_url or 'None'} + +--- + +### Changes +- [ ] Photo uploaded to `images/people/{content.image_filename}` +- [ ] Member added to `people.xlsx` members sheet +""" + if cv_entry: + body += f"- [ ] CV updated with new mentee entry\n" + + body += """ +After merging, GitHub Actions will automatically rebuild the website. + +--- +Generated by CDL Onboarding Bot +""" + return body + + def _build_offboarding_pr_body( + self, + name: str, + alumni: AlumniContent, + cv_update: Optional[Tuple[str, str]] + ) -> str: + """Build PR description for offboarding.""" + body = f"""## Transition to Alumni: {name} + +**Years Active:** {alumni.years} +**Current Position:** {alumni.current_position} +**Position URL:** {alumni.current_position_url or 'None'} + +--- + +### Changes +- [ ] Removed from `members` sheet +- [ ] Added to alumni sheet +""" + if cv_update: + body += "- [ ] CV entry updated with end date and current position\n" + + body += """ +After merging, GitHub Actions will automatically rebuild the website. + +--- +Generated by CDL Onboarding Bot +""" + return body + + +def build_cv_entry( + name: str, + role: MemberRole, + grad_type: Optional[GradType] = None, + grad_field: Optional[str] = None, + year: Optional[int] = None, +) -> Tuple[Optional[str], Optional[str]]: + """ + Build a CV entry for a new member. + + Returns: + Tuple of (entry_text, section_name) or (None, None) if role doesn't get CV entry + """ + if year is None: + year = datetime.now().year + + if role == MemberRole.LAB_MANAGER or role == MemberRole.RESEARCH_SCIENTIST: + return None, None + + if role == MemberRole.POSTDOC: + entry = f"\\item {name} ({year} -- )" + section = "Postdoctoral Advisees" + elif role == MemberRole.UNDERGRAD: + entry = f"\\item {name} ({year} -- )" + section = "Undergraduate Advisees" + elif role == MemberRole.GRAD_STUDENT: + if grad_type == GradType.MASTERS and grad_field: + entry = f"\\item {name} (Masters student, {grad_field}; {year} -- )" + elif grad_type == GradType.MASTERS: + entry = f"\\item {name} (Masters student; {year} -- )" + else: + entry = f"\\item {name} (Doctoral student; {year} -- )" + section = "Graduate Advisees" + else: + return None, None + + return entry, section + + +def build_cv_update_for_offboarding( + name: str, + role: MemberRole, + start_year: int, + end_year: int, + current_position: str, + grad_type: Optional[GradType] = None, + grad_field: Optional[str] = None, +) -> Optional[Tuple[str, str]]: + """ + Build CV update pattern for offboarding. + + Returns: + Tuple of (old_entry_pattern, new_entry) or None if role doesn't have CV entry + """ + if role == MemberRole.LAB_MANAGER or role == MemberRole.RESEARCH_SCIENTIST: + return None + + if role == MemberRole.POSTDOC: + old = f"\\item {name} ({start_year} -- )" + new = f"\\item {name} ({start_year} -- {end_year}; current position: {current_position})" + elif role == MemberRole.UNDERGRAD: + old = f"\\item {name} ({start_year} -- )" + new = f"\\item {name} ({start_year} -- {end_year})" + elif role == MemberRole.GRAD_STUDENT: + if grad_type == GradType.MASTERS and grad_field: + old = f"\\item {name} (Masters student, {grad_field}; {start_year} -- )" + new = f"\\item {name} (Masters student, {grad_field}; {start_year} -- {end_year}; current position: {current_position})" + elif grad_type == GradType.MASTERS: + old = f"\\item {name} (Masters student; {start_year} -- )" + new = f"\\item {name} (Masters student; {start_year} -- {end_year}; current position: {current_position})" + else: + old = f"\\item {name} (Doctoral student; {start_year} -- )" + new = f"\\item {name} (Doctoral student; {start_year} -- {end_year}; current position: {current_position})" + else: + return None + + return old, new diff --git a/scripts/onboarding/startup_queue.py b/scripts/onboarding/startup_queue.py new file mode 100644 index 0000000..986c914 --- /dev/null +++ b/scripts/onboarding/startup_queue.py @@ -0,0 +1,411 @@ +""" +Startup queue processor for missed workflow submissions. + +When the bot starts, this module scans recent messages in the admin's DM +for workflow submissions that may have arrived while the bot was offline. +It compares against stored requests to find and process any missed submissions. +""" + +import json +import logging +import re +from datetime import datetime, timedelta +from pathlib import Path +from typing import Optional + +from slack_bolt import App +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from .config import Config +from .storage import get_request, get_storage + +logger = logging.getLogger(__name__) + +# File to track processed message timestamps +PROCESSED_MESSAGES_FILE = Path(__file__).parent / "data" / "processed_messages.json" + +# File to store pending reprocess data (message content for button handler) +PENDING_REPROCESS_FILE = Path(__file__).parent / "data" / "pending_reprocess.json" + +# How far back to scan (in days) +DEFAULT_LOOKBACK_DAYS = 7 + + +def _load_pending_reprocess() -> dict: + """Load pending reprocess data.""" + PENDING_REPROCESS_FILE.parent.mkdir(parents=True, exist_ok=True) + if PENDING_REPROCESS_FILE.exists(): + try: + return json.loads(PENDING_REPROCESS_FILE.read_text()) + except Exception: + pass + return {} + + +def _save_pending_reprocess(data: dict): + """Save pending reprocess data.""" + try: + PENDING_REPROCESS_FILE.write_text(json.dumps(data, indent=2)) + except Exception as e: + logger.error(f"Error saving pending reprocess data: {e}") + + +class StartupQueueProcessor: + """Processes missed workflow submissions on bot startup.""" + + def __init__(self, client: WebClient, config: Config): + self.client = client + self.config = config + self._processed_timestamps: set[str] = set() + self._load_processed_timestamps() + + def _load_processed_timestamps(self): + """Load set of already-processed message timestamps.""" + PROCESSED_MESSAGES_FILE.parent.mkdir(parents=True, exist_ok=True) + if PROCESSED_MESSAGES_FILE.exists(): + try: + data = json.loads(PROCESSED_MESSAGES_FILE.read_text()) + self._processed_timestamps = set(data.get("timestamps", [])) + logger.info(f"Loaded {len(self._processed_timestamps)} processed message timestamps") + except Exception as e: + logger.error(f"Error loading processed timestamps: {e}") + self._processed_timestamps = set() + + def _save_processed_timestamps(self): + """Save processed message timestamps to disk.""" + try: + # Keep only recent timestamps (last 30 days worth) to prevent unbounded growth + data = {"timestamps": list(self._processed_timestamps)[-10000:]} + PROCESSED_MESSAGES_FILE.write_text(json.dumps(data, indent=2)) + except Exception as e: + logger.error(f"Error saving processed timestamps: {e}") + + def mark_processed(self, message_ts: str): + """Mark a message as processed.""" + self._processed_timestamps.add(message_ts) + self._save_processed_timestamps() + + def is_processed(self, message_ts: str) -> bool: + """Check if a message has already been processed.""" + return message_ts in self._processed_timestamps + + def scan_for_missed_submissions( + self, + lookback_days: int = DEFAULT_LOOKBACK_DAYS, + ) -> list[dict]: + """ + Scan admin's DMs for missed workflow submissions. + + Returns list of unprocessed workflow messages. + """ + missed = [] + + # Calculate the oldest timestamp to scan + oldest = datetime.now() - timedelta(days=lookback_days) + oldest_ts = str(oldest.timestamp()) + + try: + # Open DM with admin to get channel ID + dm_response = self.client.conversations_open( + users=[self.config.slack.admin_user_id] + ) + admin_dm_channel = dm_response["channel"]["id"] + + # Fetch conversation history + result = self.client.conversations_history( + channel=admin_dm_channel, + oldest=oldest_ts, + limit=200, # Reasonable limit for startup scan + ) + + messages = result.get("messages", []) + logger.info(f"Scanning {len(messages)} messages from last {lookback_days} days") + + for msg in messages: + # Skip if already processed + msg_ts = msg.get("ts", "") + if self.is_processed(msg_ts): + continue + + # Check if this is a workflow submission message + text = msg.get("text", "") + bot_id = msg.get("bot_id") + + # Workflow messages come from bots and contain specific text + if not bot_id: + continue + + if "CDL Onboarding" not in text or "submission from" not in text: + continue + + # Extract submitter user ID + user_match = re.search(r"submission from\s+<@([A-Z0-9]+)", text) + if not user_match: + continue + + submitter_id = user_match.group(1) + + # Check if we already have a request for this user + existing_request = get_request(submitter_id) + if existing_request: + # Already have a request - mark as processed and skip + self.mark_processed(msg_ts) + continue + + # This is a missed submission! + logger.info(f"Found missed workflow submission from {submitter_id} at {msg_ts}") + missed.append({ + "message": msg, + "submitter_id": submitter_id, + "channel": admin_dm_channel, + }) + + except SlackApiError as e: + logger.error(f"Error scanning for missed submissions: {e}") + + return missed + + +def process_startup_queue(client: WebClient, config: Config) -> int: + """ + Process any missed workflow submissions on startup. + + This is the main entry point called from bot.py. + + Returns: + Number of missed submissions found and queued for processing. + """ + processor = StartupQueueProcessor(client, config) + missed = processor.scan_for_missed_submissions() + + if not missed: + logger.info("No missed workflow submissions found") + return 0 + + logger.info(f"Found {len(missed)} missed workflow submissions") + + # For each missed submission, we need to trigger the workflow listener logic + # But we can't directly call the handler - instead, we'll notify the admin + # and let them re-trigger or we process inline + + # Load pending reprocess data to store message info for button handler + pending = _load_pending_reprocess() + + for item in missed: + submitter_id = item["submitter_id"] + msg = item["message"] + channel = item["channel"] + msg_ts = msg.get("ts", "") + + # Store message data for the reprocess button handler + reprocess_key = f"{submitter_id}_{msg_ts}" + pending[reprocess_key] = { + "submitter_id": submitter_id, + "message_text": msg.get("text", ""), + "channel": channel, + "message_ts": msg_ts, + } + + try: + # Notify admin about the missed submission with reprocess button + client.chat_postMessage( + channel=channel, + text=f"Missed workflow submission from <@{submitter_id}>", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f":warning: *Missed Submission Detected*\n\n" + f"A workflow submission from <@{submitter_id}> was received " + f"while the bot was offline.", + }, + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Reprocess Now"}, + "style": "primary", + "action_id": "reprocess_missed_submission", + "value": reprocess_key, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Dismiss"}, + "action_id": "dismiss_missed_submission", + "value": reprocess_key, + }, + ], + }, + ], + thread_ts=msg_ts, + ) + + # Mark as processed so we don't notify again on next restart + processor.mark_processed(msg_ts) + + except SlackApiError as e: + logger.error(f"Error notifying about missed submission: {e}") + + # Save pending reprocess data + _save_pending_reprocess(pending) + + return len(missed) + + +def register_startup_queue_handlers(app: App, config: Config): + """Register handlers for missed submission reprocessing buttons.""" + + from .services.github_service import GitHubService + from .services.bio_service import BioService + from .models.onboarding_request import OnboardingRequest, OnboardingStatus + from .storage import save_request + from .handlers.workflow_listener import _parse_workflow_message + + github_service = GitHubService(config.github.token, config.github.org_name) + bio_service = None + if config.anthropic: + bio_service = BioService(config.anthropic.api_key, config.anthropic.model) + + @app.action("reprocess_missed_submission") + def handle_reprocess(ack, body, client: WebClient, action): + """Handle the reprocess button click.""" + ack() + + reprocess_key = action["value"] + pending = _load_pending_reprocess() + + if reprocess_key not in pending: + client.chat_postMessage( + channel=body["channel"]["id"], + text=":x: Could not find submission data. It may have already been processed.", + thread_ts=body["message"]["ts"], + ) + return + + data = pending[reprocess_key] + submitter_id = data["submitter_id"] + message_text = data["message_text"] + channel = data["channel"] + + # Parse the workflow message + parsed_data = _parse_workflow_message(message_text) + + if not parsed_data: + client.chat_postMessage( + channel=channel, + text=f":x: Could not parse workflow message for <@{submitter_id}>. Manual processing required.", + thread_ts=body["message"]["ts"], + ) + return + + # Get user info from Slack + try: + user_info = client.users_info(user=submitter_id) + name = parsed_data.get("name") or user_info["user"]["real_name"] or user_info["user"]["name"] + email = parsed_data.get("email") or user_info["user"].get("profile", {}).get("email", "") + except SlackApiError: + name = parsed_data.get("name", "Unknown") + email = parsed_data.get("email", "") + + # Validate GitHub username if provided + github_username = parsed_data.get("github_username", "") + if github_username: + is_valid, _ = github_service.validate_username(github_username) + if not is_valid: + client.chat_postMessage( + channel=channel, + text=f":warning: GitHub username `{github_username}` is invalid, but continuing anyway.", + thread_ts=body["message"]["ts"], + ) + + # Open DM channel with the new member + try: + dm_response = client.conversations_open(users=[submitter_id]) + dm_channel = dm_response["channel"]["id"] + except SlackApiError: + dm_channel = channel + + # Create onboarding request + request = OnboardingRequest( + slack_user_id=submitter_id, + slack_channel_id=dm_channel, + name=name, + email=email, + github_username=github_username, + bio_raw=parsed_data.get("bio", ""), + website_url=parsed_data.get("website_url", ""), + ) + + # Process bio if service available + if bio_service and request.bio_raw: + edited_bio, _ = bio_service.edit_bio(request.bio_raw, name) + if edited_bio: + request.bio_edited = edited_bio + + request.update_status(OnboardingStatus.PENDING_APPROVAL) + save_request(request) + + # Remove from pending + del pending[reprocess_key] + _save_pending_reprocess(pending) + + # Send approval request (reuse workflow listener logic) + from .handlers.workflow_listener import _send_workflow_approval_request + _send_workflow_approval_request(client, config, request, github_service, channel) + + # Update the original message to show it was processed + try: + client.chat_update( + channel=body["channel"]["id"], + ts=body["message"]["ts"], + text=f":white_check_mark: Reprocessed submission from <@{submitter_id}>", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f":white_check_mark: *Submission Reprocessed*\n\n" + f"Successfully created onboarding request for <@{submitter_id}>.\n" + f"Approval request sent above.", + }, + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error updating message: {e}") + + @app.action("dismiss_missed_submission") + def handle_dismiss(ack, body, client: WebClient, action): + """Handle the dismiss button click.""" + ack() + + reprocess_key = action["value"] + pending = _load_pending_reprocess() + + # Remove from pending + if reprocess_key in pending: + del pending[reprocess_key] + _save_pending_reprocess(pending) + + # Update message to show dismissed + try: + client.chat_update( + channel=body["channel"]["id"], + ts=body["message"]["ts"], + text="Dismissed missed submission", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":no_entry_sign: *Dismissed* - This missed submission has been ignored.", + }, + }, + ], + ) + except SlackApiError as e: + logger.error(f"Error updating message: {e}") diff --git a/scripts/onboarding/storage.py b/scripts/onboarding/storage.py new file mode 100644 index 0000000..a9c06a7 --- /dev/null +++ b/scripts/onboarding/storage.py @@ -0,0 +1,114 @@ +""" +Persistent storage for onboarding requests. + +Stores requests in a JSON file to survive bot restarts. +""" + +import json +import logging +from pathlib import Path +from typing import Optional + +from .models.onboarding_request import OnboardingRequest + +logger = logging.getLogger(__name__) + +# Default storage location +DEFAULT_STORAGE_PATH = Path(__file__).parent / "data" / "requests.json" + + +class RequestStorage: + """Persistent storage for onboarding requests.""" + + def __init__(self, storage_path: Optional[Path] = None): + """ + Initialize storage. + + Args: + storage_path: Path to JSON file. Defaults to data/requests.json + """ + self.storage_path = storage_path or DEFAULT_STORAGE_PATH + self._cache: dict[str, OnboardingRequest] = {} + self._ensure_storage_exists() + self._load() + + def _ensure_storage_exists(self): + """Ensure storage directory and file exist.""" + self.storage_path.parent.mkdir(parents=True, exist_ok=True) + if not self.storage_path.exists(): + self.storage_path.write_text("{}") + + def _load(self): + """Load requests from disk.""" + try: + data = json.loads(self.storage_path.read_text()) + self._cache = { + user_id: OnboardingRequest.from_dict(req_data) + for user_id, req_data in data.items() + } + logger.info(f"Loaded {len(self._cache)} requests from storage") + except Exception as e: + logger.error(f"Error loading requests: {e}") + self._cache = {} + + def _save(self): + """Save requests to disk.""" + try: + data = { + user_id: req.to_dict() + for user_id, req in self._cache.items() + } + self.storage_path.write_text(json.dumps(data, indent=2, default=str)) + except Exception as e: + logger.error(f"Error saving requests: {e}") + + def get(self, user_id: str) -> Optional[OnboardingRequest]: + """Get an onboarding request by user ID.""" + return self._cache.get(user_id) + + def save(self, request: OnboardingRequest): + """Save an onboarding request.""" + self._cache[request.slack_user_id] = request + self._save() + + def delete(self, user_id: str): + """Delete an onboarding request.""" + if user_id in self._cache: + del self._cache[user_id] + self._save() + + def get_all(self) -> dict[str, OnboardingRequest]: + """Get all active requests.""" + return self._cache.copy() + + def get_by_status(self, status) -> list[OnboardingRequest]: + """Get all requests with a specific status.""" + return [req for req in self._cache.values() if req.status == status] + + +# Global storage instance (lazy initialized) +_storage: Optional[RequestStorage] = None + + +def get_storage(storage_path: Optional[Path] = None) -> RequestStorage: + """Get the global storage instance.""" + global _storage + if _storage is None: + _storage = RequestStorage(storage_path) + return _storage + + +# Convenience functions for backwards compatibility +def get_request(user_id: str) -> Optional[OnboardingRequest]: + """Get an active onboarding request for a user.""" + return get_storage().get(user_id) + + +def save_request(request: OnboardingRequest): + """Save an onboarding request.""" + get_storage().save(request) + + +def delete_request(user_id: str): + """Delete an onboarding request.""" + get_storage().delete(user_id)