commit dc1d0bfe756175fbd1084e8bfa8e558099fb761f Author: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri Apr 3 17:16:08 2026 -0400 Initial commit: 3CX ring group after-hours scheduler Lambda + EventBridge that updates ring group 800 forwarding daily at 8am ET. Uses 3CX V20 XAPI with OAuth2 auth. Dual cron rules for EST/EDT handling. Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77fb9d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.aws-sam/ +.env +packaged.yaml diff --git a/config/schedule.yaml b/config/schedule.yaml new file mode 100644 index 0000000..b5ceebc --- /dev/null +++ b/config/schedule.yaml @@ -0,0 +1,25 @@ +# Ring Group After-Hours Schedule +# This config controls which extension receives calls when the office is closed. +# 3CX office hours: 8am - 5pm (configured in 3CX admin) +# This Lambda runs at 8am daily and sets the after-hours destination for that evening. + +ring_group_number: "800" # Your ring group extension number + +# Default weekly schedule: day -> extension for after-hours (5pm - 8am) +# Saturday/Sunday inherit Friday's setting (Ext 116) unless overridden below +weekly_schedule: + Monday: "114" + Tuesday: "115" + Wednesday: "114" + Thursday: "115" + Friday: "116" + Saturday: "100" + Sunday: "100" + +# Exception overrides: specific dates that differ from the normal pattern +# Format: YYYY-MM-DD -> extension number +# These take priority over the weekly schedule +exceptions: + # Example: holiday coverage + # "2026-07-03": "116" + # "2026-12-24": "114" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4b53387 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +requests>=2.31.0 +pyyaml>=6.0 +boto3>=1.28.0 diff --git a/src/handler.py b/src/handler.py new file mode 100644 index 0000000..d99f781 --- /dev/null +++ b/src/handler.py @@ -0,0 +1,101 @@ +import json +import logging +import os +from datetime import datetime, timezone, timedelta + +import boto3 +import yaml + +from 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 + + +def resolve_extension(schedule: dict, now: datetime) -> str: + """Determine which extension should handle after-hours calls today.""" + date_str = now.strftime("%Y-%m-%d") + exceptions = schedule.get("exceptions") or {} + if date_str in exceptions: + ext = str(exceptions[date_str]) + logger.info("Using exception override for %s: Ext %s", date_str, ext) + return ext + + day_name = now.strftime("%A") + 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 weekly schedule for %s (%s): Ext %s", date_str, day_name, ext) + return ext + + +def handler(event, context): + """Lambda entry point. Triggered by two EventBridge rules (EST + EDT). + Only one fires at the correct 8am ET depending on DST. The other fires + at 7am or 9am ET — we skip that invocation.""" + now = datetime.now(EASTERN) + current_hour = now.hour + logger.info("Running 3CX ring group scheduler at %s", now.isoformat()) + + if current_hour != 8: + logger.info("Current ET hour is %d, not 8am — skipping (wrong DST rule fired)", current_hour) + return {"skipped": True, "reason": f"ET hour is {current_hour}, not 8"} + + schedule = load_schedule() + extension = resolve_extension(schedule, now) + + 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": now.strftime("%A"), + "extension": extension, + "ring_group": schedule["ring_group_number"], + } + logger.info("Result: %s", json.dumps(result)) + return result diff --git a/src/three_cx_client.py b/src/three_cx_client.py new file mode 100644 index 0000000..3a7a0c4 --- /dev/null +++ b/src/three_cx_client.py @@ -0,0 +1,110 @@ +import logging +import requests + +logger = logging.getLogger(__name__) + + +class ThreeCXClient: + """Client for 3CX V20 cloud-hosted management API (XAPI).""" + + def __init__(self, domain: str, auth_mode: str = "user", **auth_kwargs): + """ + Args: + domain: Your 3CX FQDN (e.g. "yourcompany.3cx.us") + auth_mode: "user" for extension login, "oauth" for Enterprise API client + auth_kwargs: credentials — see _authenticate_user / _authenticate_oauth + """ + self.base_url = f"https://{domain}" + self.session = requests.Session() + self.session.headers.update({ + "OData-Version": "4.0", + "Content-Type": "application/json", + }) + + if auth_mode == "oauth": + self._authenticate_oauth(auth_kwargs["client_id"], auth_kwargs["client_secret"]) + else: + self._authenticate_user(auth_kwargs["username"], auth_kwargs["password"]) + + def _authenticate_user(self, username: str, password: str): + """Authenticate via extension/user credentials (any license tier).""" + resp = self.session.post( + f"{self.base_url}/webclient/api/Login/GetAccessToken", + json={"SecurityCode": "", "Username": username, "Password": password}, + ) + resp.raise_for_status() + data = resp.json() + token = data.get("Token", {}).get("access_token") or data.get("access_token") + if not token: + raise ValueError(f"Failed to get access token. Response: {data}") + self.session.headers.update({"Authorization": f"Bearer {token}"}) + logger.info("Authenticated to 3CX via user credentials") + + def _authenticate_oauth(self, client_id: str, client_secret: str): + """Authenticate via OAuth2 client credentials (Enterprise license required). + API client must be created in 3CX Admin > Integrations > API.""" + resp = self.session.post( + f"{self.base_url}/connect/token", + data={ + "client_id": client_id, + "client_secret": client_secret, + "grant_type": "client_credentials", + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + resp.raise_for_status() + token = resp.json()["access_token"] + self.session.headers.update({"Authorization": f"Bearer {token}"}) + logger.info("Authenticated to 3CX via OAuth2 client credentials") + + def get_ring_group(self, extension_number: str) -> dict: + """Fetch ring group config by extension number.""" + resp = self.session.get( + f"{self.base_url}/xapi/v1/RingGroups/Pbx.GetByNumber(number='{extension_number}')", + ) + resp.raise_for_status() + return resp.json() + + def update_ring_group_forwarding( + self, + ring_group_id: int, + closed_destination: str, + holiday_destination: str, + ): + """Update the OutOfOfficeRoute and HolidaysRoute on a ring group. + + Args: + ring_group_id: Numeric ID from the ring group entity + closed_destination: Extension number for after-hours routing + holiday_destination: Extension number for holiday routing + """ + payload = { + "OutOfOfficeRoute": { + "IsPromptEnabled": False, + "Route": { + "To": "Extension", + "Number": closed_destination, + "External": "", + }, + }, + "HolidaysRoute": { + "IsPromptEnabled": False, + "Route": { + "To": "Extension", + "Number": holiday_destination, + "External": "", + }, + }, + } + resp = self.session.patch( + f"{self.base_url}/xapi/v1/RingGroups({ring_group_id})", + json=payload, + ) + resp.raise_for_status() + logger.info( + "Updated ring group %s: closed->Ext %s, holiday->Ext %s", + ring_group_id, + closed_destination, + holiday_destination, + ) + return resp.status_code diff --git a/template.yaml b/template.yaml new file mode 100644 index 0000000..74faa9a --- /dev/null +++ b/template.yaml @@ -0,0 +1,53 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: 3CX Ring Group After-Hours Scheduler + +Parameters: + Timezone: + Type: String + Default: "America/New_York" + +Globals: + Function: + Runtime: python3.12 + Timeout: 30 + MemorySize: 128 + +Resources: + SchedulerFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: 3cx-ring-group-scheduler + Handler: src/handler.handler + CodeUri: . + Environment: + Variables: + SSM_PREFIX: /3cx-scheduler + TZ: !Ref Timezone + Policies: + - SSMParameterWithSlashPrefixReadPolicy: + ParameterName: 3cx-scheduler + Events: + # EST: 8am ET = 13:00 UTC (Nov-Mar) + DailyScheduleEST: + Type: Schedule + Properties: + Schedule: cron(0 13 ? * * *) + Description: "Update 3CX ring group at 8am EST (Nov-Mar)" + Enabled: true + # EDT: 8am ET = 12:00 UTC (Mar-Nov) + DailyScheduleEDT: + Type: Schedule + Properties: + Schedule: cron(0 12 ? * * *) + Description: "Update 3CX ring group at 8am EDT (Mar-Nov)" + Enabled: true + + # SSM Parameters: create these manually via CLI before deploying. + # See setup instructions in the deploy section below. + +Outputs: + FunctionArn: + Value: !GetAtt SchedulerFunction.Arn + FunctionName: + Value: !Ref SchedulerFunction