mirror of
https://github.com/Sea-Haven-Industries/ring-scheduler-3cx.git
synced 2026-05-18 20:20:12 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
commit
dc1d0bfe75
6 changed files with 297 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.aws-sam/
|
||||||
|
.env
|
||||||
|
packaged.yaml
|
||||||
25
config/schedule.yaml
Normal file
25
config/schedule.yaml
Normal file
|
|
@ -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"
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
requests>=2.31.0
|
||||||
|
pyyaml>=6.0
|
||||||
|
boto3>=1.28.0
|
||||||
101
src/handler.py
Normal file
101
src/handler.py
Normal file
|
|
@ -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
|
||||||
110
src/three_cx_client.py
Normal file
110
src/three_cx_client.py
Normal file
|
|
@ -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
|
||||||
53
template.yaml
Normal file
53
template.yaml
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Reference in a new issue