mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-10-04 23:02:06 +00:00
Add 3CX roster sync Lambda with daily EventBridge trigger
Syncs DynamoDB roster from a configured 3CX group daily at 6am ET (before the 7am schedule post). Includes ThreeCXClient for XAPI authentication and group member queries. Preserves existing slack_user_id links and guards against accidental roster wipes. Closes #4
This commit is contained in:
parent
bd3d27f774
commit
ac3bd455e7
4 changed files with 309 additions and 0 deletions
|
|
@ -1,2 +1,3 @@
|
||||||
slack_bolt>=1.18.0,<2.0
|
slack_bolt>=1.18.0,<2.0
|
||||||
boto3>=1.28.0
|
boto3>=1.28.0
|
||||||
|
requests>=2.31.0
|
||||||
|
|
|
||||||
123
src/roster_sync.py
Normal file
123
src/roster_sync.py
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
"""Lambda handler — syncs the DynamoDB roster from 3CX group members.
|
||||||
|
|
||||||
|
Runs daily via EventBridge. Pulls members from the configured 3CX group,
|
||||||
|
filters to Extension type only (excludes RingGroups, IVRs, Voicemail, etc.),
|
||||||
|
and syncs to DynamoDB. Preserves existing slack_user_id links.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
from src.three_cx_client import ThreeCXClient
|
||||||
|
from src.schedule import ShiftSchedule
|
||||||
|
|
||||||
|
logger = logging.getLogger()
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
|
|
||||||
|
# System extensions to exclude from roster sync
|
||||||
|
EXCLUDE_NAMES = {"Voicemail", "IVR", "Fax"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_3cx_credentials() -> dict:
|
||||||
|
ssm = boto3.client("ssm")
|
||||||
|
prefix = os.environ.get("TCX_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 handler(event, context):
|
||||||
|
now = datetime.now(EASTERN)
|
||||||
|
|
||||||
|
# DST guard — two EventBridge rules fire, only one is at 6am ET
|
||||||
|
if now.hour != 6:
|
||||||
|
logger.info("ET hour is %d, not 6am — skipping (wrong DST rule fired)", now.hour)
|
||||||
|
return {"skipped": True}
|
||||||
|
|
||||||
|
group_name = os.environ.get("SYNC_GROUP", "DEFAULT")
|
||||||
|
|
||||||
|
logger.info("Starting roster sync from 3CX group '%s' at %s", group_name, now.isoformat())
|
||||||
|
|
||||||
|
creds = get_3cx_credentials()
|
||||||
|
client = ThreeCXClient(
|
||||||
|
domain=creds["domain"],
|
||||||
|
auth_mode="oauth",
|
||||||
|
client_id=creds["client_id"],
|
||||||
|
client_secret=creds["client_secret"],
|
||||||
|
)
|
||||||
|
|
||||||
|
members = client.get_group_members(group_name)
|
||||||
|
# Only sync actual phone extensions, not ring groups, IVRs, etc.
|
||||||
|
extensions = [
|
||||||
|
m for m in members
|
||||||
|
if m.get("Type") == "Extension" and m.get("MemberName") not in EXCLUDE_NAMES
|
||||||
|
]
|
||||||
|
logger.info("Found %d extensions in 3CX group '%s' (filtered from %d members)", len(extensions), group_name, len(members))
|
||||||
|
|
||||||
|
schedule = ShiftSchedule()
|
||||||
|
current_roster = {item["SK"]: item for item in schedule.get_roster()}
|
||||||
|
|
||||||
|
threecx_extensions = set()
|
||||||
|
added = []
|
||||||
|
updated = []
|
||||||
|
|
||||||
|
for ext in extensions:
|
||||||
|
number = str(ext.get("Number", ""))
|
||||||
|
name = ext.get("MemberName", "").strip()
|
||||||
|
|
||||||
|
if not number or not name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
threecx_extensions.add(number)
|
||||||
|
existing = current_roster.get(number)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
if existing.get("name") != name:
|
||||||
|
schedule.table.update_item(
|
||||||
|
Key={"PK": "ROSTER", "SK": number},
|
||||||
|
UpdateExpression="SET #n = :name",
|
||||||
|
ExpressionAttributeNames={"#n": "name"},
|
||||||
|
ExpressionAttributeValues={":name": name},
|
||||||
|
)
|
||||||
|
updated.append(f"Ext {number}: {existing['name']} -> {name}")
|
||||||
|
else:
|
||||||
|
schedule.table.put_item(
|
||||||
|
Item={
|
||||||
|
"PK": "ROSTER",
|
||||||
|
"SK": number,
|
||||||
|
"name": name,
|
||||||
|
"extension": number,
|
||||||
|
"slack_user_id": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
added.append(f"Ext {number}: {name}")
|
||||||
|
|
||||||
|
removed = []
|
||||||
|
# Only remove departed employees if we got results from 3CX
|
||||||
|
# (prevents wiping roster on API errors or misconfigured group)
|
||||||
|
if threecx_extensions:
|
||||||
|
for ext_number, item in current_roster.items():
|
||||||
|
if ext_number not in threecx_extensions:
|
||||||
|
schedule.table.delete_item(Key={"PK": "ROSTER", "SK": ext_number})
|
||||||
|
removed.append(f"Ext {ext_number}: {item.get('name', 'Unknown')}")
|
||||||
|
elif current_roster:
|
||||||
|
logger.warning("No 3CX extensions found but roster is non-empty — skipping removal to prevent data loss")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"group": group_name,
|
||||||
|
"total_3cx": len(threecx_extensions),
|
||||||
|
"added": added,
|
||||||
|
"updated": updated,
|
||||||
|
"removed": removed,
|
||||||
|
}
|
||||||
|
logger.info("Roster sync complete: %s", result)
|
||||||
|
return result
|
||||||
133
src/three_cx_client.py
Normal file
133
src/three_cx_client.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
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_group_members(self, group_name: str = "DEFAULT") -> list[dict]:
|
||||||
|
"""Fetch members of a 3CX group (department).
|
||||||
|
|
||||||
|
Returns members with Number, MemberName, Type, etc.
|
||||||
|
"""
|
||||||
|
# First, find the group ID by name
|
||||||
|
resp = self.session.get(
|
||||||
|
f"{self.base_url}/xapi/v1/Groups",
|
||||||
|
params={"$filter": f"Name eq '{group_name}'"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
groups = resp.json().get("value", [])
|
||||||
|
if not groups:
|
||||||
|
logger.warning("Group '%s' not found in 3CX", group_name)
|
||||||
|
return []
|
||||||
|
|
||||||
|
group_id = groups[0]["Id"]
|
||||||
|
|
||||||
|
# Fetch members of that group
|
||||||
|
resp = self.session.get(f"{self.base_url}/xapi/v1/Groups({group_id})/Members")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("value", [])
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -122,6 +122,56 @@ Resources:
|
||||||
Description: "Post weekly schedule Monday 7am EDT"
|
Description: "Post weekly schedule Monday 7am EDT"
|
||||||
Enabled: true
|
Enabled: true
|
||||||
|
|
||||||
|
# --- Roster Sync Lambda (daily sync from 3CX) ---
|
||||||
|
RosterSyncFunction:
|
||||||
|
Type: AWS::Serverless::Function
|
||||||
|
Properties:
|
||||||
|
FunctionName: afterhours-roster-sync
|
||||||
|
Handler: src/roster_sync.handler
|
||||||
|
CodeUri: .
|
||||||
|
Timeout: 60
|
||||||
|
Environment:
|
||||||
|
Variables:
|
||||||
|
SHIFT_TABLE: !Ref ShiftTable
|
||||||
|
TCX_SSM_PREFIX: /3cx-scheduler
|
||||||
|
SYNC_GROUP: DEFAULT
|
||||||
|
TZ: !Ref Timezone
|
||||||
|
Policies:
|
||||||
|
- DynamoDBCrudPolicy:
|
||||||
|
TableName: !Ref ShiftTable
|
||||||
|
- Statement:
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- ssm:GetParametersByPath
|
||||||
|
- ssm:GetParameter
|
||||||
|
- ssm:GetParameters
|
||||||
|
Resource:
|
||||||
|
- !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/3cx-scheduler"
|
||||||
|
- !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/3cx-scheduler/*"
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- kms:Decrypt
|
||||||
|
Resource: "*"
|
||||||
|
Condition:
|
||||||
|
StringEquals:
|
||||||
|
"kms:ViaService": !Sub "ssm.${AWS::Region}.amazonaws.com"
|
||||||
|
Events:
|
||||||
|
# Daily at 6am ET (before the 7am schedule post and 8am 3CX scheduler)
|
||||||
|
# EST: 6am ET = 11:00 UTC (Nov-Mar)
|
||||||
|
RosterSyncEST:
|
||||||
|
Type: Schedule
|
||||||
|
Properties:
|
||||||
|
Schedule: cron(0 11 ? * * *)
|
||||||
|
Description: "Sync roster from 3CX at 6am EST"
|
||||||
|
Enabled: true
|
||||||
|
# EDT: 6am ET = 10:00 UTC (Mar-Nov)
|
||||||
|
RosterSyncEDT:
|
||||||
|
Type: Schedule
|
||||||
|
Properties:
|
||||||
|
Schedule: cron(0 10 ? * * *)
|
||||||
|
Description: "Sync roster from 3CX at 6am EDT"
|
||||||
|
Enabled: true
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
SlackBotApiUrl:
|
SlackBotApiUrl:
|
||||||
Description: URL for Slack app Request URL configuration
|
Description: URL for Slack app Request URL configuration
|
||||||
|
|
@ -130,3 +180,5 @@ Outputs:
|
||||||
Value: !Ref ShiftTable
|
Value: !Ref ShiftTable
|
||||||
SlackBotFunctionArn:
|
SlackBotFunctionArn:
|
||||||
Value: !GetAtt SlackBotFunction.Arn
|
Value: !GetAtt SlackBotFunction.Arn
|
||||||
|
RosterSyncFunctionArn:
|
||||||
|
Value: !GetAtt RosterSyncFunction.Arn
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue