From b137334c5be1ebd567269898b38eefd8d5b825bc Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 15:44:12 -0400 Subject: [PATCH] Merge ring-scheduler-3cx as 4th Lambda function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add afterhours-ring-scheduler Lambda with 4 EventBridge rules (daily 8am EST/EDT + weekend 5pm EST/EDT) for 3CX ring group routing updates - Extract shared ring_scheduler.py module for direct ring group updates from both the scheduled Lambda and the Slack bot - Replace cross-Lambda invoke with direct update_ring_group() call in the Slack bot — eliminates lambda:InvokeFunction dependency - Use RingGroup API (correct) instead of Queue API (was wrong in the original ring-scheduler repo) - Eliminate YAML config fallback — DynamoDB is the sole schedule source - Add RingGroupNumber CloudFormation parameter --- .github/workflows/ci.yaml | 2 +- src/ring-scheduler/app.py | 73 ++++++++++++++++++++ src/ring-scheduler/requirements.txt | 2 + src/shared/python/shared/ring_scheduler.py | 33 +++++++++ src/slack-bot/app.py | 42 ++++++------ template.yaml | 78 +++++++++++++++++++--- 6 files changed, 200 insertions(+), 30 deletions(-) create mode 100644 src/ring-scheduler/app.py create mode 100644 src/ring-scheduler/requirements.txt create mode 100644 src/shared/python/shared/ring_scheduler.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2b6dbbc..4fc0c3d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,4 +7,4 @@ jobs: ci: uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main with: - source-dirs: "src/slack-bot src/weekly-post src/roster-sync src/shared/python/shared" + source-dirs: "src/slack-bot src/weekly-post src/roster-sync src/ring-scheduler src/shared/python/shared" diff --git a/src/ring-scheduler/app.py b/src/ring-scheduler/app.py new file mode 100644 index 0000000..b05d592 --- /dev/null +++ b/src/ring-scheduler/app.py @@ -0,0 +1,73 @@ +"""Lambda handler — updates 3CX ring group routing based on who's on shift. + +Triggered by EventBridge at 8am ET daily and 5pm ET on weekends. +Also invoked directly by the Slack bot for same-day shift changes. +""" + +import json +import logging +import os +from datetime import datetime +from zoneinfo import ZoneInfo + +from shared.ring_scheduler import update_ring_group +from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule +from shared.secrets import get_secret + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +EASTERN = ZoneInfo("America/New_York") +WEEKEND_DAYS = {"Saturday", "Sunday"} + + +def determine_shift_type(now: datetime) -> str: + day_name = now.strftime("%A") + if day_name in WEEKEND_DAYS and now.hour < 17: + return "day" + return "night" + + +def handler(event, context): + now = datetime.now(EASTERN) + current_hour = now.hour + day_name = now.strftime("%A") + logger.info("Running ring group scheduler at %s", now.isoformat()) + + force = event.get("force", False) + valid_hours = {8, 17} if day_name in WEEKEND_DAYS else {8} + if not force and current_hour not in valid_hours: + logger.info( + "ET hour is %d on %s — skipping (wrong DST rule fired)", + current_hour, + day_name, + ) + return {"skipped": True} + + shift_type = determine_shift_type(now) + + if event.get("override_extension"): + extension = event["override_extension"] + else: + schedule = ShiftSchedule() + date_str = now.strftime("%Y-%m-%d") + ext, _name, source = schedule.resolve_shift(date_str, day_name, shift_type) + extension = ext if source != "available" else FALLBACK_EXTENSION + + secret_prefix = os.environ["TCX_SECRET_PREFIX"] + result = update_ring_group( + extension=extension, + ring_group_number=os.environ["RING_GROUP_NUMBER"], + domain=get_secret(f"{secret_prefix}domain"), + client_id=get_secret(f"{secret_prefix}client-id"), + client_secret=get_secret(f"{secret_prefix}client-secret"), + ) + result.update( + { + "date": now.strftime("%Y-%m-%d"), + "day": day_name, + "shift_type": shift_type, + } + ) + logger.info("Result: %s", json.dumps(result)) + return result diff --git a/src/ring-scheduler/requirements.txt b/src/ring-scheduler/requirements.txt new file mode 100644 index 0000000..fc95321 --- /dev/null +++ b/src/ring-scheduler/requirements.txt @@ -0,0 +1,2 @@ +boto3>=1.43.6 +requests>=2.33.1 diff --git a/src/shared/python/shared/ring_scheduler.py b/src/shared/python/shared/ring_scheduler.py new file mode 100644 index 0000000..cee3bd2 --- /dev/null +++ b/src/shared/python/shared/ring_scheduler.py @@ -0,0 +1,33 @@ +"""Core ring group routing logic shared between the scheduled Lambda and the Slack bot.""" + +import logging + +from shared.three_cx_client import ThreeCXClient + +logger = logging.getLogger(__name__) + + +def update_ring_group( + extension: str, + ring_group_number: str, + domain: str, + client_id: str, + client_secret: str, +) -> dict: + """Update 3CX ring group forwarding to route calls to the given extension.""" + client = ThreeCXClient( + domain=domain, + auth_mode="oauth", + client_id=client_id, + client_secret=client_secret, + ) + ring_group = client.get_ring_group(ring_group_number) + client.update_ring_group_forwarding( + ring_group_id=ring_group["Id"], + closed_destination=extension, + holiday_destination=extension, + ) + logger.info( + "Updated ring group %s to forward to Ext %s", ring_group_number, extension + ) + return {"extension": extension, "ring_group": ring_group_number} diff --git a/src/slack-bot/app.py b/src/slack-bot/app.py index 3ff804c..497e043 100644 --- a/src/slack-bot/app.py +++ b/src/slack-bot/app.py @@ -1,13 +1,11 @@ """Slack Bolt app — /oncall command handlers and interactive actions.""" -import json import logging import os import re from datetime import datetime, timedelta from zoneinfo import ZoneInfo -import boto3 from slack_bolt import App from shared.blocks import ( @@ -17,7 +15,9 @@ from shared.blocks import ( build_shift_change_message, build_week_schedule, ) +from shared.ring_scheduler import update_ring_group from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule +from shared.secrets import get_secret logger = logging.getLogger(__name__) EASTERN = ZoneInfo("America/New_York") @@ -65,21 +65,23 @@ def parse_date(text: str) -> datetime | None: return None -def invoke_3cx_scheduler(extension: str) -> None: - """Invoke the 3CX scheduler Lambda to update the ring group immediately.""" - fn_name = os.environ.get("SCHEDULER_FUNCTION_NAME") - if not fn_name: - logger.warning("SCHEDULER_FUNCTION_NAME not set — skipping 3CX update") +def _update_3cx_routing(extension: str) -> None: + """Update the 3CX ring group to forward calls to the given extension.""" + ring_group_number = os.environ.get("RING_GROUP_NUMBER") + secret_prefix = os.environ.get("TCX_SECRET_PREFIX") + if not ring_group_number or not secret_prefix: + logger.warning("3CX env vars not set — skipping ring group update") return - - client = boto3.client("lambda") - payload = {"force": True, "override_extension": extension} - logger.info("Invoking %s with %s", fn_name, payload) - client.invoke( - FunctionName=fn_name, - InvocationType="Event", # async — don't wait - Payload=json.dumps(payload), - ) + try: + update_ring_group( + extension=extension, + ring_group_number=ring_group_number, + domain=get_secret(f"{secret_prefix}domain"), + client_id=get_secret(f"{secret_prefix}client-id"), + client_secret=get_secret(f"{secret_prefix}client-secret"), + ) + except Exception: + logger.exception("Failed to update 3CX ring group") def is_today(date_str: str) -> bool: @@ -172,7 +174,7 @@ def create_app( return if is_today(date_str) and shift_type == "night": - invoke_3cx_scheduler(employee["extension"]) + _update_3cx_routing(employee["extension"]) blocks = build_shift_change_message( user_id, date_str, "picked_up", employee["extension"], employee["name"] @@ -333,7 +335,7 @@ def create_app( schedule.set_override(date_str, employee["extension"], employee["name"]) if is_today(date_str): - invoke_3cx_scheduler(employee["extension"]) + _update_3cx_routing(employee["extension"]) respond(text=f"You picked up the shift for *{date.strftime('%A, %b %-d')}*.") @@ -380,7 +382,7 @@ def create_app( schedule.mark_open(date_str) if is_today(date_str): - invoke_3cx_scheduler(FALLBACK_EXTENSION) + _update_3cx_routing(FALLBACK_EXTENSION) respond( text=f"You dropped the shift for *{date.strftime('%A, %b %-d')}*. It's now open for pickup." @@ -445,7 +447,7 @@ def create_app( schedule.set_override(date_str, target["extension"], target["name"]) if is_today(date_str): - invoke_3cx_scheduler(target["extension"]) + _update_3cx_routing(target["extension"]) blocks = build_shift_change_message( target.get("slack_user_id", user_id), diff --git a/template.yaml b/template.yaml index 654b847..0d0ec56 100644 --- a/template.yaml +++ b/template.yaml @@ -9,10 +9,10 @@ Parameters: ShiftChannel: Type: String Description: Slack channel ID for schedule posts and shift notifications - SchedulerFunctionName: + RingGroupNumber: Type: String - Default: "3cx-ring-group-scheduler" - Description: Name of the existing 3CX ring group scheduler Lambda + Default: "800" + Description: 3CX ring group extension number to update Globals: Function: @@ -69,7 +69,8 @@ Resources: SLACK_BOT_TOKEN_SECRET: afterhours-shift-manager/slack-bot-token SLACK_SIGNING_SECRET: afterhours-shift-manager/slack-signing-secret SHIFT_CHANNEL: !Ref ShiftChannel - SCHEDULER_FUNCTION_NAME: !Ref SchedulerFunctionName + TCX_SECRET_PREFIX: afterhours-shift-manager/3cx- + RING_GROUP_NUMBER: !Ref RingGroupNumber TZ: !Ref Timezone Policies: - DynamoDBCrudPolicy: @@ -80,11 +81,6 @@ Resources: - secretsmanager:GetSecretValue Resource: - !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:afterhours-shift-manager/*" - - Effect: Allow - Action: - - lambda:InvokeFunction - Resource: - - !Sub "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${SchedulerFunctionName}" Events: SlackEvents: Type: HttpApi @@ -182,6 +178,62 @@ Resources: Description: "Sync roster from 3CX at 6am EDT" Enabled: true + # --- Ring Group Scheduler (daily 3CX routing updates) --- + RingSchedulerFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: afterhours-ring-scheduler + Handler: app.handler + CodeUri: src/ring-scheduler/ + Layers: + - !Ref SharedLayer + Timeout: 60 + Environment: + Variables: + SHIFT_TABLE: !Ref ShiftTable + TCX_SECRET_PREFIX: afterhours-shift-manager/3cx- + RING_GROUP_NUMBER: !Ref RingGroupNumber + TZ: !Ref Timezone + Policies: + - Statement: + - Effect: Allow + Action: + - dynamodb:GetItem + Resource: + - !GetAtt ShiftTable.Arn + - Effect: Allow + Action: + - secretsmanager:GetSecretValue + Resource: + - !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:afterhours-shift-manager/*" + Events: + # Daily at 8am ET — update after-hours routing + DailyScheduleEST: + Type: Schedule + Properties: + Schedule: cron(0 13 ? * * *) + Description: "Update 3CX ring group at 8am EST" + Enabled: true + DailyScheduleEDT: + Type: Schedule + Properties: + Schedule: cron(0 12 ? * * *) + Description: "Update 3CX ring group at 8am EDT" + Enabled: true + # Weekends at 5pm ET — switch to night shift person + WeekendEveningEST: + Type: Schedule + Properties: + Schedule: cron(0 22 ? * SAT,SUN *) + Description: "Update 3CX ring group at 5pm EST weekends" + Enabled: true + WeekendEveningEDT: + Type: Schedule + Properties: + Schedule: cron(0 21 ? * SAT,SUN *) + Description: "Update 3CX ring group at 5pm EDT weekends" + Enabled: true + # --- CloudWatch Log Groups (explicit 60-day retention) --- SlackBotLogGroup: Type: AWS::Logs::LogGroup @@ -201,6 +253,12 @@ Resources: LogGroupName: !Sub "/aws/lambda/${RosterSyncFunction}" RetentionInDays: 60 + RingSchedulerLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub "/aws/lambda/${RingSchedulerFunction}" + RetentionInDays: 60 + Outputs: SlackBotApiUrl: Description: URL for Slack app Request URL configuration @@ -213,3 +271,5 @@ Outputs: Value: !GetAtt WeeklyPostFunction.Arn RosterSyncFunctionArn: Value: !GetAtt RosterSyncFunction.Arn + RingSchedulerFunctionArn: + Value: !GetAtt RingSchedulerFunction.Arn