mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-10-04 10:12:05 +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:
|
ci:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main
|
||||||
with:
|
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."""
|
"""Slack Bolt app — /oncall command handlers and interactive actions."""
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import boto3
|
|
||||||
from slack_bolt import App
|
from slack_bolt import App
|
||||||
|
|
||||||
from shared.blocks import (
|
from shared.blocks import (
|
||||||
|
|
@ -17,7 +15,9 @@ from shared.blocks import (
|
||||||
build_shift_change_message,
|
build_shift_change_message,
|
||||||
build_week_schedule,
|
build_week_schedule,
|
||||||
)
|
)
|
||||||
|
from shared.ring_scheduler import update_ring_group
|
||||||
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
|
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
|
||||||
|
from shared.secrets import get_secret
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
EASTERN = ZoneInfo("America/New_York")
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
|
|
@ -65,21 +65,23 @@ def parse_date(text: str) -> datetime | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def invoke_3cx_scheduler(extension: str) -> None:
|
def _update_3cx_routing(extension: str) -> None:
|
||||||
"""Invoke the 3CX scheduler Lambda to update the ring group immediately."""
|
"""Update the 3CX ring group to forward calls to the given extension."""
|
||||||
fn_name = os.environ.get("SCHEDULER_FUNCTION_NAME")
|
ring_group_number = os.environ.get("RING_GROUP_NUMBER")
|
||||||
if not fn_name:
|
secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
|
||||||
logger.warning("SCHEDULER_FUNCTION_NAME not set — skipping 3CX update")
|
if not ring_group_number or not secret_prefix:
|
||||||
|
logger.warning("3CX env vars not set — skipping ring group update")
|
||||||
return
|
return
|
||||||
|
try:
|
||||||
client = boto3.client("lambda")
|
update_ring_group(
|
||||||
payload = {"force": True, "override_extension": extension}
|
extension=extension,
|
||||||
logger.info("Invoking %s with %s", fn_name, payload)
|
ring_group_number=ring_group_number,
|
||||||
client.invoke(
|
domain=get_secret(f"{secret_prefix}domain"),
|
||||||
FunctionName=fn_name,
|
client_id=get_secret(f"{secret_prefix}client-id"),
|
||||||
InvocationType="Event", # async — don't wait
|
client_secret=get_secret(f"{secret_prefix}client-secret"),
|
||||||
Payload=json.dumps(payload),
|
)
|
||||||
)
|
except Exception:
|
||||||
|
logger.exception("Failed to update 3CX ring group")
|
||||||
|
|
||||||
|
|
||||||
def is_today(date_str: str) -> bool:
|
def is_today(date_str: str) -> bool:
|
||||||
|
|
@ -172,7 +174,7 @@ def create_app(
|
||||||
return
|
return
|
||||||
|
|
||||||
if is_today(date_str) and shift_type == "night":
|
if is_today(date_str) and shift_type == "night":
|
||||||
invoke_3cx_scheduler(employee["extension"])
|
_update_3cx_routing(employee["extension"])
|
||||||
|
|
||||||
blocks = build_shift_change_message(
|
blocks = build_shift_change_message(
|
||||||
user_id, date_str, "picked_up", employee["extension"], employee["name"]
|
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"])
|
schedule.set_override(date_str, employee["extension"], employee["name"])
|
||||||
|
|
||||||
if is_today(date_str):
|
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')}*.")
|
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)
|
schedule.mark_open(date_str)
|
||||||
|
|
||||||
if is_today(date_str):
|
if is_today(date_str):
|
||||||
invoke_3cx_scheduler(FALLBACK_EXTENSION)
|
_update_3cx_routing(FALLBACK_EXTENSION)
|
||||||
|
|
||||||
respond(
|
respond(
|
||||||
text=f"You dropped the shift for *{date.strftime('%A, %b %-d')}*. It's now open for pickup."
|
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"])
|
schedule.set_override(date_str, target["extension"], target["name"])
|
||||||
|
|
||||||
if is_today(date_str):
|
if is_today(date_str):
|
||||||
invoke_3cx_scheduler(target["extension"])
|
_update_3cx_routing(target["extension"])
|
||||||
|
|
||||||
blocks = build_shift_change_message(
|
blocks = build_shift_change_message(
|
||||||
target.get("slack_user_id", user_id),
|
target.get("slack_user_id", user_id),
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ Parameters:
|
||||||
ShiftChannel:
|
ShiftChannel:
|
||||||
Type: String
|
Type: String
|
||||||
Description: Slack channel ID for schedule posts and shift notifications
|
Description: Slack channel ID for schedule posts and shift notifications
|
||||||
SchedulerFunctionName:
|
RingGroupNumber:
|
||||||
Type: String
|
Type: String
|
||||||
Default: "3cx-ring-group-scheduler"
|
Default: "800"
|
||||||
Description: Name of the existing 3CX ring group scheduler Lambda
|
Description: 3CX ring group extension number to update
|
||||||
|
|
||||||
Globals:
|
Globals:
|
||||||
Function:
|
Function:
|
||||||
|
|
@ -69,7 +69,8 @@ Resources:
|
||||||
SLACK_BOT_TOKEN_SECRET: afterhours-shift-manager/slack-bot-token
|
SLACK_BOT_TOKEN_SECRET: afterhours-shift-manager/slack-bot-token
|
||||||
SLACK_SIGNING_SECRET: afterhours-shift-manager/slack-signing-secret
|
SLACK_SIGNING_SECRET: afterhours-shift-manager/slack-signing-secret
|
||||||
SHIFT_CHANNEL: !Ref ShiftChannel
|
SHIFT_CHANNEL: !Ref ShiftChannel
|
||||||
SCHEDULER_FUNCTION_NAME: !Ref SchedulerFunctionName
|
TCX_SECRET_PREFIX: afterhours-shift-manager/3cx-
|
||||||
|
RING_GROUP_NUMBER: !Ref RingGroupNumber
|
||||||
TZ: !Ref Timezone
|
TZ: !Ref Timezone
|
||||||
Policies:
|
Policies:
|
||||||
- DynamoDBCrudPolicy:
|
- DynamoDBCrudPolicy:
|
||||||
|
|
@ -80,11 +81,6 @@ Resources:
|
||||||
- secretsmanager:GetSecretValue
|
- secretsmanager:GetSecretValue
|
||||||
Resource:
|
Resource:
|
||||||
- !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:afterhours-shift-manager/*"
|
- !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:
|
Events:
|
||||||
SlackEvents:
|
SlackEvents:
|
||||||
Type: HttpApi
|
Type: HttpApi
|
||||||
|
|
@ -182,6 +178,62 @@ Resources:
|
||||||
Description: "Sync roster from 3CX at 6am EDT"
|
Description: "Sync roster from 3CX at 6am EDT"
|
||||||
Enabled: true
|
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) ---
|
# --- CloudWatch Log Groups (explicit 60-day retention) ---
|
||||||
SlackBotLogGroup:
|
SlackBotLogGroup:
|
||||||
Type: AWS::Logs::LogGroup
|
Type: AWS::Logs::LogGroup
|
||||||
|
|
@ -201,6 +253,12 @@ Resources:
|
||||||
LogGroupName: !Sub "/aws/lambda/${RosterSyncFunction}"
|
LogGroupName: !Sub "/aws/lambda/${RosterSyncFunction}"
|
||||||
RetentionInDays: 60
|
RetentionInDays: 60
|
||||||
|
|
||||||
|
RingSchedulerLogGroup:
|
||||||
|
Type: AWS::Logs::LogGroup
|
||||||
|
Properties:
|
||||||
|
LogGroupName: !Sub "/aws/lambda/${RingSchedulerFunction}"
|
||||||
|
RetentionInDays: 60
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
SlackBotApiUrl:
|
SlackBotApiUrl:
|
||||||
Description: URL for Slack app Request URL configuration
|
Description: URL for Slack app Request URL configuration
|
||||||
|
|
@ -213,3 +271,5 @@ Outputs:
|
||||||
Value: !GetAtt WeeklyPostFunction.Arn
|
Value: !GetAtt WeeklyPostFunction.Arn
|
||||||
RosterSyncFunctionArn:
|
RosterSyncFunctionArn:
|
||||||
Value: !GetAtt RosterSyncFunction.Arn
|
Value: !GetAtt RosterSyncFunction.Arn
|
||||||
|
RingSchedulerFunctionArn:
|
||||||
|
Value: !GetAtt RingSchedulerFunction.Arn
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue