Merge ring-scheduler-3cx as 4th Lambda function

- 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
This commit is contained in:
Adam Moussa 2026-05-12 15:44:12 -04:00
parent 7979e2a4e7
commit b137334c5b
6 changed files with 200 additions and 30 deletions

View file

@ -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"

73
src/ring-scheduler/app.py Normal file
View file

@ -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

View file

@ -0,0 +1,2 @@
boto3>=1.43.6
requests>=2.33.1

View file

@ -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}

View file

@ -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),

View file

@ -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