This commit is contained in:
Adam Moussa 2026-05-12 22:31:32 +00:00 • committed by GitHub
commit 25e390147f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 855 additions and 201 deletions

View file

@ -1,9 +1,16 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
directories:
- "/src/slack-bot"
- "/src/weekly-post"
- "/src/roster-sync"
- "/src/ring-scheduler"
- "/src/shared"
schedule:
interval: "weekly"
assignees:
- "amoussa1229"
groups:
minor-and-patch:
update-types:
@ -13,6 +20,8 @@ updates:
directory: "/"
schedule:
interval: "weekly"
assignees:
- "amoussa1229"
groups:
minor-and-patch:
update-types:

View file

@ -7,4 +7,4 @@ jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main
with:
source-dirs: "src"
source-dirs: "src/slack-bot src/weekly-post src/roster-sync src/ring-scheduler src/shared/python/shared"

View file

@ -19,3 +19,4 @@ jobs:
cfn-role-arn: arn:aws:iam::328440206208:role/github-cfn-execution-role
secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
parameter-overrides: ${{ secrets.SAM_PARAMETER_OVERRIDES }}

View file

@ -1,10 +1,12 @@
# After-Hours Shift Manager
Slack bot for managing after-hours on-call shifts at Sea Haven Industries. Employees can pick up, drop, and swap shifts directly from Slack. Changes automatically update 3CX ring group 800 routing.
Slack bot for managing after-hours on-call shifts at Sea Haven Industries. Employees can pick up, drop, and swap shifts directly from Slack. Changes automatically update 3CX queue routing via the integrated ring scheduler.
## How It Works
A recurring weekly schedule assigns employees to after-hours phone duty (5pm-8am). Any unassigned day shows as **Available** in Slack with a pickup button. When someone picks up or drops a shift for today, the 3CX phone system is updated immediately. Future changes take effect when the 3CX scheduler runs at 8am.
A recurring weekly schedule assigns employees to after-hours phone duty. Weekend shifts are split into Day (8am-5pm) and Night (5pm-8am). Any unassigned shift shows as **Available** in Slack with a pickup button. When someone picks up or drops a shift for today, the 3CX queue is updated immediately. Future changes take effect when the ring scheduler runs at 8am daily and 5pm on weekends.
The weekly schedule post is updated live when shifts change, and the previous week's post is automatically deleted when the new one goes out.
## Slack Commands
@ -23,15 +25,49 @@ A recurring weekly schedule assigns employees to after-hours phone duty (5pm-8am
| `/oncall rate <ext> <amount>` | Set a per-person shift rate |
| `/oncall help` | Show help |
### Admin Commands
Available to users listed in `admin_users` in the CONFIG record:
| Command | Description |
|---|---|
| `/oncall admin override <date> <ext>` | Assign a shift to an extension |
| `/oncall admin open <date>` | Mark a shift as open |
| `/oncall admin clear <date>` | Remove override (revert to weekly) |
| `/oncall admin roster add <ext> <name>` | Add an employee to the roster |
| `/oncall admin roster remove <ext>` | Remove an employee |
| `/oncall admin roster rename <ext> <name>` | Rename an employee |
Dates accept: `today`, `tomorrow`, `monday`-`sunday`, `4/5`, `2026-04-05`
## Architecture
- **Runtime**: Python 3.12 on AWS Lambda (via API Gateway)
- **Runtime**: Python 3.12 on AWS Lambda (arm64)
- **Data**: DynamoDB single-table (`afterhours-shifts`)
- **IaC**: AWS SAM (`template.yaml`)
- **IaC**: AWS SAM (`template.yaml`) with shared Lambda Layer
- **Slack**: Slack Bolt framework with `/oncall` slash command
- **3CX Integration**: Invokes the `3cx-ring-group-scheduler` Lambda for same-day changes; the scheduler reads DynamoDB for future dates
- **3CX Integration**: Queue routing updated directly via 3CX Queue XAPI
- **Secrets**: AWS Secrets Manager (`afterhours-shift-manager/*`)
### Lambda Functions
| Function | Trigger | Purpose |
|---|---|---|
| `afterhours-shift-manager` | API Gateway (POST /slack/events) | Slack bot — handles `/oncall` commands and interactive buttons |
| `afterhours-weekly-post` | EventBridge (Monday 7am ET) | Posts weekly schedule to Slack, sends pay report email |
| `afterhours-roster-sync` | EventBridge (daily 6am ET) | Syncs employee roster from 3CX |
| `afterhours-ring-scheduler` | EventBridge (daily 8am ET + weekend 5pm ET) | Updates 3CX queue routing based on who's on shift |
### Project Layout
```
src/
slack-bot/ Slack Bolt Lambda (handler + app)
weekly-post/ Monday schedule + pay post
roster-sync/ Daily 3CX roster sync
ring-scheduler/ 3CX queue routing updates
shared/ Lambda Layer (schedule, blocks, 3CX client, secrets)
```
### DynamoDB Schema
@ -42,12 +78,23 @@ Single table with `PK` / `SK` keys:
| `ROSTER` | `<extension>` | Employee: name, extension, slack_user_id |
| `WEEKLY` | `<DayName>` | Default weekly schedule: extension, name |
| `OVERRIDE` | `<YYYY-MM-DD>` | Date override from pickup/drop (or `OPEN`) |
| `SCHEDULE_POST` | `<channel_id>` | Current schedule message timestamp |
| `PAY` | `<YYYY-MM-DD>` | Weekly pay record (Monday date key) |
| `CONFIG` | `CONFIG` | Settings: shift_rate, fallback_extension |
| `CONFIG` | `CONFIG` | Settings: shift_rate, fallback_extension, admin_users |
### Secrets Manager
| Secret | Description |
|---|---|
| `afterhours-shift-manager/slack-bot-token` | Slack bot OAuth token (`xoxb-...`) |
| `afterhours-shift-manager/slack-signing-secret` | Slack app signing secret |
| `afterhours-shift-manager/3cx-domain` | 3CX FQDN (e.g. `company.3cx.us`) |
| `afterhours-shift-manager/3cx-client-id` | 3CX OAuth2 client ID |
| `afterhours-shift-manager/3cx-client-secret` | 3CX OAuth2 client secret |
## Deployment
Merges to `main` are automatically deployed via **CodePipeline + CodeBuild**. The pipeline stack (`afterhours-shift-manager-pipeline`) watches the GitHub repo and runs `sam build && sam package` then deploys via CloudFormation changeset.
Merges to `main` are automatically deployed via **GitHub Actions** using reusable SAM workflows from the Sea Haven org.
For manual deploys:
@ -57,35 +104,3 @@ sam deploy
```
See [SETUP.md](SETUP.md) for full deployment and Slack app creation instructions.
### Version Bumps
A GitHub Actions workflow runs daily at 6pm ET, collects all PRs merged since the last version tag, creates a semver patch bump (e.g. v1.6.0 → v1.6.1), updates the Slack changelog canvas, and posts a summary to the team channel. Minor bumps can be triggered manually via `workflow_dispatch`.
### SSM Parameters
| Parameter | Description |
|---|---|
| `/afterhours-shift-manager/slack-bot-token` | Slack bot OAuth token (`xoxb-...`) |
| `/afterhours-shift-manager/slack-signing-secret` | Slack app signing secret |
| `/afterhours-shift-manager/channel-id` | Slack channel ID for notifications |
## Weekly Auto-Post
Every Monday at 7am ET, the bot posts the week's schedule to the configured channel with pickup buttons for any available shifts.
## Pay Report Email
A weekly Bonus Pay Summary email is sent via SES to payroll@seahaven.com with a table showing each employee's name, rate, and total pay for the week.
### Lambda Functions
| Function | Trigger | Purpose |
|---|---|---|
| `afterhours-shift-manager` | API Gateway (POST /slack/events) | Slack bot — handles `/oncall` commands and interactive buttons |
| `afterhours-weekly-post` | EventBridge (Monday 7am ET) | Posts weekly schedule to Slack, sends pay report email |
| `afterhours-roster-sync` | EventBridge (daily 6am ET) | Syncs employee roster from 3CX |
## Related
- [3cx-ring-scheduler](https://github.com/Sea-Haven-Industries/3cx-ring-scheduler) — the Lambda that updates 3CX ring group routing daily

9
samconfig.toml.example Normal file
View file

@ -0,0 +1,9 @@
version = 0.1
[default.deploy.parameters]
stack_name = "afterhours-shift-manager"
resolve_s3 = true
s3_prefix = "afterhours-shift-manager"
region = "us-east-1"
capabilities = "CAPABILITY_IAM"
confirm_changeset = true

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

@ -0,0 +1,75 @@
"""Lambda handler — updates 3CX queue 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_queue_routing
from shared.schedule import (
FALLBACK_EXTENSION,
WEEKEND_DAYS,
ShiftSchedule,
determine_shift_type,
)
from shared.secrets import get_secret
logger = logging.getLogger()
logger.setLevel(logging.INFO)
EASTERN = ZoneInfo("America/New_York")
def handler(event, context):
now = datetime.now(EASTERN)
current_hour = now.hour
day_name = now.strftime("%A")
logger.info("Running queue 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"]
try:
result = update_queue_routing(
extension=extension,
queue_number=os.environ["QUEUE_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 queue routing")
return {"error": True, "date": now.strftime("%Y-%m-%d"), "day": day_name}
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

@ -10,31 +10,18 @@ import os
from datetime import datetime
from zoneinfo import ZoneInfo
import boto3
from src.three_cx_client import ThreeCXClient
from src.schedule import ShiftSchedule
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")
# System extensions to exclude from roster sync
EXCLUDE_NAMES = {"Voicemail", "IVR", "Fax"}
def get_3cx_credentials() -> dict:
ssm = boto3.client("ssm")
prefix = os.environ.get("TCX_SSM_PREFIX", "/3cx-scheduler")
params = ssm.get_parameters_by_path(Path=prefix, WithDecryption=True)
creds = {}
for p in params["Parameters"]:
key = p["Name"].split("/")[-1]
creds[key] = p["Value"]
return creds
def handler(event, context):
now = datetime.now(EASTERN)
@ -52,12 +39,12 @@ def handler(event, context):
"Starting roster sync from 3CX group '%s' at %s", group_name, now.isoformat()
)
creds = get_3cx_credentials()
secret_prefix = os.environ["TCX_SECRET_PREFIX"]
client = ThreeCXClient(
domain=creds["domain"],
domain=get_secret(f"{secret_prefix}domain"),
auth_mode="oauth",
client_id=creds["client_id"],
client_secret=creds["client_secret"],
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)

View file

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

View file

@ -8,6 +8,12 @@ EASTERN = ZoneInfo("America/New_York")
WEEKEND_DAYS = {"Saturday", "Sunday"}
SHIFT_LABELS = {
"day": "Day (8am–5pm)",
"night": "Night (5pm–8am)",
}
def _format_shift_line(
date: datetime,
ext: str,
@ -17,8 +23,8 @@ def _format_shift_line(
shift_type: str = "night",
) -> str:
day_label = date.strftime("%a %b %-d")
if shift_type == "day":
day_label += " (Day 8a–5p)"
if shift_type in SHIFT_LABELS:
day_label += f" {SHIFT_LABELS[shift_type]}"
if is_today:
day_label = f"*{day_label} (today)*"
@ -56,17 +62,19 @@ def build_week_schedule(schedule, start_date: datetime | None = None) -> list[di
day_name = date.strftime("%A")
is_today = date_str == today_str
is_past = date_str < today_str
# Weekend day shift (8am–5pm)
if day_name in WEEKEND_DAYS:
ext, name, source = schedule.resolve_shift(date_str, day_name, "day")
lines.append(_format_shift_line(date, ext, name, source, is_today, "day"))
if source == "available":
if source == "available" and not is_past:
open_shifts.append((date_str, "day"))
# After-hours (night) shift
ext, name, source = schedule.resolve_shift(date_str, day_name)
lines.append(_format_shift_line(date, ext, name, source, is_today))
if source == "available":
if source == "available" and not is_past:
open_shifts.append((date_str, "night"))
blocks = [
@ -89,14 +97,14 @@ def build_week_schedule(schedule, start_date: datetime | None = None) -> list[di
elements = []
for date_str, shift_type in open_shifts:
dt = datetime.strptime(date_str, "%Y-%m-%d")
suffix = " (Day)" if shift_type == "day" else ""
label = "Day" if shift_type == "day" else "Night"
action_suffix = "_day" if shift_type == "day" else ""
elements.append(
{
"type": "button",
"text": {
"type": "plain_text",
"text": f"Pick up {dt.strftime('%a %b %-d')}{suffix}",
"text": f"Pick up {dt.strftime('%a %b %-d')} {label}",
},
"action_id": f"pickup_{date_str}{action_suffix}",
"style": "primary",
@ -112,25 +120,35 @@ def build_week_schedule(schedule, start_date: datetime | None = None) -> list[di
def build_shift_change_message(
user_id: str, date_str: str, action: str, ext: str, name: str
user_id: str,
date_str: str,
action: str,
ext: str,
name: str,
shift_type: str = "night",
) -> list[dict]:
"""Build a channel notification for a shift change."""
dt = datetime.strptime(date_str, "%Y-%m-%d")
day_label = dt.strftime("%A, %b %-d")
type_label = (
f" ({SHIFT_LABELS.get(shift_type, shift_type)})"
if dt.strftime("%A") in WEEKEND_DAYS
else ""
)
if action == "picked_up":
text = f":white_check_mark: <@{user_id}> picked up the shift for *{day_label}* (Ext {ext})"
text = f":white_check_mark: <@{user_id}> picked up the *{day_label}*{type_label} shift (Ext {ext})"
elif action == "dropped":
text = f":warning: <@{user_id}> dropped the shift for *{day_label}* — it's now *Available*"
text = f":warning: <@{user_id}> dropped the *{day_label}*{type_label} shift — it's now *Available*"
elif action == "swapped":
text = f":arrows_counterclockwise: <@{user_id}> swapped into the shift for *{day_label}* (Ext {ext})"
text = f":arrows_counterclockwise: <@{user_id}> swapped into the *{day_label}*{type_label} shift (Ext {ext})"
else:
text = f"Shift updated for *{day_label}*: {name} (Ext {ext})"
text = f"Shift updated for *{day_label}*{type_label}: {name} (Ext {ext})"
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
def build_help_blocks() -> list[dict]:
def build_help_blocks(is_admin: bool = False) -> list[dict]:
"""Build help message blocks."""
text = (
"*After-Hours Shift Manager*\n\n"
@ -148,6 +166,16 @@ def build_help_blocks() -> list[dict]:
"`/oncall help` — Show this help message\n\n"
"_Dates can be: today, tomorrow, monday, friday, 4/5, 2026-04-05_"
)
if is_admin:
text += (
"\n\n*Admin Commands*\n"
"`/oncall admin override <date> <ext>` — Assign a shift to an extension\n"
"`/oncall admin open <date>` — Mark a shift as open\n"
"`/oncall admin clear <date>` — Remove override (revert to weekly)\n"
"`/oncall admin roster add <ext> <name>` — Add a roster entry\n"
"`/oncall admin roster remove <ext>` — Remove a roster entry\n"
"`/oncall admin roster rename <ext> <name>` — Rename an employee"
)
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]

View file

@ -0,0 +1,31 @@
"""Core queue 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_queue_routing(
extension: str,
queue_number: str,
domain: str,
client_id: str,
client_secret: str,
) -> dict:
"""Update 3CX queue forwarding to route calls to the given extension."""
client = ThreeCXClient(
domain=domain,
auth_mode="oauth",
client_id=client_id,
client_secret=client_secret,
)
queue = client.get_queue(queue_number)
client.update_queue_forwarding(
queue_id=queue["Id"],
closed_destination=extension,
holiday_destination=extension,
)
logger.info("Updated queue %s to forward to Ext %s", queue_number, extension)
return {"extension": extension, "queue": queue_number}

View file

@ -8,14 +8,30 @@ Single-table design:
"""
import os
from datetime import datetime
from zoneinfo import ZoneInfo
import boto3
from boto3.dynamodb.conditions import Key
EASTERN = ZoneInfo("America/New_York")
WEEKEND_DAYS = {"Saturday", "Sunday"}
FALLBACK_EXTENSION = "100"
def determine_shift_type(now: datetime | None = None) -> str:
"""Return the currently active shift type: 'day' or 'night'.
Weekend 8am-5pm is the day shift; all other times are night.
"""
if now is None:
now = datetime.now(EASTERN)
day_name = now.strftime("%A")
if day_name in WEEKEND_DAYS and 8 <= now.hour < 17:
return "day"
return "night"
class ShiftSchedule:
def __init__(self, table_name: str | None = None):
dynamodb = boto3.resource("dynamodb")
@ -142,6 +158,24 @@ class ShiftSchedule:
return (FALLBACK_EXTENSION, "Available", "available")
# ── Schedule post tracking ───────────────────────────────────────────
def get_schedule_post(self, channel_id: str) -> dict | None:
resp = self.table.get_item(Key={"PK": "SCHEDULE_POST", "SK": channel_id})
return resp.get("Item")
def save_schedule_post(
self, channel_id: str, message_ts: str, week_start: str
) -> None:
self.table.put_item(
Item={
"PK": "SCHEDULE_POST",
"SK": channel_id,
"message_ts": message_ts,
"week_start": week_start,
}
)
# ── Pay records ─────────────────────────────────────────────────────
def get_pay_record(self, week_key: str) -> dict | None:
@ -159,6 +193,38 @@ class ShiftSchedule:
resp = self.table.get_item(Key={"PK": "CONFIG", "SK": "CONFIG"})
return resp.get("Item", {})
def get_admin_users(self) -> list[str]:
config = self.get_config()
return config.get("admin_users", [])
def add_roster_entry(self, extension: str, name: str) -> bool:
"""Add a new roster entry. Returns False if extension already exists."""
try:
self.table.put_item(
Item={
"PK": "ROSTER",
"SK": extension,
"name": name,
"extension": extension,
"slack_user_id": "",
},
ConditionExpression="attribute_not_exists(PK)",
)
return True
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
return False
def remove_roster_entry(self, extension: str) -> None:
self.table.delete_item(Key={"PK": "ROSTER", "SK": extension})
def rename_roster_entry(self, extension: str, new_name: str) -> None:
self.table.update_item(
Key={"PK": "ROSTER", "SK": extension},
UpdateExpression="SET #n = :name",
ExpressionAttributeNames={"#n": "name"},
ExpressionAttributeValues={":name": new_name},
)
def set_default_shift_rate(self, rate: float) -> None:
"""Set the global default shift rate in config."""
self.table.update_item(

View file

@ -0,0 +1,16 @@
"""Fetch secrets from AWS Secrets Manager."""
import boto3
_client = None
def _get_client():
global _client
if _client is None:
_client = boto3.client("secretsmanager")
return _client
def get_secret(secret_id: str) -> str:
return _get_client().get_secret_value(SecretId=secret_id)["SecretString"]

View file

@ -98,13 +98,7 @@ class ThreeCXClient:
closed_destination: str,
holiday_destination: str,
):
"""Update the OutOfOfficeRoute and HolidaysRoute on a ring group.
Args:
ring_group_id: Numeric ID from the ring group entity
closed_destination: Extension number for after-hours routing
holiday_destination: Extension number for holiday routing
"""
"""Update the OutOfOfficeRoute and HolidaysRoute on a ring group."""
payload = {
"OutOfOfficeRoute": {
"IsPromptEnabled": False,
@ -135,3 +129,49 @@ class ThreeCXClient:
holiday_destination,
)
return resp.status_code
def get_queue(self, extension_number: str) -> dict:
"""Fetch queue config by extension number."""
resp = self.session.get(
f"{self.base_url}/xapi/v1/Queues/Pbx.GetByNumber(number='{extension_number}')",
)
resp.raise_for_status()
return resp.json()
def update_queue_forwarding(
self,
queue_id: int,
closed_destination: str,
holiday_destination: str,
):
"""Update the OutOfOfficeRoute and HolidaysRoute on a queue."""
payload = {
"OutOfOfficeRoute": {
"IsPromptEnabled": False,
"Route": {
"To": "Extension",
"Number": closed_destination,
"External": "",
},
},
"HolidaysRoute": {
"IsPromptEnabled": False,
"Route": {
"To": "Extension",
"Number": holiday_destination,
"External": "",
},
},
}
resp = self.session.patch(
f"{self.base_url}/xapi/v1/Queues({queue_id})",
json=payload,
)
resp.raise_for_status()
logger.info(
"Updated queue %s: closed->Ext %s, holiday->Ext %s",
queue_id,
closed_destination,
holiday_destination,
)
return resp.status_code

View file

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

View file

@ -1,23 +1,28 @@
"""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 src.blocks import (
from shared.blocks import (
build_help_blocks,
build_pay_summary_blocks,
build_roster_blocks,
build_shift_change_message,
build_week_schedule,
)
from src.schedule import FALLBACK_EXTENSION, ShiftSchedule
from shared.ring_scheduler import update_queue_routing
from shared.schedule import (
FALLBACK_EXTENSION,
WEEKEND_DAYS,
ShiftSchedule,
determine_shift_type,
)
from shared.secrets import get_secret
logger = logging.getLogger(__name__)
EASTERN = ZoneInfo("America/New_York")
@ -65,27 +70,58 @@ 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 queue to forward calls to the given extension."""
queue_number = os.environ.get("QUEUE_NUMBER")
secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
if not queue_number or not secret_prefix:
logger.warning("3CX env vars not set — skipping queue 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_queue_routing(
extension=extension,
queue_number=queue_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 queue")
def is_today(date_str: str) -> bool:
return date_str == datetime.now(EASTERN).strftime("%Y-%m-%d")
def _is_active_shift_type(shift_type: str) -> bool:
return determine_shift_type() == shift_type
def _shift_type_label(day_name: str, shift_type: str) -> str:
if day_name not in WEEKEND_DAYS:
return ""
label = "Day" if shift_type == "day" else "Night"
return f" ({label})"
def _find_employee_shift(schedule, date_str, day_name, employee_ext):
"""Find which shift type an employee is assigned to on a given date.
On weekends, checks both day and night shifts. Returns (ext, name, source, shift_type)
or None if not found on any shift.
"""
if day_name in WEEKEND_DAYS:
for st in ("day", "night"):
ext, name, source = schedule.resolve_shift(date_str, day_name, st)
if ext == employee_ext:
return ext, name, source, st
return None
ext, name, source = schedule.resolve_shift(date_str, day_name, "night")
if ext == employee_ext:
return ext, name, source, "night"
return None
def create_app(
bot_token: str, signing_secret: str, schedule_channel: str | None = None
) -> App:
@ -105,12 +141,14 @@ def create_app(
user_id = command["user_id"]
channel_id = command["channel_id"]
is_admin = user_id in schedule.get_admin_users()
if not text or text == "schedule":
_show_schedule(respond, schedule)
elif text == "next":
_show_next_week(respond, schedule)
elif text == "help":
respond(blocks=build_help_blocks())
respond(blocks=build_help_blocks(is_admin=is_admin))
elif text == "roster":
respond(blocks=build_roster_blocks(schedule.get_roster()))
elif text == "pay":
@ -131,6 +169,8 @@ def create_app(
_handle_swap(
respond, schedule, user_id, text, schedule_channel or channel_id, client
)
elif text.startswith("admin"):
_handle_admin(respond, schedule, user_id, text, is_admin, client)
else:
respond(text="Unknown command. Try `/oncall help`")
@ -151,6 +191,15 @@ def create_app(
user_id = body["user"]["id"]
channel_id = body["channel"]["id"]
today_str = datetime.now(EASTERN).strftime("%Y-%m-%d")
if date_str < today_str:
client.chat_postEphemeral(
channel=channel_id,
user=user_id,
text="That shift has already passed and can't be picked up.",
)
return
employee = schedule.get_employee_by_slack_id(user_id)
if not employee:
client.chat_postEphemeral(
@ -171,11 +220,16 @@ def create_app(
)
return
if is_today(date_str) and shift_type == "night":
invoke_3cx_scheduler(employee["extension"])
if is_today(date_str) and _is_active_shift_type(shift_type):
_update_3cx_routing(employee["extension"])
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"],
shift_type=shift_type,
)
respond(
response_type="in_channel",
@ -183,6 +237,29 @@ def create_app(
blocks=blocks,
text=f"Shift picked up for {date_str}",
)
_refresh_schedule_post(client)
def _refresh_schedule_post(client):
"""Update the pinned schedule message in-place after a shift change."""
channel = schedule_channel
if not channel:
return
post = schedule.get_schedule_post(channel)
if not post or not post.get("message_ts"):
return
try:
blocks = build_week_schedule(schedule)
now = datetime.now(EASTERN)
this_monday = now - timedelta(days=now.weekday())
end_date = this_monday + timedelta(days=13)
client.chat_update(
channel=channel,
ts=post["message_ts"],
blocks=blocks,
text=f"After-Hours Schedule — {this_monday.strftime('%b %-d')} to {end_date.strftime('%b %-d')}",
)
except Exception:
logger.warning("Could not update schedule post", exc_info=True)
# ── Subcommand handlers ─────────────────────────────────────────────
@ -320,8 +397,24 @@ def create_app(
return
date_str = date.strftime("%Y-%m-%d")
if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"):
respond(text="You can't pick up a shift in the past.")
return
day_name = date.strftime("%A")
ext, name, source = schedule.resolve_shift(date_str, day_name)
# On weekends, find the first available shift (day then night)
shift_type = "night"
if day_name in WEEKEND_DAYS:
for st in ("day", "night"):
ext, name, source = schedule.resolve_shift(date_str, day_name, st)
if source == "available":
shift_type = st
break
else:
ext, name, source = schedule.resolve_shift(date_str, day_name)
else:
ext, name, source = schedule.resolve_shift(date_str, day_name)
# Already assigned to someone else (not open)
if source in ("weekly", "override") and ext != FALLBACK_EXTENSION:
@ -330,15 +423,24 @@ def create_app(
)
return
schedule.set_override(date_str, employee["extension"], employee["name"])
schedule.set_override(
date_str, employee["extension"], employee["name"], shift_type
)
if is_today(date_str):
invoke_3cx_scheduler(employee["extension"])
if is_today(date_str) and _is_active_shift_type(shift_type):
_update_3cx_routing(employee["extension"])
respond(text=f"You picked up the shift for *{date.strftime('%A, %b %-d')}*.")
date_label = date.strftime("%A, %b %-d")
shift_label = _shift_type_label(day_name, shift_type)
respond(text=f"You picked up the shift for *{date_label}*{shift_label}.")
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"],
shift_type=shift_type,
)
try:
client.chat_postMessage(
@ -348,6 +450,7 @@ def create_app(
)
except Exception:
logger.exception("Failed to post pickup notification to channel")
_refresh_schedule_post(client)
def _handle_drop(respond, schedule, user_id, text, channel_id, client):
parts = text.split(maxsplit=1)
@ -370,29 +473,45 @@ def create_app(
return
date_str = date.strftime("%Y-%m-%d")
day_name = date.strftime("%A")
ext, name, source = schedule.resolve_shift(date_str, day_name)
if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"):
respond(text="You can't drop a shift in the past.")
return
if ext != employee["extension"]:
day_name = date.strftime("%A")
found = _find_employee_shift(
schedule, date_str, day_name, employee["extension"]
)
if not found:
ext, name, _source = schedule.resolve_shift(date_str, day_name)
respond(text=f"That's not your shift — it belongs to {name} (Ext {ext}).")
return
schedule.mark_open(date_str)
ext, name, source, shift_type = found
schedule.mark_open(date_str, shift_type)
if is_today(date_str):
invoke_3cx_scheduler(FALLBACK_EXTENSION)
if is_today(date_str) and _is_active_shift_type(shift_type):
_update_3cx_routing(FALLBACK_EXTENSION)
date_label = date.strftime("%A, %b %-d")
shift_label = _shift_type_label(day_name, shift_type)
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_label}*{shift_label}. "
"It's now open for pickup."
)
)
blocks = build_shift_change_message(user_id, date_str, "dropped", ext, name)
blocks = build_shift_change_message(
user_id, date_str, "dropped", ext, name, shift_type=shift_type
)
try:
client.chat_postMessage(
channel=channel_id, blocks=blocks, text=f"Shift dropped for {date_str}"
)
except Exception:
logger.exception("Failed to post drop notification to channel")
_refresh_schedule_post(client)
def _handle_swap(respond, schedule, user_id, text, channel_id, client):
# Expected format: swap <date> @user OR swap <date> <extension>
@ -416,15 +535,24 @@ def create_app(
return
date_str = date.strftime("%Y-%m-%d")
day_name = date.strftime("%A")
ext, name, source = schedule.resolve_shift(date_str, day_name)
if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"):
respond(text="You can't swap a shift in the past.")
return
if ext != employee["extension"]:
day_name = date.strftime("%A")
found = _find_employee_shift(
schedule, date_str, day_name, employee["extension"]
)
if not found:
ext, name, _source = schedule.resolve_shift(date_str, day_name)
respond(
text=f"That's not your shift — it belongs to {name} (Ext {ext}). You can only swap your own shifts."
)
return
_ext, _name, _source, shift_type = found
# Resolve target user — could be <@U12345> or an extension number
target_text = parts[2].strip()
slack_id_match = re.match(r"<@(\w+)(?:\|[^>]*)?>", target_text)
@ -442,10 +570,10 @@ def create_app(
respond(text=f"Extension `{target_text}` not found in the roster.")
return
schedule.set_override(date_str, target["extension"], target["name"])
schedule.set_override(date_str, target["extension"], target["name"], shift_type)
if is_today(date_str):
invoke_3cx_scheduler(target["extension"])
if is_today(date_str) and _is_active_shift_type(shift_type):
_update_3cx_routing(target["extension"])
blocks = build_shift_change_message(
target.get("slack_user_id", user_id),
@ -453,9 +581,15 @@ def create_app(
"swapped",
target["extension"],
target["name"],
shift_type=shift_type,
)
date_label = date.strftime("%A, %b %-d")
shift_label = _shift_type_label(day_name, shift_type)
respond(
text=f"Swapped *{date.strftime('%A, %b %-d')}* to {target['name']} (Ext {target['extension']})."
text=(
f"Swapped *{date_label}*{shift_label} to {target['name']} "
f"(Ext {target['extension']})."
)
)
try:
@ -464,5 +598,159 @@ def create_app(
)
except Exception:
logger.exception("Failed to post swap notification to channel")
_refresh_schedule_post(client)
def _handle_admin(respond, schedule, user_id, text, is_admin, client):
if not is_admin:
respond(text="Admin commands are restricted. Contact an administrator.")
return
parts = text.split()
if len(parts) < 2:
respond(
text=(
"*Admin Commands:*\n"
"`admin override <date> <ext> [day|night]` — Assign shift\n"
"`admin open <date> [day|night]` — Mark open\n"
"`admin clear <date> [day|night]` — Remove override\n"
"`admin roster add <ext> <name>` — Add employee\n"
"`admin roster remove <ext>` — Remove employee\n"
"`admin roster rename <ext> <name>` — Rename"
)
)
return
subcmd = parts[1]
if subcmd == "override":
if len(parts) < 4:
respond(
text="Usage: `/oncall admin override <date> <extension> [day|night]`"
)
return
date = parse_date(parts[2])
if not date:
respond(text=f"Couldn't parse date: `{parts[2]}`")
return
ext = parts[3]
shift_type = (
parts[4] if len(parts) > 4 and parts[4] in ("day", "night") else "night"
)
employee = schedule.get_employee_by_extension(ext)
if not employee:
respond(text=f"Extension `{ext}` not found in the roster.")
return
date_str = date.strftime("%Y-%m-%d")
schedule.set_override(
date_str, employee["extension"], employee["name"], shift_type
)
if is_today(date_str) and _is_active_shift_type(shift_type):
_update_3cx_routing(employee["extension"])
label = "Day" if shift_type == "day" else "Night"
respond(
text=f"Override set: *{date.strftime('%A, %b %-d')}* ({label}) → {employee['name']} (Ext {ext})"
)
_refresh_schedule_post(client)
elif subcmd == "open":
if len(parts) < 3:
respond(text="Usage: `/oncall admin open <date> [day|night]`")
return
date = parse_date(parts[2])
if not date:
respond(text=f"Couldn't parse date: `{parts[2]}`")
return
shift_type = (
parts[3] if len(parts) > 3 and parts[3] in ("day", "night") else "night"
)
date_str = date.strftime("%Y-%m-%d")
schedule.mark_open(date_str, shift_type)
if is_today(date_str) and _is_active_shift_type(shift_type):
_update_3cx_routing(FALLBACK_EXTENSION)
label = "Day" if shift_type == "day" else "Night"
respond(text=f"*{date.strftime('%A, %b %-d')}* ({label}) marked as open.")
_refresh_schedule_post(client)
elif subcmd == "clear":
if len(parts) < 3:
respond(text="Usage: `/oncall admin clear <date> [day|night]`")
return
date = parse_date(parts[2])
if not date:
respond(text=f"Couldn't parse date: `{parts[2]}`")
return
shift_type = (
parts[3] if len(parts) > 3 and parts[3] in ("day", "night") else "night"
)
date_str = date.strftime("%Y-%m-%d")
schedule.remove_override(date_str, shift_type)
if is_today(date_str) and _is_active_shift_type(shift_type):
day_name = date.strftime("%A")
ext, _name, _source = schedule.resolve_shift(
date_str, day_name, shift_type
)
_update_3cx_routing(ext)
label = "Day" if shift_type == "day" else "Night"
respond(
text=f"Override cleared for *{date.strftime('%A, %b %-d')}* ({label}) — reverted to weekly schedule."
)
_refresh_schedule_post(client)
elif subcmd == "roster":
if len(parts) < 3:
respond(text="Usage: `admin roster add|remove|rename <ext> [name]`")
return
roster_cmd = parts[2]
if roster_cmd == "add":
if len(parts) < 5:
respond(text="Usage: `/oncall admin roster add <ext> <name>`")
return
ext = parts[3]
name = " ".join(parts[4:])
added = schedule.add_roster_entry(ext, name)
if not added:
respond(
text=f"Extension `{ext}` already exists. Use `roster rename` to change the name."
)
return
respond(text=f"Added *{name}* (Ext {ext}) to the roster.")
elif roster_cmd == "remove":
if len(parts) < 4:
respond(text="Usage: `/oncall admin roster remove <ext>`")
return
ext = parts[3]
employee = schedule.get_employee_by_extension(ext)
if not employee:
respond(text=f"Extension `{ext}` not found in the roster.")
return
schedule.remove_roster_entry(ext)
respond(
text=f"Removed *{employee.get('name', ext)}* (Ext {ext}) from the roster."
)
elif roster_cmd == "rename":
if len(parts) < 5:
respond(text="Usage: `/oncall admin roster rename <ext> <name>`")
return
ext = parts[3]
employee = schedule.get_employee_by_extension(ext)
if not employee:
respond(text=f"Extension `{ext}` not found in the roster.")
return
new_name = " ".join(parts[4:])
schedule.rename_roster_entry(ext, new_name)
respond(
text=f"Renamed Ext {ext}: {employee.get('name', '?')} → *{new_name}*"
)
else:
respond(
text="Unknown roster command. Use `add`, `remove`, or `rename`."
)
else:
respond(text=f"Unknown admin command: `{subcmd}`. Try `/oncall help`.")
return app

View file

@ -3,10 +3,10 @@
import logging
import os
import boto3
from slack_bolt.adapter.aws_lambda import SlackRequestHandler
from src.app import create_app
from app import create_app
from shared.secrets import get_secret
logger = logging.getLogger()
logger.setLevel(logging.INFO)
@ -16,23 +16,15 @@ logging.basicConfig(
format="%(asctime)s %(levelname)s %(name)s: %(message)s", level=logging.INFO
)
# Lazy-initialized app singleton
_slack_handler = None
def _get_handler() -> SlackRequestHandler:
global _slack_handler
if _slack_handler is None:
ssm = boto3.client("ssm")
bot_token = ssm.get_parameter(
Name=os.environ["SLACK_BOT_TOKEN_PARAM"], WithDecryption=True
)["Parameter"]["Value"]
signing_secret = ssm.get_parameter(
Name=os.environ["SLACK_SIGNING_SECRET_PARAM"], WithDecryption=True
)["Parameter"]["Value"]
schedule_channel = ssm.get_parameter(
Name=os.environ["SHIFT_CHANNEL_PARAM"], WithDecryption=True
)["Parameter"]["Value"]
bot_token = get_secret(os.environ["SLACK_BOT_TOKEN_SECRET"])
signing_secret = get_secret(os.environ["SLACK_SIGNING_SECRET"])
schedule_channel = os.environ["SHIFT_CHANNEL"]
app = create_app(bot_token, signing_secret, schedule_channel=schedule_channel)
_slack_handler = SlackRequestHandler(app=app)

View file

@ -1,3 +1,2 @@
slack_bolt>=1.28.0,<2.0
boto3>=1.43.6
requests>=2.33.1

View file

@ -10,8 +10,9 @@ from zoneinfo import ZoneInfo
import boto3
from slack_sdk import WebClient
from src.blocks import build_pay_summary_blocks, build_week_schedule
from src.schedule import FALLBACK_EXTENSION, ShiftSchedule
from shared.blocks import build_pay_summary_blocks, build_week_schedule
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
from shared.secrets import get_secret
logger = logging.getLogger()
logger.setLevel(logging.INFO)
@ -147,13 +148,8 @@ def handler(event, context):
)
return {"skipped": True}
ssm = boto3.client("ssm")
bot_token = ssm.get_parameter(
Name=os.environ["SLACK_BOT_TOKEN_PARAM"], WithDecryption=True
)["Parameter"]["Value"]
channel_id = ssm.get_parameter(
Name=os.environ["SHIFT_CHANNEL_PARAM"], WithDecryption=True
)["Parameter"]["Value"]
bot_token = get_secret(os.environ["SLACK_BOT_TOKEN_SECRET"])
channel_id = os.environ["SHIFT_CHANNEL"]
schedule = ShiftSchedule()
slack = WebClient(token=bot_token)
@ -193,20 +189,35 @@ def handler(event, context):
# Email pay summary to payroll
_send_pay_email(week_label, pay_record)
# --- Delete previous week's schedule post ---
old_post = schedule.get_schedule_post(channel_id)
if old_post and old_post.get("message_ts"):
try:
slack.chat_delete(channel=channel_id, ts=old_post["message_ts"])
logger.info("Deleted previous schedule post %s", old_post["message_ts"])
except Exception:
logger.warning("Could not delete old schedule post", exc_info=True)
# --- Two-week schedule (always starts on Monday of this week) ---
this_monday = now - timedelta(days=now.weekday())
blocks = build_week_schedule(schedule, start_date=this_monday)
end_date = this_monday + timedelta(days=13)
slack.chat_postMessage(
result = slack.chat_postMessage(
channel=channel_id,
blocks=blocks,
text=f"After-Hours Schedule — {now.strftime('%b %-d')} to {end_date.strftime('%b %-d')}",
text=f"After-Hours Schedule — {this_monday.strftime('%b %-d')} to {end_date.strftime('%b %-d')}",
)
logger.info("Posted weekly schedule to channel %s", channel_id)
schedule.save_schedule_post(
channel_id, result["ts"], this_monday.strftime("%Y-%m-%d")
)
logger.info(
"Posted weekly schedule to channel %s (ts=%s)", channel_id, result["ts"]
)
return {
"posted": True,
"channel": channel_id,
"message_ts": result["ts"],
"pay_calculated": bool(pay_record["breakdown"]),
}

View file

@ -0,0 +1,2 @@
slack_sdk>=3.33.0,<4.0
boto3>=1.43.6

View file

@ -6,18 +6,37 @@ Parameters:
Timezone:
Type: String
Default: "America/New_York"
SchedulerFunctionName:
ShiftChannel:
Type: String
Default: "3cx-ring-group-scheduler"
Description: Name of the existing 3CX ring group scheduler Lambda
Description: Slack channel ID for schedule posts and shift notifications
QueueNumber:
Type: String
Default: "801"
Description: 3CX queue extension number to update
Globals:
Function:
Runtime: python3.12
Timeout: 30
MemorySize: 1024
Architectures:
- arm64
Resources:
# --- Shared Lambda Layer ---
SharedLayer:
Type: AWS::Serverless::LayerVersion
Properties:
LayerName: afterhours-shared
ContentUri: src/shared/
CompatibleRuntimes:
- python3.12
CompatibleArchitectures:
- arm64
Metadata:
BuildMethod: python3.12
BuildArchitecture: arm64
# --- DynamoDB ---
ShiftTable:
Type: AWS::DynamoDB::Table
@ -40,15 +59,18 @@ Resources:
Type: AWS::Serverless::Function
Properties:
FunctionName: afterhours-shift-manager
Handler: src/handler.handler
CodeUri: .
Handler: handler.handler
CodeUri: src/slack-bot/
Layers:
- !Ref SharedLayer
Environment:
Variables:
SHIFT_TABLE: !Ref ShiftTable
SLACK_BOT_TOKEN_PARAM: /afterhours-shift-manager/slack-bot-token
SLACK_SIGNING_SECRET_PARAM: /afterhours-shift-manager/slack-signing-secret
SHIFT_CHANNEL_PARAM: /afterhours-shift-manager/channel-id
SCHEDULER_FUNCTION_NAME: !Ref SchedulerFunctionName
SLACK_BOT_TOKEN_SECRET: afterhours-shift-manager/slack-bot-token
SLACK_SIGNING_SECRET: afterhours-shift-manager/slack-signing-secret
SHIFT_CHANNEL: !Ref ShiftChannel
TCX_SECRET_PREFIX: afterhours-shift-manager/3cx-
QUEUE_NUMBER: !Ref QueueNumber
TZ: !Ref Timezone
Policies:
- DynamoDBCrudPolicy:
@ -56,21 +78,9 @@ Resources:
- Statement:
- Effect: Allow
Action:
- ssm:GetParameter
- secretsmanager:GetSecretValue
Resource:
- !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/afterhours-shift-manager/*"
- Effect: Allow
Action:
- kms:Decrypt
Resource: "*"
Condition:
StringEquals:
"kms:ViaService": !Sub "ssm.${AWS::Region}.amazonaws.com"
- Effect: Allow
Action:
- lambda:InvokeFunction
Resource:
- !Sub "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${SchedulerFunctionName}"
- !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:afterhours-shift-manager/*"
Events:
SlackEvents:
Type: HttpApi
@ -83,13 +93,15 @@ Resources:
Type: AWS::Serverless::Function
Properties:
FunctionName: afterhours-weekly-post
Handler: src/weekly_post.handler
CodeUri: .
Handler: app.handler
CodeUri: src/weekly-post/
Layers:
- !Ref SharedLayer
Environment:
Variables:
SHIFT_TABLE: !Ref ShiftTable
SLACK_BOT_TOKEN_PARAM: /afterhours-shift-manager/slack-bot-token
SHIFT_CHANNEL_PARAM: /afterhours-shift-manager/channel-id
SLACK_BOT_TOKEN_SECRET: afterhours-shift-manager/slack-bot-token
SHIFT_CHANNEL: !Ref ShiftChannel
SES_SENDER: noreply@seahaven.com
PAYROLL_RECIPIENTS: payroll@seahaven.com
PAY_REPORT_USER: U0A3SC48T47
@ -100,16 +112,9 @@ Resources:
- Statement:
- Effect: Allow
Action:
- ssm:GetParameter
- secretsmanager:GetSecretValue
Resource:
- !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/afterhours-shift-manager/*"
- Effect: Allow
Action:
- kms:Decrypt
Resource: "*"
Condition:
StringEquals:
"kms:ViaService": !Sub "ssm.${AWS::Region}.amazonaws.com"
- !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:afterhours-shift-manager/*"
- Effect: Allow
Action:
- ses:SendEmail
@ -136,13 +141,15 @@ Resources:
Type: AWS::Serverless::Function
Properties:
FunctionName: afterhours-roster-sync
Handler: src/roster_sync.handler
CodeUri: .
Handler: app.handler
CodeUri: src/roster-sync/
Layers:
- !Ref SharedLayer
Timeout: 60
Environment:
Variables:
SHIFT_TABLE: !Ref ShiftTable
TCX_SSM_PREFIX: /3cx-scheduler
TCX_SECRET_PREFIX: afterhours-shift-manager/3cx-
SYNC_GROUP: DEFAULT
TZ: !Ref Timezone
Policies:
@ -151,19 +158,9 @@ Resources:
- Statement:
- Effect: Allow
Action:
- ssm:GetParametersByPath
- ssm:GetParameter
- ssm:GetParameters
- secretsmanager:GetSecretValue
Resource:
- !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/3cx-scheduler"
- !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/3cx-scheduler/*"
- Effect: Allow
Action:
- kms:Decrypt
Resource: "*"
Condition:
StringEquals:
"kms:ViaService": !Sub "ssm.${AWS::Region}.amazonaws.com"
- !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:afterhours-shift-manager/*"
Events:
# Daily at 6am ET (before the 7am schedule post and 8am 3CX scheduler)
# EST: 6am ET = 11:00 UTC (Nov-Mar)
@ -181,6 +178,84 @@ Resources:
Description: "Sync roster from 3CX at 6am EDT"
Enabled: true
# --- Ring Scheduler (daily 3CX queue 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-
QUEUE_NUMBER: !Ref QueueNumber
TZ: !Ref Timezone
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref ShiftTable
- Statement:
- 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 queue at 8am EST"
Enabled: true
DailyScheduleEDT:
Type: Schedule
Properties:
Schedule: cron(0 12 ? * * *)
Description: "Update 3CX queue 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 queue at 5pm EST weekends"
Enabled: true
WeekendEveningEDT:
Type: Schedule
Properties:
Schedule: cron(0 21 ? * SAT,SUN *)
Description: "Update 3CX queue at 5pm EDT weekends"
Enabled: true
# --- CloudWatch Log Groups (explicit 60-day retention) ---
SlackBotLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub "/aws/lambda/${SlackBotFunction}"
RetentionInDays: 60
WeeklyPostLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub "/aws/lambda/${WeeklyPostFunction}"
RetentionInDays: 60
RosterSyncLogGroup:
Type: AWS::Logs::LogGroup
Properties:
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
@ -189,5 +264,9 @@ Outputs:
Value: !Ref ShiftTable
SlackBotFunctionArn:
Value: !GetAtt SlackBotFunction.Arn
WeeklyPostFunctionArn:
Value: !GetAtt WeeklyPostFunction.Arn
RosterSyncFunctionArn:
Value: !GetAtt RosterSyncFunction.Arn
RingSchedulerFunctionArn:
Value: !GetAtt RingSchedulerFunction.Arn