mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 10:13:11 +00:00
124 lines
4.1 KiB
Python
124 lines
4.1 KiB
Python
"""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
|
|
force = event.get("force", False)
|
|
if not force and 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
|