mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-10-07 16:19:00 +00:00
- Slack bot token and signing secret now read from Secrets Manager - 3CX credentials (domain, client-id, client-secret) moved to Secrets Manager under afterhours-shift-manager/3cx-* prefix - Channel ID is now a non-secret CloudFormation parameter (ShiftChannel) - Add shared secrets.py helper for Secrets Manager reads - Remove SSM and KMS IAM policies, add secretsmanager:GetSecretValue
123 lines
3.9 KiB
Python
123 lines
3.9 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
|
|
|
|
from shared.three_cx_client import ThreeCXClient
|
|
from shared.schedule import ShiftSchedule
|
|
from shared.secrets import get_secret
|
|
|
|
logger = logging.getLogger()
|
|
logger.setLevel(logging.INFO)
|
|
|
|
EASTERN = ZoneInfo("America/New_York")
|
|
|
|
EXCLUDE_NAMES = {"Voicemail", "IVR", "Fax"}
|
|
|
|
|
|
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()
|
|
)
|
|
|
|
secret_prefix = os.environ["TCX_SECRET_PREFIX"]
|
|
client = ThreeCXClient(
|
|
domain=get_secret(f"{secret_prefix}domain"),
|
|
auth_mode="oauth",
|
|
client_id=get_secret(f"{secret_prefix}client-id"),
|
|
client_secret=get_secret(f"{secret_prefix}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
|