mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 06:43:12 +00:00
Compare commits
5 commits
f26fe978b5
...
36d3d75654
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36d3d75654 | ||
|
|
8aee98c279 | ||
|
|
3d06b478d4 | ||
|
|
6b91ed4aa4 | ||
|
|
5fc60b6979 |
22 changed files with 860 additions and 240 deletions
11
.github/dependabot.yml
vendored
11
.github/dependabot.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
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"
|
||||
source-dirs: "src/slack-bot src/weekly-post src/roster-sync src/ring-scheduler src/shared/python/shared"
|
||||
|
|
|
|||
1
.github/workflows/deploy.yaml
vendored
1
.github/workflows/deploy.yaml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
93
README.md
93
README.md
|
|
@ -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
9
samconfig.toml.example
Normal 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
75
src/ring-scheduler/app.py
Normal 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
|
||||
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.34.0
|
||||
|
|
@ -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)
|
||||
2
src/roster-sync/requirements.txt
Normal file
2
src/roster-sync/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
boto3>=1.43.6
|
||||
requests>=2.34.0
|
||||
|
|
@ -3,9 +3,15 @@
|
|||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from shared.schedule import WEEKEND_DAYS
|
||||
|
||||
EASTERN = ZoneInfo("America/New_York")
|
||||
|
||||
WEEKEND_DAYS = {"Saturday", "Sunday"}
|
||||
|
||||
SHIFT_LABELS = {
|
||||
"day": "Day (8am–5pm)",
|
||||
"night": "Night (5pm–8am)",
|
||||
}
|
||||
|
||||
|
||||
def _format_shift_line(
|
||||
|
|
@ -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 date.strftime("%A") in WEEKEND_DAYS and 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,15 @@ 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 ""
|
||||
day_name = dt.strftime("%A")
|
||||
label = f" {SHIFT_LABELS[shift_type]}" if day_name in WEEKEND_DAYS else ""
|
||||
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 +121,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 +167,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}}]
|
||||
|
||||
|
||||
31
src/shared/python/shared/ring_scheduler.py
Normal file
31
src/shared/python/shared/ring_scheduler.py
Normal 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=extension,
|
||||
holiday=extension,
|
||||
)
|
||||
logger.info("Updated queue %s to forward to Ext %s", queue_number, extension)
|
||||
return {"extension": extension, "queue": queue_number}
|
||||
|
|
@ -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(
|
||||
16
src/shared/python/shared/secrets.py
Normal file
16
src/shared/python/shared/secrets.py
Normal 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"]
|
||||
|
|
@ -84,6 +84,33 @@ class ThreeCXClient:
|
|||
resp.raise_for_status()
|
||||
return resp.json().get("value", [])
|
||||
|
||||
def _update_forwarding(
|
||||
self, resource: str, resource_id: int, closed: str, holiday: str
|
||||
):
|
||||
payload = {
|
||||
"OutOfOfficeRoute": {
|
||||
"IsPromptEnabled": False,
|
||||
"Route": {"To": "Extension", "Number": closed, "External": ""},
|
||||
},
|
||||
"HolidaysRoute": {
|
||||
"IsPromptEnabled": False,
|
||||
"Route": {"To": "Extension", "Number": holiday, "External": ""},
|
||||
},
|
||||
}
|
||||
resp = self.session.patch(
|
||||
f"{self.base_url}/xapi/v1/{resource}({resource_id})",
|
||||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
logger.info(
|
||||
"Updated %s %s: closed->Ext %s, holiday->Ext %s",
|
||||
resource,
|
||||
resource_id,
|
||||
closed,
|
||||
holiday,
|
||||
)
|
||||
return resp.status_code
|
||||
|
||||
def get_ring_group(self, extension_number: str) -> dict:
|
||||
"""Fetch ring group config by extension number."""
|
||||
resp = self.session.get(
|
||||
|
|
@ -93,45 +120,17 @@ class ThreeCXClient:
|
|||
return resp.json()
|
||||
|
||||
def update_ring_group_forwarding(
|
||||
self,
|
||||
ring_group_id: int,
|
||||
closed_destination: str,
|
||||
holiday_destination: str,
|
||||
self, ring_group_id: int, closed: str, holiday: str
|
||||
):
|
||||
"""Update the OutOfOfficeRoute and HolidaysRoute on a ring group.
|
||||
return self._update_forwarding("RingGroups", ring_group_id, closed, holiday)
|
||||
|
||||
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
|
||||
"""
|
||||
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/RingGroups({ring_group_id})",
|
||||
json=payload,
|
||||
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()
|
||||
logger.info(
|
||||
"Updated ring group %s: closed->Ext %s, holiday->Ext %s",
|
||||
ring_group_id,
|
||||
closed_destination,
|
||||
holiday_destination,
|
||||
)
|
||||
return resp.status_code
|
||||
return resp.json()
|
||||
|
||||
def update_queue_forwarding(self, queue_id: int, closed: str, holiday: str):
|
||||
return self._update_forwarding("Queues", queue_id, closed, holiday)
|
||||
2
src/shared/requirements.txt
Normal file
2
src/shared/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
boto3>=1.43.6
|
||||
requests>=2.34.0
|
||||
|
|
@ -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 ─────────────────────────────────────────────
|
||||
|
||||
|
|
@ -300,9 +377,11 @@ def create_app(
|
|||
respond(text=f"Linked your account to *{employee['name']}* (Ext {ext}).")
|
||||
|
||||
def _handle_pick(respond, schedule, user_id, text, channel_id, client):
|
||||
parts = text.split(maxsplit=1)
|
||||
parts = text.split()
|
||||
if len(parts) < 2:
|
||||
respond(text="Usage: `/oncall pick <date>` (e.g. `/oncall pick friday`)")
|
||||
respond(
|
||||
text="Usage: `/oncall pick <date> [day|night]` (e.g. `/oncall pick friday`)"
|
||||
)
|
||||
return
|
||||
|
||||
employee = schedule.get_employee_by_slack_id(user_id)
|
||||
|
|
@ -312,16 +391,39 @@ def create_app(
|
|||
)
|
||||
return
|
||||
|
||||
date = parse_date(parts[1])
|
||||
explicit_shift = (
|
||||
parts[2] if len(parts) > 2 and parts[2] in ("day", "night") else None
|
||||
)
|
||||
date_text = parts[1]
|
||||
date = parse_date(date_text)
|
||||
if not date:
|
||||
respond(
|
||||
text=f"Couldn't parse date: `{parts[1]}`. Try: today, tomorrow, friday, 4/5, 2026-04-05"
|
||||
text=f"Couldn't parse date: `{date_text}`. Try: today, tomorrow, friday, 4/5, 2026-04-05"
|
||||
)
|
||||
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)
|
||||
|
||||
if explicit_shift:
|
||||
shift_type = explicit_shift
|
||||
ext, name, source = schedule.resolve_shift(date_str, day_name, shift_type)
|
||||
elif day_name in WEEKEND_DAYS:
|
||||
shift_type = "night"
|
||||
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:
|
||||
shift_type = "night"
|
||||
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 +432,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 +459,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 +482,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 +544,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 +579,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 +590,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 +607,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
|
||||
|
|
@ -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)
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
slack_bolt>=1.28.0,<2.0
|
||||
boto3>=1.43.6
|
||||
requests>=2.33.1
|
||||
|
|
@ -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, WEEKEND_DAYS, ShiftSchedule
|
||||
from shared.secrets import get_secret
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.INFO)
|
||||
|
|
@ -29,9 +30,6 @@ DAY_ORDER = [
|
|||
]
|
||||
|
||||
|
||||
WEEKEND_DAYS = {"Saturday", "Sunday"}
|
||||
|
||||
|
||||
def _add_shift_to_pay(schedule, breakdown, totals, date, day_name, shift_type="night"):
|
||||
"""Resolve a shift and add it to the pay breakdown if assigned."""
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
|
|
@ -147,13 +145,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 +186,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"]),
|
||||
}
|
||||
2
src/weekly-post/requirements.txt
Normal file
2
src/weekly-post/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
slack_sdk>=3.41.0,<4.0
|
||||
boto3>=1.43.6
|
||||
181
template.yaml
181
template.yaml
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue