import json import logging import os from datetime import datetime, timezone, timedelta import boto3 import yaml from src.three_cx_client import ThreeCXClient logger = logging.getLogger() logger.setLevel(logging.INFO) # US Eastern (handles DST automatically via zoneinfo on Python 3.9+) try: from zoneinfo import ZoneInfo EASTERN = ZoneInfo("America/New_York") except ImportError: EASTERN = timezone(timedelta(hours=-5)) def load_schedule() -> dict: """Load schedule from config bundled in the Lambda package.""" config_path = os.path.join(os.path.dirname(__file__), "..", "config", "schedule.yaml") with open(config_path) as f: return yaml.safe_load(f) def get_3cx_credentials() -> dict: """Fetch 3CX credentials from SSM Parameter Store.""" ssm = boto3.client("ssm") prefix = os.environ.get("SSM_PREFIX", "/3cx-scheduler") params = ssm.get_parameters_by_path( Path=prefix, WithDecryption=True, ) creds = {} for p in params["Parameters"]: key = p["Name"].split("/")[-1] creds[key] = p["Value"] return creds WEEKEND_DAYS = {"Saturday", "Sunday"} def check_dynamodb_override(date_str: str, day_name: str, shift_type: str = "night") -> str | None: """Check the afterhours-shifts DynamoDB table for a Slack-driven override. Args: date_str: Date in YYYY-MM-DD format day_name: Day of week (e.g. "Saturday") shift_type: "night" for after-hours, "day" for weekend daytime (8am-5pm) Returns the extension if an override exists, None otherwise. Only active when SHIFT_TABLE env var is set. """ table_name = os.environ.get("SHIFT_TABLE") if not table_name: return None dynamodb = boto3.resource("dynamodb") table = dynamodb.Table(table_name) # Check date-specific override first override_sk = f"{date_str}-DAY" if shift_type == "day" else date_str resp = table.get_item(Key={"PK": "OVERRIDE", "SK": override_sk}) item = resp.get("Item") if item: ext = item["extension"] if ext == "OPEN": # Open shift with no pickup — use fallback from DynamoDB config or default 100 config_resp = table.get_item(Key={"PK": "CONFIG", "SK": "CONFIG"}) fallback = config_resp.get("Item", {}).get("fallback_extension", "100") logger.info("DynamoDB override for %s (%s): OPEN — using fallback Ext %s", override_sk, shift_type, fallback) return fallback logger.info("DynamoDB override for %s (%s): Ext %s", override_sk, shift_type, ext) return ext # Check DynamoDB weekly schedule (takes precedence over YAML when SHIFT_TABLE is set) weekly_sk = f"{day_name}-Day" if shift_type == "day" else day_name resp = table.get_item(Key={"PK": "WEEKLY", "SK": weekly_sk}) item = resp.get("Item") if item: ext = item["extension"] logger.info("DynamoDB weekly schedule for %s (%s, %s): Ext %s", date_str, weekly_sk, shift_type, ext) return ext return None def determine_shift_type(now: datetime) -> str: """Determine the shift type based on day of week and time. On weekends at 8am: returns 'day' (daytime 8am-5pm shift) On weekends at 5pm: returns 'night' (after-hours shift) On weekdays: always returns 'night' (after-hours shift) """ day_name = now.strftime("%A") if day_name in WEEKEND_DAYS and now.hour < 17: return "day" return "night" def resolve_extension(schedule: dict, now: datetime, shift_type: str = "night") -> str: """Determine which extension should handle calls for this shift. Args: schedule: YAML schedule config now: Current datetime in Eastern shift_type: "night" for after-hours, "day" for weekend daytime Resolution order: 1. DynamoDB date override (Slack-driven pickup/drop) 2. DynamoDB weekly schedule (if SHIFT_TABLE is set) 3. YAML exception overrides (night shifts only) 4. YAML weekly schedule (night shifts only) """ date_str = now.strftime("%Y-%m-%d") day_name = now.strftime("%A") # Check DynamoDB first (Slack shift manager) dynamo_ext = check_dynamodb_override(date_str, day_name, shift_type) if dynamo_ext is not None: return dynamo_ext # YAML config only covers night shifts (legacy) if shift_type == "day": logger.info("No DynamoDB entry for %s day shift — using fallback Ext 100", date_str) return "100" # Fall back to YAML config exceptions = schedule.get("exceptions") or {} if date_str in exceptions: ext = str(exceptions[date_str]) logger.info("Using YAML exception override for %s: Ext %s", date_str, ext) return ext weekly = schedule["weekly_schedule"] if day_name not in weekly: raise ValueError(f"No schedule entry for {day_name}") ext = str(weekly[day_name]) logger.info("Using YAML weekly schedule for %s (%s): Ext %s", date_str, day_name, ext) return ext def handler(event, context): """Lambda entry point. Triggered by EventBridge rules: - Daily at 8am ET (EST + EDT rules) — sets after-hours route (weekdays) or daytime route (weekends) - Weekends at 5pm ET (EST + EDT rules) — switches to night shift person Only one rule per time slot fires correctly depending on DST.""" now = datetime.now(EASTERN) current_hour = now.hour day_name = now.strftime("%A") logger.info("Running 3CX ring group scheduler at %s", now.isoformat()) force = event.get("force", False) valid_hours = {8, 17} if day_name in WEEKEND_DAYS else {8} if not force and current_hour not in valid_hours: logger.info("Current ET hour is %d on %s — skipping (wrong DST rule fired)", current_hour, day_name) return {"skipped": True, "reason": f"ET hour is {current_hour}, not in {valid_hours}"} schedule = load_schedule() shift_type = determine_shift_type(now) if event.get("override_extension"): extension = event["override_extension"] else: extension = resolve_extension(schedule, now, shift_type) creds = get_3cx_credentials() client = ThreeCXClient( domain=creds["domain"], auth_mode="oauth", client_id=creds["client_id"], client_secret=creds["client_secret"], ) ring_group = client.get_ring_group(schedule["ring_group_number"]) ring_group_id = ring_group["Id"] client.update_ring_group_forwarding( ring_group_id=ring_group_id, closed_destination=extension, holiday_destination=extension, ) result = { "date": now.strftime("%Y-%m-%d"), "day": day_name, "shift_type": shift_type, "extension": extension, "ring_group": schedule["ring_group_number"], } logger.info("Result: %s", json.dumps(result)) return result