mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 10:13:11 +00:00
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:
parent
7979e2a4e7
commit
b137334c5b
6 changed files with 200 additions and 30 deletions
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
|
|
@ -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
73
src/ring-scheduler/app.py
Normal 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
|
||||
2
src/ring-scheduler/requirements.txt
Normal file
2
src/ring-scheduler/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
boto3>=1.43.6
|
||||
requests>=2.33.1
|
||||
33
src/shared/python/shared/ring_scheduler.py
Normal file
33
src/shared/python/shared/ring_scheduler.py
Normal 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}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue